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})); 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)); const editor=CodeMirror.fromTextArea($("codeEditor"),{mode:"python",theme:"material-darker",lineNumbers:true,indentUnit:4,tabSize:4,extraKeys:{Tab:cm=>cm.replaceSelection(" ")}}); let loadedStorageKey=null,restoredSocket=null,draftSavedAt=0; let studentId=localStorage.getItem("classroom_student_id")||"",allowLegacyDraft=!!studentId,catalog={courses:[]}; const HELP_COOLDOWN_MS=300000; 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(); 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()}`; function toast(t){E.toast.textContent=t;E.toast.classList.add("visible");setTimeout(()=>E.toast.classList.remove("visible"),1900)} function send(m){if(socket?.readyState!==WebSocket.OPEN||restoredSocket!==socket||loadedStorageKey!==key())return false;socket.send(JSON.stringify(m));return true} function esc(t){return String(t).replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""")} function hasWork(projectFiles){return Object.entries(projectFiles||{}).some(([path,value])=>typeof value==="string"&&(path!=="main.py"||!["","# 在這裡寫 Python","# 專案入口"].includes(value.trim())))} 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} function backupLocal(storageKey,value){ if(!storageKey||!hasWork(value?.files))return; 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 保存作品")} } function saveLocal(savedAt=Date.now()){ if(!loadedStorageKey)return; draftSavedAt=savedAt; 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 保存作品")} } function scheduleSync(){ clearTimeout(timer);const current=socket,storageKey=loadedStorageKey; const message={type:"project",files:{...files},active_file:activeFile,project_name:E.projectName.value}; timer=setTimeout(()=>{if(socket===current&&loadedStorageKey===storageKey&&send(message))E.saveStatus.textContent="同步中"},450); } function sync(){if(!loadedStorageKey||loadedStorageKey!==key())return;saveLocal();E.saveStatus.textContent=restoredSocket===socket?"同步中":"保存在此裝置";scheduleSync()} 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()} function setProject(next,name,preferred="main.py",restoring=false){ if(!loadedStorageKey||loadedStorageKey!==key())return toast("工作區尚未載入完成,請稍候"); if(!restoring)backupLocal(loadedStorageKey,{files:{...files},activeFile,projectName:E.projectName.value,output,savedAt:Date.now()}); 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(); } function renderFiles(){E.fileList.innerHTML=Object.keys(files).sort((a,b)=>a==="main.py"?-1:b==="main.py"?1:a.localeCompare(b)).map(p=>`
${p!=="main.py"?``:""}
`).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()}})} function projectLoading(loading){ editor.setOption("readOnly",loading); [E.projectName,E.newFileButton,E.importButton,E.progressButton,E.checkpointButton,E.submitButton].forEach(control=>control.disabled=loading); E.templateButton.disabled=loading||!lessonInfo()?.template; } function connect(){ clearTimeout(reconnect);clearTimeout(timer); if(loadedStorageKey){files[activeFile]=editor.getValue();saveLocal(draftSavedAt)} restoredSocket=null;projectLoading(true); 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"; const q=new URLSearchParams({class_code:c,student_id:studentId,display_name:n,week_id:workspace()}); const current=new WebSocket(`${scheme}://${location.host}/ws/classroom/student?${q}`);socket=current; current.onopen=()=>{if(socket!==current)return;E.connectionDot.className="status-dot online";E.connectionText.textContent="已連線"}; const connectionKey=key(); current.onmessage=e=>{ if(socket!==current||key()!==connectionKey)return; const m=JSON.parse(e.data); if(m.type==="restore"){ const local=readLocal(),s=m.state; const server={files:s.files,activeFile:s.active_file,projectName:s.project_name,output:s.output}; const localWork=hasWork(local?.files),serverWork=hasWork(server.files); const src=local&&(localWork&&!serverWork||(localWork===serverWork&&Number(local.savedAt||0)>Number(s.updated_at||0)))?local:server; if(src===server)backupLocal(connectionKey,local); else if(serverWork&&JSON.stringify(server.files)!==JSON.stringify(local.files))backupLocal(connectionKey,{...server,savedAt:s.updated_at}); loadedStorageKey=connectionKey;restoredSocket=current;projectLoading(false); 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}); } if(m.type==="identity"&&typeof m.display_name==="string"){ localStorage.setItem("classroom_name",m.display_name); E.studentIdentity.textContent=`${clean(localStorage.getItem("classroom_code"))} · ${m.display_name}`; toast("老師已更新你的名單姓名"); } if(m.type==="help_ack"){needsHelp=false;helpCooldownUntil=m.help_cooldown_until??helpCooldownUntil;const duration=Number(m.help_cooldown_ms??helpCooldownMs);if(duration!==helpCooldownMs){helpCooldownMs=duration;renderLesson()}E.helpStatus.textContent="老師已回應你的求助;冷卻結束後可以再次呼叫。";renderState();toast("老師已回應你的求助")} if(m.type==="help_rejected"){needsHelp=!!m.needs_help;helpCooldownUntil=m.help_cooldown_until??helpCooldownUntil;const duration=Number(m.help_cooldown_ms??helpCooldownMs);if(duration!==helpCooldownMs){helpCooldownMs=duration;renderLesson()}E.helpStatus.textContent=m.message||"請稍後再呼叫老師。";renderState();toast(m.message||"請稍後再呼叫老師")} 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"})} 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)} }; 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)}; } function helpCooldownLabel(){return helpCooldownMs>=60000?`${Math.ceil(helpCooldownMs/60000)} 分鐘`:helpCooldownMs>0?`${Math.ceil(helpCooldownMs/1000)} 秒`:"沒有冷卻"} 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()} 冷卻。`} function renderState(){renderHelpButton();const time=value=>new Date(value).toLocaleTimeString("zh-TW",{hour:"2-digit",minute:"2-digit"}),labels=[];if(checkpointAt)labels.push(`最近暫存 ${time(checkpointAt)}`);if(submittedAt)labels.push(`最近正式提交 ${time(submittedAt)}`);E.submitStatus.textContent=labels.join(" · ")||"尚無暫存或正式提交"} let editorRefreshFrame; function refreshEditorLayout(){cancelAnimationFrame(editorRefreshFrame);editorRefreshFrame=requestAnimationFrame(()=>{editor.refresh();editorRefreshFrame=requestAnimationFrame(()=>editor.refresh())})} const panelDefaults={reference:280,console:360},panelMinimums={reference:220,console:260},panelMaximums={reference:480,console:600}; function panelElement(side){return side==="reference"?E.referencePanel:E.consolePanel} function resizerElement(side){return side==="reference"?E.referenceResizer:E.consoleResizer} function panelLimits(side){ const other=side==="reference"?"console":"reference",otherHidden=E.studentLayout.classList.contains(`${other}-hidden`),otherWidth=otherHidden?0:panelElement(other).getBoundingClientRect().width; const dividerCount=(E.studentLayout.classList.contains("reference-hidden")?0:1)+(E.studentLayout.classList.contains("console-hidden")?0:1); const available=E.studentLayout.clientWidth-320-otherWidth-dividerCount*9; return {min:panelMinimums[side],max:Math.max(panelMinimums[side],Math.min(panelMaximums[side],available))}; } function setPanelWidth(side,value,persist=true){ const requested=parseFloat(value),limits=panelLimits(side),width=Math.round(Math.min(limits.max,Math.max(limits.min,Number.isFinite(requested)?requested:panelDefaults[side]))),resizer=resizerElement(side); E.studentLayout.style.setProperty(`--${side}-width`,`${width}px`); resizer.setAttribute("aria-valuemin",String(limits.min));resizer.setAttribute("aria-valuemax",String(limits.max));resizer.setAttribute("aria-valuenow",String(width)); if(persist)localStorage.setItem(`classroom_${side}_width`,String(width)); refreshEditorLayout(); } function syncPanelWidths(persist=false){setPanelWidth("reference",getComputedStyle(E.studentLayout).getPropertyValue("--reference-width"),persist);setPanelWidth("console",getComputedStyle(E.studentLayout).getPropertyValue("--console-width"),persist)} function setupResizer(side){ const resizer=resizerElement(side);let startX=0,startWidth=0; const move=e=>{if(!resizer.hasPointerCapture(e.pointerId))return;const delta=e.clientX-startX;setPanelWidth(side,startWidth+(side==="reference"?delta:-delta))}; const finish=e=>{if(resizer.hasPointerCapture(e.pointerId))resizer.releasePointerCapture(e.pointerId);resizer.classList.remove("dragging");document.body.classList.remove("panel-resizing")}; resizer.addEventListener("pointerdown",e=>{if(matchMedia("(max-width: 980px)").matches)return;startX=e.clientX;startWidth=panelElement(side).getBoundingClientRect().width;resizer.setPointerCapture(e.pointerId);resizer.classList.add("dragging");document.body.classList.add("panel-resizing");e.preventDefault()}); resizer.addEventListener("pointermove",move);resizer.addEventListener("pointerup",finish);resizer.addEventListener("pointercancel",finish); resizer.addEventListener("dblclick",()=>setPanelWidth(side,panelDefaults[side])); resizer.addEventListener("keydown",e=>{if(!["ArrowLeft","ArrowRight","Home","End"].includes(e.key))return;e.preventDefault();const limits=panelLimits(side),current=panelElement(side).getBoundingClientRect().width;if(e.key==="Home")setPanelWidth(side,limits.min);else if(e.key==="End")setPanelWidth(side,limits.max);else{const dividerDirection=e.key==="ArrowRight"?1:-1;setPanelWidth(side,current+(side==="reference"?dividerDirection:-dividerDirection)*16)}}); } function initResizablePanels(){ const savedReference=Number(localStorage.getItem("classroom_reference_width")),savedConsole=Number(localStorage.getItem("classroom_console_width")); if(Number.isFinite(savedReference)&&savedReference>0)E.studentLayout.style.setProperty("--reference-width",`${savedReference}px`); if(Number.isFinite(savedConsole)&&savedConsole>0)E.studentLayout.style.setProperty("--console-width",`${savedConsole}px`); setupResizer("reference");setupResizer("console");new ResizeObserver(refreshEditorLayout).observe(E.editorPanel);window.addEventListener("resize",()=>syncPanelWidths(false)); } function setLayout(referenceHidden,consoleHidden){E.studentLayout.classList.toggle("reference-hidden",referenceHidden);E.studentLayout.classList.toggle("console-hidden",consoleHidden);E.toggleReferenceButton.setAttribute("aria-pressed",String(referenceHidden));E.toggleConsoleButton.setAttribute("aria-pressed",String(consoleHidden));E.toggleReferenceButton.textContent=referenceHidden?"顯示側欄":"隱藏側欄";E.toggleConsoleButton.textContent=consoleHidden?"顯示 Console":"隱藏 Console";localStorage.setItem("classroom_hide_reference",String(referenceHidden));localStorage.setItem("classroom_hide_console",String(consoleHidden));requestAnimationFrame(()=>{syncPanelWidths(false);refreshEditorLayout()})} function renderStructuredGuide(guide){ const bank=Array.isArray(guide.question_bank)?guide.question_bank:[]; const flow=Array.isArray(guide.flow)?guide.flow:[]; const checklist=Array.isArray(guide.checklist)?guide.checklist:[]; const rules=Array.isArray(guide.question_rules)?guide.question_rules:[]; const sourceUrl=String(guide.source_url||""); const sourceLink=/^https:\/\//i.test(sourceUrl)?`查看官方資料集`:""; return `

