steveyehtw commited on
Commit
bfe0d21
·
1 Parent(s): 5fbb440

Protect student drafts during reconnects and workspace changes

Browse files
README.md CHANGED
@@ -32,6 +32,9 @@ Lesson 19~22 延伸為四週數據分析特別課程,使用公開 CSV、問
32
  - 學生端的保存分三層:「自動同步」更新師生即時可見的工作草稿,但不增加歷史版本;「暫存版本」建立可從舊進度載回的檢查點;「正式提交」凍結一份供老師檢查、整班下載或另行發布的版本。發布只採用正式提交,不會包含暫存。
33
  - 教師個別學生畫面可切換查看目前草稿與最近的正式提交;學生繼續編輯後,草稿會變動,已提交版本保持不變。
34
  - 學生成功呼叫老師後有 5 分鐘冷卻,伺服器會檢查;取消求助或老師標記已回應都不會重置計時。冷卻按同一班級與學生計算,跨 Lesson 共用。
 
 
 
35
  - 學生端 Python 會逐段顯示輸出;程式執行到 `input()` 時,Console 才出現輸入列並等待送出。
36
  - 內容管理後台可修改 Lesson 路線與各 Lesson 套版,也可新增、改名、編輯或刪除公開 Demo 專題。所有儲存都會寫回此 Space。
37
 
 
32
  - 學生端的保存分三層:「自動同步」更新師生即時可見的工作草稿,但不增加歷史版本;「暫存版本」建立可從舊進度載回的檢查點;「正式提交」凍結一份供老師檢查、整班下載或另行發布的版本。發布只採用正式提交,不會包含暫存。
33
  - 教師個別學生畫面可切換查看目前草稿與最近的正式提交;學生繼續編輯後,草稿會變動,已提交版本保持不變。
34
  - 學生成功呼叫老師後有 5 分鐘冷卻,伺服器會檢查;取消求助或老師標記已回應都不會重置計時。冷卻按同一班級與學生計算,跨 Lesson 共用。
35
+ - 工作區重連時,伺服器的空白初始檔不會覆蓋此裝置已有的程式;切換 Lesson 後,舊連線與延遲同步不能寫入新工作區。
36
+ - 載入另一份進度、匯入專案或遇到還原衝突時,會保留最多五份不同內容的本機副本,可從「選擇舊進度」中的「此裝置備份」載回。本機副本只存在原瀏覽器,清除網站資料後無法保留。
37
+ - 同一位學生在另一個分頁開啟相同工作區後,原分頁會停止自動重連並停止編輯,避免兩個分頁互相覆寫。
38
  - 學生端 Python 會逐段顯示輸出;程式執行到 `input()` 時,Console 才出現輸入列並等待送出。
39
  - 內容管理後台可修改 Lesson 路線與各 Lesson 套版,也可新增、改名、編輯或刪除公開 Demo 專題。所有儲存都會寫回此 Space。
40
 
classroom_service.py CHANGED
@@ -192,7 +192,7 @@ def start_help_cooldown(class_code,student_id):
192
  cooldown_until=current+HELP_REQUEST_COOLDOWN_MS
193
  db.execute("INSERT INTO classroom_help_cooldowns VALUES(?,?,?,?) ON CONFLICT(class_code,student_id) DO UPDATE SET last_requested_at=excluded.last_requested_at,cooldown_until=excluded.cooldown_until",(class_code,student_id,current,cooldown_until))
194
  return current,cooldown_until
195
- 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,"checkpoint_files":None,"active_file":"main.py","output":"","status":"idle","needs_help":False,"updated_at":now(),"submitted_at":None,"checkpoint_at":None,"help_requested_at":None,"help_cooldown_until":help_cooldown_until(c,s),"help_cooldown_ms":HELP_REQUEST_COOLDOWN_MS,"connected":True}
196
  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],"checkpoint_files":json.loads(r[13]) if r[13] else None,"checkpoint_at":r[14],"help_requested_at":r[15],"help_cooldown_ms":HELP_REQUEST_COOLDOWN_MS,"connected":connected}
197
  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,checkpoint_files,checkpoint_at,help_requested_at FROM classroom_projects"
198
  def load(c,s,n,w):
@@ -526,6 +526,7 @@ async def student_ws(ws:WebSocket):
526
  try:
527
  while True:
528
  m=await ws.receive_json()
 
529
  if not allow_student_message(key):
530
  await ws.close(code=4429,reason="message rate limited");break
531
  kind=m.get("type");version=None
 
192
  cooldown_until=current+HELP_REQUEST_COOLDOWN_MS
193
  db.execute("INSERT INTO classroom_help_cooldowns VALUES(?,?,?,?) ON CONFLICT(class_code,student_id) DO UPDATE SET last_requested_at=excluded.last_requested_at,cooldown_until=excluded.cooldown_until",(class_code,student_id,current,cooldown_until))
194
  return current,cooldown_until
195
+ 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,"checkpoint_files":None,"active_file":"main.py","output":"","status":"idle","needs_help":False,"updated_at":0,"submitted_at":None,"checkpoint_at":None,"help_requested_at":None,"help_cooldown_until":help_cooldown_until(c,s),"help_cooldown_ms":HELP_REQUEST_COOLDOWN_MS,"connected":True}
196
  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],"checkpoint_files":json.loads(r[13]) if r[13] else None,"checkpoint_at":r[14],"help_requested_at":r[15],"help_cooldown_ms":HELP_REQUEST_COOLDOWN_MS,"connected":connected}
