[HTML] 纯文本查看 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' blob: data:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' blob: data:; media-src 'self' blob:;">
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover">
<meta name="theme-color" content="#2b7cd3">
<meta name="format-detection" content="telephone=no">
<title>课堂抽签</title>
<style>
/* ============ 基础重置 ============ */
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
html,body{height:100%;margin:0;}
body{
font-family:"Microsoft Yahei","PingFang SC","Helvetica Neue",Arial,sans-serif;
background:linear-gradient(135deg,#4fc3f7 0%,#eaf6ff 45%,#0ea5e9 100%);
background-attachment:fixed;
text-align:center;padding:16px;color:#102a43;
min-height:100vh;-webkit-text-size-adjust:100%;
}
.wrap{max-width:960px;margin:0 auto;}
button{font-family:inherit;}
/* ============ 顶部工具栏 ============ */
.header-bar{
display:flex;justify-content:space-between;align-items:center;
max-width:760px;margin:0 auto 14px;flex-wrap:wrap;gap:10px;
}
.current-class-text{font-size:20px;font-weight:bold;display:flex;align-items:center;gap:8px;color:#102a43;flex-wrap:wrap;}
#topClassSelect{
font-size:18px;font-weight:bold;padding:8px 12px;border:2px solid #2b7cd3;border-radius:12px;
background:#fff;color:#2b7cd3;cursor:pointer;outline:none;max-width:240px;
box-shadow:0 3px 8px rgba(0,0,0,0.18);
}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;}
.toolbar button{
padding:10px 16px;font-size:15px;cursor:pointer;border:none;border-radius:10px;min-height:44px;
background:#6c757d;color:#fff;box-shadow:0 2px 6px rgba(0,0,0,0.18);transition:.15s;touch-action:manipulation;
}
.toolbar button:active{transform:translateY(1px);}
.toolbar button.primary{background:#2b7cd3;}
.toolbar button.warn{background:#e67e22;}
.toolbar button.danger{background:#c0392b;}
.toolbar button.success{background:#27ae60;}
/* ============ 抽签主卡片 ============ */
.box{
max-width:760px;margin:0 auto;background:linear-gradient(135deg,#ffffff 0%,#eaf4ff 55%,#ffffff 100%);
padding:14px 16px;border-radius:20px;box-shadow:0 8px 28px rgba(0,0,0,0.18);
}
#infoBar{font-size:14px;color:#486581;margin:2px 0 8px;display:flex;flex-wrap:wrap;gap:6px;justify-content:center;align-items:center;}
.kv{display:inline-block;background:#fff;padding:5px 10px;border-radius:8px;font-size:13px;color:#486581;box-shadow:0 1px 3px rgba(0,0,0,0.08);}
#toggleModeBtn{
padding:6px 12px;font-size:13px;cursor:pointer;border:1px solid #2b7cd3;border-radius:8px;
background:#e7f1fd;color:#2b7cd3;min-height:34px;touch-action:manipulation;
}
#nameBoxWrap{min-height:150px;display:flex;align-items:center;justify-content:center;}
#nameBox{
width:100%;padding:18px 20px;font-size:clamp(28px,8vw,46px);line-height:1.3;border-radius:18px;
box-shadow:inset 0 2px 8px rgba(0,0,0,0.06),0 4px 12px rgba(0,0,0,0.10);
background:linear-gradient(135deg,#e3f2fd,#bbdefb);color:#102a43;
word-break:break-word;overflow-wrap:anywhere;transition:background .2s,color .2s;
}
.selected{
color:#a35b00 !important;font-weight:bold !important;
background:linear-gradient(135deg,#fff7e6,#ffe7ba) !important;
box-shadow:0 0 0 4px #ffd666 inset,0 6px 20px rgba(218,165,32,0.4) !important;
}
#btnWrap{margin-top:12px;display:flex;gap:12px;justify-content:center;flex-wrap:wrap;}
#startBtn{
padding:12px 24px;font-size:clamp(22px,6vw,32px);font-weight:bold;cursor:pointer;border:none;border-radius:14px;
background:linear-gradient(135deg,#5b8def,#1859b3);color:#fff;min-height:56px;min-width:160px;
box-shadow:0 6px 16px rgba(43,124,211,0.45);transition:.15s;touch-action:manipulation;
}
#startBtn:active{transform:translateY(1px);}
.tip{margin-top:14px;color:#2b7cd3;font-size:clamp(18px,5vw,30px);font-weight:bold;}
/* ============ 历史记录 ============ */
.history-box{
margin:16px auto 0;padding:12px 14px;background:#f0f4f8;border-radius:12px;text-align:left;max-width:760px;
}
.history-title{font-weight:bold;color:#102a43;margin-bottom:6px;display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;}
.history-list{font-size:14px;color:#334e68;max-height:110px;overflow-y:auto;line-height:1.9;-webkit-overflow-scrolling:touch;}
.history-list span{display:inline-block;margin-right:8px;padding:2px 8px;background:#fff;border-radius:6px;margin-bottom:3px;}
/* ============ 弹窗 ============ */
.modal-mask{
position:fixed;inset:0;background:rgba(0,0,0,0.55);
display:none;align-items:flex-start;justify-content:center;z-index:999;padding:20px 10px;overflow-y:auto;
-webkit-overflow-scrolling:touch;
}
.modal-mask.show{display:flex;}
.modal-box{
background:#fff;padding:18px;border-radius:16px;width:820px;max-width:96vw;text-align:left;
box-shadow:0 10px 40px rgba(0,0,0,0.3);margin:auto;
}
.modal-box h3{margin:0 0 6px;text-align:center;color:#102a43;}
.close-modal{text-align:right;margin-bottom:4px;}
#closeBtn{padding:8px 18px;cursor:pointer;border:none;border-radius:8px;background:#bcccdc;min-height:40px;font-size:15px;}
.section-title{font-weight:bold;color:#102a43;margin:16px 0 8px;padding-bottom:5px;border-bottom:2px solid #d9e2ec;}
.row{margin:10px 0;display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
.row label{font-size:14px;color:#334e68;}
input,select,textarea{font-family:inherit;}
input,select{padding:9px 10px;font-size:16px;border:1px solid #bcccdc;border-radius:8px;outline:none;background:#fff;color:#102a43;}
input:focus,select:focus,textarea:focus{border-color:#2b7cd3;}
input[type="number"]{width:84px;}
input[type="checkbox"]{width:18px;height:18px;}
#classNameInput{flex:1;min-width:180px;}
#stuNameInput{flex:1;min-width:180px;}
textarea{padding:9px;font-size:16px;width:100%;height:110px;resize:vertical;border:1px solid #bcccdc;border-radius:8px;outline:none;line-height:1.6;}
button.small{padding:9px 14px;font-size:14px;cursor:pointer;border:none;border-radius:8px;background:#2b7cd3;color:#fff;min-height:40px;touch-action:manipulation;}
button.small.gray{background:#829ab1;}
button.small.danger{background:#c0392b;}
button.small.warn{background:#e67e22;}
button.small.success{background:#27ae60;}
.opts-row{display:flex;gap:18px;flex-wrap:wrap;align-items:center;font-size:15px;color:#334e68;margin:8px 0;}
.opts-row label{display:inline-flex;align-items:center;gap:6px;cursor:pointer;}
.list-box{border:1px solid #d9e2ec;padding:10px;min-height:80px;max-height:240px;overflow-y:auto;margin-top:8px;border-radius:8px;background:#fafbfc;-webkit-overflow-scrolling:touch;}
.student-item{display:inline-flex;align-items:center;padding:6px 10px;margin:3px;background:#e3f2fd;border-radius:16px;font-size:15px;color:#102a43;}
.student-item.drawn{background:#eee;color:#999;text-decoration:line-through;}
.del-stu{color:#c0392b;cursor:pointer;margin-left:8px;font-weight:bold;font-size:18px;line-height:1;padding:0 2px;}
.stu-check{width:18px;height:18px;padding:0;margin:0 8px 0 0;border:none;border-radius:3px;cursor:pointer;flex:none;vertical-align:middle;}
.list-head{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;}
.list-head .hb{display:flex;gap:6px;flex-wrap:wrap;}
.stu-count{font-size:12px;color:#829ab1;font-weight:normal;}
.drawn-hint{font-size:12px;color:#829ab1;}
#fileInput{display:none;}
.foot-note{font-size:12px;color:#829ab1;margin-top:14px;text-align:center;}
/* ============ 响应式:手机 ============ */
@media (max-width:600px){
body{padding:10px;}
.header-bar{justify-content:center;}
.current-class-text{font-size:16px;justify-content:center;width:100%;}
#topClassSelect{font-size:16px;max-width:200px;}
.box{padding:12px;}
#nameBoxWrap{min-height:120px;}
.tip{margin-top:10px;}
.modal-box{padding:14px;}
.row label{min-width:auto;}
}
</style>
</head>
<body>
<div class="wrap">
<div class="header-bar">
<div class="current-class-text">
<span>当前班级:</span>
<select id="topClassSelect" title="点击切换班级" aria-label="切换班级"><option value="">未选择班级</option></select>
</div>
<div class="toolbar">
<button class="primary" id="settingBtn" type="button">⚙ 设置</button>
<button class="success" id="fullBtn" type="button" title="全屏投影更适合课堂">⛶ 全屏</button>
<button class="warn" id="resetDrawnBtn" type="button" title="重置当前班级的已抽名单,使所有人重新可抽">↺ 重置已抽</button>
</div>
</div>
<div class="box">
<div id="infoBar">
<span class="kv" id="statTotal">总人数:0</span>
<span class="kv" id="statLeft">剩余可抽:0</span>
<span class="kv" id="statMode">模式:不重复</span>
<span class="kv" id="statDuration">时长:5秒</span>
<button id="toggleModeBtn" type="button">切换:可重复 / 不重复</button>
</div>
<div id="nameBoxWrap">
<div id="nameBox" role="status" aria-live="polite">准备抽签</div>
</div>
<div id="btnWrap">
<button id="startBtn" type="button">抽签</button>
</div>
<div class="tip">好运伴你行 能量满满哦</div>
</div>
<div class="history-box">
<div class="history-title">
<span>📋 本次抽签记录</span>
<button class="small gray" id="clearHistoryBtn" type="button">清空记录</button>
</div>
<div class="history-list" id="historyList"><span style="background:none;color:#999;">暂无记录</span></div>
</div>
<div class="foot-note">数据保存在本机浏览器,换电脑请用「设置 → 导出/导入 JSON 备份」</div>
</div>
<!-- 设置弹窗 -->
<div class="modal-mask" id="mask" role="dialog" aria-modal="true" aria-label="班级与学生管理">
<div class="modal-box">
<div class="close-modal"><button id="closeBtn" type="button">关闭</button></div>
<h3>班级 & 学生管理</h3>
<div class="section-title">班级管理</div>
<div class="row">
<input id="classNameInput" placeholder="输入班级名称,如:高一2619班" autocomplete="off">
<button class="small success" id="addClassBtn" type="button">新建班级</button>
</div>
<div class="row">
<select id="classSelect" aria-label="选择班级"></select>
<button class="small danger" id="delClassBtn" type="button">删除当前班级</button>
<button class="small warn" id="renameClassBtn" type="button">重命名</button>
</div>
<div class="section-title">学生管理</div>
<div class="row">
<input id="stuNameInput" placeholder="单个添加:输入学生姓名后回车" autocomplete="off">
<button class="small" id="addStuBtn" type="button">添加单个</button>
<button class="small gray" id="clearStuInputBtn" type="button">清空输入</button>
</div>
<div class="row">
<textarea id="batchStuInput" placeholder="批量导入:一行一个名字(也支持用逗号、顿号、空格分隔)张三李四王五"></textarea>
</div>
<div class="row">
<button class="small" id="batchAddBtn" type="button">批量导入(追加)</button>
<button class="small warn" id="batchReplaceBtn" type="button">批量替换(清空后导入)</button>
<button class="small danger" id="clearAllStuBtn" type="button">清空当前班级全部学生</button>
</div>
<div class="row">
<button class="small gray" id="resetDrawnModalBtn" type="button">重置当前班级已抽名单</button>
<span class="drawn-hint">重置后所有人都可以再次被抽到</span>
</div>
<div class="section-title">抽签设置</div>
<div class="row">
<label>滚动时长:</label>
<input type="number" id="durationInput" min="1" max="30" step="1" value="5"> 秒
<label style="margin-left:12px;">单次抽取人数:</label>
<input type="number" id="multiCountInput" min="1" max="20" step="1" value="1"> 人
<button class="small" id="saveOptBtn" type="button">保存设置</button>
</div>
<div class="opts-row">
<label><input type="checkbox" id="soundToggle" checked> 开启音效</label>
<label><input type="checkbox" id="noRepeatToggle" checked> 不重复抽取(抽完自动提示重置)</label>
</div>
<div class="section-title">数据备份</div>
<div class="row">
<button class="small" id="exportBtn" type="button">导出全部数据(JSON备份)</button>
<button class="small gray" id="importBtn" type="button">导入JSON备份</button>
<input type="file" id="fileInput" accept=".json,application/json">
<span class="drawn-hint">导出可用于换电脑迁移</span>
</div>
<div class="section-title list-head">
<span>当前班级学生名单 <span class="stu-count" id="stuCount"></span></span>
<span class="hb">
<button class="small gray" id="selectAllStuBtn" type="button">全选/取消</button>
<button class="small danger" id="batchDelStuBtn" type="button">删除选中</button>
<button class="small danger" id="clearListBtn" type="button">清空名单</button>
</span>
</div>
<div class="list-box" id="stuListBox"></div>
</div>
</div>
<script>
(function(){
"use strict";
/* ================================================================
* 课堂抽签 · HTML5 单文件版
* 数据结构: classData{班级:[姓名]} / drawnData{班级:[已抽姓名]}
* options{duration,multiCount,soundOn,noRepeat} / historyList[]
* ================================================================ */
/* ---------- 安全存储:localStorage 不可用时自动降级到内存 ---------- */
var memStore = {};
var storageOK = (function(){
try{
var k = "__cq_test__";
window.localStorage.setItem(k, "1");
window.localStorage.removeItem(k);
return true;
}catch(e){ return false; }
})();
var store = {
get:function(k){
if(storageOK){ try{ return window.localStorage.getItem(k); }catch(e){} }
return Object.prototype.hasOwnProperty.call(memStore,k) ? memStore[k] : null;
},
set:function(k,v){
if(storageOK){ try{ window.localStorage.setItem(k,v); return; }catch(e){} }
memStore[k]=v;
}
};
var KEYS = {
DATA:"chouqianClassData", DRAWN:"chouqianDrawnData",
OPT:"chouqianOptions", LAST:"chouqianLastClass", HISTORY:"chouqianHistory"
};
var classData = {}, drawnData = {}, currentClassName = "", historyList = [];
var options = { duration:5, multiCount:1, soundOn:true, noRepeat:true };
/* ---------- DOM ---------- */
function $(id){ return document.getElementById(id); }
var nameBox=$("nameBox"), startBtn=$("startBtn"), settingBtn=$("settingBtn"),
fullBtn=$("fullBtn"), resetDrawnBtn=$("resetDrawnBtn"), mask=$("mask"), closeBtn=$("closeBtn"),
classSelect=$("classSelect"), classNameInput=$("classNameInput"),
stuNameInput=$("stuNameInput"), batchStuInput=$("batchStuInput"), stuListBox=$("stuListBox"),
topClassSelect=$("topClassSelect"), statTotal=$("statTotal"), statLeft=$("statLeft"),
statMode=$("statMode"), statDuration=$("statDuration"), historyBox=$("historyList"),
durationInput=$("durationInput"), multiCountInput=$("multiCountInput"),
soundToggle=$("soundToggle"), noRepeatToggle=$("noRepeatToggle"), toggleModeBtn=$("toggleModeBtn");
var rollingTimer=null, countDownTimer=null, remainMs=0, isRolling=false, pendingCount=1;
/* ---------- 存储读写 ---------- */
function loadAll(){
try{ classData = JSON.parse(store.get(KEYS.DATA)) || {}; }catch(e){ classData = {}; }
try{ drawnData = JSON.parse(store.get(KEYS.DRAWN)) || {}; }catch(e){ drawnData = {}; }
try{ var o = JSON.parse(store.get(KEYS.OPT)); if(o && typeof o==="object") mergeOptions(o); }catch(e){}
try{ var h = JSON.parse(store.get(KEYS.HISTORY)); historyList = Array.isArray(h)?h:[]; }catch(e){ historyList=[]; }
currentClassName = store.get(KEYS.LAST) || "";
// 规范化
Object.keys(classData).forEach(function(k){
if(!Array.isArray(classData[k])) classData[k]=[];
if(!Array.isArray(drawnData[k])) drawnData[k]=[];
});
}
function mergeOptions(o){
if(typeof o.duration==="number" && o.duration>=1 && o.duration<=30) options.duration=o.duration;
if(typeof o.multiCount==="number" && o.multiCount>=1 && o.multiCount<=20) options.multiCount=o.multiCount;
if(typeof o.soundOn==="boolean") options.soundOn=o.soundOn;
if(typeof o.noRepeat==="boolean") options.noRepeat=o.noRepeat;
}
function saveData(){ store.set(KEYS.DATA, JSON.stringify(classData)); }
function saveDrawn(){ store.set(KEYS.DRAWN, JSON.stringify(drawnData)); }
function saveOptions(silent){
var d=parseInt(durationInput.value,10), m=parseInt(multiCountInput.value,10);
options.duration = (isNaN(d)||d<1)?5:Math.min(30,d);
options.multiCount = (isNaN(m)||m<1)?1:Math.min(20,m);
options.soundOn = soundToggle.checked;
options.noRepeat = noRepeatToggle.checked;
store.set(KEYS.OPT, JSON.stringify(options));
durationInput.value=options.duration; multiCountInput.value=options.multiCount;
syncOptionUI();
if(!silent) toast("设置已保存!");
}
function saveLast(){ store.set(KEYS.LAST, currentClassName); }
function saveHistory(){ store.set(KEYS.HISTORY, JSON.stringify(historyList)); }
/* ---------- 轻提示(替代频繁 alert) ---------- */
var toastTimer=null;
function toast(msg){
var t=$("__toast__");
if(!t){
t=document.createElement("div"); t.id="__toast__";
t.style.cssText="position:fixed;left:50%;bottom:40px;transform:translateX(-50%);"+
"background:rgba(16,42,67,.92);color:#fff;padding:12px 20px;border-radius:10px;"+
"font-size:15px;z-index:2000;max-width:88vw;box-shadow:0 6px 20px rgba(0,0,0,.3);"+
"opacity:0;transition:opacity .2s;pointer-events:none;";
document.body.appendChild(t);
}
t.textContent=msg; t.style.opacity="1";
clearTimeout(toastTimer);
toastTimer=setTimeout(function(){ t.style.opacity="0"; },1800);
}
/* ---------- 音效(Web Audio,含特性检测 + webkit 前缀 + resume) ---------- */
var audioCtx=null;
function ensureAudio(){
if(audioCtx) return true;
try{
var AC = window.AudioContext || window.webkitAudioContext;
if(!AC) return false;
audioCtx = new AC();
return true;
}catch(e){ return false; }
}
function resumeAudio(){
if(audioCtx && audioCtx.state==="suspended" && audioCtx.resume){ audioCtx.resume().catch(function(){}); }
}
function playTick(){
if(!options.soundOn || !ensureAudio()) return; resumeAudio();
try{
var t0=audioCtx.currentTime, osc=audioCtx.createOscillator(), g=audioCtx.createGain();
osc.type="sine"; osc.frequency.value=880;
g.gain.setValueAtTime(0.0001,t0);
g.gain.exponentialRampToValueAtTime(0.06,t0+0.005);
g.gain.exponentialRampToValueAtTime(0.0001,t0+0.05);
osc.connect(g); g.connect(audioCtx.destination);
osc.start(t0); osc.stop(t0+0.06);
}catch(e){}
}
function playBell(strikes){
if(!options.soundOn || !ensureAudio()) return; resumeAudio();
try{
var master=audioCtx.createGain(); master.gain.value=0.8; master.connect(audioCtx.destination);
var base=audioCtx.currentTime+0.01;
var partials=[{r:1,g:1,d:1.3},{r:2,g:0.45,d:0.85},{r:2.76,g:0.28,d:0.55},{r:4,g:0.16,d:0.32}];
function strike(t0,f0,level){
partials.forEach(function(p){
var osc=audioCtx.createOscillator(), gain=audioCtx.createGain();
osc.type="sine"; osc.frequency.value=f0*p.r;
var peak=p.g*0.26*level;
gain.gain.setValueAtTime(0.0001,t0);
gain.gain.exponentialRampToValueAtTime(peak,t0+0.004);
gain.gain.exponentialRampToValueAtTime(0.0001,t0+p.d);
osc.connect(gain); gain.connect(master);
osc.start(t0); osc.stop(t0+p.d+0.02);
});
}
strike(base,1318.5,1.0);
if(strikes===2) strike(base+0.30,784.0,0.75);
}catch(e){}
}
function vibrate(pattern){ try{ if(navigator.vibrate) navigator.vibrate(pattern); }catch(e){} }
/* ---------- 导入 / 导出 ---------- */
function exportData(){
try{
var payload={classData:classData,drawnData:drawnData,options:options,exportAt:new Date().toISOString()};
var blob=new Blob([JSON.stringify(payload,null,2)],{type:"application/json"});
var url=URL.createObjectURL(blob), a=document.createElement("a");
var n=new Date(), t=n.getFullYear()+pad(n.getMonth()+1)+pad(n.getDate());
a.href=url; a.download="课堂抽签备份_"+t+".json";
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(function(){ URL.revokeObjectURL(url); },1000);
toast("已导出备份 JSON 文件");
}catch(e){ alert("导出失败:"+e.message); }
}
function importData(ev){
var file=ev.target.files && ev.target.files[0];
if(!file) return;
var reader=new FileReader();
reader.onload=function(e){
try{
var obj=JSON.parse(e.target.result);
if(obj && obj.classData){
classData=obj.classData;
if(obj.drawnData) drawnData=obj.drawnData; else drawnData={};
if(obj.options) mergeOptions(obj.options);
}else if(obj && typeof obj==="object" && !Array.isArray(obj)){
classData=obj; drawnData={}; // 兼容旧备份
}else{ throw new Error("文件格式不正确"); }
Object.keys(classData).forEach(function(k){
if(!Array.isArray(classData[k])) classData[k]=[];
if(!Array.isArray(drawnData[k])) drawnData[k]=[];
});
saveData(); saveDrawn(); store.set(KEYS.OPT,JSON.stringify(options));
$("fileInput").value="";
var keys=Object.keys(classData);
currentClassName = classData[currentClassName] ? currentClassName : (keys[0]||"");
saveLast(); syncOptionUI(); renderClassSelect(); renderHistory(); refreshStat();
toast("导入备份成功!");
}catch(err){ alert("文件解析失败,请选择正确的抽签备份 JSON:\n"+err.message); }
};
reader.readAsText(file);
}
function pad(x){ return (x<10?"0":"")+x; }
/* ---------- 渲染 ---------- */
function renderClassSelect(){
var keys=Object.keys(classData);
classSelect.innerHTML=""; topClassSelect.innerHTML="";
if(keys.length===0){
var o=document.createElement("option"); o.value=""; o.textContent="未选择班级";
topClassSelect.appendChild(o); currentClassName="";
}else{
if(!classData[currentClassName]) currentClassName=keys[0];
keys.forEach(function(name){
var a=document.createElement("option"); a.value=name; a.textContent=name; classSelect.appendChild(a);
var b=document.createElement("option"); b.value=name; b.textContent=name; topClassSelect.appendChild(b);
});
classSelect.value=currentClassName; topClassSelect.value=currentClassName;
}
saveLast(); renderStudentList(); refreshStat();
}
function renderStudentList(){
stuListBox.innerHTML="";
var cnt=$("stuCount");
if(!classData[currentClassName]){
if(cnt) cnt.textContent="";
stuListBox.innerHTML='<span style="color:#999;font-size:13px;">请先新建或选择班级</span>'; return;
}
var stus=classData[currentClassName];
var drawnArr=drawnData[currentClassName]||[];
if(!stus.length){
if(cnt) cnt.textContent="(共 0 人)";
stuListBox.innerHTML='<span style="color:#999;font-size:13px;">当前班级暂无学生,请添加</span>'; return;
}
if(cnt) cnt.textContent="(共 "+stus.length+" 人,已抽 "+drawnArr.length+" 人)";
var drawnSet={}; drawnArr.forEach(function(n){ drawnSet[n]=true; });
stus.forEach(function(name,idx){
var span=document.createElement("span");
span.className="student-item"+(drawnSet[name]?" drawn":"");
var cb=document.createElement("input");
cb.type="checkbox"; cb.className="stu-check"; cb.setAttribute("data-idx",idx);
cb.setAttribute("aria-label","选择 "+name); cb.title="勾选 "+name;
var txt=document.createElement("span"); txt.textContent=name;
var del=document.createElement("span"); del.className="del-stu"; del.textContent="×"; del.title="删除 "+name;
del.setAttribute("role","button");
del.onclick=function(){ delStudent(idx); };
span.appendChild(cb); span.appendChild(txt); span.appendChild(del); stuListBox.appendChild(span);
});
}
function refreshStat(){
var list=classData[currentClassName];
if(!list){ statTotal.textContent="总人数:0"; statLeft.textContent="剩余可抽:0"; }
else{
var total=list.length, drawn=(drawnData[currentClassName]||[]).length;
statTotal.textContent="总人数:"+total;
statLeft.textContent = options.noRepeat ? ("剩余可抽:"+Math.max(0,total-drawn)) : ("可抽:"+total);
}
statMode.textContent = options.noRepeat?"模式:不重复":"模式:可重复";
statDuration.textContent = "时长:"+options.duration+"秒";
toggleModeBtn.textContent = options.noRepeat?"当前:不重复(点击切可重复)":"当前:可重复(点击切不重复)";
}
function escapeHtml(s){
return String(s).replace(/[&<>"']/g,function(c){
return {"&":"&","<":"<",">":">",'"':""","'":"'"}[c];
});
}
function renderHistory(){
if(!historyList.length){
historyBox.innerHTML='<span style="background:none;color:#999;">暂无记录</span>'; return;
}
historyBox.innerHTML = historyList.slice().reverse().map(function(it){
var names=(it.names||[]).map(escapeHtml).join("、");
return '<span><b>['+escapeHtml(it.time||"")+']</b> '+escapeHtml(it.cls||"")+" → "+names+'</span>';
}).join("");
}
function syncOptionUI(){
durationInput.value=options.duration; multiCountInput.value=options.multiCount;
soundToggle.checked=options.soundOn; noRepeatToggle.checked=options.noRepeat;
refreshStat();
}
function resetNameBox(text){
nameBox.textContent = (text===undefined?"":text);
nameBox.classList.remove("selected");
}
/* ---------- 班级 / 学生操作 ---------- */
function switchToClass(name){
if(isRolling) stopRoll(true); // 切换时若在滚动,安全停止
currentClassName=name; classSelect.value=name; topClassSelect.value=name;
saveLast(); renderStudentList(); refreshStat(); resetNameBox("准备抽签");
}
function addClass(){
var c=(classNameInput.value||"").trim();
if(!c){ alert("请输入班级名称"); return; }
if(classData[c]){ alert("班级已存在"); return; }
classData[c]=[]; drawnData[c]=[]; currentClassName=c;
saveData(); saveDrawn(); saveLast(); renderClassSelect();
classNameInput.value=""; toast("已新建班级:"+c);
}
function delClass(){
if(!currentClassName){ alert("当前没有可删除的班级"); return; }
if(!confirm("确定删除班级【"+currentClassName+"】吗?名单与已抽记录将全部清除。")) return;
delete classData[currentClassName]; delete drawnData[currentClassName];
saveData(); saveDrawn();
var keys=Object.keys(classData); currentClassName=keys[0]||"";
saveLast(); renderClassSelect(); resetNameBox("准备抽签"); toast("已删除班级");
}
function renameClass(){
if(!currentClassName){ alert("请先选择要重命名的班级"); return; }
var nn=(prompt("请输入新的班级名称:",currentClassName)||"").trim();
if(!nn || nn===currentClassName) return;
if(classData[nn]){ alert("已存在同名班级"); return; }
classData[nn]=classData[currentClassName];
drawnData[nn]=drawnData[currentClassName]||[];
delete classData[currentClassName]; delete drawnData[currentClassName];
currentClassName=nn; saveData(); saveDrawn(); saveLast(); renderClassSelect(); toast("已重命名");
}
function addStudent(){
if(!currentClassName){ alert("请先新建/选择班级"); return; }
var s=(stuNameInput.value||"").trim();
if(!s){ alert("请输入学生姓名"); return; }
if(classData[currentClassName].indexOf(s)>=0){
if(!confirm('当前班级已存在"'+s+'",仍然添加?(重名会造成识别歧义)')) return;
}
classData[currentClassName].push(s);
saveData(); renderStudentList(); refreshStat();
stuNameInput.value=""; stuNameInput.focus();
}
function parseBatch(text){
return String(text||"").split(/[\r\n,,、;;\t ]+/).map(function(s){return s.trim();}).filter(function(s){return s;});
}
function batchAdd(){
if(!currentClassName){ alert("请先新建/选择班级!"); return; }
var arr=parseBatch(batchStuInput.value);
if(!arr.length){ alert("没有读取到学生姓名,请检查输入!"); return; }
Array.prototype.push.apply(classData[currentClassName],arr);
saveData(); renderStudentList(); refreshStat(); batchStuInput.value="";
toast("成功追加 "+arr.length+" 名学生!");
}
function batchReplace(){
if(!currentClassName){ alert("请先新建/选择班级!"); return; }
var arr=parseBatch(batchStuInput.value);
if(!arr.length){ alert("没有读取到学生姓名,请检查输入!"); return; }
if(!confirm("将清空当前班级全部学生,并导入 "+arr.length+" 名新学生,同时重置已抽名单。确定吗?")) return;
classData[currentClassName]=arr; drawnData[currentClassName]=[];
saveData(); saveDrawn(); renderStudentList(); refreshStat(); batchStuInput.value="";
toast("已替换为 "+arr.length+" 名学生!");
}
function clearAllStudents(){
if(!currentClassName) return;
if(!confirm("确定清空当前班级的所有学生吗?已抽记录也将一并重置。")) return;
classData[currentClassName]=[]; drawnData[currentClassName]=[];
saveData(); saveDrawn(); renderStudentList(); refreshStat(); toast("已清空学生");
}
function delStudent(idx){
if(!classData[currentClassName]) return;
var removed=classData[currentClassName].splice(idx,1)[0];
if(removed && drawnData[currentClassName]){
drawnData[currentClassName]=drawnData[currentClassName].filter(function(n){return n!==removed;});
}
saveData(); saveDrawn(); renderStudentList(); refreshStat();
}
function getCheckedIdx(){
var boxes=stuListBox.querySelectorAll(".stu-check:checked"), out=[];
Array.prototype.forEach.call(boxes,function(b){
var i=parseInt(b.getAttribute("data-idx"),10); if(!isNaN(i)) out.push(i);
});
return out;
}
function toggleSelectAll(){
var boxes=stuListBox.querySelectorAll(".stu-check");
if(!boxes.length){ toast("当前没有可勾选的学生"); return; }
var anyUnchecked=Array.prototype.some.call(boxes,function(b){ return !b.checked; });
Array.prototype.forEach.call(boxes,function(b){ b.checked=anyUnchecked; });
}
function batchDeleteSelected(){
if(!classData[currentClassName]){ alert("请先选择班级"); return; }
var idxs=getCheckedIdx();
if(!idxs.length){ alert("请先勾选要删除的学生(可点「全选/取消」快速全选)"); return; }
if(!confirm("确定删除选中的 "+idxs.length+" 名学生吗?(同时从已抽名单移除)")) return;
idxs.sort(function(a,b){ return b-a; }); // 从后往前删,避免下标错位
var removed=[];
idxs.forEach(function(i){
if(i>=0 && i<classData[currentClassName].length){
var nm=classData[currentClassName].splice(i,1)[0];
if(nm!==undefined) removed.push(nm);
}
});
if(drawnData[currentClassName] && removed.length){
var set={}; removed.forEach(function(n){ set[n]=true; });
drawnData[currentClassName]=drawnData[currentClassName].filter(function(n){ return !set[n]; });
}
saveData(); saveDrawn(); renderStudentList(); refreshStat();
toast("已删除选中的 "+removed.length+" 名学生");
}
function resetDrawnInClass(){
if(!currentClassName){ alert("请先选择班级"); return; }
if(!confirm("确定重置当前班级的已抽名单?重置后所有人都可以再次被抽到。")) return;
drawnData[currentClassName]=[]; saveDrawn(); renderStudentList(); refreshStat(); toast("已重置已抽名单");
}
/* ---------- 抽签核心 ---------- */
function getCandidates(){
var list=classData[currentClassName]||[];
if(!options.noRepeat) return list.slice();
var drawnSet={}; (drawnData[currentClassName]||[]).forEach(function(n){drawnSet[n]=true;});
return list.filter(function(n){return !drawnSet[n];});
}
function pickRandom(arr){ return arr[Math.floor(Math.random()*arr.length)]; }
function startDraw(count){
var list=classData[currentClassName];
if(!list || !list.length){ alert("当前班级还没有学生,请点击「设置」添加!"); return; }
var c=Math.max(1,count||options.multiCount||1);
var candidates=getCandidates();
if(options.noRepeat && !candidates.length){
alert("班级【"+currentClassName+"】所有人都已抽过了!请点击「重置已抽」开始新一轮。"); return;
}
var poolSize = options.noRepeat ? candidates.length : list.length;
if(c>poolSize){
alert("可抽人数("+poolSize+")少于要抽取的人数("+c+")。请减少抽取人数或重置已抽。"); return;
}
pendingCount=c;
resetNameBox(""); isRolling=true;
remainMs=options.duration*1000;
startBtn.textContent=(remainMs/1000).toFixed(1)+" 秒";
var pool = options.noRepeat ? candidates : list;
rollingTimer=setInterval(function(){
if(c===1){ nameBox.textContent=pickRandom(pool); }
else{
var set={},out=[];
while(out.length<Math.min(c,pool.length)){
var p=pickRandom(pool);
if(!set[p]){ set[p]=true; out.push(p); }
}
nameBox.textContent=out.join(" · ");
}
playTick();
},80);
countDownTimer=setInterval(function(){
remainMs-=50;
startBtn.textContent=(Math.max(0,remainMs)/1000).toFixed(1)+" 秒";
if(remainMs<=0) stopRoll(false);
},50);
}
function stopRoll(cancel){
clearInterval(rollingTimer); clearInterval(countDownTimer);
rollingTimer=countDownTimer=null; isRolling=false;
startBtn.textContent="抽签";
if(cancel){ resetNameBox("准备抽签"); return; }
var c=pendingCount||1, candidates=getCandidates();
var pool=(options.noRepeat && candidates.length)?candidates:(classData[currentClassName]||[]).slice();
var picked=[], pickPool=pool.slice(), n=Math.min(c,pickPool.length);
for(var i=0;i<n;i++){
var idx=Math.floor(Math.random()*pickPool.length);
picked.push(pickPool.splice(idx,1)[0]);
}
if(!picked.length){ resetNameBox("无可抽人员"); return; }
if(options.noRepeat){
if(!drawnData[currentClassName]) drawnData[currentClassName]=[];
picked.forEach(function(nm){ if(drawnData[currentClassName].indexOf(nm)<0) drawnData[currentClassName].push(nm); });
saveDrawn();
}
nameBox.textContent = picked.length===1 ? picked[0] : picked.join(" · ");
nameBox.classList.add("selected");
playBell(picked.length>1?1:2); vibrate(picked.length>1?[60,40,60]:[80]);
var d=new Date(), ts=pad(d.getHours())+":"+pad(d.getMinutes())+":"+pad(d.getSeconds());
historyList.push({time:ts,cls:currentClassName,names:picked});
if(historyList.length>200) historyList=historyList.slice(-200);
saveHistory(); renderHistory(); renderStudentList(); refreshStat();
if(options.noRepeat && getCandidates().length===0){
setTimeout(function(){ toast("本轮已全部抽完,可点「重置已抽」开始新一轮"); },400);
}
}
/* ---------- 全屏(含 webkit 前缀兼容) ---------- */
function toggleFullscreen(){
var el=document.documentElement;
var isFull=document.fullscreenElement||document.webkitFullscreenElement||document.mozFullScreenElement||document.msFullscreenElement;
try{
if(!isFull){
if(el.requestFullscreen) el.requestFullscreen();
else if(el.webkitRequestFullscreen) el.webkitRequestFullscreen();
else if(el.msRequestFullscreen) el.msRequestFullscreen();
else toast("当前浏览器不支持全屏");
}else{
if(document.exitFullscreen) document.exitFullscreen();
else if(document.webkitExitFullscreen) document.webkitExitFullscreen();
else if(document.msExitFullscreen) document.msExitFullscreen();
}
}catch(e){ toast("全屏切换失败"); }
}
/* ---------- 弹窗 ---------- */
function openModal(){ syncOptionUI(); renderClassSelect(); mask.classList.add("show"); }
function closeModal(){ mask.classList.remove("show"); }
/* ---------- 事件绑定 ---------- */
function bind(){
startBtn.addEventListener("click",function(){
ensureAudio(); resumeAudio();
if(isRolling){ stopRoll(false); } else { startDraw(options.multiCount); }
});
settingBtn.addEventListener("click",openModal);
closeBtn.addEventListener("click",closeModal);
mask.addEventListener("click",function(e){ if(e.target===mask) closeModal(); });
fullBtn.addEventListener("click",toggleFullscreen);
resetDrawnBtn.addEventListener("click",resetDrawnInClass);
topClassSelect.addEventListener("change",function(){ if(topClassSelect.value) switchToClass(topClassSelect.value); });
classSelect.addEventListener("change",function(){ if(classSelect.value) switchToClass(classSelect.value); });
toggleModeBtn.addEventListener("click",function(){
options.noRepeat=!options.noRepeat; noRepeatToggle.checked=options.noRepeat;
store.set(KEYS.OPT,JSON.stringify(options)); refreshStat();
toast(options.noRepeat?"已切换为【不重复】:抽过的人不再被抽到":"已切换为【可重复】:每次从全员随机");
});
$("clearHistoryBtn").addEventListener("click",function(){
if(!confirm("清空本次抽签记录?(不影响名单和已抽状态)")) return;
historyList=[]; saveHistory(); renderHistory();
});
$("addClassBtn").addEventListener("click",addClass);
$("delClassBtn").addEventListener("click",delClass);
$("renameClassBtn").addEventListener("click",renameClass);
$("addStuBtn").addEventListener("click",addStudent);
$("clearStuInputBtn").addEventListener("click",function(){ stuNameInput.value=""; batchStuInput.value=""; stuNameInput.focus(); });
$("batchAddBtn").addEventListener("click",batchAdd);
$("batchReplaceBtn").addEventListener("click",batchReplace);
$("clearAllStuBtn").addEventListener("click",clearAllStudents);
$("selectAllStuBtn").addEventListener("click",toggleSelectAll);
$("batchDelStuBtn").addEventListener("click",batchDeleteSelected);
$("clearListBtn").addEventListener("click",clearAllStudents);
$("resetDrawnModalBtn").addEventListener("click",resetDrawnInClass);
$("saveOptBtn").addEventListener("click",function(){ saveOptions(false); });
$("exportBtn").addEventListener("click",exportData);
$("importBtn").addEventListener("click",function(){ $("fileInput").click(); });
$("fileInput").addEventListener("change",importData);
classNameInput.addEventListener("keydown",function(e){ if(e.key==="Enter"){ e.preventDefault(); addClass(); } });
stuNameInput.addEventListener("keydown",function(e){ if(e.key==="Enter"){ e.preventDefault(); addStudent(); } });
// 键盘快捷键:空格抽签、Esc 关弹窗(输入框内不拦截空格)
document.addEventListener("keydown",function(e){
var tag=(e.target && e.target.tagName||"").toLowerCase();
var typing = tag==="input"||tag==="textarea"||tag==="select";
if(e.key==="Escape"){ if(mask.classList.contains("show")) closeModal(); return; }
if((e.code==="Space"||e.key===" ") && !typing && !mask.classList.contains("show")){
e.preventDefault(); startBtn.click();
}
});
// 页面不可见时停止滚动,避免后台空转
document.addEventListener("visibilitychange",function(){ if(document.hidden && isRolling) stopRoll(true); });
}
/* ---------- 初始化 ---------- */
function init(){
loadAll();
if(!Object.keys(classData).length){
var demo=[]; for(var i=1;i<=50;i++) demo.push("学生"+i);
classData["示例班级"]=demo; drawnData["示例班级"]=[]; currentClassName="示例班级";
saveData(); saveDrawn(); saveLast();
}else if(!classData[currentClassName]){
currentClassName=Object.keys(classData)[0]||""; saveLast();
}
bind();
syncOptionUI();
renderClassSelect();
renderHistory();
refreshStat();
}
if(document.readyState==="loading") document.addEventListener("DOMContentLoaded",init);
else init();
})();
</script>
</body>
</html>