steveyehtw commited on
Commit
b88dc75
·
1 Parent(s): 58528b2

Restore classroom submissions and add classroom admin

Browse files
README.md CHANGED
@@ -25,6 +25,8 @@ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-
25
  - 教師可選現有課程,也可用教師密碼建立新課程代碼。新課程會寫回此 Space,因此建立時會觸發一次重新建置。
26
  - 教師可從課程刪除學生及其教室內進度;已經發布到首頁 Demo 的作品不會連帶刪除。
27
  - 班級與學生名單會寫回 Space repository 長期保存;尚未發布的草稿仍保存在 Space 執行期間。
 
 
28
  - 學生端 Python 會逐段顯示輸出;程式執行到 `input()` 時,Console 才出現輸入列並等待送出。
29
  - 內容管理後台可修改 18 週課程資料與每週套版,也可新增、改名、編輯或刪除公開 Demo 專題。所有儲存都會寫回此 Space。
30
 
 
25
  - 教師可選現有課程,也可用教師密碼建立新課程代碼。新課程會寫回此 Space,因此建立時會觸發一次重新建置。
26
  - 教師可從課程刪除學生及其教室內進度;已經發布到首頁 Demo 的作品不會連帶刪除。
27
  - 班級與學生名單會寫回 Space repository 長期保存;尚未發布的草稿仍保存在 Space 執行期間。
28
+ - 內容管理後台的「管理教室」可新增、改名或刪除班級,管理學生名單,並查看或刪除已提交內容。
29
+ - 下課前可在「管理教室」按「備份本週提交」,將當週正式版寫回 Space repository,避免重新建置後遺失。
30
  - 學生端 Python 會逐段顯示輸出;程式執行到 `input()` 時,Console 才出現輸入列並等待送出。
31
  - 內容管理後台可修改 18 週課程資料與每週套版,也可新增、改名、編輯或刪除公開 Demo 專題。所有儲存都會寫回此 Space。
32
 
admin_service.py CHANGED
@@ -55,6 +55,17 @@ class DemoSave(AdminAuth):
55
  files: dict[str, str]
56
 
57
 
 
 
 
 
 
 
 
 
 
 
 
58
  def segment(value: str, label: str) -> str:
59
  text = re.sub(r"[\x00-\x1f\x7f]", "", str(value).strip())[:100]
60
  if not text or text in {".", ".."} or "/" in text or "\\" in text:
@@ -158,7 +169,47 @@ def admin_page():
158
  @router.post("/api/admin/state")
159
  def admin_state(req: AdminAuth):
160
  classroom.check_password(req.password)
