steveyehtw commited on
Commit
60dfd5a
·
1 Parent(s): 05345c6

Use flexible lesson-based course progress

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 18 週課程,Week 1、2 已有套版。支援多個 Python 檔、ZIP 匯入匯出、即時監看、每週只保留最後正式版與整班發布。
20
 
21
  課程與名單管理:
22
 
@@ -26,9 +26,9 @@ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-
26
  - 教師可從課程刪除學生及其教室內進度;已經發布到首頁 Demo 的作品不會連帶刪除。
27
  - 班級與學生名單會寫回 Space repository 長期保存;尚未發布的草稿仍保存在 Space 執行期間。
28
  - 內容管理後台的「管理教室」可新增、改名或刪除班級,管理學生名單,並查看或刪除已提交內容。
29
- - 下課前可在「管理教室」按「備份本週提交」,將當週正式版寫回 Space repository,避免重新建置後遺失。
30
  - 學生端 Python 會逐段顯示輸出;程式執行到 `input()` 時,Console 才出現輸入列並等待送出。
31
- - 內容管理後台可修改 18 週課程資料與每週套版,也可新增、改名、編輯或刪除公開 Demo 專題。所有儲存都會寫回此 Space。
32
 
33
  Space Secrets:
34
 
 
16
  - 內容管理:`/admin`
17
  - 展示:`/`
18
 
19
+ 內建 2026 Fall Lesson 路線圖,Lesson 1~3 已有套版。Lesson 是完成進度,不綁定日曆週次;支援多個 Python 檔、ZIP 匯入匯出、即時監看、每個 Lesson 只保留最後正式版與整班發布。
20
 
21
  課程與名單管理:
22
 
 
26
  - 教師可從課程刪除學生及其教室內進度;已經發布到首頁 Demo 的作品不會連帶刪除。
27
  - 班級與學生名單會寫回 Space repository 長期保存;尚未發布的草稿仍保存在 Space 執行期間。
28
  - 內容管理後台的「管理教室」可新增、改名或刪除班級,管理學生名單,並查看或刪除已提交內容。
29
+ - 下課前可在「管理教室」按「備份 Lesson 提交」,將目前 Lesson 的正式版寫回 Space repository,避免重新建置後遺失。
30
  - 學生端 Python 會逐段顯示輸出;程式執行到 `input()` 時,Console 才出現輸入列並等待送出。
31
+ - 內容管理後台可修改 Lesson 路線與各 Lesson 套版,也可新增、改名、編輯或刪除公開 Demo 專題。所有儲存都會寫回此 Space。
32
 
33
  Space Secrets:
34
 
admin_service.py CHANGED
@@ -186,7 +186,7 @@ def backup_submissions(req: ClassroomBackupRequest):
186
  week_id = classroom.clean(req.week_id, "")
187
  states = classroom.finals(class_code, week_id)
188
  if not states:
189
- raise HTTPException(400, "這一週還沒有學生提交")
190
  additions = {}
191
  for state in states:
192
  path = classroom.submission_path(class_code, state["student_id"], week_id)
@@ -219,7 +219,7 @@ def admin_week(req: WeekRequest):
219
  course = load_course()
220
  week = next((item for item in course.get("weeks", []) if item.get("id") == week_id), None)
221
  if not week:
222
- raise HTTPException(404, "找不到這一週")
223
  root = classroom.TEMPLATES / week_id
224
  files = {}
225
  if root.is_dir():
@@ -238,7 +238,7 @@ def save_week(req: WeekSave):
238
  course = load_course()
239
  week = next((item for item in course.get("weeks", []) if item.get("id") == week_id), None)
240
  if not week:
