Upload 67 files
Browse files- app/__pycache__/config.cpython-313.pyc +0 -0
- app/__pycache__/main.cpython-313.pyc +0 -0
- app/__pycache__/models.cpython-313.pyc +0 -0
- app/config.py +13 -2
- app/main.py +18 -4
- app/models.py +4 -7
- 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 +317 -75
- app/routes/auth.py +28 -4
- app/routes/media.py +45 -11
- app/routes/user.py +12 -7
- app/services/__pycache__/bootstrap.cpython-313.pyc +0 -0
- app/services/__pycache__/images.cpython-313.pyc +0 -0
- app/services/__pycache__/presence.cpython-313.pyc +0 -0
- app/services/bootstrap.py +110 -13
- app/services/images.py +92 -14
- app/services/presence.py +27 -2
- app/static/favicon.ico +0 -0
- app/static/style.css +42 -0
- app/static/test_assets/clue_1.jpg +0 -0
- app/static/test_assets/task_1.jpg +0 -0
- app/static/test_assets/task_2.jpg +0 -0
- app/static/test_assets/task_3.jpg +0 -0
- app/static/test_assets/task_4.jpg +0 -0
- app/templates/activity_detail.html +78 -80
- app/templates/admin_activities.html +7 -5
- app/templates/admin_activity_edit.html +14 -13
- app/templates/admin_admins.html +148 -29
- app/templates/admin_images.html +86 -0
- app/templates/base.html +70 -5
app/__pycache__/config.cpython-313.pyc
CHANGED
|
Binary files a/app/__pycache__/config.cpython-313.pyc and b/app/__pycache__/config.cpython-313.pyc differ
|
|
|
app/__pycache__/main.cpython-313.pyc
CHANGED
|
Binary files a/app/__pycache__/main.cpython-313.pyc and b/app/__pycache__/main.cpython-313.pyc differ
|
|
|
app/__pycache__/models.cpython-313.pyc
CHANGED
|
Binary files a/app/__pycache__/models.cpython-313.pyc and b/app/__pycache__/models.cpython-313.pyc differ
|
|
|
app/config.py
CHANGED
|
@@ -28,7 +28,15 @@ class Settings:
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
@property
|
| 34 |
def database_url(self) -> str:
|
|
@@ -43,9 +51,12 @@ class Settings:
|
|
| 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()
|
|
|
|
|
|
| 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 |
+
docker_root: Path = ROOT_DIR / os.getenv("DOCKER_ROOT", "docker_data")
|
| 32 |
+
|
| 33 |
+
@property
|
| 34 |
+
def upload_root(self) -> Path:
|
| 35 |
+
return self.docker_root / "submissions"
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def task_media_root(self) -> Path:
|
| 39 |
+
return self.docker_root / "tasks"
|
| 40 |
|
| 41 |
@property
|
| 42 |
def database_url(self) -> str:
|
|
|
|
| 51 |
|
| 52 |
@property
|
| 53 |
def database_connect_args(self) -> dict:
|
| 54 |
+
if self.database_url.startswith("sqlite"):
|
| 55 |
+
return {}
|
| 56 |
if self.mysql_ca_file.exists():
|
| 57 |
return {"ssl": {"ca": str(self.mysql_ca_file)}}
|
| 58 |
return {}
|
| 59 |
|
| 60 |
|
| 61 |
+
settings = Settings()
|
| 62 |
+
|
app/main.py
CHANGED
|
@@ -12,6 +12,7 @@ from app.database import SessionLocal
|
|
| 12 |
from app.models import Admin, User
|
| 13 |
from app.routes import admin, auth, media, user
|
| 14 |
from app.services.bootstrap import initialize_database, seed_super_admin
|
|
|
|
| 15 |
from app.web import local_now, redirect, templates
|
| 16 |
|
| 17 |
|
|
@@ -33,7 +34,9 @@ def on_startup() -> None:
|
|
| 33 |
@app.middleware("http")
|
| 34 |
async def track_presence(request: Request, call_next):
|
| 35 |
response = await call_next(request)
|
| 36 |
-
if request.url.path.startswith("/static"):
|
|
|
|
|
|
|
| 37 |
return response
|
| 38 |
|
| 39 |
admin_id = request.session.get("admin_id")
|
|
@@ -41,17 +44,21 @@ async def track_presence(request: Request, call_next):
|
|
| 41 |
if not admin_id and not user_id:
|
| 42 |
return response
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
db = SessionLocal()
|
| 45 |
try:
|
| 46 |
if admin_id:
|
| 47 |
admin = db.get(Admin, admin_id)
|
| 48 |
if admin:
|
| 49 |
-
admin.last_seen_at =
|
| 50 |
db.add(admin)
|
| 51 |
elif user_id:
|
| 52 |
user = db.get(User, user_id)
|
| 53 |
if user:
|
| 54 |
-
user.last_seen_at =
|
| 55 |
db.add(user)
|
| 56 |
db.commit()
|
| 57 |
finally:
|
|
@@ -77,6 +84,11 @@ def health():
|
|
| 77 |
return {"status": "ok"}
|
| 78 |
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
app.include_router(auth.router)
|
| 81 |
app.include_router(user.router)
|
| 82 |
app.include_router(admin.router)
|
|
@@ -105,4 +117,6 @@ def format_timedelta(value):
|
|
| 105 |
|
| 106 |
|
| 107 |
templates.env.filters["datetime_local"] = format_datetime
|
| 108 |
-
templates.env.filters["duration_human"] = format_timedelta
|
|
|
|
|
|
|
|
|
| 12 |
from app.models import Admin, User
|
| 13 |
from app.routes import admin, auth, media, user
|
| 14 |
from app.services.bootstrap import initialize_database, seed_super_admin
|
| 15 |
+
from app.services.presence import should_write_presence
|
| 16 |
from app.web import local_now, redirect, templates
|
| 17 |
|
| 18 |
|
|
|
|
| 34 |
@app.middleware("http")
|
| 35 |
async def track_presence(request: Request, call_next):
|
| 36 |
response = await call_next(request)
|
| 37 |
+
if request.url.path.startswith("/static") or request.url.path.startswith("/media"):
|
| 38 |
+
return response
|
| 39 |
+
if request.url.path.startswith("/api"):
|
| 40 |
return response
|
| 41 |
|
| 42 |
admin_id = request.session.get("admin_id")
|
|
|
|
| 44 |
if not admin_id and not user_id:
|
| 45 |
return response
|
| 46 |
|
| 47 |
+
now = local_now()
|
| 48 |
+
if not should_write_presence(request.session, now):
|
| 49 |
+
return response
|
| 50 |
+
|
| 51 |
db = SessionLocal()
|
| 52 |
try:
|
| 53 |
if admin_id:
|
| 54 |
admin = db.get(Admin, admin_id)
|
| 55 |
if admin:
|
| 56 |
+
admin.last_seen_at = now
|
| 57 |
db.add(admin)
|
| 58 |
elif user_id:
|
| 59 |
user = db.get(User, user_id)
|
| 60 |
if user:
|
| 61 |
+
user.last_seen_at = now
|
| 62 |
db.add(user)
|
| 63 |
db.commit()
|
| 64 |
finally:
|
|
|
|
| 84 |
return {"status": "ok"}
|
| 85 |
|
| 86 |
|
| 87 |
+
@app.get("/favicon.ico", include_in_schema=False)
|
| 88 |
+
def favicon():
|
| 89 |
+
return redirect("/static/favicon.ico", status_code=307)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
app.include_router(auth.router)
|
| 93 |
app.include_router(user.router)
|
| 94 |
app.include_router(admin.router)
|
|
|
|
| 117 |
|
| 118 |
|
| 119 |
templates.env.filters["datetime_local"] = format_datetime
|
| 120 |
+
templates.env.filters["duration_human"] = format_timedelta
|
| 121 |
+
|
| 122 |
+
|
app/models.py
CHANGED
|
@@ -4,7 +4,6 @@ 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
|
|
@@ -94,13 +93,13 @@ class Task(TimestampMixin, Base):
|
|
| 94 |
description: Mapped[str] = mapped_column(Text, default="")
|
| 95 |
display_order: Mapped[int] = mapped_column(Integer, default=1)
|
| 96 |
|
| 97 |
-
|
|
|
|
| 98 |
image_mime: Mapped[str] = mapped_column(String(120), default="image/jpeg")
|
| 99 |
image_filename: Mapped[str] = mapped_column(String(255), default="task.jpg")
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
)
|
| 104 |
clue_image_mime: Mapped[Optional[str]] = mapped_column(String(120), nullable=True)
|
| 105 |
clue_image_filename: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
| 106 |
clue_release_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
|
@@ -151,5 +150,3 @@ class Submission(TimestampMixin, Base):
|
|
| 151 |
back_populates="assigned_submissions",
|
| 152 |
foreign_keys=[assigned_admin_id],
|
| 153 |
)
|
| 154 |
-
|
| 155 |
-
|
|
|
|
| 4 |
from typing import Optional
|
| 5 |
|
| 6 |
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
|
|
|
| 7 |
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 8 |
|
| 9 |
from app.database import Base
|
|
|
|
| 93 |
description: Mapped[str] = mapped_column(Text, default="")
|
| 94 |
display_order: Mapped[int] = mapped_column(Integer, default=1)
|
| 95 |
|
| 96 |
+
image_url: Mapped[Optional[str]] = mapped_column(String(1000), nullable=True)
|
| 97 |
+
image_path: Mapped[Optional[str]] = mapped_column(String(600), nullable=True)
|
| 98 |
image_mime: Mapped[str] = mapped_column(String(120), default="image/jpeg")
|
| 99 |
image_filename: Mapped[str] = mapped_column(String(255), default="task.jpg")
|
| 100 |
|
| 101 |
+
clue_image_url: Mapped[Optional[str]] = mapped_column(String(1000), nullable=True)
|
| 102 |
+
clue_image_path: Mapped[Optional[str]] = mapped_column(String(600), nullable=True)
|
|
|
|
| 103 |
clue_image_mime: Mapped[Optional[str]] = mapped_column(String(120), nullable=True)
|
| 104 |
clue_image_filename: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
| 105 |
clue_release_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
|
|
|
| 150 |
back_populates="assigned_submissions",
|
| 151 |
foreign_keys=[assigned_admin_id],
|
| 152 |
)
|
|
|
|
|
|
app/routes/__pycache__/admin.cpython-313.pyc
CHANGED
|
Binary files a/app/routes/__pycache__/admin.cpython-313.pyc and b/app/routes/__pycache__/admin.cpython-313.pyc differ
|
|
|
app/routes/__pycache__/auth.cpython-313.pyc
CHANGED
|
Binary files a/app/routes/__pycache__/auth.cpython-313.pyc and b/app/routes/__pycache__/auth.cpython-313.pyc differ
|
|
|
app/routes/__pycache__/media.cpython-313.pyc
CHANGED
|
Binary files a/app/routes/__pycache__/media.cpython-313.pyc and b/app/routes/__pycache__/media.cpython-313.pyc differ
|
|
|
app/routes/__pycache__/user.cpython-313.pyc
CHANGED
|
Binary files a/app/routes/__pycache__/user.cpython-313.pyc and b/app/routes/__pycache__/user.cpython-313.pyc differ
|
|
|
app/routes/admin.py
CHANGED
|
@@ -3,18 +3,24 @@
|
|
| 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 JSONResponse, 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
from app.services.leaderboard import build_leaderboard
|
| 17 |
-
from app.services.presence import is_online
|
| 18 |
from app.services.review_queue import online_admins, rebalance_pending_reviews
|
| 19 |
from app.web import add_flash, local_now, redirect, render
|
| 20 |
|
|
@@ -26,9 +32,6 @@ def require_admin(request: Request, db: Session) -> Admin | None:
|
|
| 26 |
admin = db.query(Admin).filter(Admin.id == (request.session.get("admin_id") or 0)).first()
|
| 27 |
if not admin or not admin.is_active:
|
| 28 |
return None
|
| 29 |
-
admin.last_seen_at = local_now()
|
| 30 |
-
db.add(admin)
|
| 31 |
-
db.flush()
|
| 32 |
return admin
|
| 33 |
|
| 34 |
|
|
@@ -68,6 +71,20 @@ def ensure_group_capacity(group: Group | None, current_users: list[User] | None
|
|
| 68 |
raise ValueError(f"{group.name} 的人数上限不足,请调整人数上限或减少选中人数。")
|
| 69 |
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
def parse_activity_fields(form) -> dict:
|
| 72 |
title = str(form.get("title", "")).strip()
|
| 73 |
description = str(form.get("description", "")).strip()
|
|
@@ -123,7 +140,7 @@ def apply_task_schedule(activity: Activity, tasks: list[Task]) -> None:
|
|
| 123 |
activity.start_at,
|
| 124 |
activity.clue_interval_minutes,
|
| 125 |
index,
|
| 126 |
-
bool(task.clue_image_filename),
|
| 127 |
)
|
| 128 |
|
| 129 |
|
|
@@ -155,15 +172,135 @@ def serialize_submission(submission: Submission) -> dict:
|
|
| 155 |
}
|
| 156 |
|
| 157 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
def serialize_presence_entry(name: str, role_label: str, last_seen_at, now: datetime) -> dict:
|
| 159 |
return {
|
| 160 |
"name": name,
|
| 161 |
"role_label": role_label,
|
| 162 |
-
"last_seen_at": last_seen_at.strftime("%Y-%m-%d %H:%M") if last_seen_at else "-",
|
|
|
|
| 163 |
"is_online": is_online(last_seen_at, now),
|
| 164 |
}
|
| 165 |
|
| 166 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
def recent_reviews_query(db: Session, activity_id: int | None = None):
|
| 168 |
query = (
|
| 169 |
db.query(Submission)
|
|
@@ -245,40 +382,34 @@ async def collect_task_payloads(
|
|
| 245 |
task_description = (
|
| 246 |
str(task_descriptions[index]).strip() if index < len(task_descriptions) else ""
|
| 247 |
)
|
| 248 |
-
|
| 249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
-
if not task_title and
|
| 252 |
continue
|
| 253 |
-
if not task_title
|
| 254 |
-
raise ValueError(f"第 {index + 1} 个新增任务需要
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
primary_bytes, primary_mime = compress_to_limit(primary_raw, 200 * 1024)
|
| 258 |
-
|
| 259 |
-
clue_bytes = None
|
| 260 |
-
clue_mime = None
|
| 261 |
-
clue_name = None
|
| 262 |
-
if clue_upload and getattr(clue_upload, "filename", ""):
|
| 263 |
-
clue_raw = await read_and_validate_upload(clue_upload)
|
| 264 |
-
clue_bytes, clue_mime = compress_to_limit(clue_raw, 200 * 1024)
|
| 265 |
-
clue_name = clue_upload.filename
|
| 266 |
|
| 267 |
tasks_payload.append(
|
| 268 |
{
|
| 269 |
"title": task_title,
|
| 270 |
"description": task_description,
|
| 271 |
-
"
|
| 272 |
-
"
|
| 273 |
-
"image_filename": primary_upload.filename,
|
| 274 |
-
"clue_image_data": clue_bytes,
|
| 275 |
-
"clue_image_mime": clue_mime,
|
| 276 |
-
"clue_image_filename": clue_name,
|
| 277 |
}
|
| 278 |
)
|
| 279 |
return tasks_payload
|
| 280 |
|
| 281 |
-
|
| 282 |
@router.get("/admin/dashboard")
|
| 283 |
def admin_dashboard(request: Request, db: Session = Depends(get_db)):
|
| 284 |
admin = require_admin(request, db)
|
|
@@ -342,7 +473,7 @@ def admin_users(request: Request, db: Session = Depends(get_db)):
|
|
| 342 |
request,
|
| 343 |
"admin_users.html",
|
| 344 |
{"page_title": "用户管理", "admin": admin, "users": users, "groups": groups},
|
| 345 |
-
)
|
| 346 |
|
| 347 |
@router.post("/admin/users")
|
| 348 |
async def create_user(request: Request, db: Session = Depends(get_db)):
|
|
@@ -516,10 +647,14 @@ def admin_admins(request: Request, db: Session = Depends(get_db)):
|
|
| 516 |
return redirect("/admin/dashboard")
|
| 517 |
|
| 518 |
now = local_now()
|
|
|
|
| 519 |
admins = db.query(Admin).order_by(Admin.created_at.desc()).all()
|
| 520 |
users = db.query(User).options(joinedload(User.group)).order_by(User.full_name.asc()).all()
|
| 521 |
admin_statuses = [
|
| 522 |
-
{
|
|
|
|
|
|
|
|
|
|
| 523 |
for item in admins
|
| 524 |
]
|
| 525 |
user_statuses = [
|
|
@@ -538,6 +673,8 @@ def admin_admins(request: Request, db: Session = Depends(get_db)):
|
|
| 538 |
"admins": admins,
|
| 539 |
"admin_statuses": admin_statuses,
|
| 540 |
"user_statuses": user_statuses,
|
|
|
|
|
|
|
| 541 |
},
|
| 542 |
)
|
| 543 |
|
|
@@ -549,30 +686,29 @@ def presence_overview(request: Request, db: Session = Depends(get_db)):
|
|
| 549 |
return JSONResponse({"error": "forbidden"}, status_code=403)
|
| 550 |
|
| 551 |
now = local_now()
|
|
|
|
| 552 |
admins = db.query(Admin).order_by(Admin.display_name.asc()).all()
|
| 553 |
users = db.query(User).options(joinedload(User.group)).order_by(User.full_name.asc()).all()
|
| 554 |
return JSONResponse(
|
| 555 |
{
|
|
|
|
|
|
|
| 556 |
"admins": [
|
| 557 |
{
|
| 558 |
-
|
| 559 |
"username": item.username,
|
| 560 |
-
"role": item.role,
|
| 561 |
-
"is_online": is_online(item.last_seen_at, now),
|
| 562 |
-
"last_seen_at": item.last_seen_at.strftime("%Y-%m-%d %H:%M") if item.last_seen_at else "-",
|
| 563 |
}
|
| 564 |
for item in admins
|
| 565 |
],
|
| 566 |
"users": [
|
| 567 |
{
|
| 568 |
-
"
|
| 569 |
"group_name": item.group.name if item.group else "未分组",
|
| 570 |
-
"is_online": is_online(item.last_seen_at, now),
|
| 571 |
-
"last_seen_at": item.last_seen_at.strftime("%Y-%m-%d %H:%M") if item.last_seen_at else "-",
|
| 572 |
}
|
| 573 |
for item in users
|
| 574 |
],
|
| 575 |
-
}
|
|
|
|
| 576 |
)
|
| 577 |
|
| 578 |
|
|
@@ -605,7 +741,7 @@ async def create_admin(request: Request, db: Session = Depends(get_db)):
|
|
| 605 |
db.add(new_admin)
|
| 606 |
db.commit()
|
| 607 |
add_flash(request, "success", f"管理员 {display_name} 已创建。")
|
| 608 |
-
return redirect("/admin/admins")
|
| 609 |
|
| 610 |
@router.get("/admin/groups")
|
| 611 |
def admin_groups(request: Request, db: Session = Depends(get_db)):
|
|
@@ -751,8 +887,8 @@ async def create_activity(request: Request, db: Session = Depends(get_db)):
|
|
| 751 |
form,
|
| 752 |
"task_title",
|
| 753 |
"task_description",
|
| 754 |
-
"
|
| 755 |
-
"
|
| 756 |
)
|
| 757 |
except ValueError as exc:
|
| 758 |
add_flash(request, "error", str(exc))
|
|
@@ -764,10 +900,19 @@ async def create_activity(request: Request, db: Session = Depends(get_db)):
|
|
| 764 |
|
| 765 |
activity = Activity(created_by_id=admin.id, **activity_fields)
|
| 766 |
db.add(activity)
|
|
|
|
| 767 |
|
| 768 |
tasks = []
|
| 769 |
for payload in tasks_payload:
|
| 770 |
-
task = Task(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 771 |
tasks.append(task)
|
| 772 |
db.add(task)
|
| 773 |
|
|
@@ -776,7 +921,6 @@ async def create_activity(request: Request, db: Session = Depends(get_db)):
|
|
| 776 |
add_flash(request, "success", f"活动 {activity.title} 已发布。")
|
| 777 |
return redirect("/admin/activities")
|
| 778 |
|
| 779 |
-
|
| 780 |
@router.post("/admin/activities/{activity_id}/edit")
|
| 781 |
async def update_activity(activity_id: int, request: Request, db: Session = Depends(get_db)):
|
| 782 |
admin = require_admin(request, db)
|
|
@@ -802,8 +946,8 @@ async def update_activity(activity_id: int, request: Request, db: Session = Depe
|
|
| 802 |
existing_task_ids = form.getlist("existing_task_id")
|
| 803 |
existing_task_titles = form.getlist("existing_task_title")
|
| 804 |
existing_task_descriptions = form.getlist("existing_task_description")
|
| 805 |
-
|
| 806 |
-
|
| 807 |
remove_clue_ids = {
|
| 808 |
int(value)
|
| 809 |
for value in form.getlist("existing_task_remove_clue")
|
|
@@ -829,27 +973,38 @@ async def update_activity(activity_id: int, request: Request, db: Session = Depe
|
|
| 829 |
if not title:
|
| 830 |
raise ValueError(f"第 {index + 1} 个已有任务标题不能为空。")
|
| 831 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 832 |
task.title = title
|
| 833 |
task.description = description
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
task.
|
| 842 |
-
task.
|
| 843 |
-
task.
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
task.
|
| 850 |
-
task.
|
| 851 |
-
elif task_id in remove_clue_ids:
|
| 852 |
-
task.clue_image_data = None
|
| 853 |
task.clue_image_mime = None
|
| 854 |
task.clue_image_filename = None
|
| 855 |
|
|
@@ -859,8 +1014,8 @@ async def update_activity(activity_id: int, request: Request, db: Session = Depe
|
|
| 859 |
form,
|
| 860 |
"new_task_title",
|
| 861 |
"new_task_description",
|
| 862 |
-
"
|
| 863 |
-
"
|
| 864 |
)
|
| 865 |
except ValueError as exc:
|
| 866 |
add_flash(request, "error", str(exc))
|
|
@@ -875,7 +1030,15 @@ async def update_activity(activity_id: int, request: Request, db: Session = Depe
|
|
| 875 |
|
| 876 |
all_tasks = list(ordered_tasks)
|
| 877 |
for payload in new_tasks_payload:
|
| 878 |
-
task = Task(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 879 |
db.add(task)
|
| 880 |
all_tasks.append(task)
|
| 881 |
|
|
@@ -889,7 +1052,6 @@ async def update_activity(activity_id: int, request: Request, db: Session = Depe
|
|
| 889 |
add_flash(request, "success", f"活动 {activity.title} 已更新。")
|
| 890 |
return redirect(f"/admin/activities/{activity_id}/edit")
|
| 891 |
|
| 892 |
-
|
| 893 |
@router.post("/admin/tasks/{task_id}/delete")
|
| 894 |
async def delete_task(task_id: int, request: Request, db: Session = Depends(get_db)):
|
| 895 |
admin = require_admin(request, db)
|
|
@@ -908,6 +1070,7 @@ async def delete_task(task_id: int, request: Request, db: Session = Depends(get_
|
|
| 908 |
return redirect(f"/admin/activities/{activity.id}/edit")
|
| 909 |
|
| 910 |
cleanup_submission_files(task.submissions)
|
|
|
|
| 911 |
activity_id = activity.id
|
| 912 |
db.delete(task)
|
| 913 |
db.flush()
|
|
@@ -924,7 +1087,6 @@ async def delete_task(task_id: int, request: Request, db: Session = Depends(get_
|
|
| 924 |
add_flash(request, "success", "任务已删除,剩余任务顺序和线索发布时间已自动更新。")
|
| 925 |
return redirect(f"/admin/activities/{activity_id}/edit")
|
| 926 |
|
| 927 |
-
|
| 928 |
@router.post("/admin/activities/{activity_id}/visibility")
|
| 929 |
async def update_leaderboard_visibility(activity_id: int, request: Request, db: Session = Depends(get_db)):
|
| 930 |
admin = require_admin(request, db)
|
|
@@ -940,7 +1102,7 @@ async def update_leaderboard_visibility(activity_id: int, request: Request, db:
|
|
| 940 |
db.add(activity)
|
| 941 |
db.commit()
|
| 942 |
add_flash(request, "success", f"已更新 {activity.title} 的排行榜可见性。")
|
| 943 |
-
return redirect("/admin/activities")
|
| 944 |
|
| 945 |
@router.get("/admin/reviews")
|
| 946 |
def admin_reviews(request: Request, db: Session = Depends(get_db)):
|
|
@@ -1093,7 +1255,87 @@ async def download_selected_submissions(request: Request, db: Session = Depends(
|
|
| 1093 |
archive,
|
| 1094 |
media_type="application/zip",
|
| 1095 |
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
| 1096 |
-
)
|
| 1097 |
-
|
| 1098 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1099 |
|
|
|
|
| 3 |
import io
|
| 4 |
import zipfile
|
| 5 |
from datetime import datetime, timedelta
|
| 6 |
+
from urllib.parse import quote
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 10 |
from fastapi.responses import JSONResponse, StreamingResponse
|
| 11 |
from sqlalchemy.orm import Session, joinedload
|
| 12 |
|
| 13 |
+
from app.config import settings
|
| 14 |
from app.database import get_db
|
| 15 |
from app.models import Activity, Admin, Group, Submission, Task, User
|
| 16 |
from app.security import hash_password
|
| 17 |
+
from app.services.images import (
|
| 18 |
+
delete_file_if_exists,
|
| 19 |
+
list_image_files,
|
| 20 |
+
resolve_managed_path,
|
| 21 |
+
)
|
| 22 |
from app.services.leaderboard import build_leaderboard
|
| 23 |
+
from app.services.presence import ONLINE_WINDOW_SECONDS, is_online, unix_seconds
|
| 24 |
from app.services.review_queue import online_admins, rebalance_pending_reviews
|
| 25 |
from app.web import add_flash, local_now, redirect, render
|
| 26 |
|
|
|
|
| 32 |
admin = db.query(Admin).filter(Admin.id == (request.session.get("admin_id") or 0)).first()
|
| 33 |
if not admin or not admin.is_active:
|
| 34 |
return None
|
|
|
|
|
|
|
|
|
|
| 35 |
return admin
|
| 36 |
|
| 37 |
|
|
|
|
| 71 |
raise ValueError(f"{group.name} 的人数上限不足,请调整人数上限或减少选中人数。")
|
| 72 |
|
| 73 |
|
| 74 |
+
|
| 75 |
+
def normalize_image_url(raw_value: str | None, field_label: str, required: bool = False) -> str | None:
|
| 76 |
+
value = str(raw_value or "").strip()
|
| 77 |
+
if not value:
|
| 78 |
+
if required:
|
| 79 |
+
raise ValueError(f"请填写{field_label}。")
|
| 80 |
+
return None
|
| 81 |
+
if not (value.startswith("http://") or value.startswith("https://")):
|
| 82 |
+
raise ValueError(f"{field_label}必须以 http:// 或 https:// 开头。")
|
| 83 |
+
if len(value) > 1000:
|
| 84 |
+
raise ValueError(f"{field_label}过长,请检查链接是否正确。")
|
| 85 |
+
return value
|
| 86 |
+
|
| 87 |
+
|
| 88 |
def parse_activity_fields(form) -> dict:
|
| 89 |
title = str(form.get("title", "")).strip()
|
| 90 |
description = str(form.get("description", "")).strip()
|
|
|
|
| 140 |
activity.start_at,
|
| 141 |
activity.clue_interval_minutes,
|
| 142 |
index,
|
| 143 |
+
bool(task.clue_image_url or task.clue_image_path or task.clue_image_filename),
|
| 144 |
)
|
| 145 |
|
| 146 |
|
|
|
|
| 172 |
}
|
| 173 |
|
| 174 |
|
| 175 |
+
def display_admin_role(role: str) -> str:
|
| 176 |
+
return "超级管理员" if role == "superadmin" else "管理员"
|
| 177 |
+
|
| 178 |
+
|
| 179 |
def serialize_presence_entry(name: str, role_label: str, last_seen_at, now: datetime) -> dict:
|
| 180 |
return {
|
| 181 |
"name": name,
|
| 182 |
"role_label": role_label,
|
| 183 |
+
"last_seen_at": last_seen_at.strftime("%Y-%m-%d %H:%M:%S") if last_seen_at else "-",
|
| 184 |
+
"last_seen_ts": unix_seconds(last_seen_at),
|
| 185 |
"is_online": is_online(last_seen_at, now),
|
| 186 |
}
|
| 187 |
|
| 188 |
|
| 189 |
+
def cleanup_task_files(task: Task) -> None:
|
| 190 |
+
delete_file_if_exists(task.image_path, settings.task_media_root)
|
| 191 |
+
delete_file_if_exists(task.clue_image_path, settings.task_media_root)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def normalize_path_key(file_path: str | Path) -> str:
|
| 195 |
+
return str(Path(file_path).resolve())
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def format_file_size(file_size: int) -> str:
|
| 199 |
+
if file_size >= 1024 * 1024:
|
| 200 |
+
return f"{file_size / (1024 * 1024):.2f} MB"
|
| 201 |
+
if file_size >= 1024:
|
| 202 |
+
return f"{file_size / 1024:.1f} KB"
|
| 203 |
+
return f"{file_size} B"
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def build_image_reference_index(db: Session) -> dict[str, list[dict]]:
|
| 207 |
+
reference_index: dict[str, list[dict]] = {}
|
| 208 |
+
|
| 209 |
+
tasks = db.query(Task).options(joinedload(Task.activity)).all()
|
| 210 |
+
for task in tasks:
|
| 211 |
+
if task.image_path:
|
| 212 |
+
reference_index.setdefault(normalize_path_key(task.image_path), []).append(
|
| 213 |
+
{
|
| 214 |
+
"type": "task",
|
| 215 |
+
"label": f"任务主图 · {task.activity.title if task.activity else '未知活动'} / {task.title}",
|
| 216 |
+
}
|
| 217 |
+
)
|
| 218 |
+
if task.clue_image_path:
|
| 219 |
+
reference_index.setdefault(normalize_path_key(task.clue_image_path), []).append(
|
| 220 |
+
{
|
| 221 |
+
"type": "task",
|
| 222 |
+
"label": f"线索图 · {task.activity.title if task.activity else '未知活动'} / {task.title}",
|
| 223 |
+
}
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
submissions = (
|
| 227 |
+
db.query(Submission)
|
| 228 |
+
.options(
|
| 229 |
+
joinedload(Submission.user),
|
| 230 |
+
joinedload(Submission.group),
|
| 231 |
+
joinedload(Submission.task).joinedload(Task.activity),
|
| 232 |
+
)
|
| 233 |
+
.all()
|
| 234 |
+
)
|
| 235 |
+
for submission in submissions:
|
| 236 |
+
if not submission.file_path:
|
| 237 |
+
continue
|
| 238 |
+
reference_index.setdefault(normalize_path_key(submission.file_path), []).append(
|
| 239 |
+
{
|
| 240 |
+
"type": "submission",
|
| 241 |
+
"label": (
|
| 242 |
+
f"用户提交图 · {submission.task.activity.title if submission.task and submission.task.activity else '未知活动'}"
|
| 243 |
+
f" / {submission.task.title if submission.task else '未知任务'}"
|
| 244 |
+
f" / {submission.group.name if submission.group else '未分组'}"
|
| 245 |
+
),
|
| 246 |
+
}
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
return reference_index
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def build_image_inventory(db: Session, scope: str) -> tuple[list[dict], dict]:
|
| 253 |
+
reference_index = build_image_reference_index(db)
|
| 254 |
+
image_paths = list_image_files(settings.docker_root)
|
| 255 |
+
|
| 256 |
+
items: list[dict] = []
|
| 257 |
+
referenced_count = 0
|
| 258 |
+
orphan_count = 0
|
| 259 |
+
|
| 260 |
+
for image_path in image_paths:
|
| 261 |
+
relative_path = image_path.relative_to(settings.docker_root).as_posix()
|
| 262 |
+
references = reference_index.get(normalize_path_key(image_path), [])
|
| 263 |
+
is_referenced = bool(references)
|
| 264 |
+
top_level = relative_path.split("/", 1)[0] if "/" in relative_path else relative_path
|
| 265 |
+
|
| 266 |
+
if scope == "tasks" and top_level != "tasks":
|
| 267 |
+
continue
|
| 268 |
+
if scope == "submissions" and top_level != "submissions":
|
| 269 |
+
continue
|
| 270 |
+
if scope == "referenced" and not is_referenced:
|
| 271 |
+
continue
|
| 272 |
+
if scope == "orphan" and is_referenced:
|
| 273 |
+
continue
|
| 274 |
+
|
| 275 |
+
if is_referenced:
|
| 276 |
+
referenced_count += 1
|
| 277 |
+
else:
|
| 278 |
+
orphan_count += 1
|
| 279 |
+
|
| 280 |
+
encoded_path = quote(relative_path, safe="/")
|
| 281 |
+
items.append(
|
| 282 |
+
{
|
| 283 |
+
"relative_path": relative_path,
|
| 284 |
+
"file_name": image_path.name,
|
| 285 |
+
"category": top_level,
|
| 286 |
+
"size_label": format_file_size(image_path.stat().st_size),
|
| 287 |
+
"modified_at": datetime.fromtimestamp(image_path.stat().st_mtime).strftime("%Y-%m-%d %H:%M"),
|
| 288 |
+
"is_referenced": is_referenced,
|
| 289 |
+
"reference_labels": [item["label"] for item in references],
|
| 290 |
+
"preview_url": f"/media/library/{encoded_path}",
|
| 291 |
+
"download_url": f"/media/library/{encoded_path}?download=1",
|
| 292 |
+
}
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
summary = {
|
| 296 |
+
"total_count": len(image_paths),
|
| 297 |
+
"referenced_count": sum(1 for path in image_paths if reference_index.get(normalize_path_key(path))),
|
| 298 |
+
"orphan_count": sum(1 for path in image_paths if not reference_index.get(normalize_path_key(path))),
|
| 299 |
+
"docker_root": str(settings.docker_root),
|
| 300 |
+
}
|
| 301 |
+
return items, summary
|
| 302 |
+
|
| 303 |
+
|
| 304 |
def recent_reviews_query(db: Session, activity_id: int | None = None):
|
| 305 |
query = (
|
| 306 |
db.query(Submission)
|
|
|
|
| 382 |
task_description = (
|
| 383 |
str(task_descriptions[index]).strip() if index < len(task_descriptions) else ""
|
| 384 |
)
|
| 385 |
+
image_url = normalize_image_url(
|
| 386 |
+
task_images[index] if index < len(task_images) else None,
|
| 387 |
+
f"第 {index + 1} 个新增任务的主图链接",
|
| 388 |
+
required=bool(task_title),
|
| 389 |
+
)
|
| 390 |
+
clue_image_url = normalize_image_url(
|
| 391 |
+
task_clue_images[index] if index < len(task_clue_images) else None,
|
| 392 |
+
f"第 {index + 1} 个新增任务的线索图链接",
|
| 393 |
+
required=False,
|
| 394 |
+
)
|
| 395 |
|
| 396 |
+
if not task_title and not image_url:
|
| 397 |
continue
|
| 398 |
+
if not task_title:
|
| 399 |
+
raise ValueError(f"第 {index + 1} 个新增任务需要填写标题。")
|
| 400 |
+
if not image_url:
|
| 401 |
+
raise ValueError(f"第 {index + 1} 个新增任务需要填写主图链接。")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
|
| 403 |
tasks_payload.append(
|
| 404 |
{
|
| 405 |
"title": task_title,
|
| 406 |
"description": task_description,
|
| 407 |
+
"image_url": image_url,
|
| 408 |
+
"clue_image_url": clue_image_url,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
}
|
| 410 |
)
|
| 411 |
return tasks_payload
|
| 412 |
|
|
|
|
| 413 |
@router.get("/admin/dashboard")
|
| 414 |
def admin_dashboard(request: Request, db: Session = Depends(get_db)):
|
| 415 |
admin = require_admin(request, db)
|
|
|
|
| 473 |
request,
|
| 474 |
"admin_users.html",
|
| 475 |
{"page_title": "用户管理", "admin": admin, "users": users, "groups": groups},
|
| 476 |
+
)
|
| 477 |
|
| 478 |
@router.post("/admin/users")
|
| 479 |
async def create_user(request: Request, db: Session = Depends(get_db)):
|
|
|
|
| 647 |
return redirect("/admin/dashboard")
|
| 648 |
|
| 649 |
now = local_now()
|
| 650 |
+
server_ts = unix_seconds(now)
|
| 651 |
admins = db.query(Admin).order_by(Admin.created_at.desc()).all()
|
| 652 |
users = db.query(User).options(joinedload(User.group)).order_by(User.full_name.asc()).all()
|
| 653 |
admin_statuses = [
|
| 654 |
+
{
|
| 655 |
+
**serialize_presence_entry(item.display_name, display_admin_role(item.role), item.last_seen_at, now),
|
| 656 |
+
"username": item.username,
|
| 657 |
+
}
|
| 658 |
for item in admins
|
| 659 |
]
|
| 660 |
user_statuses = [
|
|
|
|
| 673 |
"admins": admins,
|
| 674 |
"admin_statuses": admin_statuses,
|
| 675 |
"user_statuses": user_statuses,
|
| 676 |
+
"presence_server_ts": server_ts,
|
| 677 |
+
"online_window_seconds": ONLINE_WINDOW_SECONDS,
|
| 678 |
},
|
| 679 |
)
|
| 680 |
|
|
|
|
| 686 |
return JSONResponse({"error": "forbidden"}, status_code=403)
|
| 687 |
|
| 688 |
now = local_now()
|
| 689 |
+
server_ts = unix_seconds(now)
|
| 690 |
admins = db.query(Admin).order_by(Admin.display_name.asc()).all()
|
| 691 |
users = db.query(User).options(joinedload(User.group)).order_by(User.full_name.asc()).all()
|
| 692 |
return JSONResponse(
|
| 693 |
{
|
| 694 |
+
"server_ts": server_ts,
|
| 695 |
+
"online_window_seconds": ONLINE_WINDOW_SECONDS,
|
| 696 |
"admins": [
|
| 697 |
{
|
| 698 |
+
**serialize_presence_entry(item.display_name, display_admin_role(item.role), item.last_seen_at, now),
|
| 699 |
"username": item.username,
|
|
|
|
|
|
|
|
|
|
| 700 |
}
|
| 701 |
for item in admins
|
| 702 |
],
|
| 703 |
"users": [
|
| 704 |
{
|
| 705 |
+
**serialize_presence_entry(item.full_name, "用户", item.last_seen_at, now),
|
| 706 |
"group_name": item.group.name if item.group else "未分组",
|
|
|
|
|
|
|
| 707 |
}
|
| 708 |
for item in users
|
| 709 |
],
|
| 710 |
+
},
|
| 711 |
+
headers={"Cache-Control": "no-store"},
|
| 712 |
)
|
| 713 |
|
| 714 |
|
|
|
|
| 741 |
db.add(new_admin)
|
| 742 |
db.commit()
|
| 743 |
add_flash(request, "success", f"管理员 {display_name} 已创建。")
|
| 744 |
+
return redirect("/admin/admins")
|
| 745 |
|
| 746 |
@router.get("/admin/groups")
|
| 747 |
def admin_groups(request: Request, db: Session = Depends(get_db)):
|
|
|
|
| 887 |
form,
|
| 888 |
"task_title",
|
| 889 |
"task_description",
|
| 890 |
+
"task_image_url",
|
| 891 |
+
"task_clue_image_url",
|
| 892 |
)
|
| 893 |
except ValueError as exc:
|
| 894 |
add_flash(request, "error", str(exc))
|
|
|
|
| 900 |
|
| 901 |
activity = Activity(created_by_id=admin.id, **activity_fields)
|
| 902 |
db.add(activity)
|
| 903 |
+
db.flush()
|
| 904 |
|
| 905 |
tasks = []
|
| 906 |
for payload in tasks_payload:
|
| 907 |
+
task = Task(
|
| 908 |
+
activity=activity,
|
| 909 |
+
title=payload["title"],
|
| 910 |
+
description=payload["description"],
|
| 911 |
+
image_url=payload["image_url"],
|
| 912 |
+
clue_image_url=payload["clue_image_url"],
|
| 913 |
+
image_filename=payload["image_url"].split("/")[-1][:255] or "task-link",
|
| 914 |
+
clue_image_filename=(payload["clue_image_url"].split("/")[-1][:255] if payload["clue_image_url"] else None),
|
| 915 |
+
)
|
| 916 |
tasks.append(task)
|
| 917 |
db.add(task)
|
| 918 |
|
|
|
|
| 921 |
add_flash(request, "success", f"活动 {activity.title} 已发布。")
|
| 922 |
return redirect("/admin/activities")
|
| 923 |
|
|
|
|
| 924 |
@router.post("/admin/activities/{activity_id}/edit")
|
| 925 |
async def update_activity(activity_id: int, request: Request, db: Session = Depends(get_db)):
|
| 926 |
admin = require_admin(request, db)
|
|
|
|
| 946 |
existing_task_ids = form.getlist("existing_task_id")
|
| 947 |
existing_task_titles = form.getlist("existing_task_title")
|
| 948 |
existing_task_descriptions = form.getlist("existing_task_description")
|
| 949 |
+
existing_task_image_urls = form.getlist("existing_task_image_url")
|
| 950 |
+
existing_task_clue_urls = form.getlist("existing_task_clue_image_url")
|
| 951 |
remove_clue_ids = {
|
| 952 |
int(value)
|
| 953 |
for value in form.getlist("existing_task_remove_clue")
|
|
|
|
| 973 |
if not title:
|
| 974 |
raise ValueError(f"第 {index + 1} 个已有任务标题不能为空。")
|
| 975 |
|
| 976 |
+
image_url = normalize_image_url(
|
| 977 |
+
existing_task_image_urls[index] if index < len(existing_task_image_urls) else None,
|
| 978 |
+
f"第 {index + 1} 个已有任务的主图链接",
|
| 979 |
+
required=True,
|
| 980 |
+
)
|
| 981 |
+
clue_image_url = normalize_image_url(
|
| 982 |
+
existing_task_clue_urls[index] if index < len(existing_task_clue_urls) else None,
|
| 983 |
+
f"第 {index + 1} 个已有任务的线索图链接",
|
| 984 |
+
required=False,
|
| 985 |
+
)
|
| 986 |
+
if task_id in remove_clue_ids:
|
| 987 |
+
clue_image_url = None
|
| 988 |
+
|
| 989 |
task.title = title
|
| 990 |
task.description = description
|
| 991 |
+
task.image_url = image_url
|
| 992 |
+
task.image_filename = image_url.split("/")[-1][:255] or task.image_filename
|
| 993 |
+
if task.image_path:
|
| 994 |
+
delete_file_if_exists(task.image_path, settings.task_media_root)
|
| 995 |
+
task.image_path = None
|
| 996 |
+
|
| 997 |
+
if clue_image_url:
|
| 998 |
+
task.clue_image_url = clue_image_url
|
| 999 |
+
task.clue_image_filename = clue_image_url.split("/")[-1][:255] or task.clue_image_filename
|
| 1000 |
+
if task.clue_image_path:
|
| 1001 |
+
delete_file_if_exists(task.clue_image_path, settings.task_media_root)
|
| 1002 |
+
task.clue_image_path = None
|
| 1003 |
+
else:
|
| 1004 |
+
if task.clue_image_path:
|
| 1005 |
+
delete_file_if_exists(task.clue_image_path, settings.task_media_root)
|
| 1006 |
+
task.clue_image_url = None
|
| 1007 |
+
task.clue_image_path = None
|
|
|
|
|
|
|
| 1008 |
task.clue_image_mime = None
|
| 1009 |
task.clue_image_filename = None
|
| 1010 |
|
|
|
|
| 1014 |
form,
|
| 1015 |
"new_task_title",
|
| 1016 |
"new_task_description",
|
| 1017 |
+
"new_task_image_url",
|
| 1018 |
+
"new_task_clue_image_url",
|
| 1019 |
)
|
| 1020 |
except ValueError as exc:
|
| 1021 |
add_flash(request, "error", str(exc))
|
|
|
|
| 1030 |
|
| 1031 |
all_tasks = list(ordered_tasks)
|
| 1032 |
for payload in new_tasks_payload:
|
| 1033 |
+
task = Task(
|
| 1034 |
+
activity=activity,
|
| 1035 |
+
title=payload["title"],
|
| 1036 |
+
description=payload["description"],
|
| 1037 |
+
image_url=payload["image_url"],
|
| 1038 |
+
clue_image_url=payload["clue_image_url"],
|
| 1039 |
+
image_filename=payload["image_url"].split("/")[-1][:255] or "task-link",
|
| 1040 |
+
clue_image_filename=(payload["clue_image_url"].split("/")[-1][:255] if payload["clue_image_url"] else None),
|
| 1041 |
+
)
|
| 1042 |
db.add(task)
|
| 1043 |
all_tasks.append(task)
|
| 1044 |
|
|
|
|
| 1052 |
add_flash(request, "success", f"活动 {activity.title} 已更新。")
|
| 1053 |
return redirect(f"/admin/activities/{activity_id}/edit")
|
| 1054 |
|
|
|
|
| 1055 |
@router.post("/admin/tasks/{task_id}/delete")
|
| 1056 |
async def delete_task(task_id: int, request: Request, db: Session = Depends(get_db)):
|
| 1057 |
admin = require_admin(request, db)
|
|
|
|
| 1070 |
return redirect(f"/admin/activities/{activity.id}/edit")
|
| 1071 |
|
| 1072 |
cleanup_submission_files(task.submissions)
|
| 1073 |
+
cleanup_task_files(task)
|
| 1074 |
activity_id = activity.id
|
| 1075 |
db.delete(task)
|
| 1076 |
db.flush()
|
|
|
|
| 1087 |
add_flash(request, "success", "任务已删除,剩余任务顺序和线索发布时间已自动更新。")
|
| 1088 |
return redirect(f"/admin/activities/{activity_id}/edit")
|
| 1089 |
|
|
|
|
| 1090 |
@router.post("/admin/activities/{activity_id}/visibility")
|
| 1091 |
async def update_leaderboard_visibility(activity_id: int, request: Request, db: Session = Depends(get_db)):
|
| 1092 |
admin = require_admin(request, db)
|
|
|
|
| 1102 |
db.add(activity)
|
| 1103 |
db.commit()
|
| 1104 |
add_flash(request, "success", f"已更新 {activity.title} 的排行榜可见性。")
|
| 1105 |
+
return redirect("/admin/activities")
|
| 1106 |
|
| 1107 |
@router.get("/admin/reviews")
|
| 1108 |
def admin_reviews(request: Request, db: Session = Depends(get_db)):
|
|
|
|
| 1255 |
archive,
|
| 1256 |
media_type="application/zip",
|
| 1257 |
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
| 1258 |
+
)
|
| 1259 |
+
|
| 1260 |
+
|
| 1261 |
+
|
| 1262 |
+
|
| 1263 |
+
|
| 1264 |
+
|
| 1265 |
+
|
| 1266 |
+
|
| 1267 |
+
|
| 1268 |
+
|
| 1269 |
+
|
| 1270 |
+
|
| 1271 |
+
|
| 1272 |
+
|
| 1273 |
+
|
| 1274 |
+
|
| 1275 |
+
|
| 1276 |
+
|
| 1277 |
+
@router.get("/admin/images")
|
| 1278 |
+
def admin_images(request: Request, db: Session = Depends(get_db)):
|
| 1279 |
+
admin = require_admin(request, db)
|
| 1280 |
+
if not admin:
|
| 1281 |
+
return redirect("/admin")
|
| 1282 |
+
|
| 1283 |
+
scope = request.query_params.get("scope", "all").strip()
|
| 1284 |
+
if scope not in {"all", "tasks", "submissions", "referenced", "orphan"}:
|
| 1285 |
+
scope = "all"
|
| 1286 |
+
|
| 1287 |
+
image_items, summary = build_image_inventory(db, scope)
|
| 1288 |
+
return render(
|
| 1289 |
+
request,
|
| 1290 |
+
"admin_images.html",
|
| 1291 |
+
{
|
| 1292 |
+
"page_title": "图片管理",
|
| 1293 |
+
"admin": admin,
|
| 1294 |
+
"scope": scope,
|
| 1295 |
+
"image_items": image_items,
|
| 1296 |
+
"summary": summary,
|
| 1297 |
+
},
|
| 1298 |
+
)
|
| 1299 |
+
|
| 1300 |
+
|
| 1301 |
+
@router.post("/admin/images/delete")
|
| 1302 |
+
async def delete_managed_image(request: Request, db: Session = Depends(get_db)):
|
| 1303 |
+
admin = require_admin(request, db)
|
| 1304 |
+
if not admin:
|
| 1305 |
+
return redirect("/admin")
|
| 1306 |
+
|
| 1307 |
+
form = await request.form()
|
| 1308 |
+
relative_path = str(form.get("relative_path", "")).strip()
|
| 1309 |
+
scope = str(form.get("scope", "all")).strip()
|
| 1310 |
+
if scope not in {"all", "tasks", "submissions", "referenced", "orphan"}:
|
| 1311 |
+
scope = "all"
|
| 1312 |
+
|
| 1313 |
+
if not relative_path:
|
| 1314 |
+
add_flash(request, "error", "图片路径不能为空。")
|
| 1315 |
+
return redirect(f"/admin/images?scope={scope}")
|
| 1316 |
+
|
| 1317 |
+
try:
|
| 1318 |
+
file_path = resolve_managed_path(settings.docker_root, relative_path)
|
| 1319 |
+
except ValueError as exc:
|
| 1320 |
+
add_flash(request, "error", str(exc))
|
| 1321 |
+
return redirect(f"/admin/images?scope={scope}")
|
| 1322 |
+
|
| 1323 |
+
if not file_path.exists() or not file_path.is_file():
|
| 1324 |
+
add_flash(request, "error", "图片不存在或已被删除。")
|
| 1325 |
+
return redirect(f"/admin/images?scope={scope}")
|
| 1326 |
+
|
| 1327 |
+
references = build_image_reference_index(db).get(normalize_path_key(file_path), [])
|
| 1328 |
+
if references:
|
| 1329 |
+
add_flash(request, "error", "该图片仍被系统引用,暂时不能直接删除。")
|
| 1330 |
+
return redirect(f"/admin/images?scope={scope}")
|
| 1331 |
+
|
| 1332 |
+
delete_file_if_exists(str(file_path), settings.docker_root)
|
| 1333 |
+
add_flash(request, "success", f"已删除图片:{relative_path}")
|
| 1334 |
+
return redirect(f"/admin/images?scope={scope}")
|
| 1335 |
+
|
| 1336 |
+
|
| 1337 |
+
|
| 1338 |
+
|
| 1339 |
+
|
| 1340 |
+
|
| 1341 |
|
app/routes/auth.py
CHANGED
|
@@ -8,7 +8,8 @@ from app.auth import get_current_admin, get_current_user, sign_in_admin, sign_in
|
|
| 8 |
from app.database import get_db
|
| 9 |
from app.models import Admin, Group, User
|
| 10 |
from app.security import hash_password, verify_password
|
| 11 |
-
from app.
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
router = APIRouter()
|
|
@@ -163,8 +164,31 @@ def change_password(
|
|
| 163 |
return redirect("/account")
|
| 164 |
|
| 165 |
|
| 166 |
-
@router.
|
| 167 |
def presence_ping(request: Request, db: Session = Depends(get_db)):
|
| 168 |
-
|
|
|
|
|
|
|
| 169 |
return JSONResponse({"ok": False}, status_code=401)
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
from app.database import get_db
|
| 9 |
from app.models import Admin, Group, User
|
| 10 |
from app.security import hash_password, verify_password
|
| 11 |
+
from app.services.presence import should_write_presence, unix_seconds
|
| 12 |
+
from app.web import add_flash, local_now, redirect, render
|
| 13 |
|
| 14 |
|
| 15 |
router = APIRouter()
|
|
|
|
| 164 |
return redirect("/account")
|
| 165 |
|
| 166 |
|
| 167 |
+
@router.api_route("/api/presence/ping", methods=["GET", "POST"])
|
| 168 |
def presence_ping(request: Request, db: Session = Depends(get_db)):
|
| 169 |
+
admin = get_current_admin(request, db)
|
| 170 |
+
user = get_current_user(request, db)
|
| 171 |
+
if not admin and not user:
|
| 172 |
return JSONResponse({"ok": False}, status_code=401)
|
| 173 |
+
|
| 174 |
+
now = local_now()
|
| 175 |
+
wrote = False
|
| 176 |
+
if should_write_presence(request.session, now):
|
| 177 |
+
if admin:
|
| 178 |
+
admin.last_seen_at = now
|
| 179 |
+
db.add(admin)
|
| 180 |
+
elif user:
|
| 181 |
+
user.last_seen_at = now
|
| 182 |
+
db.add(user)
|
| 183 |
+
db.commit()
|
| 184 |
+
wrote = True
|
| 185 |
+
|
| 186 |
+
return JSONResponse(
|
| 187 |
+
{
|
| 188 |
+
"ok": True,
|
| 189 |
+
"wrote": wrote,
|
| 190 |
+
"server_ts": unix_seconds(now),
|
| 191 |
+
},
|
| 192 |
+
headers={"Cache-Control": "no-store"},
|
| 193 |
+
)
|
| 194 |
+
|
app/routes/media.py
CHANGED
|
@@ -2,28 +2,42 @@
|
|
| 2 |
|
| 3 |
from pathlib import Path
|
| 4 |
|
| 5 |
-
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 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
|
| 25 |
raise HTTPException(status_code=404, detail="Image not found")
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
@router.get("/media/tasks/{task_id}/clue")
|
|
@@ -34,11 +48,14 @@ def task_clue(task_id: int, request: Request, db: Session = Depends(get_db)):
|
|
| 34 |
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 35 |
|
| 36 |
task = db.get(Task, task_id)
|
| 37 |
-
if not task
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
|
| 44 |
@router.get("/media/submissions/{submission_id}")
|
|
@@ -58,9 +75,7 @@ def submission_image(submission_id: int, request: Request, db: Session = Depends
|
|
| 58 |
if not user or not user.group_id or user.group_id != submission.group_id:
|
| 59 |
raise HTTPException(status_code=403, detail="Forbidden")
|
| 60 |
|
| 61 |
-
file_path =
|
| 62 |
-
if not file_path.exists():
|
| 63 |
-
raise HTTPException(status_code=404, detail="Stored file missing")
|
| 64 |
|
| 65 |
if request.query_params.get("download") == "1":
|
| 66 |
return FileResponse(
|
|
@@ -68,4 +83,23 @@ def submission_image(submission_id: int, request: Request, db: Session = Depends
|
|
| 68 |
media_type=submission.mime_type,
|
| 69 |
filename=submission.original_filename,
|
| 70 |
)
|
| 71 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from pathlib import Path
|
| 4 |
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 6 |
+
from fastapi.responses import FileResponse, RedirectResponse
|
| 7 |
from sqlalchemy.orm import Session, joinedload
|
| 8 |
|
| 9 |
from app.auth import get_current_admin, get_current_user
|
| 10 |
+
from app.config import settings
|
| 11 |
from app.database import get_db
|
| 12 |
+
from app.models import Submission, Task
|
| 13 |
+
from app.services.images import resolve_managed_path
|
| 14 |
from app.web import local_now
|
| 15 |
|
| 16 |
|
| 17 |
router = APIRouter()
|
| 18 |
|
| 19 |
|
| 20 |
+
def ensure_existing_file(file_path: str | None) -> Path:
|
| 21 |
+
if not file_path:
|
| 22 |
+
raise HTTPException(status_code=404, detail="Image not found")
|
| 23 |
+
path = Path(file_path)
|
| 24 |
+
if not path.exists() or not path.is_file():
|
| 25 |
+
raise HTTPException(status_code=404, detail="Image not found")
|
| 26 |
+
return path
|
| 27 |
+
|
| 28 |
+
|
| 29 |
@router.get("/media/tasks/{task_id}/image")
|
| 30 |
def task_image(task_id: int, request: Request, db: Session = Depends(get_db)):
|
| 31 |
if not get_current_user(request, db) and not get_current_admin(request, db):
|
| 32 |
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 33 |
|
| 34 |
task = db.get(Task, task_id)
|
| 35 |
+
if not task:
|
| 36 |
raise HTTPException(status_code=404, detail="Image not found")
|
| 37 |
+
if task.image_url:
|
| 38 |
+
return RedirectResponse(task.image_url, status_code=307)
|
| 39 |
+
image_path = ensure_existing_file(task.image_path)
|
| 40 |
+
return FileResponse(image_path, media_type=task.image_mime, filename=task.image_filename)
|
| 41 |
|
| 42 |
|
| 43 |
@router.get("/media/tasks/{task_id}/clue")
|
|
|
|
| 48 |
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 49 |
|
| 50 |
task = db.get(Task, task_id)
|
| 51 |
+
if not task:
|
| 52 |
raise HTTPException(status_code=404, detail="Clue image not found")
|
| 53 |
if not admin and (not task.clue_release_at or local_now() < task.clue_release_at):
|
| 54 |
raise HTTPException(status_code=403, detail="Clue not released")
|
| 55 |
+
if task.clue_image_url:
|
| 56 |
+
return RedirectResponse(task.clue_image_url, status_code=307)
|
| 57 |
+
clue_path = ensure_existing_file(task.clue_image_path)
|
| 58 |
+
return FileResponse(clue_path, media_type=task.clue_image_mime or "image/jpeg", filename=task.clue_image_filename or clue_path.name)
|
| 59 |
|
| 60 |
|
| 61 |
@router.get("/media/submissions/{submission_id}")
|
|
|
|
| 75 |
if not user or not user.group_id or user.group_id != submission.group_id:
|
| 76 |
raise HTTPException(status_code=403, detail="Forbidden")
|
| 77 |
|
| 78 |
+
file_path = ensure_existing_file(submission.file_path)
|
|
|
|
|
|
|
| 79 |
|
| 80 |
if request.query_params.get("download") == "1":
|
| 81 |
return FileResponse(
|
|
|
|
| 83 |
media_type=submission.mime_type,
|
| 84 |
filename=submission.original_filename,
|
| 85 |
)
|
| 86 |
+
return FileResponse(file_path, media_type=submission.mime_type, filename=submission.original_filename)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@router.get("/media/library/{relative_path:path}")
|
| 90 |
+
def managed_library_file(relative_path: str, request: Request, db: Session = Depends(get_db)):
|
| 91 |
+
admin = get_current_admin(request, db)
|
| 92 |
+
if not admin:
|
| 93 |
+
raise HTTPException(status_code=403, detail="Forbidden")
|
| 94 |
+
|
| 95 |
+
try:
|
| 96 |
+
file_path = resolve_managed_path(settings.docker_root, relative_path)
|
| 97 |
+
except ValueError as exc:
|
| 98 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 99 |
+
|
| 100 |
+
if not file_path.exists() or not file_path.is_file():
|
| 101 |
+
raise HTTPException(status_code=404, detail="Image not found")
|
| 102 |
+
|
| 103 |
+
if request.query_params.get("download") == "1":
|
| 104 |
+
return FileResponse(file_path, filename=file_path.name)
|
| 105 |
+
return FileResponse(file_path)
|
app/routes/user.py
CHANGED
|
@@ -33,9 +33,6 @@ def require_user(request: Request, db: Session) -> User | None:
|
|
| 33 |
)
|
| 34 |
if not user or not user.is_active:
|
| 35 |
return None
|
| 36 |
-
user.last_seen_at = local_now()
|
| 37 |
-
db.add(user)
|
| 38 |
-
db.flush()
|
| 39 |
return user
|
| 40 |
|
| 41 |
|
|
@@ -93,7 +90,7 @@ def activity_detail(activity_id: int, request: Request, db: Session = Depends(ge
|
|
| 93 |
group_submission_by_task = build_group_submission_map(activity, user.group_id)
|
| 94 |
group_progress = build_group_progress(activity, user.group_id)
|
| 95 |
clue_states = {
|
| 96 |
-
task.id: bool(task.clue_image_filename and task.clue_release_at and now >= task.clue_release_at)
|
| 97 |
for task in activity.tasks
|
| 98 |
}
|
| 99 |
|
|
@@ -145,7 +142,7 @@ async def submit_task(
|
|
| 145 |
|
| 146 |
try:
|
| 147 |
raw = await read_and_validate_upload(photo)
|
| 148 |
-
compressed, mime_type = compress_to_limit(raw,
|
| 149 |
except ValueError as exc:
|
| 150 |
add_flash(request, "error", str(exc))
|
| 151 |
return redirect(f"/activities/{activity_id}")
|
|
@@ -215,7 +212,7 @@ def activity_clues(activity_id: int, request: Request, db: Session = Depends(get
|
|
| 215 |
"released_task_ids": [
|
| 216 |
task.id
|
| 217 |
for task in activity.tasks
|
| 218 |
-
if task.clue_image_filename and task.clue_release_at and now >= task.clue_release_at
|
| 219 |
],
|
| 220 |
"server_time": now.isoformat(timespec="seconds"),
|
| 221 |
}
|
|
@@ -251,6 +248,11 @@ def activity_status(activity_id: int, request: Request, db: Session = Depends(ge
|
|
| 251 |
"uploader_name": submission.user.full_name if submission and submission.user else None,
|
| 252 |
"reviewer_name": submission.reviewed_by.display_name if submission and submission.reviewed_by else None,
|
| 253 |
"can_upload": can_upload_now and (not submission or submission.status != "approved"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
}
|
| 255 |
)
|
| 256 |
|
|
@@ -269,4 +271,7 @@ def activity_status(activity_id: int, request: Request, db: Session = Depends(ge
|
|
| 269 |
for row in leaderboard
|
| 270 |
],
|
| 271 |
}
|
| 272 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
)
|
| 34 |
if not user or not user.is_active:
|
| 35 |
return None
|
|
|
|
|
|
|
|
|
|
| 36 |
return user
|
| 37 |
|
| 38 |
|
|
|
|
| 90 |
group_submission_by_task = build_group_submission_map(activity, user.group_id)
|
| 91 |
group_progress = build_group_progress(activity, user.group_id)
|
| 92 |
clue_states = {
|
| 93 |
+
task.id: bool((task.clue_image_url or task.clue_image_path or task.clue_image_filename) and task.clue_release_at and now >= task.clue_release_at)
|
| 94 |
for task in activity.tasks
|
| 95 |
}
|
| 96 |
|
|
|
|
| 142 |
|
| 143 |
try:
|
| 144 |
raw = await read_and_validate_upload(photo)
|
| 145 |
+
compressed, mime_type = compress_to_limit(raw, 200 * 1024)
|
| 146 |
except ValueError as exc:
|
| 147 |
add_flash(request, "error", str(exc))
|
| 148 |
return redirect(f"/activities/{activity_id}")
|
|
|
|
| 212 |
"released_task_ids": [
|
| 213 |
task.id
|
| 214 |
for task in activity.tasks
|
| 215 |
+
if (task.clue_image_url or task.clue_image_path or task.clue_image_filename) and task.clue_release_at and now >= task.clue_release_at
|
| 216 |
],
|
| 217 |
"server_time": now.isoformat(timespec="seconds"),
|
| 218 |
}
|
|
|
|
| 248 |
"uploader_name": submission.user.full_name if submission and submission.user else None,
|
| 249 |
"reviewer_name": submission.reviewed_by.display_name if submission and submission.reviewed_by else None,
|
| 250 |
"can_upload": can_upload_now and (not submission or submission.status != "approved"),
|
| 251 |
+
"clue_released": bool(
|
| 252 |
+
(task.clue_image_url or task.clue_image_path or task.clue_image_filename)
|
| 253 |
+
and task.clue_release_at
|
| 254 |
+
and now >= task.clue_release_at
|
| 255 |
+
),
|
| 256 |
}
|
| 257 |
)
|
| 258 |
|
|
|
|
| 271 |
for row in leaderboard
|
| 272 |
],
|
| 273 |
}
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
|
app/services/__pycache__/bootstrap.cpython-313.pyc
CHANGED
|
Binary files a/app/services/__pycache__/bootstrap.cpython-313.pyc and b/app/services/__pycache__/bootstrap.cpython-313.pyc differ
|
|
|
app/services/__pycache__/images.cpython-313.pyc
CHANGED
|
Binary files a/app/services/__pycache__/images.cpython-313.pyc and b/app/services/__pycache__/images.cpython-313.pyc differ
|
|
|
app/services/__pycache__/presence.cpython-313.pyc
CHANGED
|
Binary files a/app/services/__pycache__/presence.cpython-313.pyc and b/app/services/__pycache__/presence.cpython-313.pyc differ
|
|
|
app/services/bootstrap.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
|
|
|
| 3 |
from sqlalchemy import inspect, text
|
| 4 |
from sqlalchemy.orm import Session
|
| 5 |
|
|
@@ -7,6 +9,7 @@ from app.config import settings
|
|
| 7 |
from app.database import Base, engine
|
| 8 |
from app.models import Admin
|
| 9 |
from app.security import hash_password, verify_password
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
def ensure_column(table_name: str, column_name: str, ddl: str) -> None:
|
|
@@ -35,6 +38,13 @@ def submission_indexes() -> tuple[set[str], set[str]]:
|
|
| 35 |
return unique_names, index_names
|
| 36 |
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
def ensure_submission_constraints() -> None:
|
| 39 |
inspector = inspect(engine)
|
| 40 |
if "submissions" not in inspector.get_table_names():
|
|
@@ -60,18 +70,87 @@ def ensure_submission_constraints() -> None:
|
|
| 60 |
)
|
| 61 |
)
|
| 62 |
except Exception:
|
| 63 |
-
# Existing historical duplicates may prevent the unique key from being created.
|
| 64 |
-
# The application layer will still favor a single canonical submission per group/task.
|
| 65 |
pass
|
| 66 |
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
def upgrade_schema() -> None:
|
| 69 |
ensure_column("admins", "last_seen_at", "last_seen_at DATETIME NULL")
|
| 70 |
ensure_column("users", "last_seen_at", "last_seen_at DATETIME NULL")
|
| 71 |
ensure_column("activities", "leaderboard_visible", "leaderboard_visible TINYINT(1) NOT NULL DEFAULT 1")
|
| 72 |
ensure_column("activities", "clue_interval_minutes", "clue_interval_minutes INT NULL")
|
| 73 |
ensure_column("tasks", "display_order", "display_order INT NOT NULL DEFAULT 1")
|
| 74 |
-
ensure_column("tasks", "
|
|
|
|
|
|
|
|
|
|
| 75 |
ensure_column("tasks", "clue_image_mime", "clue_image_mime VARCHAR(120) NULL")
|
| 76 |
ensure_column("tasks", "clue_image_filename", "clue_image_filename VARCHAR(255) NULL")
|
| 77 |
ensure_column("tasks", "clue_release_at", "clue_release_at DATETIME NULL")
|
|
@@ -82,22 +161,40 @@ def upgrade_schema() -> None:
|
|
| 82 |
ensure_column("submissions", "approved_at", "approved_at DATETIME NULL")
|
| 83 |
|
| 84 |
with engine.begin() as connection:
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
)
|
| 94 |
-
)
|
| 95 |
|
| 96 |
ensure_submission_constraints()
|
|
|
|
| 97 |
|
| 98 |
|
| 99 |
def initialize_database() -> None:
|
|
|
|
| 100 |
settings.upload_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 101 |
Base.metadata.create_all(bind=engine)
|
| 102 |
upgrade_schema()
|
| 103 |
|
|
@@ -124,5 +221,5 @@ def seed_super_admin(db: Session) -> None:
|
|
| 124 |
changed = True
|
| 125 |
if changed:
|
| 126 |
db.add(admin)
|
| 127 |
-
db.commit()
|
| 128 |
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
from sqlalchemy import inspect, text
|
| 6 |
from sqlalchemy.orm import Session
|
| 7 |
|
|
|
|
| 9 |
from app.database import Base, engine
|
| 10 |
from app.models import Admin
|
| 11 |
from app.security import hash_password, verify_password
|
| 12 |
+
from app.services.images import persist_task_asset
|
| 13 |
|
| 14 |
|
| 15 |
def ensure_column(table_name: str, column_name: str, ddl: str) -> None:
|
|
|
|
| 38 |
return unique_names, index_names
|
| 39 |
|
| 40 |
|
| 41 |
+
def task_columns() -> set[str]:
|
| 42 |
+
inspector = inspect(engine)
|
| 43 |
+
if "tasks" not in inspector.get_table_names():
|
| 44 |
+
return set()
|
| 45 |
+
return {column["name"] for column in inspector.get_columns("tasks")}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
def ensure_submission_constraints() -> None:
|
| 49 |
inspector = inspect(engine)
|
| 50 |
if "submissions" not in inspector.get_table_names():
|
|
|
|
| 70 |
)
|
| 71 |
)
|
| 72 |
except Exception:
|
|
|
|
|
|
|
| 73 |
pass
|
| 74 |
|
| 75 |
|
| 76 |
+
def migrate_task_media_to_files() -> None:
|
| 77 |
+
columns = task_columns()
|
| 78 |
+
if not columns:
|
| 79 |
+
return
|
| 80 |
+
|
| 81 |
+
has_image_data = "image_data" in columns
|
| 82 |
+
has_clue_image_data = "clue_image_data" in columns
|
| 83 |
+
if not has_image_data and not has_clue_image_data:
|
| 84 |
+
return
|
| 85 |
+
|
| 86 |
+
settings.task_media_root.mkdir(parents=True, exist_ok=True)
|
| 87 |
+
|
| 88 |
+
select_sql = f"""
|
| 89 |
+
SELECT
|
| 90 |
+
id,
|
| 91 |
+
activity_id,
|
| 92 |
+
image_path,
|
| 93 |
+
clue_image_path,
|
| 94 |
+
image_url,
|
| 95 |
+
clue_image_url,
|
| 96 |
+
{'image_data' if has_image_data else 'NULL'} AS image_data,
|
| 97 |
+
{'clue_image_data' if has_clue_image_data else 'NULL'} AS clue_image_data
|
| 98 |
+
FROM tasks
|
| 99 |
+
"""
|
| 100 |
+
|
| 101 |
+
with engine.begin() as connection:
|
| 102 |
+
rows = connection.execute(text(select_sql)).mappings().all()
|
| 103 |
+
for row in rows:
|
| 104 |
+
updates: list[str] = []
|
| 105 |
+
params = {"task_id": row["id"]}
|
| 106 |
+
|
| 107 |
+
current_image_path = row.get("image_path")
|
| 108 |
+
image_exists = bool(current_image_path and Path(current_image_path).exists())
|
| 109 |
+
if row.get("image_data") and not row.get("image_url") and not image_exists:
|
| 110 |
+
image_path = persist_task_asset(
|
| 111 |
+
settings.task_media_root,
|
| 112 |
+
row["activity_id"],
|
| 113 |
+
row["id"],
|
| 114 |
+
"image",
|
| 115 |
+
row["image_data"],
|
| 116 |
+
)
|
| 117 |
+
updates.append("image_path = :image_path")
|
| 118 |
+
params["image_path"] = image_path
|
| 119 |
+
if has_image_data:
|
| 120 |
+
updates.append("image_data = NULL")
|
| 121 |
+
|
| 122 |
+
current_clue_path = row.get("clue_image_path")
|
| 123 |
+
clue_exists = bool(current_clue_path and Path(current_clue_path).exists())
|
| 124 |
+
if row.get("clue_image_data") and not row.get("clue_image_url") and not clue_exists:
|
| 125 |
+
clue_image_path = persist_task_asset(
|
| 126 |
+
settings.task_media_root,
|
| 127 |
+
row["activity_id"],
|
| 128 |
+
row["id"],
|
| 129 |
+
"clue",
|
| 130 |
+
row["clue_image_data"],
|
| 131 |
+
)
|
| 132 |
+
updates.append("clue_image_path = :clue_image_path")
|
| 133 |
+
params["clue_image_path"] = clue_image_path
|
| 134 |
+
if has_clue_image_data:
|
| 135 |
+
updates.append("clue_image_data = NULL")
|
| 136 |
+
|
| 137 |
+
if updates:
|
| 138 |
+
connection.execute(
|
| 139 |
+
text(f"UPDATE tasks SET {', '.join(updates)} WHERE id = :task_id"),
|
| 140 |
+
params,
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
def upgrade_schema() -> None:
|
| 145 |
ensure_column("admins", "last_seen_at", "last_seen_at DATETIME NULL")
|
| 146 |
ensure_column("users", "last_seen_at", "last_seen_at DATETIME NULL")
|
| 147 |
ensure_column("activities", "leaderboard_visible", "leaderboard_visible TINYINT(1) NOT NULL DEFAULT 1")
|
| 148 |
ensure_column("activities", "clue_interval_minutes", "clue_interval_minutes INT NULL")
|
| 149 |
ensure_column("tasks", "display_order", "display_order INT NOT NULL DEFAULT 1")
|
| 150 |
+
ensure_column("tasks", "image_url", "image_url VARCHAR(1000) NULL")
|
| 151 |
+
ensure_column("tasks", "image_path", "image_path VARCHAR(600) NULL")
|
| 152 |
+
ensure_column("tasks", "clue_image_url", "clue_image_url VARCHAR(1000) NULL")
|
| 153 |
+
ensure_column("tasks", "clue_image_path", "clue_image_path VARCHAR(600) NULL")
|
| 154 |
ensure_column("tasks", "clue_image_mime", "clue_image_mime VARCHAR(120) NULL")
|
| 155 |
ensure_column("tasks", "clue_image_filename", "clue_image_filename VARCHAR(255) NULL")
|
| 156 |
ensure_column("tasks", "clue_release_at", "clue_release_at DATETIME NULL")
|
|
|
|
| 161 |
ensure_column("submissions", "approved_at", "approved_at DATETIME NULL")
|
| 162 |
|
| 163 |
with engine.begin() as connection:
|
| 164 |
+
if engine.dialect.name == "mysql":
|
| 165 |
+
connection.execute(
|
| 166 |
+
text(
|
| 167 |
+
"""
|
| 168 |
+
UPDATE submissions AS s
|
| 169 |
+
JOIN users AS u ON s.user_id = u.id
|
| 170 |
+
SET s.group_id = u.group_id
|
| 171 |
+
WHERE s.group_id IS NULL
|
| 172 |
+
"""
|
| 173 |
+
)
|
| 174 |
+
)
|
| 175 |
+
else:
|
| 176 |
+
connection.execute(
|
| 177 |
+
text(
|
| 178 |
+
"""
|
| 179 |
+
UPDATE submissions
|
| 180 |
+
SET group_id = (
|
| 181 |
+
SELECT users.group_id
|
| 182 |
+
FROM users
|
| 183 |
+
WHERE users.id = submissions.user_id
|
| 184 |
+
)
|
| 185 |
+
WHERE group_id IS NULL
|
| 186 |
+
"""
|
| 187 |
+
)
|
| 188 |
)
|
|
|
|
| 189 |
|
| 190 |
ensure_submission_constraints()
|
| 191 |
+
migrate_task_media_to_files()
|
| 192 |
|
| 193 |
|
| 194 |
def initialize_database() -> None:
|
| 195 |
+
settings.docker_root.mkdir(parents=True, exist_ok=True)
|
| 196 |
settings.upload_root.mkdir(parents=True, exist_ok=True)
|
| 197 |
+
settings.task_media_root.mkdir(parents=True, exist_ok=True)
|
| 198 |
Base.metadata.create_all(bind=engine)
|
| 199 |
upgrade_schema()
|
| 200 |
|
|
|
|
| 221 |
changed = True
|
| 222 |
if changed:
|
| 223 |
db.add(admin)
|
| 224 |
+
db.commit()
|
| 225 |
|
app/services/images.py
CHANGED
|
@@ -9,6 +9,7 @@ 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:
|
|
@@ -23,29 +24,45 @@ def _load_rgb_image(data: bytes) -> Image.Image:
|
|
| 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 |
-
|
| 29 |
-
quality = 92
|
| 30 |
|
| 31 |
-
|
| 32 |
resized = image
|
| 33 |
if scale < 1.0:
|
| 34 |
new_size = (
|
| 35 |
-
max(
|
| 36 |
-
max(
|
| 37 |
)
|
| 38 |
resized = image.resize(new_size, Image.Resampling.LANCZOS)
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
| 45 |
if len(payload) <= limit_bytes:
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
raise ValueError(f"图片压缩后仍超过限制,请上传更清晰度适中的图片(限制 {limit_bytes // 1024}KB)。")
|
| 51 |
|
|
@@ -59,6 +76,21 @@ async def read_and_validate_upload(upload: UploadFile) -> bytes:
|
|
| 59 |
return data
|
| 60 |
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
def persist_submission_image(
|
| 63 |
upload_root: Path,
|
| 64 |
user_identifier: str,
|
|
@@ -71,4 +103,50 @@ def persist_submission_image(
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
ALLOWED_MIME_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif", "image/bmp"}
|
| 12 |
+
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
|
| 13 |
|
| 14 |
|
| 15 |
def _load_rgb_image(data: bytes) -> Image.Image:
|
|
|
|
| 24 |
return image
|
| 25 |
|
| 26 |
|
| 27 |
+
def _encode_jpeg(image: Image.Image, quality: int) -> bytes:
|
| 28 |
+
buffer = io.BytesIO()
|
| 29 |
+
image.save(
|
| 30 |
+
buffer,
|
| 31 |
+
format="JPEG",
|
| 32 |
+
quality=quality,
|
| 33 |
+
optimize=True,
|
| 34 |
+
progressive=True,
|
| 35 |
+
)
|
| 36 |
+
return buffer.getvalue()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
def compress_to_limit(data: bytes, limit_bytes: int) -> tuple[bytes, str]:
|
| 40 |
image = _load_rgb_image(data)
|
| 41 |
+
scale_steps = (1.0, 0.94, 0.88, 0.82, 0.76, 0.7, 0.64, 0.58, 0.52, 0.46, 0.4, 0.34, 0.28)
|
|
|
|
| 42 |
|
| 43 |
+
for scale in scale_steps:
|
| 44 |
resized = image
|
| 45 |
if scale < 1.0:
|
| 46 |
new_size = (
|
| 47 |
+
max(320, int(image.width * scale)),
|
| 48 |
+
max(320, int(image.height * scale)),
|
| 49 |
)
|
| 50 |
resized = image.resize(new_size, Image.Resampling.LANCZOS)
|
| 51 |
|
| 52 |
+
best_payload = None
|
| 53 |
+
low = 28
|
| 54 |
+
high = 95
|
| 55 |
+
while low <= high:
|
| 56 |
+
current_quality = (low + high) // 2
|
| 57 |
+
payload = _encode_jpeg(resized, current_quality)
|
| 58 |
if len(payload) <= limit_bytes:
|
| 59 |
+
best_payload = payload
|
| 60 |
+
low = current_quality + 1
|
| 61 |
+
else:
|
| 62 |
+
high = current_quality - 1
|
| 63 |
+
|
| 64 |
+
if best_payload is not None:
|
| 65 |
+
return best_payload, "image/jpeg"
|
| 66 |
|
| 67 |
raise ValueError(f"图片压缩后仍超过限制,请上传更清晰度适中的图片(限制 {limit_bytes // 1024}KB)。")
|
| 68 |
|
|
|
|
| 76 |
return data
|
| 77 |
|
| 78 |
|
| 79 |
+
def persist_task_asset(
|
| 80 |
+
task_root: Path,
|
| 81 |
+
activity_id: int,
|
| 82 |
+
task_id: int,
|
| 83 |
+
kind: str,
|
| 84 |
+
content: bytes,
|
| 85 |
+
) -> str:
|
| 86 |
+
folder = task_root / f"activity_{activity_id}"
|
| 87 |
+
folder.mkdir(parents=True, exist_ok=True)
|
| 88 |
+
filename = f"task_{task_id}_{kind}_{uuid4().hex}.jpg"
|
| 89 |
+
file_path = folder / filename
|
| 90 |
+
file_path.write_bytes(content)
|
| 91 |
+
return str(file_path)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
def persist_submission_image(
|
| 95 |
upload_root: Path,
|
| 96 |
user_identifier: str,
|
|
|
|
| 103 |
filename = f"{user_identifier}_{uuid4().hex}.jpg"
|
| 104 |
file_path = folder / filename
|
| 105 |
file_path.write_bytes(content)
|
| 106 |
+
return filename, str(file_path), len(content)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def delete_file_if_exists(file_path: str | None, managed_root: Path | None = None) -> None:
|
| 110 |
+
if not file_path:
|
| 111 |
+
return
|
| 112 |
+
path = Path(file_path)
|
| 113 |
+
if path.exists():
|
| 114 |
+
path.unlink(missing_ok=True)
|
| 115 |
+
if managed_root:
|
| 116 |
+
prune_empty_parents(path.parent, managed_root)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def prune_empty_parents(start_dir: Path, managed_root: Path) -> None:
|
| 120 |
+
try:
|
| 121 |
+
root_resolved = managed_root.resolve()
|
| 122 |
+
current = start_dir.resolve()
|
| 123 |
+
except FileNotFoundError:
|
| 124 |
+
return
|
| 125 |
+
|
| 126 |
+
while current != root_resolved and root_resolved in current.parents:
|
| 127 |
+
try:
|
| 128 |
+
current.rmdir()
|
| 129 |
+
except OSError:
|
| 130 |
+
break
|
| 131 |
+
current = current.parent
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def list_image_files(root: Path) -> list[Path]:
|
| 135 |
+
if not root.exists():
|
| 136 |
+
return []
|
| 137 |
+
files = [
|
| 138 |
+
path
|
| 139 |
+
for path in root.rglob("*")
|
| 140 |
+
if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES
|
| 141 |
+
]
|
| 142 |
+
return sorted(files, key=lambda item: (item.stat().st_mtime, str(item).lower()), reverse=True)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def resolve_managed_path(root: Path, relative_path: str) -> Path:
|
| 146 |
+
base = root.resolve()
|
| 147 |
+
candidate = (base / relative_path).resolve()
|
| 148 |
+
if candidate != base and base not in candidate.parents:
|
| 149 |
+
raise ValueError("非法路径")
|
| 150 |
+
return candidate
|
| 151 |
+
|
| 152 |
+
|
app/services/presence.py
CHANGED
|
@@ -5,7 +5,10 @@ from datetime import timedelta
|
|
| 5 |
from app.web import local_now
|
| 6 |
|
| 7 |
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
def online_cutoff(reference_time=None):
|
|
@@ -16,4 +19,26 @@ def online_cutoff(reference_time=None):
|
|
| 16 |
def is_online(last_seen_at, reference_time=None) -> bool:
|
| 17 |
if not last_seen_at:
|
| 18 |
return False
|
| 19 |
-
return last_seen_at >= online_cutoff(reference_time)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from app.web import local_now
|
| 6 |
|
| 7 |
|
| 8 |
+
HEARTBEAT_INTERVAL_SECONDS = 5
|
| 9 |
+
ONLINE_WINDOW_SECONDS = 15
|
| 10 |
+
PRESENCE_WRITE_INTERVAL_SECONDS = HEARTBEAT_INTERVAL_SECONDS
|
| 11 |
+
PRESENCE_OVERVIEW_PULL_SECONDS = 5
|
| 12 |
|
| 13 |
|
| 14 |
def online_cutoff(reference_time=None):
|
|
|
|
| 19 |
def is_online(last_seen_at, reference_time=None) -> bool:
|
| 20 |
if not last_seen_at:
|
| 21 |
return False
|
| 22 |
+
return last_seen_at >= online_cutoff(reference_time)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def unix_seconds(value) -> int | None:
|
| 26 |
+
if not value:
|
| 27 |
+
return None
|
| 28 |
+
return int(value.timestamp())
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def should_write_presence(session: dict, reference_time=None, force: bool = False) -> bool:
|
| 32 |
+
now = reference_time or local_now()
|
| 33 |
+
now_seconds = int(now.timestamp())
|
| 34 |
+
if force:
|
| 35 |
+
session["presence_last_written_at"] = now_seconds
|
| 36 |
+
return True
|
| 37 |
+
|
| 38 |
+
last_written = int(session.get("presence_last_written_at", 0) or 0)
|
| 39 |
+
if now_seconds - last_written < PRESENCE_WRITE_INTERVAL_SECONDS:
|
| 40 |
+
return False
|
| 41 |
+
|
| 42 |
+
session["presence_last_written_at"] = now_seconds
|
| 43 |
+
return True
|
| 44 |
+
|
app/static/favicon.ico
ADDED
|
|
app/static/style.css
CHANGED
|
@@ -1093,3 +1093,45 @@ textarea {
|
|
| 1093 |
min-height: 900px;
|
| 1094 |
}
|
| 1095 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1093 |
min-height: 900px;
|
| 1094 |
}
|
| 1095 |
}
|
| 1096 |
+
|
| 1097 |
+
.image-library-grid {
|
| 1098 |
+
display: grid;
|
| 1099 |
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
| 1100 |
+
gap: 18px;
|
| 1101 |
+
}
|
| 1102 |
+
|
| 1103 |
+
.image-library-card {
|
| 1104 |
+
display: grid;
|
| 1105 |
+
gap: 14px;
|
| 1106 |
+
padding: 16px;
|
| 1107 |
+
}
|
| 1108 |
+
|
| 1109 |
+
.image-library-thumb {
|
| 1110 |
+
width: 100%;
|
| 1111 |
+
aspect-ratio: 4 / 3;
|
| 1112 |
+
object-fit: cover;
|
| 1113 |
+
border-radius: 22px;
|
| 1114 |
+
background: rgba(255, 255, 255, 0.74);
|
| 1115 |
+
border: 1px solid rgba(78, 148, 97, 0.14);
|
| 1116 |
+
}
|
| 1117 |
+
|
| 1118 |
+
.image-library-body {
|
| 1119 |
+
display: grid;
|
| 1120 |
+
gap: 12px;
|
| 1121 |
+
}
|
| 1122 |
+
|
| 1123 |
+
.image-library-path {
|
| 1124 |
+
word-break: break-all;
|
| 1125 |
+
}
|
| 1126 |
+
|
| 1127 |
+
.image-library-actions {
|
| 1128 |
+
align-items: center;
|
| 1129 |
+
}
|
| 1130 |
+
|
| 1131 |
+
.compact-stack-list {
|
| 1132 |
+
gap: 8px;
|
| 1133 |
+
}
|
| 1134 |
+
|
| 1135 |
+
.compact-stack-item {
|
| 1136 |
+
padding: 10px 12px;
|
| 1137 |
+
}
|
app/static/test_assets/clue_1.jpg
ADDED
|
app/static/test_assets/task_1.jpg
ADDED
|
app/static/test_assets/task_2.jpg
ADDED
|
app/static/test_assets/task_3.jpg
ADDED
|
app/static/test_assets/task_4.jpg
ADDED
|
app/templates/activity_detail.html
CHANGED
|
@@ -95,9 +95,9 @@
|
|
| 95 |
class="clue-toggle {% if clue_ready %}is-ready{% endif %}"
|
| 96 |
type="button"
|
| 97 |
data-clue-toggle
|
| 98 |
-
data-clue-url="/media/tasks/
|
| 99 |
-
{% if not task.clue_image_filename %}disabled{% endif %}
|
| 100 |
-
title="{% if clue_ready %}查看线索提示图{% elif task.clue_image_filename %}线索尚未发布{% else %}未设置线索图{% endif %}"
|
| 101 |
>
|
| 102 |
💡
|
| 103 |
</button>
|
|
@@ -105,7 +105,7 @@
|
|
| 105 |
|
| 106 |
<div class="task-page-grid">
|
| 107 |
<div class="task-page-media">
|
| 108 |
-
<img src="/media/tasks/
|
| 109 |
{% if task.clue_release_at %}
|
| 110 |
<div class="release-note">线索发布时间:{{ task.clue_release_at|datetime_local }}</div>
|
| 111 |
{% endif %}
|
|
@@ -136,12 +136,12 @@
|
|
| 136 |
{% if submission %}
|
| 137 |
<div class="submission-preview" data-preview-wrap>
|
| 138 |
<span class="mini-note">当前小组提交预览</span>
|
| 139 |
-
<img src="/media/submissions/{{ submission.id }}" alt="{{ task.title }} 提交预览" data-preview-image />
|
| 140 |
</div>
|
| 141 |
{% else %}
|
| 142 |
<div class="submission-preview is-hidden" data-preview-wrap>
|
| 143 |
<span class="mini-note">当前小组提交预览</span>
|
| 144 |
-
<img
|
| 145 |
</div>
|
| 146 |
{% endif %}
|
| 147 |
|
|
@@ -257,87 +257,81 @@
|
|
| 257 |
|
| 258 |
const refreshStatuses = async () => {
|
| 259 |
try {
|
| 260 |
-
const statusResponse = await fetch('/api/activities/{{ activity.id }}/status', {
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
}
|
| 270 |
-
if (
|
| 271 |
-
|
| 272 |
}
|
| 273 |
-
if (
|
| 274 |
-
|
|
|
|
| 275 |
}
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
const uploadInput = page.querySelector('[data-upload-input]');
|
| 285 |
-
const uploadSubmit = page.querySelector('[data-upload-submit]');
|
| 286 |
-
let badge = '<span class="status-badge">尚未上传</span>';
|
| 287 |
-
if (item.status === 'approved') {
|
| 288 |
-
badge = '<span class="status-badge status-approved">小组打卡成功</span>';
|
| 289 |
-
} else if (item.status === 'rejected') {
|
| 290 |
-
badge = '<span class="status-badge status-rejected">小组打卡失败</span>';
|
| 291 |
-
} else if (item.status === 'pending') {
|
| 292 |
-
badge = '<span class="status-badge">等待审核</span>';
|
| 293 |
-
}
|
| 294 |
-
if (statusWrap) {
|
| 295 |
-
statusWrap.innerHTML = `${badge}<span class="mini-note" data-status-note>${formatStatusNote(item)}</span>`;
|
| 296 |
-
}
|
| 297 |
-
if (feedbackBox) {
|
| 298 |
-
feedbackBox.textContent = item.feedback ? `审核备注:${item.feedback}` : '';
|
| 299 |
-
feedbackBox.classList.toggle('is-hidden', !item.feedback);
|
| 300 |
}
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
}
|
| 310 |
}
|
| 311 |
-
if (uploadInput) {
|
| 312 |
-
uploadInput.disabled = !item.can_upload;
|
| 313 |
-
}
|
| 314 |
-
if (uploadSubmit) {
|
| 315 |
-
uploadSubmit.disabled = !item.can_upload;
|
| 316 |
-
}
|
| 317 |
-
});
|
| 318 |
-
refreshLeaderboard(payload.leaderboard || []);
|
| 319 |
-
}
|
| 320 |
-
|
| 321 |
-
const clueResponse = await fetch('/api/activities/{{ activity.id }}/clues', { headers: { 'X-Requested-With': 'fetch' } });
|
| 322 |
-
if (!clueResponse.ok) return;
|
| 323 |
-
const cluePayload = await clueResponse.json();
|
| 324 |
-
(cluePayload.released_task_ids || []).forEach((taskId) => {
|
| 325 |
-
const stringId = String(taskId);
|
| 326 |
-
const page = pages.find((entry) => entry.dataset.taskId === stringId);
|
| 327 |
-
if (!page) return;
|
| 328 |
-
const button = page.querySelector('[data-clue-toggle]');
|
| 329 |
-
if (!button) return;
|
| 330 |
-
button.classList.add('is-ready');
|
| 331 |
-
button.title = '查看线索提示图';
|
| 332 |
-
if (!seenReleased.has(stringId)) {
|
| 333 |
-
seenReleased.add(stringId);
|
| 334 |
-
page.classList.add('pulse-highlight');
|
| 335 |
-
setTimeout(() => page.classList.remove('pulse-highlight'), 1800);
|
| 336 |
-
if (window.matchMedia('(pointer: coarse)').matches && navigator.vibrate) {
|
| 337 |
-
navigator.vibrate([220, 80, 220]);
|
| 338 |
-
}
|
| 339 |
}
|
| 340 |
});
|
|
|
|
| 341 |
} catch (error) {
|
| 342 |
console.debug('task status refresh skipped', error);
|
| 343 |
}
|
|
@@ -347,4 +341,8 @@
|
|
| 347 |
window.setInterval(refreshStatuses, 10000);
|
| 348 |
})();
|
| 349 |
</script>
|
| 350 |
-
{% endblock %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
class="clue-toggle {% if clue_ready %}is-ready{% endif %}"
|
| 96 |
type="button"
|
| 97 |
data-clue-toggle
|
| 98 |
+
data-clue-url="{{ task.clue_image_url if task.clue_image_url else "/media/tasks/" ~ task.id ~ "/clue" }}"
|
| 99 |
+
{% if not (task.clue_image_url or task.clue_image_filename or task.clue_image_path) %}disabled{% endif %}
|
| 100 |
+
title="{% if clue_ready %}查看线索提示图{% elif task.clue_image_url or task.clue_image_filename or task.clue_image_path %}线索尚未发布{% else %}未设置线索图{% endif %}"
|
| 101 |
>
|
| 102 |
💡
|
| 103 |
</button>
|
|
|
|
| 105 |
|
| 106 |
<div class="task-page-grid">
|
| 107 |
<div class="task-page-media">
|
| 108 |
+
<img src="{{ task.image_url if task.image_url else "/media/tasks/" ~ task.id ~ "/image" }}" alt="{{ task.title }}" class="task-media" loading="lazy" decoding="async" />
|
| 109 |
{% if task.clue_release_at %}
|
| 110 |
<div class="release-note">线索发布时间:{{ task.clue_release_at|datetime_local }}</div>
|
| 111 |
{% endif %}
|
|
|
|
| 136 |
{% if submission %}
|
| 137 |
<div class="submission-preview" data-preview-wrap>
|
| 138 |
<span class="mini-note">当前小组提交预览</span>
|
| 139 |
+
<img src="/media/submissions/{{ submission.id }}" alt="{{ task.title }} 提交预览" data-preview-image loading="lazy" decoding="async" />
|
| 140 |
</div>
|
| 141 |
{% else %}
|
| 142 |
<div class="submission-preview is-hidden" data-preview-wrap>
|
| 143 |
<span class="mini-note">当前小组提交预览</span>
|
| 144 |
+
<img alt="提交预览" data-preview-image loading="lazy" decoding="async" />
|
| 145 |
</div>
|
| 146 |
{% endif %}
|
| 147 |
|
|
|
|
| 257 |
|
| 258 |
const refreshStatuses = async () => {
|
| 259 |
try {
|
| 260 |
+
const statusResponse = await fetch('/api/activities/{{ activity.id }}/status', {
|
| 261 |
+
headers: { 'X-Requested-With': 'fetch' },
|
| 262 |
+
});
|
| 263 |
+
if (!statusResponse.ok) return;
|
| 264 |
+
|
| 265 |
+
const payload = await statusResponse.json();
|
| 266 |
+
const progress = payload.progress || {};
|
| 267 |
+
if (heroProgressPill) {
|
| 268 |
+
heroProgressPill.textContent = `小组完成 ${progress.approved_count || 0}/${progress.total_tasks || 0}`;
|
| 269 |
+
}
|
| 270 |
+
if (approvedProgressChip) {
|
| 271 |
+
approvedProgressChip.textContent = `已完成 ${progress.approved_count || 0}`;
|
| 272 |
+
}
|
| 273 |
+
if (pendingProgressChip) {
|
| 274 |
+
pendingProgressChip.textContent = `待审核 ${progress.pending_count || 0}`;
|
| 275 |
+
}
|
| 276 |
+
if (rejectedProgressChip) {
|
| 277 |
+
rejectedProgressChip.textContent = `被驳回 ${progress.rejected_count || 0}`;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
(payload.tasks || []).forEach((item) => {
|
| 281 |
+
const page = pages.find((entry) => entry.dataset.taskId === String(item.task_id));
|
| 282 |
+
if (!page) return;
|
| 283 |
+
const statusWrap = page.querySelector('[data-status-wrap]');
|
| 284 |
+
const feedbackBox = page.querySelector('[data-feedback-box]');
|
| 285 |
+
const previewWrap = page.querySelector('[data-preview-wrap]');
|
| 286 |
+
const previewImage = page.querySelector('[data-preview-image]');
|
| 287 |
+
const uploadInput = page.querySelector('[data-upload-input]');
|
| 288 |
+
const uploadSubmit = page.querySelector('[data-upload-submit]');
|
| 289 |
+
const clueButton = page.querySelector('[data-clue-toggle]');
|
| 290 |
+
let badge = '<span class="status-badge">尚未上传</span>';
|
| 291 |
+
if (item.status === 'approved') {
|
| 292 |
+
badge = '<span class="status-badge status-approved">小组打卡成功</span>';
|
| 293 |
+
} else if (item.status === 'rejected') {
|
| 294 |
+
badge = '<span class="status-badge status-rejected">小组打卡失败</span>';
|
| 295 |
+
} else if (item.status === 'pending') {
|
| 296 |
+
badge = '<span class="status-badge">等待审核</span>';
|
| 297 |
}
|
| 298 |
+
if (statusWrap) {
|
| 299 |
+
statusWrap.innerHTML = `${badge}<span class="mini-note" data-status-note>${formatStatusNote(item)}</span>`;
|
| 300 |
}
|
| 301 |
+
if (feedbackBox) {
|
| 302 |
+
feedbackBox.textContent = item.feedback ? `审核备注:${item.feedback}` : '';
|
| 303 |
+
feedbackBox.classList.toggle('is-hidden', !item.feedback);
|
| 304 |
}
|
| 305 |
+
if (previewWrap && previewImage) {
|
| 306 |
+
if (item.submission_id) {
|
| 307 |
+
previewWrap.classList.remove('is-hidden');
|
| 308 |
+
const version = encodeURIComponent(item.submitted_at || Date.now());
|
| 309 |
+
previewImage.src = `/media/submissions/${item.submission_id}?ts=${version}`;
|
| 310 |
+
} else {
|
| 311 |
+
previewWrap.classList.add('is-hidden');
|
| 312 |
+
previewImage.src = '';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
}
|
| 314 |
+
}
|
| 315 |
+
if (uploadInput) {
|
| 316 |
+
uploadInput.disabled = !item.can_upload;
|
| 317 |
+
}
|
| 318 |
+
if (uploadSubmit) {
|
| 319 |
+
uploadSubmit.disabled = !item.can_upload;
|
| 320 |
+
}
|
| 321 |
+
if (clueButton && item.clue_released) {
|
| 322 |
+
clueButton.classList.add('is-ready');
|
| 323 |
+
clueButton.title = '查看线索提示图';
|
| 324 |
+
if (!seenReleased.has(String(item.task_id))) {
|
| 325 |
+
seenReleased.add(String(item.task_id));
|
| 326 |
+
page.classList.add('pulse-highlight');
|
| 327 |
+
setTimeout(() => page.classList.remove('pulse-highlight'), 1800);
|
| 328 |
+
if (window.matchMedia('(pointer: coarse)').matches && navigator.vibrate) {
|
| 329 |
+
navigator.vibrate([220, 80, 220]);
|
| 330 |
}
|
| 331 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
}
|
| 333 |
});
|
| 334 |
+
refreshLeaderboard(payload.leaderboard || []);
|
| 335 |
} catch (error) {
|
| 336 |
console.debug('task status refresh skipped', error);
|
| 337 |
}
|
|
|
|
| 341 |
window.setInterval(refreshStatuses, 10000);
|
| 342 |
})();
|
| 343 |
</script>
|
| 344 |
+
{% endblock %}
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
|
app/templates/admin_activities.html
CHANGED
|
@@ -9,7 +9,7 @@
|
|
| 9 |
<h3>发布活动与打卡任务</h3>
|
| 10 |
</div>
|
| 11 |
</div>
|
| 12 |
-
<form method="post" action="/admin/activities"
|
| 13 |
<div class="form-grid cols-2">
|
| 14 |
<label>
|
| 15 |
<span>活动标题</span>
|
|
@@ -41,6 +41,7 @@
|
|
| 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>
|
|
@@ -57,16 +58,16 @@
|
|
| 57 |
<input type="text" name="task_title" required />
|
| 58 |
</label>
|
| 59 |
<label>
|
| 60 |
-
<span>主图</span>
|
| 61 |
-
<input type="
|
| 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="
|
| 70 |
</label>
|
| 71 |
</div>
|
| 72 |
</article>
|
|
@@ -147,3 +148,4 @@
|
|
| 147 |
})();
|
| 148 |
</script>
|
| 149 |
{% endblock %}
|
|
|
|
|
|
| 9 |
<h3>发布活动与打卡任务</h3>
|
| 10 |
</div>
|
| 11 |
</div>
|
| 12 |
+
<form method="post" action="/admin/activities" class="form-stack" id="activity-form">
|
| 13 |
<div class="form-grid cols-2">
|
| 14 |
<label>
|
| 15 |
<span>活动标题</span>
|
|
|
|
| 41 |
<div>
|
| 42 |
<p class="eyebrow">Tasks</p>
|
| 43 |
<h3>任务卡片</h3>
|
| 44 |
+
<p class="mini-note">管理员任务主图和线索图仅支持公开图片链接,不再上传文件;用户打卡图仍会压缩后保存在 Docker 本地目录。</p>
|
| 45 |
</div>
|
| 46 |
<button class="btn btn-secondary" type="button" id="add-task-btn">新增任务卡片</button>
|
| 47 |
</div>
|
|
|
|
| 58 |
<input type="text" name="task_title" required />
|
| 59 |
</label>
|
| 60 |
<label>
|
| 61 |
+
<span>主图图床链接</span>
|
| 62 |
+
<input type="url" name="task_image_url" placeholder="https://..." required />
|
| 63 |
</label>
|
| 64 |
<label class="full-span">
|
| 65 |
<span>任务描述</span>
|
| 66 |
<textarea name="task_description" rows="2"></textarea>
|
| 67 |
</label>
|
| 68 |
<label class="full-span">
|
| 69 |
+
<span>线索图图床链接</span>
|
| 70 |
+
<input type="url" name="task_clue_image_url" placeholder="https://..." />
|
| 71 |
</label>
|
| 72 |
</div>
|
| 73 |
</article>
|
|
|
|
| 148 |
})();
|
| 149 |
</script>
|
| 150 |
{% endblock %}
|
| 151 |
+
|
app/templates/admin_activity_edit.html
CHANGED
|
@@ -6,7 +6,7 @@
|
|
| 6 |
<a class="ghost-link" href="/admin/activities">返回活动管理</a>
|
| 7 |
<p class="eyebrow">Edit Activity</p>
|
| 8 |
<h2>{{ activity.title }}</h2>
|
| 9 |
-
<p class="lead">在这里可以修改活动时间、排行榜可见性、任务内容
|
| 10 |
</div>
|
| 11 |
<div class="hero-badges">
|
| 12 |
<span class="pill">创建人 {{ activity.created_by.display_name }}</span>
|
|
@@ -15,7 +15,7 @@
|
|
| 15 |
</div>
|
| 16 |
</section>
|
| 17 |
|
| 18 |
-
<form method="post" action="/admin/activities/{{ activity.id }}/edit"
|
| 19 |
<section class="glass-card form-panel wide-panel">
|
| 20 |
<div class="section-head">
|
| 21 |
<div>
|
|
@@ -121,16 +121,16 @@
|
|
| 121 |
<input type="text" name="existing_task_title" value="{{ task.title }}" required />
|
| 122 |
</label>
|
| 123 |
<label>
|
| 124 |
-
<span>
|
| 125 |
-
<input type="
|
| 126 |
</label>
|
| 127 |
<label class="full-span">
|
| 128 |
<span>任务描述</span>
|
| 129 |
<textarea name="existing_task_description" rows="2">{{ task.description or '' }}</textarea>
|
| 130 |
</label>
|
| 131 |
<label>
|
| 132 |
-
<span>
|
| 133 |
-
<input type="
|
| 134 |
</label>
|
| 135 |
<label class="checkbox-row align-end-checkbox">
|
| 136 |
<input type="checkbox" name="existing_task_remove_clue" value="{{ task.id }}" />
|
|
@@ -141,12 +141,12 @@
|
|
| 141 |
<div class="editor-preview-grid">
|
| 142 |
<div>
|
| 143 |
<span class="mini-note">当前主图</span>
|
| 144 |
-
<img class="editor-thumb" src="/media/tasks/
|
| 145 |
</div>
|
| 146 |
<div>
|
| 147 |
<span class="mini-note">当前线索图</span>
|
| 148 |
-
{% if task.clue_image_filename %}
|
| 149 |
-
<img class="editor-thumb" src="/media/tasks/
|
| 150 |
{% else %}
|
| 151 |
<div class="empty-thumb">未设置线索图</div>
|
| 152 |
{% endif %}
|
|
@@ -184,16 +184,16 @@
|
|
| 184 |
<input type="text" name="new_task_title" />
|
| 185 |
</label>
|
| 186 |
<label>
|
| 187 |
-
<span>主图</span>
|
| 188 |
-
<input type="
|
| 189 |
</label>
|
| 190 |
<label class="full-span">
|
| 191 |
<span>任务描述</span>
|
| 192 |
<textarea name="new_task_description" rows="2"></textarea>
|
| 193 |
</label>
|
| 194 |
<label class="full-span">
|
| 195 |
-
<span>线索图</span>
|
| 196 |
-
<input type="
|
| 197 |
</label>
|
| 198 |
</div>
|
| 199 |
</article>
|
|
@@ -253,3 +253,4 @@
|
|
| 253 |
})();
|
| 254 |
</script>
|
| 255 |
{% endblock %}
|
|
|
|
|
|
| 6 |
<a class="ghost-link" href="/admin/activities">返回活动管理</a>
|
| 7 |
<p class="eyebrow">Edit Activity</p>
|
| 8 |
<h2>{{ activity.title }}</h2>
|
| 9 |
+
<p class="lead">在这里可以修改活动时间、排行榜可见性、任务内容和图床链接,并删除不再需要的任务。管理员任务图片仅保留链接方式,用户提交图片仍保存在 Docker 本地目录。</p>
|
| 10 |
</div>
|
| 11 |
<div class="hero-badges">
|
| 12 |
<span class="pill">创建人 {{ activity.created_by.display_name }}</span>
|
|
|
|
| 15 |
</div>
|
| 16 |
</section>
|
| 17 |
|
| 18 |
+
<form method="post" action="/admin/activities/{{ activity.id }}/edit" class="form-stack">
|
| 19 |
<section class="glass-card form-panel wide-panel">
|
| 20 |
<div class="section-head">
|
| 21 |
<div>
|
|
|
|
| 121 |
<input type="text" name="existing_task_title" value="{{ task.title }}" required />
|
| 122 |
</label>
|
| 123 |
<label>
|
| 124 |
+
<span>主图图床链接</span>
|
| 125 |
+
<input type="url" name="existing_task_image_url" value="{{ task.image_url or '' }}" placeholder="https://..." required />
|
| 126 |
</label>
|
| 127 |
<label class="full-span">
|
| 128 |
<span>任务描述</span>
|
| 129 |
<textarea name="existing_task_description" rows="2">{{ task.description or '' }}</textarea>
|
| 130 |
</label>
|
| 131 |
<label>
|
| 132 |
+
<span>线索图图床链接</span>
|
| 133 |
+
<input type="url" name="existing_task_clue_image_url" value="{{ task.clue_image_url or '' }}" placeholder="https://..." />
|
| 134 |
</label>
|
| 135 |
<label class="checkbox-row align-end-checkbox">
|
| 136 |
<input type="checkbox" name="existing_task_remove_clue" value="{{ task.id }}" />
|
|
|
|
| 141 |
<div class="editor-preview-grid">
|
| 142 |
<div>
|
| 143 |
<span class="mini-note">当前主图</span>
|
| 144 |
+
<img class="editor-thumb" src="{{ task.image_url if task.image_url else '/media/tasks/' ~ task.id ~ '/image' }}" alt="{{ task.title }} 主图" />
|
| 145 |
</div>
|
| 146 |
<div>
|
| 147 |
<span class="mini-note">当前线索图</span>
|
| 148 |
+
{% if task.clue_image_url or task.clue_image_filename or task.clue_image_path %}
|
| 149 |
+
<img class="editor-thumb" src="{{ task.clue_image_url if task.clue_image_url else '/media/tasks/' ~ task.id ~ '/clue' }}" alt="{{ task.title }} 线索图" />
|
| 150 |
{% else %}
|
| 151 |
<div class="empty-thumb">未设置线索图</div>
|
| 152 |
{% endif %}
|
|
|
|
| 184 |
<input type="text" name="new_task_title" />
|
| 185 |
</label>
|
| 186 |
<label>
|
| 187 |
+
<span>主图图床链接</span>
|
| 188 |
+
<input type="url" name="new_task_image_url" placeholder="https://..." />
|
| 189 |
</label>
|
| 190 |
<label class="full-span">
|
| 191 |
<span>任务描述</span>
|
| 192 |
<textarea name="new_task_description" rows="2"></textarea>
|
| 193 |
</label>
|
| 194 |
<label class="full-span">
|
| 195 |
+
<span>线索图图床链接</span>
|
| 196 |
+
<input type="url" name="new_task_clue_image_url" placeholder="https://..." />
|
| 197 |
</label>
|
| 198 |
</div>
|
| 199 |
</article>
|
|
|
|
| 253 |
})();
|
| 254 |
</script>
|
| 255 |
{% endblock %}
|
| 256 |
+
|
app/templates/admin_admins.html
CHANGED
|
@@ -66,6 +66,7 @@
|
|
| 66 |
<div>
|
| 67 |
<p class="eyebrow">Presence</p>
|
| 68 |
<h3>管理员在线状态</h3>
|
|
|
|
| 69 |
</div>
|
| 70 |
</div>
|
| 71 |
<div class="stack-list" id="admin-presence-list">
|
|
@@ -75,10 +76,17 @@
|
|
| 75 |
<strong>{{ item.name }}</strong>
|
| 76 |
<p class="muted">{{ item.username }} · {{ item.role_label }}</p>
|
| 77 |
</div>
|
| 78 |
-
<span
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
{{ '在线' if item.is_online else '离线' }} · {{ item.last_seen_at }}
|
| 80 |
</span>
|
| 81 |
</div>
|
|
|
|
|
|
|
| 82 |
{% endfor %}
|
| 83 |
</div>
|
| 84 |
</article>
|
|
@@ -88,6 +96,7 @@
|
|
| 88 |
<div>
|
| 89 |
<p class="eyebrow">Presence</p>
|
| 90 |
<h3>所有用户在线状态</h3>
|
|
|
|
| 91 |
</div>
|
| 92 |
</div>
|
| 93 |
<div class="stack-list" id="user-presence-list">
|
|
@@ -97,10 +106,17 @@
|
|
| 97 |
<strong>{{ item.name }}</strong>
|
| 98 |
<p class="muted">{{ item.group_name }}</p>
|
| 99 |
</div>
|
| 100 |
-
<span
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
{{ '在线' if item.is_online else '离线' }} · {{ item.last_seen_at }}
|
| 102 |
</span>
|
| 103 |
</div>
|
|
|
|
|
|
|
| 104 |
{% endfor %}
|
| 105 |
</div>
|
| 106 |
</article>
|
|
@@ -112,45 +128,148 @@
|
|
| 112 |
const userList = document.getElementById('user-presence-list');
|
| 113 |
if (!adminList || !userList) return;
|
| 114 |
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
};
|
| 118 |
|
| 119 |
-
const
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
-
const
|
| 131 |
-
|
| 132 |
-
<
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
const refreshPresence = async () => {
|
|
|
|
|
|
|
| 142 |
try {
|
| 143 |
-
const response = await fetch('/api/admin/presence/overview', {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
if (!response.ok) return;
|
| 145 |
const payload = await response.json();
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
} catch (error) {
|
| 149 |
console.debug('presence refresh skipped', error);
|
|
|
|
|
|
|
| 150 |
}
|
| 151 |
};
|
| 152 |
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
})();
|
| 155 |
</script>
|
| 156 |
-
{% endblock %}
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
<div>
|
| 67 |
<p class="eyebrow">Presence</p>
|
| 68 |
<h3>管理员在线状态</h3>
|
| 69 |
+
<p class="mini-note">在线心跳按 5 秒同步,连续 15 秒未收到心跳时判定为离线,状态文本按秒自动更新。</p>
|
| 70 |
</div>
|
| 71 |
</div>
|
| 72 |
<div class="stack-list" id="admin-presence-list">
|
|
|
|
| 76 |
<strong>{{ item.name }}</strong>
|
| 77 |
<p class="muted">{{ item.username }} · {{ item.role_label }}</p>
|
| 78 |
</div>
|
| 79 |
+
<span
|
| 80 |
+
class="status-badge {% if item.is_online %}status-approved{% else %}status-rejected{% endif %}"
|
| 81 |
+
data-presence-badge
|
| 82 |
+
data-last-seen-ts="{{ item.last_seen_ts or '' }}"
|
| 83 |
+
title="最近心跳 {{ item.last_seen_at }}"
|
| 84 |
+
>
|
| 85 |
{{ '在线' if item.is_online else '离线' }} · {{ item.last_seen_at }}
|
| 86 |
</span>
|
| 87 |
</div>
|
| 88 |
+
{% else %}
|
| 89 |
+
<p class="muted">暂无管理员在线状态。</p>
|
| 90 |
{% endfor %}
|
| 91 |
</div>
|
| 92 |
</article>
|
|
|
|
| 96 |
<div>
|
| 97 |
<p class="eyebrow">Presence</p>
|
| 98 |
<h3>所有用户在线状态</h3>
|
| 99 |
+
<p class="mini-note">仅超级管理员可见,适合现场查看整体在线情况。</p>
|
| 100 |
</div>
|
| 101 |
</div>
|
| 102 |
<div class="stack-list" id="user-presence-list">
|
|
|
|
| 106 |
<strong>{{ item.name }}</strong>
|
| 107 |
<p class="muted">{{ item.group_name }}</p>
|
| 108 |
</div>
|
| 109 |
+
<span
|
| 110 |
+
class="status-badge {% if item.is_online %}status-approved{% else %}status-rejected{% endif %}"
|
| 111 |
+
data-presence-badge
|
| 112 |
+
data-last-seen-ts="{{ item.last_seen_ts or '' }}"
|
| 113 |
+
title="最近心跳 {{ item.last_seen_at }}"
|
| 114 |
+
>
|
| 115 |
{{ '在线' if item.is_online else '离线' }} · {{ item.last_seen_at }}
|
| 116 |
</span>
|
| 117 |
</div>
|
| 118 |
+
{% else %}
|
| 119 |
+
<p class="muted">暂无用户在线状态。</p>
|
| 120 |
{% endfor %}
|
| 121 |
</div>
|
| 122 |
</article>
|
|
|
|
| 128 |
const userList = document.getElementById('user-presence-list');
|
| 129 |
if (!adminList || !userList) return;
|
| 130 |
|
| 131 |
+
let serverTs = Number('{{ presence_server_ts or 0 }}') || Math.floor(Date.now() / 1000);
|
| 132 |
+
let syncClientTs = Date.now();
|
| 133 |
+
let onlineWindowSeconds = Number('{{ online_window_seconds or 15 }}') || 15;
|
| 134 |
+
let snapshotInFlight = false;
|
| 135 |
+
|
| 136 |
+
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
| 137 |
+
'&': '&',
|
| 138 |
+
'<': '<',
|
| 139 |
+
'>': '>',
|
| 140 |
+
'"': '"',
|
| 141 |
+
"'": '''
|
| 142 |
+
}[char]));
|
| 143 |
+
|
| 144 |
+
const estimatedNowTs = () => serverTs + Math.floor((Date.now() - syncClientTs) / 1000);
|
| 145 |
+
|
| 146 |
+
const formatElapsed = (seconds) => {
|
| 147 |
+
if (seconds <= 1) return '刚刚';
|
| 148 |
+
if (seconds < 60) return `${seconds}秒前`;
|
| 149 |
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}分钟前`;
|
| 150 |
+
if (seconds < 86400) {
|
| 151 |
+
const hours = Math.floor(seconds / 3600);
|
| 152 |
+
const minutes = Math.floor((seconds % 3600) / 60);
|
| 153 |
+
return minutes ? `${hours}小时${minutes}分钟前` : `${hours}小时前`;
|
| 154 |
+
}
|
| 155 |
+
const days = Math.floor(seconds / 86400);
|
| 156 |
+
const hours = Math.floor((seconds % 86400) / 3600);
|
| 157 |
+
return hours ? `${days}天${hours}小时前` : `${days}天前`;
|
| 158 |
};
|
| 159 |
|
| 160 |
+
const getStatusMeta = (lastSeenTs) => {
|
| 161 |
+
if (!lastSeenTs) {
|
| 162 |
+
return {
|
| 163 |
+
online: false,
|
| 164 |
+
text: '离线 · 暂无心跳',
|
| 165 |
+
};
|
| 166 |
+
}
|
| 167 |
+
const elapsed = Math.max(0, estimatedNowTs() - lastSeenTs);
|
| 168 |
+
const online = elapsed <= onlineWindowSeconds;
|
| 169 |
+
return {
|
| 170 |
+
online,
|
| 171 |
+
text: `${online ? '在线' : '离线'} · ${formatElapsed(elapsed)}`,
|
| 172 |
+
};
|
| 173 |
+
};
|
| 174 |
|
| 175 |
+
const renderAdminItems = (items) => {
|
| 176 |
+
if (!items.length) {
|
| 177 |
+
adminList.innerHTML = '<p class="muted">暂无管理员在线状态。</p>';
|
| 178 |
+
return;
|
| 179 |
+
}
|
| 180 |
+
adminList.innerHTML = items.map((item) => {
|
| 181 |
+
const status = getStatusMeta(Number(item.last_seen_ts || 0));
|
| 182 |
+
return `
|
| 183 |
+
<div class="stack-item presence-item">
|
| 184 |
+
<div>
|
| 185 |
+
<strong>${escapeHtml(item.name)}</strong>
|
| 186 |
+
<p class="muted">${escapeHtml(item.username)} · ${escapeHtml(item.role_label)}</p>
|
| 187 |
+
</div>
|
| 188 |
+
<span
|
| 189 |
+
class="status-badge ${status.online ? 'status-approved' : 'status-rejected'}"
|
| 190 |
+
data-presence-badge
|
| 191 |
+
data-last-seen-ts="${item.last_seen_ts || ''}"
|
| 192 |
+
title="最近心跳 ${escapeHtml(item.last_seen_at || '-') }"
|
| 193 |
+
>
|
| 194 |
+
${status.text}
|
| 195 |
+
</span>
|
| 196 |
+
</div>`;
|
| 197 |
+
}).join('');
|
| 198 |
+
};
|
| 199 |
+
|
| 200 |
+
const renderUserItems = (items) => {
|
| 201 |
+
if (!items.length) {
|
| 202 |
+
userList.innerHTML = '<p class="muted">暂无用户在线状态。</p>';
|
| 203 |
+
return;
|
| 204 |
+
}
|
| 205 |
+
userList.innerHTML = items.map((item) => {
|
| 206 |
+
const status = getStatusMeta(Number(item.last_seen_ts || 0));
|
| 207 |
+
return `
|
| 208 |
+
<div class="stack-item presence-item">
|
| 209 |
+
<div>
|
| 210 |
+
<strong>${escapeHtml(item.name)}</strong>
|
| 211 |
+
<p class="muted">${escapeHtml(item.group_name)}</p>
|
| 212 |
+
</div>
|
| 213 |
+
<span
|
| 214 |
+
class="status-badge ${status.online ? 'status-approved' : 'status-rejected'}"
|
| 215 |
+
data-presence-badge
|
| 216 |
+
data-last-seen-ts="${item.last_seen_ts || ''}"
|
| 217 |
+
title="最近心跳 ${escapeHtml(item.last_seen_at || '-') }"
|
| 218 |
+
>
|
| 219 |
+
${status.text}
|
| 220 |
+
</span>
|
| 221 |
+
</div>`;
|
| 222 |
+
}).join('');
|
| 223 |
+
};
|
| 224 |
+
|
| 225 |
+
const refreshPresenceBadges = () => {
|
| 226 |
+
if (document.hidden) return;
|
| 227 |
+
document.querySelectorAll('[data-presence-badge]').forEach((badge) => {
|
| 228 |
+
const lastSeenTs = Number(badge.dataset.lastSeenTs || 0);
|
| 229 |
+
const status = getStatusMeta(lastSeenTs);
|
| 230 |
+
badge.textContent = status.text;
|
| 231 |
+
badge.classList.toggle('status-approved', status.online);
|
| 232 |
+
badge.classList.toggle('status-rejected', !status.online);
|
| 233 |
+
});
|
| 234 |
+
};
|
| 235 |
|
| 236 |
const refreshPresence = async () => {
|
| 237 |
+
if (document.hidden || snapshotInFlight) return;
|
| 238 |
+
snapshotInFlight = true;
|
| 239 |
try {
|
| 240 |
+
const response = await fetch('/api/admin/presence/overview', {
|
| 241 |
+
credentials: 'same-origin',
|
| 242 |
+
cache: 'no-store',
|
| 243 |
+
headers: { 'X-Requested-With': 'fetch' },
|
| 244 |
+
});
|
| 245 |
if (!response.ok) return;
|
| 246 |
const payload = await response.json();
|
| 247 |
+
serverTs = Number(payload.server_ts || 0) || Math.floor(Date.now() / 1000);
|
| 248 |
+
syncClientTs = Date.now();
|
| 249 |
+
onlineWindowSeconds = Number(payload.online_window_seconds || 0) || onlineWindowSeconds;
|
| 250 |
+
renderAdminItems(payload.admins || []);
|
| 251 |
+
renderUserItems(payload.users || []);
|
| 252 |
+
refreshPresenceBadges();
|
| 253 |
} catch (error) {
|
| 254 |
console.debug('presence refresh skipped', error);
|
| 255 |
+
} finally {
|
| 256 |
+
snapshotInFlight = false;
|
| 257 |
}
|
| 258 |
};
|
| 259 |
|
| 260 |
+
refreshPresenceBadges();
|
| 261 |
+
refreshPresence();
|
| 262 |
+
window.setInterval(refreshPresenceBadges, 1000);
|
| 263 |
+
window.setInterval(refreshPresence, 5000);
|
| 264 |
+
document.addEventListener('visibilitychange', () => {
|
| 265 |
+
if (!document.hidden) {
|
| 266 |
+
refreshPresence();
|
| 267 |
+
}
|
| 268 |
+
});
|
| 269 |
+
window.addEventListener('focus', refreshPresence);
|
| 270 |
})();
|
| 271 |
</script>
|
| 272 |
+
{% endblock %}
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
|
app/templates/admin_images.html
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends 'base.html' %}
|
| 2 |
+
|
| 3 |
+
{% block content %}
|
| 4 |
+
<section class="hero-card">
|
| 5 |
+
<div>
|
| 6 |
+
<p class="eyebrow">Docker Images</p>
|
| 7 |
+
<h2>Docker 目录图片管理</h2>
|
| 8 |
+
<p class="lead">这里会统一展示当前 Docker 本地目录中的用户提交图,以及历史遗留的任务/线索本地图,方便集中预览、下载与清理。新建任务主图与线索图现在使用外部图片链接,不再上传到这里。</p>
|
| 9 |
+
</div>
|
| 10 |
+
<div class="hero-badges">
|
| 11 |
+
<span class="pill">总图片 {{ summary.total_count }}</span>
|
| 12 |
+
<span class="pill">系统引用 {{ summary.referenced_count }}</span>
|
| 13 |
+
<span class="pill">孤立图片 {{ summary.orphan_count }}</span>
|
| 14 |
+
</div>
|
| 15 |
+
</section>
|
| 16 |
+
|
| 17 |
+
<section class="glass-card form-panel wide-panel">
|
| 18 |
+
<div class="section-head compact-head">
|
| 19 |
+
<div>
|
| 20 |
+
<p class="eyebrow">Storage Root</p>
|
| 21 |
+
<h3>图片目录</h3>
|
| 22 |
+
<p class="mini-note">{{ summary.docker_root }}</p>
|
| 23 |
+
</div>
|
| 24 |
+
<form method="get" action="/admin/images" class="inline-form inline-form-wide">
|
| 25 |
+
<label>
|
| 26 |
+
<span>筛选范围</span>
|
| 27 |
+
<select name="scope">
|
| 28 |
+
<option value="all" {% if scope == 'all' %}selected{% endif %}>全部图片</option>
|
| 29 |
+
<option value="tasks" {% if scope == 'tasks' %}selected{% endif %}>历史任务与线索图</option>
|
| 30 |
+
<option value="submissions" {% if scope == 'submissions' %}selected{% endif %}>用户提交图</option>
|
| 31 |
+
<option value="referenced" {% if scope == 'referenced' %}selected{% endif %}>系统引用中</option>
|
| 32 |
+
<option value="orphan" {% if scope == 'orphan' %}selected{% endif %}>孤立图片</option>
|
| 33 |
+
</select>
|
| 34 |
+
</label>
|
| 35 |
+
<button class="btn btn-secondary" type="submit">应用筛选</button>
|
| 36 |
+
</form>
|
| 37 |
+
</div>
|
| 38 |
+
</section>
|
| 39 |
+
|
| 40 |
+
<section class="image-library-grid">
|
| 41 |
+
{% for item in image_items %}
|
| 42 |
+
<article class="glass-card image-library-card">
|
| 43 |
+
<img class="image-library-thumb" src="{{ item.preview_url }}" alt="{{ item.file_name }}" />
|
| 44 |
+
<div class="image-library-body">
|
| 45 |
+
<strong>{{ item.file_name }}</strong>
|
| 46 |
+
<p class="muted image-library-path">{{ item.relative_path }}</p>
|
| 47 |
+
<div class="chip-row">
|
| 48 |
+
<span class="chip">{{ item.category }}</span>
|
| 49 |
+
<span class="chip">{{ item.size_label }}</span>
|
| 50 |
+
<span class="chip">{{ item.modified_at }}</span>
|
| 51 |
+
<span class="status-badge {% if item.is_referenced %}status-approved{% else %}status-rejected{% endif %}">
|
| 52 |
+
{{ '已引用' if item.is_referenced else '孤立图片' }}
|
| 53 |
+
</span>
|
| 54 |
+
</div>
|
| 55 |
+
<div class="stack-list compact-stack-list">
|
| 56 |
+
{% for label in item.reference_labels %}
|
| 57 |
+
<div class="stack-item stack-item-block compact-stack-item">
|
| 58 |
+
<p class="muted">{{ label }}</p>
|
| 59 |
+
</div>
|
| 60 |
+
{% else %}
|
| 61 |
+
<p class="muted">当前没有系统记录引用这张图片,可以在确认后清理。</p>
|
| 62 |
+
{% endfor %}
|
| 63 |
+
</div>
|
| 64 |
+
<div class="card-footer image-library-actions">
|
| 65 |
+
<a class="btn btn-ghost small-btn" href="{{ item.download_url }}">下载图片</a>
|
| 66 |
+
{% if not item.is_referenced %}
|
| 67 |
+
<form method="post" action="/admin/images/delete" class="inline-form">
|
| 68 |
+
<input type="hidden" name="relative_path" value="{{ item.relative_path }}" />
|
| 69 |
+
<input type="hidden" name="scope" value="{{ scope }}" />
|
| 70 |
+
<button class="btn btn-danger small-btn" type="submit" onclick="return confirm('确认删除这张未被系统引用的图片吗?');">删除图片</button>
|
| 71 |
+
</form>
|
| 72 |
+
{% endif %}
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
</article>
|
| 76 |
+
{% else %}
|
| 77 |
+
<article class="glass-card empty-state">
|
| 78 |
+
<h3>当前筛选下没有图片</h3>
|
| 79 |
+
<p>可以切换筛选范围,或者等待用户上传新的打卡图片。</p>
|
| 80 |
+
</article>
|
| 81 |
+
{% endfor %}
|
| 82 |
+
</section>
|
| 83 |
+
{% endblock %}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
|
app/templates/base.html
CHANGED
|
@@ -4,6 +4,7 @@
|
|
| 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 %}">
|
|
@@ -27,6 +28,7 @@
|
|
| 27 |
<a href="/admin/users">用户</a>
|
| 28 |
<a href="/admin/groups">小组</a>
|
| 29 |
<a href="/admin/activities">活动</a>
|
|
|
|
| 30 |
<a href="/admin/reviews">审核</a>
|
| 31 |
<a href="/account">账号中心</a>
|
| 32 |
{% if admin.role == 'superadmin' %}
|
|
@@ -47,13 +49,76 @@
|
|
| 47 |
{% if user or admin %}
|
| 48 |
<script>
|
| 49 |
(() => {
|
| 50 |
-
const
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
};
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
})();
|
| 56 |
</script>
|
| 57 |
{% endif %}
|
| 58 |
</body>
|
| 59 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="icon" href="/static/favicon.ico" sizes="any" />
|
| 8 |
<link rel="stylesheet" href="/static/style.css" />
|
| 9 |
</head>
|
| 10 |
<body class="{% if admin %}admin-mode{% else %}user-mode{% endif %}">
|
|
|
|
| 28 |
<a href="/admin/users">用户</a>
|
| 29 |
<a href="/admin/groups">小组</a>
|
| 30 |
<a href="/admin/activities">活动</a>
|
| 31 |
+
<a href="/admin/images">图片</a>
|
| 32 |
<a href="/admin/reviews">审核</a>
|
| 33 |
<a href="/account">账号中心</a>
|
| 34 |
{% if admin.role == 'superadmin' %}
|
|
|
|
| 49 |
{% if user or admin %}
|
| 50 |
<script>
|
| 51 |
(() => {
|
| 52 |
+
const PRESENCE_INTERVAL_MS = 5000;
|
| 53 |
+
const INITIAL_JITTER_MS = 1200;
|
| 54 |
+
let timerId = null;
|
| 55 |
+
let inFlight = false;
|
| 56 |
+
|
| 57 |
+
const shouldPing = () => !document.hidden && navigator.onLine;
|
| 58 |
+
|
| 59 |
+
const schedule = (delay = PRESENCE_INTERVAL_MS) => {
|
| 60 |
+
if (timerId) {
|
| 61 |
+
window.clearTimeout(timerId);
|
| 62 |
+
}
|
| 63 |
+
timerId = window.setTimeout(runPing, delay);
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
const runPing = async () => {
|
| 67 |
+
if (!shouldPing()) {
|
| 68 |
+
timerId = null;
|
| 69 |
+
return;
|
| 70 |
+
}
|
| 71 |
+
if (inFlight) {
|
| 72 |
+
schedule(1000);
|
| 73 |
+
return;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
inFlight = true;
|
| 77 |
+
try {
|
| 78 |
+
await fetch('/api/presence/ping', {
|
| 79 |
+
method: 'POST',
|
| 80 |
+
keepalive: true,
|
| 81 |
+
credentials: 'same-origin',
|
| 82 |
+
cache: 'no-store',
|
| 83 |
+
headers: { 'X-Requested-With': 'fetch' },
|
| 84 |
+
});
|
| 85 |
+
} catch (error) {
|
| 86 |
+
console.debug('presence ping skipped', error);
|
| 87 |
+
} finally {
|
| 88 |
+
inFlight = false;
|
| 89 |
+
schedule();
|
| 90 |
+
}
|
| 91 |
};
|
| 92 |
+
|
| 93 |
+
document.addEventListener('visibilitychange', () => {
|
| 94 |
+
if (document.hidden) {
|
| 95 |
+
if (timerId) {
|
| 96 |
+
window.clearTimeout(timerId);
|
| 97 |
+
timerId = null;
|
| 98 |
+
}
|
| 99 |
+
return;
|
| 100 |
+
}
|
| 101 |
+
runPing();
|
| 102 |
+
});
|
| 103 |
+
window.addEventListener('focus', () => {
|
| 104 |
+
if (!document.hidden) {
|
| 105 |
+
runPing();
|
| 106 |
+
}
|
| 107 |
+
});
|
| 108 |
+
window.addEventListener('online', runPing);
|
| 109 |
+
schedule(500 + Math.floor(Math.random() * INITIAL_JITTER_MS));
|
| 110 |
})();
|
| 111 |
</script>
|
| 112 |
{% endif %}
|
| 113 |
</body>
|
| 114 |
+
</html>
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
|