161
- return {"course": load_course(), "demo_projects": scan_demo()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
 
163
 
164
  @router.post("/api/admin/week")
 
55
  files: dict[str, str]
56
 
57
 
58
+ class ClassroomSubmissionRequest(AdminAuth):
59
+ class_code: str
60
+ student_id: str
61
+ week_id: str
62
+
63
+
64
+ class ClassroomBackupRequest(AdminAuth):
65
+ class_code: str
66
+ week_id: str
67
+
68
+
69
  def segment(value: str, label: str) -> str:
70
  text = re.sub(r"[\x00-\x1f\x7f]", "", str(value).strip())[:100]
71
  if not text or text in {".", ".."} or "/" in text or "\\" in text:
 
169
  @router.post("/api/admin/state")
170
  def admin_state(req: AdminAuth):
171
  classroom.check_password(req.password)
172
+ return {"course": load_course(), "demo_projects": scan_demo(), "classrooms": {"courses": classroom.catalog()["courses"], "submissions": classroom.submission_catalog()}}
173
+
174
+
175
+ @router.post("/api/admin/classroom/submission")
176
+ def admin_submission(req: ClassroomSubmissionRequest):
177
+ classroom.check_password(req.password)
178
+ state = classroom.get_submission(req.class_code, req.student_id, req.week_id)
179
+ return {"submission": classroom.submission_payload(state)}
180
+
181
+
182
+ @router.post("/api/admin/classroom/backup")
183
+ def backup_submissions(req: ClassroomBackupRequest):
184
+ classroom.check_password(req.password)
185
+ class_code = classroom.clean(req.class_code, "")
186
+ week_id = classroom.clean(req.week_id, "")
187
+ states = classroom.finals(class_code, week_id)
188
+ if not states:
189
+ raise HTTPException(400, "這一週還沒有學生提交")
190
+ additions = {}
191
+ for state in states:
192
+ path = classroom.submission_path(class_code, state["student_id"], week_id)
193
+ content = (json.dumps(classroom.submission_payload(state), ensure_ascii=False, indent=2) + "\n").encode()
194
+ additions[relative_repo_path(path)] = content
195
+ result = commit_changes(additions, [], f"Backup {class_code} {week_id} classroom submissions")
196
+ for repo_path, content in additions.items():
197
+ path = ROOT / repo_path
198
+ path.parent.mkdir(parents=True, exist_ok=True)
199
+ path.write_bytes(content)
200
+ return {"ok": True, "count": len(states), "commit_url": str(result.commit_url) if result else ""}
201
+
202
+
203
+ @router.post("/api/admin/classroom/submission/delete")
204
+ def delete_submission(req: ClassroomSubmissionRequest):
205
+ classroom.check_password(req.password)
206
+ state = classroom.get_submission(req.class_code, req.student_id, req.week_id)
207
+ path = classroom.submission_path(state["class_code"], state["student_id"], state["week_id"])
208
+ result = commit_changes({}, [relative_repo_path(path)] if path.is_file() else [], f"Delete {state['class_code']} {state['week_id']} submission for {state['display_name']}")
209
+ with classroom.lock, classroom.sqlite3.connect(classroom.DB) as db:
210
+ db.execute("UPDATE classroom_projects SET final_files=NULL,submitted_at=NULL WHERE class_code=? AND student_id=? AND week_id=?",(state["class_code"],state["student_id"],state["week_id"]))
211
+ path.unlink(missing_ok=True)
212
+ return {"ok": True, "commit_url": str(result.commit_url) if result else ""}
213
 
214
 
215
  @router.post("/api/admin/week")
classroom_data/classes.json CHANGED
@@ -7,7 +7,16 @@
7
  {
8
  "code": "L6Sat",
9
  "name": "L6Sat",
10
- "students": []
 
 
 
 
 
 
 
 
 
11
  },
12
  {
13
  "code": "dsad",
 
7
  {
8
  "code": "L6Sat",
9
  "name": "L6Sat",
10
+ "students": [
11
+ {"student_id": "大漢堡", "display_name": "大漢堡"},
12
+ {"student_id": "張棨竣", "display_name": "張棨竣"},
13
+ {"student_id": "李合盛", "display_name": "李合盛"},
14
+ {"student_id": "林守桀", "display_name": "林守桀"},
15
+ {"student_id": "王三策", "display_name": "王三策"},
16
+ {"student_id": "王辭信", "display_name": "王辭信"},
17
+ {"student_id": "胡惟媗", "display_name": "胡惟媗"},
18
+ {"student_id": "蔡昊儒", "display_name": "蔡昊儒"}
19
+ ]
20
  },
21
  {
22
  "code": "dsad",
classroom_data/submissions/L6Sat/大漢堡/week02.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "class_code": "L6Sat",
3
+ "student_id": "大漢堡",
4
+ "display_name": "大漢堡",
5
+ "week_id": "week02",
6
+ "project_name": "校園謎案調查局",
7
+ "submitted_at": 1789172208000,
8
+ "files": {
9
+ "case_data.json": "{\n \"title\": \"消失的校徽\",\n \"intro\": \"午休結束後,校慶展示櫃裡的校徽消失了。你能找出真相嗎?\",\n \"locations\": {\n \"1\": {\n \"name\": \"六年二班教室\",\n \"scene\": \"展示櫃半開,窗戶也沒有關好。\",\n \"evidence\": {\n \"id\": \"67bags\",\n \"name\": \"辭信的67個垃圾袋\",\n \"detail\": \"帶這麼多垃圾帶來做什麼啊\"\n }\n },\n \"2\": {\n \"name\": \"操場司令台\",\n \"scene\": \"地面剛拖過,留下一道彎向圖書館的輪痕。\",\n \"evidence\": {\n \"id\": \"wheel_track\",\n \"name\": \"沾著亮粉的推車輪痕\",\n \"detail\": \"亮粉和展示櫃內的裝飾相同。\"\n }\n },\n \"3\": {\n \"name\": \"圖書館\",\n \"scene\": \"清潔推車停在書架旁,後方似乎有東西反光。\",\n \"requires\": [\"blue_fiber\", \"wheel_track\"],\n \"locked_text\": \"你看見推車,卻還不知道該檢查哪裡。也許要先找出它的移動路線。\",\n \"evidence\": {\n \"id\": \"badge\",\n \"name\": \"卡在推車後方的校徽\",\n \"detail\": \"校徽沒有損壞,背面黏著一小段藍色纖維。\"\n }\n }\n },\n \"conclusions\": {\n \"1\": \"有同學故意把校徽藏在圖書館\",\n \"2\": \"風吹起校徽,清潔推車不小心把它帶到圖書館\",\n \"3\": \"校徽從一開始就放在圖書館\"\n },\n \"correct_answer\": \"2\",\n \"success\": \"推理正確!風把校徽吹到布套上,推車經過教室後又將它帶到圖書館。這是一場意外。\"\n}\n",
10
+ "main.py": "\"\"\"Week 2:校園謎案調查局 Starter。\n\n先完成 TODO 1~4。程式必須能反覆探索、收集線索、查看證物並提出推理。\n\"\"\"\n\nCASE_TITLE = \"第八節課的解剖刀\"\n\nLOCATIONS = {\n \"1\": {\n \"name\": \"舊醫學大樓地下標本解剖室\",\n \"scene\": \"廢棄三十年的老解剖教室,磁磚發黃剝落,空氣中混雜著刺鼻的酚類消毒水與皮肉腐壞的甜腥味。房間中央是一張帶有引流血槽的生鏽不鏽鋼解剖台,牆邊排列著高大的玻璃標本櫃,泡著畸形胎兒與各種器官標本,燈管接觸不良地發出急促的滋滋電流聲。\",\n \"evidence\": \"肺部硬化病理切片箱與第17號大體浸泡池\",\n },\n \"2\": {\n \"name\": \"校園北側舊熱水蒸氣鍋爐房\",\n \"scene\": \"供應全校舊宿舍暖氣與廚房熱水的鍋爐地窖,溫度高達40度以上,管線縱橫交錯,巨大鑄鐵煤炭鍋爐發出沉悶的轟鳴與金屬受熱膨脹的刮擦聲。地面淌著烏黑的煤灰凝結水,視線極度昏暗。\",\n \"evidence\": \"已停用的2號輔助燃燒室通風蝶閥\",\n },\n \"3\": {\n \"name\": \"校長室後方夾層(原早期校長密室避難所)\",\n \"scene\": \"隱藏在氣派校長室紅木書架後方的狹窄夾層,只有不到兩公尺寬,裡面沒有窗戶,牆壁貼滿泛黃的隔音棉。房間內擺著一張皮製束縛椅、一台老式錄影設備,以及一排鎖死的醫用低溫冷藏箱。空氣中只有壓縮機運轉的冰冷低頻聲。\",\n \"evidence\": \"冷藏箱底層的「自願捐贈協議書」金屬夾板\",\n },\n}\n\nevidence_bag = []\n\n\ndef investigate(location_id):\n \"\"\"調查地點並將新線索放入證物袋。\"\"\"\n # TODO 1:確認 location_id 是否存在於 LOCATIONS。\n if location_id not in LOCATIONS:\n print(\"沒這個地點編號啦齁!\")\n return\n # TODO 2:顯示地點名稱與現場描述。\n print(f\"你來到了{LOCATIONS[location_id][\"name\"]}\")\n print(LOCATIONS[location_id][\"scene\"])\n # TODO 3:線索尚未取得時才 append 到 evidence_bag。\n if location_id not in evidence_bag:\n evidence_bag.append(location_id)\n # print(\"這個調查功能還沒完成。\")\n\n\ndef show_evidence():\n \"\"\"列出目前取得的所有證物。\"\"\"\n # TODO 4:沒有證物時顯示提示;否則以 1 開始編號列出。\n print(\"證物袋功能還沒完成。\")\n\n\ndef make_deduction():\n \"\"\"取得足夠證物後,讓玩家選擇真相。\"\"\"\n if len(evidence_bag) < 3:\n print(f\"證物不足:目前 {len(evidence_bag)}/3。再去調查吧!\")\n return False\n\n print(\"\\n哪一個說法最符合所有證物?\")\n print(\"1. 有同學故意把校徽藏在圖書館\")\n print(\"2. 風吹起校徽,清潔推車不小心把它帶到圖書館\")\n print(\"3. 校徽從一開始就放在圖書館\")\n answer = input(\"你的推理:\").strip()\n\n # TODO 5(Checkpoint C):判斷答案,正確時回傳 True。\n print(\"推理判定還沒完成。\")\n return False\n\n\ndef show_menu():\n print(f\"\\n=== 校園謎案調查局:{CASE_TITLE} ===\")\n print(\"1. 調查六年一班教室\")\n print(\"2. 調查操場司令台\")\n print(\"3. 調查圖書館\")\n print(\"4. 查看證物袋\")\n print(\"5. 提出推理\")\n print(\"6. 離開\")\n\n\ndef main():\n print(\"午休結束後,校慶展示櫃裡的校徽消失了。你能找出真相嗎?\")\n\n while True:\n show_menu()\n choice = input(\"請選擇:\").strip()\n\n if choice in LOCATIONS:\n investigate(choice)\n elif choice == \"4\":\n show_evidence()\n elif choice == \"5\":\n if make_deduction():\n break\n elif choice == \"6\":\n print(\"案件暫停。證物袋已交回調查局。\")\n break\n else:\n print(\"請輸入 1~6。\")\n\n\nif __name__ == \"__main__\":\n main()\n"
11
+ }
12
+ }
13
+
classroom_data/submissions/L6Sat/張棨竣/week02.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "class_code": "L6Sat",
3
+ "student_id": "張棨竣",
4
+ "display_name": "張棨竣",
5
+ "week_id": "week02",
6
+ "project_name": "校園謎案調查局",
7
+ "submitted_at": 1789172208000,
8
+ "files": {
9
+ "case_data.json": "{\n \"title\": \"消失的校徽\",\n \"intro\": \"午休結束後,校慶展示櫃裡的校徽消失了。你能找出真相嗎?\",\n \"locations\": {\n \"1\": {\n \"name\": \"六年一班教室\",\n \"scene\": \"展示櫃半開,窗戶也沒有關好。\",\n \"evidence\": {\n \"id\": \"blue_fiber\",\n \"name\": \"窗框上的藍色纖維\",\n \"detail\": \"纖維顏色和清潔推車的布套相同。\"\n }\n },\n \"2\": {\n \"name\": \"操場司令台\",\n \"scene\": \"地面剛拖過,留下一道彎向圖書館的輪痕。\",\n \"evidence\": {\n \"id\": \"wheel_track\",\n \"name\": \"沾著亮粉的推車輪痕\",\n \"detail\": \"亮粉和展示櫃內的裝飾相同。\"\n }\n },\n \"3\": {\n \"name\": \"圖書館\",\n \"scene\": \"清潔推車停在書架旁,後方似乎有東西反光。\",\n \"requires\": [\"blue_fiber\", \"wheel_track\"],\n \"locked_text\": \"你看見推車,卻還不知道該檢查哪裡。也許要先找出它的移動路線。\",\n \"evidence\": {\n \"id\": \"badge\",\n \"name\": \"卡在推車後方的校徽\",\n \"detail\": \"校徽沒有損壞,背面黏著一小段藍色纖維。\"\n }\n }\n },\n \"conclusions\": {\n \"1\": \"有同學故意把校徽藏在圖書館\",\n \"2\": \"風吹起校徽,清潔推車不小心把它帶到圖書館\",\n \"3\": \"校徽從一開始就放在圖書館\"\n },\n \"correct_answer\": \"2\",\n \"success\": \"推理正確!風把校徽吹到布套上,推車經過教室後又將它帶到圖書館。這是一場意外。\"\n}\n",
10
+ "main.py": "\"\"\"Week 2:校園謎案調查局 Starter。\n\n先完成 TODO 1~4。程式必須能反覆探索、收集線索、查看證物並提出推理。\n\"\"\"\n\nCASE_TITLE = \"消失的水壺\"\n\nLOCATIONS = {\n \"1\": {\n \"name\": \"六年13班教室\",\n \"scene\": \"凌亂的座位,窗戶也碎掉了。\",\n \"evidence\": \"玻璃碎片\",\n },\n \"2\": {\n \"name\": \"操場的空地\",\n \"scene\": \"地面有血跡,有人刻意用落葉阻擋的痕跡。\",\n \"evidence\": \" 小鳥的羽毛\" \n },\n \"3\": {\n \"name\": \"洗手台\",\n \"scene\": \"沒有關的水龍頭,後方的垃圾桶閃著光。\",\n \"evidence\": \"在桶子裡的水壺\",\n },\n}\n\nevidence_bag = []\n\n\ndef investigate(location_id):\n \"\"\"調查地點並將新線索放入證物袋。\"\"\"\n # TODO 1:確認 location_id 是否存在於 LOCATIONS。\n if location_id not in LOCATIONS:\n print(\"bad\")\n return\n #TODO 2:顯示地點名稱與現場描述。\n print(f\"你到了{LOCATIONS[location_id][\"name\"]}\")\n print(f\"{LOCATIONS[location_id][\"scene\"]}\")\n # TODO 3:線索尚未取得時才 append 到 evidence_bag。\n if location_id not in evidence_bag:\n evidence_bag.append(location_id)\n\ndef show_evidence():\n \"\"\"列出目前取得的所有證物。\"\"\"\n # TODO 4:沒有證物時顯示提示;否則以 1 開始編號列出。\n if location_id not in evidence_bag:\n print(\"你沒證據\")\n return\n else:\n print(evidence_bag)\n print(\"證物袋功能還沒完成。\")\n\n\ndef make_deduction():\n \"\"\"取得足夠證物後,讓玩家選擇真相。\"\"\"\n if len(evidence_bag) < 3:\n print(f\"證物不足:目前 {len(evidence_bag)}/3。再去調查吧!\")\n return False\n\n print(\"\\n哪一個說法最符合所有證物?\")\n print(\"1. 有同學故意把校徽藏在圖書館\")\n print(\"2. 風吹起校徽,清潔推車不小心把它帶到圖書館\")\n print(\"3. 校徽從一開始就放在圖書館\")\n answer = input(\"你的推理:\").strip()\n\n # TODO 5(Checkpoint C):判斷答案,正確時回傳 True。\n print(\"推理判定還沒完成。\")\n return False\n\n\ndef show_menu():\n print(f\"\\n=== 校園謎案調查局:{CASE_TITLE} ===\")\n print(\"1. 調查六年13班教室\")\n print(\"2. 調查操場的空地\")\n print(\"3. 調查洗手台\")\n print(\"4. 查看證物袋\")\n print(\"5. 提出推理\")\n print(\"6. 離開\")\n\n\ndef main():\n print(\"午休結束後,校慶展示櫃裡的校徽消失了。你能找出真相嗎?\")\n\n while True:\n show_menu()\n choice = input(\"請選擇:\").strip()\n\n if choice in LOCATIONS:\n investigate(choice)\n elif choice == \"4\":\n show_evidence()\n elif choice == \"5\":\n if make_deduction():\n break\n elif choice == \"6\":\n print(\"案件暫停。證物袋已交回調查局。\")\n break\n else:\n print(\"請輸入 1~6。\")\n\n\nif __name__ == \"__main__\":\n main()\n"
11
+ }
12
+ }
13
+
classroom_data/submissions/L6Sat/李合盛/week02.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "class_code": "L6Sat",
3
+ "student_id": "李合盛",
4
+ "display_name": "李合盛",
5
+ "week_id": "week02",
6
+ "project_name": "校園謎案調查局",
7
+ "submitted_at": 1789172208000,
8
+ "files": {
9
+ "case_data.json": "{\n \"title\": \"不見dre人有67人\",\n \"intro\": \"午休結束後,校慶展示櫃裡的人消失了。你能找出真相嗎?\",\n \"locations\": {\n \"1\": {\n \"name\": \"六年一百班教室\",\n \"scene\": \"展示櫃大開,窗戶也沒有關好。\",\n \"evidence\": {\n \"id\": \"blue_fiber\",\n \"name\": \"窗框上的人頭\",\n \"detail\": \"人頭和清潔推車的頭套相同。\"\n }\n },\n \"2\": {\n \"name\": \"操場司令台有人手和腳\",\n \"scene\": \"地面剛拖過,留下一道彎向圖書館的輪痕和血。\",\n \"evidence\": {\n \"id\": \"wheel_track\",\n \"name\": \"\",\n \"detail\": \"血和展示櫃內的標本相同。\"\n }\n },\n \"3\": {\n \"name\": \"圖書館\",\n \"scene\": \"清潔推車停在書架旁,後方似乎有東西反光。\",\n \"requires\": [\"blue_fiber\", \"wheel_track\"],\n \"locked_text\": \"你看見推車,卻還不知道該檢查哪裡。也許要先找出它的移動路線。\",\n \"evidence\": {\n \"id\": \"badge\",\n \"name\": \"卡在推車後方的67人\",\n \"detail\": \"校徽沒有損壞,背面黏著一小段藍色纖維。\"\n }\n }\n },\n \"conclusions\": {\n \"1\": \"有同學故意把校徽藏在圖書館\",\n \"2\": \"風吹起校徽,清潔推車不小心把它帶到圖書館\",\n \"3\": \"校徽從一開始就放在圖書館\"\n },\n \"correct_answer\": \"2\",\n \"success\": \"推理正確!風把校徽吹到布套上,推車經過教室後又將它帶到圖書館。這是一場意外。\"\n}\n",
10
+ "main.py": "\"\"\"Week 2:校園謎案調查局 Starter。\n\n先完成 TODO 1~4。程式必須能反覆探索、收集線索、查看證物並提出推理。\n\"\"\"\n\nCASE_TITLE = \"不見dre人有67人\"\n\nLOCATIONS = {\n \"1\": {\n \"name\": \"六年一百班教室\",\n \"scene\": \"展示櫃大開,窗戶也沒有關好。\",\n \"evidence\": \"窗框上的人頭\",\n },\n \"2\": {\n \"name\": \"操場司令台有人手和腳\",\n \"scene\": \"地面剛拖過,留下一道彎向圖書館的輪痕和血。\",\n \"evidence\": \"血的推車輪痕\",\n },\n \"3\": {\n \"name\": \"生物教室\",\n \"scene\": \"清潔推車停在骨架旁下有血跡,後方似乎有東西在叫。\",\n \"evidence\": \"卡在推車後方的67人\",\n },\n}\n\nevidence_bag = []\n\n\ndef investigate(location_id):\n \"\"\"調查地點並將新線索放入證物袋。\"\"\"\n # TODO 1:確認 location_id 是否存在於 LOCATIONS。\n if location_id not in LOCATIONS\n print(\"here don't have you need\")\n retun\n print(\"這個調查功能還沒完成。\")\n\n\ndef show_evidence():\n \"\"\"列出目前取得的所有證物。\"\"\"\n # TODO 4:沒有證物時顯示提示;否則以 1 開始編號列出。\n print(\"證物袋功能還沒完成。\")\n\n\ndef make_deduction():\n \"\"\"取得足夠證物後,讓玩家選擇真相。\"\"\"\n if len(evidence_bag) < 3:\n print(f\"證物不足:目前 {len(evidence_bag)}/3。再去調查吧!\")\n return False\n\n print(\"\\n哪一個說法最符合所有證物?\")\n print(\"1.他們互相殘殺,最後一個人自殺\")\n print(\"2. 有人故意殺人\")\n print(\"3. 67人一開始就在圖書館\")\n answer = input(\"你的推理:\").strip()\n\n # TODO 5(Checkpoint C):判斷答案,正確時回傳 True。\n print(\"推理判定還沒完成。\")\n return False\n\n\ndef show_menu():\n print(f\"\\n=== 校園謎案調查局:{CASE_TITLE} ===\")\n print(\"1. 調查六年一班教室\")\n print(\"2. 調查操場司令台\")\n print(\"3. 調查圖書館\")\n print(\"4. 查看證物袋\")\n print(\"5. 提出推理\")\n print(\"6. 離開\")\n\n\ndef main():\n print(\"午休結束後,校慶展示櫃裡的校徽消失了。你能找出真相嗎?\")\n\n while True:\n show_menu()\n choice = input(\"請選擇:\").strip()\n\n if choice in LOCATIONS:\n investigate(choice)\n elif choice == \"4\":\n show_evidence()\n elif choice == \"5\":\n if make_deduction():\n break\n elif choice == \"6\":\n print(\"案件暫停。證物袋已交回調查局。\")\n break\n else:\n print(\"請輸入 1~6。\")\n\n\nif __name__ == \"__main__\":\n main()\n"
11
+ }
12
+ }
13
+
classroom_data/submissions/L6Sat/林守桀/week02.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "class_code": "L6Sat",
3
+ "student_id": "林守桀",
4
+ "display_name": "林守桀",
5
+ "week_id": "week02",
6
+ "project_name": "校園謎案調查局",
7
+ "submitted_at": 1789172208000,
8
+ "files": {
9
+ "case_data.json": "{\n \"title\": \"半夜的舊校舍\",\n \"intro\": \"深夜的校園傳出詭異琴聲與哭喊,你能揭開舊校舍的祕密嗎?\",\n \"locations\": {\n \"1\": {\n \"name\": \"廢棄音樂教室\",\n \"scene\": \"鋼琴蓋自己微微晃動,琴鍵上記號筆寫著血紅色的字。\",\n \"evidence\": {\n \"id\": \"sheet_music\",\n \"name\": \"散落一地的老舊樂譜碎片\",\n \"detail\": \"樂譜邊緣有燒焦痕跡,上面記著怪異的音符。\"\n }\n },\n \"2\": {\n \"name\": \"走廊盡頭的女廁\",\n \"scene\": \"最後一間廁所門半開,鏡子上滿是水氣與詭異手印。\",\n \"evidence\": {\n \"id\": \"red_scrunchie\",\n \"name\": \"沾著濕黏水跡的紅色髮圈\",\n \"detail\": \"髮圈上纏著幾根長髮,還散發著淡樟腦味。\"\n }\n },\n \"3\": {\n \"name\": \"地下室器材室\",\n \"scene\": \"角落的老舊木櫃發出叩叩聲,地上有一排發光的鞋印。\",\n \"requires\": [\"sheet_music\", \"red_scrunchie\"],\n \"locked_text\": \"木櫃發出強烈的震動,門被鎖死了。也許需要先找齊走廊與教室的線索才敢靠近。\",\n \"evidence\": {\n \"id\": \"bronze_key\",\n \"name\": \"刻有神秘符號的銅製鑰匙\",\n \"detail\": \"金屬表面冰冷刺骨,刻痕看起來像是手刻的。\"\n }\n }\n },\n \"conclusions\": {\n \"1\": \"這一切只是舊校舍地基下陷產生的自然聲響\",\n \"2\": \"有學長在半夜偷偷溜進舊校舍試圖解開地下的封印\",\n \"3\": \"校園裡真的有幽靈在尋找失落的舊鑰匙\"\n },\n \"correct_answer\": \"2\",\n \"success\": \"推理正確!原來是有人深夜闖入搞鬼,真相大白了!\"\n}",
10
+ "main.py": "\"\"\"Week 2:校園謎案調查局 Starter。\n\n先完成 TODO 1~4。程式必須能反覆探索、收集線索、查看證物並提出推理。\n\"\"\"\n\nCASE_TITLE = \"半夜的舊校舍\"\n\nLOCATIONS = {\n \"1\": {\n \"name\": \"廢棄音樂教室\",\n \"scene\": \"鋼琴蓋自己微微晃動,琴鍵上記號筆寫著血紅色的字。\",\n \"evidence\": \"散落一地的老舊樂譜碎片\",\n },\n \"2\": {\n \"name\": \"走廊盡頭的女廁\",\n \"scene\": \"最後一間廁所門半開,鏡子上滿是水氣與詭異手印。\",\n \"evidence\": \"沾著濕黏水跡的紅色髮圈\",\n },\n \"3\": {\n \"name\": \"地下室器材室\",\n \"scene\": \"角落的老舊木櫃發出叩叩聲,地上有一排發光的鞋印。\",\n \"evidence\": \"刻有神秘符號的銅製鑰匙\",\n },\n}\n\nevidence_bag = []\n\n\ndef investigate(location_id):\n \"\"\"調查地點並將新線索放入證物袋。\"\"\"\n # TODO 1:確認 location_id 是否存在於 LOCATIONS。\n if location_id in LOCATIONS:\n loc = LOCATIONS[location_id]\n # TODO 2:顯示地點名稱與現場描述。\n print(f\"地點:{loc['name']}\")\n print(f\"現場:{loc['scene']}\")\n # TODO 3:線索尚未取得時才 append 到 evidence_bag。\n if loc[\"evidence\"] not in evidence_bag:\n evidence_bag.append(loc[\"evidence\"])\n print(f\"找到證物:{loc['evidence']}\")\n\n\ndef show_evidence():\n \"\"\"列出目前取得的所有證物。\"\"\"\n # TODO 4:沒有證物時顯示提示;否則以 1 開始編號列出。\n if not evidence_bag:\n print(\"目前沒有任何證物。\")\n else:\n for i, item in enumerate(evidence_bag, 1):\n print(f\"{i}. {item}\")\n\n\ndef make_deduction():\n \"\"\"取得足夠證物後,讓玩家選擇真相。\"\"\"\n if len(evidence_bag) < 3:\n print(f\"證物不足:目前 {len(evidence_bag)}/3。再去調查吧!\")\n return False\n\n print(\"\\n哪一個說法最符合所有證物?\")\n print(\"1. 這一切只是舊校舍地基下陷產生的自然聲響\")\n print(\"2. 有學長在半夜偷偷溜進舊校舍試圖解開地下的封印\")\n print(\"3. 校園裡真的有幽靈在尋找失落的舊鑰匙\")\n answer = input(\"你的推理:\").strip()\n\n # TODO (5Checkpoint C):判斷答案,正確時回傳 True。\n if answer == \"2\":\n print(\"推理正確!原來是有人深夜闖入搞鬼,真相大白了!\")\n return True\n else:\n print(\"推理錯誤,這種說法無法解釋所有異象,再試試看。\")\n return False\n\n\ndef show_menu():\n print(f\"\\n=== 校園謎案調查局:{CASE_TITLE} ===\")\n print(\"1. 調查廢棄音樂教室\")\n print(\"2. 調查走廊盡頭的女廁\")\n print(\"3. 調查地下室器材室\")\n print(\"4. 查看證物袋\")\n print(\"5. 提出推理\")\n print(\"6. 逃離現場\")\n\n\ndef main():\n print(\"深夜的校園傳出詭異琴聲與哭喊,你能揭開舊校舍的祕密嗎?\")\n\n while True:\n show_menu()\n choice = input(\"請選擇:\").strip()\n\n if choice in LOCATIONS:\n investigate(choice)\n elif choice == \"4\":\n show_evidence()\n elif choice == \"5\":\n if make_deduction():\n break\n elif choice == \"6\":\n print(\"你感到一陣惡寒,決定先撤離舊校舍。\")\n break\n else:\n print(\"請輸入 1~6。\")\n\n\nif __name__ == \"__main__\":\n main()"
11
+ }
12
+ }
13
+
classroom_data/submissions/L6Sat/王三策/week02.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "class_code": "L6Sat",
3
+ "student_id": "王三策",
4
+ "display_name": "王三策",
5
+ "week_id": "week02",
6
+ "project_name": "校園謎案調查局",
7
+ "submitted_at": 1789172208000,
8
+ "files": {
9
+ "case_data.json": "{\n \"title\": \"消失的校徽\",\n \"intro\": \"午休結束後,校慶展示櫃裡的校徽消失了。你能找出真相嗎?\",\n \"locations\": {\n \"1\": {\n \"name\": \"六年一班教室\",\n \"scene\": \"展示櫃半開,窗戶也沒有關好。\",\n \"evidence\": {\n \"id\": \"blue_fiber\",\n \"name\": \"窗框上的藍色纖維\",\n \"detail\": \"纖維顏色和清潔推車的布套相同。\"\n }\n },\n \"2\": {\n \"name\": \"操場司令台\",\n \"scene\": \"地面剛拖過,留下一道彎向圖書館的輪痕。\",\n \"evidence\": {\n \"id\": \"wheel_track\",\n \"name\": \"沾著亮粉的推車輪痕\",\n \"detail\": \"亮粉和展示櫃內的裝飾相同。\"\n }\n },\n \"3\": {\n \"name\": \"圖書館\",\n \"scene\": \"清潔推車停在書架旁,後方似乎有東西反光。\",\n \"requires\": [\"blue_fiber\", \"wheel_track\"],\n \"locked_text\": \"你看見推車,卻還不知道該檢查哪裡。也許要先找出它的移動路線。\",\n \"evidence\": {\n \"id\": \"badge\",\n \"name\": \"卡在推車後方的校徽\",\n \"detail\": \"校徽沒有損壞,背面黏著一小段藍色纖維。\"\n }\n }\n },\n \"conclusions\": {\n \"1\": \"有同學故意把校徽藏在圖書館\",\n \"2\": \"風吹起校徽,清潔推車不小心把它帶到圖書館\",\n \"3\": \"校徽從一開始就放在圖書館\"\n },\n \"correct_answer\": \"2\",\n \"success\": \"推理正確!風把校徽吹到布套上,推車經過教室後又將它帶到圖書館。這是一場意外。\"\n}\n",
10
+ "main.py": "\"\"\"Week 2:校園謎案調查局 Starter。\n\n先完成 TODO 1~4。程式必須能反覆探索、收集線索、查看證物並提出推理。\n\"\"\"\n\nCASE_TITLE = \"湊企鵝謎團[棍木伱看布辶彳亍子!!!恭喜!棍木]\"\n\nLOCATIONS = {\n \"1\": {\n \"name\": \"資源班教室\",\n \"scene\": \"有兩隻湊企鵝開心的笑[gugugaga]\",\n \"evidence\": \"一個紙條,上面似乎有一串號碼...---...x1\",\n },\n \"2\": {\n \"name\": \"實驗室\",\n \"scene\": \"旁邊的貨架上有綠色和粉色的藥劑在冒泡\",\n \"evidence\": \"一瓶綠色的奇怪藥劑x1一瓶粉色的奇怪藥劑x1\",\n },\n \"3\": {\n \"name\": \"保安室\",\n \"scene\": \"窗戶碎掉了,只有一隻湊企鵝在叫,但是地上有一個奇怪的腳印...\",\n \"evidence\": \"一張奇怪的紙條.... . .-. .- -. --- ..- - -.-.--x1\",\n },\n}\n\nevidence_bag = []\n\n\ndef investigate(location_id):\n \"\"\"調查地點並將新線索放入證物袋。\"\"\"\n # TODO 1:確認 location_id 是否存在於 LOCATIONS。\n if location_id not in LOCATIONS:\n print(\"你眼x嗎沒有這個號碼0227407890\")\n return\n # TODO 2:顯示地點名稱與現場描述\n print(f\"你到了:{LOCATIONS[]['name']}\")\n print(LOCATIONS[location_id][\"scene\"])\n # TODO 3:線索尚未if not in evidence_bag:\n if location_id not in evidence_bag:\n evidence_bag.append(location_id)\n else:\n print(\"你蒐集過這個了^^\") \n\n\ndef show_evidence():\n \"\"\"列出目前取得的所有證物。\"\"\"\n # TODO 4:沒有證物時顯示提示;否則以 1 開始編號列出。\n if not evidence_bag:\n print(\"\\n證物袋目前是空的,快去各地點調查吧!\")\n return\n print(\"證物袋功能還沒完成。\")\n\n\ndef make_deduction():\n \"\"\"取得足夠證物後,讓玩家選擇真相。\"\"\"\n if len(evidence_bag) < 3:\n print(f\"證物不足:目前 {len(evidence_bag)}/3。再去調查吧!\")\n return False\n\n print(\"\\n哪一個說法最符合所有證物?\")\n print(\"1. 有同學故意把校徽藏在圖書館\")\n print(\"2. 風吹起校徽,清潔推車不小心把它帶到圖書館\")\n print(\"3. 校徽從一開始就放在圖書館\")\n answer = input(\"你的推理:\").strip()\n\n # TODO 5(Checkpoint C):判斷答案,正確時回傳 True。\n print(\"推理判定還沒完成。\")\n return False\n\n\ndef show_menu():\n print(f\"\\n=== 校園謎案調查局:{CASE_TITLE} ===\")\n print(\"1. 調查六年一班教室\")\n print(\"2. 調查操場司令台\")\n print(\"3. 調查圖書館\")\n print(\"4. 查看證物袋\")\n print(\"5. 提出推理\")\n print(\"6. 離開\")\n\n\ndef main():\n print(\"午休結束後,校慶展示櫃裡的校徽消失了。你能找出真相嗎?\")\n\n while True:\n show_menu()\n choice = input(\"請選擇:\").strip()\n\n if choice in LOCATIONS:\n investigate(choice)\n elif choice == \"4\":\n show_evidence()\n elif choice == \"5\":\n if make_deduction():\n break\n elif choice == \"6\":\n print(\"案件暫停。證物袋已交回調查局。\")\n break\n else:\n print(\"請輸入 1~6。\")\n\n\nif __name__ == \"__main__\":\n main()\n"
11
+ }
12
+ }
13
+
classroom_data/submissions/L6Sat/王辭信/week02.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "class_code": "L6Sat",
3
+ "student_id": "王辭信",
4
+ "display_name": "王辭信",
5
+ "week_id": "week02",
6
+ "project_name": "校園謎案調查局",
7
+ "submitted_at": 1789172208000,
8
+ "files": {
9
+ "case_data.json": "{\n \"title\": \"消失的校徽\",\n \"intro\": \"午休結束後,校慶展示櫃裡的校徽消失了。你能找出真相嗎?\",\n \"locations\": {\n \"1\": {\n \"name\": \"六年一班教室\",\n \"scene\": \"展示櫃半開,窗戶也沒有關好。\",\n \"evidence\": {\n \"id\": \"blue_fiber\",\n \"name\": \"窗框上的藍色纖維\",\n \"detail\": \"纖維顏色和清潔推車的布套相同。\"\n }\n },\n \"2\": {\n \"name\": \"操場司令台\",\n \"scene\": \"地面剛拖過,留下一道彎向圖書館的輪痕。\",\n \"evidence\": {\n \"id\": \"wheel_track\",\n \"name\": \"沾著亮粉的推車輪痕\",\n \"detail\": \"亮粉和展示櫃內的裝飾相同。\"\n }\n },\n \"3\": {\n \"name\": \"圖書館\",\n \"scene\": \"清潔推車停在書架旁,後方似乎有東西反光。\",\n \"requires\": [\"blue_fiber\", \"wheel_track\"],\n \"locked_text\": \"你看見推車,卻還不知道該檢查哪裡。也許要先找出它的移動路線。\",\n \"evidence\": {\n \"id\": \"badge\",\n \"name\": \"卡在推車後方的校徽\",\n \"detail\": \"校徽沒有損壞,背面黏著一小段藍色纖維。\"\n }\n }\n },\n \"conclusions\": {\n \"1\": \"有同學故意把校徽藏在圖書館\",\n \"2\": \"風吹起校徽,清潔推車不小心把它帶到圖書館\",\n \"3\": \"校徽從一開始就放在圖書館\"\n },\n \"correct_answer\": \"2\",\n \"success\": \"推理正確!風把校徽吹到布套上,推車經過教室後又將它帶到圖書館。這是一場意外。\"\n}\n",
10
+ "main.py": "\"\"\"Week 2:校園謎案調查局 Starter。\n\n先完成 TODO 1~4。程式必須能反覆探索、收集線索、查看證物並提出推理。\n\"\"\"\n\nCASE_TITLE = \"消失的鑽石棍木67不知道很雷霆\"\n\nLOCATIONS = {\n \"1\": {\n \"name\": \"六七年七十八九十一班教室\",\n \"scene\": \"展示櫃半開,窗戶也沒有關好就受者。\",\n \"evidence\": \"窗框上的棍木6767色纖維\",\n },\n \"2\": {\n \"name\": \"676767這波我挺偉偉的操場\",\n \"scene\": \"地面剛拖過,留下一道彎向圖書館的痕跡。\",\n \"evidence\": \"沾著野生狗奶棍木6767的推車輪痕\",\n },\n \"3\": {\n \"name\": \"676767競爭者不好玩圖書館\",\n \"scene\": \"清潔推車停在書架旁,後方似乎有東西反光。\",\n \"evidence\": \"卡在推車後方的鑽石棍木67\",\n },\n}\n\nevidence_bag = []\n\n\ndef investigate(location_id):\n \"\"\"調查地點並將新線索放入證物袋。\"\"\"\n # TODO 1:確認 location_id 是否存在於 LOCATIONS。\n if location_id not in LOCATIONS:\n print(\"無效的地點!\")\n return\n \n # 取得對應地點的資料\n loc = LOCATIONS[location_id]\n \n # TODO 2:顯示地點名稱與現場描述。\n print(f\"\\n【調查地點:{loc['name']}】\")\n print(f\"現場狀況:{loc['scene']}\")\n \n # TODO 3:線索尚未取得時才 append 到 evidence_bag。\n found_evidence = loc['evidence']\n if found_evidence not in evidence_bag:\n evidence_bag.append(found_evidence)\n 過了,沒有新的發現。\")\n\n\ndef show_evidence():\n \"\"\"列出目前取得的所有證物。\"\"\"\n # TODO 4:沒有證物時顯示提示;否則以 1 開始編號列出。\n if not evidence_bag:\n print(\"目前空空如也,趕快去調查吧!\")\n else:\n print(\"\\n=== 目前收到的證物 ===\")\n for i, evidence in enumerate(evidence_bag, start=1):\n print(f\"{i}. {evidence}\")\n print(\"=============\")\n\n\ndef make_deduction():\n \"\"\"取得足夠證物後,讓玩家選擇真相。\"\"\"\n if len(evidence_bag) < 3:\n print(f\"證物不足:目前 {len(evidence_bag)}/3。再去調查吧!\")\n return False\n\n print(\"\\n哪一個說法最符合所有證物?\")\n print(\"1. 有同學故意把鑽石棍木67藏在圖書館\")\n print(\"2. 風吹起鑽石棍木67,清潔推車不小心把它帶到圖書館\")\n print(\"3. 鑽石棍木67從一開始就放在圖書館\")\n answer = input(\"你的推理:\").strip()\n\n # TODO 5(Checkpoint C):判斷答案,正確時回傳 True。\n if answer == \"2\":\n print(\"\\n🎉 推理正確!窗邊的棍木6767色纖維跟推車的軌跡吻合,是一場意外!案件圓滿解決。\")\n return True\n else:\n print(\"\\n❌ 案情似乎沒有這麼簡單,再重新看一次證物吧!\")\n return False\n\n\ndef show_menu():\n print(f\"\\n=== 校園謎案調查局:{CASE_TITLE} ===\")\n print(\"1. 調查六七年七十八九十一班教室\")\n print(\"2. 調查676767這波我挺偉偉的操場\")\n print(\"3. 調查676767競爭者不好玩圖書館\")\n print(\"4. 查看證物袋\")\n print(\"5. 提出推理\")\n print(\"6. 離開\")\n\n\ndef main():\n print(\"午休結束後,校慶展示櫃裡的鑽石棍木67消失了。你能找出真相嗎?\")\n\n while True:\n show_menu()\n choice = input(\"請選擇:\").strip()\n\n if choice in LOCATIONS:\n investigate(choice)\n elif choice == \"4\":\n show_evidence()\n elif choice == \"5\":\n if make_deduction():\n break\n elif choice == \"6\":\n print(\"案件暫停。證物袋已交回調查局。\")\n break\n else:\n print(\"請輸入 1~6。\")\n\n\nif __name__ == \"__main__\":\n main()"
11
+ }
12
+ }
13
+
classroom_data/submissions/L6Sat/胡惟媗/week02.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "class_code": "L6Sat",
3
+ "student_id": "胡惟媗",
4
+ "display_name": "胡惟媗",
5
+ "week_id": "week02",
6
+ "project_name": "校園謎案調查局",
7
+ "submitted_at": 1789172208000,
8
+ "files": {
9
+ "case_data.json": "{\n \"title\": \"消失的校徽\",\n \"intro\": \"午休結束後,校慶展示櫃裡的校徽消失了。你能找出真相嗎?\",\n \"locations\": {\n \"1\": {\n \"name\": \"六年一班教室\",\n \"scene\": \"展示櫃半開,窗戶也沒有關好。\",\n \"evidence\": {\n \"id\": \"blue_fiber\",\n \"name\": \"窗框上的藍色纖維\",\n \"detail\": \"纖維顏色和清潔推車的布套相同。\"\n }\n },\n \"2\": {\n \"name\": \"操場司令台\",\n \"scene\": \"地面剛拖過,留下一道彎向圖書館的輪痕。\",\n \"evidence\": {\n \"id\": \"wheel_track\",\n \"name\": \"沾著亮粉的推車輪痕\",\n \"detail\": \"亮粉和展示櫃內的裝飾相同。\"\n }\n },\n \"3\": {\n \"name\": \"圖書館\",\n \"scene\": \"清潔推車停在書架旁,後方似乎有東西反光。\",\n \"requires\": [\"blue_fiber\", \"wheel_track\"],\n \"locked_text\": \"你看見推車,卻還不知道該檢查哪裡。也許要先找出它的移動路線。\",\n \"evidence\": {\n \"id\": \"badge\",\n \"name\": \"卡在推車後方的校徽\",\n \"detail\": \"校徽沒有損壞,背面黏著一小段藍色纖維。\"\n }\n }\n },\n \"conclusions\": {\n \"1\": \"有同學故意把校徽藏在圖書館\",\n \"2\": \"風吹起校徽,清潔推車不小心把它帶到圖書館\",\n \"3\": \"校徽從一開始就放在圖書館\"\n },\n \"correct_answer\": \"2\",\n \"success\": \"推理正確!風把校徽吹到布套上,推車經過教室後又將它帶到圖書館。這是一場意外。\"\n}\n",
10
+ "main.py": "\"\"\"Week 2:校園謎案調查局 Starter。\n\n先完成 TODO 1~4。程式必須能反覆探索、收集線索、查看證物並提出推理。\n\"\"\"\n\nCASE_TITLE = \"停止の時間(?\"\n\nLOCATIONS = {\n \"1\": {\n \"name\": \"日本の地下鉄\",\n \"scene\": \"車子裡面空無一人\",\n \"evidence\": \"時間一直停在11:59\",\n },\n \"2\": {\n \"name\": \"座位隔壁\",\n \"scene\": \"有一張照片\",\n \"evidence\": \"是自己和過世的母親的照片\",\n },\n \"3\": {\n \"name\": \"駕駛員座椅\",\n \"scene\": \"地鐵一直行駛\",\n \"evidence\": \"駕駛座沒有人\",\n },\n}\n\nevidence_bag = []\n\n\ndef investigate(location_id):\n \"\"\"調查地點並將新線索放入證物袋。\"\"\"\n \n if location_id in LOCATIONS:\n print(LOCATIONS[location_id][\"scene\"])\n else:\n print(\"找不到地點 請重找\")\n# TODO 2:顯示地點名稱與現場描述。\n print(f\"地點:{loc['name']}\")\n print(f\"現場描述:{loc['scene']}\")\n # TODO 3:線索尚未取得時才 append 到 evidence_bag。\n evidence = loc[\"evidence\"]\n if evidence not in evidence_bag:\n evidence_bag.append(evidence)\n print(f\"取得新線索:{evidence}\")\n else:\n print(\"找不到地點 請重找\")\n\ndef show_evidence():\n \"\"\"列出目前取得的所有證物。\"\"\"\n # TODO 4:沒有證物時顯示提示;否則以 1 開始編號列出。\n print(\"證物袋功能還沒完成。\")\n\n\ndef make_deduction():\n \"\"\"取得足夠證物後,讓玩家選擇真相。\"\"\"\n if len(evidence_bag) < 3:\n print(f\"證物不足:目前 {len(evidence_bag)}/3。再去調查吧!\")\n return False\n\n print(\"\\n哪一個說法最符合所有證物?\")\n print(\"1. 有同學故意把校徽藏在圖書館\")\n print(\"2. 風吹起校徽,清潔推車不小心把它帶到圖書館\")\n print(\"3. 校徽從一開始就放在圖書館\")\n answer = input(\"你的推理:\").strip()\n\n # TODO 5(Checkpoint C):判斷答案,正確時回傳 True。\n print(\"推理判定還沒完成。\")\n return False\n\n\ndef show_menu():\n print(f\"\\n=== 校園謎案調查局:{CASE_TITLE} ===\")\n print(\"1. 調查六年一班教室\")\n print(\"2. 調查操場司令台\")\n print(\"3. 調查圖書館\")\n print(\"4. 查看證物袋\")\n print(\"5. 提出推理\")\n print(\"6. 離開\")\n\n\ndef main():\n print(\"午休結束後,校慶展示櫃裡的校徽消失了。你能找出真相嗎?\")\n\n while True:\n show_menu()\n choice = input(\"請選擇:\").strip()\n\n if choice in LOCATIONS:\n investigate(choice)\n elif choice == \"4\":\n show_evidence()\n elif choice == \"5\":\n if make_deduction():\n break\n elif choice == \"6\":\n print(\"案件暫停。證物袋已交回調查局。\")\n break\n else:\n print(\"請輸入 1~6。\")\n\n\nif __name__ == \"__main__\":\n main()\n"
11
+ }
12
+ }
13
+
classroom_data/submissions/L6Sat/蔡昊儒/week02.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "class_code": "L6Sat",
3
+ "student_id": "蔡昊儒",
4
+ "display_name": "蔡昊儒",
5
+ "week_id": "week02",
6
+ "project_name": "校園謎案調查局",
7
+ "submitted_at": 1789172208000,
8
+ "files": {
9
+ "case_data.json": "{\n \"title\": \"消失的校徽\",\n \"intro\": \"午休結束後,校慶展示櫃裡的校徽消失了。你能找出真相嗎?\",\n \"locations\": {\n \"1\": {\n \"name\": \"六年一班教室\",\n \"scene\": \"展示櫃半開,窗戶也沒有關好。\",\n \"evidence\": {\n \"id\": \"blue_fiber\",\n \"name\": \"窗框上的藍色纖維\",\n \"detail\": \"纖維顏色和清潔推車的布套相同。\"\n }\n },\n \"2\": {\n \"name\": \"操場司令台\",\n \"scene\": \"地面剛拖過,留下一道彎向圖書館的輪痕。\",\n \"evidence\": {\n \"id\": \"wheel_track\",\n \"name\": \"沾著亮粉的推車輪痕\",\n \"detail\": \"亮粉和展示櫃內的裝飾相同。\"\n }\n },\n \"3\": {\n \"name\": \"圖書館\",\n \"scene\": \"清潔推車停在書架旁,後方似乎有東西反光。\",\n \"requires\": [\"blue_fiber\", \"wheel_track\"],\n \"locked_text\": \"你看見推車,卻還不知道該檢查哪裡。也許要先找出它的移動路線。\",\n \"evidence\": {\n \"id\": \"badge\",\n \"name\": \"卡在推車後方的校徽\",\n \"detail\": \"校徽沒有損壞,背面黏著一小段藍色纖維。\"\n }\n }\n },\n \"conclusions\": {\n \"1\": \"有同學故意把校徽藏在圖書館\",\n \"2\": \"風吹起校徽,清潔推車不小心把它帶到圖書館\",\n \"3\": \"校徽從一開始就放在圖書館\"\n },\n \"correct_answer\": \"2\",\n \"success\": \"推理正確!風把校徽吹到布套上,推車經過教室後又將它帶到圖書館。這是一場意外。\"\n}\n",
10
+ "main.py": "\"\"\"Week 2:校園謎案調查局 Starter。\n\n先完成 TODO 1~4。程式必須能反覆探索、收集線索、查看證物並提出推理。\n\"\"\"\n\nCASE_TITLE = \"失火的房子\"\n\nLOCATIONS = {\n \"1\": {\n \"name\": \"廚房\",\n \"scene\": \"烏漆嗎黑\",\n \"evidence\": \"有電磁爐爆炸的痕跡\",\n },\n \"2\": {\n \"name\": \"客廳\",\n \"scene\": \"物品東倒西歪,有些物品燒焦\",\n \"evidence\": \"打火機在地上\",\n },\n \"3\": {\n \"name\": \"書房\",\n \"scene\": \"棉被蓋住一個屍體\",\n \"evidence\": \"屍體是房子主人的弟弟\",\n },\n}\n\nevidence_bag = []\n\n\ndef investigate(location_id):\n \"\"\"調查地點並將新線索放入證物袋。\"\"\"\n # TODO 1:確認 location_id 是否存在於 LOCATIONS。\n str_id = str(location_id)\n is_in_locations = False\n for i in LOCATIONS:\n if str_id == i:\n is_in_locations = False\n if is_in_locations == False:\n return 0 \n # TODO 2:顯示地點名稱與現場描述。\n print(LOCATIONS[str_id][\"name\"])\n print(LOCATIONS[str_id][\"scene\"])\n # TODO 3:線索尚未取得時才 append 到 evidence_bag。\n if LOCATIONS[str_id][\"evidence\"] not in evidence_bag:\n evidence_bag.append(LOCATIONS[str_id][\"evidence\"])\n\n\ndef show_evidence():\n \"\"\"列出目前取得的所有證物。\"\"\"\n # TODO 4:沒有證物時顯示提示;否則以 1 開始編號列出。\n if evidence_bag == []:\n print(\"there is nothing in the bag bro\")\n else:\n for index, evidence in enumerate(evidence_bag, 0):\n print(f\"Clue {index+1}: {evidence}\")\n \n print(\"證物袋功能還沒完成。\")\n\n\ndef make_deduction():\n \"\"\"取得足夠證物後,讓玩家選擇真相。\"\"\"\n if len(evidence_bag) < 3:\n print(f\"證物不足:目前 {len(evidence_bag)}/3。再去調查吧!\")\n return False\n\n print(\"\\n哪一個說法最符合所有證物?\")\n print(\"1. 主人煮飯搞砸了\")\n print(\"2. 弟弟煮飯時去睡覺\")\n print(\"3. 主人用打火機點燃廚房,害死弟弟\")\n answer = input(\"你的推理:\").strip()\n\n # TODO 5(Checkpoint C):判斷答案,正確時回傳 True。\n if answer == 2:\n print(\"success\")\n return True\n print(\"推理判定還沒完成。\")\n return False\n\n\ndef show_menu():\n print(f\"\\n=== 校園謎案調查局:{CASE_TITLE} ===\")\n print(\"1. 調查六年一班教室\")\n print(\"2. 調查操場司令台\")\n print(\"3. 調查圖書館\")\n print(\"4. 查看證物袋\")\n print(\"5. 提出推理\")\n print(\"6. 離開\")\n\n\ndef main():\n print(\"午休結束後,校慶展示櫃裡的校徽消失了。你能找出真相嗎?\")\n\n while True:\n show_menu()\n choice = input(\"請選擇:\").strip()\n\n if choice in LOCATIONS:\n investigate(choice)\n elif choice == \"4\":\n show_evidence()\n elif choice == \"5\":\n if make_deduction():\n break\n elif choice == \"6\":\n print(\"案件暫停。證物袋已交回調查局。\")\n break\n else:\n print(\"請輸入 1~6。\")\n\n\nif __name__ == \"__main__\":\n main()\n"
11
+ }
12
+ }
13
+
classroom_service.py CHANGED
@@ -15,6 +15,8 @@ router=APIRouter(); lock=threading.Lock(); persist_lock=threading.Lock(); studen
15
 