197
  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,checkpoint_files,checkpoint_at,help_requested_at FROM classroom_projects"
198
  def load(c,s,n,w):
 
526
  try:
527
  while True:
528
  m=await ws.receive_json()
529
+ if students.get(key) is not ws:break
530
  if not allow_student_message(key):
531
  await ws.close(code=4429,reason="message rate limited");break
532
  kind=m.get("type");version=None
classroom_static/student.html CHANGED
@@ -143,6 +143,6 @@
143
  <script src="/classroom-assets/vendor/codemirror/python.js"></script>
144
  <script src="/classroom-assets/vendor/jszip/jszip.min.js"></script>
145
  <script src="/classroom-assets/pygame-support.js?v=2026fall-1"></script>
146
- <script src="/classroom-assets/student.js?v=2026fall-19"></script>
147
  </body>
148
  </html>
 
143
  <script src="/classroom-assets/vendor/codemirror/python.js"></script>
144
  <script src="/classroom-assets/vendor/jszip/jszip.min.js"></script>
145
  <script src="/classroom-assets/pygame-support.js?v=2026fall-1"></script>
146
+ <script src="/classroom-assets/student.js?v=2026fall-20"></script>
147
  </body>
148
  </html>
classroom_static/student.js CHANGED
@@ -1,21 +1,47 @@
1
  const SNIPPETS=[["基礎","輸入與輸出",'name = input("名字:")\nprint("你好", name)'],["判斷","if / elif / else",'if score >= 90:\n print("A")\nelse:\n print("繼續加油")'],["迴圈","for 與 range",'for number in range(1, 6):\n print(number)'],["迴圈","while",'while True:\n choice = input("選擇:")\n if choice == "q":\n break'],["資料","list",'items = []\nitems.append("線索")\nfor item in items:\n print(item)'],["資料","dict",'student = {"name": "Alex"}\nprint(student.get("team", "未分組"))'],["函式","def / return",'def add(a, b):\n return a + b'],["檔案","引入自己的模組",'from clues import find_clue'],["檔案","讀取 JSON",'import json\nwith open("data.json", encoding="utf-8") as file:\n data = json.load(file)'],["錯誤","try / except",'try:\n age = int(input("年齡:"))\nexcept ValueError:\n print("請輸入整數")'],["Pygame","瀏覽器版 Pygame 入門",'import asyncio\nimport pygame\n\nasync def main():\n pygame.init()\n screen = pygame.display.set_mode((640, 480))\n pygame.display.set_caption("我的第一個遊戲")\n clock = pygame.time.Clock()\n x = 300\n running = True\n\n while running:\n for event in pygame.event.get():\n if event.type == pygame.QUIT:\n running = False\n\n keys = pygame.key.get_pressed()\n if keys[pygame.K_LEFT]:\n x -= 4\n if keys[pygame.K_RIGHT]:\n x += 4\n\n screen.fill((24, 34, 52))\n pygame.draw.circle(screen, (255, 190, 80), (x, 240), 28)\n pygame.display.flip()\n await asyncio.sleep(1 / 60)\n\n pygame.quit()\n\n# 瀏覽器版會自動呼叫 async main(),不用另外加 asyncio.run(main())']].map(([category,title,code])=>({category,title,code}));
2
  const $=id=>document.getElementById(id),E={};["identityDialog","identityForm","identityError","classCode","displayName","studentIdentity","identityButton","connectionDot","connectionText","helpButton","helpStatus","weekSelect","projectName","templateButton","progressButton","importButton","exportButton","checkpointButton","submitButton","fileImport","fileList","newFileButton","activeFileLabel","lessonHeading","lessonLabel","lessonGuideContent","syntaxSearch","categoryFilters","snippetList","snippetCount","runButton","stopButton","toggleReferenceButton","toggleConsoleButton","studentLayout","editorPanel","referencePanel","consolePanel","referenceResizer","consoleResizer","consoleInputForm","consoleInput","consoleOutput","consoleTabs","consoleOutputTab","pygameOutputTab","consoleStream","pygameFrame","consoleHint","clearOutputButton","runStatusDot","runStatusText","saveStatus","submitStatus","toast","progressDialog","progressList"].forEach(id=>E[id]=$(id));
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")||"",allowLegacyDraft=!!studentId,catalog={courses:[]};
5
  const HELP_COOLDOWN_MS=300000;
6
  let course,files={"main.py":"# 在這裡寫 Python\n"},activeFile="main.py",socket,worker,workerReady=false,running=false,pygameRunning=false,needsHelp=false,helpCooldownUntil=null,helpCooldownMs=HELP_COOLDOWN_MS,checkpointAt=null,submittedAt=null,output="",suppress=false,timer,reconnect,category="全部",inputBuffer,inputControl,inputBytes,stdoutDecoder=new TextDecoder(),stderrDecoder=new TextDecoder();
7
  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")}_${studentId}_${workspace()}`,legacyKey=()=>`classroom_project_${localStorage.getItem("classroom_code")}_${workspace()}`;
8
  function toast(t){E.toast.textContent=t;E.toast.classList.add("visible");setTimeout(()=>E.toast.classList.remove("visible"),1900)}
