Upload 60 files
Browse files- app/__pycache__/main.cpython-313.pyc +0 -0
- app/__pycache__/models.cpython-313.pyc +0 -0
- app/main.py +31 -1
- app/models.py +31 -4
- app/routes/__pycache__/admin.cpython-313.pyc +0 -0
- app/routes/__pycache__/auth.cpython-313.pyc +0 -0
- app/routes/__pycache__/media.cpython-313.pyc +0 -0
- app/routes/__pycache__/user.cpython-313.pyc +0 -0
- app/routes/admin.py +374 -78
- app/routes/auth.py +103 -4
- app/routes/media.py +4 -3
- app/routes/user.py +95 -42
- app/services/__pycache__/bootstrap.cpython-313.pyc +0 -0
- app/services/__pycache__/group_progress.cpython-313.pyc +0 -0
- app/services/__pycache__/leaderboard.cpython-313.pyc +0 -0
- app/services/__pycache__/presence.cpython-313.pyc +0 -0
- app/services/__pycache__/review_queue.cpython-313.pyc +0 -0
- app/services/bootstrap.py +90 -0
- app/services/group_progress.py +99 -0
- app/services/leaderboard.py +35 -36
- app/services/presence.py +19 -0
- app/services/review_queue.py +71 -0
- app/static/style.css +201 -0
- app/templates/account.html +69 -0
- app/templates/activity_detail.html +301 -141
- app/templates/admin_activity_edit.html +42 -0
- app/templates/admin_admins.html +95 -1
- app/templates/admin_dashboard.html +47 -2
- app/templates/admin_reviews.html +206 -55
- app/templates/admin_users.html +105 -39
- app/templates/base.html +14 -0
- app/templates/dashboard.html +8 -7
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(
|
|
|
|
|
|
|
| 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__ = (
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 50 |
if not group:
|
| 51 |
return
|
|
|
|
| 52 |
current_count = len(group.members)
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 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":
|
|
|
|
|
|
|
| 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,
|
| 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 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 702 |
-
|
| 703 |
-
.
|
| 704 |
-
|
| 705 |
-
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 793 |
-
|
| 794 |
-
archive_name = f"{activity_title}/{
|
| 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
|
|
|
|
| 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
|
| 58 |
-
|
|
|
|
| 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 =
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
activity_cards = []
|
| 43 |
now = local_now()
|
| 44 |
for activity in activities:
|
| 45 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
"
|
|
|
|
| 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 |
-
|
| 157 |
db.query(Submission)
|
| 158 |
-
.filter(Submission.
|
| 159 |
-
.
|
|
|
|
| 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
|
| 7 |
|
| 8 |
-
from app.models import
|
|
|
|
| 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 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
| 29 |
|
| 30 |
for task in activity.tasks:
|
|
|
|
| 31 |
for submission in task.submissions:
|
| 32 |
-
if
|
| 33 |
continue
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 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 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 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 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
</article>
|
| 88 |
-
{% endfor %}
|
| 89 |
-
</section>
|
| 90 |
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
</div>
|
|
|
|
| 101 |
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 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 |
-
|
| 132 |
-
|
| 133 |
-
{% endif %}
|
| 134 |
-
</section>
|
| 135 |
|
| 136 |
<script>
|
| 137 |
(() => {
|
| 138 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
const seenReleased = new Set(
|
| 140 |
-
|
| 141 |
);
|
| 142 |
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
|
|
|
|
|
|
|
|
|
| 148 |
button.addEventListener('click', () => {
|
| 149 |
if (!button.classList.contains('is-ready')) return;
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
button.classList.toggle('is-clue-view', !showingClue);
|
| 154 |
});
|
| 155 |
});
|
| 156 |
|
| 157 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
-
const
|
| 160 |
try {
|
| 161 |
-
const
|
| 162 |
-
if (
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
const stringId = String(taskId);
|
| 166 |
-
const
|
| 167 |
-
if (!
|
| 168 |
-
const button =
|
| 169 |
if (!button) return;
|
| 170 |
button.classList.add('is-ready');
|
| 171 |
-
|
| 172 |
if (!seenReleased.has(stringId)) {
|
| 173 |
seenReleased.add(stringId);
|
| 174 |
-
|
| 175 |
-
setTimeout(() =>
|
| 176 |
-
if (
|
| 177 |
navigator.vibrate([220, 80, 220]);
|
| 178 |
}
|
| 179 |
}
|
| 180 |
});
|
| 181 |
} catch (error) {
|
| 182 |
-
console.debug('
|
| 183 |
}
|
| 184 |
};
|
| 185 |
|
| 186 |
-
|
|
|
|
| 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>
|
| 31 |
-
<strong>{{ stats.
|
| 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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 42 |
-
<
|
| 43 |
-
<div
|
| 44 |
-
<
|
| 45 |
-
|
| 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 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
<
|
| 68 |
-
<
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
<
|
| 72 |
-
<
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 41 |
<div class="section-head">
|
| 42 |
<div>
|
| 43 |
-
<p class="eyebrow">
|
| 44 |
-
<h3>用户
|
| 45 |
</div>
|
| 46 |
</div>
|
| 47 |
-
<
|
| 48 |
-
<
|
| 49 |
-
<
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
</
|
| 56 |
-
|
| 57 |
-
|
| 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 |
-
</
|
| 81 |
-
</
|
| 82 |
-
|
|
|
|
|
|
|
| 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 李四 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 }},
|
| 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>
|
| 44 |
-
<strong>{{ card.total_tasks }}</strong>
|
| 45 |
</div>
|
| 46 |
<div>
|
| 47 |
-
<span>
|
| 48 |
-
<strong>{{ card.
|
| 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">
|
| 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>管理员发布活动后,这里会展示你的任务
|
| 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>
|