16
  class TeacherRequest(BaseModel): class_code:str; week_id:str; password:str
17
  class CourseCreate(BaseModel): class_code:str; course_name:str=""; password:str
 
 
18
  class StudentCreate(BaseModel): class_code:str; display_names:list[str]; password:str
19
  class StudentLogin(BaseModel): class_code:str; display_name:str
20
  class StudentDelete(BaseModel): class_code:str; student_id:str; password:str
@@ -44,7 +46,17 @@ def init_db():
44
  name=clean_label(student.get("display_name") or student.get("name"),"")
45
  student_id=clean(student.get("student_id") or name,"")
46
  if name and student_id:c.execute("INSERT OR IGNORE INTO classroom_members VALUES(?,?,?,?)",(code,student_id,name,now()))
47
- def empty(c,s,n,w): return {"class_code":c,"student_id":s,"display_name":n,"week_id":w,"project_name":"Python專案","files":{"main.py":"# 在這裡寫 Python\n"},"final_files":None,"active_file":"main.py","output":"","status":"idle","needs_help":False,"updated_at":now(),"submitted_at":None,"connected":True}
 
 
 
 
 
 
 
 
 
 
48
  def from_row(r,connected=False): return {"class_code":r[0],"student_id":r[1],"display_name":r[2],"week_id":r[3],"project_name":r[4],"files":json.loads(r[5]),"final_files":json.loads(r[6]) if r[6] else None,"active_file":r[7],"output":r[8],"status":r[9],"needs_help":bool(r[10]),"updated_at":r[11],"submitted_at":r[12],"connected":connected}