本週目標

${esc(guide.goal||"")}

${bank.length?`

可選研究問題

先選一題,改成你真正好奇的比較。

`:""} ${guide.source_title?`

共用範例資料

${esc(guide.source_title)}
${esc(guide.source_note||"")}

${sourceLink}
`:""} ${rules.length?`
自訂題目檢查
`:""} ${flow.length?`

課堂流程|120 分鐘

    ${flow.map(step=>`
  1. ${esc(step.time||"")}|${esc(step.title||"")}:${esc(step.body||"")}
  2. `).join("")}
`:""} ${checklist.length?`

完成檢查

`:""}

AI 自主驗證

${esc(guide.ai_checkpoint||"")}

${guide.ai_note?`

${esc(guide.ai_note)}

`:""}

本週要留下的成果

${esc(guide.submission||"")}

暫存和正式提交的差別

需要老師幫忙?

按一次就會通知老師。成功呼叫後冷卻 ${helpCooldownLabel()};取消或老師回應都不會重置。

`; } function renderLesson(){ const item=lessonInfo(),shared=item?.workspace_id&&item.workspace_id!==item.id,isClosure=item?.id==="week05"; E.templateButton.disabled=!item?.template; E.templateButton.textContent=item?.template?"匯入專案起始套版":shared?"沿用目前專案":"尚未提供套版"; E.lessonHeading.textContent=item?`Lesson ${item.lesson??item.week}|${item.title}`:"本堂任務"; E.lessonLabel.textContent=item?.project||""; if(item?.guide){E.lessonGuideContent.innerHTML=renderStructuredGuide(item.guide);setSideView("lessonView");return} E.lessonGuideContent.innerHTML=isClosure?`

