Spaces:
Running
Running
Commit ·
b88dc75
1
Parent(s): 58528b2
Restore classroom submissions and add classroom admin
Browse files- README.md +2 -0
- admin_service.py +52 -1
- classroom_data/classes.json +10 -1
- classroom_data/submissions/L6Sat/大漢堡/week02.json +13 -0
- classroom_data/submissions/L6Sat/張棨竣/week02.json +13 -0
- classroom_data/submissions/L6Sat/李合盛/week02.json +13 -0
- classroom_data/submissions/L6Sat/林守桀/week02.json +13 -0
- classroom_data/submissions/L6Sat/王三策/week02.json +13 -0
- classroom_data/submissions/L6Sat/王辭信/week02.json +13 -0
- classroom_data/submissions/L6Sat/胡惟媗/week02.json +13 -0
- classroom_data/submissions/L6Sat/蔡昊儒/week02.json +13 -0
- classroom_service.py +55 -7
- classroom_static/admin.css +39 -2
- classroom_static/admin.html +16 -4
- classroom_static/admin.js +185 -6
- classroom_static/student.html +4 -4
- classroom_static/student.js +1 -1
- classroom_static/teacher.html +2 -2
- templates/index.html +1 -1
- templates/player.html +1 -1
- tests/test_admin.py +25 -2
- tests/test_classroom.py +23 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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"]=
|
| 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 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 8 |
-
<link rel="stylesheet" href="/classroom-assets/admin.css?v=2026fall-
|
| 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-
|
| 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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
| 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
|
| 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":
|
| 265 |
demoAssets = [];
|
| 266 |
demoFile = "main.py";
|
| 267 |
-
E.demoClass.value = pickedClass || "
|
| 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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
| 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"
|
| 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"
|
| 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"
|
| 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"
|
| 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("&","&").replaceAll("<","<").
|
|
| 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||"
|
| 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"
|
| 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"
|
| 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"
|
| 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">></span>
|
| 88 |
-
<input type="text" id="terminal-input-field"
|
| 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">></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.
|
|
|
|
| 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)
|