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

Upload 53 files

Browse files
README.md CHANGED
@@ -1,10 +1,117 @@
1
- ---
2
- title: Cam
3
- emoji: 👀
4
- colorFrom: purple
5
- colorTo: pink
6
  sdk: docker
7
- pinned: false
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- title = str(form.get("title", "")).strip()
323
- description = str(form.get("description", "")).strip()
324
- start_raw = str(form.get("start_at", "")).strip()
325
- deadline_raw = str(form.get("deadline_at", "")).strip()
326
- clue_interval_raw = str(form.get("clue_interval_minutes", "")).strip()
327
- leaderboard_visible = form.get("leaderboard_visible") == "on"
 
 
 
 
 
 
328
 
329
- if not title or not start_raw or not deadline_raw:
330
- add_flash(request, "error", "请完整填写活动标题、开始时间和截止时间。")
331
  return redirect("/admin/activities")
332
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  try:
334
- start_at = datetime.fromisoformat(start_raw)
335
- deadline_at = datetime.fromisoformat(deadline_raw)
336
- except ValueError:
337
- add_flash(request, "error", "时间格式不正确。")
338
- return redirect("/admin/activities")
 
 
 
 
 
 
 
 
 
 
339
 
340
- if deadline_at <= start_at:
341
- add_flash(request, "error", "截止时间必须晚于开始时间。")
342
- return redirect("/admin/activities")
343
 
344
  try:
345
- clue_interval_minutes = int(clue_interval_raw) if clue_interval_raw else None
346
- except ValueError:
347
- add_flash(request, "error", "线索发布时间间隔必须是。")
348
- return redirect("/admin/activities")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
 
350
- task_titles = form.getlist("task_title")
351
- task_descriptions = form.getlist("task_description")
352
- task_images = form.getlist("task_image")
353
- task_clue_images = form.getlist("task_clue_image")
354
 
355
- tasks_payload = []
356
- for index, raw_title in enumerate(task_titles):
357
- task_title = str(raw_title).strip()
358
- task_description = str(task_descriptions[index]).strip() if index < len(task_descriptions) else ""
359
- primary_upload = task_images[index] if index < len(task_images) else None
360
- clue_upload = task_clue_images[index] if index < len(task_clue_images) else None
 
 
 
 
 
 
 
 
 
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
- clue_name = clue_upload.filename
378
- except ValueError as exc:
379
- add_flash(request, "error", str(exc))
380
- return redirect("/admin/activities")
381
-
382
- tasks_payload.append(
383
- {
384
- "title": task_title,
385
- "description": task_description,
386
- "image_data": primary_bytes,
387
- "image_mime": primary_mime,
388
- "image_filename": primary_upload.filename,
389
- "clue_image_data": clue_bytes,
390
- "clue_image_mime": clue_mime,
391
- "clue_image_filename": clue_name,
392
- }
393
  )
 
 
 
394
 
395
- if not tasks_payload:
396
- add_flash(request, "error", "至少需要添加一个任务卡片。")
397
- return redirect("/admin/activities")
398
 
399
- activity = Activity(
400
- title=title,
401
- description=description,
402
- start_at=start_at,
403
- deadline_at=deadline_at,
404
- leaderboard_visible=leaderboard_visible,
405
- clue_interval_minutes=clue_interval_minutes,
406
- created_by_id=admin.id,
407
- )
 
 
 
 
 
408
  db.add(activity)
409
- db.flush()
 
 
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
- add_flash(request, "success", f"活动 {title} 已发布。")
437
- return redirect("/admin/activities")
 
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
- if (field.type === 'file' || field.type === 'text' || field.type === 'number' || field.type === 'datetime-local') {
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