今天完成一條可玩的案件路線

新玩家可以自己開始調查、收集線索、完成推理並看到結局。

收尾檢查

課堂流程|120 分鐘

  1. 0–8 分|確認目標:說明今天的完成條件和 AI 使用規則。
  2. 8–18 分|重現現況:載入自己的舊進度、執行一次,記下第一個問題。
  3. 18–28 分|定義問題:寫下目前結果、預期結果和一個可重現的測試;示範如何請 AI 給提示。
  4. 28–53 分|修第一個阻礙:一次改一件事,每次修改後都執行確認。
  5. 53–73 分|同儕黑箱測試:同學只看操作說明試玩,記錄卡點和輸入。
  6. 73–88 分|修復並重測:挑一個最重要的問題修好,請原測試者重跑。
  7. 88–106 分|個人說明:每人 1–2 分鐘,說明一段程式、預測新測試,再實際展示。
  8. 106–116 分|保存進度:達成條件且能執行就正式提交;還有問題就暫存並寫下下一步。
  9. 116–120 分|離堂回顧:記下今天學會的事、AI 幫助和仍卡住的地方。

AI 使用方式

先自己指出問題,再請 AI 解釋錯誤、給小提示或設計測試。把 AI 建議的程式放進專案執行,留下「我問了什麼/我採用或修改什麼/測試結果」。老師會請你說明程式並現場預測測試結果;不需要隱瞞有使用 AI。