9
- function send(m){if(socket?.readyState!==WebSocket.OPEN)return false;socket.send(JSON.stringify(m));return true}
10
  function esc(t){return String(t).replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;")}
 
11
  function readLocal(){for(const storageKey of [key(),...(allowLegacyDraft?[legacyKey()]:[])]){try{const value=JSON.parse(localStorage.getItem(storageKey)||"null");if(value?.files)return value}catch(_){}}return null}
12
- function saveLocal(){localStorage.setItem(key(),JSON.stringify({files:{...files},activeFile,projectName:E.projectName.value,output,savedAt:Date.now()}))}
13
- function sync(){saveLocal();E.saveStatus.textContent="同步中";clearTimeout(timer);timer=setTimeout(()=>send({type:"project",files,active_file:activeFile,project_name:E.projectName.value}),450)}
14
- 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()}
15
- 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()}
16
- 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()}})}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  function connect(){
18
- clearTimeout(reconnect);
 
 
19
  const previous=socket;
20
  if(previous){previous.onclose=null;previous.close()}
21
  const c=clean(localStorage.getItem("classroom_code")),n=localStorage.getItem("classroom_name")?.trim();
@@ -24,14 +50,20 @@ function connect(){
24
  const scheme=location.protocol==="https:"?"wss":"ws";
25
  const q=new URLSearchParams({class_code:c,student_id:studentId,display_name:n,week_id:workspace()});
26
  const current=new WebSocket(`${scheme}://${location.host}/ws/classroom/student?${q}`);socket=current;
27
- current.onopen=()=>{E.connectionDot.className="status-dot online";E.connectionText.textContent="已連線"};
 
28
  current.onmessage=e=>{
 
29
  const m=JSON.parse(e.data);
30
  if(m.type==="restore"){
31
  const local=readLocal(),s=m.state;
32
  const server={files:s.files,activeFile:s.active_file,projectName:s.project_name,output:s.output};
33
- const src=local&&Number(local.savedAt||0)>Number(s.updated_at||0)?local:server;
34
- output=src.output||"";E.consoleOutput.textContent=output||"尚無執行結果。";checkpointAt=s.checkpoint_at;submittedAt=s.submitted_at;needsHelp=!!s.needs_help;helpCooldownUntil=s.help_cooldown_until;helpCooldownMs=Number(s.help_cooldown_ms??helpCooldownMs);renderLesson();renderState();setProject(src.files,src.projectName,src.activeFile);send({type:"output",output});
 
 
 
 
35
  }
36
  if(m.type==="identity"&&typeof m.display_name==="string"){
37
  localStorage.setItem("classroom_name",m.display_name);
@@ -43,7 +75,7 @@ function connect(){
43
  if(m.type==="write_rejected"){E.saveStatus.textContent="未儲存";if(m.kind==="checkpoint"){E.checkpointButton.disabled=false;E.checkpointButton.textContent="暫存版本"}if(m.kind==="submit"){E.submitButton.disabled=false;E.submitButton.textContent="正式提交"}toast(m.message||"儲存受到限制,請稍後再試");send({type:"ping"})}
44
  if(["ack","pong"].includes(m.type)){if(typeof m.needs_help==="boolean"){const seen=needsHelp&&!m.needs_help;needsHelp=m.needs_help;if(seen){E.helpStatus.textContent="老師已回應你的求助。";toast("老師已回應你的求助")}}if(Object.prototype.hasOwnProperty.call(m,"help_cooldown_until"))helpCooldownUntil=m.help_cooldown_until;if(Object.prototype.hasOwnProperty.call(m,"help_cooldown_ms")){const duration=Number(m.help_cooldown_ms);if(duration!==helpCooldownMs){helpCooldownMs=duration;renderLesson()}}if(Object.prototype.hasOwnProperty.call(m,"checkpoint_at"))checkpointAt=m.checkpoint_at;if(Object.prototype.hasOwnProperty.call(m,"submitted_at"))submittedAt=m.submitted_at;renderState();if(m.type==="ack"){E.saveStatus.textContent="已同步";if(m.version_kind==="checkpoint"){E.checkpointButton.disabled=false;E.checkpointButton.textContent="暫存版本";toast("暫存版本已建立,可以從進度載回")}if(m.version_kind==="submit"){E.submitButton.disabled=false;E.submitButton.textContent="正式提交";toast("正式提交已完成;尚未發布 Demo")}}if(m.version_warning)toast(m.version_warning)}
45
  };
46
- current.onclose=async e=>{if(socket!==current)return;E.connectionDot.className="status-dot error";E.connectionText.textContent="已離線";E.saveStatus.textContent="保存在此裝置";E.checkpointButton.disabled=false;E.checkpointButton.textContent="暫存版本";E.submitButton.disabled=false;E.submitButton.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)};
47
  }
48
  function helpCooldownLabel(){return helpCooldownMs>=60000?`${Math.ceil(helpCooldownMs/60000)} 分鐘`:helpCooldownMs>0?`${Math.ceil(helpCooldownMs/1000)} 秒`:"沒有冷卻"}
49
  function renderHelpButton(){const remaining=Math.max(0,Number(helpCooldownUntil||0)-Date.now()),seconds=Math.ceil(remaining/1000),label=needsHelp?"取消求助請求":remaining?`再次呼叫 ${Math.floor(seconds/60)}:${String(seconds%60).padStart(2,"0")}`:"請老師協助";E.helpButton.disabled=!needsHelp&&remaining>0;E.helpButton.classList.toggle("active",needsHelp);E.helpButton.textContent=label;E.helpButton.title=needsHelp?`老師已收到求助;取消後仍會保留 ${helpCooldownLabel()} 冷卻時間。`:`成功呼叫後有 ${helpCooldownLabel()} 冷卻。`}
@@ -132,13 +164,17 @@ function renderSyntax(){const cats=["全部",...new Set(SNIPPETS.map(s=>s.catego
132
  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")||""}
133
  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||"無法驗證名單");allowLegacyDraft=studentId===data.student_id;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;
134
  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()};
135
- 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("已匯入專案起始套版")};
136
- 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(),source={published:"已發布",submit:"正式提交",checkpoint:"進度暫存"};E.progressList.innerHTML=d.progress.length?d.progress.map((p,i)=>{const item=course.weeks.find(w=>w.id===p.week_id),shared=course.weeks.filter(w=>(w.workspace_id||w.id)===p.week_id),lessons=shared.map(w=>w.lesson??w.week),label=shared.length>1?`Lesson ${lessons[0]}–${lessons[lessons.length-1]} 共用專案`: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>${source[p.source]||"已儲存"} · ${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();toast("已載入這個版本的工作副本")});E.progressDialog.showModal()};document.querySelectorAll(".dialog-close").forEach(b=>b.onclick=()=>b.closest("dialog").close());
137
- 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("已匯入專案")};
 
 
138
  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)};