49
  SELECT="SELECT class_code,student_id,display_name,week_id,project_name,draft_files,final_files,active_file,output,status,needs_help,updated_at,submitted_at FROM classroom_projects"
50
  def load(c,s,n,w):
@@ -71,6 +83,20 @@ def find_member(class_code,display_name="",student_id=""):
71
  if student_id and member_id==student_id and (not wanted or name.casefold()==wanted):return {"student_id":member_id,"display_name":name}
72
  if not student_id and wanted and name.casefold()==wanted:return {"student_id":member_id,"display_name":name}
73
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  def snapshot(c,w):
75
  with lock,sqlite3.connect(DB) as db:rows=db.execute(SELECT+" WHERE class_code=? AND week_id=?",(c,w)).fetchall()
76
  found={r[1]:from_row(r) for r in rows}
@@ -111,14 +137,16 @@ def hf_failure_message(exc):
111
  else:reason=f"連線到 Hugging Face 時發生 {type(exc).__name__}"
112
  suffix=f"HTTP {status}:{detail}" if status else detail
113
  return f"課程已可使用,但尚未永久寫入 Space。{reason}。詳情:{suffix}"
114
- def persist_catalog(message):
115
  if not TOKEN:return "名單已可使用,但尚未永久寫入 Space;請設定 HF_TOKEN。"
116
  try:
117
- from huggingface_hub import CommitOperationAdd,HfApi
118
  payload=[]
119
  for course in catalog()["courses"]:payload.append({"code":course["class_code"],"name":course["course_name"],"students":course["students"]})
120
  content=(json.dumps(payload,ensure_ascii=False,indent=2)+"\n").encode()
121
- with persist_lock:HfApi(token=TOKEN).create_commit(repo_id=REPO,repo_type="space",operations=[CommitOperationAdd(path_in_repo="classroom_data/classes.json",path_or_fileobj=content)],commit_message=message)
 
 
122
  return ""
123
  except Exception as exc:
124
  logger.exception("Could not persist classroom roster to %s",REPO)
@@ -155,6 +183,26 @@ def create_class(req:CourseCreate):
155
  else:db.execute("INSERT INTO classroom_courses VALUES(?,?,?)",(code,name,now()))
156
  warning=persist_catalog(f"Add classroom course {code}")
157
  return {"ok":True,"class_code":code,"course_name":name,"already_exists":bool(existing),"warning":warning}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  @router.post("/api/classroom/students")
159
  async def create_students(req:StudentCreate):
160
  check_password(req.password); code=clean(req.class_code,"")
@@ -174,7 +222,7 @@ async def create_students(req:StudentCreate):
174
  return {"ok":True,"created":created,"warning":warning}
175
  @router.delete("/api/classroom/students")
176
  async def delete_student(req:StudentDelete):
177
- check_password(req.password); code=clean(req.class_code,"L6A"); student_id=clean(req.student_id,"student")
178
  targets=[(key,ws) for key,ws in students.items() if key[0]==code and key[1]==student_id]
179
  for key,ws in targets:
180
  students.pop(key,None);live.pop(key,None)
@@ -183,7 +231,7 @@ async def delete_student(req:StudentDelete):
183
  with lock,sqlite3.connect(DB) as db:
184
  db.execute("DELETE FROM classroom_projects WHERE class_code=? AND student_id=?",(code,student_id))
185
  db.execute("DELETE FROM classroom_members WHERE class_code=? AND student_id=?",(code,student_id))
186
- warning=persist_catalog(f"Remove classroom student {student_id} from {code}")
187
  for class_code,week_id in list(teachers):
188
  if class_code==code:await broadcast(code,week_id)
189
  return {"ok":True,"warning":warning}
@@ -232,7 +280,7 @@ async def student_ws(ws:WebSocket):
232
  while True:
233
  m=await ws.receive_json();kind=m.get("type");state["updated_at"]=now()
234
  if kind=="project":
235
- state["files"]=norm_files(m.get("files"));state["project_name"]=clean(m.get("project_name"),"Python專案");active=safe_path(m.get("active_file","main.py"));state["active_file"]=active if active in state["files"] else "main.py"
236
  elif kind=="output":state["output"]=str(m.get("output",""))[-40000:]
237
  elif kind=="status":state["status"]=m.get("status") if m.get("status") in {"idle","running","success","error"} else "idle"
238
  elif kind=="help":state["needs_help"]=bool(m.get("needs_help"))
 
15
 
16
  class TeacherRequest(BaseModel): class_code:str; week_id:str; password:str
17
  class CourseCreate(BaseModel): class_code:str; course_name:str=""; password:str
18
+ class CourseUpdate(BaseModel): class_code:str; course_name:str; password:str
19
+ class CourseDelete(BaseModel): class_code:str; password:str
20
  class StudentCreate(BaseModel): class_code:str; display_names:list[str]; password:str
21
  class StudentLogin(BaseModel): class_code:str; display_name:str
22
  class StudentDelete(BaseModel): class_code:str; student_id:str; password:str
 
46
  name=clean_label(student.get("display_name") or student.get("name"),"")
47
  student_id=clean(student.get("student_id") or name,"")
48
  if name and student_id:c.execute("INSERT OR IGNORE INTO classroom_members VALUES(?,?,?,?)",(code,student_id,name,now()))
49
+ submissions=DATA/"submissions"
50
+ if submissions.is_dir():
51
+ for path in submissions.glob("*/*/*.json"):
52
+ try:record=json.loads(path.read_text(encoding="utf-8"));files=norm_files(record.get("files",{}));code=clean(record.get("class_code"),"");student_id=clean(record.get("student_id"),"");name=clean_label(record.get("display_name"),"");week_id=clean(record.get("week_id"),"")
53
+ except (OSError,ValueError,TypeError):continue
54
+ if not all((code,student_id,name,week_id)):continue
55
+ submitted_at=int(record.get("submitted_at") or int(path.stat().st_mtime*1000));project_name=clean_label(record.get("project_name"),"")
56
+ c.execute("INSERT OR IGNORE INTO classroom_courses VALUES(?,?,?)",(code,code,now()))
57
+ c.execute("INSERT OR IGNORE INTO classroom_members VALUES(?,?,?,?)",(code,student_id,name,now()))
58
+ c.execute("INSERT OR IGNORE INTO classroom_projects VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",(code,student_id,name,week_id,project_name,json.dumps(files,ensure_ascii=False),json.dumps(files,ensure_ascii=False),"main.py","","idle",0,submitted_at,submitted_at))
59
+ def empty(c,s,n,w): return {"class_code":c,"student_id":s,"display_name":n,"week_id":w,"project_name":"","files":{"main.py":"# 在這裡寫 Python\n"},"final_files":None,"active_file":"main.py","output":"","status":"idle","needs_help":False,"updated_at":now(),"submitted_at":None,"connected":True}
60
  def from_row(r,connected=False): return {"class_code":r[0],"student_id":r[1],"display_name":r[2],"week_id":r[3],"project_name":r[4],"files":json.loads(r[5]),"final_files":json.loads(r[6]) if r[6] else None,"active_file":r[7],"output":r[8],"status":r[9],"needs_help":bool(r[10]),"updated_at":r[11],"submitted_at":r[12],"connected":connected}
61
  SELECT="SELECT class_code,student_id,display_name,week_id,project_name,draft_files,final_files,active_file,output,status,needs_help,updated_at,submitted_at FROM classroom_projects"
62
  def load(c,s,n,w):
 
83
  if student_id and member_id==student_id and (not wanted or name.casefold()==wanted):return {"student_id":member_id,"display_name":name}
84
  if not student_id and wanted and name.casefold()==wanted:return {"student_id":member_id,"display_name":name}
85
  return None
86
+ def submission_path(class_code,student_id,week_id):return DATA/"submissions"/clean(class_code,"")/clean(student_id,"")/f"{clean(week_id,'')}.json"
87
+ def submission_payload(state):
88
+ return {key:state[key] for key in ("class_code","student_id","display_name","week_id","project_name","submitted_at")}|{"files":state["final_files"]}
89
+ def submission_catalog():
90
+ with lock,sqlite3.connect(DB) as db:rows=db.execute(SELECT+" WHERE final_files IS NOT NULL ORDER BY class_code,display_name,week_id").fetchall()
91
+ return [{"class_code":x["class_code"],"student_id":x["student_id"],"display_name":x["display_name"],"week_id":x["week_id"],"project_name":x["project_name"],"submitted_at":x["submitted_at"],"file_count":len(x["final_files"] or {})} for x in (from_row(row) for row in rows)]
92
+ def get_submission(class_code,student_id,week_id):
93
+ with lock,sqlite3.connect(DB) as db:row=db.execute(SELECT+" WHERE class_code=? AND student_id=? AND week_id=? AND final_files IS NOT NULL",(clean(class_code,""),clean(student_id,""),clean(week_id,""))).fetchone()
94
+ if not row:raise HTTPException(404,"找不到這份提交")
95
+ return from_row(row)
96
+ def archived_paths(class_code,student_id=""):
97
+ root=DATA/"submissions"/clean(class_code,"")
98
+ target=root/clean(student_id,"") if student_id else root
99
+ return [path.relative_to(ROOT).as_posix() for path in target.rglob("*.json")] if target.is_dir() else []
100
  def snapshot(c,w):
101
  with lock,sqlite3.connect(DB) as db:rows=db.execute(SELECT+" WHERE class_code=? AND week_id=?",(c,w)).fetchall()
102
  found={r[1]:from_row(r) for r in rows}
 
137
  else:reason=f"連線到 Hugging Face 時發生 {type(exc).__name__}"
138
  suffix=f"HTTP {status}:{detail}" if status else detail
139
  return f"課程已可使用,但尚未永久寫入 Space。{reason}。詳情:{suffix}"
140
+ def persist_catalog(message,deletions=None):
141
  if not TOKEN:return "名單已可使用,但尚未永久寫入 Space;請設定 HF_TOKEN。"
142
  try:
143
+ from huggingface_hub import CommitOperationAdd,CommitOperationDelete,HfApi
144
  payload=[]
145
  for course in catalog()["courses"]:payload.append({"code":course["class_code"],"name":course["course_name"],"students":course["students"]})
146
  content=(json.dumps(payload,ensure_ascii=False,indent=2)+"\n").encode()
147
+ operations=[CommitOperationDelete(path_in_repo=path) for path in sorted(set(deletions or []))]
148
+ operations.append(CommitOperationAdd(path_in_repo="classroom_data/classes.json",path_or_fileobj=content))
149
+ with persist_lock:HfApi(token=TOKEN).create_commit(repo_id=REPO,repo_type="space",operations=operations,commit_message=message)
150
  return ""
151
  except Exception as exc:
152
  logger.exception("Could not persist classroom roster to %s",REPO)
 
183
  else:db.execute("INSERT INTO classroom_courses VALUES(?,?,?)",(code,name,now()))
184
  warning=persist_catalog(f"Add classroom course {code}")
185
  return {"ok":True,"class_code":code,"course_name":name,"already_exists":bool(existing),"warning":warning}