自動同步、暫存和正式提交的差別

需要老師幫忙?

按一次「請老師協助」就會通知老師。成功呼叫後冷卻 ${helpCooldownLabel()};取消或老師回應都不會重置冷卻。等待時先寫下卡住的步驟和試過的方法。

`:`

${esc(item?.output||"本堂作品任務")}

先確認目前版本,再完成一項可以執行和說明的功能。

完成後記得

暫存和正式提交的差別

需要老師幫忙?

按一次就會通知老師。成功呼叫後冷卻 ${helpCooldownLabel()};取消或老師回應都不會重置。

`; if(isClosure)setSideView("lessonView"); } function setSideView(name){document.querySelectorAll(".side-tab").forEach(button=>button.classList.toggle("active",button.dataset.side===name));document.querySelectorAll(".side-view").forEach(view=>view.hidden=view.id!==name);if(lessonInfo()?.id==="week05"||lessonInfo()?.guide){document.querySelectorAll(".mobile-tab").forEach(button=>button.setAttribute("aria-selected",String(button.dataset.panel==="referencePanel")));document.querySelectorAll(".student-layout>.panel").forEach(panel=>panel.classList.toggle("mobile-active",panel.id==="referencePanel"))}} function setConsoleView(view){E.consoleStream.dataset.view=view;E.consoleOutputTab.classList.toggle("active",view==="console");E.pygameOutputTab.classList.toggle("active",view==="pygame");E.consoleOutputTab.setAttribute("aria-selected",String(view==="console"));E.pygameOutputTab.setAttribute("aria-selected",String(view==="pygame"))} ClassroomPygame.attach(E.pygameFrame,message=>{if(message.type==="output"&&message.text)append(message.text);if(message.type==="status"&&message.label)E.runStatusText.textContent=message.label;if(message.type==="done"){pygameRunning=false;runState("success","遊戲已結束")}if(message.type==="error"||message.type==="load_error"){pygameRunning=false;if(message.text)append(`${message.text}\n`);runState("error","Pygame 執行錯誤");setConsoleView("console")}}); 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","執行器錯誤")}} 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})} function append(t){output=(output+t).slice(-40000);E.consoleOutput.textContent=output;E.consoleOutput.scrollTop=E.consoleOutput.scrollHeight;saveLocal();send({type:"output",output})} function renderSyntax(){const cats=["全部",...new Set(SNIPPETS.map(s=>s.category))];E.categoryFilters.innerHTML=cats.map(c=>``).join("");E.categoryFilters.querySelectorAll("button").forEach(b=>b.onclick=()=>{category=b.dataset.cat;renderSyntax()});const q=E.syntaxSearch.value.toLowerCase(),list=SNIPPETS.filter(s=>(category==="全部"||s.category===category)&&(`${s.title} ${s.code}`.toLowerCase().includes(q)));E.snippetCount.textContent=`${list.length} 項`;E.snippetList.innerHTML=list.map(s=>`
${s.title}${s.category}
${esc(s.code)}
`).join("")||'
找不到符合的語法。
';E.snippetList.querySelectorAll("[data-copy]").forEach(b=>b.onclick=async()=>{const s=SNIPPETS[+b.dataset.copy];await navigator.clipboard.writeText(s.code);toast(`已複製:${s.title}`)})} async function loadCatalog(){catalog=await(await fetch("/api/classroom/classes")).json();const saved=localStorage.getItem("classroom_code");E.classCode.innerHTML=catalog.courses.map(c=>``).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")||""} 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; 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()}; 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("已匯入專案起始套版")}; 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; 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(_){} 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 ``}).join(""):'

還沒有已儲存的版本。

';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()); 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("已匯入專案")}; 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)}; 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="正在暫存…"}; 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="正在提交…"}; 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(){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()}}); 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()send({type:"ping"}),4000); (async()=>{[course]=await Promise.all([(await fetch("/api/classroom/course")).json(),loadCatalog()]);E.weekSelect.innerHTML=course.weeks.map(w=>``).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()})(); window.addEventListener("pagehide",()=>{if(loadedStorageKey){files[activeFile]=editor.getValue();saveLocal(draftSavedAt)}});