139
  E.checkpointButton.onclick=()=>{files[activeFile]=editor.getValue();if(!files["main.py"].trim())return toast("main.py 不能為空");saveLocal();if(!send({type:"checkpoint",files,project_name:E.projectName.value}))return toast("目前離線,請連線後再暫存");E.checkpointButton.disabled=true;E.checkpointButton.textContent="正在暫存…"};
140
  E.submitButton.onclick=()=>{files[activeFile]=editor.getValue();if(!files["main.py"].trim())return toast("main.py 不能為空");if(!confirm("正式提交目前版本給老師檢查?你之後仍可繼續修改並再次提交。"))return;saveLocal();if(!send({type:"submit",files,project_name:E.projectName.value}))return toast("目前離線,請連線後再提交");E.submitButton.disabled=true;E.submitButton.textContent="正在提交…"};
141
  E.helpButton.onclick=()=>{if(!socket||socket.readyState!==WebSocket.OPEN)return toast("目前離線,連線後才能呼叫老師");if(needsHelp){needsHelp=false;E.helpStatus.textContent=`已取消求助;${helpCooldownLabel()} 冷卻仍會繼續。`;send({type:"help",needs_help:false});renderState();return}if(Date.now()<Number(helpCooldownUntil||0))return;needsHelp=true;helpCooldownUntil=Date.now()+helpCooldownMs;E.helpStatus.textContent="已通知老師。等待時請記下��前卡住的步驟和試過的方法。";send({type:"help",needs_help:true});renderState()};