241
- raise HTTPException(404, "找不到這一週")
242
  week.update({
243
  "title": classroom.clean_label(req.title, week.get("title", "")),
244
  "project": classroom.clean_label(req.project, week.get("project", "")),
 
186
  week_id = classroom.clean(req.week_id, "")
187
  states = classroom.finals(class_code, week_id)
188
  if not states:
189
+ raise HTTPException(400, "這個 Lesson 還沒有學生提交")
190
  additions = {}
191
  for state in states:
192
  path = classroom.submission_path(class_code, state["student_id"], week_id)
 
219
  course = load_course()
220
  week = next((item for item in course.get("weeks", []) if item.get("id") == week_id), None)
221
  if not week:
222
+ raise HTTPException(404, "找不到這個 Lesson")
223
  root = classroom.TEMPLATES / week_id
224
  files = {}
225
  if root.is_dir():
 
238
  course = load_course()
239
  week = next((item for item in course.get("weeks", []) if item.get("id") == week_id), None)
240
  if not week:
241
+ raise HTTPException(404, "找不到這個 Lesson")
242
  week.update({
243
  "title": classroom.clean_label(req.title, week.get("title", "")),
244
  "project": classroom.clean_label(req.project, week.get("project", "")),
classroom_data/course.json CHANGED
@@ -1,20 +1,20 @@
1
  {"course_id":"2026Fall","title":"2026 Fall 進階專題","demo_class":"2026Fall進階專題","weeks":[
2
- {"id":"week01","week":1,"title":"任務管理器開發挑戰","project":"能力診斷","output":"文字版任務管理器","template":true},
3
- {"id":"week02","week":2,"title":"消失的校徽","project":"校園謎案調查局","output":"探索地點與收集線索的文字案件","template":true},
4
- {"id":"week03","week":3,"title":"線索會改變世界","project":"校園謎案調查局","output":"條件解鎖、變化證詞與多結局案件","template":true},
5
- {"id":"week04","week":4,"title":"把文字案件變成可點的現場","project":"校園謎案調查局","output":"Pygame 互動現場","template":false},
6
- {"id":"week05","week":5,"title":"案件發布日","project":"校園謎案調查局","output":"原創短案件與線上展示","template":false},
7
- {"id":"week06","week":6,"title":"領養一顆神祕蛋","project":"電子寵物進化實驗室","output":"可餵食、玩耍、休息的寵物原型","template":false},
8
- {"id":"week07","week":7,"title":"寵物真的活起來","project":"電子寵物進化實驗室","output":"class 與 object 寵物","template":false},
9
- {"id":"week08","week":8,"title":"你的選擇決定牠的進化","project":"電子寵物進化實驗室","output":"三條進化路線","template":false},
10
- {"id":"week09","week":9,"title":"寵物小屋與圖鑑","project":"電子寵物進化實驗室","output":"動畫、存檔與圖鑑","template":false},
11
- {"id":"week10","week":10,"title":"進化博覽會","project":"電子寵物進化實驗室","output":"可重新遊玩的完成版","template":false},
12
- {"id":"week11","week":11,"title":"用亂數畫出第一件作品","project":"生成藝術玩具箱","output":"亂數生成畫面","template":false},
13
- {"id":"week12","week":12,"title":"對稱、重複與規則之美","project":"生成藝術玩具箱","output":"萬花筒或魔法陣","template":false},
14
- {"id":"week13","week":13,"title":"讓作品動起來","project":"生成藝術玩具箱","output":"粒子與軌跡動畫","template":false},
15
- {"id":"week14","week":14,"title":"做成一台真的藝術機器","project":"生成藝術玩具箱","output":"參數、seed 與圖片匯出","template":false},
16
- {"id":"week15","week":15,"title":"班級數位藝廊","project":"生成藝術玩具箱","output":"系列作品與線上展示","template":false},
17
- {"id":"week16","week":16,"title":"Director's Cut 重製計畫","project":"精選作品重製","output":"選題與改進承諾","template":false},
18
- {"id":"week17","week":17,"title":"Release Candidate","project":"精選作品重製","output":"通過驗收的上架版","template":false},
19
- {"id":"week18","week":18,"title":"Demo Day","project":"精選作品重製","output":"線上作品、展示稿與回顧","template":false}
20
  ]}
 
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},
9
+ {"id":"week08","lesson":8,"title":"你的選擇決定牠的進化","project":"電子寵物進化實驗室","output":"三條進化路線","template":false},
10
+ {"id":"week09","lesson":9,"title":"寵物小屋與圖鑑","project":"電子寵物進化實驗室","output":"動畫、存檔與圖鑑","template":false},
11
+ {"id":"week10","lesson":10,"title":"進化博覽會","project":"電子寵物進化實驗室","output":"可重新遊玩的完成版","template":false},
12
+ {"id":"week11","lesson":11,"title":"用亂數畫出第一件作品","project":"生成藝術玩具箱","output":"亂數生成畫面","template":false},
13
+ {"id":"week12","lesson":12,"title":"對稱、重複與規則之美","project":"生成藝術玩具箱","output":"萬花筒或魔法陣","template":false},
14
+ {"id":"week13","lesson":13,"title":"讓作品動起來","project":"生成藝術玩具箱","output":"粒子與軌跡動畫","template":false},
15
+ {"id":"week14","lesson":14,"title":"做成一台真的藝術機器","project":"生成藝術玩具箱","output":"參數、seed 與圖片匯出","template":false},
16
+ {"id":"week15","lesson":15,"title":"班級數位藝廊","project":"生成藝術玩具箱","output":"系列作品與線上展示","template":false},
17
+ {"id":"week16","lesson":16,"title":"Director's Cut 重製計畫","project":"精選作品重製","output":"選題與改進承諾","template":false},
18
+ {"id":"week17","lesson":17,"title":"Release Candidate","project":"精選作品重製","output":"通過驗收的上架版","template":false},
19
+ {"id":"week18","lesson":18,"title":"Demo Day","project":"精選作品重製","output":"線上作品、展示稿與回顧","template":false}
20
  ]}
classroom_data/templates/week01/main.py CHANGED
@@ -1,4 +1,4 @@
1
- """第一週診斷挑戰:文字版任務管理器。
2
 
3
  請依照學生任務單完成 TODO。先完成 Checkpoint A,再挑戰 B、C。
4
  """
 
1
+ """Lesson 1 診斷挑戰:文字版任務管理器。
2
 
3
  請依照學生任務單完成 TODO。先完成 Checkpoint A,再挑戰 B、C。
4
  """
classroom_data/templates/week02/main.py CHANGED
@@ -1,4 +1,4 @@
1
- """Week 2:校園謎案調查局 Starter。
2
 
3
  先完成 TODO 1~4。程式必須能反覆探索、收集線索、查看證物並提出推理。
4
  """
 
1
+ """Lesson 2:校園謎案調查局 Starter。
2
 
3
  先完成 TODO 1~4。程式必須能反覆探索、收集線索、查看證物並提出推理。
4
  """
classroom_data/templates/week03/main.py CHANGED
@@ -1,4 +1,4 @@
1
- """Week 3:線索會改變世界 Starter。
2
 
3
  先完成 TODO 1~4。你會讓線索解鎖行動、改變證詞,並產生不同結局。
4
  """
 
1
+ """Lesson 3:線索會改變世界 Starter。
2
 
3
  先完成 TODO 1~4。你會讓線索解鎖行動、改變證詞,並產生不同結局。
4
  """
classroom_service.py CHANGED
@@ -112,7 +112,7 @@ async def broadcast(c,w):
112
  except Exception:teachers.get((c,w),set()).discard(ws)
113
  def template_files(w):
114
  root=TEMPLATES/clean(w,"week01")
115
- if not root.is_dir():raise HTTPException(404,"這週尚未發布套版")
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=[]
@@ -253,7 +253,7 @@ def publish(req:TeacherRequest):
253
  if not TOKEN:raise HTTPException(503,"請先在 Space Secrets 設定 HF_TOKEN")
254
  class_code=clean(req.class_code,"L6A"); week_id=clean(req.week_id,"week01")
255
  states=finals(class_code,week_id)
256
- if not states:raise HTTPException(400,"本週尚無正式提交")
257
  from huggingface_hub import CommitOperationAdd,CommitOperationDelete,HfApi
258
  api=HfApi(token=TOKEN); existing=set(api.list_repo_files(repo_id=REPO,repo_type="space")); desired={}; prefixes=[]
259
  for x in states:
 
112
  except Exception:teachers.get((c,w),set()).discard(ws)
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=[]
 
253
  if not TOKEN:raise HTTPException(503,"請先在 Space Secrets 設定 HF_TOKEN")
254
  class_code=clean(req.class_code,"L6A"); week_id=clean(req.week_id,"week01")
255
  states=finals(class_code,week_id)
256
+ if not states:raise HTTPException(400,"目前 Lesson 尚無正式提交")
257
  from huggingface_hub import CommitOperationAdd,CommitOperationDelete,HfApi
258
  api=HfApi(token=TOKEN); existing=set(api.list_repo_files(repo_id=REPO,repo_type="space")); desired={}; prefixes=[]
259
  for x in states:
classroom_static/admin.html CHANGED
@@ -16,10 +16,10 @@
16
 
17
  <main class="admin-main">
18
  <section class="admin-view active" id="courseView">
19
- <aside class="admin-sidebar"><div class="admin-sidebar-head"><div><strong>18 週課程</strong><small>選一週修改主題與套版</small></div></div><div class="admin-list" id="weekAdminList"></div></aside>
20
  <form class="admin-editor" id="weekForm">
21
- <header class="admin-editor-head"><div><h1 id="weekHeading">選擇週次</h1><p>課堂顯示內容與學生可匯入的本週套版。</p></div><button class="btn" id="saveWeekButton" type="submit">儲存這一週</button></header>
22
- <div class="admin-fields three"><label>課程標題<input class="text-field" id="weekTitle" required></label><label>專題名稱<input class="text-field" id="weekProject" required></label><label>本週產出<input class="text-field" id="weekOutput" required></label></div>
23
  <div class="file-workspace"><aside><div class="file-workspace-head"><strong>套版檔案</strong><button class="copy-button" id="newWeekFile" type="button">+新增</button></div><div class="admin-file-list" id="weekFiles"></div></aside><label class="code-field"><span id="weekFileLabel">尚未選擇檔案</span><textarea id="weekFileContent" spellcheck="false" disabled></textarea></label></div>
24
  </form>
25
  </section>
@@ -28,7 +28,7 @@
28
  <aside class="admin-sidebar classroom-sidebar"><div class="admin-sidebar-head"><div><strong>我的班級</strong><small id="classroomTotal">尚未建立班級</small></div><button class="copy-button" id="newClassroomButton">+新增</button></div><div class="admin-list" id="classroomList"></div></aside>
29
  <section class="classroom-manager">
30
  <header class="admin-editor-head"><div><h1 id="classroomHeading">選擇班級</h1><p>管理班級、學生名單與已提交的正式版本。</p></div><div class="admin-actions"><button class="copy-button danger-action" id="deleteClassroomButton" disabled>刪除班級</button><button class="btn" id="saveClassroomButton" disabled>儲存班級</button></div></header>
31
- <div class="classroom-fields"><label>班級代碼<input class="text-field" id="classroomCode" disabled></label><label>班級名稱<input class="text-field" id="classroomName" disabled></label><div class="backup-controls"><label>備份週次<select class="text-field" id="backupWeek" disabled></select></label><button class="copy-button" id="backupSubmissionsButton" disabled>備份本週提交</button></div></div>
32
  <div class="classroom-workspace">
33
  <aside class="student-manager"><form class="manager-add" id="adminStudentForm"><label for="adminStudentNames">新增學生(多人可用逗號分開)</label><div><input class="text-field" id="adminStudentNames" disabled><button class="btn compact" id="adminAddStudentsButton" disabled>加入</button></div></form><div class="manager-title"><strong>學生名單</strong><span id="adminStudentTotal">0 人</span></div><div class="manager-list" id="adminStudentList"></div></aside>
34
  <section class="submission-manager"><div class="manager-title"><div><strong id="submissionHeading">已提交內容</strong><small id="submissionTotal">0 份</small></div></div><div class="submission-workspace"><div class="submission-list" id="submissionList"></div><div class="submission-detail"><header><div><strong id="submissionProject">尚未選擇提交</strong><small id="submissionMeta"></small></div><button class="copy-button danger-action" id="deleteSubmissionButton" disabled>刪除提交</button></header><nav class="submission-files" id="submissionFiles"></nav><pre id="submissionCode">選擇一份提交查看內容。</pre></div></div></section>
@@ -48,6 +48,6 @@
48
 
49
  <dialog id="adminLoginDialog"><form class="dialog-content form-stack" id="adminLoginForm"><div><h1>登入內容管理後台</h1><p>使用與教師控制台相同的密碼。</p></div><label><span class="field-label">教師密碼</span><input class="text-field" id="adminPassword" type="password" required autofocus></label><p class="form-error" id="adminLoginError"></p><button class="btn" type="submit">登入</button></form></dialog>
50
  <div class="toast" id="toast"></div>
51
- <script src="/classroom-assets/admin.js?v=2026fall-6"></script>
52
  </body>
53
  </html>
 
16
 
17
  <main class="admin-main">
18
  <section class="admin-view active" id="courseView">
19
+ <aside class="admin-sidebar"><div class="admin-sidebar-head"><div><strong>Lesson 路線</strong><small>選擇 Lesson 修改主題與套版</small></div></div><div class="admin-list" id="weekAdminList"></div></aside>
20
  <form class="admin-editor" id="weekForm">
21
+ <header class="admin-editor-head"><div><h1 id="weekHeading">選擇 Lesson</h1><p>課堂顯示內容與學生可匯入的 Lesson 套版。</p></div><button class="btn" id="saveWeekButton" type="submit">儲存此 Lesson</button></header>
22
+ <div class="admin-fields three"><label>課程標題<input class="text-field" id="weekTitle" required></label><label>專題名稱<input class="text-field" id="weekProject" required></label><label>Lesson 產出<input class="text-field" id="weekOutput" required></label></div>
23
  <div class="file-workspace"><aside><div class="file-workspace-head"><strong>套版檔案</strong><button class="copy-button" id="newWeekFile" type="button">+新增</button></div><div class="admin-file-list" id="weekFiles"></div></aside><label class="code-field"><span id="weekFileLabel">尚未選擇檔案</span><textarea id="weekFileContent" spellcheck="false" disabled></textarea></label></div>
24
  </form>
25
  </section>
 
28
  <aside class="admin-sidebar classroom-sidebar"><div class="admin-sidebar-head"><div><strong>我的班級</strong><small id="classroomTotal">尚未建立班級</small></div><button class="copy-button" id="newClassroomButton">+新增</button></div><div class="admin-list" id="classroomList"></div></aside>
29
  <section class="classroom-manager">
30
  <header class="admin-editor-head"><div><h1 id="classroomHeading">選擇班級</h1><p>管理班級、學生名單與已提交的正式版本。</p></div><div class="admin-actions"><button class="copy-button danger-action" id="deleteClassroomButton" disabled>刪除班級</button><button class="btn" id="saveClassroomButton" disabled>儲存班級</button></div></header>
31
+ <div class="classroom-fields"><label>班級代碼<input class="text-field" id="classroomCode" disabled></label><label>班級名稱<input class="text-field" id="classroomName" disabled></label><div class="backup-controls"><label>備份 Lesson<select class="text-field" id="backupWeek" disabled></select></label><button class="copy-button" id="backupSubmissionsButton" disabled>備份 Lesson 提交</button></div></div>
32
  <div class="classroom-workspace">
33
  <aside class="student-manager"><form class="manager-add" id="adminStudentForm"><label for="adminStudentNames">新增學生(多人可用逗號分開)</label><div><input class="text-field" id="adminStudentNames" disabled><button class="btn compact" id="adminAddStudentsButton" disabled>加入</button></div></form><div class="manager-title"><strong>學生名單</strong><span id="adminStudentTotal">0 人</span></div><div class="manager-list" id="adminStudentList"></div></aside>
34
  <section class="submission-manager"><div class="manager-title"><div><strong id="submissionHeading">已提交內容</strong><small id="submissionTotal">0 份</small></div></div><div class="submission-workspace"><div class="submission-list" id="submissionList"></div><div class="submission-detail"><header><div><strong id="submissionProject">尚未選擇提交</strong><small id="submissionMeta"></small></div><button class="copy-button danger-action" id="deleteSubmissionButton" disabled>刪除提交</button></header><nav class="submission-files" id="submissionFiles"></nav><pre id="submissionCode">選擇一份提交查看內容。</pre></div></div></section>
 
48
 
49
  <dialog id="adminLoginDialog"><form class="dialog-content form-stack" id="adminLoginForm"><div><h1>登入內容管理後台</h1><p>使用與教師控制台相同的密碼。</p></div><label><span class="field-label">教師密碼</span><input class="text-field" id="adminPassword" type="password" required autofocus></label><p class="form-error" id="adminLoginError"></p><button class="btn" type="submit">登入</button></form></dialog>
50
  <div class="toast" id="toast"></div>
51
+ <script src="/classroom-assets/admin.js?v=2026fall-7"></script>
52
  </body>
53
  </html>
classroom_static/admin.js CHANGED
@@ -70,7 +70,7 @@ async function login() {
70
  E.adminStatus.textContent = `已登入 · ${state.course.title || "課程管理"}`;
71
  renderWeeks();
72
  renderDemoPicker();
73
- E.backupWeek.innerHTML = state.course.weeks.map(week => `<option value="${esc(week.id)}">第 ${week.week} 週|${esc(week.title)}</option>`).join("");
74
  E.backupWeek.value = "week02";
75
  renderClassrooms();
76
  if (state.course.weeks.length) await selectWeek(state.course.weeks[0].id);
@@ -95,7 +95,7 @@ E.loginButton.onclick = () => E.adminLoginDialog.showModal();
95
  function renderWeeks() {
96
  E.weekAdminList.innerHTML = state.course.weeks.map(week => `
97
  <button class="admin-list-button ${week.id === selectedWeek ? "active" : ""}" data-week="${esc(week.id)}">
98
- <div><strong>第 ${week.week} 週|${esc(week.title)}</strong><small>${esc(week.project)}</small></div>
99
  <span>${week.template ? "有套版" : "無套版"}</span>
100
  </button>`).join("");
101
  E.weekAdminList.querySelectorAll("[data-week]").forEach(button => button.onclick = () => selectWeek(button.dataset.week));
@@ -106,7 +106,7 @@ async function selectWeek(weekId) {
106
  selectedWeek = weekId;
107
  renderWeeks();
108
  const data = await request("/api/admin/week", { week_id: weekId });
109
- E.weekHeading.textContent = `第 ${data.week.week} 週|${data.week.title}`;
110
  E.weekTitle.value = data.week.title || "";
111
  E.weekProject.value = data.week.project || "";
112
  E.weekOutput.value = data.week.output || "";
@@ -121,7 +121,7 @@ function saveWeekFileBuffer() {
121
 
122
  function renderWeekFiles() {
123
  E.weekFiles.innerHTML = Object.keys(weekFiles).sort().map(path => `
124
- <div class="admin-file-row ${path === weekFile ? "active" : ""}"><button type="button" data-open="${esc(path)}">${esc(path)}</button><button class="remove-file" type="button" data-remove="${esc(path)}" aria-label="移除 ${esc(path)}">×</button></div>`).join("") || '<div class="admin-empty">這週目前沒有套版。學生會看到「尚未發布套版」。</div>';
125
  E.weekFiles.querySelectorAll("[data-open]").forEach(button => button.onclick = () => {
126
  saveWeekFileBuffer(); weekFile = button.dataset.open; renderWeekFiles();
127
  });
@@ -150,7 +150,7 @@ E.newWeekFile.onclick = () => {
150
  saveWeekFileBuffer();
151
  const path = askFileName(weekFiles);
152
  if (!path) return;
153
- weekFiles[path] = path.endsWith(".py") ? "# 本週 Python 套版\n" : "";
154
  weekFile = path;
155
  renderWeekFiles();
156
  };
@@ -169,7 +169,7 @@ E.weekForm.onsubmit = async event => {
169
  });
170
  const week = state.course.weeks.find(item => item.id === selectedWeek);
171
  Object.assign(week, { title: E.weekTitle.value.trim(), project: E.weekProject.value.trim(), output: E.weekOutput.value.trim(), template: Object.keys(weekFiles).length > 0 });
172
- E.weekHeading.textContent = `第 ${week.week} 週|${week.title}`;
173
  renderWeeks();
174
  toast("課程內容已儲存,Space 正在重新建置");
175
  } catch (error) { toast(error.message); }
 
70
  E.adminStatus.textContent = `已登入 · ${state.course.title || "課程管理"}`;
71
  renderWeeks();
72
  renderDemoPicker();
73
+ E.backupWeek.innerHTML = state.course.weeks.map(week => `<option value="${esc(week.id)}">Lesson ${week.lesson ?? week.week}|${esc(week.title)}</option>`).join("");
74
  E.backupWeek.value = "week02";
75
  renderClassrooms();
76
  if (state.course.weeks.length) await selectWeek(state.course.weeks[0].id);
 
95
  function renderWeeks() {
96
  E.weekAdminList.innerHTML = state.course.weeks.map(week => `
97
  <button class="admin-list-button ${week.id === selectedWeek ? "active" : ""}" data-week="${esc(week.id)}">
98
+ <div><strong>Lesson ${week.lesson ?? week.week}|${esc(week.title)}</strong><small>${esc(week.project)}</small></div>
99
  <span>${week.template ? "有套版" : "無套版"}</span>
100
  </button>`).join("");
101
  E.weekAdminList.querySelectorAll("[data-week]").forEach(button => button.onclick = () => selectWeek(button.dataset.week));
 
106
  selectedWeek = weekId;
107
  renderWeeks();
108
  const data = await request("/api/admin/week", { week_id: weekId });
109
+ E.weekHeading.textContent = `Lesson ${data.week.lesson ?? data.week.week}|${data.week.title}`;
110
  E.weekTitle.value = data.week.title || "";
111
  E.weekProject.value = data.week.project || "";
112
  E.weekOutput.value = data.week.output || "";
 
121
 
122
  function renderWeekFiles() {
123
  E.weekFiles.innerHTML = Object.keys(weekFiles).sort().map(path => `
124
+ <div class="admin-file-row ${path === weekFile ? "active" : ""}"><button type="button" data-open="${esc(path)}">${esc(path)}</button><button class="remove-file" type="button" data-remove="${esc(path)}" aria-label="移除 ${esc(path)}">×</button></div>`).join("") || '<div class="admin-empty">此 Lesson 目前沒有套版。學生會看到「尚未發布套版」。</div>';
125
  E.weekFiles.querySelectorAll("[data-open]").forEach(button => button.onclick = () => {
126
  saveWeekFileBuffer(); weekFile = button.dataset.open; renderWeekFiles();
127
  });
 
150
  saveWeekFileBuffer();
151
  const path = askFileName(weekFiles);
152
  if (!path) return;
153
+ weekFiles[path] = path.endsWith(".py") ? "# Lesson Python 套版\n" : "";
154
  weekFile = path;
155
  renderWeekFiles();
156
  };
 
169
  });
170
  const week = state.course.weeks.find(item => item.id === selectedWeek);
171
  Object.assign(week, { title: E.weekTitle.value.trim(), project: E.weekProject.value.trim(), output: E.weekOutput.value.trim(), template: Object.keys(weekFiles).length > 0 });
172
+ E.weekHeading.textContent = `Lesson ${week.lesson ?? week.week}|${week.title}`;
173
  renderWeeks();
174
  toast("課程內容已儲存,Space 正在重新建置");
175
  } catch (error) { toast(error.message); }
classroom_static/student.html CHANGED
@@ -1,10 +1,10 @@
1
  <!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light"><title>Python 專案教室|學生工作區</title><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css"><link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-5"></head><body>
2
  <header class="app-header"><div class="brand"><div class="brand-mark">Py</div><div class="brand-copy"><strong>Python 專案教室</strong><span id="studentIdentity">尚未進入教室</span></div></div><div class="header-actions"><div class="connection-state"><i class="status-dot" id="connectionDot"></i><span id="connectionText">未連線</span></div><button class="btn help compact" id="helpButton">請老師協助</button><button class="btn secondary compact" id="identityButton">更換身分</button></div></header>
3
- <section class="project-bar"><div class="project-context"><label for="weekSelect">週次</label><select id="weekSelect"></select><label for="projectName">專案</label><input id="projectName" maxlength="36"></div><div class="project-actions"><button class="copy-button" id="templateButton">匯入本週套版</button><button class="copy-button" id="progressButton">選擇舊進度</button><button class="copy-button" id="importButton">匯入 .py / ZIP</button><button class="copy-button" id="exportButton">匯出專案</button><button class="btn compact" id="submitButton">提交本週版本</button><input id="fileImport" type="file" accept=".py,.zip" hidden></div></section>
4
  <nav class="mobile-tabs" role="tablist"><button class="mobile-tab" data-panel="referencePanel">專案/語法</button><button class="mobile-tab" data-panel="editorPanel" aria-selected="true">程式</button><button class="mobile-tab" data-panel="consolePanel">執行結果</button></nav>
5
  <main class="student-layout"><aside class="panel reference-panel" id="referencePanel"><div class="side-tabs"><button class="side-tab active" data-side="filesView">專案檔案</button><button class="side-tab" data-side="syntaxView">語法查詢</button></div><section class="side-view" id="filesView"><div class="panel-heading"><h1>檔案夾</h1><button class="copy-button" id="newFileButton">+新增</button></div><div class="file-list" id="fileList"></div><p class="side-note">入口固定為 <code>main.py</code>,可用 <code>import</code> 引入其他 Python 檔。</p></section><section class="side-view" id="syntaxView" hidden><div class="panel-heading"><h1>Python 語法查詢</h1><span id="snippetCount">0 項</span></div><div class="search-wrap"><label class="field-label" for="syntaxSearch">輸入語法或用途</label><input class="text-field" id="syntaxSearch" type="search"><div class="chip-row" id="categoryFilters"></div></div><div class="snippet-list" id="snippetList"></div></section></aside>
6
  <section class="panel editor-panel mobile-active" id="editorPanel"><div class="panel-heading dark"><span class="file-name" id="activeFileLabel">main.py</span><div class="editor-toolbar"><button class="btn stop compact" id="stopButton" disabled>停止</button><button class="btn compact" id="runButton" disabled>載入 Python…</button></div></div><div class="editor-host"><textarea id="codeEditor" aria-label="程式碼"></textarea></div></section>
7
- <aside class="panel console-panel" id="consolePanel"><div class="panel-heading"><h2>互動式 Console</h2><button class="copy-button" id="clearOutputButton">清除</button></div><div class="console-body"><div class="console-stream"><pre class="console-output" id="consoleOutput">Python 執行環境載入中…</pre><form class="console-input-row" id="consoleInputForm" hidden><span aria-hidden="true">›</span><label class="sr-only" for="consoleInput">Python 程式要求輸入</label><input id="consoleInput" autocomplete="off"><button class="btn compact" type="submit">送出</button></form></div><p class="console-hint">執行 <code>main.py</code>;遇到 <code>input()</code> 時會在這裡詢問。</p><div class="console-status"><i class="status-dot" id="runStatusDot"></i><span id="runStatusText">準備環境</span><span>·</span><span id="saveStatus">尚未同步</span><span>·</span><span id="submitStatus">本週尚未提交</span></div></div></aside></main>
8
  <dialog id="identityDialog"><form class="dialog-content form-stack" id="identityForm"><div><h1>進入程式教室</h1><p>選擇班級並輸入自己在名單上的姓名。</p></div><div><label class="field-label" for="classCode">課程</label><select class="text-field" id="classCode" required></select></div><div><label class="field-label" for="displayName">姓名</label><input class="text-field" id="displayName" autocomplete="name" required><p class="field-help">姓名必須與老師建立的名單一致。</p></div><p class="form-error" id="identityError" role="alert"></p><button class="btn" type="submit">進入教室</button></form></dialog>
9
- <dialog id="progressDialog"><div class="dialog-content"><h2>選擇正式進度</h2><p>匯入後會成為目前週次的工作副本。</p><div class="progress-list" id="progressList"></div><button class="btn secondary dialog-close">關閉</button></div></dialog><div class="toast" id="toast"></div>
10
- <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/python/python.min.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script><script src="/classroom-assets/student.js?v=2026fall-5"></script></body></html>
 
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>
classroom_static/student.js CHANGED
@@ -13,7 +13,7 @@ function openFile(path){if(!(path in files))return;if(activeFile in files&&!supp
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"})}`:"本週尚未提交"}
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})}
@@ -21,11 +21,11 @@ function renderSyntax(){const cats=["全部",...new Set(SNIPPETS.map(s=>s.catego
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("這週尚未發布套版");const d=await r.json(),w=course.weeks.find(x=>x.id===week());setProject(d.files,w?.project);toast("已匯入本週套版")};
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)=>`<button class="progress-item" data-i="${i}"><strong>${esc(p.week_id)}|${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("設為本週最後正式版本?重新提交會覆蓋前一版。"))return;send({type:"submit",files});submittedAt=Date.now();renderState();toast("本週正式版本已提交")};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}">第 ${w.week} 週|${esc(w.title)}${w.template?"":"(規劃)"}</option>`).join("");E.weekSelect.value=localStorage.getItem("classroom_week")||"week02";renderFiles();renderSyntax();initWorker();connect()})();
 
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})}
 
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()})();
classroom_static/teacher.html CHANGED
@@ -3,7 +3,7 @@
3
  <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Python 專案教室|教師控制台</title><link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-5"></head>
