Files
selfgroup/shortcut_trainer/static/teacher.js
2026-10-28 09:36:55 +08:00

252 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 `<option value="${value}"${selected?' selected':''}>${text}</option>`}
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=`<button class="ghost">${item.name}</button><button class="ghost">填入</button><button class="success">发布</button>`;
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=`<span class="dot"></span><span>${st.name} · ${st.student_no} · ${st.class_name}</span><span class="chip">${st.online?'在线':'离线'}</span>`;
element('students').appendChild(row);
});
element('scores').innerHTML='';
if(!s.scores.length)element('scores').innerHTML='<div class="hint">暂无完成记录</div>';
s.scores.forEach(x=>{
const row=document.createElement('div');
row.className='row winner';
row.innerHTML=`<span class="score">#${x.rank}</span><span>${x.name} · ${x.student_no} · ${x.class_name}</span><span>${new Date(x.finished_at*1000).toLocaleTimeString()}</span>`;
element('scores').appendChild(row);
});
element('events').innerHTML='';
if(!s.events.length)element('events').innerHTML='<div class="hint">暂无操作</div>';
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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='<option value="">全部班级</option>'+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(''):'<option value="">未找到匹配的学生</option>';
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();