186
+ @router.put("/api/classroom/classes")
187
+ def update_class(req:CourseUpdate):
188
+ check_password(req.password);code=clean(req.class_code,"");name=clean_label(req.course_name,"")
189
+ if not code or not name:raise HTTPException(400,"請輸入班級名稱")
190
+ with lock,sqlite3.connect(DB) as db:
191
+ if not db.execute("SELECT 1 FROM classroom_courses WHERE class_code=?",(code,)).fetchone():raise HTTPException(404,"找不到這個班級")
192
+ db.execute("UPDATE classroom_courses SET course_name=? WHERE class_code=?",(name,code))
193
+ return {"ok":True,"warning":persist_catalog(f"Rename classroom course {code}"),"course_name":name}
194
+ @router.delete("/api/classroom/classes")
195
+ async def delete_class(req:CourseDelete):
196
+ check_password(req.password);code=clean(req.class_code,"");deletions=archived_paths(code)
197
+ with lock,sqlite3.connect(DB) as db:
198
+ if not db.execute("SELECT 1 FROM classroom_courses WHERE class_code=?",(code,)).fetchone():raise HTTPException(404,"找不到這個班級")
199
+ db.execute("DELETE FROM classroom_projects WHERE class_code=?",(code,));db.execute("DELETE FROM classroom_members WHERE class_code=?",(code,));db.execute("DELETE FROM classroom_courses WHERE class_code=?",(code,))
200
+ for key,ws in list(students.items()):
201
+ if key[0]==code:
202
+ students.pop(key,None);live.pop(key,None)
203
+ try:await ws.close(code=4004,reason="class removed")
204
+ except Exception:pass
205
+ return {"ok":True,"warning":persist_catalog(f"Delete classroom course {code}",deletions)}
206
  @router.post("/api/classroom/students")
207
  async def create_students(req:StudentCreate):
208
  check_password(req.password); code=clean(req.class_code,"")
 
222
  return {"ok":True,"created":created,"warning":warning}
223
  @router.delete("/api/classroom/students")
224
  async def delete_student(req:StudentDelete):
225
+ check_password(req.password); code=clean(req.class_code,"L6A"); student_id=clean(req.student_id,"student");deletions=archived_paths(code,student_id)
226
  targets=[(key,ws) for key,ws in students.items() if key[0]==code and key[1]==student_id]
227
  for key,ws in targets:
228
  students.pop(key,None);live.pop(key,None)
 
231
  with lock,sqlite3.connect(DB) as db:
232
  db.execute("DELETE FROM classroom_projects WHERE class_code=? AND student_id=?",(code,student_id))
233
  db.execute("DELETE FROM classroom_members WHERE class_code=? AND student_id=?",(code,student_id))
234
+ warning=persist_catalog(f"Remove classroom student {student_id} from {code}",deletions)
235
  for class_code,week_id in list(teachers):
236
  if class_code==code:await broadcast(code,week_id)
237
  return {"ok":True,"warning":warning}
 
280
  while True:
281
  m=await ws.receive_json();kind=m.get("type");state["updated_at"]=now()
282
  if kind=="project":
283
+ state["files"]=norm_files(m.get("files"));state["project_name"]=clean_label(m.get("project_name"),"");active=safe_path(m.get("active_file","main.py"));state["active_file"]=active if active in state["files"] else "main.py"
284
  elif kind=="output":state["output"]=str(m.get("output",""))[-40000:]
285
  elif kind=="status":state["status"]=m.get("status") if m.get("status") in {"idle","running","success","error"} else "idle"
286
  elif kind=="help":state["needs_help"]=bool(m.get("needs_help"))
classroom_static/admin.css CHANGED
@@ -52,6 +52,43 @@
52
  .code-field textarea:focus { box-shadow:inset 0 0 0 2px var(--action); }
53
  .asset-note { max-height:110px;overflow:auto;padding:.65rem .75rem;border-top:1px solid var(--line);color:var(--muted);font-size:.72rem;line-height:1.45; }
54
  .admin-empty { padding:1.25rem;color:var(--muted);font-size:.82rem;line-height:1.5; }
55
- @media (max-width:980px) { .admin-tabs>span{display:none}.admin-view{grid-template-columns:270px minmax(0,1fr)}.admin-fields.three{grid-template-columns:1fr}.admin-fields{max-height:230px;overflow:auto}.file-workspace{grid-template-columns:200px minmax(0,1fr)} }
56
- @media (max-width:720px) { .admin-view{grid-template-columns:220px minmax(0,1fr)}.admin-tabs{padding-inline:.5rem}.admin-tab{padding-inline:.7rem}.admin-editor-head{padding-inline:.75rem}.admin-editor-head p{display:none}.admin-fields{padding:.75rem}.file-workspace{grid-template-columns:170px minmax(0,1fr)} }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  @media (prefers-reduced-motion:reduce) { *,*::before,*::after{transition-duration:.01ms!important;animation-duration:.01ms!important} }
 
52
  .code-field textarea:focus { box-shadow:inset 0 0 0 2px var(--action); }
53
  .asset-note { max-height:110px;overflow:auto;padding:.65rem .75rem;border-top:1px solid var(--line);color:var(--muted);font-size:.72rem;line-height:1.45; }
54
  .admin-empty { padding:1.25rem;color:var(--muted);font-size:.82rem;line-height:1.5; }
55
+ .classroom-sidebar { grid-template-rows:auto minmax(0,1fr); }
56
+ .classroom-manager { min-width:0;min-height:0;display:grid;grid-template-rows:auto auto minmax(0,1fr);overflow:hidden; }
57
+ .classroom-fields { display:grid;grid-template-columns:minmax(150px,.7fr) minmax(220px,1fr) auto;gap:1rem;align-items:end;padding:.8rem 1.5rem;border-bottom:1px solid var(--line);background:var(--surface); }
58
+ .classroom-fields>label,.backup-controls label,.manager-add>label { display:grid;gap:.35rem;color:var(--muted);font-size:.76rem;font-weight:800; }
59
+ .backup-controls { display:flex;align-items:end;gap:.5rem; }
60
+ .backup-controls select { min-width:220px; }
61
+ .classroom-workspace { min-height:0;display:grid;grid-template-columns:280px minmax(0,1fr); }
62
+ .student-manager { min-height:0;display:grid;grid-template-rows:auto auto minmax(0,1fr);overflow:hidden;border-right:1px solid var(--line);background:var(--surface); }
63
+ .manager-add { display:grid;gap:.5rem;padding:1rem;border-bottom:1px solid var(--line); }
64
+ .manager-add>div { display:flex;gap:.5rem; }
65
+ .manager-add input { min-width:0;flex:1; }
66
+ .manager-add .btn { flex:none;white-space:nowrap; }
67
+ .manager-title { min-height:48px;display:flex;align-items:center;justify-content:space-between;gap:.75rem;padding:.65rem 1rem;border-bottom:1px solid var(--line);background:var(--surface); }
68
+ .manager-title strong,.manager-title small { display:block; }
69
+ .manager-title small,.manager-title span { color:var(--muted);font-size:.75rem; }
70
+ .manager-list { min-height:0;overflow:auto;padding:.4rem; }
71
+ .manager-person-row { display:grid;grid-template-columns:minmax(0,1fr) 36px;align-items:center;border-radius:var(--radius-sm); }
72
+ .manager-person { width:100%;min-height:40px;padding:0 .7rem;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--ink);text-align:left;font-weight:700; }
73
+ .manager-person:hover,.manager-person.active { background:var(--action-soft);color:var(--navy); }
74
+ .manager-person-row .remove-file { min-height:36px;border:0;background:transparent;color:var(--muted);font-size:1.1rem; }
75
+ .manager-person-row .remove-file:hover { color:var(--danger); }
76
+ .submission-manager { min-width:0;min-height:0;display:grid;grid-template-rows:auto minmax(0,1fr);overflow:hidden; }
77
+ .submission-workspace { min-height:0;display:grid;grid-template-columns:260px minmax(0,1fr); }
78
+ .submission-list { min-height:0;overflow:auto;padding:.4rem;border-right:1px solid var(--line);background:var(--surface); }
79
+ .submission-row { width:100%;display:block;padding:.7rem;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--ink);text-align:left; }
80
+ .submission-row:hover,.submission-row.active { background:var(--action-soft);color:var(--navy); }
81
+ .submission-row strong,.submission-row span { display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
82
+ .submission-row span { margin-top:.25rem;color:var(--muted);font-size:.74rem; }
83
+ .submission-detail { min-width:0;min-height:0;display:grid;grid-template-rows:auto 42px minmax(0,1fr);overflow:hidden;background:var(--editor); }
84
+ .submission-detail>header { min-height:58px;display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.65rem 1rem;background:var(--surface);border-bottom:1px solid var(--line); }
85
+ .submission-detail>header strong,.submission-detail>header small { display:block; }
86
+ .submission-detail>header small { margin-top:.25rem;color:var(--muted);font-size:.72rem; }
87
+ .submission-files { display:flex;gap:.2rem;padding:.3rem .65rem;overflow:auto;background:var(--editor-raised);border-bottom:1px solid var(--editor-line); }
88
+ .submission-files button { flex:none;padding:0 .7rem;border:0;border-radius:var(--radius-sm);background:transparent;color:oklch(.76 .02 255);font:650 .76rem/1 monospace; }
89
+ .submission-files button:hover,.submission-files button.active { background:var(--editor-line);color:white; }
90
+ #submissionCode { min-height:0;margin:0;padding:1rem 1.25rem;overflow:auto;color:var(--editor-ink);font:500 .84rem/1.65 "SFMono-Regular",Consolas,monospace;white-space:pre-wrap;tab-size:4; }
91
+ @media (max-width:1180px) { .classroom-fields{grid-template-columns:1fr 1fr}.backup-controls{grid-column:1/-1}.classroom-workspace{grid-template-columns:240px minmax(0,1fr)}.submission-workspace{grid-template-columns:220px minmax(0,1fr)} }
92
+ @media (max-width:980px) { .admin-tabs>span{display:none}.admin-view{grid-template-columns:270px minmax(0,1fr)}.admin-fields.three{grid-template-columns:1fr}.admin-fields{max-height:230px;overflow:auto}.file-workspace{grid-template-columns:200px minmax(0,1fr)}.classroom-workspace{grid-template-columns:210px minmax(0,1fr)}.submission-workspace{display:block}.submission-list{max-height:180px;border-right:0;border-bottom:1px solid var(--line)}.submission-detail{height:calc(100% - 180px)} }
93
+ @media (max-width:720px) { .admin-view{grid-template-columns:220px minmax(0,1fr)}.admin-tabs{padding-inline:.5rem;overflow-x:auto}.admin-tab{padding-inline:.7rem;flex:none}.admin-editor-head{padding-inline:.75rem}.admin-editor-head p{display:none}.admin-fields{padding:.75rem}.file-workspace{grid-template-columns:170px minmax(0,1fr)}.classroom-fields{grid-template-columns:1fr;padding:.75rem;max-height:210px;overflow:auto}.backup-controls{grid-column:auto;align-items:stretch;flex-direction:column}.classroom-workspace{grid-template-columns:180px minmax(0,1fr)} }
94
  @media (prefers-reduced-motion:reduce) { *,*::before,*::after{transition-duration:.01ms!important;animation-duration:.01ms!important} }
classroom_static/admin.html CHANGED
@@ -4,15 +4,15 @@
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
  <title>Telearn 內容管理後台</title>
7
- <link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-4">
8
- <link rel="stylesheet" href="/classroom-assets/admin.css?v=2026fall-5">
9
  </head>
10
  <body class="admin-body">
11
  <header class="app-header">
12
  <div class="brand"><div class="brand-mark">Py</div><div class="brand-copy"><strong>內容管理後台</strong><span id="adminStatus">尚未登入</span></div></div>
13
  <div class="header-actions"><a class="btn secondary compact" href="/classroom/teacher">即時教室</a><a class="btn secondary compact" href="/">查看 Demo</a><button class="btn compact" id="loginButton">重新登入</button></div>
14
  </header>
15
- <nav class="admin-tabs" aria-label="管理內容"><button class="admin-tab active" data-view="courseView">教室課程</button><button class="admin-tab" data-view="demoView">Demo 專題</button><span>儲存後會重新建置 Space,通常需等待約 1–2 分鐘。</span></nav>
16
 
17
  <main class="admin-main">
18
  <section class="admin-view active" id="courseView">
@@ -24,6 +24,18 @@
24
  </form>
25
  </section>
26
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  <section class="admin-view" id="demoView">
28
  <aside class="admin-sidebar demo-picker-sidebar"><div class="admin-sidebar-head"><div><strong>公開 Demo</strong><small id="demoTotal">依課程、學生、專題逐層選擇</small></div><button class="copy-button" id="newDemoButton">+新增</button></div><div class="demo-picker"><label><span>1 課程</span><select class="text-field" id="demoClassSelect"><option value="">請選擇課程</option></select></label><label><span>2 學生</span><select class="text-field" id="demoStudentSelect" disabled><option value="">請先選課程</option></select></label><label><span>3 專題</span><select class="text-field" id="demoProjectSelect" disabled><option value="">請先選學生</option></select></label></div><div class="picker-summary" id="demoPickerSummary"><strong>尚未選擇專題</strong><p>選好三個層級後,右側會載入可編��的程式檔案。</p></div></aside>
29
  <form class="admin-editor" id="demoForm">
@@ -36,6 +48,6 @@
36
 
37
  <dialog id="adminLoginDialog"><form class="dialog-content form-stack" id="adminLoginForm"><div><h1>登入內容管理後台</h1><p>使用與教師控制台相同的密碼。</p></div><label><span class="field-label">教師密碼</span><input class="text-field" id="adminPassword" type="password" required autofocus></label><p class="form-error" id="adminLoginError"></p><button class="btn" type="submit">登入</button></form></dialog>
38
  <div class="toast" id="toast"></div>
39
- <script src="/classroom-assets/admin.js?v=2026fall-5"></script>
40
  </body>
41
  </html>
 
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
  <title>Telearn 內容管理後台</title>
7
+ <link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-6">
8
+ <link rel="stylesheet" href="/classroom-assets/admin.css?v=2026fall-6">
9
  </head>
10
  <body class="admin-body">
11
  <header class="app-header">
12
  <div class="brand"><div class="brand-mark">Py</div><div class="brand-copy"><strong>內容管理後台</strong><span id="adminStatus">尚未登入</span></div></div>
13
  <div class="header-actions"><a class="btn secondary compact" href="/classroom/teacher">即時教室</a><a class="btn secondary compact" href="/">查看 Demo</a><button class="btn compact" id="loginButton">重新登入</button></div>
14
  </header>
15
+ <nav class="admin-tabs" aria-label="管理內容"><button class="admin-tab active" data-view="courseView">教室課程</button><button class="admin-tab" data-view="classroomView">管理教室</button><button class="admin-tab" data-view="demoView">Demo 專題</button><span>儲存後會重新建置 Space,通常需等待約 1–2 分鐘。</span></nav>
16
 
17
  <main class="admin-main">
18
  <section class="admin-view active" id="courseView">
 
24
  </form>
25
  </section>
26
 
27
+ <section class="admin-view" id="classroomView">
28
+ <aside class="admin-sidebar classroom-sidebar"><div class="admin-sidebar-head"><div><strong>我的班級</strong><small id="classroomTotal">尚未建立班級</small></div><button class="copy-button" id="newClassroomButton">+新增</button></div><div class="admin-list" id="classroomList"></div></aside>
29
+ <section class="classroom-manager">
30
+ <header class="admin-editor-head"><div><h1 id="classroomHeading">選擇班級</h1><p>管理班級、學生名單與已提交的正式版本。</p></div><div class="admin-actions"><button class="copy-button danger-action" id="deleteClassroomButton" disabled>刪除班級</button><button class="btn" id="saveClassroomButton" disabled>儲存班級</button></div></header>
31
+ <div class="classroom-fields"><label>班級代碼<input class="text-field" id="classroomCode" disabled></label><label>班級名稱<input class="text-field" id="classroomName" disabled></label><div class="backup-controls"><label>備份週次<select class="text-field" id="backupWeek" disabled></select></label><button class="copy-button" id="backupSubmissionsButton" disabled>備份本週提交</button></div></div>
32
+ <div class="classroom-workspace">
33
+ <aside class="student-manager"><form class="manager-add" id="adminStudentForm"><label for="adminStudentNames">新增學生(多人可用逗號分開)</label><div><input class="text-field" id="adminStudentNames" disabled><button class="btn compact" id="adminAddStudentsButton" disabled>加入</button></div></form><div class="manager-title"><strong>學生名單</strong><span id="adminStudentTotal">0 人</span></div><div class="manager-list" id="adminStudentList"></div></aside>
34
+ <section class="submission-manager"><div class="manager-title"><div><strong id="submissionHeading">已提交內容</strong><small id="submissionTotal">0 份</small></div></div><div class="submission-workspace"><div class="submission-list" id="submissionList"></div><div class="submission-detail"><header><div><strong id="submissionProject">尚未選擇提交</strong><small id="submissionMeta"></small></div><button class="copy-button danger-action" id="deleteSubmissionButton" disabled>刪除提交</button></header><nav class="submission-files" id="submissionFiles"></nav><pre id="submissionCode">選擇一份提交查看內容。</pre></div></div></section>
35
+ </div>
36
+ </section>
37
+ </section>
38
+
39
  <section class="admin-view" id="demoView">
40
  <aside class="admin-sidebar demo-picker-sidebar"><div class="admin-sidebar-head"><div><strong>公開 Demo</strong><small id="demoTotal">依課程、學生、專題逐層選擇</small></div><button class="copy-button" id="newDemoButton">+新增</button></div><div class="demo-picker"><label><span>1 課程</span><select class="text-field" id="demoClassSelect"><option value="">請選擇課程</option></select></label><label><span>2 學生</span><select class="text-field" id="demoStudentSelect" disabled><option value="">請先選課程</option></select></label><label><span>3 專題</span><select class="text-field" id="demoProjectSelect" disabled><option value="">請先選學生</option></select></label></div><div class="picker-summary" id="demoPickerSummary"><strong>尚未選擇專題</strong><p>選好三個層級後,右側會載入可編��的程式檔案。</p></div></aside>
41
  <form class="admin-editor" id="demoForm">
 
48
 
49
  <dialog id="adminLoginDialog"><form class="dialog-content form-stack" id="adminLoginForm"><div><h1>登入內容管理後台</h1><p>使用與教師控制台相同的密碼。</p></div><label><span class="field-label">教師密碼</span><input class="text-field" id="adminPassword" type="password" required autofocus></label><p class="form-error" id="adminLoginError"></p><button class="btn" type="submit">登入</button></form></dialog>
50
  <div class="toast" id="toast"></div>
51
+ <script src="/classroom-assets/admin.js?v=2026fall-6"></script>
52
  </body>
53
  </html>
classroom_static/admin.js CHANGED
@@ -8,11 +8,17 @@ const E = {};
8
  "demoForm", "demoHeading", "demoClass", "demoStudent",
9
  "demoProject", "newDemoFile", "demoFiles", "demoFileLabel", "demoFileContent",
10
  "demoAssets", "saveDemoButton", "deleteDemoButton", "adminLoginDialog",
11
- "adminLoginForm", "adminPassword", "adminLoginError", "toast"
 
 
 
 
 
 
12
  ].forEach(id => E[id] = $(id));