4
  <body>
5
  <header class="app-header"><div class="brand"><div class="brand-mark">Py</div><div class="brand-copy"><strong>全班專案控制台</strong><span id="classLabel">尚未進入班級</span></div></div><div class="header-actions"><div class="connection-state"><i id="connectionDot" class="status-dot"></i><span id="connectionText">未連線</span></div><a class="btn secondary compact header-link" href="/admin">內容管理</a><button class="btn secondary compact" id="loginButton">切換班級</button></div></header>
6
- <section class="project-bar"><div class="project-context"><label for="teacherWeek">週次</label><select id="teacherWeek"></select><span id="submissionSummary" class="submission-summary">等待學生</span></div><div class="project-actions"><button class="copy-button" id="downloadButton">下載全班 ZIP</button><button class="btn compact" id="publishButton">結算並發布 Demo</button></div></section>
7
  <main class="teacher-layout">
8
  <section class="roster-panel">
9
  <div class="panel-heading"><h1>學生狀態</h1><span id="studentTotal">0 人</span></div>
@@ -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-7"></script>
18
  </body>
19
  </html>
 
3
  <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Python 專案教室|教師控制台</title><link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-5"></head>
4
  <body>
5
  <header class="app-header"><div class="brand"><div class="brand-mark">Py</div><div class="brand-copy"><strong>全班專案控制台</strong><span id="classLabel">尚未進入班級</span></div></div><div class="header-actions"><div class="connection-state"><i id="connectionDot" class="status-dot"></i><span id="connectionText">未連線</span></div><a class="btn secondary compact header-link" href="/admin">內容管理</a><button class="btn secondary compact" id="loginButton">切換班級</button></div></header>
