Spaces:
Running
Running
Download classroom_static/student.js from Telearn/Project_Demo: direct link, hf CLI and curl.
- Browser
- Download file 41.7 kB
-
https://huggingface.co/spaces/Telearn/Project_Demo/resolve/main/classroom_static/student.js
- Command line
-
hf download hf://spaces/Telearn/Project_Demo/classroom_static/student.js
-
curl -L -o student.js https://huggingface.co/spaces/Telearn/Project_Demo/resolve/main/classroom_static/student.js
41.7 kB
| 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)}}); | |