cacode commited on
Commit
02a8414
·
verified ·
1 Parent(s): 3d06737

Upload 60 files

Browse files
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/main.py CHANGED
@@ -9,9 +9,10 @@ from starlette.middleware.sessions import SessionMiddleware
9
  from app.auth import get_current_admin, get_current_user
10
  from app.config import ROOT_DIR, settings
11
  from app.database import SessionLocal
 
12
  from app.routes import admin, auth, media, user
13
  from app.services.bootstrap import initialize_database, seed_super_admin
14
- from app.web import redirect, templates
15
 
16
 
17
  app = FastAPI(title=settings.app_name)
@@ -29,6 +30,35 @@ def on_startup() -> None:
29
  db.close()
30
 
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  @app.get("/")
33
  def index(request: Request):
34
  db = SessionLocal()
 
9
  from app.auth import get_current_admin, get_current_user
10
  from app.config import ROOT_DIR, settings
11
  from app.database import SessionLocal
12
+ from app.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
 
18
  app = FastAPI(title=settings.app_name)
 
30
  db.close()
31
 
32
 
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")
40
+ user_id = request.session.get("user_id")
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:
58
+ db.close()
59
+ return response
60
+
61
+
62
  @app.get("/")
63
  def index(request: Request):
64
  db = SessionLocal()
app/models.py CHANGED
@@ -26,10 +26,16 @@ class Admin(TimestampMixin, Base):
26
  password_hash: Mapped[str] = mapped_column(String(255))
27
  role: Mapped[str] = mapped_column(String(32), default="admin")
28
  is_active: Mapped[bool] = mapped_column(Boolean, default=True)
 
29
 
30
  created_activities: Mapped[list["Activity"]] = relationship(back_populates="created_by")
31
  reviewed_submissions: Mapped[list["Submission"]] = relationship(
32
- back_populates="reviewed_by"
 
 
 
 
 
33
  )
34
 
35
 
@@ -41,6 +47,7 @@ class Group(TimestampMixin, Base):
41
  max_members: Mapped[int] = mapped_column(Integer, default=6)
42
 
43
  members: Mapped[list["User"]] = relationship(back_populates="group")
 
44
 
45
 
46
  class User(TimestampMixin, Base):
@@ -52,6 +59,7 @@ class User(TimestampMixin, Base):
52
  password_hash: Mapped[str] = mapped_column(String(255))
53
  is_active: Mapped[bool] = mapped_column(Boolean, default=True)
54
  group_id: Mapped[Optional[int]] = mapped_column(ForeignKey("groups.id"), nullable=True)
 
55
 
56
  group: Mapped[Optional["Group"]] = relationship(back_populates="members")
57
  submissions: Mapped[list["Submission"]] = relationship(back_populates="user")
@@ -90,7 +98,9 @@ class Task(TimestampMixin, Base):
90
  image_mime: Mapped[str] = mapped_column(String(120), default="image/jpeg")
91
  image_filename: Mapped[str] = mapped_column(String(255), default="task.jpg")
92
 
93
- clue_image_data: Mapped[Optional[bytes]] = mapped_column(LONGBLOB, deferred=True, nullable=True)
 
 
94
  clue_image_mime: Mapped[Optional[str]] = mapped_column(String(120), nullable=True)
95
  clue_image_filename: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
96
  clue_release_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
@@ -103,11 +113,14 @@ class Task(TimestampMixin, Base):
103
 
104
  class Submission(TimestampMixin, Base):
105
  __tablename__ = "submissions"
106
- __table_args__ = (UniqueConstraint("user_id", "task_id", name="uq_user_task_submission"),)
 
 
107
 
108
  id: Mapped[int] = mapped_column(primary_key=True)
109
  task_id: Mapped[int] = mapped_column(ForeignKey("tasks.id"))
110
  user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
 
111
 
112
  stored_filename: Mapped[str] = mapped_column(String(255))
113
  original_filename: Mapped[str] = mapped_column(String(255))
@@ -120,9 +133,23 @@ class Submission(TimestampMixin, Base):
120
  reviewed_by_id: Mapped[Optional[int]] = mapped_column(
121
  ForeignKey("admins.id"), nullable=True
122
  )
 
 
 
 
123
  reviewed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
124
  approved_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
125
 
126
  task: Mapped["Task"] = relationship(back_populates="submissions")
127
  user: Mapped["User"] = relationship(back_populates="submissions")
128
- reviewed_by: Mapped[Optional["Admin"]] = relationship(back_populates="reviewed_submissions")
 
 
 
 
 
 
 
 
 
 
 
26
  password_hash: Mapped[str] = mapped_column(String(255))
27
  role: Mapped[str] = mapped_column(String(32), default="admin")
28
  is_active: Mapped[bool] = mapped_column(Boolean, default=True)
29
+ last_seen_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
30
 
31
  created_activities: Mapped[list["Activity"]] = relationship(back_populates="created_by")
32
  reviewed_submissions: Mapped[list["Submission"]] = relationship(
33
+ back_populates="reviewed_by",
34
+ foreign_keys="Submission.reviewed_by_id",
35
+ )
36
+ assigned_submissions: Mapped[list["Submission"]] = relationship(
37
+ back_populates="assigned_admin",
38
+ foreign_keys="Submission.assigned_admin_id",
39
  )
40
 
41
 
 
47
  max_members: Mapped[int] = mapped_column(Integer, default=6)
48
 
49
  members: Mapped[list["User"]] = relationship(back_populates="group")
50
+ submissions: Mapped[list["Submission"]] = relationship(back_populates="group")
51
 
52
 
53
  class User(TimestampMixin, Base):
 
59
  password_hash: Mapped[str] = mapped_column(String(255))
60
  is_active: Mapped[bool] = mapped_column(Boolean, default=True)
61
  group_id: Mapped[Optional[int]] = mapped_column(ForeignKey("groups.id"), nullable=True)
62
+ last_seen_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
63
 
64
  group: Mapped[Optional["Group"]] = relationship(back_populates="members")
65
  submissions: Mapped[list["Submission"]] = relationship(back_populates="user")
 
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)
 
113
 
114
  class Submission(TimestampMixin, Base):
115
  __tablename__ = "submissions"
116
+ __table_args__ = (
117
+ UniqueConstraint("group_id", "task_id", name="uq_group_task_submission"),
118
+ )
119
 
120
  id: Mapped[int] = mapped_column(primary_key=True)
121
  task_id: Mapped[int] = mapped_column(ForeignKey("tasks.id"))
122
  user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
123
+ group_id: Mapped[Optional[int]] = mapped_column(ForeignKey("groups.id"), nullable=True)
124
 
125
  stored_filename: Mapped[str] = mapped_column(String(255))
126
  original_filename: Mapped[str] = mapped_column(String(255))
 
133
  reviewed_by_id: Mapped[Optional[int]] = mapped_column(
134
  ForeignKey("admins.id"), nullable=True
135
  )
136
+ assigned_admin_id: Mapped[Optional[int]] = mapped_column(
137
+ ForeignKey("admins.id"), nullable=True
138
+ )
139
+ assigned_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
140
  reviewed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
141
  approved_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
142
 
143
  task: Mapped["Task"] = relationship(back_populates="submissions")
144
  user: Mapped["User"] = relationship(back_populates="submissions")
145
+ group: Mapped[Optional["Group"]] = relationship(back_populates="submissions")
146
+ reviewed_by: Mapped[Optional["Admin"]] = relationship(
147
+ back_populates="reviewed_submissions",
148
+ foreign_keys=[reviewed_by_id],
149
+ )
150
+ assigned_admin: Mapped[Optional["Admin"]] = relationship(
151
+ back_populates="assigned_submissions",
152
+ foreign_keys=[assigned_admin_id],
153
+ )
154
+
155
+
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
@@ -6,13 +6,16 @@ from datetime import datetime, timedelta
6
  from pathlib import Path
7
 
8
  from fastapi import APIRouter, Depends, HTTPException, Request
9
- from fastapi.responses import StreamingResponse
10
  from sqlalchemy.orm import Session, joinedload
11
 
12
  from app.database import get_db
13
  from app.models import Activity, Admin, Group, Submission, Task, User
14
  from app.security import hash_password
15
  from app.services.images import compress_to_limit, read_and_validate_upload
 
 
 
16
  from app.web import add_flash, local_now, redirect, render
17
 
18
 
@@ -23,6 +26,9 @@ def require_admin(request: Request, db: Session) -> Admin | None:
23
  admin = db.query(Admin).filter(Admin.id == (request.session.get("admin_id") or 0)).first()
24
  if not admin or not admin.is_active:
25
  return None
 
 
 
26
  return admin
27
 
28
 
@@ -46,14 +52,20 @@ def parse_optional_group(group_id_value: str | None, db: Session) -> Group | Non
46
  )
47
 
48
 
49
- def ensure_group_capacity(group: Group | None, current_user: User | None = None) -> None:
50
  if not group:
51
  return
 
52
  current_count = len(group.members)
53
- if current_user and current_user.group_id == group.id:
54
- current_count -= 1
55
- if current_count >= group.max_members:
56
- raise ValueError(f"{group.name} 已满员,请调整人数上限或选择其他小组。")
 
 
 
 
 
57
 
58
 
59
  def parse_activity_fields(form) -> dict:
@@ -124,6 +136,97 @@ def cleanup_submission_files(submissions: list[Submission]) -> None:
124
  file_path.unlink(missing_ok=True)
125
 
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  async def collect_task_payloads(
128
  form,
129
  title_key: str,
@@ -182,12 +285,17 @@ def admin_dashboard(request: Request, db: Session = Depends(get_db)):
182
  if not admin:
183
  return redirect("/admin")
184
 
 
 
 
185
  stats = {
186
- "user_count": db.query(User).count(),
187
  "group_count": db.query(Group).count(),
188
  "activity_count": db.query(Activity).count(),
189
  "pending_count": db.query(Submission).filter(Submission.status == "pending").count(),
190
- "admin_count": db.query(Admin).count(),
 
 
191
  }
192
  recent_activities = (
193
  db.query(Activity)
@@ -196,6 +304,19 @@ def admin_dashboard(request: Request, db: Session = Depends(get_db)):
196
  .limit(5)
197
  .all()
198
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  return render(
200
  request,
201
  "admin_dashboard.html",
@@ -204,6 +325,7 @@ def admin_dashboard(request: Request, db: Session = Depends(get_db)):
204
  "admin": admin,
205
  "stats": stats,
206
  "recent_activities": recent_activities,
 
207
  },
208
  )
209
 
@@ -220,8 +342,7 @@ def admin_users(request: Request, db: Session = Depends(get_db)):
220
  request,
221
  "admin_users.html",
222
  {"page_title": "用户管理", "admin": admin, "users": users, "groups": groups},
223
- )
224
-
225
 
226
  @router.post("/admin/users")
227
  async def create_user(request: Request, db: Session = Depends(get_db)):
@@ -243,7 +364,7 @@ async def create_user(request: Request, db: Session = Depends(get_db)):
243
  return redirect("/admin/users")
244
 
245
  try:
246
- ensure_group_capacity(group)
247
  except ValueError as exc:
248
  add_flash(request, "error", str(exc))
249
  return redirect("/admin/users")
@@ -260,6 +381,108 @@ async def create_user(request: Request, db: Session = Depends(get_db)):
260
  return redirect("/admin/users")
261
 
262
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  @router.post("/admin/users/{user_id}/group")
264
  async def assign_user_group(user_id: int, request: Request, db: Session = Depends(get_db)):
265
  admin = require_admin(request, db)
