Spaces:
Running
Running
Commit ·
7e8293f
1
Parent(s): 60dfd5a
Keep Lessons 2 to 4 in one project workspace
Browse files- README.md +1 -1
- classroom_data/course.json +3 -3
- classroom_data/repairs/L6Sat/week02/大漢堡.py +99 -0
- classroom_data/repairs/L6Sat/week02/張棨竣.py +102 -0
- classroom_data/repairs/L6Sat/week02/李合盛.py +93 -0
- classroom_data/repairs/L6Sat/week02/林守桀.py +106 -0
- classroom_data/repairs/L6Sat/week02/王三策.py +103 -0
- classroom_data/repairs/L6Sat/week02/王辭信.py +116 -0
- classroom_data/repairs/L6Sat/week02/胡惟媗.py +102 -0
- classroom_data/repairs/L6Sat/week02/蔡昊儒.py +112 -0
- classroom_data/templates/week03/case_data.json +0 -42
- classroom_data/templates/week03/main.py +0 -98
- classroom_service.py +16 -2
- classroom_static/student.html +4 -4
- classroom_static/student.js +32 -8
- classroom_static/teacher.html +1 -1
- classroom_static/teacher.js +9 -7
- tests/test_classroom.py +10 -1
README.md
CHANGED
|
@@ -16,7 +16,7 @@ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-
|
|
| 16 |
- 內容管理:`/admin`
|
| 17 |
- 展示:`/`
|
| 18 |
|
| 19 |
-
內建 2026 Fall Lesson 路線圖
|
| 20 |
|
| 21 |
課程與名單管理:
|
| 22 |
|
|
|
|
| 16 |
- 內容管理:`/admin`
|
| 17 |
- 展示:`/`
|
| 18 |
|
| 19 |
+
內建 2026 Fall Lesson 路線圖。Lesson 2 只匯入一次專案起始套版,Lesson 3~4 共用同一份案件進度;支援多個 Python 檔、ZIP 匯入匯出、即時監看、專案最後正式版與整班發布。
|
| 20 |
|
| 21 |
課程與名單管理:
|
| 22 |
|
classroom_data/course.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
{"course_id":"2026Fall","title":"2026 Fall 進階專題","demo_class":"2026Fall進階專題","weeks":[
|
| 2 |
{"id":"week01","lesson":1,"title":"任務管理器開發挑戰","project":"能力診斷","output":"文字版任務管理器","template":true},
|
| 3 |
-
{"id":"week02","lesson":2,"title":"消失的校徽","project":"校園謎案調查局","output":"探索地點與收集線索的文字案件","template":true},
|
| 4 |
-
{"id":"week03","lesson":3,"title":"線索會改變世界","project":"校園謎案調查局","output":"條件解鎖、
|
| 5 |
-
{"id":"week04","lesson":4,"title":"
|
| 6 |
{"id":"week05","lesson":5,"title":"案件發布日","project":"校園謎案調查局","output":"原創短案件與線上展示","template":false},
|
| 7 |
{"id":"week06","lesson":6,"title":"領養一顆神祕蛋","project":"電子寵物進化實驗室","output":"可餵食、玩耍、休息的寵物原型","template":false},
|
| 8 |
{"id":"week07","lesson":7,"title":"寵物真的活起來","project":"電子寵物進化實驗室","output":"class 與 object 寵物","template":false},
|
|
|
|
| 1 |
{"course_id":"2026Fall","title":"2026 Fall 進階專題","demo_class":"2026Fall進階專題","weeks":[
|
| 2 |
{"id":"week01","lesson":1,"title":"任務管理器開發挑戰","project":"能力診斷","output":"文字版任務管理器","template":true},
|
| 3 |
+
{"id":"week02","lesson":2,"title":"消失的校徽","project":"校園謎案調查局","output":"探索地點與收集線索的文字案件","workspace_id":"week02","template":true},
|
| 4 |
+
{"id":"week03","lesson":3,"title":"線索會改變世界","project":"校園謎案調查局","output":"在原案件加入條件解鎖、證詞變化與多結局","workspace_id":"week02","template":false},
|
| 5 |
+
{"id":"week04","lesson":4,"title":"案件體驗升級","project":"校園謎案調查局","output":"在原案件完成互動、提示、測試與修正","workspace_id":"week02","template":false},
|
| 6 |
{"id":"week05","lesson":5,"title":"案件發布日","project":"校園謎案調查局","output":"原創短案件與線上展示","template":false},
|
| 7 |
{"id":"week06","lesson":6,"title":"領養一顆神祕蛋","project":"電子寵物進化實驗室","output":"可餵食、玩耍、休息的寵物原型","template":false},
|
| 8 |
{"id":"week07","lesson":7,"title":"寵物真的活起來","project":"電子寵物進化實驗室","output":"class 與 object 寵物","template":false},
|
classroom_data/repairs/L6Sat/week02/大漢堡.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Week 2:校園謎案調查局 Starter。
|
| 2 |
+
|
| 3 |
+
先完成 TODO 1~4。程式必須能反覆探索、收集線索、查看證物並提出推理。
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
CASE_TITLE = "第八節課的解剖刀"
|
| 7 |
+
|
| 8 |
+
LOCATIONS = {
|
| 9 |
+
"1": {
|
| 10 |
+
"name": "舊醫學大樓地下標本解剖室",
|
| 11 |
+
"scene": "廢棄三十年的老解剖教室,磁磚發黃剝落,空氣中混雜著刺鼻的酚類消毒水與皮肉腐壞的甜腥味。房間中央是一張帶有引流血槽的生鏽不鏽鋼解剖台,牆邊排列著高大的玻璃標本櫃,泡著畸形胎兒與各種器官標本,燈管接觸不良地發出急促的滋滋電流聲。",
|
| 12 |
+
"evidence": "肺部硬化病理切片箱與第17號大體浸泡池",
|
| 13 |
+
},
|
| 14 |
+
"2": {
|
| 15 |
+
"name": "校園北側舊熱水蒸氣鍋爐房",
|
| 16 |
+
"scene": "供應全校舊宿舍暖氣與廚房熱水的鍋爐地窖,溫度高達40度以上,管線縱橫交錯,巨大鑄鐵煤炭鍋爐發出沉悶的轟鳴與金屬受熱膨脹的刮擦聲。地面淌著烏黑的煤灰凝結水,視線極度昏暗。",
|
| 17 |
+
"evidence": "已停用的2號輔助燃燒室通風蝶閥",
|
| 18 |
+
},
|
| 19 |
+
"3": {
|
| 20 |
+
"name": "校長室後方夾層(原早期校長密室避難所)",
|
| 21 |
+
"scene": "隱藏在氣派校長室紅木書架後方的狹窄夾層,只有不到兩公尺寬,裡面沒有窗戶,牆壁貼滿泛黃的隔音棉。房間內擺著一張皮製束縛椅、一台老式錄影設備,以及一排鎖死的醫用低溫冷藏箱。空氣中只有壓縮機運轉的冰冷低頻聲。",
|
| 22 |
+
"evidence": "冷藏箱底層的「自願捐贈協議書」金屬夾板",
|
| 23 |
+
},
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
evidence_bag = []
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def investigate(location_id):
|
| 30 |
+
"""調查地點並將新線索放入證物袋。"""
|
| 31 |
+
# TODO 1:確認 location_id 是否存在於 LOCATIONS。
|
| 32 |
+
if location_id not in LOCATIONS:
|
| 33 |
+
print("沒這個地點編號啦齁!")
|
| 34 |
+
return
|
| 35 |
+
# TODO 2:顯示地點名稱與現場描述。
|
| 36 |
+
print(f"你來到了{LOCATIONS[location_id]['name']}")
|
| 37 |
+
print(LOCATIONS[location_id]["scene"])
|
| 38 |
+
# TODO 3:線索尚未取得時才 append 到 evidence_bag。
|
| 39 |
+
if location_id not in evidence_bag:
|
| 40 |
+
evidence_bag.append(location_id)
|
| 41 |
+
# print("這個調查功能還沒完成。")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def show_evidence():
|
| 45 |
+
"""列出目前取得的所有證物。"""
|
| 46 |
+
# TODO 4:沒有證物時顯示提示;否則以 1 開始編號列出。
|
| 47 |
+
print("證物袋功能還沒完成。")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def make_deduction():
|
| 51 |
+
"""取得足夠證物後,讓玩家選擇真相。"""
|
| 52 |
+
if len(evidence_bag) < 3:
|
| 53 |
+
print(f"證物不足:目前 {len(evidence_bag)}/3。再去調查吧!")
|
| 54 |
+
return False
|
| 55 |
+
|
| 56 |
+
print("\n哪一個說法最符合所有證物?")
|
| 57 |
+
print("1. 有同學故意把校徽藏在圖書館")
|
| 58 |
+
print("2. 風吹起校徽,清潔推車不小心把它帶到圖書館")
|
| 59 |
+
print("3. 校徽從一開始就放在圖書館")
|
| 60 |
+
answer = input("你的推理:").strip()
|
| 61 |
+
|
| 62 |
+
# TODO 5(Checkpoint C):判斷答案,正確時回傳 True。
|
| 63 |
+
print("推理判定還沒完成。")
|
| 64 |
+
return False
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def show_menu():
|
| 68 |
+
print(f"\n=== 校園謎案調查局:{CASE_TITLE} ===")
|
| 69 |
+
print("1. 調查六年一班教室")
|
| 70 |
+
print("2. 調查操場司令台")
|
| 71 |
+
print("3. 調查圖書館")
|
| 72 |
+
print("4. 查看證物袋")
|
| 73 |
+
print("5. 提出推理")
|
| 74 |
+
print("6. 離開")
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def main():
|
| 78 |
+
print("午休結束後,校慶展示櫃裡的校徽消失了。你能找出真相嗎?")
|
| 79 |
+
|
| 80 |
+
while True:
|
| 81 |
+
show_menu()
|
| 82 |
+
choice = input("請選擇:").strip()
|
| 83 |
+
|
| 84 |
+
if choice in LOCATIONS:
|
| 85 |
+
investigate(choice)
|
| 86 |
+
elif choice == "4":
|
| 87 |
+
show_evidence()
|
| 88 |
+
elif choice == "5":
|
| 89 |
+
if make_deduction():
|
| 90 |
+
break
|
| 91 |
+
elif choice == "6":
|
| 92 |
+
print("案件暫停。證物袋已交回調查局。")
|
| 93 |
+
break
|
| 94 |
+
else:
|
| 95 |
+
print("請輸入 1~6。")
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
if __name__ == "__main__":
|
| 99 |
+
main()
|
classroom_data/repairs/L6Sat/week02/張棨竣.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Week 2:校園謎案調查局 Starter。
|
| 2 |
+
|
| 3 |
+
先完成 TODO 1~4。程式必須能反覆探索、收集線索、查看證物並提出推理。
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
CASE_TITLE = "消失的水壺"
|
| 7 |
+
|
| 8 |
+
LOCATIONS = {
|
| 9 |
+
"1": {
|
| 10 |
+
"name": "六年13班教室",
|
| 11 |
+
"scene": "凌亂的座位,窗戶也碎掉了。",
|
| 12 |
+
"evidence": "玻璃碎片",
|
| 13 |
+
},
|
| 14 |
+
"2": {
|
| 15 |
+
"name": "操場的空地",
|
| 16 |
+
"scene": "地面有血跡,有人刻意用落葉阻擋的痕跡。",
|
| 17 |
+
"evidence": " 小鳥的羽毛"
|
| 18 |
+
},
|
| 19 |
+
"3": {
|
| 20 |
+
"name": "洗手台",
|
| 21 |
+
"scene": "沒有關的水龍頭,後方的垃圾桶閃著光。",
|
| 22 |
+
"evidence": "在桶子裡的水壺",
|
| 23 |
+
},
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
evidence_bag = []
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def investigate(location_id):
|
| 30 |
+
"""調查地點並將新線索放入證物袋。"""
|
| 31 |
+
# TODO 1:確認 location_id 是否存在於 LOCATIONS。
|
| 32 |
+
if location_id not in LOCATIONS:
|
| 33 |
+
print("bad")
|
| 34 |
+
return
|
| 35 |
+
#TODO 2:顯示地點名稱與現場描述。
|
| 36 |
+
print(f"你到了{LOCATIONS[location_id]['name']}")
|
| 37 |
+
print(f"{LOCATIONS[location_id]['scene']}")
|
| 38 |
+
# TODO 3:線索尚未取得時才 append 到 evidence_bag。
|
| 39 |
+
if location_id not in evidence_bag:
|
| 40 |
+
evidence_bag.append(location_id)
|
| 41 |
+
|
| 42 |
+
def show_evidence():
|
| 43 |
+
"""列出目前取得的所有證物。"""
|
| 44 |
+
# TODO 4:沒有證物時顯示提示;否則以 1 開始編號列出。
|
| 45 |
+
if not evidence_bag:
|
| 46 |
+
print("你沒證據")
|
| 47 |
+
return
|
| 48 |
+
else:
|
| 49 |
+
print(evidence_bag)
|
| 50 |
+
print("證物袋功能還沒完成。")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def make_deduction():
|
| 54 |
+
"""取得足夠證物後,讓玩家選擇真相。"""
|
| 55 |
+
if len(evidence_bag) < 3:
|
| 56 |
+
print(f"證物不足:目前 {len(evidence_bag)}/3。再去調查吧!")
|
| 57 |
+
return False
|
| 58 |
+
|
| 59 |
+
print("\n哪一個說法最符合所有證物?")
|
| 60 |
+
print("1. 有同學故意把校徽藏在圖書館")
|
| 61 |
+
print("2. 風吹起校徽,清潔推車不小心把它帶到圖書館")
|
| 62 |
+
print("3. 校徽從一開始就放在圖書館")
|
| 63 |
+
answer = input("你的推理:").strip()
|
| 64 |
+
|
| 65 |
+
# TODO 5(Checkpoint C):判斷答案,正確時回傳 True。
|
| 66 |
+
print("推理判定還沒完成。")
|
| 67 |
+
return False
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def show_menu():
|
| 71 |
+
print(f"\n=== 校園謎案調查局:{CASE_TITLE} ===")
|
| 72 |
+
print("1. 調查六年13班教室")
|
| 73 |
+
print("2. 調查操場的空地")
|
| 74 |
+
print("3. 調查洗手台")
|
| 75 |
+
print("4. 查看證物袋")
|
| 76 |
+
print("5. 提出推理")
|
| 77 |
+
print("6. 離開")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def main():
|
| 81 |
+
print("午休結束後,校慶展示櫃裡的校徽消失了。你能找出真相嗎?")
|
| 82 |
+
|
| 83 |
+
while True:
|
| 84 |
+
show_menu()
|
| 85 |
+
choice = input("請選擇:").strip()
|
| 86 |
+
|
| 87 |
+
if choice in LOCATIONS:
|
| 88 |
+
investigate(choice)
|
| 89 |
+
elif choice == "4":
|
| 90 |
+
show_evidence()
|
| 91 |
+
elif choice == "5":
|
| 92 |
+
if make_deduction():
|
| 93 |
+
break
|
| 94 |
+
elif choice == "6":
|
| 95 |
+
print("案件暫停。證物袋已交回調查局。")
|
| 96 |
+
break
|
| 97 |
+
else:
|
| 98 |
+
print("請輸入 1~6。")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
if __name__ == "__main__":
|
| 102 |
+
main()
|
classroom_data/repairs/L6Sat/week02/李合盛.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Week 2:校園謎案調查局 Starter。
|
| 2 |
+
|
| 3 |
+
先完成 TODO 1~4。程式必須能反覆探索、收集線索、查看證物並提出推理。
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
CASE_TITLE = "不見dre人有67人"
|
| 7 |
+
|
| 8 |
+
LOCATIONS = {
|
| 9 |
+
"1": {
|
| 10 |
+
"name": "六年一百班教室",
|
| 11 |
+
"scene": "展示櫃大開,窗戶也沒有關好。",
|
| 12 |
+
"evidence": "窗框上的人頭",
|
| 13 |
+
},
|
| 14 |
+
"2": {
|
| 15 |
+
"name": "操場司令台有人手和腳",
|
| 16 |
+
"scene": "地面剛拖過,留下一道彎向圖書館的輪痕和血。",
|
| 17 |
+
"evidence": "血的推車輪痕",
|
| 18 |
+
},
|
| 19 |
+
"3": {
|
| 20 |
+
"name": "生物教室",
|
| 21 |
+
"scene": "清潔推車停在骨架旁下有血跡,後方似乎有東西在叫。",
|
| 22 |
+
"evidence": "卡在推車後方的67人",
|
| 23 |
+
},
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
evidence_bag = []
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def investigate(location_id):
|
| 30 |
+
"""調查地點並將新線索放入證物袋。"""
|
| 31 |
+
# TODO 1:確認 location_id 是否存在於 LOCATIONS。
|
| 32 |
+
if location_id not in LOCATIONS:
|
| 33 |
+
print("here don't have you need")
|
| 34 |
+
return
|
| 35 |
+
print("這個調查功能還沒完成。")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def show_evidence():
|
| 39 |
+
"""列出目前取得的所有證物。"""
|
| 40 |
+
# TODO 4:沒有證物時顯示提示;否則以 1 開始編號列出。
|
| 41 |
+
print("證物袋功能還沒完成。")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def make_deduction():
|
| 45 |
+
"""取得足夠證物後,讓玩家選擇真相。"""
|
| 46 |
+
if len(evidence_bag) < 3:
|
| 47 |
+
print(f"證物不足:目前 {len(evidence_bag)}/3。再去調查吧!")
|
| 48 |
+
return False
|
| 49 |
+
|
| 50 |
+
print("\n哪一個說法最符合所有證物?")
|
| 51 |
+
print("1.他們互相殘殺,最後一個人自殺")
|
| 52 |
+
print("2. 有人故意殺人")
|
| 53 |
+
print("3. 67人一開始就在圖書館")
|
| 54 |
+
answer = input("你的推理:").strip()
|
| 55 |
+
|
| 56 |
+
# TODO 5(Checkpoint C):判斷答案,正確時回傳 True。
|
| 57 |
+
print("推理判定還沒完成。")
|
| 58 |
+
return False
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def show_menu():
|
| 62 |
+
print(f"\n=== 校園謎案調查局:{CASE_TITLE} ===")
|
| 63 |
+
print("1. 調查六年一班教室")
|
| 64 |
+
print("2. 調查操場司令台")
|
| 65 |
+
print("3. 調查圖書館")
|
| 66 |
+
print("4. 查看證物袋")
|
| 67 |
+
print("5. 提出推理")
|
| 68 |
+
print("6. 離開")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def main():
|
| 72 |
+
print("午休結束後,校慶展示櫃裡的校徽消失了。你能找出真相嗎?")
|
| 73 |
+
|
| 74 |
+
while True:
|
| 75 |
+
show_menu()
|
| 76 |
+
choice = input("請選擇:").strip()
|
| 77 |
+
|
| 78 |
+
if choice in LOCATIONS:
|
| 79 |
+
investigate(choice)
|
| 80 |
+
elif choice == "4":
|
| 81 |
+
show_evidence()
|
| 82 |
+
elif choice == "5":
|
| 83 |
+
if make_deduction():
|
| 84 |
+
break
|
| 85 |
+
elif choice == "6":
|
| 86 |
+
print("案件暫停。證物袋已交回調查局。")
|
| 87 |
+
break
|
| 88 |
+
else:
|
| 89 |
+
print("請輸入 1~6。")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
if __name__ == "__main__":
|
| 93 |
+
main()
|
classroom_data/repairs/L6Sat/week02/林守桀.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Week 2:校園謎案調查局 Starter。
|
| 2 |
+
|
| 3 |
+
先完成 TODO 1~4。程式必須能反覆探索、收集線索、查看證物並提出推理。
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
CASE_TITLE = "半夜的舊校舍"
|
| 7 |
+
|
| 8 |
+
LOCATIONS = {
|
| 9 |
+
"1": {
|
| 10 |
+
"name": "廢棄音樂教室",
|
| 11 |
+
"scene": "鋼琴蓋自己微微晃動,琴鍵上記號筆寫著血紅色的字。",
|
| 12 |
+
"evidence": "散落一地的老舊樂譜碎片",
|
| 13 |
+
},
|
| 14 |
+
"2": {
|
| 15 |
+
"name": "走廊盡頭的女廁",
|
| 16 |
+
"scene": "最後一間廁所門半開,鏡子上滿是水氣與詭異手印。",
|
| 17 |
+
"evidence": "沾著濕黏水跡的紅色髮圈",
|
| 18 |
+
},
|
| 19 |
+
"3": {
|
| 20 |
+
"name": "地下室器材室",
|
| 21 |
+
"scene": "角落的老舊木櫃發出叩叩聲,地上有一排發光的鞋印。",
|
| 22 |
+
"evidence": "刻有神秘符號的銅製鑰匙",
|
| 23 |
+
},
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
evidence_bag = []
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def investigate(location_id):
|
| 30 |
+
"""調查地點並將新線索放入證物袋。"""
|
| 31 |
+
# TODO 1:確認 location_id 是否存在於 LOCATIONS。
|
| 32 |
+
if location_id in LOCATIONS:
|
| 33 |
+
loc = LOCATIONS[location_id]
|
| 34 |
+
# TODO 2:顯示地點名稱與現場描述。
|
| 35 |
+
print(f"地點:{loc['name']}")
|
| 36 |
+
print(f"現場:{loc['scene']}")
|
| 37 |
+
# TODO 3:線索尚未取得時才 append 到 evidence_bag。
|
| 38 |
+
if loc["evidence"] not in evidence_bag:
|
| 39 |
+
evidence_bag.append(loc["evidence"])
|
| 40 |
+
print(f"找到證物:{loc['evidence']}")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def show_evidence():
|
| 44 |
+
"""列出目前取得的所有證物。"""
|
| 45 |
+
# TODO 4:沒有證物時顯示提示;否則以 1 開始編號列出。
|
| 46 |
+
if not evidence_bag:
|
| 47 |
+
print("目前沒有任何證物。")
|
| 48 |
+
else:
|
| 49 |
+
for i, item in enumerate(evidence_bag, 1):
|
| 50 |
+
print(f"{i}. {item}")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def make_deduction():
|
| 54 |
+
"""取得足夠證物後,讓玩家選擇真相。"""
|
| 55 |
+
if len(evidence_bag) < 3:
|
| 56 |
+
print(f"證物不足:目前 {len(evidence_bag)}/3。再去調查吧!")
|
| 57 |
+
return False
|
| 58 |
+
|
| 59 |
+
print("\n哪一個說法最符合所有證物?")
|
| 60 |
+
print("1. 這一切只是舊校舍地基下陷產生的自然聲響")
|
| 61 |
+
print("2. 有學長在半夜偷偷溜進舊校舍試圖解開地下的封印")
|
| 62 |
+
print("3. 校園裡真的有幽靈在尋找失落的舊鑰匙")
|
| 63 |
+
answer = input("你的推理:").strip()
|
| 64 |
+
|
| 65 |
+
# TODO (5Checkpoint C):判斷答案,正確時回傳 True。
|
| 66 |
+
if answer == "2":
|
| 67 |
+
print("推理正確!原來是有人深夜闖入搞鬼,真相大白了!")
|
| 68 |
+
return True
|
| 69 |
+
else:
|
| 70 |
+
print("推理錯誤,這種說法無法解釋所有異象,再試試看。")
|
| 71 |
+
return False
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def show_menu():
|
| 75 |
+
print(f"\n=== 校園謎案調查局:{CASE_TITLE} ===")
|
| 76 |
+
print("1. 調查廢棄音樂教室")
|
| 77 |
+
print("2. 調查走廊盡頭的女廁")
|
| 78 |
+
print("3. 調查地下室器材室")
|
| 79 |
+
print("4. 查看證物袋")
|
| 80 |
+
print("5. 提出推理")
|
| 81 |
+
print("6. 逃離現場")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def main():
|
| 85 |
+
print("深夜的校園傳出詭異琴聲與哭喊,你能揭開舊校舍的祕密嗎?")
|
| 86 |
+
|
| 87 |
+
while True:
|
| 88 |
+
show_menu()
|
| 89 |
+
choice = input("請選擇:").strip()
|
| 90 |
+
|
| 91 |
+
if choice in LOCATIONS:
|
| 92 |
+
investigate(choice)
|
| 93 |
+
elif choice == "4":
|
| 94 |
+
show_evidence()
|
| 95 |
+
elif choice == "5":
|
| 96 |
+
if make_deduction():
|
| 97 |
+
break
|
| 98 |
+
elif choice == "6":
|
| 99 |
+
print("你感到一陣惡寒,決定先撤離舊校舍。")
|
| 100 |
+
break
|
| 101 |
+
else:
|
| 102 |
+
print("請輸入 1~6。")
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
if __name__ == "__main__":
|
| 106 |
+
main()
|
classroom_data/repairs/L6Sat/week02/王三策.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Week 2:校園謎案調查局 Starter。
|
| 2 |
+
|
| 3 |
+
先完成 TODO 1~4。程式必須能反覆探索、收集線索、查看證物並提出推理。
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
CASE_TITLE = "湊企鵝謎團[棍木伱看布辶彳亍子!!!恭喜!棍木]"
|
| 7 |
+
|
| 8 |
+
LOCATIONS = {
|
| 9 |
+
"1": {
|
| 10 |
+
"name": "資源班教室",
|
| 11 |
+
"scene": "有兩隻湊企鵝開心的笑[gugugaga]",
|
| 12 |
+
"evidence": "一個紙條,上面似乎有一串號碼...---...x1",
|
| 13 |
+
},
|
| 14 |
+
"2": {
|
| 15 |
+
"name": "實驗室",
|
| 16 |
+
"scene": "旁邊的貨架上有綠色和粉色的藥劑在冒泡",
|
| 17 |
+
"evidence": "一瓶綠色的奇怪藥劑x1一瓶粉色的奇怪藥劑x1",
|
| 18 |
+
},
|
| 19 |
+
"3": {
|
| 20 |
+
"name": "保安室",
|
| 21 |
+
"scene": "窗戶碎掉了,只有一隻湊企鵝在叫,但是地上有一個奇怪的腳印...",
|
| 22 |
+
"evidence": "一張奇怪的紙條.... . .-. .- -. --- ..- - -.-.--x1",
|
| 23 |
+
},
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
evidence_bag = []
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def investigate(location_id):
|
| 30 |
+
"""調查地點並將新線索放入證物袋。"""
|
| 31 |
+
# TODO 1:確認 location_id 是否存在於 LOCATIONS。
|
| 32 |
+
if location_id not in LOCATIONS:
|
| 33 |
+
print("你眼x嗎沒有這個號碼0227407890")
|
| 34 |
+
return
|
| 35 |
+
# TODO 2:顯示地點名稱與現場描述
|
| 36 |
+
print(f"你到了:{LOCATIONS[location_id]['name']}")
|
| 37 |
+
print(LOCATIONS[location_id]["scene"])
|
| 38 |
+
# TODO 3:線索尚未if not in evidence_bag:
|
| 39 |
+
if location_id not in evidence_bag:
|
| 40 |
+
evidence_bag.append(location_id)
|
| 41 |
+
else:
|
| 42 |
+
print("你蒐集過這個了^^")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def show_evidence():
|
| 46 |
+
"""列出目前取得的所有證物。"""
|
| 47 |
+
# TODO 4:沒有證物時顯示提示;否則以 1 開始編號列出。
|
| 48 |
+
if not evidence_bag:
|
| 49 |
+
print("\n證物袋目前是空的,快去各地點調查吧!")
|
| 50 |
+
return
|
| 51 |
+
print("證物袋功能還沒完成。")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def make_deduction():
|
| 55 |
+
"""取得足夠證物後,讓玩家選擇真相。"""
|
| 56 |
+
if len(evidence_bag) < 3:
|
| 57 |
+
print(f"證物不足:目前 {len(evidence_bag)}/3。再去調查吧!")
|
| 58 |
+
return False
|
| 59 |
+
|
| 60 |
+
print("\n哪一個說法最符合所有證物?")
|
| 61 |
+
print("1. 有同學故意把校徽藏在圖書館")
|
| 62 |
+
print("2. 風吹起校徽,清潔推車不小心把它帶到圖書館")
|
| 63 |
+
print("3. 校徽從一開始就放在圖書館")
|
| 64 |
+
answer = input("你的推理:").strip()
|
| 65 |
+
|
| 66 |
+
# TODO 5(Checkpoint C):判斷答案,正確時回傳 True。
|
| 67 |
+
print("推理判定還沒完成。")
|
| 68 |
+
return False
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def show_menu():
|
| 72 |
+
print(f"\n=== 校園謎案調查局:{CASE_TITLE} ===")
|
| 73 |
+
print("1. 調查六年一班教室")
|
| 74 |
+
print("2. 調查操場司令台")
|
| 75 |
+
print("3. 調查圖書館")
|
| 76 |
+
print("4. 查看證物袋")
|
| 77 |
+
print("5. 提出推理")
|
| 78 |
+
print("6. 離開")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def main():
|
| 82 |
+
print("午休結束後,校慶展示櫃裡的校徽消失了。你能找出真相嗎?")
|
| 83 |
+
|
| 84 |
+
while True:
|
| 85 |
+
show_menu()
|
| 86 |
+
choice = input("請選擇:").strip()
|
| 87 |
+
|
| 88 |
+
if choice in LOCATIONS:
|
| 89 |
+
investigate(choice)
|
| 90 |
+
elif choice == "4":
|
| 91 |
+
show_evidence()
|
| 92 |
+
elif choice == "5":
|
| 93 |
+
if make_deduction():
|
| 94 |
+
break
|
| 95 |
+
elif choice == "6":
|
| 96 |
+
print("案件暫停。證物袋已交回調查局。")
|
| 97 |
+
break
|
| 98 |
+
else:
|
| 99 |
+
print("請輸入 1~6。")
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
if __name__ == "__main__":
|
| 103 |
+
main()
|
classroom_data/repairs/L6Sat/week02/王辭信.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Week 2:校園謎案調查局 Starter。
|
| 2 |
+
|
| 3 |
+
先完成 TODO 1~4。程式必須能反覆探索、收集線索、查看證物並提出推理。
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
CASE_TITLE = "消失的鑽石棍木67不知道很雷霆"
|
| 7 |
+
|
| 8 |
+
LOCATIONS = {
|
| 9 |
+
"1": {
|
| 10 |
+
"name": "六七年七十八九十一班教室",
|
| 11 |
+
"scene": "展示櫃半開,窗戶也沒有關好就受者。",
|
| 12 |
+
"evidence": "窗框上的棍木6767色纖維",
|
| 13 |
+
},
|
| 14 |
+
"2": {
|
| 15 |
+
"name": "676767這波我挺偉偉的操場",
|
| 16 |
+
"scene": "地面剛拖過,留下一道彎向圖書館的痕跡。",
|
| 17 |
+
"evidence": "沾著野生狗奶棍木6767的推車輪痕",
|
| 18 |
+
},
|
| 19 |
+
"3": {
|
| 20 |
+
"name": "676767競爭者不好玩圖書館",
|
| 21 |
+
"scene": "清潔推車停在書架旁,後方似乎有東西反光。",
|
| 22 |
+
"evidence": "卡在推車後方的鑽石棍木67",
|
| 23 |
+
},
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
evidence_bag = []
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def investigate(location_id):
|
| 30 |
+
"""調查地點並將新線索放入證物袋。"""
|
| 31 |
+
# TODO 1:確認 location_id 是否存在於 LOCATIONS。
|
| 32 |
+
if location_id not in LOCATIONS:
|
| 33 |
+
print("無效的地點!")
|
| 34 |
+
return
|
| 35 |
+
|
| 36 |
+
# 取得對應地點的資料
|
| 37 |
+
loc = LOCATIONS[location_id]
|
| 38 |
+
|
| 39 |
+
# TODO 2:顯示地點名稱與現場描述。
|
| 40 |
+
print(f"\n【調查地點:{loc['name']}】")
|
| 41 |
+
print(f"現場狀況:{loc['scene']}")
|
| 42 |
+
|
| 43 |
+
# TODO 3:線索尚未取得時才 append 到 evidence_bag。
|
| 44 |
+
found_evidence = loc['evidence']
|
| 45 |
+
if found_evidence not in evidence_bag:
|
| 46 |
+
evidence_bag.append(found_evidence)
|
| 47 |
+
else:
|
| 48 |
+
print("這個地點已經調查過了,沒有新的發現。")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def show_evidence():
|
| 52 |
+
"""列出目前取得的所有證物。"""
|
| 53 |
+
# TODO 4:沒有證物時顯示提示;否則以 1 開始編號列出。
|
| 54 |
+
if not evidence_bag:
|
| 55 |
+
print("目前空空如也,趕快去調查吧!")
|
| 56 |
+
else:
|
| 57 |
+
print("\n=== 目前收到的證物 ===")
|
| 58 |
+
for i, evidence in enumerate(evidence_bag, start=1):
|
| 59 |
+
print(f"{i}. {evidence}")
|
| 60 |
+
print("=============")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def make_deduction():
|
| 64 |
+
"""取得足夠證物後,讓玩家選擇真相。"""
|
| 65 |
+
if len(evidence_bag) < 3:
|
| 66 |
+
print(f"證物不足:目前 {len(evidence_bag)}/3。再去調查吧!")
|
| 67 |
+
return False
|
| 68 |
+
|
| 69 |
+
print("\n哪一個說法最符合所有證物?")
|
| 70 |
+
print("1. 有同學故意把鑽石棍木67藏在圖書館")
|
| 71 |
+
print("2. 風吹起鑽石棍木67,清潔推車不小心把它帶到圖書館")
|
| 72 |
+
print("3. 鑽石棍木67從一開始就放在圖書館")
|
| 73 |
+
answer = input("你的推理:").strip()
|
| 74 |
+
|
| 75 |
+
# TODO 5(Checkpoint C):判斷答案,正確時回傳 True。
|
| 76 |
+
if answer == "2":
|
| 77 |
+
print("\n🎉 推理正確!窗邊的棍木6767色纖維跟推車的軌跡吻合,是一場意外!案件圓滿解決。")
|
| 78 |
+
return True
|
| 79 |
+
else:
|
| 80 |
+
print("\n❌ 案情似乎沒有這麼簡單,再重新看一次證物吧!")
|
| 81 |
+
return False
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def show_menu():
|
| 85 |
+
print(f"\n=== 校園謎案調查局:{CASE_TITLE} ===")
|
| 86 |
+
print("1. 調查六七年七十八九十一班教室")
|
| 87 |
+
print("2. 調查676767這波我挺偉偉的操場")
|
| 88 |
+
print("3. 調查676767競爭者不好玩圖書館")
|
| 89 |
+
print("4. 查看證物袋")
|
| 90 |
+
print("5. 提出推理")
|
| 91 |
+
print("6. 離開")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def main():
|
| 95 |
+
print("午休結束後,校慶展示櫃裡的鑽石棍木67消失了。你能找出真相嗎?")
|
| 96 |
+
|
| 97 |
+
while True:
|
| 98 |
+
show_menu()
|
| 99 |
+
choice = input("請選擇:").strip()
|
| 100 |
+
|
| 101 |
+
if choice in LOCATIONS:
|
| 102 |
+
investigate(choice)
|
| 103 |
+
elif choice == "4":
|
| 104 |
+
show_evidence()
|
| 105 |
+
elif choice == "5":
|
| 106 |
+
if make_deduction():
|
| 107 |
+
break
|
| 108 |
+
elif choice == "6":
|
| 109 |
+
print("案件暫停。證物袋已交回調查局。")
|
| 110 |
+
break
|
| 111 |
+
else:
|
| 112 |
+
print("請輸入 1~6。")
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
if __name__ == "__main__":
|
| 116 |
+
main()
|
classroom_data/repairs/L6Sat/week02/胡惟媗.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Week 2:校園謎案調查局 Starter。
|
| 2 |
+
|
| 3 |
+
先完成 TODO 1~4。程式必須能反覆探索、收集線索、查看證物並提出推理。
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
CASE_TITLE = "停止の時間(?"
|
| 7 |
+
|
| 8 |
+
LOCATIONS = {
|
| 9 |
+
"1": {
|
| 10 |
+
"name": "日本の地下鉄",
|
| 11 |
+
"scene": "車子裡面空無一人",
|
| 12 |
+
"evidence": "時間一直停在11:59",
|
| 13 |
+
},
|
| 14 |
+
"2": {
|
| 15 |
+
"name": "座位隔壁",
|
| 16 |
+
"scene": "有一張照片",
|
| 17 |
+
"evidence": "是自己和過世的母親的照片",
|
| 18 |
+
},
|
| 19 |
+
"3": {
|
| 20 |
+
"name": "駕駛員座椅",
|
| 21 |
+
"scene": "地鐵一直行駛",
|
| 22 |
+
"evidence": "駕駛座沒有人",
|
| 23 |
+
},
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
evidence_bag = []
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def investigate(location_id):
|
| 30 |
+
"""調查地點並將新線索放入證物袋。"""
|
| 31 |
+
|
| 32 |
+
if location_id not in LOCATIONS:
|
| 33 |
+
print("找不到地點 請重找")
|
| 34 |
+
return
|
| 35 |
+
loc = LOCATIONS[location_id]
|
| 36 |
+
# TODO 2:顯示地點名稱與現場描述。
|
| 37 |
+
print(f"地點:{loc['name']}")
|
| 38 |
+
print(f"現場描述:{loc['scene']}")
|
| 39 |
+
# TODO 3:線索尚未取得時才 append 到 evidence_bag。
|
| 40 |
+
evidence = loc["evidence"]
|
| 41 |
+
if evidence not in evidence_bag:
|
| 42 |
+
evidence_bag.append(evidence)
|
| 43 |
+
print(f"取得新線索:{evidence}")
|
| 44 |
+
else:
|
| 45 |
+
print("找不到地點 請重找")
|
| 46 |
+
|
| 47 |
+
def show_evidence():
|
| 48 |
+
"""列出目前取得的所有證物。"""
|
| 49 |
+
# TODO 4:沒有證物時顯示提示;否則以 1 開始編號列出。
|
| 50 |
+
print("證物袋功能還沒完成。")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def make_deduction():
|
| 54 |
+
"""取得足夠證物後,讓玩家選擇真相。"""
|
| 55 |
+
if len(evidence_bag) < 3:
|
| 56 |
+
print(f"證物不足:目前 {len(evidence_bag)}/3。再去調查吧!")
|
| 57 |
+
return False
|
| 58 |
+
|
| 59 |
+
print("\n哪一個說法最符合所有證物?")
|
| 60 |
+
print("1. 有同學故意把校徽藏在圖書館")
|
| 61 |
+
print("2. 風吹起校徽,清潔推車不小心把它帶到圖書館")
|
| 62 |
+
print("3. 校徽從一開始就放在圖書館")
|
| 63 |
+
answer = input("你的推理:").strip()
|
| 64 |
+
|
| 65 |
+
# TODO 5(Checkpoint C):判斷答案,正確時回傳 True。
|
| 66 |
+
print("推理判定還沒完成。")
|
| 67 |
+
return False
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def show_menu():
|
| 71 |
+
print(f"\n=== 校園謎案調查局:{CASE_TITLE} ===")
|
| 72 |
+
print("1. 調查六年一班教室")
|
| 73 |
+
print("2. 調查操場司令台")
|
| 74 |
+
print("3. 調查圖書館")
|
| 75 |
+
print("4. 查看證物袋")
|
| 76 |
+
print("5. 提出推理")
|
| 77 |
+
print("6. 離開")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def main():
|
| 81 |
+
print("午休結束後,校慶展示櫃裡的校徽消失了。你能找出真相嗎?")
|
| 82 |
+
|
| 83 |
+
while True:
|
| 84 |
+
show_menu()
|
| 85 |
+
choice = input("請選擇:").strip()
|
| 86 |
+
|
| 87 |
+
if choice in LOCATIONS:
|
| 88 |
+
investigate(choice)
|
| 89 |
+
elif choice == "4":
|
| 90 |
+
show_evidence()
|
| 91 |
+
elif choice == "5":
|
| 92 |
+
if make_deduction():
|
| 93 |
+
break
|
| 94 |
+
elif choice == "6":
|
| 95 |
+
print("案件暫停。證物袋已交回調查局。")
|
| 96 |
+
break
|
| 97 |
+
else:
|
| 98 |
+
print("請輸入 1~6。")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
if __name__ == "__main__":
|
| 102 |
+
main()
|
classroom_data/repairs/L6Sat/week02/蔡昊儒.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Week 2:校園謎案調查局 Starter。
|
| 2 |
+
|
| 3 |
+
先完成 TODO 1~4。程式必須能反覆探索、收集線索、查看證物並提出推理。
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
CASE_TITLE = "失火的房子"
|
| 7 |
+
|
| 8 |
+
LOCATIONS = {
|
| 9 |
+
"1": {
|
| 10 |
+
"name": "廚房",
|
| 11 |
+
"scene": "烏漆嗎黑",
|
| 12 |
+
"evidence": "有電磁爐爆炸的痕跡",
|
| 13 |
+
},
|
| 14 |
+
"2": {
|
| 15 |
+
"name": "客廳",
|
| 16 |
+
"scene": "物品東倒西歪,有些物品燒焦",
|
| 17 |
+
"evidence": "打火機在地上",
|
| 18 |
+
},
|
| 19 |
+
"3": {
|
| 20 |
+
"name": "書房",
|
| 21 |
+
"scene": "棉被蓋住一個屍體",
|
| 22 |
+
"evidence": "屍體是房子主人的弟弟",
|
| 23 |
+
},
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
evidence_bag = []
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def investigate(location_id):
|
| 30 |
+
"""調查地點並將新線索放入證物袋。"""
|
| 31 |
+
# TODO 1:確認 location_id 是否存在於 LOCATIONS。
|
| 32 |
+
str_id = str(location_id)
|
| 33 |
+
is_in_locations = False
|
| 34 |
+
for i in LOCATIONS:
|
| 35 |
+
if str_id == i:
|
| 36 |
+
is_in_locations = True
|
| 37 |
+
break
|
| 38 |
+
if is_in_locations == False:
|
| 39 |
+
return 0
|
| 40 |
+
# TODO 2:顯示地點名稱與現場描述。
|
| 41 |
+
print(LOCATIONS[str_id]["name"])
|
| 42 |
+
print(LOCATIONS[str_id]["scene"])
|
| 43 |
+
# TODO 3:線索尚未取得時才 append 到 evidence_bag。
|
| 44 |
+
if LOCATIONS[str_id]["evidence"] not in evidence_bag:
|
| 45 |
+
evidence_bag.append(LOCATIONS[str_id]["evidence"])
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def show_evidence():
|
| 49 |
+
"""列出目前取得的所有證物。"""
|
| 50 |
+
# TODO 4:沒有證物時顯示提示;否則以 1 開始編號列出。
|
| 51 |
+
if evidence_bag == []:
|
| 52 |
+
print("there is nothing in the bag bro")
|
| 53 |
+
else:
|
| 54 |
+
for index, evidence in enumerate(evidence_bag, 0):
|
| 55 |
+
print(f"Clue {index+1}: {evidence}")
|
| 56 |
+
|
| 57 |
+
print("證物袋功能還沒完成。")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def make_deduction():
|
| 61 |
+
"""取得足夠證物後,讓玩家選擇真相。"""
|
| 62 |
+
if len(evidence_bag) < 3:
|
| 63 |
+
print(f"證物不足:目前 {len(evidence_bag)}/3。再去調查吧!")
|
| 64 |
+
return False
|
| 65 |
+
|
| 66 |
+
print("\n哪一個說法最符合所有證物?")
|
| 67 |
+
print("1. 主人煮飯搞砸了")
|
| 68 |
+
print("2. 弟弟煮飯時去睡覺")
|
| 69 |
+
print("3. 主人用打火機點燃廚房,害死弟弟")
|
| 70 |
+
answer = input("你的推理:").strip()
|
| 71 |
+
|
| 72 |
+
# TODO 5(Checkpoint C):判斷答案,正確時回傳 True。
|
| 73 |
+
if answer == "2":
|
| 74 |
+
print("success")
|
| 75 |
+
return True
|
| 76 |
+
print("推理判定還沒完成。")
|
| 77 |
+
return False
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def show_menu():
|
| 81 |
+
print(f"\n=== 校園謎案調查局:{CASE_TITLE} ===")
|
| 82 |
+
print("1. 調查六年一班教室")
|
| 83 |
+
print("2. 調查操場司令台")
|
| 84 |
+
print("3. 調查圖書館")
|
| 85 |
+
print("4. 查看證物袋")
|
| 86 |
+
print("5. 提出推理")
|
| 87 |
+
print("6. 離開")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def main():
|
| 91 |
+
print("午休結束後,校慶展示櫃裡的校徽消失了。你能找出真相嗎?")
|
| 92 |
+
|
| 93 |
+
while True:
|
| 94 |
+
show_menu()
|
| 95 |
+
choice = input("請選擇:").strip()
|
| 96 |
+
|
| 97 |
+
if choice in LOCATIONS:
|
| 98 |
+
investigate(choice)
|
| 99 |
+
elif choice == "4":
|
| 100 |
+
show_evidence()
|
| 101 |
+
elif choice == "5":
|
| 102 |
+
if make_deduction():
|
| 103 |
+
break
|
| 104 |
+
elif choice == "6":
|
| 105 |
+
print("案件暫停。證物袋已交回調查局。")
|
| 106 |
+
break
|
| 107 |
+
else:
|
| 108 |
+
print("請輸入 1~6。")
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
if __name__ == "__main__":
|
| 112 |
+
main()
|
classroom_data/templates/week03/case_data.json
DELETED
|
@@ -1,42 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"title": "午夜廣播室",
|
| 3 |
-
"intro": "放學後,校內廣播突然播放一段求救訊息,卻沒有人承認進過廣播室。",
|
| 4 |
-
"actions": {
|
| 5 |
-
"1": {
|
| 6 |
-
"name": "搜索導師桌",
|
| 7 |
-
"before": "抽屜裡有一張被塗改的廣播值日表。",
|
| 8 |
-
"after": "抽屜已經檢查過了。",
|
| 9 |
-
"changes_after": "duty_list",
|
| 10 |
-
"gives": {"id": "duty_list", "name": "被塗改的廣播值日表"}
|
| 11 |
-
},
|
| 12 |
-
"2": {
|
| 13 |
-
"name": "詢問工友",
|
| 14 |
-
"before": "工友搖搖頭:我只看到有人往舊校舍走。",
|
| 15 |
-
"after": "看到值日表後,工友改口:我撿到一張紙條,上面寫著門禁碼 0426。",
|
| 16 |
-
"changes_after": "duty_list",
|
| 17 |
-
"gives_requires": ["duty_list"],
|
| 18 |
-
"gives": {"id": "door_code", "name": "廣播室門禁碼 0426"}
|
| 19 |
-
},
|
| 20 |
-
"3": {
|
| 21 |
-
"name": "進入廣播室",
|
| 22 |
-
"requires": ["door_code"],
|
| 23 |
-
"locked_text": "門鎖需要四位數密碼。也許有人知道密碼的下落。",
|
| 24 |
-
"before": "控制台旁有一台仍在播放的錄音機。",
|
| 25 |
-
"after": "錄音機已經檢查過了。",
|
| 26 |
-
"changes_after": "recording",
|
| 27 |
-
"gives": {"id": "recording", "name": "剪接過的求救錄音"}
|
| 28 |
-
}
|
| 29 |
-
},
|
| 30 |
-
"endings": {
|
| 31 |
-
"1": {
|
| 32 |
-
"label": "指控值日生惡作劇",
|
| 33 |
-
"requires": ["recording"],
|
| 34 |
-
"text": "【誤判結局】錄音雖然是剪接的,但值日表被塗改,不能證明值日生真的進過廣播室。"
|
| 35 |
-
},
|
| 36 |
-
"2": {
|
| 37 |
-
"label": "判斷這是預演錄音誤播",
|
| 38 |
-
"requires": ["duty_list", "door_code", "recording"],
|
| 39 |
-
"text": "【真相結局】求救聲是校慶戲劇預演錄音;工友誤觸了排程播放,怕被責怪才隱瞞紙條。案件破解!"
|
| 40 |
-
}
|
| 41 |
-
}
|
| 42 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
classroom_data/templates/week03/main.py
DELETED
|
@@ -1,98 +0,0 @@
|
|
| 1 |
-
"""Lesson 3:線索會改變世界 Starter。
|
| 2 |
-
|
| 3 |
-
先完成 TODO 1~4。你會讓線索解鎖行動、改變證詞,並產生不同結局。
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
import json
|
| 7 |
-
from pathlib import Path
|
| 8 |
-
|
| 9 |
-
CASE_FILE = Path(__file__).with_name("case_data.json")
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def load_case():
|
| 13 |
-
with CASE_FILE.open("r", encoding="utf-8") as file:
|
| 14 |
-
return json.load(file)
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
def requirements_met(required_ids, clues):
|
| 18 |
-
"""所有 required_ids 都已取得時回傳 True。"""
|
| 19 |
-
# TODO 1:可使用 all(required_id in clues for required_id in required_ids)
|
| 20 |
-
return True
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def choose_text(action, clues):
|
| 24 |
-
"""依目前線索,選擇行動前或行動後的文字。"""
|
| 25 |
-
# TODO 2:若 changes_after 已在 clues,回傳 after;否則回傳 before。
|
| 26 |
-
return action["before"]
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def perform_action(action, clues):
|
| 30 |
-
"""檢查前置條件、顯示內容並加入新線索。"""
|
| 31 |
-
required_ids = action.get("requires", [])
|
| 32 |
-
# TODO 3:條件不足時顯示 locked_text 並直接 return。
|
| 33 |
-
|
| 34 |
-
print(f"\n{choose_text(action, clues)}")
|
| 35 |
-
clue = action.get("gives")
|
| 36 |
-
can_give = requirements_met(action.get("gives_requires", []), clues)
|
| 37 |
-
# TODO 4:clue 存在、can_give 且尚未取得時,加入 clue["id"] 並顯示名稱。
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
def show_clues(clues, case):
|
| 41 |
-
print("\n【調查狀態】")
|
| 42 |
-
if not clues:
|
| 43 |
-
print("尚未取得任何線索。")
|
| 44 |
-
return
|
| 45 |
-
names = {
|
| 46 |
-
action["gives"]["id"]: action["gives"]["name"]
|
| 47 |
-
for action in case["actions"].values()
|
| 48 |
-
if action.get("gives")
|
| 49 |
-
}
|
| 50 |
-
for number, clue_id in enumerate(clues, start=1):
|
| 51 |
-
print(f"{number}. {names.get(clue_id, clue_id)}")
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
def choose_ending(case, clues):
|
| 55 |
-
print("\n【結案判斷】")
|
| 56 |
-
for ending_id, ending in case["endings"].items():
|
| 57 |
-
print(f"{ending_id}. {ending['label']}")
|
| 58 |
-
choice = input("你的判斷:").strip()
|
| 59 |
-
ending = case["endings"].get(choice)
|
| 60 |
-
if ending is None:
|
| 61 |
-
print("沒有這個結局選項。")
|
| 62 |
-
return False
|
| 63 |
-
if not requirements_met(ending.get("requires", []), clues):
|
| 64 |
-
print("目前的線索還不足以支持這個判斷。")
|
| 65 |
-
return False
|
| 66 |
-
print(ending["text"])
|
| 67 |
-
return True
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
def main():
|
| 71 |
-
case = load_case()
|
| 72 |
-
clues = []
|
| 73 |
-
print(f"=== {case['title']} ===")
|
| 74 |
-
print(case["intro"])
|
| 75 |
-
while True:
|
| 76 |
-
print("\n你要做什麼?")
|
| 77 |
-
for action_id, action in case["actions"].items():
|
| 78 |
-
print(f"{action_id}. {action['name']}")
|
| 79 |
-
print("4. 查看調查狀態")
|
| 80 |
-
print("5. 提出結案判斷")
|
| 81 |
-
print("6. 暫停案件")
|
| 82 |
-
choice = input("請選擇:").strip()
|
| 83 |
-
if choice in case["actions"]:
|
| 84 |
-
perform_action(case["actions"][choice], clues)
|
| 85 |
-
elif choice == "4":
|
| 86 |
-
show_clues(clues, case)
|
| 87 |
-
elif choice == "5":
|
| 88 |
-
if choose_ending(case, clues):
|
| 89 |
-
break
|
| 90 |
-
elif choice == "6":
|
| 91 |
-
print("案件已暫停。")
|
| 92 |
-
break
|
| 93 |
-
else:
|
| 94 |
-
print("請輸入畫面上的選項。")
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
if __name__ == "__main__":
|
| 98 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
classroom_service.py
CHANGED
|
@@ -7,7 +7,7 @@ from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
|
| 7 |
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
| 8 |
from pydantic import BaseModel
|
| 9 |
|
| 10 |
-
ROOT=Path(__file__).resolve().parent; STATIC=ROOT/"classroom_static"; DATA=ROOT/"classroom_data"; TEMPLATES=DATA/"templates"; PUBLISHED=ROOT/"projects"
|
| 11 |
RUNTIME=Path(os.getenv("CLASSROOM_DATA_DIR","/tmp/telearn_classroom")); DB=RUNTIME/"classroom.sqlite3"
|
| 12 |
PASSWORD=os.getenv("CLASSROOM_TEACHER_PASSWORD",""); REPO=os.getenv("CLASSROOM_DEMO_REPO",os.getenv("SPACE_ID","Telearn/Project_Demo")).strip(); DEMO_CLASS=os.getenv("CLASSROOM_DEMO_CLASS","2026Fall進階專題"); TOKEN=os.getenv("HF_TOKEN","").strip()
|
| 13 |
ALLOWED={".py",".json",".txt",".csv",".md",".toml",".yaml",".yml"}; ID_RE=re.compile(r"[^0-9A-Za-z_\-一-鿿]")
|
|
@@ -39,6 +39,7 @@ def init_db():
|
|
| 39 |
c.execute("""CREATE TABLE IF NOT EXISTS classroom_projects(class_code TEXT,student_id TEXT,display_name TEXT,week_id TEXT,project_name TEXT,draft_files TEXT,final_files TEXT,active_file TEXT,output TEXT,status TEXT,needs_help INTEGER,updated_at INTEGER,submitted_at INTEGER,PRIMARY KEY(class_code,student_id,week_id))""")
|
| 40 |
c.execute("""CREATE TABLE IF NOT EXISTS classroom_courses(class_code TEXT PRIMARY KEY,course_name TEXT,created_at INTEGER)""")
|
| 41 |
c.execute("""CREATE TABLE IF NOT EXISTS classroom_members(class_code TEXT,student_id TEXT,display_name TEXT,created_at INTEGER,PRIMARY KEY(class_code,student_id))""")
|
|
|
|
| 42 |
for item in json.loads((DATA/"classes.json").read_text(encoding="utf-8")):
|
| 43 |
code=clean(item.get("code"),"L6A")
|
| 44 |
c.execute("""INSERT INTO classroom_courses VALUES(?,?,?) ON CONFLICT(class_code) DO UPDATE SET course_name=excluded.course_name""",(code,clean_label(item.get("name"),code),now()))
|
|
@@ -56,6 +57,17 @@ def init_db():
|
|
| 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"
|
|
@@ -113,7 +125,9 @@ async def broadcast(c,w):
|
|
| 113 |
def template_files(w):
|
| 114 |
root=TEMPLATES/clean(w,"week01")
|
| 115 |
if not root.is_dir():raise HTTPException(404,"這個 Lesson 尚未發布套版")
|
| 116 |
-
|
|
|
|
|
|
|
| 117 |
def published_progress(display_name):
|
| 118 |
student_root=PUBLISHED/DEMO_CLASS/clean(display_name,"student"); found=[]
|
| 119 |
if not student_root.is_dir():return found
|
|
|
|
| 7 |
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
| 8 |
from pydantic import BaseModel
|
| 9 |
|
| 10 |
+
ROOT=Path(__file__).resolve().parent; STATIC=ROOT/"classroom_static"; DATA=ROOT/"classroom_data"; TEMPLATES=DATA/"templates"; REPAIRS=DATA/"repairs"; PUBLISHED=ROOT/"projects"
|
| 11 |
RUNTIME=Path(os.getenv("CLASSROOM_DATA_DIR","/tmp/telearn_classroom")); DB=RUNTIME/"classroom.sqlite3"
|
| 12 |
PASSWORD=os.getenv("CLASSROOM_TEACHER_PASSWORD",""); REPO=os.getenv("CLASSROOM_DEMO_REPO",os.getenv("SPACE_ID","Telearn/Project_Demo")).strip(); DEMO_CLASS=os.getenv("CLASSROOM_DEMO_CLASS","2026Fall進階專題"); TOKEN=os.getenv("HF_TOKEN","").strip()
|
| 13 |
ALLOWED={".py",".json",".txt",".csv",".md",".toml",".yaml",".yml"}; ID_RE=re.compile(r"[^0-9A-Za-z_\-一-鿿]")
|
|
|
|
| 39 |
c.execute("""CREATE TABLE IF NOT EXISTS classroom_projects(class_code TEXT,student_id TEXT,display_name TEXT,week_id TEXT,project_name TEXT,draft_files TEXT,final_files TEXT,active_file TEXT,output TEXT,status TEXT,needs_help INTEGER,updated_at INTEGER,submitted_at INTEGER,PRIMARY KEY(class_code,student_id,week_id))""")
|
| 40 |
c.execute("""CREATE TABLE IF NOT EXISTS classroom_courses(class_code TEXT PRIMARY KEY,course_name TEXT,created_at INTEGER)""")
|
| 41 |
c.execute("""CREATE TABLE IF NOT EXISTS classroom_members(class_code TEXT,student_id TEXT,display_name TEXT,created_at INTEGER,PRIMARY KEY(class_code,student_id))""")
|
| 42 |
+
c.execute("""CREATE TABLE IF NOT EXISTS classroom_migrations(migration_id TEXT PRIMARY KEY,applied_at INTEGER)""")
|
| 43 |
for item in json.loads((DATA/"classes.json").read_text(encoding="utf-8")):
|
| 44 |
code=clean(item.get("code"),"L6A")
|
| 45 |
c.execute("""INSERT INTO classroom_courses VALUES(?,?,?) ON CONFLICT(class_code) DO UPDATE SET course_name=excluded.course_name""",(code,clean_label(item.get("name"),code),now()))
|
|
|
|
| 57 |
c.execute("INSERT OR IGNORE INTO classroom_courses VALUES(?,?,?)",(code,code,now()))
|
| 58 |
c.execute("INSERT OR IGNORE INTO classroom_members VALUES(?,?,?,?)",(code,student_id,name,now()))
|
| 59 |
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))
|
| 60 |
+
migration_id="lesson2_syntax_repair_20260916"
|
| 61 |
+
if not c.execute("SELECT 1 FROM classroom_migrations WHERE migration_id=?",(migration_id,)).fetchone():
|
| 62 |
+
for repair in REPAIRS.glob("*/*/*.py") if REPAIRS.is_dir() else []:
|
| 63 |
+
class_code,week_id=repair.parts[-3],repair.parts[-2];student_id=repair.stem
|
| 64 |
+
row=c.execute("SELECT draft_files,final_files FROM classroom_projects WHERE class_code=? AND student_id=? AND week_id=?",(class_code,student_id,week_id)).fetchone()
|
| 65 |
+
if not row:continue
|
| 66 |
+
draft=json.loads(row[0]);final=json.loads(row[1]) if row[1] else None;fixed=repair.read_text(encoding="utf-8")
|
| 67 |
+
draft["main.py"]=fixed
|
| 68 |
+
if final is not None:final["main.py"]=fixed
|
| 69 |
+
c.execute("UPDATE classroom_projects SET draft_files=?,final_files=?,updated_at=? WHERE class_code=? AND student_id=? AND week_id=?",(json.dumps(draft,ensure_ascii=False),json.dumps(final,ensure_ascii=False) if final is not None else None,now(),class_code,student_id,week_id))
|
| 70 |
+
c.execute("INSERT INTO classroom_migrations VALUES(?,?)",(migration_id,now()))
|
| 71 |
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}
|
| 72 |
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}
|
| 73 |
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"
|
|
|
|
| 125 |
def template_files(w):
|
| 126 |
root=TEMPLATES/clean(w,"week01")
|
| 127 |
if not root.is_dir():raise HTTPException(404,"這個 Lesson 尚未發布套版")
|
| 128 |
+
files={p.relative_to(root).as_posix():p.read_text(encoding="utf-8") for p in root.rglob("*") if p.is_file() and p.suffix.lower() in ALLOWED}
|
| 129 |
+
if not files:raise HTTPException(404,"這個 Lesson 尚未發布套版")
|
| 130 |
+
return norm_files(files)
|
| 131 |
def published_progress(display_name):
|
| 132 |
student_root=PUBLISHED/DEMO_CLASS/clean(display_name,"student"); found=[]
|
| 133 |
if not student_root.is_dir():return found
|
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">Lesson</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">匯入
|
| 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">目前
|
| 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>匯入後會成為目前
|
| 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-
|
|
|
|
| 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">Lesson</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-7"></script></body></html>
|
classroom_static/student.js
CHANGED
|
@@ -3,29 +3,53 @@ const $=id=>document.getElementById(id),E={};["identityDialog","identityForm","i
|
|
| 3 |
const editor=CodeMirror.fromTextArea($("codeEditor"),{mode:"python",theme:"material-darker",lineNumbers:true,indentUnit:4,tabSize:4,extraKeys:{Tab:cm=>cm.replaceSelection(" ")}});
|
| 4 |
let studentId=localStorage.getItem("classroom_student_id")||"",catalog={courses:[]};
|
| 5 |
let course,files={"main.py":"# 在這裡寫 Python\n"},activeFile="main.py",socket,worker,workerReady=false,running=false,needsHelp=false,submittedAt=null,output="",suppress=false,timer,reconnect,category="全部",inputBuffer,inputControl,inputBytes,stdoutDecoder=new TextDecoder(),stderrDecoder=new TextDecoder();
|
| 6 |
-
const clean=v=>String(v||"").trim().replace(/[^0-9A-Za-z_\-一-鿿]/g,"").slice(0,48),
|
| 7 |
function toast(t){E.toast.textContent=t;E.toast.classList.add("visible");setTimeout(()=>E.toast.classList.remove("visible"),1900)}
|
| 8 |
function send(m){if(socket?.readyState===WebSocket.OPEN)socket.send(JSON.stringify(m))}
|
| 9 |
function esc(t){return String(t).replaceAll("&","&").replaceAll("<","<").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||"";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(){
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
function initWorker(){worker?.terminate();workerReady=false;running=false;E.consoleInputForm.hidden=true;E.runButton.disabled=true;E.runButton.textContent="載入 Python…";if(!crossOriginIsolated||typeof SharedArrayBuffer==="undefined"){append("瀏覽器未啟用互動式 Python。請重新整理,並使用最新版 Chrome、Edge 或 Safari。\n");runState("error","環境不支援");return}inputBuffer=new SharedArrayBuffer(16392);inputControl=new Int32Array(inputBuffer,0,2);inputBytes=new Uint8Array(inputBuffer,8);worker=new Worker("/classroom-assets/py-worker.js?v=2026fall-4");worker.onmessage=e=>{const m=e.data;if(m.type==="ready"){workerReady=true;E.runButton.disabled=false;E.runButton.textContent="執行";runState("online","準備完成")}else if(m.type==="stream"){const decoder=m.channel==="stderr"?stderrDecoder:stdoutDecoder;append(decoder.decode(m.bytes,{stream:true}))}else if(m.type==="input_request"){E.consoleInputForm.hidden=false;E.consoleInput.value="";E.runStatusText.textContent="等待輸入";E.consoleInput.focus()}else if(m.type==="done"){E.consoleInputForm.hidden=true;runState("success","執行成功")}else if(m.type==="python_error"){E.consoleInputForm.hidden=true;if(m.text)append(`${m.text}\n`);runState("error","程式錯誤")}else if(m.type==="load_error"){append(`Python 載入失敗:${m.text}\n`);runState("error","載入失敗")}};worker.onerror=e=>{append(`Python 執行器錯誤:${e.message}\n`);runState("error","執行器錯誤")}}
|
| 18 |
function runState(s,t){running=s==="running";E.runStatusDot.className=`status-dot ${s}`;E.runStatusText.textContent=t;E.runButton.disabled=!workerReady||running;E.stopButton.disabled=!running;send({type:"status",status:s==="online"?"idle":s})}
|
| 19 |
function append(t){output=(output+t).slice(-40000);E.consoleOutput.textContent=output;E.consoleOutput.scrollTop=E.consoleOutput.scrollHeight;saveLocal();send({type:"output",output})}
|
| 20 |
function renderSyntax(){const cats=["全部",...new Set(SNIPPETS.map(s=>s.category))];E.categoryFilters.innerHTML=cats.map(c=>`<button class="chip" data-cat="${c}" aria-pressed="${c===category}">${c}</button>`).join("");E.categoryFilters.querySelectorAll("button").forEach(b=>b.onclick=()=>{category=b.dataset.cat;renderSyntax()});const q=E.syntaxSearch.value.toLowerCase(),list=SNIPPETS.filter(s=>(category==="全部"||s.category===category)&&(`${s.title} ${s.code}`.toLowerCase().includes(q)));E.snippetCount.textContent=`${list.length} 項`;E.snippetList.innerHTML=list.map(s=>`<article class="snippet-item"><div class="snippet-title"><strong>${s.title}</strong><span>${s.category}</span></div><pre>${esc(s.code)}</pre><button class="copy-button" data-copy="${SNIPPETS.indexOf(s)}">複製範例</button></article>`).join("")||'<div class="snippet-empty">找不到符合的語法。</div>';E.snippetList.querySelectorAll("[data-copy]").forEach(b=>b.onclick=async()=>{const s=SNIPPETS[+b.dataset.copy];await navigator.clipboard.writeText(s.code);toast(`已複製:${s.title}`)})}
|
| 21 |
async function loadCatalog(){catalog=await(await fetch("/api/classroom/classes")).json();const saved=localStorage.getItem("classroom_code");E.classCode.innerHTML=catalog.courses.map(c=>`<option value="${esc(c.class_code)}">${esc(c.course_name)}|${esc(c.class_code)}</option>`).join("");E.classCode.value=catalog.courses.some(c=>c.class_code===saved)?saved:(catalog.courses[0]?.class_code||"");E.displayName.value=localStorage.getItem("classroom_name")||""}
|
| 22 |
-
E.identityForm.onsubmit=async e=>{e.preventDefault();const c=clean(E.classCode.value),n=E.displayName.value.trim(),button=E.identityForm.querySelector("button[type=submit]");if(!c||!n){E.identityError.textContent="請選擇課程並輸入姓名";return}button.disabled=true;button.textContent="驗證名單中…";E.identityError.textContent="";try{const response=await fetch("/api/classroom/student-login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({class_code:c,display_name:n})});const data=await response.json();if(!response.ok)throw Error(data.detail||"無法驗證名單");studentId=data.student_id;localStorage.setItem("classroom_student_id",studentId);localStorage.setItem("classroom_code",c);localStorage.setItem("classroom_name",data.display_name);E.identityDialog.close();connect()}catch(error){E.identityError.textContent=error.message}finally{button.disabled=false;button.textContent="進入教室"}};E.identityButton.onclick=async()=>{await loadCatalog();E.identityDialog.showModal()};E.weekSelect.onchange=()=>{localStorage.setItem("classroom_week",
|
| 23 |
E.newFileButton.onclick=()=>{const p=(prompt("新檔名(例如 clues.py):")||"").trim();if(!/^[\w\-\u4e00-\u9fff/]+\.(py|json|txt|csv|md)$/.test(p)||p.includes(".."))return toast("檔名不合規格");if(p in files)return toast("檔案已存在");files[p]=p.endsWith(".py")?"# 新的 Python 模組\n":"";openFile(p);sync()};
|
| 24 |
-
E.templateButton.onclick=async()=>{if(!confirm("匯入套版會取代目前內容,繼續?"))return;const r=await fetch(`/api/classroom/template/${
|
| 25 |
E.progressButton.onclick=async()=>{const q=new URLSearchParams({class_code:localStorage.getItem("classroom_code")||"",student_id:studentId,display_name:localStorage.getItem("classroom_name")||""}),r=await fetch(`/api/classroom/progress?${q}`),d=await r.json();E.progressList.innerHTML=d.progress.length?d.progress.map((p,i)=>{const item=course.weeks.find(w=>w.id===p.week_id),label=item?`Lesson ${item.lesson??item.week}`:p.week_id;return `<button class="progress-item" data-i="${i}"><strong>${esc(label)}|${esc(p.project_name)}</strong><span>${p.source==="published"?"已發布 ":""}${new Date(p.submitted_at).toLocaleString("zh-TW")}</span></button>`}).join(""):'<p>還沒有正式版本。</p>';E.progressList.querySelectorAll("[data-i]").forEach(b=>b.onclick=()=>{const p=d.progress[+b.dataset.i];setProject(p.files,p.project_name);E.progressDialog.close()});E.progressDialog.showModal()};document.querySelectorAll(".dialog-close").forEach(b=>b.onclick=()=>b.closest("dialog").close());
|
| 26 |
E.importButton.onclick=()=>E.fileImport.click();E.fileImport.onchange=async()=>{const f=E.fileImport.files[0];if(!f)return;if(f.name.endsWith(".py"))setProject({"main.py":await f.text()},f.name.replace(/\.py$/,"").trim());else{const z=await JSZip.loadAsync(f),next={};for(const [p,item] of Object.entries(z.files))if(!item.dir&&/\.(py|json|txt|csv|md)$/i.test(p)&&!p.includes(".."))next[p]=await item.async("string");setProject(next,f.name.replace(/\.zip$/i,""))}E.fileImport.value="";toast("已匯入專案")};
|
| 27 |
E.exportButton.onclick=async()=>{files[activeFile]=editor.getValue();const z=new JSZip();Object.entries(files).forEach(([p,t])=>z.file(p,t));const blob=await z.generateAsync({type:"blob"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=`${clean(E.projectName.value)||"project"}.zip`;a.click();URL.revokeObjectURL(a.href)};
|
| 28 |
-
E.submitButton.onclick=()=>{files[activeFile]=editor.getValue();if(!files["main.py"].trim())return toast("main.py 不能為空");if(!confirm("設為目前
|
| 29 |
E.runButton.onclick=()=>{if(!workerReady||running)return;files[activeFile]=editor.getValue();output="";E.consoleOutput.textContent="";E.consoleInputForm.hidden=true;stdoutDecoder=new TextDecoder();stderrDecoder=new TextDecoder();Atomics.store(inputControl,0,0);runState("running","執行中");worker.postMessage({type:"run",files,entry:"main.py",inputBuffer})};E.consoleInputForm.onsubmit=e=>{e.preventDefault();if(!running||E.consoleInputForm.hidden)return;const encoded=new TextEncoder().encode(E.consoleInput.value);if(encoded.length>inputBytes.length)return toast("單次輸入內容太長");inputBytes.fill(0);inputBytes.set(encoded);Atomics.store(inputControl,1,encoded.length);append(`${E.consoleInput.value}\n`);E.consoleInputForm.hidden=true;E.runStatusText.textContent="執行中";Atomics.store(inputControl,0,1);Atomics.notify(inputControl,0,1)};E.stopButton.onclick=()=>{append("\n[已停止]\n");runState("idle","已停止");initWorker()};E.clearOutputButton.onclick=()=>{output="";E.consoleOutput.textContent="尚無執行結果。";send({type:"output",output:""})};E.syntaxSearch.oninput=renderSyntax;editor.on("change",()=>{if(!suppress){files[activeFile]=editor.getValue();sync()}});
|
| 30 |
document.querySelectorAll(".side-tab").forEach(b=>b.onclick=()=>{document.querySelectorAll(".side-tab").forEach(x=>x.classList.toggle("active",x===b));document.querySelectorAll(".side-view").forEach(v=>v.hidden=v.id!==b.dataset.side)});document.querySelectorAll(".mobile-tab").forEach(b=>b.onclick=()=>{document.querySelectorAll(".mobile-tab").forEach(x=>x.setAttribute("aria-selected",String(x===b)));document.querySelectorAll(".student-layout>.panel").forEach(p=>p.classList.toggle("mobile-active",p.id===b.dataset.panel));if(b.dataset.panel==="editorPanel")setTimeout(()=>editor.refresh(),0)});setInterval(()=>send({type:"ping"}),4000);
|
| 31 |
-
(async()=>{[course]=await Promise.all([(await fetch("/api/classroom/course")).json(),loadCatalog()]);E.weekSelect.innerHTML=course.weeks.map(w=>`<option value="${w.id}">Lesson ${w.lesson??w.week}|${esc(w.title)}${w.
|
|
|
|
| 3 |
const editor=CodeMirror.fromTextArea($("codeEditor"),{mode:"python",theme:"material-darker",lineNumbers:true,indentUnit:4,tabSize:4,extraKeys:{Tab:cm=>cm.replaceSelection(" ")}});
|
| 4 |
let studentId=localStorage.getItem("classroom_student_id")||"",catalog={courses:[]};
|
| 5 |
let course,files={"main.py":"# 在這裡寫 Python\n"},activeFile="main.py",socket,worker,workerReady=false,running=false,needsHelp=false,submittedAt=null,output="",suppress=false,timer,reconnect,category="全部",inputBuffer,inputControl,inputBytes,stdoutDecoder=new TextDecoder(),stderrDecoder=new TextDecoder();
|
| 6 |
+
const clean=v=>String(v||"").trim().replace(/[^0-9A-Za-z_\-一-鿿]/g,"").slice(0,48),lesson=()=>E.weekSelect.value||"week02",lessonInfo=()=>course?.weeks.find(item=>item.id===lesson()),workspace=()=>lessonInfo()?.workspace_id||lesson(),key=()=>`classroom_project_${localStorage.getItem("classroom_code")}_${workspace()}`;
|
| 7 |
function toast(t){E.toast.textContent=t;E.toast.classList.add("visible");setTimeout(()=>E.toast.classList.remove("visible"),1900)}
|
| 8 |
function send(m){if(socket?.readyState===WebSocket.OPEN)socket.send(JSON.stringify(m))}
|
| 9 |
function esc(t){return String(t).replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""")}
|
| 10 |
+
function saveLocal(){localStorage.setItem(key(),JSON.stringify({files,activeFile,projectName:E.projectName.value,output,savedAt:Date.now()}))}
|
| 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(){
|
| 16 |
+
clearTimeout(reconnect);
|
| 17 |
+
const previous=socket;
|
| 18 |
+
if(previous){previous.onclose=null;previous.close()}
|
| 19 |
+
const c=clean(localStorage.getItem("classroom_code")),n=localStorage.getItem("classroom_name")?.trim();
|
| 20 |
+
if(!c||!n||!studentId){E.identityDialog.showModal();return}
|
| 21 |
+
E.studentIdentity.textContent=`${c} · ${n}`;E.connectionText.textContent="連線中";
|
| 22 |
+
const scheme=location.protocol==="https:"?"wss":"ws";
|
| 23 |
+
const q=new URLSearchParams({class_code:c,student_id:studentId,display_name:n,week_id:workspace()});
|
| 24 |
+
const current=new WebSocket(`${scheme}://${location.host}/ws/classroom/student?${q}`);socket=current;
|
| 25 |
+
current.onopen=()=>{E.connectionDot.className="status-dot online";E.connectionText.textContent="已連線"};
|
| 26 |
+
current.onmessage=e=>{
|
| 27 |
+
const m=JSON.parse(e.data);
|
| 28 |
+
if(m.type==="restore"){
|
| 29 |
+
const local=JSON.parse(localStorage.getItem(key())||"null"),s=m.state;
|
| 30 |
+
const server={files:s.files,activeFile:s.active_file,projectName:s.project_name,output:s.output};
|
| 31 |
+
const src=local&&Number(local.savedAt||0)>Number(s.updated_at||0)?local:server;
|
| 32 |
+
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});
|
| 33 |
+
}
|
| 34 |
+
if(m.type==="help_ack"){needsHelp=false;renderState();toast("老師已看到你的協助請求")}
|
| 35 |
+
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="已同步"}
|
| 36 |
+
};
|
| 37 |
+
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)};
|
| 38 |
+
}
|
| 39 |
+
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"})}`:"目前專案尚未提交"}
|
| 40 |
+
function renderLesson(){const item=lessonInfo(),shared=item?.workspace_id&&item.workspace_id!==item.id;E.templateButton.disabled=!item?.template;E.templateButton.textContent=item?.template?"匯入專案起始套版":shared?"沿用目前專案":"尚未提供套版"}
|
| 41 |
function initWorker(){worker?.terminate();workerReady=false;running=false;E.consoleInputForm.hidden=true;E.runButton.disabled=true;E.runButton.textContent="載入 Python…";if(!crossOriginIsolated||typeof SharedArrayBuffer==="undefined"){append("瀏覽器未啟用互動式 Python。請重新整理,並使用最新版 Chrome、Edge 或 Safari。\n");runState("error","環境不支援");return}inputBuffer=new SharedArrayBuffer(16392);inputControl=new Int32Array(inputBuffer,0,2);inputBytes=new Uint8Array(inputBuffer,8);worker=new Worker("/classroom-assets/py-worker.js?v=2026fall-4");worker.onmessage=e=>{const m=e.data;if(m.type==="ready"){workerReady=true;E.runButton.disabled=false;E.runButton.textContent="執行";runState("online","準備完成")}else if(m.type==="stream"){const decoder=m.channel==="stderr"?stderrDecoder:stdoutDecoder;append(decoder.decode(m.bytes,{stream:true}))}else if(m.type==="input_request"){E.consoleInputForm.hidden=false;E.consoleInput.value="";E.runStatusText.textContent="等待輸入";E.consoleInput.focus()}else if(m.type==="done"){E.consoleInputForm.hidden=true;runState("success","執行成功")}else if(m.type==="python_error"){E.consoleInputForm.hidden=true;if(m.text)append(`${m.text}\n`);runState("error","程式錯誤")}else if(m.type==="load_error"){append(`Python 載入失敗:${m.text}\n`);runState("error","載入失敗")}};worker.onerror=e=>{append(`Python 執行器錯誤:${e.message}\n`);runState("error","執行器錯誤")}}
|
| 42 |
function runState(s,t){running=s==="running";E.runStatusDot.className=`status-dot ${s}`;E.runStatusText.textContent=t;E.runButton.disabled=!workerReady||running;E.stopButton.disabled=!running;send({type:"status",status:s==="online"?"idle":s})}
|
| 43 |
function append(t){output=(output+t).slice(-40000);E.consoleOutput.textContent=output;E.consoleOutput.scrollTop=E.consoleOutput.scrollHeight;saveLocal();send({type:"output",output})}
|
| 44 |
function renderSyntax(){const cats=["全部",...new Set(SNIPPETS.map(s=>s.category))];E.categoryFilters.innerHTML=cats.map(c=>`<button class="chip" data-cat="${c}" aria-pressed="${c===category}">${c}</button>`).join("");E.categoryFilters.querySelectorAll("button").forEach(b=>b.onclick=()=>{category=b.dataset.cat;renderSyntax()});const q=E.syntaxSearch.value.toLowerCase(),list=SNIPPETS.filter(s=>(category==="全部"||s.category===category)&&(`${s.title} ${s.code}`.toLowerCase().includes(q)));E.snippetCount.textContent=`${list.length} 項`;E.snippetList.innerHTML=list.map(s=>`<article class="snippet-item"><div class="snippet-title"><strong>${s.title}</strong><span>${s.category}</span></div><pre>${esc(s.code)}</pre><button class="copy-button" data-copy="${SNIPPETS.indexOf(s)}">複製範例</button></article>`).join("")||'<div class="snippet-empty">找不到符合的語法。</div>';E.snippetList.querySelectorAll("[data-copy]").forEach(b=>b.onclick=async()=>{const s=SNIPPETS[+b.dataset.copy];await navigator.clipboard.writeText(s.code);toast(`已複製:${s.title}`)})}
|
| 45 |
async function loadCatalog(){catalog=await(await fetch("/api/classroom/classes")).json();const saved=localStorage.getItem("classroom_code");E.classCode.innerHTML=catalog.courses.map(c=>`<option value="${esc(c.class_code)}">${esc(c.course_name)}|${esc(c.class_code)}</option>`).join("");E.classCode.value=catalog.courses.some(c=>c.class_code===saved)?saved:(catalog.courses[0]?.class_code||"");E.displayName.value=localStorage.getItem("classroom_name")||""}
|
| 46 |
+
E.identityForm.onsubmit=async e=>{e.preventDefault();const c=clean(E.classCode.value),n=E.displayName.value.trim(),button=E.identityForm.querySelector("button[type=submit]");if(!c||!n){E.identityError.textContent="請選擇課程並輸入姓名";return}button.disabled=true;button.textContent="驗證名單中…";E.identityError.textContent="";try{const response=await fetch("/api/classroom/student-login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({class_code:c,display_name:n})});const data=await response.json();if(!response.ok)throw Error(data.detail||"無法驗證名單");studentId=data.student_id;localStorage.setItem("classroom_student_id",studentId);localStorage.setItem("classroom_code",c);localStorage.setItem("classroom_name",data.display_name);E.identityDialog.close();connect()}catch(error){E.identityError.textContent=error.message}finally{button.disabled=false;button.textContent="進入教室"}};E.identityButton.onclick=async()=>{await loadCatalog();E.identityDialog.showModal()};E.weekSelect.onchange=()=>{localStorage.setItem("classroom_week",lesson());renderLesson();connect()};E.projectName.oninput=sync;
|
| 47 |
E.newFileButton.onclick=()=>{const p=(prompt("新檔名(例如 clues.py):")||"").trim();if(!/^[\w\-\u4e00-\u9fff/]+\.(py|json|txt|csv|md)$/.test(p)||p.includes(".."))return toast("檔名不合規格");if(p in files)return toast("檔案已存在");files[p]=p.endsWith(".py")?"# 新的 Python 模組\n":"";openFile(p);sync()};
|
| 48 |
+
E.templateButton.onclick=async()=>{const item=lessonInfo();if(!item?.template)return;if(!confirm("匯入起始套版會取代目前專案內容,繼續?"))return;const r=await fetch(`/api/classroom/template/${item.id}`);if(!r.ok)return toast("這個專案尚未發布套版");const d=await r.json();setProject(d.files,item.project);toast("已匯入專案起始套版")};
|
| 49 |
E.progressButton.onclick=async()=>{const q=new URLSearchParams({class_code:localStorage.getItem("classroom_code")||"",student_id:studentId,display_name:localStorage.getItem("classroom_name")||""}),r=await fetch(`/api/classroom/progress?${q}`),d=await r.json();E.progressList.innerHTML=d.progress.length?d.progress.map((p,i)=>{const item=course.weeks.find(w=>w.id===p.week_id),label=item?`Lesson ${item.lesson??item.week}`:p.week_id;return `<button class="progress-item" data-i="${i}"><strong>${esc(label)}|${esc(p.project_name)}</strong><span>${p.source==="published"?"已發布 ":""}${new Date(p.submitted_at).toLocaleString("zh-TW")}</span></button>`}).join(""):'<p>還沒有正式版本。</p>';E.progressList.querySelectorAll("[data-i]").forEach(b=>b.onclick=()=>{const p=d.progress[+b.dataset.i];setProject(p.files,p.project_name);E.progressDialog.close()});E.progressDialog.showModal()};document.querySelectorAll(".dialog-close").forEach(b=>b.onclick=()=>b.closest("dialog").close());
|
| 50 |
E.importButton.onclick=()=>E.fileImport.click();E.fileImport.onchange=async()=>{const f=E.fileImport.files[0];if(!f)return;if(f.name.endsWith(".py"))setProject({"main.py":await f.text()},f.name.replace(/\.py$/,"").trim());else{const z=await JSZip.loadAsync(f),next={};for(const [p,item] of Object.entries(z.files))if(!item.dir&&/\.(py|json|txt|csv|md)$/i.test(p)&&!p.includes(".."))next[p]=await item.async("string");setProject(next,f.name.replace(/\.zip$/i,""))}E.fileImport.value="";toast("已匯入專案")};
|
| 51 |
E.exportButton.onclick=async()=>{files[activeFile]=editor.getValue();const z=new JSZip();Object.entries(files).forEach(([p,t])=>z.file(p,t));const blob=await z.generateAsync({type:"blob"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=`${clean(E.projectName.value)||"project"}.zip`;a.click();URL.revokeObjectURL(a.href)};
|
| 52 |
+
E.submitButton.onclick=()=>{files[activeFile]=editor.getValue();if(!files["main.py"].trim())return toast("main.py 不能為空");if(!confirm("設為目前專案的最後正式版本?重新提交會覆蓋前一版。"))return;send({type:"submit",files});submittedAt=Date.now();renderState();toast("專案正式版本已提交")};E.helpButton.onclick=()=>{needsHelp=!needsHelp;renderState();send({type:"help",needs_help:needsHelp})};
|
| 53 |
E.runButton.onclick=()=>{if(!workerReady||running)return;files[activeFile]=editor.getValue();output="";E.consoleOutput.textContent="";E.consoleInputForm.hidden=true;stdoutDecoder=new TextDecoder();stderrDecoder=new TextDecoder();Atomics.store(inputControl,0,0);runState("running","執行中");worker.postMessage({type:"run",files,entry:"main.py",inputBuffer})};E.consoleInputForm.onsubmit=e=>{e.preventDefault();if(!running||E.consoleInputForm.hidden)return;const encoded=new TextEncoder().encode(E.consoleInput.value);if(encoded.length>inputBytes.length)return toast("單次輸入內容太長");inputBytes.fill(0);inputBytes.set(encoded);Atomics.store(inputControl,1,encoded.length);append(`${E.consoleInput.value}\n`);E.consoleInputForm.hidden=true;E.runStatusText.textContent="執行中";Atomics.store(inputControl,0,1);Atomics.notify(inputControl,0,1)};E.stopButton.onclick=()=>{append("\n[已停止]\n");runState("idle","已停止");initWorker()};E.clearOutputButton.onclick=()=>{output="";E.consoleOutput.textContent="尚無執行結果。";send({type:"output",output:""})};E.syntaxSearch.oninput=renderSyntax;editor.on("change",()=>{if(!suppress){files[activeFile]=editor.getValue();sync()}});
|
| 54 |
document.querySelectorAll(".side-tab").forEach(b=>b.onclick=()=>{document.querySelectorAll(".side-tab").forEach(x=>x.classList.toggle("active",x===b));document.querySelectorAll(".side-view").forEach(v=>v.hidden=v.id!==b.dataset.side)});document.querySelectorAll(".mobile-tab").forEach(b=>b.onclick=()=>{document.querySelectorAll(".mobile-tab").forEach(x=>x.setAttribute("aria-selected",String(x===b)));document.querySelectorAll(".student-layout>.panel").forEach(p=>p.classList.toggle("mobile-active",p.id===b.dataset.panel));if(b.dataset.panel==="editorPanel")setTimeout(()=>editor.refresh(),0)});setInterval(()=>send({type:"ping"}),4000);
|
| 55 |
+
(async()=>{[course]=await Promise.all([(await fetch("/api/classroom/course")).json(),loadCatalog()]);E.weekSelect.innerHTML=course.weeks.map(w=>`<option value="${w.id}">Lesson ${w.lesson??w.week}|${esc(w.title)}${w.workspace_id&&w.workspace_id!==w.id?"(同一專案)":""}</option>`).join("");const saved=localStorage.getItem("classroom_week");E.weekSelect.value=course.weeks.some(w=>w.id===saved)?saved:"week02";renderLesson();renderFiles();renderSyntax();initWorker();connect()})();
|
classroom_static/teacher.html
CHANGED
|
@@ -14,6 +14,6 @@
|
|
| 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-
|
| 18 |
</body>
|
| 19 |
</html>
|
|
|
|
| 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-9"></script>
|
| 18 |
</body>
|
| 19 |
</html>
|
classroom_static/teacher.js
CHANGED
|
@@ -20,9 +20,11 @@ let filter = "all";
|
|
| 20 |
let credentials;
|
| 21 |
let reconnect;
|
| 22 |
let catalog = { courses: [] };
|
|
|
|
| 23 |
|
| 24 |
const clean = value => String(value || "").trim().replace(/[^0-9A-Za-z_\-一-鿿]/g, "").slice(0, 48);
|
| 25 |
const esc = value => String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
|
|
| 26 |
|
| 27 |
function toast(text) {
|
| 28 |
E.toast.textContent = text;
|
|
@@ -71,7 +73,7 @@ function detail() {
|
|
| 71 |
const paths = Object.keys(student.files || {}).sort((a, b) => a === "main.py" ? -1 : b === "main.py" ? 1 : a.localeCompare(b));
|
| 72 |
if (!(file in student.files)) file = paths[0];
|
| 73 |
E.selectedStudentName.textContent = `${student.display_name} · ${student.project_name}`;
|
| 74 |
-
E.selectedStudentMeta.textContent = `${label} · ${student.submitted_at ? "已提交目前
|
| 75 |
E.ackHelpButton.hidden = !student.needs_help;
|
| 76 |
E.teacherFiles.innerHTML = paths.map(path => `<button class="teacher-file-tab ${path === file ? "active" : ""}" data-file="${esc(path)}">${esc(path)}</button>`).join("");
|
| 77 |
E.teacherFiles.querySelectorAll("[data-file]").forEach(button => button.onclick = () => {
|
|
@@ -94,7 +96,7 @@ function connect() {
|
|
| 94 |
previous.close();
|
| 95 |
}
|
| 96 |
if (!credentials) return;
|
| 97 |
-
const query = new URLSearchParams({ class_code: credentials.classCode, week_id:
|
| 98 |
const scheme = location.protocol === "https:" ? "wss" : "ws";
|
| 99 |
E.classLabel.textContent = `${credentials.classCode} · ${E.teacherWeek.options[E.teacherWeek.selectedIndex]?.textContent || "Lesson"}`;
|
| 100 |
E.connectionText.textContent = "連線中";
|
|
@@ -127,7 +129,7 @@ function connect() {
|
|
| 127 |
async function request(path, method = "POST", extra = {}) {
|
| 128 |
const base = credentials ? {
|
| 129 |
class_code: credentials.classCode,
|
| 130 |
-
week_id:
|
| 131 |
password: credentials.password
|
| 132 |
} : {};
|
| 133 |
const response = await fetch(path, {
|
|
@@ -232,7 +234,7 @@ E.downloadButton.onclick = async () => {
|
|
| 232 |
const blob = await response.blob();
|
| 233 |
const anchor = document.createElement("a");
|
| 234 |
anchor.href = URL.createObjectURL(blob);
|
| 235 |
-
anchor.download = `${credentials.classCode}_${
|
| 236 |
anchor.click();
|
| 237 |
URL.revokeObjectURL(anchor.href);
|
| 238 |
toast("已產生全班 ZIP");
|
|
@@ -240,7 +242,7 @@ E.downloadButton.onclick = async () => {
|
|
| 240 |
};
|
| 241 |
E.publishButton.onclick = () => {
|
| 242 |
const count = students.filter(student => student.submitted_at).length;
|
| 243 |
-
E.publishMessage.textContent = `將 ${count} 份目前
|
| 244 |
E.publishDialog.showModal();
|
| 245 |
};
|
| 246 |
E.cancelPublish.onclick = () => E.publishDialog.close();
|
|
@@ -260,8 +262,8 @@ setInterval(() => {
|
|
| 260 |
}, 5000);
|
| 261 |
|
| 262 |
(async () => {
|
| 263 |
-
|
| 264 |
-
E.teacherWeek.innerHTML =
|
| 265 |
E.teacherWeek.value = "week02";
|
| 266 |
await loadCatalog();
|
| 267 |
E.loginDialog.showModal();
|
|
|
|
| 20 |
let credentials;
|
| 21 |
let reconnect;
|
| 22 |
let catalog = { courses: [] };
|
| 23 |
+
let courseCatalog = { weeks: [] };
|
| 24 |
|
| 25 |
const clean = value => String(value || "").trim().replace(/[^0-9A-Za-z_\-一-鿿]/g, "").slice(0, 48);
|
| 26 |
const esc = value => String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
| 27 |
+
const teacherWorkspace = () => courseCatalog.weeks.find(item => item.id === E.teacherWeek.value)?.workspace_id || E.teacherWeek.value;
|
| 28 |
|
| 29 |
function toast(text) {
|
| 30 |
E.toast.textContent = text;
|
|
|
|
| 73 |
const paths = Object.keys(student.files || {}).sort((a, b) => a === "main.py" ? -1 : b === "main.py" ? 1 : a.localeCompare(b));
|
| 74 |
if (!(file in student.files)) file = paths[0];
|
| 75 |
E.selectedStudentName.textContent = `${student.display_name} · ${student.project_name}`;
|
| 76 |
+
E.selectedStudentMeta.textContent = `${label} · ${student.submitted_at ? "已提交目前專案" : "目前專案尚未提交"}`;
|
| 77 |
E.ackHelpButton.hidden = !student.needs_help;
|
| 78 |
E.teacherFiles.innerHTML = paths.map(path => `<button class="teacher-file-tab ${path === file ? "active" : ""}" data-file="${esc(path)}">${esc(path)}</button>`).join("");
|
| 79 |
E.teacherFiles.querySelectorAll("[data-file]").forEach(button => button.onclick = () => {
|
|
|
|
| 96 |
previous.close();
|
| 97 |
}
|
| 98 |
if (!credentials) return;
|
| 99 |
+
const query = new URLSearchParams({ class_code: credentials.classCode, week_id: teacherWorkspace() });
|
| 100 |
const scheme = location.protocol === "https:" ? "wss" : "ws";
|
| 101 |
E.classLabel.textContent = `${credentials.classCode} · ${E.teacherWeek.options[E.teacherWeek.selectedIndex]?.textContent || "Lesson"}`;
|
| 102 |
E.connectionText.textContent = "連線中";
|
|
|
|
| 129 |
async function request(path, method = "POST", extra = {}) {
|
| 130 |
const base = credentials ? {
|
| 131 |
class_code: credentials.classCode,
|
| 132 |
+
week_id: teacherWorkspace(),
|
| 133 |
password: credentials.password
|
| 134 |
} : {};
|
| 135 |
const response = await fetch(path, {
|
|
|
|
| 234 |
const blob = await response.blob();
|
| 235 |
const anchor = document.createElement("a");
|
| 236 |
anchor.href = URL.createObjectURL(blob);
|
| 237 |
+
anchor.download = `${credentials.classCode}_${teacherWorkspace()}_final.zip`;
|
| 238 |
anchor.click();
|
| 239 |
URL.revokeObjectURL(anchor.href);
|
| 240 |
toast("已產生全班 ZIP");
|
|
|
|
| 242 |
};
|
| 243 |
E.publishButton.onclick = () => {
|
| 244 |
const count = students.filter(student => student.submitted_at).length;
|
| 245 |
+
E.publishMessage.textContent = `將 ${count} 份目前專案的最後版本發布到 Project_Demo。Space 會重新建置。`;
|
| 246 |
E.publishDialog.showModal();
|
| 247 |
};
|
| 248 |
E.cancelPublish.onclick = () => E.publishDialog.close();
|
|
|
|
| 262 |
}, 5000);
|
| 263 |
|
| 264 |
(async () => {
|
| 265 |
+
courseCatalog = await (await fetch("/api/classroom/course")).json();
|
| 266 |
+
E.teacherWeek.innerHTML = courseCatalog.weeks.map(item => `<option value="${item.id}">Lesson ${item.lesson ?? item.week}|${esc(item.title)}${item.workspace_id && item.workspace_id !== item.id ? "(同一專案)" : ""}</option>`).join("");
|
| 267 |
E.teacherWeek.value = "week02";
|
| 268 |
await loadCatalog();
|
| 269 |
E.loginDialog.showModal();
|
tests/test_classroom.py
CHANGED
|
@@ -65,7 +65,13 @@ class ClassroomServiceTests(unittest.TestCase):
|
|
| 65 |
self.assertEqual(len(course["weeks"]), 18)
|
| 66 |
self.assertEqual([item["lesson"] for item in course["weeks"]], list(range(1, 19)))
|
| 67 |
self.assertEqual(set(classroom.template_files("week02")), {"main.py", "case_data.json"})
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
def test_catalog_lists_courses_and_existing_students(self):
|
| 71 |
state = classroom.empty("L6A", "student-1", "Alex", "week02")
|
|
@@ -173,6 +179,9 @@ class ClassroomServiceTests(unittest.TestCase):
|
|
| 173 |
restored = classroom.get_submission("L6Sat", "胡惟媗", "week02")
|
| 174 |
self.assertEqual(restored["project_name"], "校園謎案調查局")
|
| 175 |
self.assertEqual(set(restored["final_files"]), {"main.py", "case_data.json"})
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
def test_hf_failure_message_distinguishes_authentication_and_permission(self):
|
| 178 |
unauthorized = RuntimeError("token hf_should_never_leak was rejected")
|
|
|
|
| 65 |
self.assertEqual(len(course["weeks"]), 18)
|
| 66 |
self.assertEqual([item["lesson"] for item in course["weeks"]], list(range(1, 19)))
|
| 67 |
self.assertEqual(set(classroom.template_files("week02")), {"main.py", "case_data.json"})
|
| 68 |
+
lesson3 = next(item for item in course["weeks"] if item["id"] == "week03")
|
| 69 |
+
lesson4 = next(item for item in course["weeks"] if item["id"] == "week04")
|
| 70 |
+
self.assertEqual(lesson3["workspace_id"], "week02")
|
| 71 |
+
self.assertEqual(lesson4["workspace_id"], "week02")
|
| 72 |
+
self.assertFalse(lesson3["template"])
|
| 73 |
+
with self.assertRaises(Exception):
|
| 74 |
+
classroom.template_files("week03")
|
| 75 |
|
| 76 |
def test_catalog_lists_courses_and_existing_students(self):
|
| 77 |
state = classroom.empty("L6A", "student-1", "Alex", "week02")
|
|
|
|
| 179 |
restored = classroom.get_submission("L6Sat", "胡惟媗", "week02")
|
| 180 |
self.assertEqual(restored["project_name"], "校園謎案調查局")
|
| 181 |
self.assertEqual(set(restored["final_files"]), {"main.py", "case_data.json"})
|
| 182 |
+
for student in course["students"]:
|
| 183 |
+
project = classroom.get_submission("L6Sat", student["student_id"], "week02")
|
| 184 |
+
compile(project["final_files"]["main.py"], student["display_name"], "exec")
|
| 185 |
|
| 186 |
def test_hf_failure_message_distinguishes_authentication_and_permission(self):
|
| 187 |
unauthorized = RuntimeError("token hf_should_never_leak was rejected")
|