add shortcuts traner
This commit is contained in:
251
shortcut_trainer/static/teacher.js
Normal file
251
shortcut_trainer/static/teacher.js
Normal file
@@ -0,0 +1,251 @@
|
||||
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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();
|
||||
Reference in New Issue
Block a user