steveyehtw commited on
Commit
7e8293f
·
1 Parent(s): 60dfd5a

Keep Lessons 2 to 4 in one project workspace

Browse files
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 路線圖,Lesson 1~3 已有套版。Lesson 是完成進度,不綁定日曆週次;支援多個 Python 檔、ZIP 匯入匯出、即時監看、每個 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":"條件解鎖、變化證詞與多結局案件","template":true},
5
- {"id":"week04","lesson":4,"title":"把文字案件變成可點的現場","project":"校園謎案調查局","output":"Pygame 互動現場","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},
 
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
- return norm_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})
 
 
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">匯入此 Lesson 套版</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">提交 Lesson 版本</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">目前 Lesson 尚未提交</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>匯入後會成為目前 Lesson 的工作副本。</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-6"></script></body></html>
 
1
  <!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light"><title>Python 專案教室|學生工作區</title><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css"><link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-5"></head><body>
2
  <header class="app-header"><div class="brand"><div class="brand-mark">Py</div><div class="brand-copy"><strong>Python 專案教室</strong><span id="studentIdentity">尚未進入教室</span></div></div><div class="header-actions"><div class="connection-state"><i class="status-dot" id="connectionDot"></i><span id="connectionText">未連線</span></div><button class="btn help compact" id="helpButton">請老師協助</button><button class="btn secondary compact" id="identityButton">更換身分</button></div></header>
3
+ <section class="project-bar"><div class="project-context"><label for="weekSelect">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),week=()=>E.weekSelect.value||"week02",key=()=>`classroom_project_${localStorage.getItem("classroom_code")}_${week()}`;
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("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;")}
10
- function saveLocal(){localStorage.setItem(key(),JSON.stringify({files,activeFile,projectName:E.projectName.value,output}))}
11
  function sync(){saveLocal();E.saveStatus.textContent="同步中";clearTimeout(timer);timer=setTimeout(()=>send({type:"project",files,active_file:activeFile,project_name:E.projectName.value}),450)}
12
  function openFile(path){if(!(path in files))return;if(activeFile in files&&!suppress)files[activeFile]=editor.getValue();activeFile=path;suppress=true;editor.setValue(files[path]);suppress=false;E.activeFileLabel.textContent=path;editor.setOption("mode",path.endsWith(".py")?"python":"text");renderFiles()}
13
  function setProject(next,name,preferred="main.py"){files=Object.fromEntries(Object.entries(next||{}).filter(([p,v])=>typeof v==="string"&&!p.includes("..")));files["main.py"]??="# 專案入口\n";E.projectName.value=name||"";const firstFile=files[preferred]?preferred:"main.py";activeFile="";openFile(firstFile);sync()}
14
  function renderFiles(){E.fileList.innerHTML=Object.keys(files).sort((a,b)=>a==="main.py"?-1:b==="main.py"?1:a.localeCompare(b)).map(p=>`<div class="file-row ${p===activeFile?"selected":""}"><button data-open="${esc(p)}">◇ ${esc(p)}</button>${p!=="main.py"?`<button class="file-remove" data-remove="${esc(p)}">×</button>`:""}</div>`).join("");E.fileList.querySelectorAll("[data-open]").forEach(b=>b.onclick=()=>openFile(b.dataset.open));E.fileList.querySelectorAll("[data-remove]").forEach(b=>b.onclick=()=>{if(confirm(`刪除 ${b.dataset.remove}?`)){delete files[b.dataset.remove];if(activeFile===b.dataset.remove)activeFile="main.py";openFile(activeFile);sync()}})}
