cacode commited on
Commit
3d6b7f2
·
verified ·
1 Parent(s): 71c586c

Upload 67 files

Browse files
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
- upload_root: Path = ROOT_DIR / os.getenv("UPLOAD_ROOT", "data/submissions")
 
 
 
 
 
 
 
 
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 = local_now()
50
  db.add(admin)
51
  elif user_id:
52
  user = db.get(User, user_id)
53
  if user:
54
- user.last_seen_at = local_now()
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
- image_data: Mapped[bytes] = mapped_column(LONGBLOB, deferred=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_data: Mapped[Optional[bytes]] = mapped_column(
102
- LONGBLOB, deferred=True, nullable=True
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 compress_to_limit, read_and_validate_upload
 
 
 
 
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
- primary_upload = task_images[index] if index < len(task_images) else None
249
- clue_upload = task_clue_images[index] if index < len(task_clue_images) else None
 
 
 
 
 
 
 
 
250
 
251
- if not task_title and (not primary_upload or not getattr(primary_upload, "filename", "")):
252
  continue
253
- if not task_title or not primary_upload or not getattr(primary_upload, "filename", ""):
254
- raise ValueError(f"第 {index + 1} 个新增任务需要完整填写标题并上传主图。")
255
-
256
- primary_raw = await read_and_validate_upload(primary_upload)
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
- "image_data": primary_bytes,
272
- "image_mime": primary_mime,
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
- {**serialize_presence_entry(item.display_name, item.role, item.last_seen_at, now), "username": item.username}
 
 
 
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
- "name": item.display_name,
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
- "name": item.full_name,
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
- "task_image",
755
- "task_clue_image",
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(activity=activity, **payload)
 
 
 
 
 
 
 
 
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
- existing_task_images = form.getlist("existing_task_image")
806
- existing_task_clue_images = form.getlist("existing_task_clue_image")
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
- primary_upload = existing_task_images[index] if index < len(existing_task_images) else None
836
- clue_upload = existing_task_clue_images[index] if index < len(existing_task_clue_images) else None
837
-
838
- if primary_upload and getattr(primary_upload, "filename", ""):
839
- primary_raw = await read_and_validate_upload(primary_upload)
840
- primary_bytes, primary_mime = compress_to_limit(primary_raw, 200 * 1024)
841
- task.image_data = primary_bytes
842
- task.image_mime = primary_mime
843
- task.image_filename = primary_upload.filename
844
-
845
- if clue_upload and getattr(clue_upload, "filename", ""):
846
- clue_raw = await read_and_validate_upload(clue_upload)
847
- clue_bytes, clue_mime = compress_to_limit(clue_raw, 200 * 1024)
848
- task.clue_image_data = clue_bytes
849
- task.clue_image_mime = clue_mime
850
- task.clue_image_filename = clue_upload.filename
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
- "new_task_image",
863
- "new_task_clue_image",
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(activity=activity, **payload)
 
 
 
 
 
 
 
 
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.web import add_flash, redirect, render
 
12
 
13
 
14
  router = APIRouter()
@@ -163,8 +164,31 @@ def change_password(
163
  return redirect("/account")
164
 
165
 
166
- @router.get("/api/presence/ping")
167
  def presence_ping(request: Request, db: Session = Depends(get_db)):
168
- if not get_current_admin(request, db) and not get_current_user(request, db):
 
 
169
  return JSONResponse({"ok": False}, status_code=401)
170
- return JSONResponse({"ok": True})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, Response
6
- from fastapi.responses import FileResponse
7
  from sqlalchemy.orm import Session, joinedload
8
 
9
  from app.auth import get_current_admin, get_current_user
 
10
  from app.database import get_db
11
- from app.models import Submission, Task, User
 
12
  from app.web import local_now
13
 
14
 
15
  router = APIRouter()
16
 
17
 
 
 
 
 
 
 
 
 
 
18
  @router.get("/media/tasks/{task_id}/image")
19
  def task_image(task_id: int, request: Request, db: Session = Depends(get_db)):
20
  if not get_current_user(request, db) and not get_current_admin(request, db):
21
  raise HTTPException(status_code=401, detail="Unauthorized")
22
 
23
  task = db.get(Task, task_id)
24
- if not task or not task.image_data:
25
  raise HTTPException(status_code=404, detail="Image not found")
26
- return Response(content=task.image_data, media_type=task.image_mime)
 
 
 
27
 
28
 
29
  @router.get("/media/tasks/{task_id}/clue")
@@ -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 or not task.clue_image_data:
38
  raise HTTPException(status_code=404, detail="Clue image not found")
39
  if not admin and (not task.clue_release_at or local_now() < task.clue_release_at):
40
  raise HTTPException(status_code=403, detail="Clue not released")
41
- return Response(content=task.clue_image_data, media_type=task.clue_image_mime)
 
 
 
42
 
43
 
44
  @router.get("/media/submissions/{submission_id}")
@@ -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 = Path(submission.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 Response(content=file_path.read_bytes(), media_type=submission.mime_type)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, 2 * 1024 * 1024)
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", "clue_image_data", "clue_image_data LONGBLOB NULL")
 
 
 
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
- connection.execute(
86
- text(
87
- """
88
- UPDATE submissions AS s
89
- JOIN users AS u ON s.user_id = u.id
90
- SET s.group_id = u.group_id
91
- WHERE s.group_id IS NULL
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
- scale = 1.0
29
- quality = 92
30
 
31
- while scale >= 0.28:
32
  resized = image
33
  if scale < 1.0:
34
  new_size = (
35
- max(240, int(image.width * scale)),
36
- max(240, int(image.height * scale)),
37
  )
38
  resized = image.resize(new_size, Image.Resampling.LANCZOS)
39
 
40
- current_quality = quality
41
- while current_quality >= 28:
42
- buffer = io.BytesIO()
43
- resized.save(buffer, format="JPEG", quality=current_quality, optimize=True)
44
- payload = buffer.getvalue()
 
45
  if len(payload) <= limit_bytes:
46
- return payload, "image/jpeg"
47
- current_quality -= 6
48
- scale -= 0.08
 
 
 
 
49
 
50
  raise ValueError(f"图片压缩后仍超过限制,请上传更清晰度适中的图片(限制 {limit_bytes // 1024}KB)。")
51
 
@@ -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
- ONLINE_WINDOW_SECONDS = 75
 
 
 
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/{{ task.id }}/clue"
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/{{ task.id }}/image" alt="{{ task.title }}" class="task-media" />
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 src="" alt="提交预览" data-preview-image />
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', { headers: { 'X-Requested-With': 'fetch' } });
261
- if (statusResponse.ok) {
262
- const payload = await statusResponse.json();
263
- const progress = payload.progress || {};
264
- if (heroProgressPill) {
265
- heroProgressPill.textContent = `小组完成 ${progress.approved_count || 0}/${progress.total_tasks || 0}`;
266
- }
267
- if (approvedProgressChip) {
268
- approvedProgressChip.textContent = `完成 ${progress.approved_count || 0}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  }
270
- if (pendingProgressChip) {
271
- pendingProgressChip.textContent = `待审核 ${progress.pending_count || 0}`;
272
  }
273
- if (rejectedProgressChip) {
274
- rejectedProgressChip.textContent = `被驳回 ${progress.rejected_count || 0}`;
 
275
  }
276
-
277
- (payload.tasks || []).forEach((item) => {
278
- const page = pages.find((entry) => entry.dataset.taskId === String(item.task_id));
279
- if (!page) return;
280
- const statusWrap = page.querySelector('[data-status-wrap]');
281
- const feedbackBox = page.querySelector('[data-feedback-box]');
282
- const previewWrap = page.querySelector('[data-preview-wrap]');
283
- const previewImage = page.querySelector('[data-preview-image]');
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
- if (previewWrap && previewImage) {
302
- if (item.submission_id) {
303
- previewWrap.classList.remove('is-hidden');
304
- const version = encodeURIComponent(item.submitted_at || Date.now());
305
- previewImage.src = `/media/submissions/${item.submission_id}?ts=${version}`;
306
- } else {
307
- previewWrap.classList.add('is-hidden');
308
- previewImage.src = '';
 
 
 
 
 
 
 
 
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" enctype="multipart/form-data" class="form-stack" id="activity-form">
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="file" name="task_image" accept="image/*" required />
62
  </label>
63
  <label class="full-span">
64
  <span>任务描述</span>
65
  <textarea name="task_description" rows="2"></textarea>
66
  </label>
67
  <label class="full-span">
68
- <span>线索图</span>
69
- <input type="file" name="task_clue_image" accept="image/*" />
70
  </label>
71
  </div>
72
  </article>
@@ -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">在这里可以修改活动时间、排行榜可见性、任务内容、任务,并删除不再需要的任务。</p>
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" enctype="multipart/form-data" class="form-stack">
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>替换主图</span>
125
- <input type="file" name="existing_task_image" accept="image/*" />
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="file" name="existing_task_clue_image" accept="image/*" />
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/{{ task.id }}/image" alt="{{ task.title }} 主图" />
145
  </div>
146
  <div>
147
  <span class="mini-note">当前线索图</span>
148
- {% if task.clue_image_filename %}
149
- <img class="editor-thumb" src="/media/tasks/{{ task.id }}/clue" alt="{{ task.title }} 线索图" />
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="file" name="new_task_image" accept="image/*" />
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="file" name="new_task_clue_image" accept="image/*" />
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 class="status-badge {% if item.is_online %}status-approved{% else %}status-rejected{% endif %}">
 
 
 
 
 
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 class="status-badge {% if item.is_online %}status-approved{% else %}status-rejected{% endif %}">
 
 
 
 
 
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
- const renderItems = (container, items, formatter) => {
116
- container.innerHTML = items.map(formatter).join('');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  };
118
 
119
- const adminFormatter = (item) => `
120
- <div class="stack-item presence-item">
121
- <div>
122
- <strong>${item.name}</strong>
123
- <p class="muted">${item.username} · ${item.role}</p>
124
- </div>
125
- <span class="status-badge ${item.is_online ? 'status-approved' : 'status-rejected'}">
126
- ${item.is_online ? '在线' : '离线'} · ${item.last_seen_at}
127
- </span>
128
- </div>`;
 
 
 
 
129
 
130
- const userFormatter = (item) => `
131
- <div class="stack-item presence-item">
132
- <div>
133
- <strong>${item.name}</strong>
134
- <p class="muted">${item.group_name}</p>
135
- </div>
136
- <span class="status-badge ${item.is_online ? 'status-approved' : 'status-rejected'}">
137
- ${item.is_online ? '在线' : '离线'} · ${item.last_seen_at}
138
- </span>
139
- </div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
  const refreshPresence = async () => {
 
 
142
  try {
143
- const response = await fetch('/api/admin/presence/overview', { headers: { 'X-Requested-With': 'fetch' } });
 
 
 
 
144
  if (!response.ok) return;
145
  const payload = await response.json();
146
- renderItems(adminList, payload.admins || [], adminFormatter);
147
- renderItems(userList, payload.users || [], userFormatter);
 
 
 
 
148
  } catch (error) {
149
  console.debug('presence refresh skipped', error);
 
 
150
  }
151
  };
152
 
153
- window.setInterval(refreshPresence, 15000);
 
 
 
 
 
 
 
 
 
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
+ '&': '&amp;',
138
+ '<': '&lt;',
139
+ '>': '&gt;',
140
+ '"': '&quot;',
141
+ "'": '&#39;'
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 ping = () => {
51
- fetch('/api/presence/ping', { headers: { 'X-Requested-With': 'fetch' } }).catch(() => null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  };
53
- ping();
54
- window.setInterval(ping, 20000);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+