13
 
14
  let password = "";
15
- let state = { course: { weeks: [] }, demo_projects: [] };
16
  let selectedWeek = "";
17
  let weekFiles = {};
18
  let weekFile = "";
@@ -20,6 +26,11 @@ let originalDemo = null;
20
  let demoFiles = {};
21
  let demoFile = "";
22
  let demoAssets = [];
 
 
 
 
 
23
 
24
  const esc = value => String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
25
 
@@ -29,9 +40,9 @@ function toast(message) {
29
  setTimeout(() => E.toast.classList.remove("visible"), 2600);
30
  }
31
 
32
- async function request(path, data = {}) {
33
  const response = await fetch(path, {
34
- method: "POST",
35
  headers: { "Content-Type": "application/json" },
36
  body: JSON.stringify({ password, ...data })
37
  });
@@ -59,6 +70,9 @@ async function login() {
59
  E.adminStatus.textContent = `已登入 · ${state.course.title || "課程管理"}`;
60
  renderWeeks();
61
  renderDemoPicker();
 
 
 
62
  if (state.course.weeks.length) await selectWeek(state.course.weeks[0].id);
63
  }
64
 
@@ -261,10 +275,10 @@ E.newDemoButton.onclick = () => {
261
  const pickedClass = E.demoClassSelect.value;
262
  const pickedStudent = E.demoStudentSelect.value;
263
  originalDemo = null;
264
- demoFiles = { "main.py": '# 新 Demo 專題\nprint("Hello, Telearn!")\n' };
265
  demoAssets = [];
266
  demoFile = "main.py";
267
- E.demoClass.value = pickedClass || "2026Fall進階專題";
268
  E.demoStudent.value = pickedStudent || "";
269
  E.demoProject.value = "";
270
  E.demoHeading.textContent = "建立新 Demo 專題";
@@ -326,4 +340,169 @@ E.deleteDemoButton.onclick = async () => {
326
  } catch (error) { toast(error.message); enableDemoForm(true); }
327
  };
328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  E.adminLoginDialog.showModal();
 
8
  "demoForm", "demoHeading", "demoClass", "demoStudent",
9
  "demoProject", "newDemoFile", "demoFiles", "demoFileLabel", "demoFileContent",
10
  "demoAssets", "saveDemoButton", "deleteDemoButton", "adminLoginDialog",
11
+ "adminLoginForm", "adminPassword", "adminLoginError", "toast",
12
+ "classroomTotal", "newClassroomButton", "classroomList", "classroomHeading",
13
+ "deleteClassroomButton", "saveClassroomButton", "classroomCode", "classroomName",
14
+ "backupWeek", "backupSubmissionsButton", "adminStudentForm", "adminStudentNames",
15
+ "adminAddStudentsButton", "adminStudentTotal", "adminStudentList", "submissionHeading",
16
+ "submissionTotal", "submissionList", "submissionProject", "submissionMeta",
17
+ "deleteSubmissionButton", "submissionFiles", "submissionCode"
18
  ].forEach(id => E[id] = $(id));
19
 
20
  let password = "";
21
+ let state = { course: { weeks: [] }, demo_projects: [], classrooms: { courses: [], submissions: [] } };
22
  let selectedWeek = "";
23
  let weekFiles = {};
24
  let weekFile = "";
 
26
  let demoFiles = {};
27
  let demoFile = "";
28
  let demoAssets = [];
29
+ let selectedClassroom = "";
30
+ let selectedClassroomStudent = "";
31
+ let selectedSubmission = null;
32
+ let submissionFiles = {};
33
+ let submissionFile = "";
34
 
35
  const esc = value => String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
36
 
 
40
  setTimeout(() => E.toast.classList.remove("visible"), 2600);
41
  }
42
 
43
+ async function request(path, data = {}, method = "POST") {
44
  const response = await fetch(path, {
45
+ method,
46
  headers: { "Content-Type": "application/json" },
47
  body: JSON.stringify({ password, ...data })
48
  });
 
70
  E.adminStatus.textContent = `已登入 · ${state.course.title || "課程管理"}`;
71
  renderWeeks();
72
  renderDemoPicker();
73
+ E.backupWeek.innerHTML = state.course.weeks.map(week => `<option value="${esc(week.id)}">第 ${week.week} 週|${esc(week.title)}</option>`).join("");
74
+ E.backupWeek.value = "week02";
75
+ renderClassrooms();
76
  if (state.course.weeks.length) await selectWeek(state.course.weeks[0].id);
77
  }
78
 
 
275
  const pickedClass = E.demoClassSelect.value;
276
  const pickedStudent = E.demoStudentSelect.value;
277
  originalDemo = null;
278
+ demoFiles = { "main.py": "" };
279
  demoAssets = [];
280
  demoFile = "main.py";
281
+ E.demoClass.value = pickedClass || "";
282
  E.demoStudent.value = pickedStudent || "";
283
  E.demoProject.value = "";
284
  E.demoHeading.textContent = "建立新 Demo 專題";
 
340
  } catch (error) { toast(error.message); enableDemoForm(true); }
341
  };
342
 
343
+ async function refreshClassrooms(classCode = selectedClassroom, studentId = selectedClassroomStudent) {
344
+ const next = await request("/api/admin/state");
345
+ state.classrooms = next.classrooms;
346
+ selectedClassroom = state.classrooms.courses.some(item => item.class_code === classCode) ? classCode : (state.classrooms.courses[0]?.class_code || "");
347
+ selectedClassroomStudent = studentId;
348
+ renderClassrooms();
349
+ }
350
+
351
+ function currentClassroom() {
352
+ return state.classrooms.courses.find(item => item.class_code === selectedClassroom);
353
+ }
354
+
355
+ function renderClassrooms() {
356
+ const courses = state.classrooms.courses || [];
357
+ if (selectedClassroom !== "__new__" && !courses.some(item => item.class_code === selectedClassroom)) selectedClassroom = courses[0]?.class_code || "";
358
+ E.classroomTotal.textContent = `${courses.length} 個班級`;
359
+ E.classroomList.innerHTML = courses.map(item => {
360
+ const submissions = state.classrooms.submissions.filter(entry => entry.class_code === item.class_code).length;
361
+ return `<button class="admin-list-button ${item.class_code === selectedClassroom ? "active" : ""}" data-classroom="${esc(item.class_code)}"><div><strong>${esc(item.course_name)}</strong><small>${esc(item.class_code)}</small></div><span>${item.students.length} 人 · ${submissions} 份</span></button>`;
362
+ }).join("") || '<div class="admin-empty">尚未建立班級。</div>';
363
+ E.classroomList.querySelectorAll("[data-classroom]").forEach(button => button.onclick = () => {
364
+ selectedClassroom = button.dataset.classroom;
365
+ selectedClassroomStudent = "";
366
+ selectedSubmission = null;
367
+ renderClassrooms();
368
+ });
369
+ renderClassroomDetail();
370
+ }
371
+
372
+ function renderClassroomDetail() {
373
+ const course = currentClassroom();
374
+ const enabled = Boolean(course);
375
+ E.classroomHeading.textContent = course ? course.course_name : (selectedClassroom === "__new__" ? "建立新班級" : "選擇班級");
376
+ E.classroomCode.disabled = selectedClassroom !== "__new__";
377
+ E.classroomName.disabled = !enabled && selectedClassroom !== "__new__";
378
+ E.saveClassroomButton.disabled = !enabled && selectedClassroom !== "__new__";
379
+ E.deleteClassroomButton.disabled = !enabled;
380
+ [E.backupWeek, E.backupSubmissionsButton, E.adminStudentNames, E.adminAddStudentsButton].forEach(element => element.disabled = !enabled);
381
+ if (selectedClassroom === "__new__") {
382
+ E.classroomCode.value = "";
383
+ E.classroomName.value = "";
384
+ } else {
385
+ E.classroomCode.value = course?.class_code || "";
386
+ E.classroomName.value = course?.course_name || "";
387
+ }
388
+ renderClassroomStudents();
389
+ }
390
+
391
+ function renderClassroomStudents() {
392
+ const members = currentClassroom()?.students || [];
393
+ if (selectedClassroomStudent && !members.some(item => item.student_id === selectedClassroomStudent)) selectedClassroomStudent = "";
394
+ E.adminStudentTotal.textContent = `${members.length} 人`;
395
+ E.adminStudentList.innerHTML = members.length ? `<button class="manager-person ${selectedClassroomStudent ? "" : "active"}" data-student="">全部學生</button>` + members.map(member => `<div class="manager-person-row"><button class="manager-person ${member.student_id === selectedClassroomStudent ? "active" : ""}" data-student="${esc(member.student_id)}">${esc(member.display_name)}</button><button class="remove-file" data-remove-student="${esc(member.student_id)}" aria-label="刪除 ${esc(member.display_name)}">×</button></div>`).join("") : '<div class="admin-empty">這個班級尚無學生。</div>';
396
+ E.adminStudentList.querySelectorAll("[data-student]").forEach(button => button.onclick = () => { selectedClassroomStudent = button.dataset.student; selectedSubmission = null; renderClassroomStudents(); });
397
+ E.adminStudentList.querySelectorAll("[data-remove-student]").forEach(button => button.onclick = () => removeClassroomStudent(button.dataset.removeStudent));
398
+ renderSubmissions();
399
+ }
400
+
401
+ function visibleSubmissions() {
402
+ return state.classrooms.submissions.filter(item => item.class_code === selectedClassroom && (!selectedClassroomStudent || item.student_id === selectedClassroomStudent));
403
+ }
404
+
405
+ function renderSubmissions() {
406
+ const entries = visibleSubmissions();
407
+ const student = currentClassroom()?.students.find(item => item.student_id === selectedClassroomStudent);
408
+ E.submissionHeading.textContent = student ? `${student.display_name}的提交` : "已提交內容";
409
+ E.submissionTotal.textContent = `${entries.length} 份`;
410
+ E.submissionList.innerHTML = entries.map((item, index) => `<button class="submission-row ${selectedSubmission && item.student_id === selectedSubmission.student_id && item.week_id === selectedSubmission.week_id ? "active" : ""}" data-submission="${index}"><strong>${esc(item.display_name)} · ${esc(item.week_id)}</strong><span>${esc(item.project_name)} · ${item.file_count} 檔</span></button>`).join("") || '<div class="admin-empty">目前沒有已提交內容。</div>';
411
+ E.submissionList.querySelectorAll("[data-submission]").forEach(button => button.onclick = () => selectSubmission(entries[Number(button.dataset.submission)]));
412
+ if (!selectedSubmission || !entries.some(item => item.student_id === selectedSubmission.student_id && item.week_id === selectedSubmission.week_id)) resetSubmissionDetail();
413
+ }
414
+
415
+ async function selectSubmission(item) {
416
+ selectedSubmission = item;
417
+ const data = await request("/api/admin/classroom/submission", item);
418
+ submissionFiles = data.submission.files || {};
419
+ submissionFile = Object.keys(submissionFiles)[0] || "";
420
+ E.submissionProject.textContent = item.project_name;
421
+ E.submissionMeta.textContent = `${item.display_name} · ${item.week_id} · ${new Date(item.submitted_at).toLocaleString("zh-TW")}`;
422
+ E.deleteSubmissionButton.disabled = false;
423
+ renderSubmissionFiles();
424
+ renderSubmissions();
425
+ }
426
+
427
+ function renderSubmissionFiles() {
428
+ E.submissionFiles.innerHTML = Object.keys(submissionFiles).map(path => `<button class="${path === submissionFile ? "active" : ""}" data-submission-file="${esc(path)}">${esc(path)}</button>`).join("");
429
+ E.submissionFiles.querySelectorAll("[data-submission-file]").forEach(button => button.onclick = () => { submissionFile = button.dataset.submissionFile; renderSubmissionFiles(); });
430
+ E.submissionCode.textContent = submissionFile ? submissionFiles[submissionFile] : "這份提交沒有可顯示的檔案。";
431
+ }
432
+
433
+ function resetSubmissionDetail() {
434
+ selectedSubmission = null;
435
+ submissionFiles = {};
436
+ submissionFile = "";
437
+ E.submissionProject.textContent = "尚未選擇提交";
438
+ E.submissionMeta.textContent = "";
439
+ E.submissionFiles.innerHTML = "";
440
+ E.submissionCode.textContent = "選擇一份提交查看內容。";
441
+ E.deleteSubmissionButton.disabled = true;
442
+ }
443
+
444
+ E.newClassroomButton.onclick = () => {
445
+ selectedClassroom = "__new__";
446
+ selectedClassroomStudent = "";
447
+ resetSubmissionDetail();
448
+ renderClassrooms();
449
+ E.classroomCode.focus();
450
+ };
451
+
452
+ E.saveClassroomButton.onclick = async () => {
453
+ const code = E.classroomCode.value.trim();
454
+ const name = E.classroomName.value.trim();
455
+ if (!code || !name) return toast("請輸入班級代碼與名稱");
456
+ setBusy(E.saveClassroomButton, true, "儲存中…");
457
+ try {
458
+ const method = selectedClassroom === "__new__" ? "POST" : "PUT";
459
+ const data = await request("/api/classroom/classes", { class_code: code, course_name: name }, method);
460
+ selectedClassroom = code;
461
+ await refreshClassrooms(code);
462
+ toast(data.warning || "班級已儲存,Space 正在重新建置");
463
+ } catch (error) { toast(error.message); }
464
+ finally { setBusy(E.saveClassroomButton, false); }
465
+ };
466
+
467
+ E.deleteClassroomButton.onclick = async () => {
468
+ const course = currentClassroom();
469
+ if (!course || !confirm(`刪除班級「${course.course_name}」?\n\n學生名單、教室進度與提交封存都會被移除。`)) return;
470
+ setBusy(E.deleteClassroomButton, true, "刪除中…");
471
+ try { const data = await request("/api/classroom/classes", { class_code: course.class_code }, "DELETE"); selectedClassroom = ""; await refreshClassrooms(); toast(data.warning || "班級已刪除"); }
472
+ catch (error) { toast(error.message); }
473
+ finally { setBusy(E.deleteClassroomButton, false); }
474
+ };
475
+
476
+ E.adminStudentForm.onsubmit = async event => {
477
+ event.preventDefault();
478
+ const names = E.adminStudentNames.value.split(/[,,\n]+/).map(value => value.trim()).filter(Boolean);
479
+ if (!names.length) return toast("請輸入學生姓名");
480
+ setBusy(E.adminAddStudentsButton, true, "加入中…");
481
+ try { const data = await request("/api/classroom/students", { class_code: selectedClassroom, display_names: names }); E.adminStudentNames.value = ""; await refreshClassrooms(selectedClassroom); toast(data.warning || `已加入 ${data.created.length} 位學生`); }
482
+ catch (error) { toast(error.message); }
483
+ finally { setBusy(E.adminAddStudentsButton, false); }
484
+ };
485
+
486
+ async function removeClassroomStudent(studentId) {
487
+ const member = currentClassroom()?.students.find(item => item.student_id === studentId);
488
+ if (!member || !confirm(`從這個班級刪除「${member.display_name}」?\n\n教室進度與提交封存也會被移除。`)) return;
489
+ try { const data = await request("/api/classroom/students", { class_code: selectedClassroom, student_id: studentId }, "DELETE"); selectedClassroomStudent = ""; await refreshClassrooms(selectedClassroom); toast(data.warning || "學生已刪除"); }
490
+ catch (error) { toast(error.message); }
491
+ }
492
+
493
+ E.backupSubmissionsButton.onclick = async () => {
494
+ setBusy(E.backupSubmissionsButton, true, "備份中…");
495
+ try { const data = await request("/api/admin/classroom/backup", { class_code: selectedClassroom, week_id: E.backupWeek.value }); toast(`已備份 ${data.count} 份提交,Space 正在重新建置`); }
496
+ catch (error) { toast(error.message); }
497
+ finally { setBusy(E.backupSubmissionsButton, false); }
498
+ };
499
+
500
+ E.deleteSubmissionButton.onclick = async () => {
501
+ if (!selectedSubmission || !confirm(`刪除 ${selectedSubmission.display_name} 的 ${selectedSubmission.week_id} 正式提交?`)) return;
502
+ setBusy(E.deleteSubmissionButton, true, "刪除中…");
503
+ try { await request("/api/admin/classroom/submission/delete", selectedSubmission); resetSubmissionDetail(); await refreshClassrooms(selectedClassroom, selectedClassroomStudent); toast("已刪除提交"); }
504
+ catch (error) { toast(error.message); }
505
+ finally { setBusy(E.deleteSubmissionButton, false); }
506
+ };
507
+
508
  E.adminLoginDialog.showModal();