142
- E.runButton.onclick=async()=>{if(!workerReady||running)return;files[activeFile]=editor.getValue();output="";E.consoleOutput.textContent="";E.consoleInputForm.hidden=true;stdoutDecoder=new TextDecoder();stderrDecoder=new TextDecoder();if(ClassroomPygame.isPygame(files)){pygameRunning=true;E.consoleTabs.hidden=false;E.pygameFrame.hidden=false;setConsoleView("pygame");runState("running","準備瀏覽器版 Pygame");try{await ClassroomPygame.run(files)}catch(error){if(!running)return;pygameRunning=false;append(`Pygame 載入失敗:${error.message}\n`);runState("error","Pygame 載入失敗");setConsoleView("console")}return}ClassroomPygame.stop();pygameRunning=false;E.consoleTabs.hidden=true;E.pygameFrame.hidden=true;setConsoleView("console");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");if(pygameRunning){ClassroomPygame.stop();pygameRunning=false;setConsoleView("console");runState("idle","已停止");return}runState("idle","已停止");initWorker()};E.consoleOutputTab.onclick=()=>setConsoleView("console");E.pygameOutputTab.onclick=()=>setConsoleView("pygame");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()}});
143
  E.toggleReferenceButton.onclick=()=>setLayout(!E.studentLayout.classList.contains("reference-hidden"),E.studentLayout.classList.contains("console-hidden"));E.toggleConsoleButton.onclick=()=>setLayout(E.studentLayout.classList.contains("reference-hidden"),!E.studentLayout.classList.contains("console-hidden"));document.querySelectorAll(".side-tab").forEach(b=>b.onclick=()=>setSideView(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")refreshEditorLayout()});let helpWasCooling=false;setInterval(()=>{const cooling=Date.now()<Number(helpCooldownUntil||0);if(helpWasCooling&&!cooling)E.helpStatus.textContent="冷卻時間已結束,現在可以再次呼叫老師。";helpWasCooling=cooling;renderHelpButton()},1000);setInterval(()=>send({type:"ping"}),4000);
144
  (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();initResizablePanels();setLayout(localStorage.getItem("classroom_hide_reference")==="true",localStorage.getItem("classroom_hide_console")==="true");initWorker();connect()})();
 
 
 
1
  const SNIPPETS=[["基礎","輸入與輸出",'name = input("名字:")\nprint("你好", name)'],["判斷","if / elif / else",'if score >= 90:\n print("A")\nelse:\n print("繼續加油")'],["迴圈","for 與 range",'for number in range(1, 6):\n print(number)'],["迴圈","while",'while True:\n choice = input("選擇:")\n if choice == "q":\n break'],["資料","list",'items = []\nitems.append("線索")\nfor item in items:\n print(item)'],["資料","dict",'student = {"name": "Alex"}\nprint(student.get("team", "未分組"))'],["函式","def / return",'def add(a, b):\n return a + b'],["檔案","引入自己的模組",'from clues import find_clue'],["檔案","讀取 JSON",'import json\nwith open("data.json", encoding="utf-8") as file:\n data = json.load(file)'],["錯誤","try / except",'try:\n age = int(input("年齡:"))\nexcept ValueError:\n print("請輸入整數")'],["Pygame","瀏覽器版 Pygame 入門",'import asyncio\nimport pygame\n\nasync def main():\n pygame.init()\n screen = pygame.display.set_mode((640, 480))\n pygame.display.set_caption("我的第一個遊戲")\n clock = pygame.time.Clock()\n x = 300\n running = True\n\n while running:\n for event in pygame.event.get():\n if event.type == pygame.QUIT:\n running = False\n\n keys = pygame.key.get_pressed()\n if keys[pygame.K_LEFT]:\n x -= 4\n if keys[pygame.K_RIGHT]:\n x += 4\n\n screen.fill((24, 34, 52))\n pygame.draw.circle(screen, (255, 190, 80), (x, 240), 28)\n pygame.display.flip()\n await asyncio.sleep(1 / 60)\n\n pygame.quit()\n\n# 瀏覽器版會自動呼叫 async main(),不用另外加 asyncio.run(main())']].map(([category,title,code])=>({category,title,code}));
2
  const $=id=>document.getElementById(id),E={};["identityDialog","identityForm","identityError","classCode","displayName","studentIdentity","identityButton","connectionDot","connectionText","helpButton","helpStatus","weekSelect","projectName","templateButton","progressButton","importButton","exportButton","checkpointButton","submitButton","fileImport","fileList","newFileButton","activeFileLabel","lessonHeading","lessonLabel","lessonGuideContent","syntaxSearch","categoryFilters","snippetList","snippetCount","runButton","stopButton","toggleReferenceButton","toggleConsoleButton","studentLayout","editorPanel","referencePanel","consolePanel","referenceResizer","consoleResizer","consoleInputForm","consoleInput","consoleOutput","consoleTabs","consoleOutputTab","pygameOutputTab","consoleStream","pygameFrame","consoleHint","clearOutputButton","runStatusDot","runStatusText","saveStatus","submitStatus","toast","progressDialog","progressList"].forEach(id=>E[id]=$(id));
3
  const editor=CodeMirror.fromTextArea($("codeEditor"),{mode:"python",theme:"material-darker",lineNumbers:true,indentUnit:4,tabSize:4,extraKeys:{Tab:cm=>cm.replaceSelection(" ")}});
4
+ let loadedStorageKey=null,restoredSocket=null,draftSavedAt=0;
5
  let studentId=localStorage.getItem("classroom_student_id")||"",allowLegacyDraft=!!studentId,catalog={courses:[]};
6
  const HELP_COOLDOWN_MS=300000;
7
  let course,files={"main.py":"# 在這裡寫 Python\n"},activeFile="main.py",socket,worker,workerReady=false,running=false,pygameRunning=false,needsHelp=false,helpCooldownUntil=null,helpCooldownMs=HELP_COOLDOWN_MS,checkpointAt=null,submittedAt=null,output="",suppress=false,timer,reconnect,category="全部",inputBuffer,inputControl,inputBytes,stdoutDecoder=new TextDecoder(),stderrDecoder=new TextDecoder();
8
  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")}_${studentId}_${workspace()}`,legacyKey=()=>`classroom_project_${localStorage.getItem("classroom_code")}_${workspace()}`;
9
  function toast(t){E.toast.textContent=t;E.toast.classList.add("visible");setTimeout(()=>E.toast.classList.remove("visible"),1900)}
10
+ function send(m){if(socket?.readyState!==WebSocket.OPEN||restoredSocket!==socket||loadedStorageKey!==key())return false;socket.send(JSON.stringify(m));return true}
11
  function esc(t){return String(t).replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;")}
12
+ function hasWork(projectFiles){return Object.entries(projectFiles||{}).some(([path,value])=>typeof value==="string"&&(path!=="main.py"||!["","# 在這裡寫 Python","# 專案入口"].includes(value.trim())))}
13
  function readLocal(){for(const storageKey of [key(),...(allowLegacyDraft?[legacyKey()]:[])]){try{const value=JSON.parse(localStorage.getItem(storageKey)||"null");if(value?.files)return value}catch(_){}}return null}
14
+ function backupLocal(storageKey,value){
15
+ if(!storageKey||!hasWork(value?.files))return;
16
+ try{const backupKey=`${storageKey}_recovery`,items=JSON.parse(localStorage.getItem(backupKey)||"[]"),fingerprint=JSON.stringify(value.files);if(items.some(item=>JSON.stringify(item.files)===fingerprint))return;localStorage.setItem(backupKey,JSON.stringify([{...value,savedAt:value.savedAt||Date.now()},...items].slice(0,5)))}catch(_){toast("本機備份空間不足,請匯出 ZIP 保存作品")}
17
+ }
18
+ function saveLocal(savedAt=Date.now()){
19
+ if(!loadedStorageKey)return;
20
+ draftSavedAt=savedAt;
21
+ try{const previous=JSON.parse(localStorage.getItem(loadedStorageKey)||"null");if(hasWork(previous?.files)&&!hasWork(files))backupLocal(loadedStorageKey,previous);localStorage.setItem(loadedStorageKey,JSON.stringify({files:{...files},activeFile,projectName:E.projectName.value,output,savedAt}))}catch(_){toast("本機儲存空間不足,請匯出 ZIP 保存作品")}
22
+ }
23
+ function scheduleSync(){
24
+ clearTimeout(timer);const current=socket,storageKey=loadedStorageKey;
25
+ const message={type:"project",files:{...files},active_file:activeFile,project_name:E.projectName.value};
26
+ timer=setTimeout(()=>{if(socket===current&&loadedStorageKey===storageKey&&send(message))E.saveStatus.textContent="同步中"},450);
27
+ }
28
+ function sync(){if(!loadedStorageKey||loadedStorageKey!==key())return;saveLocal();E.saveStatus.textContent=restoredSocket===socket?"同步中":"保存在此裝置";scheduleSync()}
29
+ function openFile(path){if(loadedStorageKey!==key()||!(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()}
30
+ function setProject(next,name,preferred="main.py",restoring=false){
31
+ if(!loadedStorageKey||loadedStorageKey!==key())return toast("工作區尚未載入完成,請稍候");
32
+ if(!restoring)backupLocal(loadedStorageKey,{files:{...files},activeFile,projectName:E.projectName.value,output,savedAt:Date.now()});
33
+ files=Object.fromEntries(Object.entries(next||{}).filter(([p,v])=>typeof v==="string"&&!p.includes("..")));files["main.py"]??="# 專案入口\n";E.projectName.value=name||"";const firstFile=Object.hasOwn(files,preferred)?preferred:"main.py";activeFile="";openFile(firstFile);if(!restoring)sync();
34
+ }
35
+ 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(loadedStorageKey!==key())return;if(confirm(`刪除 ${b.dataset.remove}?`)){delete files[b.dataset.remove];if(activeFile===b.dataset.remove)activeFile="";openFile(activeFile||"main.py");sync()}})}
36
+ function projectLoading(loading){
37
+ editor.setOption("readOnly",loading);
38
+ [E.projectName,E.newFileButton,E.importButton,E.progressButton,E.checkpointButton,E.submitButton].forEach(control=>control.disabled=loading);
39
+ E.templateButton.disabled=loading||!lessonInfo()?.template;
40
+ }
41
  function connect(){
42
+ clearTimeout(reconnect);clearTimeout(timer);
43
+ if(loadedStorageKey){files[activeFile]=editor.getValue();saveLocal(draftSavedAt)}
44
+ restoredSocket=null;projectLoading(true);
45
  const previous=socket;
46
  if(previous){previous.onclose=null;previous.close()}
47
  const c=clean(localStorage.getItem("classroom_code")),n=localStorage.getItem("classroom_name")?.trim();
 
50
  const scheme=location.protocol==="https:"?"wss":"ws";
51
  const q=new URLSearchParams({class_code:c,student_id:studentId,display_name:n,week_id:workspace()});
52
  const current=new WebSocket(`${scheme}://${location.host}/ws/classroom/student?${q}`);socket=current;
53
+ current.onopen=()=>{if(socket!==current)return;E.connectionDot.className="status-dot online";E.connectionText.textContent="已連線"};
54
+ const connectionKey=key();
55
  current.onmessage=e=>{
56
+ if(socket!==current||key()!==connectionKey)return;
57
  const m=JSON.parse(e.data);
58
  if(m.type==="restore"){
59
  const local=readLocal(),s=m.state;
60
  const server={files:s.files,activeFile:s.active_file,projectName:s.project_name,output:s.output};
61
+ const localWork=hasWork(local?.files),serverWork=hasWork(server.files);
62
+ const src=local&&(localWork&&!serverWork||(localWork===serverWork&&Number(local.savedAt||0)>Number(s.updated_at||0)))?local:server;
63
+ if(src===server)backupLocal(connectionKey,local);
64
+ else if(serverWork&&JSON.stringify(server.files)!==JSON.stringify(local.files))backupLocal(connectionKey,{...server,savedAt:s.updated_at});
65
+ loadedStorageKey=connectionKey;restoredSocket=current;projectLoading(false);
66
+ output=src.output||"";E.consoleOutput.textContent=output||"尚無執行結果。";checkpointAt=s.checkpoint_at;submittedAt=s.submitted_at;needsHelp=!!s.needs_help;helpCooldownUntil=s.help_cooldown_until;helpCooldownMs=Number(s.help_cooldown_ms??helpCooldownMs);renderLesson();renderState();setProject(src.files,src.projectName,src.activeFile,true);saveLocal(src===local?local.savedAt:s.updated_at);scheduleSync();send({type:"output",output});
67
  }
68
  if(m.type==="identity"&&typeof m.display_name==="string"){
69
  localStorage.setItem("classroom_name",m.display_name);
 
75
  if(m.type==="write_rejected"){E.saveStatus.textContent="未儲存";if(m.kind==="checkpoint"){E.checkpointButton.disabled=false;E.checkpointButton.textContent="暫存版本"}if(m.kind==="submit"){E.submitButton.disabled=false;E.submitButton.textContent="正式提交"}toast(m.message||"儲存受到限制,請稍後再試");send({type:"ping"})}
76
  if(["ack","pong"].includes(m.type)){if(typeof m.needs_help==="boolean"){const seen=needsHelp&&!m.needs_help;needsHelp=m.needs_help;if(seen){E.helpStatus.textContent="老師已回應你的求助。";toast("老師已回應你的求助")}}if(Object.prototype.hasOwnProperty.call(m,"help_cooldown_until"))helpCooldownUntil=m.help_cooldown_until;if(Object.prototype.hasOwnProperty.call(m,"help_cooldown_ms")){const duration=Number(m.help_cooldown_ms);if(duration!==helpCooldownMs){helpCooldownMs=duration;renderLesson()}}if(Object.prototype.hasOwnProperty.call(m,"checkpoint_at"))checkpointAt=m.checkpoint_at;if(Object.prototype.hasOwnProperty.call(m,"submitted_at"))submittedAt=m.submitted_at;renderState();if(m.type==="ack"){E.saveStatus.textContent="已同步";if(m.version_kind==="checkpoint"){E.checkpointButton.disabled=false;E.checkpointButton.textContent="暫存版本";toast("暫存版本已建立,可以從進度載回")}if(m.version_kind==="submit"){E.submitButton.disabled=false;E.submitButton.textContent="正式提交";toast("正式提交已完成;尚未發布 Demo")}}if(m.version_warning)toast(m.version_warning)}
77
  };
78
+ current.onclose=async e=>{if(socket!==current)return;restoredSocket=null;E.connectionDot.className="status-dot error";E.connectionText.textContent="已離線";E.saveStatus.textContent="保存在此裝置";E.checkpointButton.disabled=false;E.checkpointButton.textContent="暫存版本";E.submitButton.disabled=false;E.submitButton.textContent="正式提交";if(e.code===4001){loadedStorageKey=null;E.connectionText.textContent="另一個分頁已開啟";projectLoading(true);toast("此學生已在另一個分頁開啟,請使用該分頁繼續,避免互相覆蓋");return}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)};
79
  }
80
  function helpCooldownLabel(){return helpCooldownMs>=60000?`${Math.ceil(helpCooldownMs/60000)} 分鐘`:helpCooldownMs>0?`${Math.ceil(helpCooldownMs/1000)} 秒`:"沒有冷卻"}
81
  function renderHelpButton(){const remaining=Math.max(0,Number(helpCooldownUntil||0)-Date.now()),seconds=Math.ceil(remaining/1000),label=needsHelp?"取消求助請求":remaining?`再次呼叫 ${Math.floor(seconds/60)}:${String(seconds%60).padStart(2,"0")}`:"請老師協助";E.helpButton.disabled=!needsHelp&&remaining>0;E.helpButton.classList.toggle("active",needsHelp);E.helpButton.textContent=label;E.helpButton.title=needsHelp?`老師已收到求助;取消後仍會保留 ${helpCooldownLabel()} 冷卻時間。`:`成功呼叫後有 ${helpCooldownLabel()} 冷卻。`}
 
164
  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")||""}
165
  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||"無法驗證名單");allowLegacyDraft=studentId===data.student_id;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;
166
  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()};
