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?`可選研究問題
先選一題,改成你真正好奇的比較。
${bank.map(item=>`- ${esc(item.group||"選題")} ${esc(item.text||"")}
`).join("")}
`:""}
${guide.source_title?`共用範例資料
${esc(guide.source_title)}
${esc(guide.source_note||"")}
${sourceLink}`:""}
${rules.length?`自訂題目檢查
${rules.map(item=>`- ${esc(item)}
`).join("")}
`:""}
${flow.length?`課堂流程|120 分鐘
${flow.map(step=>`- ${esc(step.time||"")}|${esc(step.title||"")}:${esc(step.body||"")}
`).join("")}
`:""}
${checklist.length?`完成檢查
${checklist.map(item=>`- ${esc(item)}
`).join("")}
`:""}
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 分鐘
- 0–8 分|確認目標:說明今天的完成條件和 AI 使用規則。
- 8–18 分|重現現況:載入自己的舊進度、執行一次,記下第一個問題。
- 18–28 分|定義問題:寫下目前結果、預期結果和一個可重現的測試;示範如何請 AI 給提示。
- 28–53 分|修第一個阻礙:一次改一件事,每次修改後都執行確認。
- 53–73 分|同儕黑箱測試:同學只看操作說明試玩,記錄卡點和輸入。
- 73–88 分|修復並重測:挑一個最重要的問題修好,請原測試者重跑。
- 88–106 分|個人說明:每人 1–2 分鐘,說明一段程式、預測新測試,再實際展示。
- 106–116 分|保存進度:達成條件且能執行就正式提交;還有問題就暫存並寫下下一步。
- 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)}});