classroom_static/student.html CHANGED
@@ -1,10 +1,10 @@
1
  <!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light"><title>Python 專案教室|學生工作區</title><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css"><link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-5"></head><body>
2
  <header class="app-header"><div class="brand"><div class="brand-mark">Py</div><div class="brand-copy"><strong>Python 專案教室</strong><span id="studentIdentity">尚未進入教室</span></div></div><div class="header-actions"><div class="connection-state"><i class="status-dot" id="connectionDot"></i><span id="connectionText">未連線</span></div><button class="btn help compact" id="helpButton">請老師協助</button><button class="btn secondary compact" id="identityButton">更換身分</button></div></header>
3
- <section class="project-bar"><div class="project-context"><label for="weekSelect">週次</label><select id="weekSelect"></select><label for="projectName">專案</label><input id="projectName" maxlength="36" value="Python專案"></div><div class="project-actions"><button class="copy-button" id="templateButton">匯入本週套版</button><button class="copy-button" id="progressButton">選擇舊進度</button><button class="copy-button" id="importButton">匯入 .py / ZIP</button><button class="copy-button" id="exportButton">匯出專案</button><button class="btn compact" id="submitButton">提交本週版本</button><input id="fileImport" type="file" accept=".py,.zip" hidden></div></section>
4
  <nav class="mobile-tabs" role="tablist"><button class="mobile-tab" data-panel="referencePanel">專案/語法</button><button class="mobile-tab" data-panel="editorPanel" aria-selected="true">程式</button><button class="mobile-tab" data-panel="consolePanel">執行結果</button></nav>
5
- <main class="student-layout"><aside class="panel reference-panel" id="referencePanel"><div class="side-tabs"><button class="side-tab active" data-side="filesView">專案檔案</button><button class="side-tab" data-side="syntaxView">語法查詢</button></div><section class="side-view" id="filesView"><div class="panel-heading"><h1>檔案夾</h1><button class="copy-button" id="newFileButton">+新增</button></div><div class="file-list" id="fileList"></div><p class="side-note">入口固定為 <code>main.py</code>,可用 <code>import</code> 引入其他 Python 檔。</p></section><section class="side-view" id="syntaxView" hidden><div class="panel-heading"><h1>Python 語法查詢</h1><span id="snippetCount">0 項</span></div><div class="search-wrap"><label class="field-label" for="syntaxSearch">輸入語法或用途</label><input class="text-field" id="syntaxSearch" type="search" placeholder="迴圈、字典、JSON"><div class="chip-row" id="categoryFilters"></div></div><div class="snippet-list" id="snippetList"></div></section></aside>
6
  <section class="panel editor-panel mobile-active" id="editorPanel"><div class="panel-heading dark"><span class="file-name" id="activeFileLabel">main.py</span><div class="editor-toolbar"><button class="btn stop compact" id="stopButton" disabled>停止</button><button class="btn compact" id="runButton" disabled>載入 Python…</button></div></div><div class="editor-host"><textarea id="codeEditor" aria-label="程式碼"></textarea></div></section>
7
- <aside class="panel console-panel" id="consolePanel"><div class="panel-heading"><h2>互動式 Console</h2><button class="copy-button" id="clearOutputButton">清除</button></div><div class="console-body"><div class="console-stream"><pre class="console-output" id="consoleOutput">Python 執行環境載入中…</pre><form class="console-input-row" id="consoleInputForm" hidden><span aria-hidden="true">›</span><label class="sr-only" for="consoleInput">Python 程式要求輸入</label><input id="consoleInput" autocomplete="off" placeholder="輸入後按 Enter"><button class="btn compact" type="submit">送出</button></form></div><p class="console-hint">執行 <code>main.py</code>;遇到 <code>input()</code> 時會在這裡詢問。</p><div class="console-status"><i class="status-dot" id="runStatusDot"></i><span id="runStatusText">準備環境</span><span>·</span><span id="saveStatus">尚未同步</span><span>·</span><span id="submitStatus">本週尚未提交</span></div></div></aside></main>
8
- <dialog id="identityDialog"><form class="dialog-content form-stack" id="identityForm"><div><h1>進入程式教室</h1><p>選擇班級並輸入自己在名單上的姓名。</p></div><div><label class="field-label" for="classCode">課程</label><select class="text-field" id="classCode" required></select></div><div><label class="field-label" for="displayName">姓名</label><input class="text-field" id="displayName" autocomplete="name" placeholder="請輸入名單上的姓名" required><p class="field-help">姓名必須與老師建立的名單一致。</p></div><p class="form-error" id="identityError" role="alert"></p><button class="btn" type="submit">進入教室</button></form></dialog>
9
  <dialog id="progressDialog"><div class="dialog-content"><h2>選擇正式進度</h2><p>匯入後會成為目前週次的工作副本。</p><div class="progress-list" id="progressList"></div><button class="btn secondary dialog-close">關閉</button></div></dialog><div class="toast" id="toast"></div>
10
  <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/python/python.min.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script><script src="/classroom-assets/student.js?v=2026fall-5"></script></body></html>
 
1
  <!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light"><title>Python 專案教室|學生工作區</title><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css"><link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-5"></head><body>
2
  <header class="app-header"><div class="brand"><div class="brand-mark">Py</div><div class="brand-copy"><strong>Python 專案教室</strong><span id="studentIdentity">尚未進入教室</span></div></div><div class="header-actions"><div class="connection-state"><i class="status-dot" id="connectionDot"></i><span id="connectionText">未連線</span></div><button class="btn help compact" id="helpButton">請老師協助</button><button class="btn secondary compact" id="identityButton">更換身分</button></div></header>
3
+ <section class="project-bar"><div class="project-context"><label for="weekSelect">週次</label><select id="weekSelect"></select><label for="projectName">專案</label><input id="projectName" maxlength="36"></div><div class="project-actions"><button class="copy-button" id="templateButton">匯入本週套版</button><button class="copy-button" id="progressButton">選擇舊進度</button><button class="copy-button" id="importButton">匯入 .py / ZIP</button><button class="copy-button" id="exportButton">匯出專案</button><button class="btn compact" id="submitButton">提交本週版本</button><input id="fileImport" type="file" accept=".py,.zip" hidden></div></section>
4
  <nav class="mobile-tabs" role="tablist"><button class="mobile-tab" data-panel="referencePanel">專案/語法</button><button class="mobile-tab" data-panel="editorPanel" aria-selected="true">程式</button><button class="mobile-tab" data-panel="consolePanel">執行結果</button></nav>
5
+ <main class="student-layout"><aside class="panel reference-panel" id="referencePanel"><div class="side-tabs"><button class="side-tab active" data-side="filesView">專案檔案</button><button class="side-tab" data-side="syntaxView">語法查詢</button></div><section class="side-view" id="filesView"><div class="panel-heading"><h1>檔案夾</h1><button class="copy-button" id="newFileButton">+新增</button></div><div class="file-list" id="fileList"></div><p class="side-note">入口固定為 <code>main.py</code>,可用 <code>import</code> 引入其他 Python 檔。</p></section><section class="side-view" id="syntaxView" hidden><div class="panel-heading"><h1>Python 語法查詢</h1><span id="snippetCount">0 項</span></div><div class="search-wrap"><label class="field-label" for="syntaxSearch">輸入語法或用途</label><input class="text-field" id="syntaxSearch" type="search"><div class="chip-row" id="categoryFilters"></div></div><div class="snippet-list" id="snippetList"></div></section></aside>
6
  <section class="panel editor-panel mobile-active" id="editorPanel"><div class="panel-heading dark"><span class="file-name" id="activeFileLabel">main.py</span><div class="editor-toolbar"><button class="btn stop compact" id="stopButton" disabled>停止</button><button class="btn compact" id="runButton" disabled>載入 Python…</button></div></div><div class="editor-host"><textarea id="codeEditor" aria-label="程式碼"></textarea></div></section>
7
+ <aside class="panel console-panel" id="consolePanel"><div class="panel-heading"><h2>互動式 Console</h2><button class="copy-button" id="clearOutputButton">清除</button></div><div class="console-body"><div class="console-stream"><pre class="console-output" id="consoleOutput">Python 執行環境載入中…</pre><form class="console-input-row" id="consoleInputForm" hidden><span aria-hidden="true">›</span><label class="sr-only" for="consoleInput">Python 程式要求輸入</label><input id="consoleInput" autocomplete="off"><button class="btn compact" type="submit">送出</button></form></div><p class="console-hint">執行 <code>main.py</code>;遇到 <code>input()</code> 時會在這裡詢問。</p><div class="console-status"><i class="status-dot" id="runStatusDot"></i><span id="runStatusText">準備環境</span><span>·</span><span id="saveStatus">尚未同步</span><span>·</span><span id="submitStatus">本週尚未提交</span></div></div></aside></main>
8
+ <dialog id="identityDialog"><form class="dialog-content form-stack" id="identityForm"><div><h1>進入程式教室</h1><p>選擇班級並輸入自己在名單上的姓名。</p></div><div><label class="field-label" for="classCode">課程</label><select class="text-field" id="classCode" required></select></div><div><label class="field-label" for="displayName">姓名</label><input class="text-field" id="displayName" autocomplete="name" required><p class="field-help">姓名必須與老師建立的名單一致。</p></div><p class="form-error" id="identityError" role="alert"></p><button class="btn" type="submit">進入教室</button></form></dialog>
9
  <dialog id="progressDialog"><div class="dialog-content"><h2>選擇正式進度</h2><p>匯入後會成為目前週次的工作副本。</p><div class="progress-list" id="progressList"></div><button class="btn secondary dialog-close">關閉</button></div></dialog><div class="toast" id="toast"></div>
10
  <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/python/python.min.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script><script src="/classroom-assets/student.js?v=2026fall-5"></script></body></html>