15
- function connect(){clearTimeout(reconnect);const previous=socket;if(previous){previous.onclose=null;previous.close()}const c=clean(localStorage.getItem("classroom_code")),n=localStorage.getItem("classroom_name")?.trim();if(!c||!n||!studentId){E.identityDialog.showModal();return}E.studentIdentity.textContent=`${c} · ${n}`;E.connectionText.textContent="連線中";const scheme=location.protocol==="https:"?"wss":"ws",q=new URLSearchParams({class_code:c,student_id:studentId,display_name:n,week_id:week()}),current=new WebSocket(`${scheme}://${location.host}/ws/classroom/student?${q}`);socket=current;current.onopen=()=>{E.connectionDot.className="status-dot online";E.connectionText.textContent="已連線"};current.onmessage=e=>{const m=JSON.parse(e.data);if(m.type==="restore"){const local=JSON.parse(localStorage.getItem(key())||"null"),s=m.state,src=local||{files:s.files,activeFile:s.active_file,projectName:s.project_name,output:s.output};output=src.output||"";E.consoleOutput.textContent=output||"尚無執行結果。";submittedAt=s.submitted_at;needsHelp=!!s.needs_help;renderState();setProject(src.files,src.projectName,src.activeFile);send({type:"output",output})}if(m.type==="help_ack"){needsHelp=false;renderState();toast("老師已看到你的協助請求")}if(["ack","pong"].includes(m.type)){if(typeof m.needs_help==="boolean"){const seen=needsHelp&&!m.needs_help;needsHelp=m.needs_help;if(seen)toast("老師已看到你的協助請求")}if(m.submitted_at)submittedAt=m.submitted_at;renderState();if(m.type==="ack")E.saveStatus.textContent="已同步"}};current.onclose=async e=>{if(socket!==current)return;E.connectionDot.className="status-dot error";E.connectionText.textContent="已離線";E.saveStatus.textContent="保存在此裝置";if(e.code===4004||e.code===4404){localStorage.removeItem("classroom_student_id");localStorage.removeItem("classroom_name");studentId="";await loadCatalog();E.identityError.textContent=e.code===4004?"老師已將你從名單移除。":"姓名與班級名單不符,請重新輸入。";E.identityDialog.showModal();return}reconnect=setTimeout(connect,2500)}}
16
- function renderState(){E.helpButton.classList.toggle("active",needsHelp);E.helpButton.textContent=needsHelp?"取消請求協助":"請老師協助";E.submitStatus.textContent=submittedAt?`已提交 ${new Date(submittedAt).toLocaleTimeString("zh-TW",{hour:"2-digit",minute:"2-digit"})}`:"目前 Lesson 尚未提交"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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",week());submittedAt=null;connect()};E.projectName.oninput=sync;
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/${week()}`);if(!r.ok)return toast("這個 Lesson 尚未發布套版");const d=await r.json(),w=course.weeks.find(x=>x.id===week());setProject(d.files,w?.project);toast("已匯入此 Lesson 套版")};
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("設為目前 Lesson 的最後正式版本?重新提交會覆蓋前一版。"))return;send({type:"submit",files});submittedAt=Date.now();renderState();toast("Lesson 正式版本已提交")};E.helpButton.onclick=()=>{needsHelp=!needsHelp;renderState();send({type:"help",needs_help:needsHelp})};
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.template?"":"(規劃)"}</option>`).join("");E.weekSelect.value=localStorage.getItem("classroom_week")||"week02";renderFiles();renderSyntax();initWorker();connect()})();
 
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("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;")}
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-8"></script>
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
 
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 ? "已提交目前 Lesson" : "目前 Lesson 尚未提交"}`;
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: E.teacherWeek.value });
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: E.teacherWeek.value,
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}_${E.teacherWeek.value}_final.zip`;
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} 份目前 Lesson 的最後版本發布到 Project_Demo。Space 會重新建置。`;
244
  E.publishDialog.showModal();
245
  };
246
  E.cancelPublish.onclick = () => E.publishDialog.close();
@@ -260,8 +262,8 @@ setInterval(() => {
260
  }, 5000);
261
 
262
  (async () => {
263
- const course = await (await fetch("/api/classroom/course")).json();
264
- E.teacherWeek.innerHTML = course.weeks.map(item => `<option value="${item.id}">Lesson ${item.lesson ?? item.week}|${esc(item.title)}</option>`).join("");
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
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
- self.assertEqual(set(classroom.template_files("week03")), {"main.py", "case_data.json"})
 
 
 
 
 
 
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")