6
+ <section class="project-bar"><div class="project-context"><label for="teacherWeek">Lesson</label><select id="teacherWeek"></select><span id="submissionSummary" class="submission-summary">等待學生</span></div><div class="project-actions"><button class="copy-button" id="downloadButton">下載全班 ZIP</button><button class="btn compact" id="publishButton">結算並發布 Demo</button></div></section>
7
  <main class="teacher-layout">
8
  <section class="roster-panel">
9
  <div class="panel-heading"><h1>學生狀態</h1><span id="studentTotal">0 人</span></div>
 
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>
classroom_static/teacher.js CHANGED
@@ -71,7 +71,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 = () => {
@@ -96,7 +96,7 @@ function connect() {
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.value}`;
100
  E.connectionText.textContent = "連線中";
101
  const current = new WebSocket(`${scheme}://${location.host}/ws/classroom/teacher?${query}`);
102
  socket = current;
@@ -240,7 +240,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} 份 ${E.teacherWeek.value} 最後版本發布到 Project_Demo。Space 會重新建置。`;
244
  E.publishDialog.showModal();
245
  };
246
  E.cancelPublish.onclick = () => E.publishDialog.close();
@@ -261,7 +261,7 @@ setInterval(() => {
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}">第 ${item.week} 週|${esc(item.title)}</option>`).join("");
265
  E.teacherWeek.value = "week02";
266
  await loadCatalog();
267
  E.loginDialog.showModal();
 
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 = () => {
 
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 = "連線中";
101
  const current = new WebSocket(`${scheme}://${location.host}/ws/classroom/teacher?${query}`);
102
  socket = current;
 
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();
 
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();
tests/test_classroom.py CHANGED
@@ -63,6 +63,7 @@ class ClassroomServiceTests(unittest.TestCase):
63
  def test_course_and_week_templates_are_bundled(self):
64
  course = json.loads((classroom.DATA / "course.json").read_text(encoding="utf-8"))
65
  self.assertEqual(len(course["weeks"]), 18)
 
66
  self.assertEqual(set(classroom.template_files("week02")), {"main.py", "case_data.json"})
67
  self.assertEqual(set(classroom.template_files("week03")), {"main.py", "case_data.json"})
68
 
 
63
  def test_course_and_week_templates_are_bundled(self):
64
  course = json.loads((classroom.DATA / "course.json").read_text(encoding="utf-8"))
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