classroom_static/student.js CHANGED
@@ -10,7 +10,7 @@ function esc(t){return String(t).replaceAll("&","&amp;").replaceAll("<","&lt;").
10
  function saveLocal(){localStorage.setItem(key(),JSON.stringify({files,activeFile,projectName:E.projectName.value,output}))}
11
  function sync(){saveLocal();E.saveStatus.textContent="同步中";clearTimeout(timer);timer=setTimeout(()=>send({type:"project",files,active_file:activeFile,project_name:E.projectName.value}),450)}
12
  function openFile(path){if(!(path in files))return;if(activeFile in files&&!suppress)files[activeFile]=editor.getValue();activeFile=path;suppress=true;editor.setValue(files[path]);suppress=false;E.activeFileLabel.textContent=path;editor.setOption("mode",path.endsWith(".py")?"python":"text");renderFiles()}
13
- function setProject(next,name,preferred="main.py"){files=Object.fromEntries(Object.entries(next||{}).filter(([p,v])=>typeof v==="string"&&!p.includes("..")));files["main.py"]??="# 專案入口\n";E.projectName.value=name||"Python專案";const firstFile=files[preferred]?preferred:"main.py";activeFile="";openFile(firstFile);sync()}
14
  function renderFiles(){E.fileList.innerHTML=Object.keys(files).sort((a,b)=>a==="main.py"?-1:b==="main.py"?1:a.localeCompare(b)).map(p=>`<div class="file-row ${p===activeFile?"selected":""}"><button data-open="${esc(p)}">◇ ${esc(p)}</button>${p!=="main.py"?`<button class="file-remove" data-remove="${esc(p)}">×</button>`:""}</div>`).join("");E.fileList.querySelectorAll("[data-open]").forEach(b=>b.onclick=()=>openFile(b.dataset.open));E.fileList.querySelectorAll("[data-remove]").forEach(b=>b.onclick=()=>{if(confirm(`刪除 ${b.dataset.remove}?`)){delete files[b.dataset.remove];if(activeFile===b.dataset.remove)activeFile="main.py";openFile(activeFile);sync()}})}
15
  function connect(){clearTimeout(reconnect);const previous=socket;if(previous){previous.onclose=null;previous.close()}const c=clean(localStorage.getItem("classroom_code")),n=localStorage.getItem("classroom_name")?.trim();if(!c||!n||!studentId){E.identityDialog.showModal();return}E.studentIdentity.textContent=`${c} · ${n}`;E.connectionText.textContent="連線中";const scheme=location.protocol==="https:"?"wss":"ws",q=new URLSearchParams({class_code:c,student_id:studentId,display_name:n,week_id:week()}),current=new WebSocket(`${scheme}://${location.host}/ws/classroom/student?${q}`);socket=current;current.onopen=()=>{E.connectionDot.className="status-dot online";E.connectionText.textContent="已連線"};current.onmessage=e=>{const m=JSON.parse(e.data);if(m.type==="restore"){const local=JSON.parse(localStorage.getItem(key())||"null"),s=m.state,src=local||{files:s.files,activeFile:s.active_file,projectName:s.project_name,output:s.output};output=src.output||"";E.consoleOutput.textContent=output||"尚無執行結果。";submittedAt=s.submitted_at;needsHelp=!!s.needs_help;renderState();setProject(src.files,src.projectName,src.activeFile);send({type:"output",output})}if(m.type==="help_ack"){needsHelp=false;renderState();toast("老師已看到你的協助請求")}if(["ack","pong"].includes(m.type)){if(typeof m.needs_help==="boolean"){const seen=needsHelp&&!m.needs_help;needsHelp=m.needs_help;if(seen)toast("老師已看到你的協助請求")}if(m.submitted_at)submittedAt=m.submitted_at;renderState();if(m.type==="ack")E.saveStatus.textContent="已同步"}};current.onclose=async e=>{if(socket!==current)return;E.connectionDot.className="status-dot error";E.connectionText.textContent="已離線";E.saveStatus.textContent="保存在此裝置";if(e.code===4004||e.code===4404){localStorage.removeItem("classroom_student_id");localStorage.removeItem("classroom_name");studentId="";await loadCatalog();E.identityError.textContent=e.code===4004?"老師已將你從名單移除。":"姓名與班級名單不符,請重新輸入。";E.identityDialog.showModal();return}reconnect=setTimeout(connect,2500)}}
16
  function renderState(){E.helpButton.classList.toggle("active",needsHelp);E.helpButton.textContent=needsHelp?"取消請求協助":"請老師協助";E.submitStatus.textContent=submittedAt?`已提交 ${new Date(submittedAt).toLocaleTimeString("zh-TW",{hour:"2-digit",minute:"2-digit"})}`:"本週尚未提交"}
 
10
  function saveLocal(){localStorage.setItem(key(),JSON.stringify({files,activeFile,projectName:E.projectName.value,output}))}
11
  function sync(){saveLocal();E.saveStatus.textContent="同步中";clearTimeout(timer);timer=setTimeout(()=>send({type:"project",files,active_file:activeFile,project_name:E.projectName.value}),450)}
12
  function openFile(path){if(!(path in files))return;if(activeFile in files&&!suppress)files[activeFile]=editor.getValue();activeFile=path;suppress=true;editor.setValue(files[path]);suppress=false;E.activeFileLabel.textContent=path;editor.setOption("mode",path.endsWith(".py")?"python":"text");renderFiles()}
13
+ function setProject(next,name,preferred="main.py"){files=Object.fromEntries(Object.entries(next||{}).filter(([p,v])=>typeof v==="string"&&!p.includes("..")));files["main.py"]??="# 專案入口\n";E.projectName.value=name||"";const firstFile=files[preferred]?preferred:"main.py";activeFile="";openFile(firstFile);sync()}
14
  function renderFiles(){E.fileList.innerHTML=Object.keys(files).sort((a,b)=>a==="main.py"?-1:b==="main.py"?1:a.localeCompare(b)).map(p=>`<div class="file-row ${p===activeFile?"selected":""}"><button data-open="${esc(p)}">◇ ${esc(p)}</button>${p!=="main.py"?`<button class="file-remove" data-remove="${esc(p)}">×</button>`:""}</div>`).join("");E.fileList.querySelectorAll("[data-open]").forEach(b=>b.onclick=()=>openFile(b.dataset.open));E.fileList.querySelectorAll("[data-remove]").forEach(b=>b.onclick=()=>{if(confirm(`刪除 ${b.dataset.remove}?`)){delete files[b.dataset.remove];if(activeFile===b.dataset.remove)activeFile="main.py";openFile(activeFile);sync()}})}
15
  function connect(){clearTimeout(reconnect);const previous=socket;if(previous){previous.onclose=null;previous.close()}const c=clean(localStorage.getItem("classroom_code")),n=localStorage.getItem("classroom_name")?.trim();if(!c||!n||!studentId){E.identityDialog.showModal();return}E.studentIdentity.textContent=`${c} · ${n}`;E.connectionText.textContent="連線中";const scheme=location.protocol==="https:"?"wss":"ws",q=new URLSearchParams({class_code:c,student_id:studentId,display_name:n,week_id:week()}),current=new WebSocket(`${scheme}://${location.host}/ws/classroom/student?${q}`);socket=current;current.onopen=()=>{E.connectionDot.className="status-dot online";E.connectionText.textContent="已連線"};current.onmessage=e=>{const m=JSON.parse(e.data);if(m.type==="restore"){const local=JSON.parse(localStorage.getItem(key())||"null"),s=m.state,src=local||{files:s.files,activeFile:s.active_file,projectName:s.project_name,output:s.output};output=src.output||"";E.consoleOutput.textContent=output||"尚無執行結果。";submittedAt=s.submitted_at;needsHelp=!!s.needs_help;renderState();setProject(src.files,src.projectName,src.activeFile);send({type:"output",output})}if(m.type==="help_ack"){needsHelp=false;renderState();toast("老師已看到你的協助請求")}if(["ack","pong"].includes(m.type)){if(typeof m.needs_help==="boolean"){const seen=needsHelp&&!m.needs_help;needsHelp=m.needs_help;if(seen)toast("老師已看到你的協助請求")}if(m.submitted_at)submittedAt=m.submitted_at;renderState();if(m.type==="ack")E.saveStatus.textContent="已同步"}};current.onclose=async e=>{if(socket!==current)return;E.connectionDot.className="status-dot error";E.connectionText.textContent="已離線";E.saveStatus.textContent="保存在此裝置";if(e.code===4004||e.code===4404){localStorage.removeItem("classroom_student_id");localStorage.removeItem("classroom_name");studentId="";await loadCatalog();E.identityError.textContent=e.code===4004?"老師已將你從名單移除。":"姓名與班級名單不符,請重新輸入。";E.identityDialog.showModal();return}reconnect=setTimeout(connect,2500)}}
16
  function renderState(){E.helpButton.classList.toggle("active",needsHelp);E.helpButton.textContent=needsHelp?"取消請求協助":"請老師協助";E.submitStatus.textContent=submittedAt?`已提交 ${new Date(submittedAt).toLocaleTimeString("zh-TW",{hour:"2-digit",minute:"2-digit"})}`:"本週尚未提交"}
classroom_static/teacher.html CHANGED
@@ -7,13 +7,13 @@
7
  <main class="teacher-layout">
8
  <section class="roster-panel">
9
  <div class="panel-heading"><h1>學生狀態</h1><span id="studentTotal">0 人</span></div>
10
- <form class="roster-add" id="rosterAddForm"><label class="sr-only" for="studentNames">新增學生姓名</label><input class="text-field" id="studentNames" placeholder="輸入姓名;多人可用逗號分開" autocomplete="off"><button class="btn compact" id="addStudentsButton" type="submit">加入名單</button></form>
11
  <div class="filter-bar" id="teacherFilters"><button class="chip" data-filter="all" aria-pressed="true">全部</button><button class="chip" data-filter="submitted">已提交</button><button class="chip" data-filter="help">需要協助</button><button class="chip" data-filter="error">錯誤</button></div>
12
  <div class="roster-header"><span>學生</span><span>狀態</span><span>檔案</span><span>提交</span></div><div class="roster-list" id="rosterList"></div>
13
  </section>
14
  <section class="teacher-detail" id="teacherDetail"><div class="detail-empty" id="detailEmpty">選擇一位學生查看多檔案專案。</div><div class="detail-view" id="detailView" hidden><header class="detail-header"><div><h1 id="selectedStudentName"></h1><p class="detail-meta" id="selectedStudentMeta"></p></div><div class="detail-actions"><button class="copy-button" id="ackHelpButton" hidden>標記已查看</button><button class="copy-button" id="copyCodeButton">複製檔案</button><button class="copy-button danger-action" id="deleteStudentButton">刪除學生</button></div></header><div class="teacher-project"><nav class="teacher-file-tabs" id="teacherFiles"></nav><div class="detail-split"><section class="read-only-pane"><div class="pane-label"><span id="teacherActiveFile">main.py</span><span id="lineCount">0 行</span></div><pre class="code-readonly" id="studentCode"></pre></section><section class="read-only-pane"><div class="pane-label"><span>Console</span><span id="outputState"></span></div><pre class="output-readonly" id="studentOutput"></pre></section></div></div></div></section>
15
  </main>
16
- <dialog id="loginDialog"><form class="dialog-content form-stack" id="loginForm"><div><h1>進入教師控制台</h1><p>選取現有課程,或建立新的課程代碼。</p></div><div><label class="field-label" for="classCode">課程</label><select class="text-field" id="classCode" required></select></div><div id="newClassFields" hidden><label class="field-label" for="newClassCode">新課程代碼</label><input class="text-field" id="newClassCode" placeholder="L6B"><label class="field-label secondary-label" for="newClassName">課程名稱</label><input class="text-field" id="newClassName" placeholder="進階專題 B 班"></div><div><label class="field-label" for="teacherPassword">教師密碼</label><input class="text-field" id="teacherPassword" type="password" required></div><p class="form-error" id="loginError"></p><button class="btn" type="submit">開始監看</button></form></dialog>
17
  <dialog id="publishDialog"><div class="dialog-content"><h2>結算並發布</h2><p id="publishMessage"></p><div class="publish-actions"><button class="btn secondary" id="cancelPublish">取消</button><button class="btn" id="confirmPublish">確認發布</button></div></div></dialog><div class="toast" id="toast"></div><script src="/classroom-assets/teacher.js?v=2026fall-7"></script>
18
  </body>
19
  </html>
 
7
  <main class="teacher-layout">
8
  <section class="roster-panel">
9
  <div class="panel-heading"><h1>學生狀態</h1><span id="studentTotal">0 人</span></div>
10
+ <form class="roster-add" id="rosterAddForm"><label class="sr-only" for="studentNames">新增學生姓名,多人可用逗號分開</label><input class="text-field" id="studentNames" autocomplete="off"><button class="btn compact" id="addStudentsButton" type="submit">加入名單</button></form>
11
  <div class="filter-bar" id="teacherFilters"><button class="chip" data-filter="all" aria-pressed="true">全部</button><button class="chip" data-filter="submitted">已提交</button><button class="chip" data-filter="help">需要協助</button><button class="chip" data-filter="error">錯誤</button></div>
12
  <div class="roster-header"><span>學生</span><span>狀態</span><span>檔案</span><span>提交</span></div><div class="roster-list" id="rosterList"></div>
13
  </section>
14
  <section class="teacher-detail" id="teacherDetail"><div class="detail-empty" id="detailEmpty">選擇一位學生查看多檔案專案。</div><div class="detail-view" id="detailView" hidden><header class="detail-header"><div><h1 id="selectedStudentName"></h1><p class="detail-meta" id="selectedStudentMeta"></p></div><div class="detail-actions"><button class="copy-button" id="ackHelpButton" hidden>標記已查看</button><button class="copy-button" id="copyCodeButton">複製檔案</button><button class="copy-button danger-action" id="deleteStudentButton">刪除學生</button></div></header><div class="teacher-project"><nav class="teacher-file-tabs" id="teacherFiles"></nav><div class="detail-split"><section class="read-only-pane"><div class="pane-label"><span id="teacherActiveFile">main.py</span><span id="lineCount">0 行</span></div><pre class="code-readonly" id="studentCode"></pre></section><section class="read-only-pane"><div class="pane-label"><span>Console</span><span id="outputState"></span></div><pre class="output-readonly" id="studentOutput"></pre></section></div></div></div></section>
15
  </main>
16
+ <dialog id="loginDialog"><form class="dialog-content form-stack" id="loginForm"><div><h1>進入教師控制台</h1><p>選取現有課程,或建立新的課程代碼。</p></div><div><label class="field-label" for="classCode">課程</label><select class="text-field" id="classCode" required></select></div><div id="newClassFields" hidden><label class="field-label" for="newClassCode">新課程代碼</label><input class="text-field" id="newClassCode"><label class="field-label secondary-label" for="newClassName">課程名稱</label><input class="text-field" id="newClassName"></div><div><label class="field-label" for="teacherPassword">教師密碼</label><input class="text-field" id="teacherPassword" type="password" required></div><p class="form-error" id="loginError"></p><button class="btn" type="submit">開始監看</button></form></dialog>
17
  <dialog id="publishDialog"><div class="dialog-content"><h2>結算並發布</h2><p id="publishMessage"></p><div class="publish-actions"><button class="btn secondary" id="cancelPublish">取消</button><button class="btn" id="confirmPublish">確認發布</button></div></div></dialog><div class="toast" id="toast"></div><script src="/classroom-assets/teacher.js?v=2026fall-7"></script>
18
  </body>
19
  </html>
templates/index.html CHANGED
@@ -55,7 +55,7 @@
55
  </select>
56
  </div>
57
  <div class="result-group">
58
- <input type="text" id="gen-url-result" placeholder="選取後自動產生連結..." readonly />
59
  <button id="btn-copy-url" onclick="copyGeneratedUrl()">複製連結</button>
60
  </div>
61
  </div>
 
55
  </select>
56
  </div>
57
  <div class="result-group">
58
+ <input type="text" id="gen-url-result" readonly />
59
  <button id="btn-copy-url" onclick="copyGeneratedUrl()">複製連結</button>
60
  </div>
61
  </div>
templates/player.html CHANGED
@@ -85,7 +85,7 @@
85
  <div class="terminal-box" id="console-logs"></div>
86
  <div class="terminal-input-wrapper">
87
  <span class="terminal-prompt">&gt;</span>
88
- <input type="text" id="terminal-input-field" placeholder="輸入資料後按 Enter 傳送..." autocomplete="off" />
89
  </div>
90
  </div>
91
 
 
85
  <div class="terminal-box" id="console-logs"></div>
86
  <div class="terminal-input-wrapper">
87
  <span class="terminal-prompt">&gt;</span>
88
+ <input type="text" id="terminal-input-field" autocomplete="off" />
89
  </div>
90
  </div>
91
 
tests/test_admin.py CHANGED
@@ -24,10 +24,14 @@ class AdminServiceTests(unittest.TestCase):
24
  "project": "舊專題", "output": "舊產出", "template": False,
25
  }],
26
  }, ensure_ascii=False), encoding="utf-8")
27
- self.old = (classroom.DATA, classroom.TEMPLATES, classroom.PASSWORD, admin.ROOT, admin.PROJECTS, admin.commit_changes)
 
28
  classroom.DATA = self.data
29
  classroom.TEMPLATES = self.templates
30
  classroom.PASSWORD = "teacher-test"
 
 
 
31
  admin.ROOT = self.root
32
  admin.PROJECTS = self.projects
33
  self.commits = []
@@ -39,7 +43,7 @@ class AdminServiceTests(unittest.TestCase):
39
  admin.commit_changes = fake_commit
40
 
41
  def tearDown(self):
42
- classroom.DATA, classroom.TEMPLATES, classroom.PASSWORD, admin.ROOT, admin.PROJECTS, admin.commit_changes = self.old
43
  self.temp_dir.cleanup()
44
 
45
  def test_teacher_updates_week_and_template_files(self):
@@ -79,6 +83,25 @@ class AdminServiceTests(unittest.TestCase):
79
  self.assertFalse(renamed_root.exists())
80
  self.assertEqual(len(self.commits), 3)
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
  if __name__ == "__main__":
84
  unittest.main()
 
24
  "project": "舊專題", "output": "舊產出", "template": False,
25
  }],
26
  }, ensure_ascii=False), encoding="utf-8")
27
+ (self.data / "classes.json").write_text(json.dumps([{"code": "L6A", "name": "測試班", "students": []}], ensure_ascii=False), encoding="utf-8")
28
+ self.old = (classroom.DATA, classroom.TEMPLATES, classroom.PASSWORD, classroom.RUNTIME, classroom.DB, admin.ROOT, admin.PROJECTS, admin.commit_changes)
29
  classroom.DATA = self.data
30
  classroom.TEMPLATES = self.templates
31
  classroom.PASSWORD = "teacher-test"
32
+ classroom.RUNTIME = self.root / "runtime"
33
+ classroom.DB = classroom.RUNTIME / "classroom.sqlite3"
34
+ classroom.init_db()
35
  admin.ROOT = self.root
36
  admin.PROJECTS = self.projects
37
  self.commits = []
 
43
  admin.commit_changes = fake_commit
44
 
45
  def tearDown(self):
46
+ classroom.DATA, classroom.TEMPLATES, classroom.PASSWORD, classroom.RUNTIME, classroom.DB, admin.ROOT, admin.PROJECTS, admin.commit_changes = self.old
47
  self.temp_dir.cleanup()
48
 
49
  def test_teacher_updates_week_and_template_files(self):
 
83
  self.assertFalse(renamed_root.exists())
84
  self.assertEqual(len(self.commits), 3)
85
 
86
+ def test_teacher_backs_up_and_manages_submitted_content(self):
87
+ state = classroom.empty("L6A", "alex", "Alex", "week02")
88
+ state["project_name"] = "謎案"
89
+ state["files"] = {"main.py": 'print("saved")\n'}
90
+ state["final_files"] = dict(state["files"])
91
+ state["submitted_at"] = 123456789
92
+ classroom.save(state)
93
+
94
+ result = admin.backup_submissions(admin.ClassroomBackupRequest(password="teacher-test", class_code="L6A", week_id="week02"))
95
+ saved = self.data / "submissions" / "L6A" / "alex" / "week02.json"
96
+ self.assertEqual(result["count"], 1)
97
+ self.assertTrue(saved.is_file())
98
+ detail = admin.admin_submission(admin.ClassroomSubmissionRequest(password="teacher-test", class_code="L6A", student_id="alex", week_id="week02"))
99
+ self.assertIn("saved", detail["submission"]["files"]["main.py"])
100
+
101
+ admin.delete_submission(admin.ClassroomSubmissionRequest(password="teacher-test", class_code="L6A", student_id="alex", week_id="week02"))
102
+ self.assertFalse(saved.exists())
103
+ self.assertEqual(classroom.submission_catalog(), [])
104
+
105
 
106
  if __name__ == "__main__":
107
  unittest.main()
tests/test_classroom.py CHANGED
@@ -142,6 +142,20 @@ class ClassroomServiceTests(unittest.TestCase):
142
  classroom.StudentLogin(class_code="L6A", display_name="不在名單")
143
  )
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  def test_public_course_catalog_does_not_expose_student_names(self):
146
  state = classroom.empty("L6A", "student-1", "Alex", "week02")
147
  classroom.save(state)
@@ -149,6 +163,15 @@ class ClassroomServiceTests(unittest.TestCase):
149
  self.assertNotIn("students", public["courses"][0])
150
  self.assertNotIn("Alex", json.dumps(public, ensure_ascii=False))
151
 
 
 
 
 
 
 
 
 
 
152
  def test_hf_failure_message_distinguishes_authentication_and_permission(self):
153
  unauthorized = RuntimeError("token hf_should_never_leak was rejected")
154
  unauthorized.response = SimpleNamespace(status_code=401)
 
142
  classroom.StudentLogin(class_code="L6A", display_name="不在名單")
143
  )
144
 
145
+ def test_admin_can_rename_and_delete_classroom(self):
146
+ old_password, old_token = classroom.PASSWORD, classroom.TOKEN
147
+ try:
148
+ classroom.PASSWORD = "teacher-test"
149
+ classroom.TOKEN = ""
150
+ classroom.create_class(classroom.CourseCreate(class_code="L6Z", course_name="舊班名", password="teacher-test"))
151
+ updated = classroom.update_class(classroom.CourseUpdate(class_code="L6Z", course_name="新班名", password="teacher-test"))
152
+ self.assertEqual(updated["course_name"], "新班名")
153
+ deleted = asyncio.run(classroom.delete_class(classroom.CourseDelete(class_code="L6Z", password="teacher-test")))
154
+ self.assertTrue(deleted["ok"])
155
+ finally:
156
+ classroom.PASSWORD, classroom.TOKEN = old_password, old_token
157
+ self.assertFalse(any(item["class_code"] == "L6Z" for item in classroom.catalog()["courses"]))
158
+
159
  def test_public_course_catalog_does_not_expose_student_names(self):
160
  state = classroom.empty("L6A", "student-1", "Alex", "week02")
161
  classroom.save(state)
 
163
  self.assertNotIn("students", public["courses"][0])
164
  self.assertNotIn("Alex", json.dumps(public, ensure_ascii=False))
165
 
166
+ def test_bundled_l6sat_backup_restores_students_and_submissions(self):
167
+ course = next(item for item in classroom.catalog()["courses"] if item["class_code"] == "L6Sat")
168
+ submissions = [item for item in classroom.submission_catalog() if item["class_code"] == "L6Sat"]
169
+ self.assertEqual(len(course["students"]), 8)
170
+ self.assertEqual(len(submissions), 8)
171
+ restored = classroom.get_submission("L6Sat", "胡惟媗", "week02")
172
+ self.assertEqual(restored["project_name"], "校園謎案調查局")
173
+ self.assertEqual(set(restored["final_files"]), {"main.py", "case_data.json"})
174
+
175
  def test_hf_failure_message_distinguishes_authentication_and_permission(self):
176
  unauthorized = RuntimeError("token hf_should_never_leak was rejected")
177
  unauthorized.response = SimpleNamespace(status_code=401)