Spaces:
Running
Running
File size: 41,703 Bytes
7547388 dcab2e7 21c192a bfe0d21 23af0a7 dcab2e7 23af0a7 21c192a bfe0d21 21c192a bfe0d21 23af0a7 bfe0d21 7e8293f bfe0d21 7e8293f bfe0d21 7e8293f bfe0d21 7e8293f 23af0a7 7e8293f bfe0d21 7e8293f 7547388 dcab2e7 7e8293f bfe0d21 7e8293f dcab2e7 12e244e 5fbb440 dcab2e7 5fbb440 dcab2e7 5fbb440 7547388 ed832a7 21c192a ed832a7 21c192a a2cbc5c 23af0a7 21c192a bfe0d21 21c192a dcab2e7 bfe0d21 dcab2e7 12e244e bfe0d21 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 | 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=>`<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()}})}
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)?`<a class="lesson-source-link" href="${esc(sourceUrl)}" target="_blank" rel="noopener noreferrer">查看官方資料集</a>`:"";
return `
<section class="lesson-guide-block lesson-target"><h2>本週目標</h2><p>${esc(guide.goal||"")}</p></section>
${bank.length?`<section class="lesson-guide-block"><h2>可選研究問題</h2><p>先選一題,改成你真正好奇的比較。</p><ul>${bank.map(item=>`<li><strong>${esc(item.group||"選題")}</strong> ${esc(item.text||"")}</li>`).join("")}</ul></section>`:""}
${guide.source_title?`<section class="lesson-guide-block"><h2>共用範例資料</h2><p><strong>${esc(guide.source_title)}</strong><br>${esc(guide.source_note||"")}</p>${sourceLink}</section>`:""}
${rules.length?`<details class="lesson-details"><summary>自訂題目檢查</summary><ul>${rules.map(item=>`<li>${esc(item)}</li>`).join("")}</ul></details>`:""}
${flow.length?`<section class="lesson-guide-block"><h2>課堂流程|120 分鐘</h2><ol class="lesson-timeline">${flow.map(step=>`<li><strong>${esc(step.time||"")}|${esc(step.title||"")}:</strong>${esc(step.body||"")}</li>`).join("")}</ol></section>`:""}
${checklist.length?`<section class="lesson-guide-block"><h2>完成檢查</h2><ul>${checklist.map(item=>`<li>${esc(item)}</li>`).join("")}</ul></section>`:""}
<section class="lesson-guide-block lesson-ai-note"><h2>AI 自主驗證</h2><p>${esc(guide.ai_checkpoint||"")}</p>${guide.ai_note?`<p>${esc(guide.ai_note)}</p>`:""}</section>
<section class="lesson-help-note"><h2>本週要留下的成果</h2><p>${esc(guide.submission||"")}</p></section>
<details class="lesson-details"><summary>暫存和正式提交的差別</summary><ul><li><strong>自動同步:</strong>老師可查看目前草稿,不會建立歷史版本。</li><li><strong>暫存版本:</strong>可載回的工作進度,不會交給老師或公開。</li><li><strong>正式提交:</strong>凍結一份交給老師檢查;老師另行發布後才會公開。</li></ul></details>
<section class="lesson-help-note"><h2>需要老師幫忙?</h2><p>按一次就會通知老師。成功呼叫後冷卻 ${helpCooldownLabel()};取消或老師回應都不會重置。</p></section>`;
}
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?`
<section class="lesson-guide-block lesson-target"><h2>今天完成一條可玩的案件路線</h2><p>新玩家可以自己開始調查、收集線索、完成推理並看到結局。</p></section>
<section class="lesson-guide-block"><h2>收尾檢查</h2><ul><li>至少三個地點、三項不同線索</li><li>能查看證物,重複調查不會重複加入</li><li>必要線索能解鎖推理和合理結局</li><li>空白或錯誤輸入不會讓程式中止</li></ul></section>
<section class="lesson-guide-block"><h2>課堂流程|120 分鐘</h2><ol class="lesson-timeline"><li><strong>0–8 分|確認目標:</strong>說明今天的完成條件和 AI 使用規則。</li><li><strong>8–18 分|重現現況:</strong>載入自己的舊進度、執行一次,記下第一個問題。</li><li><strong>18–28 分|定義問題:</strong>寫下目前結果、預期結果和一個可重現的測試;示範如何請 AI 給提示。</li><li><strong>28–53 分|修第一個阻礙:</strong>一次改一件事,每次修改後都執行確認。</li><li><strong>53–73 分|同儕黑箱測試:</strong>同學只看操作說明試玩,記錄卡點和輸入。</li><li><strong>73–88 分|修復並重測:</strong>挑一個最重要的問題修好,請原測試者重跑。</li><li><strong>88–106 分|個人說明:</strong>每人 1–2 分鐘,說明一段程式、預測新測試,再實際展示。</li><li><strong>106–116 分|保存進度:</strong>達成條件且能執行就正式提交;還有問題就暫存並寫下下一步。</li><li><strong>116–120 分|離堂回顧:</strong>記下今天學會的事、AI 幫助和仍卡住的地方。</li></ol></section>
<section class="lesson-guide-block lesson-ai-note"><h2>AI 使用方式</h2><p>先自己指出問題,再請 AI 解釋錯誤、給小提示或設計測試。把 AI 建議的程式放進專案執行,留下「我問了什麼/我採用或修改什麼/測試結果」。老師會請你說明程式並現場預測測試結果;不需要隱瞞有使用 AI。</p></section>
<details class="lesson-details"><summary>自動同步、暫存和正式提交的差別</summary><ul><li><strong>自動同步:</strong>更新目前工作草稿,老師端可即時查看;不會新增一筆歷史版本。</li><li><strong>暫存版本:</strong>建立可從「選擇舊進度」載回的版本;不算交作業,也不會發布。</li><li><strong>正式提交:</strong>凍結一份交給老師檢查的版本。你仍能繼續編輯及再次提交;老師另外發布後才會公開。</li></ul></details>
<section class="lesson-help-note"><h2>需要老師幫忙?</h2><p>按一次「請老師協助」就會通知老師。成功呼叫後冷卻 ${helpCooldownLabel()};取消或老師回應都不會重置冷卻。等待時先寫下卡住的步驟和試過的方法。</p></section>`:`
<section class="lesson-guide-block lesson-target"><h2>${esc(item?.output||"本堂作品任務")}</h2><p>先確認目前版本,再完成一項可以執行和說明的功能。</p></section>
<section class="lesson-guide-block"><h2>完成後記得</h2><ul><li>執行程式並確認結果</li><li>建立暫存版本,保留可回復的進度</li><li>需要交給老師檢查時,再按正式提交</li></ul></section>
<details class="lesson-details"><summary>暫存和正式提交的差別</summary><ul><li><strong>自動同步:</strong>老師可查看目前草稿,不會建立歷史版本。</li><li><strong>暫存版本:</strong>可載回的工作進度,不會交給老師或公開。</li><li><strong>正式提交:</strong>凍結一份交給老師檢查;老師另行發布後才會公開。</li></ul></details>
<section class="lesson-help-note"><h2>需要老師幫忙?</h2><p>按一次就會通知老師。成功呼叫後冷卻 ${helpCooldownLabel()};取消或老師回應都不會重置。</p></section>`;
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=>`<button class="chip" data-cat="${c}" aria-pressed="${c===category}">${c}</button>`).join("");E.categoryFilters.querySelectorAll("button").forEach(b=>b.onclick=()=>{category=b.dataset.cat;renderSyntax()});const q=E.syntaxSearch.value.toLowerCase(),list=SNIPPETS.filter(s=>(category==="全部"||s.category===category)&&(`${s.title} ${s.code}`.toLowerCase().includes(q)));E.snippetCount.textContent=`${list.length} 項`;E.snippetList.innerHTML=list.map(s=>`<article class="snippet-item"><div class="snippet-title"><strong>${s.title}</strong><span>${s.category}</span></div><pre>${esc(s.code)}</pre><button class="copy-button" data-copy="${SNIPPETS.indexOf(s)}">複製範例</button></article>`).join("")||'<div class="snippet-empty">找不到符合的語法。</div>';E.snippetList.querySelectorAll("[data-copy]").forEach(b=>b.onclick=async()=>{const s=SNIPPETS[+b.dataset.copy];await navigator.clipboard.writeText(s.code);toast(`已複製:${s.title}`)})}
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")||""}
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 `<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());
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()<Number(helpCooldownUntil||0))return;needsHelp=true;helpCooldownUntil=Date.now()+helpCooldownMs;E.helpStatus.textContent="已通知老師。等待時請記下目前卡住的步驟和試過的方法。";send({type:"help",needs_help:true});renderState()};
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()}});
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);
(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()})();
window.addEventListener("pagehide",()=>{if(loadedStorageKey){files[activeFile]=editor.getValue();saveLocal(draftSavedAt)}});
|