167
+ E.templateButton.onclick=async()=>{const item=lessonInfo();if(!item?.template)return;if(!confirm("匯入起始套版會取代目前專案內容,繼續?"))return;const targetKey=key();const r=await fetch(`/api/classroom/template/${item.id}`);if(!r.ok)return toast("這個專案尚未發布套版");const d=await r.json();if(key()!==targetKey)return;setProject(d.files,item.project);toast("已匯入專案起始套版")};
168
+ E.progressButton.onclick=async()=>{const targetKey=key();const q=new URLSearchParams({class_code:localStorage.getItem("classroom_code")||"",student_id:studentId,display_name:localStorage.getItem("classroom_name")||""}),d=await fetch(`/api/classroom/progress?${q}`).then(r=>{if(!r.ok)throw Error("progress unavailable");return r.json()}).catch(()=>({progress:[]})),source={recovery:"此裝置備份",published:"已發布",submit:"正式提交",checkpoint:"進度暫存"};if(key()!==targetKey)return;
169
+ try{const backups=JSON.parse(localStorage.getItem(`${targetKey}_recovery`)||"[]");d.progress=[...backups.map(item=>({...item,week_id:workspace(),project_name:item.projectName||"本機副本",submitted_at:item.savedAt,source:"recovery"})),...d.progress]}catch(_){}
170
+ E.progressList.innerHTML=d.progress.length?d.progress.map((p,i)=>{const item=course.weeks.find(w=>w.id===p.week_id),shared=course.weeks.filter(w=>(w.workspace_id||w.id)===p.week_id),lessons=shared.map(w=>w.lesson??w.week),label=shared.length>1?`Lesson ${lessons[0]}–${lessons[lessons.length-1]} 共用專案`: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>${source[p.source]||"已儲存"} · ${new Date(p.submitted_at).toLocaleString("zh-TW")}</span></button>`}).join(""):'<p>還沒有已儲存的版本。</p>';E.progressList.querySelectorAll("[data-i]").forEach(b=>b.onclick=()=>{if(key()!==targetKey)return;const p=d.progress[+b.dataset.i];setProject(p.files,p.project_name);E.progressDialog.close();toast("已載入這個版本的工作副本")});E.progressDialog.showModal()};document.querySelectorAll(".dialog-close").forEach(b=>b.onclick=()=>b.closest("dialog").close());
171
+ E.importButton.onclick=()=>E.fileImport.click();E.fileImport.onchange=async()=>{const targetKey=key();const f=E.fileImport.files[0];if(!f)return;if(f.name.endsWith(".py")){const text=await f.text();if(key()!==targetKey)return;setProject({"main.py":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");if(key()!==targetKey)return;setProject(next,f.name.replace(/\.zip$/i,""))}E.fileImport.value="";toast("已匯入專案")};
172
  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)};
173
  E.checkpointButton.onclick=()=>{files[activeFile]=editor.getValue();if(!files["main.py"].trim())return toast("main.py 不能為空");saveLocal();if(!send({type:"checkpoint",files,project_name:E.projectName.value}))return toast("目前離線,請連線後再暫存");E.checkpointButton.disabled=true;E.checkpointButton.textContent="正在暫存…"};
174
  E.submitButton.onclick=()=>{files[activeFile]=editor.getValue();if(!files["main.py"].trim())return toast("main.py 不能為空");if(!confirm("正式提交目前版本給老師檢查?你之後仍可繼續修改並再次提交。"))return;saveLocal();if(!send({type:"submit",files,project_name:E.projectName.value}))return toast("目前離線,請連線後再提交");E.submitButton.disabled=true;E.submitButton.textContent="正在提交…"};
175
  E.helpButton.onclick=()=>{if(!socket||socket.readyState!==WebSocket.OPEN)return toast("目前離線,連線後才能呼叫老師");if(needsHelp){needsHelp=false;E.helpStatus.textContent=`已取消求助;${helpCooldownLabel()} 冷卻仍會繼續。`;send({type:"help",needs_help:false});renderState();return}if(Date.now()<Number(helpCooldownUntil||0))return;needsHelp=true;helpCooldownUntil=Date.now()+helpCooldownMs;E.helpStatus.textContent="已通知老師。等待時請記下��前卡住的步驟和試過的方法。";send({type:"help",needs_help:true});renderState()};
176
+ E.runButton.onclick=async()=>{if(!workerReady||running)return;files[activeFile]=editor.getValue();output="";E.consoleOutput.textContent="";E.consoleInputForm.hidden=true;stdoutDecoder=new TextDecoder();stderrDecoder=new TextDecoder();if(ClassroomPygame.isPygame(files)){pygameRunning=true;E.consoleTabs.hidden=false;E.pygameFrame.hidden=false;setConsoleView("pygame");runState("running","準備瀏覽器版 Pygame");try{await ClassroomPygame.run(files)}catch(error){if(!running)return;pygameRunning=false;append(`Pygame 載入失敗:${error.message}\n`);runState("error","Pygame 載入失敗");setConsoleView("console")}return}ClassroomPygame.stop();pygameRunning=false;E.consoleTabs.hidden=true;E.pygameFrame.hidden=true;setConsoleView("console");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");if(pygameRunning){ClassroomPygame.stop();pygameRunning=false;setConsoleView("console");runState("idle","已停止");return}runState("idle","已停止");initWorker()};E.consoleOutputTab.onclick=()=>setConsoleView("console");E.pygameOutputTab.onclick=()=>setConsoleView("pygame");E.clearOutputButton.onclick=()=>{output="";E.consoleOutput.textContent="尚無執行結果。";send({type:"output",output:""})};E.syntaxSearch.oninput=renderSyntax;editor.on("change",()=>{if(!suppress&&loadedStorageKey===key()){files[activeFile]=editor.getValue();sync()}});
177
  E.toggleReferenceButton.onclick=()=>setLayout(!E.studentLayout.classList.contains("reference-hidden"),E.studentLayout.classList.contains("console-hidden"));E.toggleConsoleButton.onclick=()=>setLayout(E.studentLayout.classList.contains("reference-hidden"),!E.studentLayout.classList.contains("console-hidden"));document.querySelectorAll(".side-tab").forEach(b=>b.onclick=()=>setSideView(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")refreshEditorLayout()});let helpWasCooling=false;setInterval(()=>{const cooling=Date.now()<Number(helpCooldownUntil||0);if(helpWasCooling&&!cooling)E.helpStatus.textContent="冷卻時間已結束,現在可以再次呼叫老師。";helpWasCooling=cooling;renderHelpButton()},1000);setInterval(()=>send({type:"ping"}),4000);
178
  (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();initResizablePanels();setLayout(localStorage.getItem("classroom_hide_reference")==="true",localStorage.getItem("classroom_hide_console")==="true");initWorker();connect()})();
179
+
180
+ window.addEventListener("pagehide",()=>{if(loadedStorageKey){files[activeFile]=editor.getValue();saveLocal(draftSavedAt)}});