const PAGE_META=[ {page:1,key:'P',title:'红色火星站'}, {page:2,key:'Q',title:'蓝色深海舱'}, {page:3,key:'R',title:'绿色能量站'}, {page:4,key:'T',title:'黄色指挥所'}, {page:5,key:'Y',title:'紫色观测台'}, {page:6,key:'U',title:'橙色发射场'} ]; const BUTTONS=['火箭准备','燃料检查','通讯开启','系统诊断','备用电源','外部链接']; const ACTIONS={ page:{label:'只打开页面',choices:[]}, button:{label:'点击指定按钮',choices:BUTTONS.map((name,index)=>({value:String(index+1),label:`${index+1}. ${name}(${ribbonPath(index+1)},或 Alt+${index+1})`}))}, menu:{label:'使用页面右键菜单',choices:[ {value:'refresh',label:'按 E:刷新'}, {value:'rename',label:'按 R:重命名'}, {value:'copy',label:'按 C:复制'} ]}, url:{label:'打开演示链接',choices:[ {value:'baidu.com',label:'打开百度演示'}, {value:'bilibili.com',label:'打开 Bilibili 演示'} ]} }; const PRESETS=[ {name:'打开第1页,点击第3个按钮',page:1,action:'button',choice:'3'}, {name:'打开第2页,点击第1个按钮',page:2,action:'button',choice:'1'}, {name:'打开第3页,点击燃料检查',page:3,action:'button',choice:'2'}, {name:'打开第4页,右键菜单刷新',page:4,action:'menu',choice:'refresh'}, {name:'打开第5页,打开百度演示',page:5,action:'url',choice:'baidu.com'}, {name:'打开第6页,打开演示网站',page:6,action:'url',choice:'bilibili.com'} ]; let serverPages=[]; let timer; let accountStudents=[]; const element=id=>document.getElementById(id); const pages=()=>serverPages.length?serverPages:PAGE_META; const pageBy=no=>pages().find(item=>Number(item.page)===Number(no))||PAGE_META[0]; function option(value,text,selected=false){return ``} function renderBuilder(){ element('builderPage').innerHTML=pages().map(p=>option(p.page,`${p.page}. ${p.title}(Alt+${p.key})`)).join(''); element('builderAction').innerHTML=Object.entries(ACTIONS).map(([value,item])=>option(value,item.label)).join(''); element('builderAction').value='button'; syncBuilder(); } function renderChoices(preferred){ const action=element('builderAction').value; const choices=ACTIONS[action]?ACTIONS[action].choices:[]; element('choiceField').classList.toggle('hidden',!choices.length); if(!choices.length){element('builderChoice').innerHTML='';return} element('choiceLabel').textContent=action==='button'?'目标按钮':action==='menu'?'菜单动作':'演示链接'; const previous=preferred??element('builderChoice').value; element('builderChoice').innerHTML=choices.map(item=>option(item.value,item.label)).join(''); if(choices.some(item=>String(item.value)===String(previous))) element('builderChoice').value=previous; } function instruction(pageNo,action,choice){ const page=pageBy(pageNo); const steps=[{type:'page',page:Number(page.page)}]; let text=`打开第${page.page}页(${page.title})`; const hint=['学生可点击控制台页面卡片,或直接按 Alt+'+page.key+' 打开页面。']; if(action==='button'){ const index=Number(choice); const name=BUTTONS[index-1]; text+=`,点击第${index}个按钮(${name})`; hint.push(`在训练页点击按钮,或按 ${ribbonPath(index)},或按 Alt+${index},判定相同。`); if(index===6) hint.push('打开外部链接后,再按 D 选择百度,或按 F 选择 Bilibili。'); steps.push({type:'button',page:Number(page.page),index,name}); }else if(action==='menu'){ const names={refresh:'刷新(E)',rename:'重命名(R)',copy:'复制(C)'}; text+=`,右键菜单执行${names[choice]}`; hint.push('学生在页面空白处右键,再按对应字母。'); steps.push({type:'menu',item:choice}); }else if(action==='url'){ const names={'baidu.com':'百度演示','bilibili.com':'Bilibili 演示'}; text+=`,打开${names[choice]}`; hint.push('学生按 Alt+B,再按 L 打开外部链接菜单,然后按 D 打开百度,或按 F 打开 Bilibili。'); steps.push({type:'url',url:choice}); } return {label:text,steps,hint:hint.join(' ')}; } function readBuilder(){ return instruction(element('builderPage').value,element('builderAction').value,element('builderChoice').value); } function updatePreview(){ const built=readBuilder(); element('label').value=built.label; element('expectedPreview').textContent=built.label; element('builderTip').textContent=built.hint; element('expected').value=JSON.stringify({steps:built.steps},null,2); } function syncBuilder(){ renderChoices(); updatePreview(); } function renderPresets(){ element('presets').innerHTML=''; PRESETS.forEach(item=>{ const row=document.createElement('div'); row.className='preset'; row.innerHTML=``; const [name,fill,go]=row.querySelectorAll('button'); name.onclick=()=>setBuilder(item); fill.onclick=()=>setBuilder(item); go.onclick=()=>{setBuilder(item);publish()}; element('presets').appendChild(row); }); } function setBuilder(item){ element('builderPage').value=item.page; element('builderAction').value=item.action; renderChoices(item.choice); updatePreview(); } async function publish(){ const built=readBuilder(); const label=element('label').value.trim()||built.label; const r=await API.post('/api/teacher/round',{label,expected:{steps:built.steps}}); API.notify(element('message'),r.message||(r.ok?'已发布':'指令格式错误')); loadState(); } function eventText(e){ const names={refresh:'刷新',rename:'重命名',copy:'复制',maximize:'最大化',minimize:'最小化',move:'移动'}; if(e.kind==='page')return `打开第${e.page_no}页`; if(e.kind==='button')return `点击第${e.item_index}个按钮`; if(e.kind==='shortcut')return `快捷键 ${e.value}`; if(e.kind==='menu')return `右键菜单:${names[e.value]||e.value}`; if(e.kind==='window')return `窗口:${names[e.value]||e.value}`; if(e.kind==='url')return `打开 ${e.value}`; return e.kind; } async function init(){ const s=await API.get('/api/teacher/state'); const authed=s.ok; element('loginArea').classList.toggle('hidden',authed); element('mainArea').classList.toggle('hidden',!authed); element('toolbar').classList.toggle('hidden',!authed); if(!authed)return; serverPages=s.pages||[]; renderBuilder(); renderPresets(); loadState(); timer=setInterval(loadState,1200); } async function loadState(refreshAccounts=false){ const s=await API.get('/api/teacher/state'); if(!s.ok){clearInterval(timer);location.reload();return} const hadAccounts=accountStudents.length; accountStudents=s.students||[]; if(refreshAccounts||!hadAccounts)renderAccounts(); showRound(s.round); element('roundState').textContent=s.round?'进行中':'未开始'; element('students').innerHTML=''; s.students.forEach(st=>{ const row=document.createElement('div'); row.className='row'+(st.online?' online':''); row.innerHTML=`${st.name} · ${st.student_no} · ${st.class_name}${st.online?'在线':'离线'}`; element('students').appendChild(row); }); element('scores').innerHTML=''; if(!s.scores.length)element('scores').innerHTML='
暂无完成记录
'; s.scores.forEach(x=>{ const row=document.createElement('div'); row.className='row winner'; row.innerHTML=`#${x.rank}${x.name} · ${x.student_no} · ${x.class_name}${new Date(x.finished_at*1000).toLocaleTimeString()}`; element('scores').appendChild(row); }); element('events').innerHTML=''; if(!s.events.length)element('events').innerHTML='
暂无操作
'; s.events.forEach(e=>{ const item=document.createElement('div'); item.className='event'; item.textContent=`${new Date(e.created_at*1000).toLocaleTimeString()} ${e.name} · ${eventText(e)}`; element('events').appendChild(item); }); } function text(value){return String(value??'').replace(/[&<>"']/g,ch=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]))} function renderAccounts(){ const keyword=element('studentNoSearch').value.trim().toLowerCase(); const className=element('accountClass').value; const classes=[...new Set(accountStudents.map(item=>item.class_name))]; const classSelect=element('accountClass'); const previousClass=classSelect.value; classSelect.innerHTML=''+classes.map(name=>option(name,`${name}班`)).join(''); if(classes.includes(previousClass)||previousClass==='') classSelect.value=previousClass; const students=accountStudents.filter(item=>{ const hit=!keyword||String(item.student_no).toLowerCase().includes(keyword)||String(item.name).toLowerCase().includes(keyword); return hit&&(!className||item.class_name===className); }); const select=element('studentSelect'); const previous=select.value; select.innerHTML=students.length?students.map(item=>option(item.id,`${item.student_no} · ${item.name} · ${item.class_name}班${item.must_change_password?' · 待改密':''}`)).join(''):''; if(students.some(item=>String(item.id)===previous)) select.value=previous; } element('studentNoSearch').oninput=renderAccounts; element('accountClass').onchange=renderAccounts; element('studentPasswordForm').onsubmit=async e=>{ e.preventDefault(); const notice=element('studentPasswordNotice'); const student=accountStudents.find(item=>String(item.id)===element('studentSelect').value); const password=element('studentNewPassword').value; const confirmPassword=element('studentConfirmPassword').value; notice.textContent=''; if(!student){API.notify(notice,'请选择要修改密码的学生');return} if(password!==confirmPassword){API.notify(notice,'两次输入的新密码不一致');return} if(!confirm(`确定修改 ${student.student_no} ${student.name} 的登录密码吗?修改后该学生需要重新登录。`))return; const result=await API.post('/api/teacher/student-password',{studentId:Number(student.id),newPassword:password}); API.notify(notice,result.message||(result.ok?'已修改密码':'修改失败')); if(result.ok)element('studentPasswordForm').reset(); }; element('studentCreateForm').onsubmit=async e=>{ e.preventDefault(); const notice=element('studentCreateNotice'); const result=await API.post('/api/teacher/student',{ className:element('newClassName').value, studentNo:element('newStudentNo').value, name:element('newStudentName').value, password:element('newStudentPassword').value }); API.notify(notice,result.message||(result.ok?'已添加':'添加失败')); if(result.ok){element('studentCreateForm').reset();loadState(true)} }; element('loginForm').onsubmit=async e=>{ e.preventDefault(); const r=await API.post('/api/teacher/login',{username:element('username').value,password:element('password').value}); if(!r.ok)return API.notify(element('notice'),r.message); element('notice').textContent=''; init(); }; element('builderPage').onchange=updatePreview; element('builderAction').onchange=syncBuilder; element('builderChoice').onchange=updatePreview; element('start').onclick=publish; element('stop').onclick=async()=>{await API.post('/api/teacher/stop');loadState()}; element('logout').onclick=async()=>{await API.post('/api/logout');location.reload()}; init();