@@ -273,7 +496,7 @@ async def assign_user_group(user_id: int, request: Request, db: Session = Depend
273
  form = await request.form()
274
  group = parse_optional_group(form.get("group_id"), db)
275
  try:
276
- ensure_group_capacity(group, current_user=user)
277
  except ValueError as exc:
278
  add_flash(request, "error", str(exc))
279
  return redirect("/admin/users")
@@ -292,11 +515,64 @@ def admin_admins(request: Request, db: Session = Depends(get_db)):
292
  add_flash(request, "error", "只有超级管理员可以管理管理员账号。")
293
  return redirect("/admin/dashboard")
294
 
 
295
  admins = db.query(Admin).order_by(Admin.created_at.desc()).all()
 
 
 
 
 
 
 
 
 
 
 
 
296
  return render(
297
  request,
298
  "admin_admins.html",
299
- {"page_title": "管理员管理", "admin": admin, "admins": admins},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  )
301
 
302
 
@@ -329,8 +605,7 @@ async def create_admin(request: Request, db: Session = Depends(get_db)):
329
  db.add(new_admin)
330
  db.commit()
331
  add_flash(request, "success", f"管理员 {display_name} 已创建。")
332
- return redirect("/admin/admins")
333
-
334
 
335
  @router.get("/admin/groups")
336
  def admin_groups(request: Request, db: Session = Depends(get_db)):
@@ -450,10 +725,16 @@ def edit_activity_page(activity_id: int, request: Request, db: Session = Depends
450
  if not activity:
451
  raise HTTPException(status_code=404, detail="活动不存在")
452
 
 
453
  return render(
454
  request,
455
  "admin_activity_edit.html",
456
- {"page_title": f"编辑活动 · {activity.title}", "admin": admin, "activity": activity},
 
 
 
 
 
457
  )
458
 
459
 
@@ -543,30 +824,16 @@ async def update_activity(activity_id: int, request: Request, db: Session = Depe
543
  raise ValueError("任务数据无效,请刷新页面后重试。")
544
  seen_task_ids.add(task_id)
545
 
546
- title = (
547
- str(existing_task_titles[index]).strip()
548
- if index < len(existing_task_titles)
549
- else ""
550
- )
551
- description = (
552
- str(existing_task_descriptions[index]).strip()
553
- if index < len(existing_task_descriptions)
554
- else ""
555
- )
556
  if not title:
557
  raise ValueError(f"第 {index + 1} 个已有任务标题不能为空。")
558
 
559
  task.title = title
560
  task.description = description
561
 
562
- primary_upload = (
563
- existing_task_images[index] if index < len(existing_task_images) else None
564
- )
565
- clue_upload = (
566
- existing_task_clue_images[index]
567
- if index < len(existing_task_clue_images)
568
- else None
569
- )
570
 
571
  if primary_upload and getattr(primary_upload, "filename", ""):
572
  primary_raw = await read_and_validate_upload(primary_upload)
@@ -629,21 +896,11 @@ async def delete_task(task_id: int, request: Request, db: Session = Depends(get_
629
  if not admin:
630
  return redirect("/admin")
631
 
632
- task = (
633
- db.query(Task)
634
- .options(joinedload(Task.submissions))
635
- .filter(Task.id == task_id)
636
- .first()
637
- )
638
  if not task:
639
  raise HTTPException(status_code=404, detail="任务不存在")
640
 
641
- activity = (
642
- db.query(Activity)
643
- .options(joinedload(Activity.tasks))
644
- .filter(Activity.id == task.activity_id)
645
- .first()
646
- )
647
  if not activity:
648
  raise HTTPException(status_code=404, detail="活动不存在")
649
  if len(activity.tasks) <= 1:
@@ -652,7 +909,6 @@ async def delete_task(task_id: int, request: Request, db: Session = Depends(get_
652
 
653
  cleanup_submission_files(task.submissions)
654
  activity_id = activity.id
655
-
656
  db.delete(task)
657
  db.flush()
658
 
@@ -670,9 +926,7 @@ async def delete_task(task_id: int, request: Request, db: Session = Depends(get_
670
 
671
 
672
  @router.post("/admin/activities/{activity_id}/visibility")
673
- async def update_leaderboard_visibility(
674
- activity_id: int, request: Request, db: Session = Depends(get_db)
675
- ):
676
  admin = require_admin(request, db)
677
  if not admin:
678
  return redirect("/admin")
@@ -686,8 +940,7 @@ async def update_leaderboard_visibility(
686
  db.add(activity)
687
  db.commit()
688
  add_flash(request, "success", f"已更新 {activity.title} 的排行榜可见性。")
689
- return redirect("/admin/activities")
690
-
691
 
692
  @router.get("/admin/reviews")
693
  def admin_reviews(request: Request, db: Session = Depends(get_db)):
@@ -695,24 +948,14 @@ def admin_reviews(request: Request, db: Session = Depends(get_db)):
695
  if not admin:
696
  return redirect("/admin")
697
 
698
- status_filter = request.query_params.get("status", "pending")
699
  activity_filter = request.query_params.get("activity_id", "")
 
700
 
701
- query = (
702
- db.query(Submission)
703
- .options(
704
- joinedload(Submission.user),
705
- joinedload(Submission.task).joinedload(Task.activity),
706
- joinedload(Submission.reviewed_by),
707
- )
708
- .order_by(Submission.created_at.desc())
709
- )
710
- if status_filter:
711
- query = query.filter(Submission.status == status_filter)
712
- if activity_filter.isdigit():
713
- query = query.join(Submission.task).filter(Task.activity_id == int(activity_filter))
714
-
715
- submissions = query.all()
716
  activities = db.query(Activity).order_by(Activity.start_at.desc()).all()
717
  return render(
718
  request,
@@ -720,22 +963,60 @@ def admin_reviews(request: Request, db: Session = Depends(get_db)):
720
  {
721
  "page_title": "审核中心",
722
  "admin": admin,
723
- "submissions": submissions,
724
  "activities": activities,
725
- "status_filter": status_filter,
726
  "activity_filter": activity_filter,
 
 
 
727
  },
728
  )
729
 
730
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
731
  @router.post("/admin/submissions/{submission_id}/review")
732
  async def review_submission(submission_id: int, request: Request, db: Session = Depends(get_db)):
733
  admin = require_admin(request, db)
734
  if not admin:
 
 
735
  return redirect("/admin")
736
 
737
- submission = db.get(Submission, submission_id)
 
 
 
 
 
 
 
 
 
 
 
738
  if not submission:
 
 
739
  raise HTTPException(status_code=404, detail="提交记录不存在")
740
 
741
  form = await request.form()
@@ -743,16 +1024,31 @@ async def review_submission(submission_id: int, request: Request, db: Session =
743
  feedback = str(form.get("feedback", "")).strip() or None
744
 
745
  if decision not in {"approved", "rejected"}:
 
 
746
  add_flash(request, "error", "审核操作无效。")
747
  return redirect("/admin/reviews")
748
 
 
 
 
 
 
 
 
749
  submission.status = decision
750
  submission.feedback = feedback
751
  submission.reviewed_by_id = admin.id
 
 
752
  submission.reviewed_at = local_now()
753
  submission.approved_at = submission.created_at if decision == "approved" else None
754
  db.add(submission)
755
  db.commit()
 
 
 
 
756
 
757
  add_flash(request, "success", "审核结果已保存。")
758
  return redirect("/admin/reviews")
@@ -772,10 +1068,7 @@ async def download_selected_submissions(request: Request, db: Session = Depends(
772
 
773
  submissions = (
774
  db.query(Submission)
775
- .options(
776
- joinedload(Submission.user),
777
- joinedload(Submission.task).joinedload(Task.activity),
778
- )
779
  .filter(Submission.id.in_(selected_ids))
780
  .all()
781
  )
@@ -789,9 +1082,9 @@ async def download_selected_submissions(request: Request, db: Session = Depends(
789
  suffix = file_path.suffix or ".jpg"
790
  activity_title = submission.task.activity.title.replace("/", "_")
791
  task_title = submission.task.title.replace("/", "_")
792
- student_id = submission.user.student_id.replace("/", "_")
793
- full_name = submission.user.full_name.replace("/", "_")
794
- archive_name = f"{activity_title}/{task_title}/{student_id}_{full_name}{suffix}"
795
  zip_file.write(file_path, archive_name)
796
 
797
  archive.seek(0)
@@ -801,3 +1094,6 @@ async def download_selected_submissions(request: Request, db: Session = Depends(
801
  media_type="application/zip",
802
  headers={"Content-Disposition": f'attachment; filename="{filename}"'},
803
  )
 
 
 
 
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
 
21
 
 
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
 
 
52
  )
53
 
54
 
55
+ def ensure_group_capacity(group: Group | None, current_users: list[User] | None = None) -> None:
56
  if not group:
57
  return
58
+ current_users = current_users or []
59
  current_count = len(group.members)
60
+ incoming_count = 0
61
+ for user in current_users:
62
+ belongs_to_target = user.group_id == group.id or (
63
+ getattr(user, "group", None) is not None and getattr(user.group, "id", None) == group.id
64
+ )
65
+ if not belongs_to_target:
66
+ incoming_count += 1
67
+ if current_count + incoming_count > group.max_members:
68
+ raise ValueError(f"{group.name} 的人数上限不足,请调整人数上限或减少选中人数。")
69
 
70
 
71
  def parse_activity_fields(form) -> dict:
 
136
  file_path.unlink(missing_ok=True)
137
 
138
 
139
+ def serialize_submission(submission: Submission) -> dict:
140
+ return {
141
+ "id": submission.id,
142
+ "task_title": submission.task.title if submission.task else "",
143
+ "activity_title": submission.task.activity.title if submission.task and submission.task.activity else "",
144
+ "group_name": submission.group.name if submission.group else "未分组",
145
+ "uploader_name": submission.user.full_name if submission.user else "未知成员",
146
+ "student_id": submission.user.student_id if submission.user else "",
147
+ "status": submission.status,
148
+ "feedback": submission.feedback,
149
+ "created_at": submission.created_at.strftime("%Y-%m-%d %H:%M") if submission.created_at else "-",
150
+ "reviewed_at": submission.reviewed_at.strftime("%Y-%m-%d %H:%M") if submission.reviewed_at else None,
151
+ "reviewed_by_name": submission.reviewed_by.display_name if submission.reviewed_by else None,
152
+ "assigned_admin_name": submission.assigned_admin.display_name if submission.assigned_admin else None,
153
+ "image_url": f"/media/submissions/{submission.id}",
154
+ "download_url": f"/media/submissions/{submission.id}?download=1",
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)
170
+ .options(
171
+ joinedload(Submission.user),
172
+ joinedload(Submission.group),
173
+ joinedload(Submission.task).joinedload(Task.activity),
174
+ joinedload(Submission.reviewed_by),
175
+ joinedload(Submission.assigned_admin),
176
+ )
177
+ .filter(Submission.status.in_(["approved", "rejected"]))
178
+ .order_by(Submission.reviewed_at.desc(), Submission.id.desc())
179
+ )
180
+ if activity_id is not None:
181
+ query = query.join(Submission.task).filter(Task.activity_id == activity_id)
182
+ return query
183
+
184
+
185
+ def pick_replacement_user(
186
+ db: Session,
187
+ group_id: int | None,
188
+ task_id: int,
189
+ excluded_ids: set[int],
190
+ ) -> User | None:
191
+ if not group_id:
192
+ return None
193
+ candidates = (
194
+ db.query(User)
195
+ .filter(User.group_id == group_id, User.id.notin_(excluded_ids))
196
+ .order_by(User.id.asc())
197
+ .all()
198
+ )
199
+ for candidate in candidates:
200
+ existing_submission = (
201
+ db.query(Submission.id)
202
+ .filter(Submission.user_id == candidate.id, Submission.task_id == task_id)
203
+ .first()
204
+ )
205
+ if not existing_submission:
206
+ return candidate
207
+ return None
208
+
209
+
210
+ def delete_users_and_handle_submissions(db: Session, users: list[User]) -> tuple[int, int]:
211
+ excluded_ids = {user.id for user in users}
212
+ removed_submission_count = 0
213
+
214
+ for user in users:
215
+ submissions = db.query(Submission).filter(Submission.user_id == user.id).all()
216
+ for submission in submissions:
217
+ replacement = pick_replacement_user(db, submission.group_id, submission.task_id, excluded_ids)
218
+ if replacement:
219
+ submission.user_id = replacement.id
220
+ db.add(submission)
221
+ continue
222
+ cleanup_submission_files([submission])
223
+ db.delete(submission)
224
+ removed_submission_count += 1
225
+ db.delete(user)
226
+
227
+ return len(users), removed_submission_count
228
+
229
+
230
  async def collect_task_payloads(
231
  form,
232
  title_key: str,
 
285
  if not admin:
286
  return redirect("/admin")
287
 
288
+ now = local_now()
289
+ admin_rows = db.query(Admin).order_by(Admin.id.asc()).all()
290
+ user_rows = db.query(User).order_by(User.id.asc()).all()
291
  stats = {
292
+ "user_count": len(user_rows),
293
  "group_count": db.query(Group).count(),
294
  "activity_count": db.query(Activity).count(),
295
  "pending_count": db.query(Submission).filter(Submission.status == "pending").count(),
296
+ "admin_count": len(admin_rows),
297
+ "online_admin_count": sum(1 for item in admin_rows if is_online(item.last_seen_at, now)),
298
+ "online_user_count": sum(1 for item in user_rows if is_online(item.last_seen_at, now)),
299
  }
300
  recent_activities = (
301
  db.query(Activity)
 
304
  .limit(5)
305
  .all()
306
  )
307
+ online_overview = None
308
+ if admin.role == "superadmin":
309
+ online_overview = {
310
+ "admins": [
311
+ serialize_presence_entry(item.display_name, "管理员", item.last_seen_at, now)
312
+ for item in admin_rows
313
+ ],
314
+ "users": [
315
+ serialize_presence_entry(item.full_name, "用户", item.last_seen_at, now)
316
+ for item in user_rows
317
+ ],
318
+ }
319
+
320
  return render(
321
  request,
322
  "admin_dashboard.html",
 
325
  "admin": admin,
326
  "stats": stats,
327
  "recent_activities": recent_activities,
328
+ "online_overview": online_overview,
329
  },
330
  )
331
 
 
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)):
 
364
  return redirect("/admin/users")
365
 
366
  try:
367
+ ensure_group_capacity(group, [])
368
  except ValueError as exc:
369
  add_flash(request, "error", str(exc))
370
  return redirect("/admin/users")
 
381
  return redirect("/admin/users")
382
 
383
 
384
+ @router.post("/admin/users/import")
385
+ async def import_users(request: Request, db: Session = Depends(get_db)):
386
+ admin = require_admin(request, db)
387
+ if not admin:
388
+ return redirect("/admin")
389
+
390
+ form = await request.form()
391
+ raw_lines = str(form.get("import_text", "")).splitlines()
392
+ group = parse_optional_group(form.get("group_id"), db)
393
+
394
+ parsed_users = []
395
+ skipped = []
396
+ seen_student_ids = set()
397
+ for index, line in enumerate(raw_lines, start=1):
398
+ line = line.strip()
399
+ if not line:
400
+ continue
401
+ parts = line.split()
402
+ if len(parts) < 2:
403
+ skipped.append(f"第 {index} 行格式不正确")
404
+ continue
405
+ student_id = parts[-1].strip()
406
+ full_name = " ".join(parts[:-1]).strip()
407
+ if not full_name or not student_id:
408
+ skipped.append(f"第 {index} 行格式不正确")
409
+ continue
410
+ if student_id in seen_student_ids or db.query(User).filter(User.student_id == student_id).first():
411
+ skipped.append(f"{student_id} 已存在,已跳过")
412
+ continue
413
+ seen_student_ids.add(student_id)
414
+ parsed_users.append(
415
+ User(
416
+ student_id=student_id,
417
+ full_name=full_name,
418
+ password_hash=hash_password(student_id[-6:] if len(student_id) >= 6 else student_id),
419
+ group=group,
420
+ )
421
+ )
422
+
423
+ try:
424
+ ensure_group_capacity(group, parsed_users)
425
+ except ValueError as exc:
426
+ add_flash(request, "error", str(exc))
427
+ return redirect("/admin/users")
428
+
429
+ for user in parsed_users:
430
+ db.add(user)
431
+ db.commit()
432
+
433
+ message = f"成功导入 {len(parsed_users)} 位用户。"
434
+ if skipped:
435
+ message += " 跳过:" + ";".join(skipped[:5])
436
+ add_flash(request, "success", message)
437
+ return redirect("/admin/users")
438
+
439
+
440
+ @router.post("/admin/users/bulk")
441
+ async def bulk_manage_users(request: Request, db: Session = Depends(get_db)):
442
+ admin = require_admin(request, db)
443
+ if not admin:
444
+ return redirect("/admin")
445
+
446
+ form = await request.form()
447
+ selected_ids = [int(value) for value in form.getlist("user_ids") if str(value).isdigit()]
448
+ action = str(form.get("bulk_action", "")).strip()
449
+ if not selected_ids:
450
+ add_flash(request, "error", "请先勾选用户。")
451
+ return redirect("/admin/users")
452
+
453
+ users = db.query(User).options(joinedload(User.group)).filter(User.id.in_(selected_ids)).all()
454
+ if not users:
455
+ add_flash(request, "error", "未找到选中的用户。")
456
+ return redirect("/admin/users")
457
+
458
+ if action == "assign_group":
459
+ group = parse_optional_group(form.get("group_id"), db)
460
+ try:
461
+ ensure_group_capacity(group, users)
462
+ except ValueError as exc:
463
+ add_flash(request, "error", str(exc))
464
+ return redirect("/admin/users")
465
+ for user in users:
466
+ user.group = group
467
+ db.add(user)
468
+ db.commit()
469
+ add_flash(request, "success", f"已批量更新 {len(users)} 位用户的小组。")
470
+ return redirect("/admin/users")
471
+
472
+ if action == "delete":
473
+ removed_user_count, removed_submission_count = delete_users_and_handle_submissions(db, users)
474
+ db.commit()
475
+ add_flash(
476
+ request,
477
+ "success",
478
+ f"已删除 {removed_user_count} 位用户,清理 {removed_submission_count} 条无可继承的打卡记录。",
479
+ )
480
+ return redirect("/admin/users")
481
+
482
+ add_flash(request, "error", "批量操作无效。")
483
+ return redirect("/admin/users")
484
+
485
+
486
  @router.post("/admin/users/{user_id}/group")
487
  async def assign_user_group(user_id: int, request: Request, db: Session = Depends(get_db)):
488
  admin = require_admin(request, db)
 
496
  form = await request.form()
497
  group = parse_optional_group(form.get("group_id"), db)
498
  try:
499
+ ensure_group_capacity(group, [user])
500
  except ValueError as exc:
501
  add_flash(request, "error", str(exc))
502
  return redirect("/admin/users")
 
515
  add_flash(request, "error", "只有超级管理员可以管理管理员账号。")
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 = [
526
+ {
527
+ **serialize_presence_entry(item.full_name, "用户", item.last_seen_at, now),
528
+ "group_name": item.group.name if item.group else "未分组",
529
+ }
530
+ for item in users
531
+ ]
532
  return render(
533
  request,
534
  "admin_admins.html",
535
+ {
536
+ "page_title": "管理员管理",
537
+ "admin": admin,
538
+ "admins": admins,
539
+ "admin_statuses": admin_statuses,
540
+ "user_statuses": user_statuses,
541
+ },
542
+ )
543
+
544
+
545
+ @router.get("/api/admin/presence/overview")
546
+ def presence_overview(request: Request, db: Session = Depends(get_db)):
547
+ admin = require_super_admin(request, db)
548
+ if not admin:
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
  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)):
 
725
  if not activity:
726
  raise HTTPException(status_code=404, detail="活动不存在")
727
 
728
+ leaderboard = build_leaderboard(db, activity.id)
729
  return render(
730
  request,
731
  "admin_activity_edit.html",
732
+ {
733
+ "page_title": f"编辑活动 · {activity.title}",
734
+ "admin": admin,
735
+ "activity": activity,
736
+ "leaderboard": leaderboard,
737
+ },
738
  )
739
 
740
 
 
824
  raise ValueError("任务数据无效,请刷新页面后重试。")
825
  seen_task_ids.add(task_id)
826
 
827
+ title = str(existing_task_titles[index]).strip() if index < len(existing_task_titles) else ""
828
+ description = str(existing_task_descriptions[index]).strip() if index < len(existing_task_descriptions) else ""
 
 
 
 
 
 
 
 
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)
 
896
  if not admin:
897
  return redirect("/admin")
898
 
899
+ task = db.query(Task).options(joinedload(Task.submissions)).filter(Task.id == task_id).first()
 
 
 
 
 
900
  if not task:
901
  raise HTTPException(status_code=404, detail="任务不存在")
902
 
903
+ activity = db.query(Activity).options(joinedload(Activity.tasks)).filter(Activity.id == task.activity_id).first()
 
 
 
 
 
904
  if not activity:
905
  raise HTTPException(status_code=404, detail="活动不存在")
906
  if len(activity.tasks) <= 1:
 
909
 
910
  cleanup_submission_files(task.submissions)
911
  activity_id = activity.id
 
912
  db.delete(task)
913
  db.flush()
914
 
 
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)
931
  if not admin:
932
  return redirect("/admin")
 
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)):
 
948
  if not admin:
949
  return redirect("/admin")
950
 
 
951
  activity_filter = request.query_params.get("activity_id", "")
952
+ activity_id = int(activity_filter) if activity_filter.isdigit() else None
953
 
954
+ pending_submissions = rebalance_pending_reviews(db, activity_id)
955
+ assigned_submissions = [
956
+ submission for submission in pending_submissions if submission.assigned_admin_id == admin.id
957
+ ]
958
+ recent_submissions = recent_reviews_query(db, activity_id).limit(20).all()
 
 
 
 
 
 
 
 
 
 
959
  activities = db.query(Activity).order_by(Activity.start_at.desc()).all()
960
  return render(
961
  request,
 
963
  {
964
  "page_title": "审核中心",
965
  "admin": admin,
 
966
  "activities": activities,
 
967
  "activity_filter": activity_filter,
968
+ "assigned_submissions": assigned_submissions,
969
+ "recent_submissions": recent_submissions,
970
+ "online_admin_count": len(online_admins(db)),
971
  },
972
  )
973
 
974
 
975
+ @router.get("/api/admin/reviews/feed")
976
+ def review_feed(request: Request, db: Session = Depends(get_db)):
977
+ admin = require_admin(request, db)
978
+ if not admin:
979
+ return JSONResponse({"error": "forbidden"}, status_code=403)
980
+
981
+ activity_filter = request.query_params.get("activity_id", "")
982
+ activity_id = int(activity_filter) if activity_filter.isdigit() else None
983
+ pending_submissions = rebalance_pending_reviews(db, activity_id)
984
+ assigned_submissions = [
985
+ submission for submission in pending_submissions if submission.assigned_admin_id == admin.id
986
+ ]
987
+ recent_submissions = recent_reviews_query(db, activity_id).limit(20).all()
988
+ return JSONResponse(
989
+ {
990
+ "online_admin_count": len(online_admins(db)),
991
+ "assigned_submissions": [serialize_submission(submission) for submission in assigned_submissions],
992
+ "recent_submissions": [serialize_submission(submission) for submission in recent_submissions],
993
+ }
994
+ )
995
+
996
+
997
  @router.post("/admin/submissions/{submission_id}/review")
998
  async def review_submission(submission_id: int, request: Request, db: Session = Depends(get_db)):
999
  admin = require_admin(request, db)
1000
  if not admin:
1001
+ if request.headers.get("X-Requested-With") == "fetch":
1002
+ return JSONResponse({"error": "forbidden"}, status_code=403)
1003
  return redirect("/admin")
1004
 
1005
+ submission = (
1006
+ db.query(Submission)
1007
+ .options(
1008
+ joinedload(Submission.task).joinedload(Task.activity),
1009
+ joinedload(Submission.user),
1010
+ joinedload(Submission.group),
1011
+ joinedload(Submission.reviewed_by),
1012
+ joinedload(Submission.assigned_admin),
1013
+ )
1014
+ .filter(Submission.id == submission_id)
1015
+ .first()
1016
+ )
1017
  if not submission:
1018
+ if request.headers.get("X-Requested-With") == "fetch":
1019
+ return JSONResponse({"error": "not_found"}, status_code=404)
1020
  raise HTTPException(status_code=404, detail="提交记录不存在")
1021
 
1022
  form = await request.form()
 
1024
  feedback = str(form.get("feedback", "")).strip() or None
1025
 
1026
  if decision not in {"approved", "rejected"}:
1027
+ if request.headers.get("X-Requested-With") == "fetch":
1028
+ return JSONResponse({"error": "invalid_decision"}, status_code=400)
1029
  add_flash(request, "error", "审核操作无效。")
1030
  return redirect("/admin/reviews")
1031
 
1032
+ if submission.status != "pending":
1033
+ message = "该提交已被其他管理员处理,页面即将刷新到最新状态。"
1034
+ if request.headers.get("X-Requested-With") == "fetch":
1035
+ return JSONResponse({"error": "already_reviewed", "message": message}, status_code=409)
1036
+ add_flash(request, "info", message)
1037
+ return redirect("/admin/reviews")
1038
+
1039
  submission.status = decision
1040
  submission.feedback = feedback
1041
  submission.reviewed_by_id = admin.id
1042
+ submission.assigned_admin_id = admin.id
1043
+ submission.assigned_at = submission.assigned_at or local_now()
1044
  submission.reviewed_at = local_now()
1045
  submission.approved_at = submission.created_at if decision == "approved" else None
1046
  db.add(submission)
1047
  db.commit()
1048
+ db.refresh(submission)
1049
+
1050
+ if request.headers.get("X-Requested-With") == "fetch":
1051
+ return JSONResponse({"ok": True, "submission": serialize_submission(submission)})
1052
 
1053
  add_flash(request, "success", "审核结果已保存。")
1054
  return redirect("/admin/reviews")
 
1068
 
1069
  submissions = (
1070
  db.query(Submission)
1071
+ .options(joinedload(Submission.user), joinedload(Submission.group), joinedload(Submission.task).joinedload(Task.activity))
 
 
 
1072
  .filter(Submission.id.in_(selected_ids))
1073
  .all()
1074
  )
 
1082
  suffix = file_path.suffix or ".jpg"
1083
  activity_title = submission.task.activity.title.replace("/", "_")
1084
  task_title = submission.task.title.replace("/", "_")
1085
+ group_name = (submission.group.name if submission.group else "未分组").replace("/", "_")
1086
+ uploader_name = (submission.user.full_name if submission.user else "unknown").replace("/", "_")
1087
+ archive_name = f"{activity_title}/{group_name}/{task_title}_{uploader_name}{suffix}"
1088
  zip_file.write(file_path, archive_name)
1089
 
1090
  archive.seek(0)
 
1094
  media_type="application/zip",
1095
  headers={"Content-Disposition": f'attachment; filename="{filename}"'},
1096
  )
1097
+
1098
+
1099
+
app/routes/auth.py CHANGED
@@ -1,18 +1,63 @@
1
  from __future__ import annotations
2
 
3
  from fastapi import APIRouter, Depends, Form, Request
4
- from sqlalchemy.orm import Session
 
5
 
6
  from app.auth import get_current_admin, get_current_user, sign_in_admin, sign_in_user, sign_out
7
  from app.database import get_db
8
- from app.models import Admin, User
9
- from app.security import verify_password
10
  from app.web import add_flash, redirect, render
11
 
12
 
13
  router = APIRouter()
14
 
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  @router.get("/login")
17
  def login_page(request: Request, db: Session = Depends(get_db)):
18
  if get_current_user(request, db):
@@ -68,4 +113,58 @@ def admin_login_submit(
68
  @router.get("/admin/logout")
69
  def admin_logout(request: Request):
70
  sign_out(request)
71
- return redirect("/admin")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  from fastapi import APIRouter, Depends, Form, Request
4
+ from fastapi.responses import JSONResponse
5
+ from sqlalchemy.orm import Session, joinedload
6
 
7
  from app.auth import get_current_admin, get_current_user, sign_in_admin, sign_in_user, sign_out
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()
15
 
16
 
17
+ def load_account_context(request: Request, db: Session):
18
+ admin = get_current_admin(request, db)
19
+ user = get_current_user(request, db)
20
+ if admin:
21
+ groups = db.query(Group).options(joinedload(Group.members)).order_by(Group.name.asc()).all()
22
+ group_cards = [
23
+ {
24
+ "name": group.name,
25
+ "members": [member.full_name for member in group.members],
26
+ }
27
+ for group in groups
28
+ ]
29
+ return {
30
+ "admin": admin,
31
+ "user": None,
32
+ "identity_name": admin.display_name,
33
+ "identity_label": "管理员账号",
34
+ "group_cards": group_cards,
35
+ }
36
+ if user:
37
+ user = (
38
+ db.query(User)
39
+ .options(joinedload(User.group).joinedload(Group.members))
40
+ .filter(User.id == user.id)
41
+ .first()
42
+ )
43
+ group_cards = []
44
+ if user and user.group:
45
+ group_cards.append(
46
+ {
47
+ "name": user.group.name,
48
+ "members": [member.full_name for member in user.group.members],
49
+ }
50
+ )
51
+ return {
52
+ "admin": None,
53
+ "user": user,
54
+ "identity_name": user.full_name if user else "",
55
+ "identity_label": "用户账号",
56
+ "group_cards": group_cards,
57
+ }
58
+ return None
59
+
60
+
61
  @router.get("/login")
62
  def login_page(request: Request, db: Session = Depends(get_db)):
63
  if get_current_user(request, db):
 
113
  @router.get("/admin/logout")
114
  def admin_logout(request: Request):
115
  sign_out(request)
116
+ return redirect("/admin")
117
+
118
+
119
+ @router.get("/account")
120
+ def account_page(request: Request, db: Session = Depends(get_db)):
121
+ account_context = load_account_context(request, db)
122
+ if not account_context:
123
+ return redirect("/login")
124
+ return render(
125
+ request,
126
+ "account.html",
127
+ {
128
+ "page_title": "账号中心",
129
+ **account_context,
130
+ },
131
+ )
132
+
133
+
134
+ @router.post("/account/password")
135
+ def change_password(
136
+ request: Request,
137
+ current_password: str = Form(...),
138
+ new_password: str = Form(...),
139
+ confirm_password: str = Form(...),
140
+ db: Session = Depends(get_db),
141
+ ):
142
+ admin = get_current_admin(request, db)
143
+ user = get_current_user(request, db)
144
+ if not admin and not user:
145
+ return redirect("/login")
146
+
147
+ identity = admin or user
148
+ password_hash = identity.password_hash
149
+ if not verify_password(current_password, password_hash):
150
+ add_flash(request, "error", "当前密码不正确。")
151
+ return redirect("/account")
152
+ if len(new_password.strip()) < 6:
153
+ add_flash(request, "error", "新密码至少需要 6 位。")
154
+ return redirect("/account")
155
+ if new_password != confirm_password:
156
+ add_flash(request, "error", "两次输入的新密码不一致。")
157
+ return redirect("/account")
158
+
159
+ identity.password_hash = hash_password(new_password)
160
+ db.add(identity)
161
+ db.commit()
162
+ add_flash(request, "success", "密码已更新,下次登录请使用新密码。")
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})
app/routes/media.py CHANGED
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session, joinedload
8
 
9
  from app.auth import get_current_admin, get_current_user
10
  from app.database import get_db
11
- from app.models import Submission, Task
12
  from app.web import local_now
13
 
14
 
@@ -54,8 +54,9 @@ def submission_image(submission_id: int, request: Request, db: Session = Depends
54
  raise HTTPException(status_code=404, detail="Submission not found")
55
 
56
  user = get_current_user(request, db)
57
- if not admin and (not user or user.id != submission.user_id):
58
- raise HTTPException(status_code=403, detail="Forbidden")
 
59
 
60
  file_path = Path(submission.file_path)
61
  if not file_path.exists():
 
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
 
 
54
  raise HTTPException(status_code=404, detail="Submission not found")
55
 
56
  user = get_current_user(request, db)
57
+ if not admin:
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():
app/routes/user.py CHANGED
@@ -9,7 +9,13 @@ from sqlalchemy.orm import Session, joinedload
9
  from app.auth import get_current_user
10
  from app.config import settings
11
  from app.database import get_db
12
- from app.models import Activity, Submission, Task, User
 
 
 
 
 
 
13
  from app.services.images import compress_to_limit, persist_submission_image, read_and_validate_upload
14
  from app.services.leaderboard import build_leaderboard
15
  from app.web import add_flash, local_now, redirect, render
@@ -21,12 +27,15 @@ router = APIRouter()
21
  def require_user(request: Request, db: Session) -> User | None:
22
  user = (
23
  db.query(User)
24
- .options(joinedload(User.group), joinedload(User.submissions))
25
  .filter(User.id == (request.session.get("user_id") or 0))
26
  .first()
27
  )
28
  if not user or not user.is_active:
29
  return None
 
 
 
30
  return user
31
 
32
 
@@ -36,31 +45,24 @@ def dashboard(request: Request, db: Session = Depends(get_db)):
36
  if not user:
37
  return redirect("/login")
38
 
39
- activities = db.query(Activity).options(joinedload(Activity.tasks)).order_by(Activity.start_at.asc()).all()
40
- submission_by_task = {submission.task_id: submission for submission in user.submissions}
 
 
 
 
41
 
42
  activity_cards = []
43
  now = local_now()
44
  for activity in activities:
45
- total_tasks = len(activity.tasks)
46
- approved_count = sum(
47
- 1
48
- for task in activity.tasks
49
- if submission_by_task.get(task.id)
50
- and submission_by_task[task.id].status == "approved"
51
- )
52
- pending_count = sum(
53
- 1
54
- for task in activity.tasks
55
- if submission_by_task.get(task.id)
56
- and submission_by_task[task.id].status == "pending"
57
- )
58
  activity_cards.append(
59
  {
60
  "activity": activity,
61
- "total_tasks": total_tasks,
62
- "approved_count": approved_count,
63
- "pending_count": pending_count,
 
64
  "is_active": activity.start_at <= now <= activity.deadline_at,
65
  "is_overdue": now > activity.deadline_at,
66
  }
@@ -83,29 +85,20 @@ def activity_detail(activity_id: int, request: Request, db: Session = Depends(ge
83
  if not user:
84
  return redirect("/login")
85
 
86
- activity = (
87
- db.query(Activity)
88
- .options(joinedload(Activity.tasks).joinedload(Task.submissions))
89
- .filter(Activity.id == activity_id)
90
- .first()
91
- )
92
  if not activity:
93
  raise HTTPException(status_code=404, detail="活动不存在")
94
 
95
- submission_by_task = {}
96
- for task in activity.tasks:
97
- for submission in task.submissions:
98
- if submission.user_id == user.id:
99
- submission_by_task[task.id] = submission
100
- break
101
-
102
  now = local_now()
103
- leaderboard = build_leaderboard(db, activity.id) if activity.leaderboard_visible else []
 
104
  clue_states = {
105
  task.id: bool(task.clue_image_filename and task.clue_release_at and now >= task.clue_release_at)
106
  for task in activity.tasks
107
  }
108
 
 
 
109
  return render(
110
  request,
111
  "activity_detail.html",
@@ -113,7 +106,8 @@ def activity_detail(activity_id: int, request: Request, db: Session = Depends(ge
113
  "page_title": activity.title,
114
  "user": user,
115
  "activity": activity,
116
- "submission_by_task": submission_by_task,
 
117
  "leaderboard": leaderboard,
118
  "clue_states": clue_states,
119
  "now": now,
@@ -132,6 +126,9 @@ async def submit_task(
132
  user = require_user(request, db)
133
  if not user:
134
  return redirect("/login")
 
 
 
135
 
136
  activity = db.query(Activity).filter(Activity.id == activity_id).first()
137
  task = db.query(Task).filter(Task.id == task_id, Task.activity_id == activity_id).first()
@@ -153,13 +150,15 @@ async def submit_task(
153
  add_flash(request, "error", str(exc))
154
  return redirect(f"/activities/{activity_id}")
155
 
156
- submission = (
157
  db.query(Submission)
158
- .filter(Submission.user_id == user.id, Submission.task_id == task.id)
159
- .first()
 
160
  )
 
161
  if submission and submission.status == "approved":
162
- add_flash(request, "info", "打卡点已经审核通过,无需重复提交。")
163
  return redirect(f"/activities/{activity_id}")
164
 
165
  if submission and submission.file_path:
@@ -176,9 +175,11 @@ async def submit_task(
176
  )
177
 
178
  if not submission:
179
- submission = Submission(task_id=task.id, user_id=user.id)
180
  db.add(submission)
181
 
 
 
182
  submission.stored_filename = stored_filename
183
  submission.original_filename = photo.filename or stored_filename
184
  submission.file_path = file_path
@@ -187,12 +188,14 @@ async def submit_task(
187
  submission.status = "pending"
188
  submission.feedback = None
189
  submission.reviewed_by_id = None
 
 
190
  submission.reviewed_at = None
191
  submission.approved_at = None
192
  submission.created_at = now
193
  db.commit()
194
 
195
- add_flash(request, "success", "图片已提交,等待管理员审核。")
196
  return redirect(f"/activities/{activity_id}")
197
 
198
 
@@ -216,4 +219,54 @@ def activity_clues(activity_id: int, request: Request, db: Session = Depends(get
216
  ],
217
  "server_time": now.isoformat(timespec="seconds"),
218
  }
219
- return JSONResponse(payload)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  from app.auth import get_current_user
10
  from app.config import settings
11
  from app.database import get_db
12
+ from app.models import Activity, Group, Submission, Task, User
13
+ from app.services.group_progress import (
14
+ build_group_progress,
15
+ build_group_submission_map,
16
+ get_activity_with_group_context,
17
+ pick_primary_submission,
18
+ )
19
  from app.services.images import compress_to_limit, persist_submission_image, read_and_validate_upload
20
  from app.services.leaderboard import build_leaderboard
21
  from app.web import add_flash, local_now, redirect, render
 
27
  def require_user(request: Request, db: Session) -> User | None:
28
  user = (
29
  db.query(User)
30
+ .options(joinedload(User.group).joinedload(Group.members), joinedload(User.submissions))
31
  .filter(User.id == (request.session.get("user_id") or 0))
32
  .first()
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
 
 
45
  if not user:
46
  return redirect("/login")
47
 
48
+ activities = (
49
+ db.query(Activity)
50
+ .options(joinedload(Activity.tasks).joinedload(Task.submissions))
51
+ .order_by(Activity.start_at.asc())
52
+ .all()
53
+ )
54
 
55
  activity_cards = []
56
  now = local_now()
57
  for activity in activities:
58
+ progress = build_group_progress(activity, user.group_id)
 
 
 
 
 
 
 
 
 
 
 
 
59
  activity_cards.append(
60
  {
61
  "activity": activity,
62
+ "total_tasks": progress["total_tasks"],
63
+ "approved_count": progress["approved_count"],
64
+ "pending_count": progress["pending_count"],
65
+ "rejected_count": progress["rejected_count"],
66
  "is_active": activity.start_at <= now <= activity.deadline_at,
67
  "is_overdue": now > activity.deadline_at,
68
  }
 
85
  if not user:
86
  return redirect("/login")
87
 
88
+ activity = get_activity_with_group_context(db, activity_id)
 
 
 
 
 
89
  if not activity:
90
  raise HTTPException(status_code=404, detail="活动不存在")
91
 
 
 
 
 
 
 
 
92
  now = local_now()
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
 
100
+ leaderboard = build_leaderboard(db, activity.id) if activity.leaderboard_visible else []
101
+
102
  return render(
103
  request,
104
  "activity_detail.html",
 
106
  "page_title": activity.title,
107
  "user": user,
108
  "activity": activity,
109
+ "group_submission_by_task": group_submission_by_task,
110
+ "group_progress": group_progress,
111
  "leaderboard": leaderboard,
112
  "clue_states": clue_states,
113
  "now": now,
 
126
  user = require_user(request, db)
127
  if not user:
128
  return redirect("/login")
129
+ if not user.group_id:
130
+ add_flash(request, "error", "你当前还没有加入小组,暂时无法参与小组打卡。")
131
+ return redirect(f"/activities/{activity_id}")
132
 
133
  activity = db.query(Activity).filter(Activity.id == activity_id).first()
134
  task = db.query(Task).filter(Task.id == task_id, Task.activity_id == activity_id).first()
 
150
  add_flash(request, "error", str(exc))
151
  return redirect(f"/activities/{activity_id}")
152
 
153
+ group_submissions = (
154
  db.query(Submission)
155
+ .filter(Submission.group_id == user.group_id, Submission.task_id == task.id)
156
+ .order_by(Submission.created_at.asc(), Submission.id.asc())
157
+ .all()
158
  )
159
+ submission = pick_primary_submission(group_submissions)
160
  if submission and submission.status == "approved":
161
+ add_flash(request, "info", "你的小组已经完成了这个打卡点,无需重复提交。")
162
  return redirect(f"/activities/{activity_id}")
163
 
164
  if submission and submission.file_path:
 
175
  )
176
 
177
  if not submission:
178
+ submission = Submission(task_id=task.id, user_id=user.id, group_id=user.group_id)
179
  db.add(submission)
180
 
181
+ submission.user_id = user.id
182
+ submission.group_id = user.group_id
183
  submission.stored_filename = stored_filename
184
  submission.original_filename = photo.filename or stored_filename
185
  submission.file_path = file_path
 
188
  submission.status = "pending"
189
  submission.feedback = None
190
  submission.reviewed_by_id = None
191
+ submission.assigned_admin_id = None
192
+ submission.assigned_at = None
193
  submission.reviewed_at = None
194
  submission.approved_at = None
195
  submission.created_at = now
196
  db.commit()
197
 
198
+ add_flash(request, "success", "小组图片已提交,等待管理员审核。")
199
  return redirect(f"/activities/{activity_id}")
200
 
201
 
 
219
  ],
220
  "server_time": now.isoformat(timespec="seconds"),
221
  }
222
+ return JSONResponse(payload)
223
+
224
+
225
+ @router.get("/api/activities/{activity_id}/status")
226
+ def activity_status(activity_id: int, request: Request, db: Session = Depends(get_db)):
227
+ user = require_user(request, db)
228
+ if not user:
229
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
230
+
231
+ activity = get_activity_with_group_context(db, activity_id)
232
+ if not activity:
233
+ return JSONResponse({"error": "not_found"}, status_code=404)
234
+
235
+ submission_map = build_group_submission_map(activity, user.group_id)
236
+ progress = build_group_progress(activity, user.group_id)
237
+ leaderboard = build_leaderboard(db, activity.id) if activity.leaderboard_visible else []
238
+ now = local_now()
239
+ can_upload_now = bool(user.group_id) and activity.start_at <= now <= activity.deadline_at
240
+
241
+ tasks_payload = []
242
+ for task in activity.tasks:
243
+ submission = submission_map.get(task.id)
244
+ tasks_payload.append(
245
+ {
246
+ "task_id": task.id,
247
+ "status": submission.status if submission else "idle",
248
+ "feedback": submission.feedback if submission else None,
249
+ "submission_id": submission.id if submission else None,
250
+ "submitted_at": submission.created_at.strftime("%Y-%m-%d %H:%M") if submission else None,
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
+
257
+ return JSONResponse(
258
+ {
259
+ "activity_id": activity.id,
260
+ "progress": progress,
261
+ "tasks": tasks_payload,
262
+ "leaderboard": [
263
+ {
264
+ "group_name": row["group_name"],
265
+ "completed_count": row["completed_count"],
266
+ "member_count": row["member_count"],
267
+ "total_elapsed": row["total_elapsed_minutes"],
268
+ }
269
+ for row in leaderboard
270
+ ],
271
+ }
272
+ )
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__/group_progress.cpython-313.pyc ADDED
Binary file (5.71 kB). View file
 
app/services/__pycache__/leaderboard.cpython-313.pyc CHANGED
Binary files a/app/services/__pycache__/leaderboard.cpython-313.pyc and b/app/services/__pycache__/leaderboard.cpython-313.pyc differ
 
app/services/__pycache__/presence.cpython-313.pyc ADDED
Binary file (757 Bytes). View file
 
app/services/__pycache__/review_queue.cpython-313.pyc ADDED
Binary file (4.9 kB). View file
 
app/services/bootstrap.py CHANGED
@@ -1,5 +1,6 @@
1
  from __future__ import annotations
2
 
 
3
  from sqlalchemy.orm import Session
4
 
5
  from app.config import settings
@@ -8,9 +9,97 @@ from app.models import Admin
8
  from app.security import hash_password, verify_password
9
 
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  def initialize_database() -> None:
12
  settings.upload_root.mkdir(parents=True, exist_ok=True)
13
  Base.metadata.create_all(bind=engine)
 
14
 
15
 
16
  def seed_super_admin(db: Session) -> None:
@@ -36,3 +125,4 @@ def seed_super_admin(db: Session) -> None:
36
  if changed:
37
  db.add(admin)
38
  db.commit()
 
 
1
  from __future__ import annotations
2
 
3
+ from sqlalchemy import inspect, text
4
  from sqlalchemy.orm import Session
5
 
6
  from app.config import settings
 
9
  from app.security import hash_password, verify_password
10
 
11
 
12
+ def ensure_column(table_name: str, column_name: str, ddl: str) -> None:
13
+ inspector = inspect(engine)
14
+ if table_name not in inspector.get_table_names():
15
+ return
16
+ columns = {column["name"] for column in inspector.get_columns(table_name)}
17
+ if column_name in columns:
18
+ return
19
+ with engine.begin() as connection:
20
+ connection.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {ddl}"))
21
+
22
+
23
+ def submission_indexes() -> tuple[set[str], set[str]]:
24
+ inspector = inspect(engine)
25
+ unique_names = {
26
+ item["name"]
27
+ for item in inspector.get_unique_constraints("submissions")
28
+ if item.get("name")
29
+ }
30
+ index_names = {
31
+ item["name"]
32
+ for item in inspector.get_indexes("submissions")
33
+ if item.get("name")
34
+ }
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():
41
+ return
42
+
43
+ unique_names, index_names = submission_indexes()
44
+ with engine.begin() as connection:
45
+ if "uq_user_task_submission" in unique_names or "uq_user_task_submission" in index_names:
46
+ try:
47
+ connection.execute(text("ALTER TABLE submissions DROP INDEX uq_user_task_submission"))
48
+ except Exception:
49
+ pass
50
+
51
+ unique_names, index_names = submission_indexes()
52
+ if "uq_group_task_submission" in unique_names or "uq_group_task_submission" in index_names:
53
+ return
54
+
55
+ with engine.begin() as connection:
56
+ try:
57
+ connection.execute(
58
+ text(
59
+ "ALTER TABLE submissions ADD CONSTRAINT uq_group_task_submission UNIQUE (group_id, task_id)"
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")
78
+ ensure_column("submissions", "group_id", "group_id INT NULL")
79
+ ensure_column("submissions", "assigned_admin_id", "assigned_admin_id INT NULL")
80
+ ensure_column("submissions", "assigned_at", "assigned_at DATETIME NULL")
81
+ ensure_column("submissions", "reviewed_at", "reviewed_at DATETIME NULL")
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
 
104
 
105
  def seed_super_admin(db: Session) -> None:
 
125
  if changed:
126
  db.add(admin)
127
  db.commit()
128
+
app/services/group_progress.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from sqlalchemy.orm import Session, joinedload
6
+
7
+ from app.models import Activity, Submission, Task, User
8
+
9
+
10
+ def get_activity_with_group_context(db: Session, activity_id: int) -> Activity | None:
11
+ return (
12
+ db.query(Activity)
13
+ .options(
14
+ joinedload(Activity.tasks)
15
+ .joinedload(Task.submissions)
16
+ .joinedload(Submission.user)
17
+ .joinedload(User.group),
18
+ joinedload(Activity.tasks)
19
+ .joinedload(Task.submissions)
20
+ .joinedload(Submission.group),
21
+ joinedload(Activity.tasks)
22
+ .joinedload(Task.submissions)
23
+ .joinedload(Submission.reviewed_by),
24
+ joinedload(Activity.tasks)
25
+ .joinedload(Task.submissions)
26
+ .joinedload(Submission.assigned_admin),
27
+ )
28
+ .filter(Activity.id == activity_id)
29
+ .first()
30
+ )
31
+
32
+
33
+ def pick_primary_submission(submissions: list[Submission]) -> Submission | None:
34
+ if not submissions:
35
+ return None
36
+
37
+ approved = [submission for submission in submissions if submission.status == "approved"]
38
+ if approved:
39
+ return min(
40
+ approved,
41
+ key=lambda item: ((item.approved_at or item.created_at or datetime.min), item.id),
42
+ )
43
+
44
+ pending = [submission for submission in submissions if submission.status == "pending"]
45
+ if pending:
46
+ return max(
47
+ pending,
48
+ key=lambda item: ((item.created_at or datetime.min), item.id),
49
+ )
50
+
51
+ rejected = [submission for submission in submissions if submission.status == "rejected"]
52
+ if rejected:
53
+ return max(
54
+ rejected,
55
+ key=lambda item: ((item.created_at or datetime.min), item.id),
56
+ )
57
+
58
+ return max(
59
+ submissions,
60
+ key=lambda item: ((item.created_at or datetime.min), item.id),
61
+ )
62
+
63
+
64
+ def build_group_submission_map(activity: Activity, group_id: int | None) -> dict[int, Submission]:
65
+ if not activity or not group_id:
66
+ return {}
67
+
68
+ group_submissions: dict[int, Submission] = {}
69
+ for task in activity.tasks:
70
+ task_submissions = [submission for submission in task.submissions if submission.group_id == group_id]
71
+ primary_submission = pick_primary_submission(task_submissions)
72
+ if primary_submission:
73
+ group_submissions[task.id] = primary_submission
74
+ return group_submissions
75
+
76
+
77
+ def build_group_progress(activity: Activity, group_id: int | None) -> dict:
78
+ total_tasks = len(activity.tasks) if activity else 0
79
+ if not activity or not group_id:
80
+ return {
81
+ "total_tasks": total_tasks,
82
+ "approved_count": 0,
83
+ "pending_count": 0,
84
+ "rejected_count": 0,
85
+ "completion_ratio": 0,
86
+ }
87
+
88
+ submission_map = build_group_submission_map(activity, group_id)
89
+ approved_count = sum(1 for submission in submission_map.values() if submission.status == "approved")
90
+ pending_count = sum(1 for submission in submission_map.values() if submission.status == "pending")
91
+ rejected_count = sum(1 for submission in submission_map.values() if submission.status == "rejected")
92
+
93
+ return {
94
+ "total_tasks": total_tasks,
95
+ "approved_count": approved_count,
96
+ "pending_count": pending_count,
97
+ "rejected_count": rejected_count,
98
+ "completion_ratio": 0 if total_tasks == 0 else approved_count / total_tasks,
99
+ }
app/services/leaderboard.py CHANGED
@@ -3,51 +3,50 @@
3
  from collections import defaultdict
4
  from datetime import timedelta
5
 
6
- from sqlalchemy.orm import Session, joinedload
7
 
8
- from app.models import Activity, Submission, Task, User
 
9
 
10
 
11
  def build_leaderboard(db: Session, activity_id: int) -> list[dict]:
12
- activity = (
13
- db.query(Activity)
14
- .options(
15
- joinedload(Activity.tasks)
16
- .joinedload(Task.submissions)
17
- .joinedload(Submission.user)
18
- .joinedload(User.group)
19
- )
20
- .filter(Activity.id == activity_id)
21
- .first()
22
- )
23
  if not activity:
24
  return []
25
 
26
- group_stats: dict[str, dict] = defaultdict(
27
- lambda: {"completed_count": 0, "total_elapsed": timedelta(), "members": set()}
28
- )
 
 
29
 
30
  for task in activity.tasks:
 
31
  for submission in task.submissions:
32
- if submission.status != "approved" or not submission.user:
33
  continue
34
- group_name = submission.user.group.name if submission.user.group else "未分组"
35
- elapsed = max(submission.created_at - activity.start_at, timedelta())
36
- stats = group_stats[group_name]
37
- stats["completed_count"] += 1
38
- stats["total_elapsed"] += elapsed
39
- stats["members"].add(submission.user.full_name)
40
-
41
- rows = []
42
- for group_name, stats in group_stats.items():
43
- rows.append(
44
- {
45
- "group_name": group_name,
46
- "completed_count": stats["completed_count"],
47
- "total_elapsed": stats["total_elapsed"],
48
- "member_count": len(stats["members"]),
49
- "total_elapsed_minutes": int(stats["total_elapsed"].total_seconds() // 60),
50
- }
51
- )
52
- rows.sort(key=lambda item: (-item["completed_count"], item["total_elapsed"]))
 
 
 
 
 
53
  return rows
 
3
  from collections import defaultdict
4
  from datetime import timedelta
5
 
6
+ from sqlalchemy.orm import Session
7
 
8
+ from app.models import Group
9
+ from app.services.group_progress import get_activity_with_group_context, pick_primary_submission
10
 
11
 
12
  def build_leaderboard(db: Session, activity_id: int) -> list[dict]:
13
+ activity = get_activity_with_group_context(db, activity_id)
 
 
 
 
 
 
 
 
 
 
14
  if not activity:
15
  return []
16
 
17
+ rows_by_group: dict[int, dict] = {}
18
+ known_groups = {
19
+ group.id: group
20
+ for group in db.query(Group).order_by(Group.name.asc()).all()
21
+ }
22
 
23
  for task in activity.tasks:
24
+ submissions_by_group = defaultdict(list)
25
  for submission in task.submissions:
26
+ if not submission.group_id:
27
  continue
28
+ submissions_by_group[submission.group_id].append(submission)
29
+
30
+ for group_id, group_submissions in submissions_by_group.items():
31
+ submission = pick_primary_submission(group_submissions)
32
+ if not submission or submission.status != "approved":
33
+ continue
34
+
35
+ if group_id not in rows_by_group:
36
+ group = submission.group or known_groups.get(group_id)
37
+ rows_by_group[group_id] = {
38
+ "group_name": group.name if group else "未命名小组",
39
+ "completed_count": 0,
40
+ "total_elapsed": timedelta(),
41
+ "member_count": len(group.members) if group else 0,
42
+ }
43
+ row = rows_by_group[group_id]
44
+ elapsed = max((submission.approved_at or submission.created_at) - activity.start_at, timedelta())
45
+ row["completed_count"] += 1
46
+ row["total_elapsed"] += elapsed
47
+
48
+ rows = list(rows_by_group.values())
49
+ rows.sort(key=lambda item: (-item["completed_count"], item["total_elapsed"], item["group_name"]))
50
+ for row in rows:
51
+ row["total_elapsed_minutes"] = int(row["total_elapsed"].total_seconds() // 60)
52
  return rows
app/services/presence.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import timedelta
4
+
5
+ from app.web import local_now
6
+
7
+
8
+ ONLINE_WINDOW_SECONDS = 75
9
+
10
+
11
+ def online_cutoff(reference_time=None):
12
+ now = reference_time or local_now()
13
+ return now - timedelta(seconds=ONLINE_WINDOW_SECONDS)
14
+
15
+
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)
app/services/review_queue.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy.orm import Session, joinedload
4
+
5
+ from app.models import Admin, Submission, Task
6
+ from app.services.presence import is_online
7
+ from app.web import local_now
8
+
9
+
10
+ def online_admins(db: Session) -> list[Admin]:
11
+ now = local_now()
12
+ admins = db.query(Admin).filter(Admin.is_active.is_(True)).order_by(Admin.id.asc()).all()
13
+ return [admin for admin in admins if is_online(admin.last_seen_at, now)]
14
+
15
+
16
+ def rebalance_pending_reviews(db: Session, activity_id: int | None = None) -> list[Submission]:
17
+ pending_query = (
18
+ db.query(Submission)
19
+ .options(
20
+ joinedload(Submission.user),
21
+ joinedload(Submission.group),
22
+ joinedload(Submission.task).joinedload(Task.activity),
23
+ joinedload(Submission.assigned_admin),
24
+ joinedload(Submission.reviewed_by),
25
+ )
26
+ .filter(Submission.status == "pending")
27
+ .order_by(Submission.created_at.asc(), Submission.id.asc())
28
+ )
29
+ if activity_id is not None:
30
+ pending_query = pending_query.join(Submission.task).filter(Task.activity_id == activity_id)
31
+
32
+ pending_submissions = pending_query.all()
33
+ admins = online_admins(db)
34
+ if not admins:
35
+ return pending_submissions
36
+
37
+ now = local_now()
38
+ active_ids = {admin.id for admin in admins}
39
+ buckets: dict[int, list[Submission]] = {admin.id: [] for admin in admins}
40
+ changed = False
41
+
42
+ for submission in pending_submissions:
43
+ if submission.assigned_admin_id in active_ids:
44
+ buckets[submission.assigned_admin_id].append(submission)
45
+
46
+ for submission in pending_submissions:
47
+ if submission.assigned_admin_id in active_ids:
48
+ continue
49
+ target_admin = min(admins, key=lambda item: (len(buckets[item.id]), item.id))
50
+ submission.assigned_admin_id = target_admin.id
51
+ submission.assigned_at = now
52
+ buckets[target_admin.id].append(submission)
53
+ changed = True
54
+
55
+ while True:
56
+ source_admin = max(admins, key=lambda item: (len(buckets[item.id]), -item.id))
57
+ target_admin = min(admins, key=lambda item: (len(buckets[item.id]), item.id))
58
+ if len(buckets[source_admin.id]) - len(buckets[target_admin.id]) <= 1:
59
+ break
60
+
61
+ buckets[source_admin.id].sort(key=lambda item: (item.created_at, item.id))
62
+ moved_submission = buckets[source_admin.id].pop()
63
+ moved_submission.assigned_admin_id = target_admin.id
64
+ moved_submission.assigned_at = now
65
+ buckets[target_admin.id].append(moved_submission)
66
+ changed = True
67
+
68
+ if changed:
69
+ db.commit()
70
+ pending_submissions = pending_query.all()
71
+ return pending_submissions
app/static/style.css CHANGED
@@ -892,3 +892,204 @@ textarea {
892
  grid-template-columns: 1fr;
893
  }
894
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
892
  grid-template-columns: 1fr;
893
  }
894
  }
895
+
896
+ .users-admin-grid,
897
+ .presence-admin-grid,
898
+ .review-live-grid {
899
+ grid-template-columns: 1fr 1fr;
900
+ }
901
+
902
+ .bulk-toolbar {
903
+ display: grid;
904
+ grid-template-columns: 1fr 1fr auto;
905
+ gap: 14px;
906
+ margin-bottom: 18px;
907
+ align-items: end;
908
+ }
909
+
910
+ .leaderboard-hero {
911
+ align-items: stretch;
912
+ }
913
+
914
+ .hero-split-main {
915
+ display: grid;
916
+ gap: 14px;
917
+ flex: 1 1 55%;
918
+ }
919
+
920
+ .hero-side-rank {
921
+ width: min(420px, 100%);
922
+ padding: 18px 20px;
923
+ }
924
+
925
+ .inset-rank-card {
926
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(231, 248, 233, 0.82));
927
+ }
928
+
929
+ .compact-head {
930
+ margin-bottom: 12px;
931
+ }
932
+
933
+ .task-carousel-shell {
934
+ display: grid;
935
+ gap: 18px;
936
+ }
937
+
938
+ .task-carousel-head {
939
+ display: flex;
940
+ align-items: center;
941
+ justify-content: space-between;
942
+ gap: 14px;
943
+ }
944
+
945
+ .carousel-controls {
946
+ display: flex;
947
+ align-items: center;
948
+ gap: 10px;
949
+ }
950
+
951
+ .task-flip-book {
952
+ position: relative;
953
+ min-height: 720px;
954
+ }
955
+
956
+ .task-page {
957
+ position: absolute;
958
+ inset: 0;
959
+ opacity: 0;
960
+ pointer-events: none;
961
+ transform: perspective(1200px) rotateY(10deg) translateX(24px) scale(0.98);
962
+ transition: opacity 0.45s ease, transform 0.45s ease;
963
+ }
964
+
965
+ .task-page.is-active {
966
+ opacity: 1;
967
+ pointer-events: auto;
968
+ transform: perspective(1200px) rotateY(0deg) translateX(0) scale(1);
969
+ z-index: 2;
970
+ }
971
+
972
+ .task-page.is-left {
973
+ transform: perspective(1200px) rotateY(-12deg) translateX(-26px) scale(0.97);
974
+ }
975
+
976
+ .task-page-inner {
977
+ display: grid;
978
+ gap: 18px;
979
+ }
980
+
981
+ .task-page-grid {
982
+ display: grid;
983
+ grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
984
+ gap: 22px;
985
+ }
986
+
987
+ .task-page-content {
988
+ display: grid;
989
+ gap: 14px;
990
+ align-content: start;
991
+ }
992
+
993
+ .task-page-media {
994
+ display: grid;
995
+ gap: 12px;
996
+ }
997
+
998
+ .is-hidden {
999
+ display: none !important;
1000
+ }
1001
+
1002
+ .clue-modal {
1003
+ position: fixed;
1004
+ inset: 0;
1005
+ display: none;
1006
+ align-items: center;
1007
+ justify-content: center;
1008
+ z-index: 50;
1009
+ padding: 24px;
1010
+ }
1011
+
1012
+ .clue-modal.is-open {
1013
+ display: flex;
1014
+ }
1015
+
1016
+ .clue-modal-backdrop {
1017
+ position: absolute;
1018
+ inset: 0;
1019
+ background: linear-gradient(135deg, rgba(24, 57, 34, 0.78), rgba(76, 148, 97, 0.48), rgba(246, 184, 91, 0.42));
1020
+ backdrop-filter: blur(16px);
1021
+ }
1022
+
1023
+ .clue-modal-dialog {
1024
+ position: relative;
1025
+ z-index: 1;
1026
+ width: min(760px, 100%);
1027
+ display: grid;
1028
+ gap: 12px;
1029
+ }
1030
+
1031
+ .clue-close-btn {
1032
+ justify-self: end;
1033
+ }
1034
+
1035
+ .clue-gradient-panel {
1036
+ padding: 18px;
1037
+ border-radius: 30px;
1038
+ background: linear-gradient(145deg, rgba(255, 255, 255, 0.94), rgba(223, 245, 228, 0.86), rgba(255, 241, 214, 0.9));
1039
+ box-shadow: 0 30px 90px rgba(20, 51, 29, 0.28);
1040
+ }
1041
+
1042
+ .clue-gradient-panel img {
1043
+ width: 100%;
1044
+ border-radius: 24px;
1045
+ max-height: 72vh;
1046
+ object-fit: contain;
1047
+ background: rgba(255, 255, 255, 0.72);
1048
+ }
1049
+
1050
+ .review-live-grid .review-grid {
1051
+ grid-template-columns: 1fr;
1052
+ }
1053
+
1054
+ .presence-item,
1055
+ .activity-actions {
1056
+ display: flex;
1057
+ align-items: center;
1058
+ justify-content: space-between;
1059
+ gap: 12px;
1060
+ }
1061
+
1062
+ .inset-rank-card::after,
1063
+ .hero-side-rank::after {
1064
+ display: none;
1065
+ }
1066
+
1067
+ @media (max-width: 1080px) {
1068
+ .leaderboard-hero,
1069
+ .task-page-grid,
1070
+ .users-admin-grid,
1071
+ .presence-admin-grid,
1072
+ .review-live-grid {
1073
+ grid-template-columns: 1fr;
1074
+ }
1075
+
1076
+ .hero-side-rank {
1077
+ width: 100%;
1078
+ }
1079
+ }
1080
+
1081
+ @media (max-width: 720px) {
1082
+ .bulk-toolbar,
1083
+ .task-carousel-head,
1084
+ .carousel-controls,
1085
+ .presence-item,
1086
+ .activity-actions {
1087
+ grid-template-columns: 1fr;
1088
+ flex-direction: column;
1089
+ align-items: stretch;
1090
+ }
1091
+
1092
+ .task-flip-book {
1093
+ min-height: 900px;
1094
+ }
1095
+ }
app/templates/account.html ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block content %}
4
+ <section class="hero-card">
5
+ <div>
6
+ <p class="eyebrow">Account Center</p>
7
+ <h2>{{ identity_name }}</h2>
8
+ <p class="lead">可以在这里修改登录密码,并查看与你相关的小组成员名单。</p>
9
+ </div>
10
+ <div class="hero-badges">
11
+ <span class="pill">{{ identity_label }}</span>
12
+ <span class="pill">{{ group_cards|length }} 个小组视图</span>
13
+ </div>
14
+ </section>
15
+
16
+ <section class="page-grid admin-page-grid">
17
+ <article class="glass-card form-panel">
18
+ <div class="section-head">
19
+ <div>
20
+ <p class="eyebrow">Password</p>
21
+ <h3>修改密码</h3>
22
+ </div>
23
+ </div>
24
+ <form method="post" action="/account/password" class="form-stack">
25
+ <label>
26
+ <span>当前密码</span>
27
+ <input type="password" name="current_password" required />
28
+ </label>
29
+ <label>
30
+ <span>新密码</span>
31
+ <input type="password" name="new_password" required />
32
+ </label>
33
+ <label>
34
+ <span>确认新密码</span>
35
+ <input type="password" name="confirm_password" required />
36
+ </label>
37
+ <button class="btn btn-primary" type="submit">更新密码</button>
38
+ </form>
39
+ </article>
40
+
41
+ <article class="glass-card table-panel">
42
+ <div class="section-head">
43
+ <div>
44
+ <p class="eyebrow">Groups</p>
45
+ <h3>小组信息</h3>
46
+ </div>
47
+ </div>
48
+ <div class="stack-list">
49
+ {% for group_card in group_cards %}
50
+ <article class="stack-item stack-item-block">
51
+ <div>
52
+ <strong>{{ group_card.name }}</strong>
53
+ <p class="muted">仅展示成员姓名</p>
54
+ </div>
55
+ <div class="chip-row">
56
+ {% for member_name in group_card.members %}
57
+ <span class="chip">{{ member_name }}</span>
58
+ {% else %}
59
+ <span class="chip">暂无成员</span>
60
+ {% endfor %}
61
+ </div>
62
+ </article>
63
+ {% else %}
64
+ <p class="muted">当前没有可查看的小组信息。</p>
65
+ {% endfor %}
66
+ </div>
67
+ </article>
68
+ </section>
69
+ {% endblock %}
app/templates/activity_detail.html CHANGED
@@ -1,190 +1,350 @@
1
  {% extends 'base.html' %}
2
 
3
  {% block content %}
4
- <section class="hero-card activity-hero">
5
- <div>
6
  <a class="ghost-link" href="/dashboard">返回活动列表</a>
7
  <p class="eyebrow">Activity Detail</p>
8
  <h2>{{ activity.title }}</h2>
9
  <p class="lead">{{ activity.description or '管理员暂未填写活动说明。' }}</p>
 
 
 
 
 
10
  </div>
11
- <div class="hero-badges">
12
- <span class="pill">开始 {{ activity.start_at|datetime_local }}</span>
13
- <span class="pill">截止 {{ activity.deadline_at|datetime_local }}</span>
14
- <span class="pill">{{ activity.tasks|length }} 个任务</span>
15
- </div>
16
- </section>
17
 
18
- <section class="task-grid">
19
- {% for task in activity.tasks %}
20
- {% set submission = submission_by_task.get(task.id) %}
21
- {% set clue_ready = clue_states.get(task.id) %}
22
- <article class="glass-card task-card" data-task-card data-task-id="{{ task.id }}" data-clue-released="{{ 'true' if clue_ready else 'false' }}">
23
- <div class="task-card-head">
24
- <div>
25
- <p class="eyebrow">Task {{ loop.index }}</p>
26
- <h3>{{ task.title }}</h3>
27
- </div>
28
- <button
29
- class="clue-toggle {% if clue_ready %}is-ready{% endif %}"
30
- type="button"
31
- data-clue-toggle
32
- data-primary-url="/media/tasks/{{ task.id }}/image"
33
- data-clue-url="/media/tasks/{{ task.id }}/clue"
34
- {% if not task.clue_image_filename %}disabled{% endif %}
35
- title="{% if clue_ready %}点击查看线索{% elif task.clue_image_filename %}线索尚未发布{% else %}未设置线索图{% endif %}"
36
- >
37
- 💡
38
- </button>
39
  </div>
40
- <p class="muted task-description">{{ task.description or '到达对应打卡点后上传一张清晰照片。' }}</p>
41
-
42
- <div class="task-media-wrap">
43
- <img
44
- src="/media/tasks/{{ task.id }}/image"
45
- alt="{{ task.title }}"
46
- class="task-media"
47
- data-task-image
48
- />
49
- {% if task.clue_release_at %}
50
- <div class="release-note">线索发布时间:{{ task.clue_release_at|datetime_local }}</div>
51
- {% endif %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  </div>
 
 
 
 
 
53
 
54
- <div class="task-status-row">
55
- {% if submission %}
56
- {% if submission.status == 'approved' %}
57
- <span class="status-badge status-approved">打卡成功</span>
58
- {% elif submission.status == 'rejected' %}
59
- <span class="status-badge status-rejected">打卡失败</span>
60
- {% else %}
61
- <span class="status-badge">等待审核</span>
62
- {% endif %}
63
- <span class="mini-note">最近提交:{{ submission.created_at|datetime_local }}</span>
64
- {% else %}
65
- <span class="status-badge">尚未上传</span>
66
- {% endif %}
67
- </div>
68
 
69
- {% if submission and submission.feedback %}
70
- <p class="feedback-box">审核备注:{{ submission.feedback }}</p>
71
- {% endif %}
 
 
 
 
 
 
 
 
 
 
72
 
73
- {% if submission %}
74
- <div class="submission-preview">
75
- <span class="mini-note">我的提交预览</span>
76
- <img src="/media/submissions/{{ submission.id }}" alt="{{ task.title }} 提交预览" />
77
- </div>
78
- {% endif %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
- <form action="/activities/{{ activity.id }}/tasks/{{ task.id }}/submit" method="post" enctype="multipart/form-data" class="upload-form">
81
- <label class="upload-label">
82
- <span>上传打卡照片</span>
83
- <input type="file" name="photo" accept="image/*" {% if submission and submission.status == 'approved' %}disabled{% endif %} required />
84
- </label>
85
- <button class="btn btn-primary" type="submit" {% if submission and submission.status == 'approved' %}disabled{% endif %}>提交审核</button>
86
- </form>
87
- </article>
88
- {% endfor %}
89
- </section>
90
 
91
- <section class="glass-card leaderboard-card">
92
- <div class="section-head">
93
- <div>
94
- <p class="eyebrow">Live Ranking</p>
95
- <h3>实时排行榜</h3>
96
- </div>
97
- {% if not activity.leaderboard_visible %}
98
- <span class="status-badge">管理员已隐藏</span>
99
- {% endif %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  </div>
 
101
 
102
- {% if activity.leaderboard_visible %}
103
- <div class="rank-table-wrap">
104
- <table class="rank-table">
105
- <thead>
106
- <tr>
107
- <th>排名</th>
108
- <th>小组</th>
109
- <th>完成打卡点</th>
110
- <th>成员数</th>
111
- <th>总耗时</th>
112
- </tr>
113
- </thead>
114
- <tbody>
115
- {% for row in leaderboard %}
116
- <tr>
117
- <td>#{{ loop.index }}</td>
118
- <td>{{ row.group_name }}</td>
119
- <td>{{ row.completed_count }}</td>
120
- <td>{{ row.member_count }}</td>
121
- <td>{{ row.total_elapsed|duration_human }}</td>
122
- </tr>
123
- {% else %}
124
- <tr>
125
- <td colspan="5">还没有通过审核的打卡记录,排行榜会在成功打卡后自动更新。</td>
126
- </tr>
127
- {% endfor %}
128
- </tbody>
129
- </table>
130
  </div>
131
- {% else %}
132
- <p class="muted">当前活动的排行榜暂不对用户开放。</p>
133
- {% endif %}
134
- </section>
135
 
136
  <script>
137
  (() => {
138
- const cards = Array.from(document.querySelectorAll('[data-task-card]'));
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  const seenReleased = new Set(
140
- cards.filter((card) => card.dataset.clueReleased === 'true').map((card) => card.dataset.taskId)
141
  );
142
 
143
- cards.forEach((card) => {
144
- const button = card.querySelector('[data-clue-toggle]');
145
- const image = card.querySelector('[data-task-image]');
146
- if (!button || !image) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
 
 
 
148
  button.addEventListener('click', () => {
149
  if (!button.classList.contains('is-ready')) return;
150
- const showingClue = button.dataset.mode === 'clue';
151
- image.src = showingClue ? button.dataset.primaryUrl : button.dataset.clueUrl;
152
- button.dataset.mode = showingClue ? 'primary' : 'clue';
153
- button.classList.toggle('is-clue-view', !showingClue);
154
  });
155
  });
156
 
157
- const isMobileLike = window.matchMedia('(pointer: coarse)').matches;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
 
159
- const poll = async () => {
160
  try {
161
- const response = await fetch('/api/activities/{{ activity.id }}/clues', { headers: { 'X-Requested-With': 'fetch' } });
162
- if (!response.ok) return;
163
- const payload = await response.json();
164
- (payload.released_task_ids || []).forEach((taskId) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  const stringId = String(taskId);
166
- const card = cards.find((item) => item.dataset.taskId === stringId);
167
- if (!card) return;
168
- const button = card.querySelector('[data-clue-toggle]');
169
  if (!button) return;
170
  button.classList.add('is-ready');
171
- card.dataset.clueReleased = 'true';
172
  if (!seenReleased.has(stringId)) {
173
  seenReleased.add(stringId);
174
- card.classList.add('pulse-highlight');
175
- setTimeout(() => card.classList.remove('pulse-highlight'), 1800);
176
- if (isMobileLike && navigator.vibrate) {
177
  navigator.vibrate([220, 80, 220]);
178
  }
179
  }
180
  });
181
  } catch (error) {
182
- console.debug('clue polling skipped', error);
183
  }
184
  };
185
 
186
- window.setInterval(poll, 20000);
 
187
  })();
188
  </script>
189
  {% endblock %}
190
-
 
1
  {% extends 'base.html' %}
2
 
3
  {% block content %}
4
+ <section class="hero-card leaderboard-hero">
5
+ <div class="hero-split-main">
6
  <a class="ghost-link" href="/dashboard">返回活动列表</a>
7
  <p class="eyebrow">Activity Detail</p>
8
  <h2>{{ activity.title }}</h2>
9
  <p class="lead">{{ activity.description or '管理员暂未填写活动说明。' }}</p>
10
+ <div class="hero-badges">
11
+ <span class="pill">开始 {{ activity.start_at|datetime_local }}</span>
12
+ <span class="pill">截止 {{ activity.deadline_at|datetime_local }}</span>
13
+ <span class="pill" id="hero-progress-pill">小组完成 {{ group_progress.approved_count }}/{{ group_progress.total_tasks }}</span>
14
+ </div>
15
  </div>
 
 
 
 
 
 
16
 
17
+ <div class="hero-side-rank glass-card inset-rank-card" id="leaderboard-panel">
18
+ <div class="section-head compact-head">
19
+ <div>
20
+ <p class="eyebrow">Live Ranking</p>
21
+ <h3>实时排行榜</h3>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  </div>
23
+ {% if not activity.leaderboard_visible %}
24
+ <span class="status-badge">管理员已隐藏</span>
25
+ {% endif %}
26
+ </div>
27
+ {% if activity.leaderboard_visible %}
28
+ <div class="rank-table-wrap">
29
+ <table class="rank-table" id="leaderboard-table">
30
+ <thead>
31
+ <tr>
32
+ <th>排名</th>
33
+ <th>小组</th>
34
+ <th>完成</th>
35
+ <th>人数</th>
36
+ <th>耗时</th>
37
+ </tr>
38
+ </thead>
39
+ <tbody>
40
+ {% for row in leaderboard %}
41
+ <tr>
42
+ <td>#{{ loop.index }}</td>
43
+ <td>{{ row.group_name }}</td>
44
+ <td>{{ row.completed_count }}</td>
45
+ <td>{{ row.member_count }}</td>
46
+ <td>{{ row.total_elapsed|duration_human }}</td>
47
+ </tr>
48
+ {% else %}
49
+ <tr>
50
+ <td colspan="5">还没有通过审核的打卡记录。</td>
51
+ </tr>
52
+ {% endfor %}
53
+ </tbody>
54
+ </table>
55
  </div>
56
+ {% else %}
57
+ <p class="muted">当前活动的排行榜暂不对用户开放。</p>
58
+ {% endif %}
59
+ </div>
60
+ </section>
61
 
62
+ {% if not user.group %}
63
+ <section class="glass-card empty-state">
64
+ <h3>你还没有加入小组</h3>
65
+ <p>当前活动采用小组共享进度模式,请先联系管理员分组后再参与��。</p>
66
+ </section>
67
+ {% endif %}
 
 
 
 
 
 
 
 
68
 
69
+ <section class="task-carousel-shell">
70
+ <div class="task-carousel-head">
71
+ <div class="chip-row">
72
+ <span class="chip" id="approved-progress-chip">已完成 {{ group_progress.approved_count }}</span>
73
+ <span class="chip" id="pending-progress-chip">待审核 {{ group_progress.pending_count }}</span>
74
+ <span class="chip" id="rejected-progress-chip">被驳回 {{ group_progress.rejected_count }}</span>
75
+ </div>
76
+ <div class="carousel-controls">
77
+ <button class="btn btn-secondary small-btn" type="button" id="prev-task-btn">上一张</button>
78
+ <span class="mini-note" id="task-counter">1 / {{ activity.tasks|length }}</span>
79
+ <button class="btn btn-secondary small-btn" type="button" id="next-task-btn">下一张</button>
80
+ </div>
81
+ </div>
82
 
83
+ <div class="task-flip-book" id="task-flip-book">
84
+ {% for task in activity.tasks %}
85
+ {% set submission = group_submission_by_task.get(task.id) %}
86
+ {% set clue_ready = clue_states.get(task.id) %}
87
+ <article class="glass-card task-page {% if loop.first %}is-active{% endif %}" data-task-page data-task-index="{{ loop.index0 }}" data-task-id="{{ task.id }}" data-clue-released="{{ 'true' if clue_ready else 'false' }}">
88
+ <div class="task-page-inner">
89
+ <div class="task-card-head">
90
+ <div>
91
+ <p class="eyebrow">Task {{ loop.index }}</p>
92
+ <h3>{{ task.title }}</h3>
93
+ </div>
94
+ <button
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>
104
+ </div>
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 %}
112
+ </div>
 
 
 
113
 
114
+ <div class="task-page-content">
115
+ <p class="muted task-description">{{ task.description or '到达对应打卡点后,由小组任意成员上传一张清晰照片即可。' }}</p>
116
+ <div class="task-status-row" data-status-wrap>
117
+ {% if submission %}
118
+ {% if submission.status == 'approved' %}
119
+ <span class="status-badge status-approved">小组打卡成功</span>
120
+ {% elif submission.status == 'rejected' %}
121
+ <span class="status-badge status-rejected">小组打卡失败</span>
122
+ {% else %}
123
+ <span class="status-badge">等待审核</span>
124
+ {% endif %}
125
+ <span class="mini-note" data-status-note>上传人:{{ submission.user.full_name if submission.user else '未知成员' }} · {{ submission.created_at|datetime_local }}</span>
126
+ {% else %}
127
+ <span class="status-badge">尚未上传</span>
128
+ <span class="mini-note" data-status-note>当前点位还没有小组提交记录。</span>
129
+ {% endif %}
130
+ </div>
131
+
132
+ <p class="feedback-box {% if not submission or not submission.feedback %}is-hidden{% endif %}" data-feedback-box>
133
+ 审核备注:{{ submission.feedback if submission and submission.feedback else '' }}
134
+ </p>
135
+
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
+
148
+ <form action="/activities/{{ activity.id }}/tasks/{{ task.id }}/submit" method="post" enctype="multipart/form-data" class="upload-form" data-upload-form>
149
+ <label class="upload-label">
150
+ <span>上传小组打卡照片</span>
151
+ <input type="file" name="photo" accept="image/*" data-upload-input {% if submission and submission.status == 'approved' or not user.group %}disabled{% endif %} required />
152
+ </label>
153
+ <button class="btn btn-primary" type="submit" data-upload-submit {% if submission and submission.status == 'approved' or not user.group %}disabled{% endif %}>提交审核</button>
154
+ </form>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ </article>
159
+ {% endfor %}
160
  </div>
161
+ </section>
162
 
163
+ <div class="clue-modal" id="clue-modal" aria-hidden="true">
164
+ <div class="clue-modal-backdrop"></div>
165
+ <div class="clue-modal-dialog">
166
+ <button class="btn btn-ghost small-btn clue-close-btn" type="button" id="clue-close-btn">关闭</button>
167
+ <div class="clue-gradient-panel">
168
+ <img src="" alt="线索提示图" id="clue-modal-image" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  </div>
170
+ </div>
171
+ </div>
 
 
172
 
173
  <script>
174
  (() => {
175
+ const pages = Array.from(document.querySelectorAll('[data-task-page]'));
176
+ if (!pages.length) return;
177
+
178
+ const counter = document.getElementById('task-counter');
179
+ const prevBtn = document.getElementById('prev-task-btn');
180
+ const nextBtn = document.getElementById('next-task-btn');
181
+ const clueModal = document.getElementById('clue-modal');
182
+ const clueModalImage = document.getElementById('clue-modal-image');
183
+ const clueCloseBtn = document.getElementById('clue-close-btn');
184
+ const heroProgressPill = document.getElementById('hero-progress-pill');
185
+ const approvedProgressChip = document.getElementById('approved-progress-chip');
186
+ const pendingProgressChip = document.getElementById('pending-progress-chip');
187
+ const rejectedProgressChip = document.getElementById('rejected-progress-chip');
188
+ let currentIndex = 0;
189
  const seenReleased = new Set(
190
+ pages.filter((page) => page.dataset.clueReleased === 'true').map((page) => page.dataset.taskId)
191
  );
192
 
193
+ const formatStatusNote = (item) => {
194
+ if (!item.uploader_name) {
195
+ return '当前点位还没有小组提交记录。';
196
+ }
197
+ const parts = [`上传人:${item.uploader_name}`];
198
+ if (item.submitted_at) parts.push(item.submitted_at);
199
+ if (item.reviewer_name && item.status !== 'pending') parts.push(`审核人:${item.reviewer_name}`);
200
+ return parts.join(' · ');
201
+ };
202
+
203
+ const setActivePage = (index) => {
204
+ currentIndex = (index + pages.length) % pages.length;
205
+ pages.forEach((page, pageIndex) => {
206
+ page.classList.toggle('is-active', pageIndex === currentIndex);
207
+ page.classList.toggle('is-left', pageIndex < currentIndex);
208
+ });
209
+ if (counter) counter.textContent = `${currentIndex + 1} / ${pages.length}`;
210
+ };
211
+
212
+ prevBtn?.addEventListener('click', () => setActivePage(currentIndex - 1));
213
+ nextBtn?.addEventListener('click', () => setActivePage(currentIndex + 1));
214
+ setActivePage(0);
215
+
216
+ const closeClueModal = () => {
217
+ clueModal?.classList.remove('is-open');
218
+ if (clueModal) clueModal.setAttribute('aria-hidden', 'true');
219
+ };
220
+
221
+ clueCloseBtn?.addEventListener('click', closeClueModal);
222
+ clueModal?.addEventListener('click', (event) => {
223
+ if (event.target === clueModal || event.target.classList.contains('clue-modal-backdrop')) {
224
+ closeClueModal();
225
+ }
226
+ });
227
 
228
+ pages.forEach((page) => {
229
+ const button = page.querySelector('[data-clue-toggle]');
230
+ if (!button) return;
231
  button.addEventListener('click', () => {
232
  if (!button.classList.contains('is-ready')) return;
233
+ if (clueModalImage) clueModalImage.src = button.dataset.clueUrl;
234
+ clueModal?.classList.add('is-open');
235
+ clueModal?.setAttribute('aria-hidden', 'false');
 
236
  });
237
  });
238
 
239
+ const refreshLeaderboard = (rows) => {
240
+ const table = document.getElementById('leaderboard-table');
241
+ if (!table) return;
242
+ const body = table.querySelector('tbody');
243
+ if (!body) return;
244
+ if (!rows.length) {
245
+ body.innerHTML = '<tr><td colspan="5">还没有通过审核的打卡记录。</td></tr>';
246
+ return;
247
+ }
248
+ body.innerHTML = rows.map((row, index) => `
249
+ <tr>
250
+ <td>#${index + 1}</td>
251
+ <td>${row.group_name}</td>
252
+ <td>${row.completed_count}</td>
253
+ <td>${row.member_count}</td>
254
+ <td>${row.total_elapsed} 分钟</td>
255
+ </tr>`).join('');
256
+ };
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
  }
344
  };
345
 
346
+ refreshStatuses();
347
+ window.setInterval(refreshStatuses, 10000);
348
  })();
349
  </script>
350
  {% endblock %}
 
app/templates/admin_activity_edit.html CHANGED
@@ -51,6 +51,48 @@
51
  </label>
52
  </section>
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  <section class="glass-card form-panel wide-panel">
55
  <div class="section-head">
56
  <div>
 
51
  </label>
52
  </section>
53
 
54
+ <section class="glass-card leaderboard-card wide-panel">
55
+ <div class="section-head compact-head">
56
+ <div>
57
+ <p class="eyebrow">Live Ranking</p>
58
+ <h3>活动实时排行榜</h3>
59
+ <p class="mini-note">管理员始终可见,用于现场统筹和审核判断;用户是否可见由上方开关控制。</p>
60
+ </div>
61
+ <div class="chip-row">
62
+ <span class="chip">用户端{{ '可见' if activity.leaderboard_visible else '隐藏' }}</span>
63
+ <span class="chip">按完成点位数优先,再按总耗时排序</span>
64
+ </div>
65
+ </div>
66
+ <div class="rank-table-wrap">
67
+ <table class="rank-table">
68
+ <thead>
69
+ <tr>
70
+ <th>排名</th>
71
+ <th>小组</th>
72
+ <th>完成</th>
73
+ <th>人数</th>
74
+ <th>总耗时</th>
75
+ </tr>
76
+ </thead>
77
+ <tbody>
78
+ {% for row in leaderboard %}
79
+ <tr>
80
+ <td>#{{ loop.index }}</td>
81
+ <td>{{ row.group_name }}</td>
82
+ <td>{{ row.completed_count }}</td>
83
+ <td>{{ row.member_count }}</td>
84
+ <td>{{ row.total_elapsed|duration_human }}</td>
85
+ </tr>
86
+ {% else %}
87
+ <tr>
88
+ <td colspan="5">当前还没有通过审核的小组打卡记录。</td>
89
+ </tr>
90
+ {% endfor %}
91
+ </tbody>
92
+ </table>
93
+ </div>
94
+ </section>
95
+
96
  <section class="glass-card form-panel wide-panel">
97
  <div class="section-head">
98
  <div>
app/templates/admin_admins.html CHANGED
@@ -1,7 +1,7 @@
1
  {% extends 'base.html' %}
2
 
3
  {% block content %}
4
- <section class="page-grid admin-page-grid">
5
  <article class="glass-card form-panel">
6
  <div class="section-head">
7
  <div>
@@ -59,4 +59,98 @@
59
  </div>
60
  </article>
61
  </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  {% endblock %}
 
1
  {% extends 'base.html' %}
2
 
3
  {% block content %}
4
+ <section class="page-grid admin-page-grid presence-admin-grid">
5
  <article class="glass-card form-panel">
6
  <div class="section-head">
7
  <div>
 
59
  </div>
60
  </article>
61
  </section>
62
+
63
+ <section class="page-grid admin-page-grid presence-admin-grid">
64
+ <article class="glass-card table-panel">
65
+ <div class="section-head">
66
+ <div>
67
+ <p class="eyebrow">Presence</p>
68
+ <h3>管理员在线状态</h3>
69
+ </div>
70
+ </div>
71
+ <div class="stack-list" id="admin-presence-list">
72
+ {% for item in admin_statuses %}
73
+ <div class="stack-item presence-item">
74
+ <div>
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>
85
+
86
+ <article class="glass-card table-panel">
87
+ <div class="section-head">
88
+ <div>
89
+ <p class="eyebrow">Presence</p>
90
+ <h3>所有用户在线状态</h3>
91
+ </div>
92
+ </div>
93
+ <div class="stack-list" id="user-presence-list">
94
+ {% for item in user_statuses %}
95
+ <div class="stack-item presence-item">
96
+ <div>
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>
107
+ </section>
108
+
109
+ <script>
110
+ (() => {
111
+ const adminList = document.getElementById('admin-presence-list');
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 %}
app/templates/admin_dashboard.html CHANGED
@@ -10,6 +10,7 @@
10
  <div class="hero-badges">
11
  <span class="pill">角色 {{ '超级管理员' if admin.role == 'superadmin' else '管理员' }}</span>
12
  <span class="pill">待审核 {{ stats.pending_count }} 项</span>
 
13
  </div>
14
  </section>
15
 
@@ -27,8 +28,8 @@
27
  <strong>{{ stats.activity_count }}</strong>
28
  </article>
29
  <article class="glass-card stat-card">
30
- <span>管理员数量</span>
31
- <strong>{{ stats.admin_count }}</strong>
32
  </article>
33
  </section>
34
 
@@ -70,4 +71,48 @@
70
  </div>
71
  </article>
72
  </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  {% endblock %}
 
10
  <div class="hero-badges">
11
  <span class="pill">角色 {{ '超级管理员' if admin.role == 'superadmin' else '管理员' }}</span>
12
  <span class="pill">待审核 {{ stats.pending_count }} 项</span>
13
+ <span class="pill">在线管理员 {{ stats.online_admin_count }}</span>
14
  </div>
15
  </section>
16
 
 
28
  <strong>{{ stats.activity_count }}</strong>
29
  </article>
30
  <article class="glass-card stat-card">
31
+ <span>在线用户</span>
32
+ <strong>{{ stats.online_user_count }}</strong>
33
  </article>
34
  </section>
35
 
 
71
  </div>
72
  </article>
73
  </section>
74
+
75
+ {% if online_overview %}
76
+ <section class="page-grid admin-page-grid">
77
+ <article class="glass-card table-panel">
78
+ <div class="section-head">
79
+ <div>
80
+ <p class="eyebrow">Presence</p>
81
+ <h3>管理员在线状态</h3>
82
+ </div>
83
+ </div>
84
+ <div class="stack-list">
85
+ {% for item in online_overview.admins %}
86
+ <div class="stack-item">
87
+ <strong>{{ item.name }}</strong>
88
+ <span class="status-badge {% if item.is_online %}status-approved{% else %}status-rejected{% endif %}">
89
+ {{ '在线' if item.is_online else '离线' }} · {{ item.last_seen_at }}
90
+ </span>
91
+ </div>
92
+ {% endfor %}
93
+ </div>
94
+ </article>
95
+
96
+ <article class="glass-card table-panel">
97
+ <div class="section-head">
98
+ <div>
99
+ <p class="eyebrow">Presence</p>
100
+ <h3>用户在线状态</h3>
101
+ </div>
102
+ </div>
103
+ <div class="stack-list">
104
+ {% for item in online_overview.users[:18] %}
105
+ <div class="stack-item">
106
+ <strong>{{ item.name }}</strong>
107
+ <span class="status-badge {% if item.is_online %}status-approved{% else %}status-rejected{% endif %}">
108
+ {{ '在线' if item.is_online else '离线' }} · {{ item.last_seen_at }}
109
+ </span>
110
+ </div>
111
+ {% else %}
112
+ <p class="muted">暂无用户。</p>
113
+ {% endfor %}
114
+ </div>
115
+ </article>
116
+ </section>
117
+ {% endif %}
118
  {% endblock %}
app/templates/admin_reviews.html CHANGED
@@ -5,22 +5,18 @@
5
  <div class="section-head">
6
  <div>
7
  <p class="eyebrow">Review Center</p>
8
- <h3>图片审核与批量下载</h3>
 
 
 
 
 
 
 
9
  </div>
10
- <form id="download-form" method="post" action="/admin/reviews/download" class="inline-form">
11
- <button class="btn btn-primary" type="submit">下载已勾选图片</button>
12
- </form>
13
  </div>
14
 
15
  <form method="get" action="/admin/reviews" class="form-grid cols-3 review-filter-form">
16
- <label>
17
- <span>审核状态</span>
18
- <select name="status">
19
- <option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>待审核</option>
20
- <option value="approved" {% if status_filter == 'approved' %}selected{% endif %}>已通过</option>
21
- <option value="rejected" {% if status_filter == 'rejected' %}selected{% endif %}>已驳回</option>
22
- </select>
23
- </label>
24
  <label>
25
  <span>活动筛选</span>
26
  <select name="activity_id">
@@ -37,49 +33,204 @@
37
  </form>
38
  </section>
39
 
40
- <section class="review-grid">
41
- {% for submission in submissions %}
42
- <article class="glass-card review-card">
43
- <div class="card-topline">
44
- <label class="checkbox-row compact-checkbox">
45
- <input type="checkbox" name="submission_ids" value="{{ submission.id }}" form="download-form" />
46
- <span>加入下载</span>
47
- </label>
48
- {% if submission.status == 'approved' %}
49
- <span class="status-badge status-approved">打卡成功</span>
50
- {% elif submission.status == 'rejected' %}
51
- <span class="status-badge status-rejected">打卡失败</span>
52
- {% else %}
53
- <span class="status-badge">待审核</span>
54
- {% endif %}
55
  </div>
56
- <h3>{{ submission.task.title }}</h3>
57
- <p class="muted">{{ submission.task.activity.title }}</p>
58
- <p class="muted">{{ submission.user.full_name }} · {{ submission.user.student_id }}</p>
59
- <img class="review-image" src="/media/submissions/{{ submission.id }}" alt="{{ submission.task.title }}" />
60
- <p class="mini-note">提交时间:{{ submission.created_at|datetime_local }}</p>
61
- {% if submission.feedback %}
62
- <p class="feedback-box">当前备注:{{ submission.feedback }}</p>
63
- {% endif %}
64
- <a class="btn btn-ghost full-width-btn" href="/media/submissions/{{ submission.id }}?download=1">单张下载</a>
65
- <form method="post" action="/admin/submissions/{{ submission.id }}/review" class="form-stack compact-form">
66
- <label>
67
- <span>审核备注</span>
68
- <textarea name="feedback" rows="2" placeholder="可填写通过说明或驳回原因">{{ submission.feedback or '' }}</textarea>
69
- </label>
70
- <div class="action-grid two-actions">
71
- <button class="btn btn-primary" type="submit" name="decision" value="approved">审核通过</button>
72
- <button class="btn btn-danger" type="submit" name="decision" value="rejected">审核驳回</button>
73
- </div>
74
- </form>
75
- </article>
76
- {% else %}
77
- <article class="empty-state">
78
- <h3>当前没有符合条件的提交记录</h3>
79
- <p>切换筛选条件后再看看,或者等待用户上传打卡照片。</p>
80
- </article>
81
- {% endfor %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  {% endblock %}
84
-
85
-
 
5
  <div class="section-head">
6
  <div>
7
  <p class="eyebrow">Review Center</p>
8
+ <h3>实时审核中心</h3>
9
+ <p class="mini-note" id="review-live-note">待审核内容会自动分配给在线管理员,页面会持续刷新。</p>
10
+ </div>
11
+ <div class="hero-badges">
12
+ <span class="pill" id="online-admin-pill">当前在线管理员 {{ online_admin_count }}</span>
13
+ <form id="download-form" method="post" action="/admin/reviews/download" class="inline-form">
14
+ <button class="btn btn-primary" type="submit">下载已勾选图片</button>
15
+ </form>
16
  </div>
 
 
 
17
  </div>
18
 
19
  <form method="get" action="/admin/reviews" class="form-grid cols-3 review-filter-form">
 
 
 
 
 
 
 
 
20
  <label>
21
  <span>活动筛选</span>
22
  <select name="activity_id">
 
33
  </form>
34
  </section>
35
 
36
+ <section class="page-grid admin-page-grid review-live-grid">
37
+ <article class="glass-card table-panel">
38
+ <div class="section-head">
39
+ <div>
40
+ <p class="eyebrow">Assigned Queue</p>
41
+ <h3>分配给我的待审核任务</h3>
 
 
 
 
 
 
 
 
 
42
  </div>
43
+ </div>
44
+ <div class="review-grid" id="assigned-review-grid">
45
+ {% for submission in assigned_submissions %}
46
+ <article class="glass-card review-card" data-review-card data-submission-id="{{ submission.id }}">
47
+ <div class="card-topline">
48
+ <label class="checkbox-row compact-checkbox">
49
+ <input type="checkbox" name="submission_ids" value="{{ submission.id }}" form="download-form" />
50
+ <span>加入下载</span>
51
+ </label>
52
+ <span class="status-badge">待审核</span>
53
+ </div>
54
+ <h3>{{ submission.task.title }}</h3>
55
+ <p class="muted">{{ submission.task.activity.title }}</p>
56
+ <p class="muted">{{ submission.group.name if submission.group else '未分组' }} · 上传人 {{ submission.user.full_name if submission.user else '未知成员' }}</p>
57
+ <img class="review-image" src="/media/submissions/{{ submission.id }}" alt="{{ submission.task.title }}" />
58
+ <p class="mini-note">提交时间:{{ submission.created_at|datetime_local }}</p>
59
+ <a class="btn btn-ghost full-width-btn" href="/media/submissions/{{ submission.id }}?download=1">单张下载</a>
60
+ <form method="post" action="/admin/submissions/{{ submission.id }}/review" class="form-stack compact-form review-action-form">
61
+ <label>
62
+ <span>审核备注</span>
63
+ <textarea name="feedback" rows="2" placeholder="可填写通过说明或驳回原因"></textarea>
64
+ </label>
65
+ <div class="action-grid two-actions">
66
+ <button class="btn btn-primary" type="submit" name="decision" value="approved">审核通过</button>
67
+ <button class="btn btn-danger" type="submit" name="decision" value="rejected">审核驳回</button>
68
+ </div>
69
+ </form>
70
+ </article>
71
+ {% else %}
72
+ <article class="empty-state" id="assigned-empty-state">
73
+ <h3>当前还没有分配给你的待审核内容</h3>
74
+ <p>保持页面打开,系统会自动把新的待审核任务分配给在线管理员。</p>
75
+ </article>
76
+ {% endfor %}
77
+ </div>
78
+ </article>
79
+
80
+ <article class="glass-card table-panel">
81
+ <div class="section-head">
82
+ <div>
83
+ <p class="eyebrow">Recent Reviews</p>
84
+ <h3>最近审核结果</h3>
85
+ </div>
86
+ </div>
87
+ <div class="stack-list" id="recent-review-list">
88
+ {% for submission in recent_submissions %}
89
+ <article class="stack-item stack-item-block">
90
+ <div>
91
+ <strong>{{ submission.task.activity.title }} · {{ submission.task.title }}</strong>
92
+ <p class="muted">{{ submission.group.name if submission.group else '未分组' }} · {{ submission.user.full_name if submission.user else '未知成员' }}</p>
93
+ </div>
94
+ <div class="chip-row">
95
+ <span class="status-badge {% if submission.status == 'approved' %}status-approved{% else %}status-rejected{% endif %}">
96
+ {{ '通过' if submission.status == 'approved' else '驳回' }}
97
+ </span>
98
+ <span class="chip">{{ submission.reviewed_by.display_name if submission.reviewed_by else '未知管理员' }}</span>
99
+ <span class="chip">{{ submission.reviewed_at|datetime_local if submission.reviewed_at else '-' }}</span>
100
+ </div>
101
+ </article>
102
+ {% else %}
103
+ <p class="muted">还没有最近审核记录。</p>
104
+ {% endfor %}
105
+ </div>
106
+ </article>
107
  </section>
108
+
109
+ <script>
110
+ (() => {
111
+ const assignedGrid = document.getElementById('assigned-review-grid');
112
+ const recentList = document.getElementById('recent-review-list');
113
+ const onlineAdminPill = document.getElementById('online-admin-pill');
114
+ const activityFilter = '{{ activity_filter }}';
115
+ const selectedIds = new Set(
116
+ Array.from(document.querySelectorAll('input[name="submission_ids"]:checked')).map((item) => item.value)
117
+ );
118
+
119
+ const syncSelection = (target) => {
120
+ if (!target || target.name !== 'submission_ids') return;
121
+ if (target.checked) {
122
+ selectedIds.add(target.value);
123
+ } else {
124
+ selectedIds.delete(target.value);
125
+ }
126
+ };
127
+
128
+ document.addEventListener('change', (event) => {
129
+ syncSelection(event.target);
130
+ });
131
+
132
+ const cardHtml = (item) => `
133
+ <article class="glass-card review-card" data-review-card data-submission-id="${item.id}">
134
+ <div class="card-topline">
135
+ <label class="checkbox-row compact-checkbox">
136
+ <input type="checkbox" name="submission_ids" value="${item.id}" form="download-form" ${selectedIds.has(String(item.id)) ? 'checked' : ''} />
137
+ <span>加入下载</span>
138
+ </label>
139
+ <span class="status-badge">待审核</span>
140
+ </div>
141
+ <h3>${item.task_title}</h3>
142
+ <p class="muted">${item.activity_title}</p>
143
+ <p class="muted">${item.group_name} · 上传人 ${item.uploader_name}</p>
144
+ <img class="review-image" src="${item.image_url}" alt="${item.task_title}" />
145
+ <p class="mini-note">提交时间:${item.created_at}</p>
146
+ <a class="btn btn-ghost full-width-btn" href="${item.download_url}">单张下载</a>
147
+ <form method="post" action="/admin/submissions/${item.id}/review" class="form-stack compact-form review-action-form">
148
+ <label>
149
+ <span>审核备注</span>
150
+ <textarea name="feedback" rows="2" placeholder="可填写通过说明或驳回原因"></textarea>
151
+ </label>
152
+ <div class="action-grid two-actions">
153
+ <button class="btn btn-primary" type="submit" name="decision" value="approved">审核通过</button>
154
+ <button class="btn btn-danger" type="submit" name="decision" value="rejected">审核驳回</button>
155
+ </div>
156
+ </form>
157
+ </article>`;
158
+
159
+ const recentHtml = (item) => `
160
+ <article class="stack-item stack-item-block">
161
+ <div>
162
+ <strong>${item.activity_title} · ${item.task_title}</strong>
163
+ <p class="muted">${item.group_name} · ${item.uploader_name}</p>
164
+ </div>
165
+ <div class="chip-row">
166
+ <span class="status-badge ${item.status === 'approved' ? 'status-approved' : 'status-rejected'}">
167
+ ${item.status === 'approved' ? '通过' : '驳回'}
168
+ </span>
169
+ <span class="chip">${item.reviewed_by_name || '未知管理员'}</span>
170
+ <span class="chip">${item.reviewed_at || '-'}</span>
171
+ </div>
172
+ </article>`;
173
+
174
+ const attachReviewForms = () => {
175
+ document.querySelectorAll('.review-action-form').forEach((form) => {
176
+ if (form.dataset.bound === 'true') return;
177
+ form.dataset.bound = 'true';
178
+ form.addEventListener('submit', async (event) => {
179
+ event.preventDefault();
180
+ const submitter = event.submitter;
181
+ const formData = new FormData(form);
182
+ if (submitter) {
183
+ formData.set(submitter.name, submitter.value);
184
+ }
185
+ const response = await fetch(form.action, {
186
+ method: 'POST',
187
+ headers: { 'X-Requested-With': 'fetch' },
188
+ body: formData,
189
+ });
190
+ if (!response.ok) {
191
+ refreshFeed();
192
+ return;
193
+ }
194
+ refreshFeed();
195
+ });
196
+ });
197
+ };
198
+
199
+ const refreshFeed = async () => {
200
+ try {
201
+ const search = activityFilter ? `?activity_id=${activityFilter}` : '';
202
+ const response = await fetch(`/api/admin/reviews/feed${search}`, { headers: { 'X-Requested-With': 'fetch' } });
203
+ if (!response.ok) return;
204
+ const payload = await response.json();
205
+
206
+ if (onlineAdminPill) {
207
+ onlineAdminPill.textContent = `当前在线管理员 ${payload.online_admin_count || 0}`;
208
+ }
209
+
210
+ if (payload.assigned_submissions && payload.assigned_submissions.length) {
211
+ assignedGrid.innerHTML = payload.assigned_submissions.map(cardHtml).join('');
212
+ } else {
213
+ assignedGrid.innerHTML = `
214
+ <article class="empty-state" id="assigned-empty-state">
215
+ <h3>当前还没有分配给你的待审核内容</h3>
216
+ <p>保持页面打开,系统会自动把新的待审核任务分配给在线管理员。</p>
217
+ </article>`;
218
+ }
219
+
220
+ if (payload.recent_submissions && payload.recent_submissions.length) {
221
+ recentList.innerHTML = payload.recent_submissions.map(recentHtml).join('');
222
+ } else {
223
+ recentList.innerHTML = '<p class="muted">还没有最近审核记录。</p>';
224
+ }
225
+ attachReviewForms();
226
+ } catch (error) {
227
+ console.debug('review feed refresh skipped', error);
228
+ }
229
+ };
230
+
231
+ attachReviewForms();
232
+ refreshFeed();
233
+ window.setInterval(refreshFeed, 5000);
234
+ })();
235
+ </script>
236
  {% endblock %}
 
 
app/templates/admin_users.html CHANGED
@@ -1,7 +1,7 @@
1
  {% extends 'base.html' %}
2
 
3
  {% block content %}
4
- <section class="page-grid admin-page-grid">
5
  <article class="glass-card form-panel">
6
  <div class="section-head">
7
  <div>
@@ -37,49 +37,115 @@
37
  </form>
38
  </article>
39
 
40
- <article class="glass-card table-panel">
41
  <div class="section-head">
42
  <div>
43
- <p class="eyebrow">Users</p>
44
- <h3>用户列表</h3>
45
  </div>
46
  </div>
47
- <div class="table-shell">
48
- <table class="data-table">
49
- <thead>
50
- <tr>
51
- <th>学号</th>
52
- <th>姓名</th>
53
- <th>当前小组</th>
54
- <th>调整小组</th>
55
- </tr>
56
- </thead>
57
- <tbody>
58
- {% for user_item in users %}
59
- <tr>
60
- <td>{{ user_item.student_id }}</td>
61
- <td>{{ user_item.full_name }}</td>
62
- <td>{{ user_item.group.name if user_item.group else '未分组' }}</td>
63
- <td>
64
- <form method="post" action="/admin/users/{{ user_item.id }}/group" class="inline-form">
65
- <select name="group_id">
66
- <option value="">未分组</option>
67
- {% for group in groups %}
68
- <option value="{{ group.id }}" {% if user_item.group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
69
- {% endfor %}
70
- </select>
71
- <button class="btn btn-secondary small-btn" type="submit">保存</button>
72
- </form>
73
- </td>
74
- </tr>
75
- {% else %}
76
- <tr>
77
- <td colspan="4">还没有用户,请先录入。</td>
78
- </tr>
79
  {% endfor %}
80
- </tbody>
81
- </table>
82
- </div>
 
 
83
  </article>
84
  </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  {% endblock %}
 
1
  {% extends 'base.html' %}
2
 
3
  {% block content %}
4
+ <section class="page-grid admin-page-grid users-admin-grid">
5
  <article class="glass-card form-panel">
6
  <div class="section-head">
7
  <div>
 
37
  </form>
38
  </article>
39
 
40
+ <article class="glass-card form-panel">
41
  <div class="section-head">
42
  <div>
43
+ <p class="eyebrow">Batch Import</p>
44
+ <h3>批量导入用户</h3>
45
  </div>
46
  </div>
47
+ <form method="post" action="/admin/users/import" class="form-stack">
48
+ <label>
49
+ <span>每行一人,格式:姓名 学号</span>
50
+ <textarea name="import_text" rows="8" placeholder="张三 20230001&#10;李四 20230002"></textarea>
51
+ </label>
52
+ <label>
53
+ <span>导入后分配到小组</span>
54
+ <select name="group_id">
55
+ <option value="">暂不分组</option>
56
+ {% for group in groups %}
57
+ <option value="{{ group.id }}">{{ group.name }}({{ group.members|length }}/{{ group.max_members }})</option>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  {% endfor %}
59
+ </select>
60
+ </label>
61
+ <p class="mini-note">默认密码为学号后六位,不足六位则使用完整学号。</p>
62
+ <button class="btn btn-secondary" type="submit">开始导入</button>
63
+ </form>
64
  </article>
65
  </section>
66
+
67
+ <section class="glass-card table-panel">
68
+ <div class="section-head">
69
+ <div>
70
+ <p class="eyebrow">Users</p>
71
+ <h3>用户列表与批量操作</h3>
72
+ </div>
73
+ </div>
74
+
75
+ <form id="bulk-users-form" method="post" action="/admin/users/bulk" class="bulk-toolbar">
76
+ <label>
77
+ <span>批量操作</span>
78
+ <select name="bulk_action" required>
79
+ <option value="assign_group">批量分组</option>
80
+ <option value="delete">批量删除</option>
81
+ </select>
82
+ </label>
83
+ <label>
84
+ <span>目标小组</span>
85
+ <select name="group_id">
86
+ <option value="">未分组</option>
87
+ {% for group in groups %}
88
+ <option value="{{ group.id }}">{{ group.name }}</option>
89
+ {% endfor %}
90
+ </select>
91
+ </label>
92
+ <button class="btn btn-primary" type="submit">执行批量操作</button>
93
+ </form>
94
+
95
+ <div class="table-shell">
96
+ <table class="data-table">
97
+ <thead>
98
+ <tr>
99
+ <th>
100
+ <input type="checkbox" data-check-all />
101
+ </th>
102
+ <th>学号</th>
103
+ <th>姓名</th>
104
+ <th>当前小组</th>
105
+ <th>调整小组</th>
106
+ </tr>
107
+ </thead>
108
+ <tbody>
109
+ {% for user_item in users %}
110
+ <tr>
111
+ <td>
112
+ <input type="checkbox" name="user_ids" value="{{ user_item.id }}" data-user-check form="bulk-users-form" />
113
+ </td>
114
+ <td>{{ user_item.student_id }}</td>
115
+ <td>{{ user_item.full_name }}</td>
116
+ <td>{{ user_item.group.name if user_item.group else '未分组' }}</td>
117
+ <td>
118
+ <form method="post" action="/admin/users/{{ user_item.id }}/group" class="inline-form">
119
+ <select name="group_id">
120
+ <option value="">未分组</option>
121
+ {% for group in groups %}
122
+ <option value="{{ group.id }}" {% if user_item.group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
123
+ {% endfor %}
124
+ </select>
125
+ <button class="btn btn-secondary small-btn" type="submit">保存</button>
126
+ </form>
127
+ </td>
128
+ </tr>
129
+ {% else %}
130
+ <tr>
131
+ <td colspan="5">还没有用户,请先录入。</td>
132
+ </tr>
133
+ {% endfor %}
134
+ </tbody>
135
+ </table>
136
+ </div>
137
+ </section>
138
+
139
+ <script>
140
+ (() => {
141
+ const checkAll = document.querySelector('[data-check-all]');
142
+ const checks = Array.from(document.querySelectorAll('[data-user-check]'));
143
+ if (!checkAll || checks.length === 0) return;
144
+ checkAll.addEventListener('change', () => {
145
+ checks.forEach((checkbox) => {
146
+ checkbox.checked = checkAll.checked;
147
+ });
148
+ });
149
+ })();
150
+ </script>
151
  {% endblock %}
app/templates/base.html CHANGED
@@ -20,6 +20,7 @@
20
  <nav class="topnav">
21
  {% if user %}
22
  <a href="/dashboard">活动广场</a>
 
23
  <a href="/logout">退出登录</a>
24
  {% elif admin %}
25
  <a href="/admin/dashboard">总览</a>
@@ -27,6 +28,7 @@
27
  <a href="/admin/groups">小组</a>
28
  <a href="/admin/activities">活动</a>
29
  <a href="/admin/reviews">审核</a>
 
30
  {% if admin.role == 'superadmin' %}
31
  <a href="/admin/admins">管理员</a>
32
  {% endif %}
@@ -41,5 +43,17 @@
41
  {% block content %}{% endblock %}
42
  </main>
43
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
44
  </body>
45
  </html>
 
20
  <nav class="topnav">
21
  {% if user %}
22
  <a href="/dashboard">活动广场</a>
23
+ <a href="/account">账号中心</a>
24
  <a href="/logout">退出登录</a>
25
  {% elif admin %}
26
  <a href="/admin/dashboard">总览</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' %}
33
  <a href="/admin/admins">管理员</a>
34
  {% endif %}
 
43
  {% block content %}{% endblock %}
44
  </main>
45
  </div>
46
+
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>
app/templates/dashboard.html CHANGED
@@ -4,7 +4,7 @@
4
  <section class="hero-card">
5
  <div>
6
  <p class="eyebrow">Welcome Back</p>
7
- <h2>{{ user.full_name }},今天也一起去打卡吧</h2>
8
  <p class="lead">
9
  当前小组:<strong>{{ user.group.name if user.group else '暂未分组' }}</strong>
10
  </p>
@@ -12,6 +12,7 @@
12
  <div class="hero-badges">
13
  <span class="pill">学号 {{ user.student_id }}</span>
14
  <span class="pill">{{ activity_cards|length }} 个活动</span>
 
15
  </div>
16
  </section>
17
 
@@ -40,26 +41,26 @@
40
  <strong>{{ card.activity.deadline_at|datetime_local }}</strong>
41
  </div>
42
  <div>
43
- <span>任务数量</span>
44
- <strong>{{ card.total_tasks }}</strong>
45
  </div>
46
  <div>
47
- <span>已通过</span>
48
- <strong>{{ card.approved_count }}</strong>
49
  </div>
50
  </div>
51
  <div class="progress-line">
52
  <div style="width: {{ 0 if card.total_tasks == 0 else (card.approved_count / card.total_tasks * 100)|round(0) }}%"></div>
53
  </div>
54
  <div class="card-footer">
55
- <span class="mini-note">待审核 {{ card.pending_count }} 项</span>
56
  <a class="btn btn-primary" href="/activities/{{ card.activity.id }}">查看任务</a>
57
  </div>
58
  </article>
59
  {% else %}
60
  <article class="empty-state">
61
  <h3>还没有活动</h3>
62
- <p>管理员发布活动后,这里会展示你的任务卡片与打卡进度。</p>
63
  </article>
64
  {% endfor %}
65
  </section>
 
4
  <section class="hero-card">
5
  <div>
6
  <p class="eyebrow">Welcome Back</p>
7
+ <h2>{{ user.full_name }},和你的小组一起继续前进吧</h2>
8
  <p class="lead">
9
  当前小组:<strong>{{ user.group.name if user.group else '暂未分组' }}</strong>
10
  </p>
 
12
  <div class="hero-badges">
13
  <span class="pill">学号 {{ user.student_id }}</span>
14
  <span class="pill">{{ activity_cards|length }} 个活动</span>
15
+ <span class="pill">共享进度模式</span>
16
  </div>
17
  </section>
18
 
 
41
  <strong>{{ card.activity.deadline_at|datetime_local }}</strong>
42
  </div>
43
  <div>
44
+ <span>已完成点位</span>
45
+ <strong>{{ card.approved_count }}/{{ card.total_tasks }}</strong>
46
  </div>
47
  <div>
48
+ <span>待审核点位</span>
49
+ <strong>{{ card.pending_count }}</strong>
50
  </div>
51
  </div>
52
  <div class="progress-line">
53
  <div style="width: {{ 0 if card.total_tasks == 0 else (card.approved_count / card.total_tasks * 100)|round(0) }}%"></div>
54
  </div>
55
  <div class="card-footer">
56
+ <span class="mini-note">驳回 {{ card.rejected_count }} 项 · 小组共享</span>
57
  <a class="btn btn-primary" href="/activities/{{ card.activity.id }}">查看任务</a>
58
  </div>
59
  </article>
60
  {% else %}
61
  <article class="empty-state">
62
  <h3>还没有活动</h3>
63
+ <p>管理员发布活动后,这里会展示你所在小组共享任务进度。</p>
64
  </article>
65
  {% endfor %}
66
  </section>