Upload 53 files
Browse files- README.md +114 -7
- app/routes/__pycache__/admin.cpython-313.pyc +0 -0
- app/routes/admin.py +328 -96
- app/static/style.css +42 -0
- app/templates/admin_activities.html +12 -12
- app/templates/admin_activity_edit.html +213 -0
- requirements.txt +2 -1
README.md
CHANGED
|
@@ -1,10 +1,117 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Spring Check-In Activity Manager
|
| 3 |
+
emoji: 🌿
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
fullWidth: true
|
| 9 |
+
header: default
|
| 10 |
+
short_description: 春日打卡行动管理系统,支持用户与多管理员后台、活动任务、线索发布与图片审核。
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# Spring Check-In Activity Manager
|
| 14 |
+
|
| 15 |
+
这是一个可直接部署到 Hugging Face Docker Space 的春日打卡行动系统,包含:
|
| 16 |
+
|
| 17 |
+
- 用户端 `/login`:学号 + 密码登录
|
| 18 |
+
- 管理员端 `/admin`:多管理员后台,支持超级管理员
|
| 19 |
+
- 用户管理:手动录入用户,按小组分配成员
|
| 20 |
+
- 小组管理:卡片式展示每个小组与人数上限
|
| 21 |
+
- 活动发布:一个活动包含多个打卡任务,可配置开始/截止时间
|
| 22 |
+
- 图片上传:用户提交图片会自动压缩到 2MB 以下并暂存到本地
|
| 23 |
+
- 图片审核:管理员统一审核通过/驳回,并支持勾选批量下载
|
| 24 |
+
- 线索发布:管理员为任务上传线索图,按固定时间间隔自动发布
|
| 25 |
+
- 排行榜:按完成打卡点数量优先、总耗时次优进行小组排序,可控制是否对用户可见
|
| 26 |
+
|
| 27 |
+
## 目录结构
|
| 28 |
+
|
| 29 |
+
```text
|
| 30 |
+
app/
|
| 31 |
+
main.py
|
| 32 |
+
config.py
|
| 33 |
+
database.py
|
| 34 |
+
models.py
|
| 35 |
+
routes/
|
| 36 |
+
services/
|
| 37 |
+
static/
|
| 38 |
+
templates/
|
| 39 |
+
ca.pem
|
| 40 |
+
Dockerfile
|
| 41 |
+
requirements.txt
|
| 42 |
+
README.md
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
## 必填 Hugging Face Secrets
|
| 46 |
+
|
| 47 |
+
在 Hugging Face Space 的 `Settings -> Variables and secrets` 中至少配置以下运行时 Secrets:
|
| 48 |
+
|
| 49 |
+
- `ADMIN`:超级管理员账号
|
| 50 |
+
- `PASSWORD`:超级管理员密码
|
| 51 |
+
- `SQL_PASSWORD`:Aiven MySQL 的密码
|
| 52 |
+
- `SESSION_SECRET`:用于 Session 签名的随机长字符串
|
| 53 |
+
|
| 54 |
+
## 可选 Variables
|
| 55 |
+
|
| 56 |
+
如果你想覆盖默认连接配置,可以额外设置这些 Variables:
|
| 57 |
+
|
| 58 |
+
- `SQL_USER`,默认 `avnadmin`
|
| 59 |
+
- `SQL_HOST`,默认 `mysql-2bace9cd-cacode.i.aivencloud.com`
|
| 60 |
+
- `SQL_PORT`,默认 `21260`
|
| 61 |
+
- `SQL_DATABASE`,默认 `CAM`
|
| 62 |
+
- `MYSQL_CA_FILE`,默认 `ca.pem`
|
| 63 |
+
- `APP_TIMEZONE`,默认 `Asia/Shanghai`
|
| 64 |
+
- `UPLOAD_ROOT`,默认 `data/submissions`
|
| 65 |
+
- `DATABASE_URL`,如设置则优先使用完整数据库连接串
|
| 66 |
+
|
| 67 |
+
## 数据存储说明
|
| 68 |
+
|
| 69 |
+
- 任务主图和线索图存入 MySQL,并在入库前自动压缩到 200KB 以下。
|
| 70 |
+
- 用户打卡图不会写入 MySQL,而是保存在应用本地临时目录中,默认是 `data/submissions/`。
|
| 71 |
+
- 本地文件用于管理员审核、预览和批量下载。
|
| 72 |
+
- Docker Space 容器重启后,本地临时文件可能丢失,因此本项目按“临时文件”设计;若后续需要长期保存,可以再接入对象存储。
|
| 73 |
+
|
| 74 |
+
## 本地运行
|
| 75 |
+
|
| 76 |
+
先准备环境变量:
|
| 77 |
+
|
| 78 |
+
```bash
|
| 79 |
+
cp .env.example .env
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
Windows PowerShell 示例:
|
| 83 |
+
|
| 84 |
+
```powershell
|
| 85 |
+
$env:ADMIN="superadmin"
|
| 86 |
+
$env:PASSWORD="change-me-now"
|
| 87 |
+
$env:SQL_PASSWORD="your_mysql_password"
|
| 88 |
+
$env:SESSION_SECRET="replace-with-a-long-random-secret"
|
| 89 |
+
python -m venv .venv
|
| 90 |
+
.\.venv\Scripts\Activate.ps1
|
| 91 |
+
pip install -r requirements.txt
|
| 92 |
+
uvicorn app.main:app --host 0.0.0.0 --port 7860 --reload
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
## 部署到 Hugging Face Space
|
| 96 |
+
|
| 97 |
+
1. 在 Hugging Face 上创建一个新的 Space,并选择 `Docker` 作为 SDK。
|
| 98 |
+
2. 将当前仓库文件上传到 Space 仓库根目录。
|
| 99 |
+
3. 确认 `README.md` 顶部保留了 `sdk: docker` 和 `app_port: 7860` 的 YAML 元数据。
|
| 100 |
+
4. 在 Space 设置中填入 `ADMIN`、`PASSWORD`、`SQL_PASSWORD`、`SESSION_SECRET`。
|
| 101 |
+
5. 确保 `ca.pem` 一并上传到仓库根目录。
|
| 102 |
+
6. 等待 Space 自动构建完成。
|
| 103 |
+
|
| 104 |
+
## 关键行为说明
|
| 105 |
+
|
| 106 |
+
- 超级管理员账号由环境变量 `ADMIN` / `PASSWORD` 初始化,并在启动时自动同步到数据库。
|
| 107 |
+
- 任务线索图如果存在,会根据 `线索发布时间间隔` 和任务顺序计算发布时间;用户端会轮询并在移动端触发震动提醒。
|
| 108 |
+
- 排行榜按小组聚合,排序规则是:
|
| 109 |
+
1. 已审核通过的打卡点数量降序
|
| 110 |
+
2. 总耗时升序
|
| 111 |
+
|
| 112 |
+
## 后续可选增强
|
| 113 |
+
|
| 114 |
+
- 给管理员增加活动编辑与任务删除功能
|
| 115 |
+
- 给用户增加二次提交历史记录
|
| 116 |
+
- 增加导出 Excel 报表
|
| 117 |
+
- 接入对象存储以保存用户原图或长期归档文件
|
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/admin.py
CHANGED
|
@@ -56,6 +56,126 @@ def ensure_group_capacity(group: Group | None, current_user: User | None = None)
|
|
| 56 |
raise ValueError(f"{group.name} 已满员,请调整人数上限或选择其他小组。")
|
| 57 |
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
@router.get("/admin/dashboard")
|
| 60 |
def admin_dashboard(request: Request, db: Session = Depends(get_db)):
|
| 61 |
admin = require_admin(request, db)
|
|
@@ -312,6 +432,31 @@ def admin_activities(request: Request, db: Session = Depends(get_db)):
|
|
| 312 |
)
|
| 313 |
|
| 314 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
@router.post("/admin/activities")
|
| 316 |
async def create_activity(request: Request, db: Session = Depends(get_db)):
|
| 317 |
admin = require_admin(request, db)
|
|
@@ -319,122 +464,209 @@ async def create_activity(request: Request, db: Session = Depends(get_db)):
|
|
| 319 |
return redirect("/admin")
|
| 320 |
|
| 321 |
form = await request.form()
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
|
| 329 |
-
if not
|
| 330 |
-
add_flash(request, "error", "
|
| 331 |
return redirect("/admin/activities")
|
| 332 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
try:
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
|
| 344 |
try:
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
task_images = form.getlist("task_image")
|
| 353 |
-
task_clue_images = form.getlist("task_clue_image")
|
| 354 |
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
|
| 362 |
-
if not task_title and (not primary_upload or not getattr(primary_upload, "filename", "")):
|
| 363 |
-
continue
|
| 364 |
-
if not task_title or not primary_upload or not getattr(primary_upload, "filename", ""):
|
| 365 |
-
add_flash(request, "error", f"第 {index + 1} 个任务需要完整填写标题并上传主图。")
|
| 366 |
-
return redirect("/admin/activities")
|
| 367 |
-
|
| 368 |
-
try:
|
| 369 |
-
primary_raw = await read_and_validate_upload(primary_upload)
|
| 370 |
-
primary_bytes, primary_mime = compress_to_limit(primary_raw, 200 * 1024)
|
| 371 |
-
clue_bytes = None
|
| 372 |
-
clue_mime = None
|
| 373 |
-
clue_name = None
|
| 374 |
if clue_upload and getattr(clue_upload, "filename", ""):
|
| 375 |
clue_raw = await read_and_validate_upload(clue_upload)
|
| 376 |
clue_bytes, clue_mime = compress_to_limit(clue_raw, 200 * 1024)
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
)
|
|
|
|
|
|
|
|
|
|
| 394 |
|
| 395 |
-
if
|
| 396 |
-
add_flash(request, "error", "
|
| 397 |
-
return redirect("/admin/activities")
|
| 398 |
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
db.add(activity)
|
| 409 |
-
db.
|
|
|
|
|
|
|
| 410 |
|
| 411 |
-
for index, payload in enumerate(tasks_payload, start=1):
|
| 412 |
-
release_at = None
|
| 413 |
-
if payload["clue_image_data"]:
|
| 414 |
-
if clue_interval_minutes and clue_interval_minutes > 0:
|
| 415 |
-
release_at = start_at + timedelta(minutes=clue_interval_minutes * index)
|
| 416 |
-
else:
|
| 417 |
-
release_at = start_at
|
| 418 |
-
|
| 419 |
-
db.add(
|
| 420 |
-
Task(
|
| 421 |
-
activity_id=activity.id,
|
| 422 |
-
title=payload["title"],
|
| 423 |
-
description=payload["description"],
|
| 424 |
-
display_order=index,
|
| 425 |
-
image_data=payload["image_data"],
|
| 426 |
-
image_mime=payload["image_mime"],
|
| 427 |
-
image_filename=payload["image_filename"],
|
| 428 |
-
clue_image_data=payload["clue_image_data"],
|
| 429 |
-
clue_image_mime=payload["clue_image_mime"],
|
| 430 |
-
clue_image_filename=payload["clue_image_filename"],
|
| 431 |
-
clue_release_at=release_at,
|
| 432 |
-
)
|
| 433 |
-
)
|
| 434 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
db.commit()
|
| 436 |
-
|
| 437 |
-
|
|
|
|
| 438 |
|
| 439 |
|
| 440 |
@router.post("/admin/activities/{activity_id}/visibility")
|
|
|
|
| 56 |
raise ValueError(f"{group.name} 已满员,请调整人数上限或选择其他小组。")
|
| 57 |
|
| 58 |
|
| 59 |
+
def parse_activity_fields(form) -> dict:
|
| 60 |
+
title = str(form.get("title", "")).strip()
|
| 61 |
+
description = str(form.get("description", "")).strip()
|
| 62 |
+
start_raw = str(form.get("start_at", "")).strip()
|
| 63 |
+
deadline_raw = str(form.get("deadline_at", "")).strip()
|
| 64 |
+
clue_interval_raw = str(form.get("clue_interval_minutes", "")).strip()
|
| 65 |
+
leaderboard_visible = form.get("leaderboard_visible") == "on"
|
| 66 |
+
|
| 67 |
+
if not title or not start_raw or not deadline_raw:
|
| 68 |
+
raise ValueError("请完整填写活动标题、开始时间和截止时间。")
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
start_at = datetime.fromisoformat(start_raw)
|
| 72 |
+
deadline_at = datetime.fromisoformat(deadline_raw)
|
| 73 |
+
except ValueError as exc:
|
| 74 |
+
raise ValueError("时间格式不正确。") from exc
|
| 75 |
+
|
| 76 |
+
if deadline_at <= start_at:
|
| 77 |
+
raise ValueError("截止时间必须晚于开始时间。")
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
clue_interval_minutes = int(clue_interval_raw) if clue_interval_raw else None
|
| 81 |
+
except ValueError as exc:
|
| 82 |
+
raise ValueError("线索发布时间间隔必须是数字。") from exc
|
| 83 |
+
|
| 84 |
+
return {
|
| 85 |
+
"title": title,
|
| 86 |
+
"description": description,
|
| 87 |
+
"start_at": start_at,
|
| 88 |
+
"deadline_at": deadline_at,
|
| 89 |
+
"clue_interval_minutes": clue_interval_minutes,
|
| 90 |
+
"leaderboard_visible": leaderboard_visible,
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def compute_clue_release_at(
|
| 95 |
+
start_at: datetime,
|
| 96 |
+
clue_interval_minutes: int | None,
|
| 97 |
+
display_order: int,
|
| 98 |
+
has_clue_image: bool,
|
| 99 |
+
) -> datetime | None:
|
| 100 |
+
if not has_clue_image:
|
| 101 |
+
return None
|
| 102 |
+
if clue_interval_minutes and clue_interval_minutes > 0:
|
| 103 |
+
return start_at + timedelta(minutes=clue_interval_minutes * display_order)
|
| 104 |
+
return start_at
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def apply_task_schedule(activity: Activity, tasks: list[Task]) -> None:
|
| 108 |
+
for index, task in enumerate(tasks, start=1):
|
| 109 |
+
task.display_order = index
|
| 110 |
+
task.clue_release_at = compute_clue_release_at(
|
| 111 |
+
activity.start_at,
|
| 112 |
+
activity.clue_interval_minutes,
|
| 113 |
+
index,
|
| 114 |
+
bool(task.clue_image_filename),
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def cleanup_submission_files(submissions: list[Submission]) -> None:
|
| 119 |
+
for submission in submissions:
|
| 120 |
+
if not submission.file_path:
|
| 121 |
+
continue
|
| 122 |
+
file_path = Path(submission.file_path)
|
| 123 |
+
if file_path.exists():
|
| 124 |
+
file_path.unlink(missing_ok=True)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
async def collect_task_payloads(
|
| 128 |
+
form,
|
| 129 |
+
title_key: str,
|
| 130 |
+
description_key: str,
|
| 131 |
+
image_key: str,
|
| 132 |
+
clue_key: str,
|
| 133 |
+
) -> list[dict]:
|
| 134 |
+
task_titles = form.getlist(title_key)
|
| 135 |
+
task_descriptions = form.getlist(description_key)
|
| 136 |
+
task_images = form.getlist(image_key)
|
| 137 |
+
task_clue_images = form.getlist(clue_key)
|
| 138 |
+
|
| 139 |
+
tasks_payload = []
|
| 140 |
+
for index, raw_title in enumerate(task_titles):
|
| 141 |
+
task_title = str(raw_title).strip()
|
| 142 |
+
task_description = (
|
| 143 |
+
str(task_descriptions[index]).strip() if index < len(task_descriptions) else ""
|
| 144 |
+
)
|
| 145 |
+
primary_upload = task_images[index] if index < len(task_images) else None
|
| 146 |
+
clue_upload = task_clue_images[index] if index < len(task_clue_images) else None
|
| 147 |
+
|
| 148 |
+
if not task_title and (not primary_upload or not getattr(primary_upload, "filename", "")):
|
| 149 |
+
continue
|
| 150 |
+
if not task_title or not primary_upload or not getattr(primary_upload, "filename", ""):
|
| 151 |
+
raise ValueError(f"第 {index + 1} 个新增任务需要完整填写标题并上传主图。")
|
| 152 |
+
|
| 153 |
+
primary_raw = await read_and_validate_upload(primary_upload)
|
| 154 |
+
primary_bytes, primary_mime = compress_to_limit(primary_raw, 200 * 1024)
|
| 155 |
+
|
| 156 |
+
clue_bytes = None
|
| 157 |
+
clue_mime = None
|
| 158 |
+
clue_name = None
|
| 159 |
+
if clue_upload and getattr(clue_upload, "filename", ""):
|
| 160 |
+
clue_raw = await read_and_validate_upload(clue_upload)
|
| 161 |
+
clue_bytes, clue_mime = compress_to_limit(clue_raw, 200 * 1024)
|
| 162 |
+
clue_name = clue_upload.filename
|
| 163 |
+
|
| 164 |
+
tasks_payload.append(
|
| 165 |
+
{
|
| 166 |
+
"title": task_title,
|
| 167 |
+
"description": task_description,
|
| 168 |
+
"image_data": primary_bytes,
|
| 169 |
+
"image_mime": primary_mime,
|
| 170 |
+
"image_filename": primary_upload.filename,
|
| 171 |
+
"clue_image_data": clue_bytes,
|
| 172 |
+
"clue_image_mime": clue_mime,
|
| 173 |
+
"clue_image_filename": clue_name,
|
| 174 |
+
}
|
| 175 |
+
)
|
| 176 |
+
return tasks_payload
|
| 177 |
+
|
| 178 |
+
|
| 179 |
@router.get("/admin/dashboard")
|
| 180 |
def admin_dashboard(request: Request, db: Session = Depends(get_db)):
|
| 181 |
admin = require_admin(request, db)
|
|
|
|
| 432 |
)
|
| 433 |
|
| 434 |
|
| 435 |
+
@router.get("/admin/activities/{activity_id}/edit")
|
| 436 |
+
def edit_activity_page(activity_id: int, request: Request, db: Session = Depends(get_db)):
|
| 437 |
+
admin = require_admin(request, db)
|
| 438 |
+
if not admin:
|
| 439 |
+
return redirect("/admin")
|
| 440 |
+
|
| 441 |
+
activity = (
|
| 442 |
+
db.query(Activity)
|
| 443 |
+
.options(
|
| 444 |
+
joinedload(Activity.created_by),
|
| 445 |
+
joinedload(Activity.tasks).joinedload(Task.submissions),
|
| 446 |
+
)
|
| 447 |
+
.filter(Activity.id == activity_id)
|
| 448 |
+
.first()
|
| 449 |
+
)
|
| 450 |
+
if not activity:
|
| 451 |
+
raise HTTPException(status_code=404, detail="活动不存在")
|
| 452 |
+
|
| 453 |
+
return render(
|
| 454 |
+
request,
|
| 455 |
+
"admin_activity_edit.html",
|
| 456 |
+
{"page_title": f"编辑活动 · {activity.title}", "admin": admin, "activity": activity},
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
|
| 460 |
@router.post("/admin/activities")
|
| 461 |
async def create_activity(request: Request, db: Session = Depends(get_db)):
|
| 462 |
admin = require_admin(request, db)
|
|
|
|
| 464 |
return redirect("/admin")
|
| 465 |
|
| 466 |
form = await request.form()
|
| 467 |
+
try:
|
| 468 |
+
activity_fields = parse_activity_fields(form)
|
| 469 |
+
tasks_payload = await collect_task_payloads(
|
| 470 |
+
form,
|
| 471 |
+
"task_title",
|
| 472 |
+
"task_description",
|
| 473 |
+
"task_image",
|
| 474 |
+
"task_clue_image",
|
| 475 |
+
)
|
| 476 |
+
except ValueError as exc:
|
| 477 |
+
add_flash(request, "error", str(exc))
|
| 478 |
+
return redirect("/admin/activities")
|
| 479 |
|
| 480 |
+
if not tasks_payload:
|
| 481 |
+
add_flash(request, "error", "至少需要添加一个任务卡片。")
|
| 482 |
return redirect("/admin/activities")
|
| 483 |
|
| 484 |
+
activity = Activity(created_by_id=admin.id, **activity_fields)
|
| 485 |
+
db.add(activity)
|
| 486 |
+
|
| 487 |
+
tasks = []
|
| 488 |
+
for payload in tasks_payload:
|
| 489 |
+
task = Task(activity=activity, **payload)
|
| 490 |
+
tasks.append(task)
|
| 491 |
+
db.add(task)
|
| 492 |
+
|
| 493 |
+
apply_task_schedule(activity, tasks)
|
| 494 |
+
db.commit()
|
| 495 |
+
add_flash(request, "success", f"活动 {activity.title} 已发布。")
|
| 496 |
+
return redirect("/admin/activities")
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
@router.post("/admin/activities/{activity_id}/edit")
|
| 500 |
+
async def update_activity(activity_id: int, request: Request, db: Session = Depends(get_db)):
|
| 501 |
+
admin = require_admin(request, db)
|
| 502 |
+
if not admin:
|
| 503 |
+
return redirect("/admin")
|
| 504 |
+
|
| 505 |
+
activity = (
|
| 506 |
+
db.query(Activity)
|
| 507 |
+
.options(joinedload(Activity.tasks))
|
| 508 |
+
.filter(Activity.id == activity_id)
|
| 509 |
+
.first()
|
| 510 |
+
)
|
| 511 |
+
if not activity:
|
| 512 |
+
raise HTTPException(status_code=404, detail="活动不存在")
|
| 513 |
+
|
| 514 |
+
form = await request.form()
|
| 515 |
try:
|
| 516 |
+
activity_fields = parse_activity_fields(form)
|
| 517 |
+
except ValueError as exc:
|
| 518 |
+
add_flash(request, "error", str(exc))
|
| 519 |
+
return redirect(f"/admin/activities/{activity_id}/edit")
|
| 520 |
+
|
| 521 |
+
existing_task_ids = form.getlist("existing_task_id")
|
| 522 |
+
existing_task_titles = form.getlist("existing_task_title")
|
| 523 |
+
existing_task_descriptions = form.getlist("existing_task_description")
|
| 524 |
+
existing_task_images = form.getlist("existing_task_image")
|
| 525 |
+
existing_task_clue_images = form.getlist("existing_task_clue_image")
|
| 526 |
+
remove_clue_ids = {
|
| 527 |
+
int(value)
|
| 528 |
+
for value in form.getlist("existing_task_remove_clue")
|
| 529 |
+
if str(value).isdigit()
|
| 530 |
+
}
|
| 531 |
|
| 532 |
+
tasks_by_id = {task.id: task for task in activity.tasks}
|
| 533 |
+
ordered_tasks: list[Task] = []
|
| 534 |
+
seen_task_ids: set[int] = set()
|
| 535 |
|
| 536 |
try:
|
| 537 |
+
for index, raw_task_id in enumerate(existing_task_ids):
|
| 538 |
+
if not str(raw_task_id).isdigit():
|
| 539 |
+
raise ValueError("任务数据无效,请刷新页面后重试。")
|
| 540 |
+
task_id = int(raw_task_id)
|
| 541 |
+
task = tasks_by_id.get(task_id)
|
| 542 |
+
if not task or task_id in seen_task_ids:
|
| 543 |
+
raise ValueError("任务数据无效,请刷新页面后重试。")
|
| 544 |
+
seen_task_ids.add(task_id)
|
| 545 |
+
|
| 546 |
+
title = (
|
| 547 |
+
str(existing_task_titles[index]).strip()
|
| 548 |
+
if index < len(existing_task_titles)
|
| 549 |
+
else ""
|
| 550 |
+
)
|
| 551 |
+
description = (
|
| 552 |
+
str(existing_task_descriptions[index]).strip()
|
| 553 |
+
if index < len(existing_task_descriptions)
|
| 554 |
+
else ""
|
| 555 |
+
)
|
| 556 |
+
if not title:
|
| 557 |
+
raise ValueError(f"第 {index + 1} 个已有任务标题不能为空。")
|
| 558 |
|
| 559 |
+
task.title = title
|
| 560 |
+
task.description = description
|
|
|
|
|
|
|
| 561 |
|
| 562 |
+
primary_upload = (
|
| 563 |
+
existing_task_images[index] if index < len(existing_task_images) else None
|
| 564 |
+
)
|
| 565 |
+
clue_upload = (
|
| 566 |
+
existing_task_clue_images[index]
|
| 567 |
+
if index < len(existing_task_clue_images)
|
| 568 |
+
else None
|
| 569 |
+
)
|
| 570 |
+
|
| 571 |
+
if primary_upload and getattr(primary_upload, "filename", ""):
|
| 572 |
+
primary_raw = await read_and_validate_upload(primary_upload)
|
| 573 |
+
primary_bytes, primary_mime = compress_to_limit(primary_raw, 200 * 1024)
|
| 574 |
+
task.image_data = primary_bytes
|
| 575 |
+
task.image_mime = primary_mime
|
| 576 |
+
task.image_filename = primary_upload.filename
|
| 577 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
if clue_upload and getattr(clue_upload, "filename", ""):
|
| 579 |
clue_raw = await read_and_validate_upload(clue_upload)
|
| 580 |
clue_bytes, clue_mime = compress_to_limit(clue_raw, 200 * 1024)
|
| 581 |
+
task.clue_image_data = clue_bytes
|
| 582 |
+
task.clue_image_mime = clue_mime
|
| 583 |
+
task.clue_image_filename = clue_upload.filename
|
| 584 |
+
elif task_id in remove_clue_ids:
|
| 585 |
+
task.clue_image_data = None
|
| 586 |
+
task.clue_image_mime = None
|
| 587 |
+
task.clue_image_filename = None
|
| 588 |
+
|
| 589 |
+
ordered_tasks.append(task)
|
| 590 |
+
|
| 591 |
+
new_tasks_payload = await collect_task_payloads(
|
| 592 |
+
form,
|
| 593 |
+
"new_task_title",
|
| 594 |
+
"new_task_description",
|
| 595 |
+
"new_task_image",
|
| 596 |
+
"new_task_clue_image",
|
| 597 |
)
|
| 598 |
+
except ValueError as exc:
|
| 599 |
+
add_flash(request, "error", str(exc))
|
| 600 |
+
return redirect(f"/admin/activities/{activity_id}/edit")
|
| 601 |
|
| 602 |
+
if len(seen_task_ids) != len(tasks_by_id):
|
| 603 |
+
add_flash(request, "error", "任务列表不完整,请刷新页面后重试。")
|
| 604 |
+
return redirect(f"/admin/activities/{activity_id}/edit")
|
| 605 |
|
| 606 |
+
for field_name, field_value in activity_fields.items():
|
| 607 |
+
setattr(activity, field_name, field_value)
|
| 608 |
+
|
| 609 |
+
all_tasks = list(ordered_tasks)
|
| 610 |
+
for payload in new_tasks_payload:
|
| 611 |
+
task = Task(activity=activity, **payload)
|
| 612 |
+
db.add(task)
|
| 613 |
+
all_tasks.append(task)
|
| 614 |
+
|
| 615 |
+
if not all_tasks:
|
| 616 |
+
add_flash(request, "error", "活动至少需要保留一个任务。")
|
| 617 |
+
return redirect(f"/admin/activities/{activity_id}/edit")
|
| 618 |
+
|
| 619 |
+
apply_task_schedule(activity, all_tasks)
|
| 620 |
db.add(activity)
|
| 621 |
+
db.commit()
|
| 622 |
+
add_flash(request, "success", f"活动 {activity.title} 已更新。")
|
| 623 |
+
return redirect(f"/admin/activities/{activity_id}/edit")
|
| 624 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
|
| 626 |
+
@router.post("/admin/tasks/{task_id}/delete")
|
| 627 |
+
async def delete_task(task_id: int, request: Request, db: Session = Depends(get_db)):
|
| 628 |
+
admin = require_admin(request, db)
|
| 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:
|
| 650 |
+
add_flash(request, "error", "活动至少需要保留一个任务,不能删除最后一个任务。")
|
| 651 |
+
return redirect(f"/admin/activities/{activity.id}/edit")
|
| 652 |
+
|
| 653 |
+
cleanup_submission_files(task.submissions)
|
| 654 |
+
activity_id = activity.id
|
| 655 |
+
|
| 656 |
+
db.delete(task)
|
| 657 |
+
db.flush()
|
| 658 |
+
|
| 659 |
+
remaining_tasks = (
|
| 660 |
+
db.query(Task)
|
| 661 |
+
.filter(Task.activity_id == activity_id)
|
| 662 |
+
.order_by(Task.display_order.asc(), Task.id.asc())
|
| 663 |
+
.all()
|
| 664 |
+
)
|
| 665 |
+
apply_task_schedule(activity, remaining_tasks)
|
| 666 |
db.commit()
|
| 667 |
+
|
| 668 |
+
add_flash(request, "success", "任务已删除,剩余任务顺序和线索发布时间已自动更新。")
|
| 669 |
+
return redirect(f"/admin/activities/{activity_id}/edit")
|
| 670 |
|
| 671 |
|
| 672 |
@router.post("/admin/activities/{activity_id}/visibility")
|
app/static/style.css
CHANGED
|
@@ -850,3 +850,45 @@ textarea {
|
|
| 850 |
width: 100%;
|
| 851 |
}
|
| 852 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 850 |
width: 100%;
|
| 851 |
}
|
| 852 |
}
|
| 853 |
+
|
| 854 |
+
.activity-actions,
|
| 855 |
+
.task-editor-list {
|
| 856 |
+
display: grid;
|
| 857 |
+
gap: 14px;
|
| 858 |
+
}
|
| 859 |
+
|
| 860 |
+
.task-editor-card {
|
| 861 |
+
gap: 16px;
|
| 862 |
+
}
|
| 863 |
+
|
| 864 |
+
.editor-preview-grid {
|
| 865 |
+
display: grid;
|
| 866 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 867 |
+
gap: 16px;
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
.editor-thumb,
|
| 871 |
+
.empty-thumb {
|
| 872 |
+
width: 100%;
|
| 873 |
+
aspect-ratio: 4 / 3;
|
| 874 |
+
object-fit: cover;
|
| 875 |
+
border-radius: 20px;
|
| 876 |
+
border: 1px solid rgba(78, 148, 97, 0.16);
|
| 877 |
+
background: rgba(255, 255, 255, 0.6);
|
| 878 |
+
}
|
| 879 |
+
|
| 880 |
+
.empty-thumb {
|
| 881 |
+
display: grid;
|
| 882 |
+
place-items: center;
|
| 883 |
+
color: var(--muted);
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
.align-end-checkbox {
|
| 887 |
+
align-self: end;
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
@media (max-width: 720px) {
|
| 891 |
+
.editor-preview-grid {
|
| 892 |
+
grid-template-columns: 1fr;
|
| 893 |
+
}
|
| 894 |
+
}
|
app/templates/admin_activities.html
CHANGED
|
@@ -89,14 +89,18 @@
|
|
| 89 |
<strong>{{ activity.title }}</strong>
|
| 90 |
<p class="muted">{{ activity.start_at|datetime_local }} 至 {{ activity.deadline_at|datetime_local }}</p>
|
| 91 |
<p class="muted">{{ activity.tasks|length }} 个任务 · 创建人 {{ activity.created_by.display_name }}</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
</div>
|
| 93 |
-
<form method="post" action="/admin/activities/{{ activity.id }}/visibility" class="inline-form visibility-form">
|
| 94 |
-
<label class="checkbox-row compact-checkbox">
|
| 95 |
-
<input type="checkbox" name="leaderboard_visible" {% if activity.leaderboard_visible %}checked{% endif %} />
|
| 96 |
-
<span>用户可见排行榜</span>
|
| 97 |
-
</label>
|
| 98 |
-
<button class="btn btn-secondary small-btn" type="submit">保存</button>
|
| 99 |
-
</form>
|
| 100 |
</article>
|
| 101 |
{% else %}
|
| 102 |
<p class="muted">还没有活动,先发布一个吧。</p>
|
|
@@ -134,11 +138,7 @@
|
|
| 134 |
const template = builder.querySelector('[data-task-template]');
|
| 135 |
const clone = template.cloneNode(true);
|
| 136 |
clone.querySelectorAll('input, textarea').forEach((field) => {
|
| 137 |
-
|
| 138 |
-
field.value = '';
|
| 139 |
-
} else {
|
| 140 |
-
field.value = '';
|
| 141 |
-
}
|
| 142 |
});
|
| 143 |
attachRemove(clone);
|
| 144 |
builder.appendChild(clone);
|
|
|
|
| 89 |
<strong>{{ activity.title }}</strong>
|
| 90 |
<p class="muted">{{ activity.start_at|datetime_local }} 至 {{ activity.deadline_at|datetime_local }}</p>
|
| 91 |
<p class="muted">{{ activity.tasks|length }} 个任务 · 创建人 {{ activity.created_by.display_name }}</p>
|
| 92 |
+
<p class="muted">线索间隔:{{ activity.clue_interval_minutes if activity.clue_interval_minutes is not none else '与活动开始同步' }}</p>
|
| 93 |
+
</div>
|
| 94 |
+
<div class="activity-actions">
|
| 95 |
+
<a class="btn btn-primary small-btn" href="/admin/activities/{{ activity.id }}/edit">编辑活动</a>
|
| 96 |
+
<form method="post" action="/admin/activities/{{ activity.id }}/visibility" class="inline-form visibility-form">
|
| 97 |
+
<label class="checkbox-row compact-checkbox">
|
| 98 |
+
<input type="checkbox" name="leaderboard_visible" {% if activity.leaderboard_visible %}checked{% endif %} />
|
| 99 |
+
<span>用户可见排行榜</span>
|
| 100 |
+
</label>
|
| 101 |
+
<button class="btn btn-secondary small-btn" type="submit">保存</button>
|
| 102 |
+
</form>
|
| 103 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
</article>
|
| 105 |
{% else %}
|
| 106 |
<p class="muted">还没有活动,先发布一个吧。</p>
|
|
|
|
| 138 |
const template = builder.querySelector('[data-task-template]');
|
| 139 |
const clone = template.cloneNode(true);
|
| 140 |
clone.querySelectorAll('input, textarea').forEach((field) => {
|
| 141 |
+
field.value = '';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
});
|
| 143 |
attachRemove(clone);
|
| 144 |
builder.appendChild(clone);
|
app/templates/admin_activity_edit.html
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends 'base.html' %}
|
| 2 |
+
|
| 3 |
+
{% block content %}
|
| 4 |
+
<section class="hero-card admin-hero">
|
| 5 |
+
<div>
|
| 6 |
+
<a class="ghost-link" href="/admin/activities">返回活动管理</a>
|
| 7 |
+
<p class="eyebrow">Edit Activity</p>
|
| 8 |
+
<h2>{{ activity.title }}</h2>
|
| 9 |
+
<p class="lead">在这里可以修改活动时间、排行榜可见性、任务内容、任务图片,并删除不再需要的任务。</p>
|
| 10 |
+
</div>
|
| 11 |
+
<div class="hero-badges">
|
| 12 |
+
<span class="pill">创建人 {{ activity.created_by.display_name }}</span>
|
| 13 |
+
<span class="pill">{{ activity.tasks|length }} 个任务</span>
|
| 14 |
+
<span class="pill">开始 {{ activity.start_at|datetime_local }}</span>
|
| 15 |
+
</div>
|
| 16 |
+
</section>
|
| 17 |
+
|
| 18 |
+
<form method="post" action="/admin/activities/{{ activity.id }}/edit" enctype="multipart/form-data" class="form-stack">
|
| 19 |
+
<section class="glass-card form-panel wide-panel">
|
| 20 |
+
<div class="section-head">
|
| 21 |
+
<div>
|
| 22 |
+
<p class="eyebrow">Activity Settings</p>
|
| 23 |
+
<h3>活动信息</h3>
|
| 24 |
+
</div>
|
| 25 |
+
</div>
|
| 26 |
+
<div class="form-grid cols-2">
|
| 27 |
+
<label>
|
| 28 |
+
<span>活动标题</span>
|
| 29 |
+
<input type="text" name="title" value="{{ activity.title }}" required />
|
| 30 |
+
</label>
|
| 31 |
+
<label>
|
| 32 |
+
<span>线索发布时间间隔(分钟)</span>
|
| 33 |
+
<input type="number" name="clue_interval_minutes" min="0" value="{{ activity.clue_interval_minutes if activity.clue_interval_minutes is not none else '' }}" placeholder="留空表示与活动开始同步" />
|
| 34 |
+
</label>
|
| 35 |
+
<label>
|
| 36 |
+
<span>开始时间</span>
|
| 37 |
+
<input type="datetime-local" name="start_at" value="{{ activity.start_at.strftime('%Y-%m-%dT%H:%M') }}" required />
|
| 38 |
+
</label>
|
| 39 |
+
<label>
|
| 40 |
+
<span>截止时间</span>
|
| 41 |
+
<input type="datetime-local" name="deadline_at" value="{{ activity.deadline_at.strftime('%Y-%m-%dT%H:%M') }}" required />
|
| 42 |
+
</label>
|
| 43 |
+
</div>
|
| 44 |
+
<label>
|
| 45 |
+
<span>活动说明</span>
|
| 46 |
+
<textarea name="description" rows="3">{{ activity.description or '' }}</textarea>
|
| 47 |
+
</label>
|
| 48 |
+
<label class="checkbox-row">
|
| 49 |
+
<input type="checkbox" name="leaderboard_visible" {% if activity.leaderboard_visible %}checked{% endif %} />
|
| 50 |
+
<span>允许用户查看实时排行榜</span>
|
| 51 |
+
</label>
|
| 52 |
+
</section>
|
| 53 |
+
|
| 54 |
+
<section class="glass-card form-panel wide-panel">
|
| 55 |
+
<div class="section-head">
|
| 56 |
+
<div>
|
| 57 |
+
<p class="eyebrow">Existing Tasks</p>
|
| 58 |
+
<h3>已有任务</h3>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
<div class="task-editor-list">
|
| 62 |
+
{% for task in activity.tasks %}
|
| 63 |
+
<article class="task-builder-card task-editor-card">
|
| 64 |
+
<div class="builder-title-row">
|
| 65 |
+
<strong>任务 {{ loop.index }}</strong>
|
| 66 |
+
<button
|
| 67 |
+
class="btn btn-danger small-btn"
|
| 68 |
+
type="submit"
|
| 69 |
+
form="delete-task-{{ task.id }}"
|
| 70 |
+
onclick="return confirm('确定删除这个任务吗?相关提交记录也会一并删除。');"
|
| 71 |
+
>删除任务</button>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<input type="hidden" name="existing_task_id" value="{{ task.id }}" />
|
| 75 |
+
|
| 76 |
+
<div class="form-grid cols-2">
|
| 77 |
+
<label>
|
| 78 |
+
<span>任务标题</span>
|
| 79 |
+
<input type="text" name="existing_task_title" value="{{ task.title }}" required />
|
| 80 |
+
</label>
|
| 81 |
+
<label>
|
| 82 |
+
<span>替换主图</span>
|
| 83 |
+
<input type="file" name="existing_task_image" accept="image/*" />
|
| 84 |
+
</label>
|
| 85 |
+
<label class="full-span">
|
| 86 |
+
<span>任务描述</span>
|
| 87 |
+
<textarea name="existing_task_description" rows="2">{{ task.description or '' }}</textarea>
|
| 88 |
+
</label>
|
| 89 |
+
<label>
|
| 90 |
+
<span>替换线索图</span>
|
| 91 |
+
<input type="file" name="existing_task_clue_image" accept="image/*" />
|
| 92 |
+
</label>
|
| 93 |
+
<label class="checkbox-row align-end-checkbox">
|
| 94 |
+
<input type="checkbox" name="existing_task_remove_clue" value="{{ task.id }}" />
|
| 95 |
+
<span>移除当前线索图</span>
|
| 96 |
+
</label>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
<div class="editor-preview-grid">
|
| 100 |
+
<div>
|
| 101 |
+
<span class="mini-note">当前主图</span>
|
| 102 |
+
<img class="editor-thumb" src="/media/tasks/{{ task.id }}/image" alt="{{ task.title }} 主图" />
|
| 103 |
+
</div>
|
| 104 |
+
<div>
|
| 105 |
+
<span class="mini-note">当前线索图</span>
|
| 106 |
+
{% if task.clue_image_filename %}
|
| 107 |
+
<img class="editor-thumb" src="/media/tasks/{{ task.id }}/clue" alt="{{ task.title }} 线索图" />
|
| 108 |
+
{% else %}
|
| 109 |
+
<div class="empty-thumb">未设置线索图</div>
|
| 110 |
+
{% endif %}
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
<div class="chip-row">
|
| 115 |
+
<span class="chip">提交记录 {{ task.submissions|length }} 条</span>
|
| 116 |
+
<span class="chip">当前顺序 {{ task.display_order }}</span>
|
| 117 |
+
<span class="chip">线索发布时间 {{ task.clue_release_at|datetime_local if task.clue_release_at else '未发布' }}</span>
|
| 118 |
+
</div>
|
| 119 |
+
</article>
|
| 120 |
+
{% endfor %}
|
| 121 |
+
</div>
|
| 122 |
+
</section>
|
| 123 |
+
|
| 124 |
+
<section class="glass-card form-panel wide-panel">
|
| 125 |
+
<div class="section-head tight-head">
|
| 126 |
+
<div>
|
| 127 |
+
<p class="eyebrow">New Tasks</p>
|
| 128 |
+
<h3>新增任务</h3>
|
| 129 |
+
</div>
|
| 130 |
+
<button class="btn btn-secondary" type="button" id="add-new-task-btn">新增任务卡片</button>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
<div class="task-builder" id="new-task-builder">
|
| 134 |
+
<article class="task-builder-card" data-new-task-template>
|
| 135 |
+
<div class="builder-title-row">
|
| 136 |
+
<strong>新增任务 1</strong>
|
| 137 |
+
<button type="button" class="btn btn-ghost small-btn" data-remove-new-task>删除</button>
|
| 138 |
+
</div>
|
| 139 |
+
<div class="form-grid cols-2">
|
| 140 |
+
<label>
|
| 141 |
+
<span>任务标题</span>
|
| 142 |
+
<input type="text" name="new_task_title" />
|
| 143 |
+
</label>
|
| 144 |
+
<label>
|
| 145 |
+
<span>主图</span>
|
| 146 |
+
<input type="file" name="new_task_image" accept="image/*" />
|
| 147 |
+
</label>
|
| 148 |
+
<label class="full-span">
|
| 149 |
+
<span>任务描述</span>
|
| 150 |
+
<textarea name="new_task_description" rows="2"></textarea>
|
| 151 |
+
</label>
|
| 152 |
+
<label class="full-span">
|
| 153 |
+
<span>线索图</span>
|
| 154 |
+
<input type="file" name="new_task_clue_image" accept="image/*" />
|
| 155 |
+
</label>
|
| 156 |
+
</div>
|
| 157 |
+
</article>
|
| 158 |
+
</div>
|
| 159 |
+
|
| 160 |
+
<div class="card-footer">
|
| 161 |
+
<span class="mini-note">保存后会自动重排任务顺序,并按新的顺序刷新线索发布时间。</span>
|
| 162 |
+
<button class="btn btn-primary" type="submit">保存活动修改</button>
|
| 163 |
+
</div>
|
| 164 |
+
</section>
|
| 165 |
+
</form>
|
| 166 |
+
|
| 167 |
+
{% for task in activity.tasks %}
|
| 168 |
+
<form id="delete-task-{{ task.id }}" method="post" action="/admin/tasks/{{ task.id }}/delete"></form>
|
| 169 |
+
{% endfor %}
|
| 170 |
+
|
| 171 |
+
<script>
|
| 172 |
+
(() => {
|
| 173 |
+
const builder = document.getElementById('new-task-builder');
|
| 174 |
+
const addBtn = document.getElementById('add-new-task-btn');
|
| 175 |
+
if (!builder || !addBtn) return;
|
| 176 |
+
|
| 177 |
+
const renumber = () => {
|
| 178 |
+
builder.querySelectorAll('[data-new-task-template]').forEach((card, index) => {
|
| 179 |
+
const title = card.querySelector('.builder-title-row strong');
|
| 180 |
+
if (title) title.textContent = `新增任务 ${index + 1}`;
|
| 181 |
+
});
|
| 182 |
+
};
|
| 183 |
+
|
| 184 |
+
const attachRemove = (card) => {
|
| 185 |
+
const removeBtn = card.querySelector('[data-remove-new-task]');
|
| 186 |
+
if (!removeBtn) return;
|
| 187 |
+
removeBtn.addEventListener('click', () => {
|
| 188 |
+
if (builder.querySelectorAll('[data-new-task-template]').length === 1) {
|
| 189 |
+
card.querySelectorAll('input, textarea').forEach((field) => {
|
| 190 |
+
field.value = '';
|
| 191 |
+
});
|
| 192 |
+
return;
|
| 193 |
+
}
|
| 194 |
+
card.remove();
|
| 195 |
+
renumber();
|
| 196 |
+
});
|
| 197 |
+
};
|
| 198 |
+
|
| 199 |
+
builder.querySelectorAll('[data-new-task-template]').forEach(attachRemove);
|
| 200 |
+
|
| 201 |
+
addBtn.addEventListener('click', () => {
|
| 202 |
+
const template = builder.querySelector('[data-new-task-template]');
|
| 203 |
+
const clone = template.cloneNode(true);
|
| 204 |
+
clone.querySelectorAll('input, textarea').forEach((field) => {
|
| 205 |
+
field.value = '';
|
| 206 |
+
});
|
| 207 |
+
attachRemove(clone);
|
| 208 |
+
builder.appendChild(clone);
|
| 209 |
+
renumber();
|
| 210 |
+
});
|
| 211 |
+
})();
|
| 212 |
+
</script>
|
| 213 |
+
{% endblock %}
|
requirements.txt
CHANGED
|
@@ -6,4 +6,5 @@ jinja2==3.1.6
|
|
| 6 |
python-multipart==0.0.20
|
| 7 |
passlib[bcrypt]==1.7.4
|
| 8 |
bcrypt==4.1.3
|
| 9 |
-
pillow==11.1.0
|
|
|
|
|
|
| 6 |
python-multipart==0.0.20
|
| 7 |
passlib[bcrypt]==1.7.4
|
| 8 |
bcrypt==4.1.3
|
| 9 |
+
pillow==11.1.0
|
| 10 |
+
itsdangerous==2.2.0
|