Main.py:WHITE_HOSTS 增 rollcall/viper/venom 主机,STATIC_SITES 增 rollcall/venom 静态根,静态站点路由下 /API/ 前缀请求转交 Django 处理。 gvsds:settings.py 注册 gvsds.apps.rollcall,urls.py 挂载 API/RollCall/;新增 rollcall 应用(模型/视图/路由/迁移 + RollCallManager 数据访问层)与 TempLate/rollcall 前端站点。 .gitignore:排除 Viper/Venom 前端工程与部署产物、TempLate/FTP 二进制分发目录。
1034 lines
38 KiB
JavaScript
1034 lines
38 KiB
JavaScript
/* ============================================================
|
||
* 同学点名 - 课堂随机抽取系统
|
||
* 原生 HTML/CSS/JS,localStorage 持久化
|
||
* ============================================================ */
|
||
|
||
(function () {
|
||
'use strict';
|
||
|
||
/* ============ 常量 ============ */
|
||
const STORAGE_KEY = 'tongxue-dianming-data-v1';
|
||
const SETTINGS_KEY = 'tongxue-dianming-settings-v1';
|
||
|
||
/* ============ 默认示例数据 ============ */
|
||
const DEMO_STUDENTS = [
|
||
{ id: 's1', name: '张明轩', no: '2024001', note: '班长', avatar: 0, group: '第一组' },
|
||
{ id: 's2', name: '李思琪', no: '2024002', note: '学习委员', avatar: 1, group: '第一组' },
|
||
{ id: 's3', name: '王浩然', no: '2024003', note: '', avatar: 2, group: '第一组' },
|
||
{ id: 's4', name: '陈雨桐', no: '2024004', note: '', avatar: 3, group: '第二组' },
|
||
{ id: 's5', name: '刘子涵', no: '2024005', note: '体育委员', avatar: 4, group: '第二组' },
|
||
{ id: 's6', name: '杨欣怡', no: '2024006', note: '', avatar: 5, group: '第二组' },
|
||
{ id: 's7', name: '黄宇辰', no: '2024007', note: '', avatar: 6, group: '第三组' },
|
||
{ id: 's8', name: '赵梓萱', no: '2024008', note: '', avatar: 7, group: '第三组' },
|
||
{ id: 's9', name: '周奕辰', no: '2024009', note: '', avatar: 8, group: '第三组' },
|
||
{ id: 's10', name: '吴沐瑶', no: '2024010', note: '文艺委员', avatar: 9, group: '第四组' },
|
||
{ id: 's11', name: '徐子睿', no: '2024011', note: '', avatar: 10, group: '第四组' },
|
||
{ id: 's12', name: '孙若汐', no: '2024012', note: '', avatar: 11, group: '第四组' },
|
||
];
|
||
|
||
const DEMO_QUESTIONS = [
|
||
{ id: 'q1', subject: '语文', content: '请背诵《静夜思》并解释"举头望明月"的意境。', difficulty: '简单' },
|
||
{ id: 'q2', subject: '语文', content: '分析《背影》一文中父亲形象的特点及其表现手法。', difficulty: '中等' },
|
||
{ id: 'q3', subject: '语文', content: '请举例说明比喻与拟人修辞手法的区别。', difficulty: '简单' },
|
||
{ id: 'q4', subject: '数学', content: '已知函数 f(x) = x² - 4x + 3,求其顶点坐标和对称轴。', difficulty: '中等' },
|
||
{ id: 'q5', subject: '数学', content: '证明:三角形任意两边之和大于第三边。', difficulty: '困难' },
|
||
{ id: 'q6', subject: '数学', content: '计算:sin30° + cos60° - tan45° 的值。', difficulty: '简单' },
|
||
{ id: 'q7', subject: '英语', content: 'Please introduce yourself in English within 5 sentences.', difficulty: '简单' },
|
||
{ id: 'q8', subject: '英语', content: 'Translate the following sentence: "Knowledge is power."', difficulty: '中等' },
|
||
{ id: 'q9', subject: '英语', content: 'Explain the difference between "have been to" and "have gone to".', difficulty: '困难' },
|
||
{ id: 'q10', subject: '物理', content: '解释牛顿第一定律的内容,并举例说明惯性现象。', difficulty: '中等' },
|
||
{ id: 'q11', subject: '物理', content: '一个物体从高处自由下落,3秒后落地,求高度(g=10m/s²)。', difficulty: '中等' },
|
||
{ id: 'q12', subject: '历史', content: '简述丝绸之路的开通及其历史意义。', difficulty: '中等' },
|
||
];
|
||
|
||
// 学科配色
|
||
const SUBJECT_COLORS = {
|
||
'语文': '#c84b4b', '数学': '#3a8c7a', '英语': '#4a7c8c',
|
||
'物理': '#6b5b95', '化学': '#d97e4f', '生物': '#88a36e',
|
||
'历史': '#b8558c', '地理': '#5c6b73', '政治': '#c9974a',
|
||
};
|
||
|
||
/* ============ SVG 头像生成 ============ */
|
||
// 12 种配色方案
|
||
const AVATAR_PALETTES = [
|
||
{ bg: '#e8a838', bg2: '#c8861a', fg: '#fff', accent: '#fff' }, // 琥珀金
|
||
{ bg: '#3a8c7a', bg2: '#2a6b5c', fg: '#fff', accent: '#f4c668' }, // 青绿
|
||
{ bg: '#c84b4b', bg2: '#a83a3a', fg: '#fff', accent: '#f4c668' }, // 砖红
|
||
{ bg: '#1a2238', bg2: '#0f1525', fg: '#fff', accent: '#e8a838' }, // 墨蓝
|
||
{ bg: '#6b5b95', bg2: '#524675', fg: '#fff', accent: '#f4c668' }, // 紫
|
||
{ bg: '#88a36e', bg2: '#6b8455', fg: '#fff', accent: '#fff' }, // 草绿
|
||
{ bg: '#d97e4f', bg2: '#b8653a', fg: '#fff', accent: '#fff' }, // 橙
|
||
{ bg: '#4a7c8c', bg2: '#355f6c', fg: '#fff', accent: '#f4c668' }, // 钢蓝
|
||
{ bg: '#b8558c', bg2: '#944471', fg: '#fff', accent: '#fff' }, // 玫红
|
||
{ bg: '#5c6b73', bg2: '#445259', fg: '#fff', accent: '#e8a838' }, // 石灰
|
||
{ bg: '#c9974a', bg2: '#a87d35', fg: '#fff', accent: '#1a2238' }, // 麦黄
|
||
{ bg: '#3d6b7d', bg2: '#2c5260', fg: '#fff', accent: '#f4c668' }, // 深青
|
||
];
|
||
|
||
// 装饰图案类型
|
||
const PATTERNS = ['dots', 'stars', 'books', 'pencil', 'waves', 'grid'];
|
||
|
||
/**
|
||
* 生成 SVG 头像字符串
|
||
* @param {string} name - 学生姓名
|
||
* @param {number} avatarIdx - 头像索引 0-11
|
||
*/
|
||
function makeAvatar(name, avatarIdx) {
|
||
const idx = ((avatarIdx % 12) + 12) % 12;
|
||
const p = AVATAR_PALETTES[idx];
|
||
const pattern = PATTERNS[idx % PATTERNS.length];
|
||
const ch = (name || '?').trim().charAt(0) || '?';
|
||
const gid = 'g' + idx + '_' + Math.random().toString(36).slice(2, 7);
|
||
|
||
let patternSvg = '';
|
||
switch (pattern) {
|
||
case 'dots':
|
||
patternSvg = `<g opacity="0.18" fill="${p.accent}">
|
||
<circle cx="20" cy="20" r="2.5"/><circle cx="80" cy="20" r="2.5"/>
|
||
<circle cx="20" cy="80" r="2.5"/><circle cx="80" cy="80" r="2.5"/>
|
||
<circle cx="50" cy="15" r="2"/><circle cx="15" cy="50" r="2"/>
|
||
<circle cx="85" cy="50" r="2"/><circle cx="50" cy="85" r="2"/>
|
||
</g>`;
|
||
break;
|
||
case 'stars':
|
||
patternSvg = `<g opacity="0.2" fill="${p.accent}">
|
||
<path d="M18 18l2 5 5 2-5 2-2 5-2-5-5-2 5-2z"/>
|
||
<path d="M82 78l2 5 5 2-5 2-2 5-2-5-5-2 5-2z"/>
|
||
<path d="M82 22l1.5 4 4 1.5-4 1.5-1.5 4-1.5-4-4-1.5 4-1.5z"/>
|
||
</g>`;
|
||
break;
|
||
case 'books':
|
||
patternSvg = `<g opacity="0.2" fill="${p.accent}">
|
||
<rect x="12" y="74" width="18" height="12" rx="1"/>
|
||
<rect x="32" y="74" width="18" height="12" rx="1"/>
|
||
<rect x="52" y="74" width="18" height="12" rx="1"/>
|
||
</g>`;
|
||
break;
|
||
case 'pencil':
|
||
patternSvg = `<g opacity="0.2" fill="${p.accent}">
|
||
<path d="M78 16l6 6-20 20-6 0 0-6z"/>
|
||
<rect x="76" y="14" width="4" height="8" transform="rotate(45 78 18)"/>
|
||
</g>`;
|
||
break;
|
||
case 'waves':
|
||
patternSvg = `<g opacity="0.18" stroke="${p.accent}" stroke-width="2.5" fill="none" stroke-linecap="round">
|
||
<path d="M10 78 Q30 70 50 78 T90 78"/>
|
||
<path d="M10 86 Q30 78 50 86 T90 86"/>
|
||
</g>`;
|
||
break;
|
||
case 'grid':
|
||
patternSvg = `<g opacity="0.12" stroke="${p.accent}" stroke-width="1">
|
||
<line x1="25" y1="10" x2="25" y2="90"/>
|
||
<line x1="50" y1="10" x2="50" y2="90"/>
|
||
<line x1="75" y1="10" x2="75" y2="90"/>
|
||
<line x1="10" y1="25" x2="90" y2="25"/>
|
||
<line x1="10" y1="50" x2="90" y2="50"/>
|
||
<line x1="10" y1="75" x2="90" y2="75"/>
|
||
</g>`;
|
||
break;
|
||
}
|
||
|
||
return `<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||
<defs>
|
||
<linearGradient id="${gid}" x1="0" y1="0" x2="1" y2="1">
|
||
<stop offset="0%" stop-color="${p.bg}"/>
|
||
<stop offset="100%" stop-color="${p.bg2}"/>
|
||
</linearGradient>
|
||
</defs>
|
||
<rect width="100" height="100" rx="22" fill="url(#${gid})"/>
|
||
${patternSvg}
|
||
<text x="50" y="58" text-anchor="middle" font-family="'Noto Serif SC', serif" font-size="42" font-weight="900" fill="${p.fg}">${escapeXml(ch)}</text>
|
||
</svg>`;
|
||
}
|
||
|
||
function escapeXml(s) {
|
||
return String(s).replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c]));
|
||
}
|
||
|
||
/* ============ 数据存储 ============ */
|
||
const Store = {
|
||
students: [],
|
||
questions: [],
|
||
history: [], // [{id, studentId, name, time}]
|
||
pickedIds: new Set(), // 本次课堂已抽中学生
|
||
pickedQuestionIds: new Set(), // 本次课堂已抽中题目
|
||
|
||
load() {
|
||
// 优先从后端 API 读取;失败回退本地缓存(后端持久化,多端/多浏览器共享)
|
||
return fetch('/API/RollCall/Data', { headers: { 'Accept': 'application/json' } })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
this.students = Array.isArray(data.students) ? data.students : [];
|
||
this.questions = Array.isArray(data.questions) ? data.questions : [];
|
||
this.history = Array.isArray(data.history) ? data.history : [];
|
||
this.saveLocal();
|
||
})
|
||
.catch(() => { this.loadLocal(); });
|
||
},
|
||
save() {
|
||
// 先写本地缓存(立即生效),再异步同步后端
|
||
this.saveLocal();
|
||
fetch('/API/RollCall/Data', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
students: this.students,
|
||
questions: this.questions,
|
||
history: this.history,
|
||
}),
|
||
}).catch(e => console.warn('后端保存失败(已存本地缓存)', e));
|
||
},
|
||
loadLocal() {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
if (raw) {
|
||
const data = JSON.parse(raw);
|
||
this.students = Array.isArray(data.students) ? data.students : [];
|
||
this.questions = Array.isArray(data.questions) ? data.questions : [];
|
||
this.history = Array.isArray(data.history) ? data.history : [];
|
||
}
|
||
} catch (e) { console.warn('加载本地缓存失败', e); }
|
||
},
|
||
saveLocal() {
|
||
try {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||
students: this.students,
|
||
questions: this.questions,
|
||
history: this.history,
|
||
}));
|
||
} catch (e) { console.warn('保存本地缓存失败', e); }
|
||
},
|
||
getSettings() {
|
||
try {
|
||
const raw = localStorage.getItem(SETTINGS_KEY);
|
||
if (raw) return Object.assign(defaultSettings(), JSON.parse(raw));
|
||
} catch (e) {}
|
||
return defaultSettings();
|
||
},
|
||
saveSettings(s) {
|
||
try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)); } catch (e) {}
|
||
},
|
||
addStudent(s) {
|
||
s.id = 's' + Date.now() + Math.random().toString(36).slice(2, 5);
|
||
this.students.push(s);
|
||
this.save();
|
||
},
|
||
updateStudent(id, patch) {
|
||
const s = this.students.find(x => x.id === id);
|
||
if (s) { Object.assign(s, patch); this.save(); }
|
||
},
|
||
removeStudent(id) {
|
||
this.students = this.students.filter(x => x.id !== id);
|
||
this.save();
|
||
},
|
||
addQuestion(q) {
|
||
q.id = 'q' + Date.now() + Math.random().toString(36).slice(2, 5);
|
||
this.questions.push(q);
|
||
this.save();
|
||
},
|
||
updateQuestion(id, patch) {
|
||
const q = this.questions.find(x => x.id === id);
|
||
if (q) { Object.assign(q, patch); this.save(); }
|
||
},
|
||
removeQuestion(id) {
|
||
this.questions = this.questions.filter(x => x.id !== id);
|
||
this.save();
|
||
},
|
||
getGroups() {
|
||
const set = new Set();
|
||
this.students.forEach(s => { if (s.group) set.add(s.group); });
|
||
return Array.from(set);
|
||
},
|
||
getSubjects() {
|
||
const set = new Set();
|
||
this.questions.forEach(q => { if (q.subject) set.add(q.subject); });
|
||
return Array.from(set);
|
||
},
|
||
resetPicked() {
|
||
this.pickedIds.clear();
|
||
this.pickedQuestionIds.clear();
|
||
this.history = [];
|
||
this.save();
|
||
},
|
||
};
|
||
|
||
function defaultSettings() {
|
||
return { speed: 3, sound: false, history: true, mode: 'single', multiCount: 3, pickPerson: true, pickQuestion: false, questionNoRepeat: false };
|
||
}
|
||
|
||
let settings = Store.getSettings();
|
||
|
||
/* ============ DOM 引用 ============ */
|
||
const $ = (id) => document.getElementById(id);
|
||
const els = {
|
||
// tabs
|
||
tabs: document.querySelectorAll('.tab'),
|
||
views: document.querySelectorAll('.view'),
|
||
countBadge: $('countBadge'),
|
||
refreshBtn: $('refreshBtn'),
|
||
// roll
|
||
rollerCard: $('rollerCard'),
|
||
rollerAvatar: $('rollerAvatar'),
|
||
rollerName: $('rollerName'),
|
||
rollerTag: $('rollerTag'),
|
||
rollBtn: $('rollBtn'),
|
||
resetBtn: $('resetBtn'),
|
||
modeSwitch: $('modeSwitch'),
|
||
multiConfig: $('multiConfig'),
|
||
stepVal: $('stepVal'),
|
||
stepUp: $('stepUp'),
|
||
stepDown: $('stepDown'),
|
||
confetti: $('confetti'),
|
||
resultToast: $('resultToast'),
|
||
resultName: $('resultName'),
|
||
groupSelect: $('groupSelect'),
|
||
// mode toggles
|
||
modeToggles: document.querySelectorAll('.mode-toggle'),
|
||
personControls: $('personControls'),
|
||
questionControls: $('questionControls'),
|
||
// multi results
|
||
multiResults: $('multiResults'),
|
||
multiResultsCount: $('multiResultsCount'),
|
||
multiResultsGrid: $('multiResultsGrid'),
|
||
// question result
|
||
questionResult: $('questionResult'),
|
||
subjectSelect: $('subjectSelect'),
|
||
questionDisplay: $('questionDisplay'),
|
||
questionNoRepeat: $('questionNoRepeat'),
|
||
// wall
|
||
wall: $('wall'),
|
||
wallEmpty: $('wallEmpty'),
|
||
cntAll: $('cntAll'),
|
||
cntPending: $('cntPending'),
|
||
cntPicked: $('cntPicked'),
|
||
loadDemoBtn: $('loadDemoBtn'),
|
||
// history
|
||
historyList: $('historyList'),
|
||
historyEmpty: $('historyEmpty'),
|
||
clearHistoryBtn: $('clearHistoryBtn'),
|
||
// settings
|
||
speedRange: $('speedRange'),
|
||
speedVal: $('speedVal'),
|
||
soundToggle: $('soundToggle'),
|
||
historyToggle: $('historyToggle'),
|
||
loadDemoBtn2: $('loadDemoBtn2'),
|
||
clearAllBtn: $('clearAllBtn'),
|
||
// toast
|
||
toast: $('toast'),
|
||
};
|
||
|
||
/* ============ 工具 ============ */
|
||
let toastTimer = null;
|
||
function toast(msg, type) {
|
||
els.toast.textContent = msg;
|
||
els.toast.className = 'toast' + (type ? ' ' + type : '');
|
||
els.toast.hidden = false;
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => { els.toast.hidden = true; }, 2200);
|
||
}
|
||
|
||
function fmtTime(t) {
|
||
const d = new Date(t);
|
||
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0') + ':' + d.getSeconds().toString().padStart(2, '0');
|
||
}
|
||
|
||
function uid() { return 's' + Date.now() + Math.random().toString(36).slice(2, 6); }
|
||
|
||
/* ============ 视图切换 ============ */
|
||
function switchView(name) {
|
||
els.tabs.forEach(t => t.classList.toggle('active', t.dataset.view === name));
|
||
els.views.forEach(v => v.classList.toggle('active', v.id === 'view-' + name));
|
||
if (name === 'settings') renderSettings();
|
||
}
|
||
|
||
/* ============ 渲染:分组下拉 ============ */
|
||
function renderGroupSelect() {
|
||
const groups = Store.getGroups();
|
||
const current = els.groupSelect.value;
|
||
els.groupSelect.innerHTML = '<option value="">全部学生</option>' +
|
||
groups.map(g => `<option value="${escapeHtml(g)}"${g === current ? ' selected' : ''}>${escapeHtml(g)}</option>`).join('');
|
||
}
|
||
|
||
/* ============ 渲染:学科下拉 ============ */
|
||
function renderSubjectSelects() {
|
||
const subjects = Store.getSubjects();
|
||
const opts = '<option value="">全部学科</option>' +
|
||
subjects.map(s => `<option value="${escapeHtml(s)}">${escapeHtml(s)}</option>`).join('');
|
||
els.subjectSelect.innerHTML = opts;
|
||
}
|
||
|
||
function subjectColor(sub) {
|
||
return SUBJECT_COLORS[sub] || '#5c6b73';
|
||
}
|
||
|
||
/* ============ 模式开关 UI 更新 ============ */
|
||
function updateModeToggleUI() {
|
||
els.modeToggles.forEach(btn => {
|
||
const key = btn.dataset.toggle === 'person' ? 'pickPerson' : 'pickQuestion';
|
||
btn.classList.toggle('active', settings[key]);
|
||
});
|
||
els.personControls.hidden = !settings.pickPerson;
|
||
els.questionControls.hidden = !settings.pickQuestion;
|
||
// 多人数量配置也跟随抽人模式
|
||
if (settings.pickPerson) {
|
||
els.multiConfig.hidden = settings.mode !== 'multi';
|
||
} else {
|
||
els.multiConfig.hidden = true;
|
||
}
|
||
// 更新按钮文字
|
||
if (!rolling) {
|
||
els.rollBtn.querySelector('.btn-text').textContent = getRollBtnText();
|
||
}
|
||
}
|
||
|
||
/* ============ 渲染:学生卡片墙 ============ */
|
||
let wallFilter = 'all';
|
||
|
||
function renderWall() {
|
||
const list = Store.students;
|
||
const pending = list.filter(s => !Store.pickedIds.has(s.id));
|
||
const picked = list.filter(s => Store.pickedIds.has(s.id));
|
||
|
||
els.cntAll.textContent = list.length;
|
||
els.cntPending.textContent = pending.length;
|
||
els.cntPicked.textContent = picked.length;
|
||
els.countBadge.textContent = list.length + ' 人';
|
||
|
||
let show = list;
|
||
if (wallFilter === 'pending') show = pending;
|
||
else if (wallFilter === 'picked') show = picked;
|
||
|
||
els.wall.innerHTML = '';
|
||
if (list.length === 0) {
|
||
els.wallEmpty.hidden = false;
|
||
els.wallEmpty.querySelector('button').onclick = loadDemo;
|
||
return;
|
||
}
|
||
els.wallEmpty.hidden = true;
|
||
|
||
show.forEach(s => {
|
||
const card = document.createElement('div');
|
||
card.className = 'wall-card' + (Store.pickedIds.has(s.id) ? ' picked' : '');
|
||
card.dataset.id = s.id;
|
||
card.innerHTML = `
|
||
<div class="wall-avatar">${makeAvatar(s.name, s.avatar || 0)}</div>
|
||
<div class="wall-name">${escapeHtml(s.name)}</div>
|
||
<div class="wall-no">${escapeHtml(s.no || '')}</div>${s.group ? `<div class="wall-group">${escapeHtml(s.group)}</div>` : ''}`;
|
||
els.wall.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function highlightWallCard(id) {
|
||
document.querySelectorAll('.wall-card').forEach(c => c.classList.remove('current'));
|
||
if (id) {
|
||
const card = els.wall.querySelector(`.wall-card[data-id="${id}"]`);
|
||
if (card) card.classList.add('current');
|
||
}
|
||
}
|
||
|
||
/* ============ 渲染:历史记录 ============ */
|
||
function renderHistory() {
|
||
const list = Store.history;
|
||
if (list.length === 0) {
|
||
els.historyList.innerHTML = '';
|
||
els.historyEmpty.hidden = false;
|
||
return;
|
||
}
|
||
els.historyEmpty.hidden = true;
|
||
els.historyList.innerHTML = list.map((h, i) => {
|
||
const s = Store.students.find(x => x.id === h.studentId) || { avatar: 0 };
|
||
return `<div class="history-item">
|
||
<span class="history-idx">${i + 1}</span>
|
||
<div class="history-avatar">${makeAvatar(h.name, s.avatar || 0)}</div>
|
||
<span class="history-name">${escapeHtml(h.name)}</span>
|
||
<span class="history-no">${escapeHtml(h.no || '')}</span>
|
||
<span class="history-time">${fmtTime(h.time)}</span>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
/* ============ 渲染:设置 ============ */
|
||
function renderSettings() {
|
||
els.speedRange.value = settings.speed;
|
||
els.speedVal.textContent = speedLabel(settings.speed);
|
||
els.soundToggle.checked = settings.sound;
|
||
els.historyToggle.checked = settings.history;
|
||
els.questionNoRepeat.checked = settings.questionNoRepeat;
|
||
}
|
||
|
||
function speedLabel(v) {
|
||
return ['', '极快', '快速', '标准', '舒缓', '慢速'][v] || '标准';
|
||
}
|
||
|
||
/* ============ 抽取动画核心 ============ */
|
||
let rolling = false;
|
||
let multiResults = []; // 多人模式暂存结果
|
||
|
||
function getCandidates() {
|
||
const group = els.groupSelect.value;
|
||
let pool = Store.students;
|
||
if (group) pool = pool.filter(s => s.group === group);
|
||
if (settings.mode === 'norepeat') {
|
||
return pool.filter(s => !Store.pickedIds.has(s.id));
|
||
}
|
||
return pool;
|
||
}
|
||
|
||
function getQuestionPool() {
|
||
const subj = els.subjectSelect.value;
|
||
let pool = Store.questions;
|
||
if (subj) pool = pool.filter(q => q.subject === subj);
|
||
if (settings.questionNoRepeat) {
|
||
pool = pool.filter(q => !Store.pickedQuestionIds.has(q.id));
|
||
}
|
||
return pool;
|
||
}
|
||
|
||
function startRoll() {
|
||
if (rolling) return;
|
||
const doPerson = settings.pickPerson;
|
||
const doQuestion = settings.pickQuestion;
|
||
|
||
if (!doPerson && !doQuestion) {
|
||
toast('请至少开启一个模式(抽人或抽题)', 'error');
|
||
return;
|
||
}
|
||
|
||
// 预选学生
|
||
let personResults = [];
|
||
if (doPerson) {
|
||
const candidates = getCandidates();
|
||
if (candidates.length === 0) {
|
||
toast(settings.mode === 'norepeat' ? '所有学生已被抽中,请重置' : '暂无学生数据', 'error');
|
||
return;
|
||
}
|
||
const count = settings.mode === 'multi' ? Math.min(settings.multiCount, candidates.length) : 1;
|
||
const pool = candidates.slice();
|
||
for (let i = 0; i < count; i++) {
|
||
personResults.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]);
|
||
}
|
||
}
|
||
|
||
// 预选题目
|
||
let questionTarget = null;
|
||
if (doQuestion) {
|
||
const qPool = getQuestionPool();
|
||
if (qPool.length === 0) {
|
||
if (Store.questions.length === 0) {
|
||
toast('暂无题目数据', 'error');
|
||
} else if (settings.questionNoRepeat) {
|
||
toast('所有题目已被抽完,请重置或关闭不重复', 'error');
|
||
} else {
|
||
toast('该学科暂无题目', 'error');
|
||
}
|
||
return;
|
||
}
|
||
questionTarget = qPool[Math.floor(Math.random() * qPool.length)];
|
||
}
|
||
|
||
rolling = true;
|
||
multiResults = [];
|
||
els.rollBtn.disabled = true;
|
||
els.rollBtn.querySelector('.btn-text').textContent = '抽取中…';
|
||
els.resultToast.hidden = true;
|
||
els.rollerCard.classList.remove('done');
|
||
els.rollerCard.classList.remove('question-mode');
|
||
els.rollerCard.classList.add('spinning');
|
||
// 清空上一次结果(防止缓存残留)
|
||
els.multiResults.hidden = true;
|
||
els.multiResultsGrid.innerHTML = '';
|
||
els.questionResult.hidden = true;
|
||
els.questionDisplay.classList.remove('has-question');
|
||
els.questionDisplay.innerHTML = '<span class="question-placeholder">抽取中…</span>';
|
||
highlightWallCard(null);
|
||
// 清空 roller 卡片内容,避免显示上次结果
|
||
els.rollerAvatar.innerHTML = '';
|
||
els.rollerName.textContent = '抽取中…';
|
||
els.rollerTag.textContent = '';
|
||
|
||
if (doPerson) {
|
||
runPersonRoll(personResults, () => {
|
||
if (doQuestion) {
|
||
// 人物完成后,揭示题目
|
||
setTimeout(() => revealQuestionResult(questionTarget), 500);
|
||
}
|
||
});
|
||
} else if (doQuestion) {
|
||
// 仅抽题:在 roller 上做题目滚动动画
|
||
runQuestionRoll(questionTarget);
|
||
}
|
||
}
|
||
|
||
/* ---- 人物滚动动画 ---- */
|
||
function runPersonRoll(results, onComplete) {
|
||
const list = Store.students.length > 0 ? Store.students : results;
|
||
const durations = [0, 1600, 2200, 3000, 3800, 4600];
|
||
const total = settings.mode === 'multi' ? durations[settings.speed] + 600 : durations[settings.speed];
|
||
const start = performance.now();
|
||
const minInterval = settings.mode === 'multi' ? 160 : 180;
|
||
|
||
function tick(now) {
|
||
const elapsed = now - start;
|
||
const progress = Math.min(elapsed / total, 1);
|
||
const eased = easeOutCubic(progress);
|
||
const interval = 50 + (minInterval - 50) * eased;
|
||
|
||
if (progress < 1) {
|
||
const random = list[Math.floor(Math.random() * list.length)];
|
||
showRollerCard(random);
|
||
setTimeout(() => requestAnimationFrame(tick), interval);
|
||
} else {
|
||
finishPersonRoll(results, onComplete);
|
||
}
|
||
}
|
||
requestAnimationFrame(tick);
|
||
}
|
||
|
||
function finishPersonRoll(results, onComplete) {
|
||
els.rollerCard.classList.remove('spinning');
|
||
els.rollerCard.classList.add('done');
|
||
|
||
if (settings.mode === 'multi') {
|
||
multiResults = results.slice();
|
||
els.multiResults.hidden = false;
|
||
els.multiResultsCount.textContent = '共 ' + results.length + ' 人';
|
||
els.multiResultsGrid.innerHTML = '';
|
||
revealMulti(0, onComplete);
|
||
} else {
|
||
const target = results[0];
|
||
showRollerCard(target);
|
||
els.resultName.textContent = target.name;
|
||
els.resultToast.hidden = false;
|
||
highlightWallCard(target.id);
|
||
recordPick(target);
|
||
fireConfetti();
|
||
playSound();
|
||
setTimeout(() => { els.resultToast.hidden = true; }, 3000);
|
||
if (onComplete) {
|
||
rolling = false;
|
||
els.rollBtn.disabled = false;
|
||
els.rollBtn.querySelector('.btn-text').textContent = getRollBtnText();
|
||
onComplete();
|
||
}
|
||
}
|
||
}
|
||
|
||
function revealMulti(i, onComplete) {
|
||
if (i >= multiResults.length) {
|
||
els.resultName.textContent = multiResults.map(s => s.name).join('、');
|
||
els.resultToast.hidden = false;
|
||
fireConfetti();
|
||
playSound();
|
||
setTimeout(() => { els.resultToast.hidden = true; }, 3500);
|
||
rolling = false;
|
||
els.rollBtn.disabled = false;
|
||
els.rollBtn.querySelector('.btn-text').textContent = getRollBtnText();
|
||
if (onComplete) onComplete();
|
||
return;
|
||
}
|
||
const target = multiResults[i];
|
||
showRollerCard(target);
|
||
highlightWallCard(target.id);
|
||
recordPick(target);
|
||
addMultiResultCard(target, i + 1);
|
||
playSound();
|
||
fireConfetti(true);
|
||
setTimeout(() => revealMulti(i + 1, onComplete), 900);
|
||
}
|
||
|
||
/* ---- 题目滚动动画 ---- */
|
||
function runQuestionRoll(target) {
|
||
els.rollerCard.classList.add('question-mode');
|
||
const list = Store.questions;
|
||
const durations = [0, 1400, 2000, 2800, 3600, 4400];
|
||
const total = durations[settings.speed];
|
||
const start = performance.now();
|
||
|
||
function tick(now) {
|
||
const elapsed = now - start;
|
||
const progress = Math.min(elapsed / total, 1);
|
||
const eased = easeOutCubic(progress);
|
||
const interval = 60 + (200 - 60) * eased;
|
||
|
||
if (progress < 1) {
|
||
const random = list[Math.floor(Math.random() * list.length)];
|
||
showRollerQuestion(random);
|
||
setTimeout(() => requestAnimationFrame(tick), interval);
|
||
} else {
|
||
finishQuestionRoll(target);
|
||
}
|
||
}
|
||
requestAnimationFrame(tick);
|
||
}
|
||
|
||
function finishQuestionRoll(target) {
|
||
els.rollerCard.classList.remove('spinning');
|
||
els.rollerCard.classList.add('done');
|
||
showRollerQuestion(target);
|
||
fireConfetti();
|
||
playSound();
|
||
revealQuestionResult(target);
|
||
rolling = false;
|
||
els.rollBtn.disabled = false;
|
||
els.rollBtn.querySelector('.btn-text').textContent = getRollBtnText();
|
||
}
|
||
|
||
function revealQuestionResult(question) {
|
||
// 记录已抽中题目(不重复模式)
|
||
if (settings.questionNoRepeat) {
|
||
Store.pickedQuestionIds.add(question.id);
|
||
}
|
||
els.questionResult.hidden = false;
|
||
els.questionDisplay.classList.add('has-question');
|
||
// 重置动画
|
||
els.questionDisplay.style.animation = 'none';
|
||
void els.questionDisplay.offsetHeight;
|
||
els.questionDisplay.style.animation = '';
|
||
els.questionDisplay.innerHTML = `
|
||
<div class="question-content">
|
||
<div class="question-meta">
|
||
<span class="question-subject-tag" style="background:${subjectColor(question.subject)}">${escapeHtml(question.subject)}</span>
|
||
<span class="question-diff-tag ${escapeHtml(question.difficulty || '中等')}">${escapeHtml(question.difficulty || '中等')}</span>
|
||
</div>
|
||
<div class="question-text">${escapeHtml(question.content)}</div>
|
||
</div>`;
|
||
playSound();
|
||
}
|
||
|
||
function showRollerCard(student) {
|
||
els.rollerCard.classList.remove('question-mode');
|
||
els.rollerAvatar.innerHTML = makeAvatar(student.name, student.avatar || 0);
|
||
els.rollerName.textContent = student.name;
|
||
els.rollerTag.textContent = student.no || '';
|
||
}
|
||
|
||
function showRollerQuestion(question) {
|
||
els.rollerCard.classList.add('question-mode');
|
||
els.rollerAvatar.innerHTML = `<div class="roller-q-icon" style="background:${subjectColor(question.subject)};width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-family:var(--serif);font-size:48px;font-weight:900;color:#fff;border-radius:22px;">${escapeHtml((question.subject || '?').charAt(0))}</div>`;
|
||
els.rollerName.textContent = question.subject || '题目';
|
||
const content = question.content || '';
|
||
els.rollerTag.textContent = content.length > 30 ? content.slice(0, 30) + '…' : content;
|
||
}
|
||
|
||
function getRollBtnText() {
|
||
const p = settings.pickPerson, q = settings.pickQuestion;
|
||
if (p && q) return '开始抽取';
|
||
if (p) return '开始点名';
|
||
if (q) return '开始抽题';
|
||
return '开始';
|
||
}
|
||
|
||
function addMultiResultCard(student, idx) {
|
||
const card = document.createElement('div');
|
||
card.className = 'multi-result-card';
|
||
card.innerHTML = `
|
||
<span class="mr-idx">${idx}</span>
|
||
<div class="mr-avatar">${makeAvatar(student.name, student.avatar || 0)}</div>
|
||
<div class="mr-name">${escapeHtml(student.name)}</div>
|
||
<div class="mr-no">${escapeHtml(student.no || '')}</div>`;
|
||
els.multiResultsGrid.appendChild(card);
|
||
}
|
||
|
||
function recordPick(student) {
|
||
if (settings.mode === 'norepeat') {
|
||
Store.pickedIds.add(student.id);
|
||
}
|
||
if (settings.history) {
|
||
Store.history.push({ id: uid(), studentId: student.id, name: student.name, no: student.no, time: Date.now() });
|
||
Store.save();
|
||
}
|
||
renderWall();
|
||
renderHistory();
|
||
}
|
||
|
||
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
|
||
|
||
/* ============ 粒子效果 ============ */
|
||
const confettiCtx = els.confetti.getContext('2d');
|
||
let confettiParticles = [];
|
||
let confettiRaf = null;
|
||
|
||
function resizeConfetti() {
|
||
const r = els.confetti.getBoundingClientRect();
|
||
els.confetti.width = r.width * devicePixelRatio;
|
||
els.confetti.height = r.height * devicePixelRatio;
|
||
confettiCtx.scale(devicePixelRatio, devicePixelRatio);
|
||
}
|
||
|
||
function fireConfetti(small) {
|
||
resizeConfetti();
|
||
const r = els.confetti.getBoundingClientRect();
|
||
const cx = r.width / 2;
|
||
const cy = r.height / 2;
|
||
const colors = ['#e8a838', '#f4c668', '#3a8c7a', '#c84b4b', '#fff'];
|
||
const count = small ? 40 : 80;
|
||
for (let i = 0; i < count; i++) {
|
||
const angle = (Math.PI * 2 * i) / count + Math.random() * 0.5;
|
||
const speed = 3 + Math.random() * 6;
|
||
confettiParticles.push({
|
||
x: cx, y: cy,
|
||
vx: Math.cos(angle) * speed,
|
||
vy: Math.sin(angle) * speed - 2,
|
||
size: 4 + Math.random() * 6,
|
||
color: colors[Math.floor(Math.random() * colors.length)],
|
||
rot: Math.random() * Math.PI,
|
||
vr: (Math.random() - 0.5) * 0.3,
|
||
life: 1,
|
||
});
|
||
}
|
||
if (!confettiRaf) animateConfetti();
|
||
}
|
||
|
||
function animateConfetti() {
|
||
const r = els.confetti.getBoundingClientRect();
|
||
confettiCtx.clearRect(0, 0, r.width, r.height);
|
||
confettiParticles = confettiParticles.filter(p => p.life > 0);
|
||
confettiParticles.forEach(p => {
|
||
p.x += p.vx;
|
||
p.y += p.vy;
|
||
p.vy += 0.18; // 重力
|
||
p.vx *= 0.99;
|
||
p.rot += p.vr;
|
||
p.life -= 0.012;
|
||
confettiCtx.save();
|
||
confettiCtx.translate(p.x, p.y);
|
||
confettiCtx.rotate(p.rot);
|
||
confettiCtx.globalAlpha = Math.max(p.life, 0);
|
||
confettiCtx.fillStyle = p.color;
|
||
confettiCtx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6);
|
||
confettiCtx.restore();
|
||
});
|
||
if (confettiParticles.length > 0) {
|
||
confettiRaf = requestAnimationFrame(animateConfetti);
|
||
} else {
|
||
confettiRaf = null;
|
||
confettiCtx.clearRect(0, 0, r.width, r.height);
|
||
}
|
||
}
|
||
|
||
/* ============ 音效(Web Audio 合成) ============ */
|
||
let audioCtx = null;
|
||
function playSound() {
|
||
if (!settings.sound) return;
|
||
try {
|
||
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||
const t = audioCtx.currentTime;
|
||
// 两个短音
|
||
[880, 1320].forEach((freq, i) => {
|
||
const osc = audioCtx.createOscillator();
|
||
const gain = audioCtx.createGain();
|
||
osc.type = 'sine';
|
||
osc.frequency.value = freq;
|
||
gain.gain.setValueAtTime(0, t + i * 0.12);
|
||
gain.gain.linearRampToValueAtTime(0.15, t + i * 0.12 + 0.02);
|
||
gain.gain.exponentialRampToValueAtTime(0.001, t + i * 0.12 + 0.25);
|
||
osc.connect(gain).connect(audioCtx.destination);
|
||
osc.start(t + i * 0.12);
|
||
osc.stop(t + i * 0.12 + 0.3);
|
||
});
|
||
} catch (e) {}
|
||
}
|
||
|
||
/* ============ 示例数据 / 清空 ============ */
|
||
function loadDemo() {
|
||
Store.students = DEMO_STUDENTS.map(s => ({ ...s }));
|
||
Store.questions = DEMO_QUESTIONS.map(q => ({ ...q }));
|
||
Store.history = [];
|
||
Store.pickedIds.clear();
|
||
Store.save();
|
||
renderWall();
|
||
renderHistory();
|
||
renderGroupSelect();
|
||
renderSubjectSelects();
|
||
toast('已加载示例数据', 'success');
|
||
}
|
||
|
||
function clearAll() {
|
||
if (!confirm('确定清空所有学生、题目数据与课堂记录?此操作不可恢复。')) return;
|
||
Store.students = [];
|
||
Store.questions = [];
|
||
Store.history = [];
|
||
Store.pickedIds.clear();
|
||
Store.save();
|
||
renderWall();
|
||
renderHistory();
|
||
renderGroupSelect();
|
||
renderSubjectSelects();
|
||
toast('已清空', 'success');
|
||
}
|
||
|
||
/* ============ 工具:HTML 转义 ============ */
|
||
function escapeHtml(s) {
|
||
return String(s).replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c]));
|
||
}
|
||
|
||
/* ============ 事件绑定 ============ */
|
||
function bindEvents() {
|
||
// 标签切换
|
||
els.tabs.forEach(t => t.addEventListener('click', () => switchView(t.dataset.view)));
|
||
|
||
// 模式切换
|
||
els.modeSwitch.querySelectorAll('.mode-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
els.modeSwitch.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
settings.mode = btn.dataset.mode;
|
||
els.multiConfig.hidden = !(settings.pickPerson && settings.mode === 'multi');
|
||
Store.saveSettings(settings);
|
||
});
|
||
});
|
||
|
||
// 多人数量
|
||
els.stepUp.addEventListener('click', () => {
|
||
settings.multiCount = Math.min(settings.multiCount + 1, 20);
|
||
els.stepVal.textContent = settings.multiCount;
|
||
Store.saveSettings(settings);
|
||
});
|
||
els.stepDown.addEventListener('click', () => {
|
||
settings.multiCount = Math.max(settings.multiCount - 1, 1);
|
||
els.stepVal.textContent = settings.multiCount;
|
||
Store.saveSettings(settings);
|
||
});
|
||
|
||
// 抽取 / 重置
|
||
els.rollBtn.addEventListener('click', startRoll);
|
||
els.resetBtn.addEventListener('click', () => {
|
||
if (rolling) return;
|
||
Store.resetPicked();
|
||
els.rollerCard.classList.remove('done');
|
||
els.rollerCard.classList.remove('question-mode');
|
||
els.rollerAvatar.innerHTML = '';
|
||
els.rollerName.textContent = '准备就绪';
|
||
els.rollerTag.textContent = '点击下方按钮开始';
|
||
els.resultToast.hidden = true;
|
||
els.multiResults.hidden = true;
|
||
els.multiResultsGrid.innerHTML = '';
|
||
els.questionResult.hidden = true;
|
||
els.questionDisplay.classList.remove('has-question');
|
||
els.questionDisplay.innerHTML = '<span class="question-placeholder">点击下方按钮开始抽题</span>';
|
||
highlightWallCard(null);
|
||
renderWall();
|
||
renderHistory();
|
||
toast('已重置', 'success');
|
||
});
|
||
|
||
// 分组筛选
|
||
els.groupSelect.addEventListener('change', renderWall);
|
||
|
||
// 模式开关(抽人/抽题)
|
||
els.modeToggles.forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const key = btn.dataset.toggle === 'person' ? 'pickPerson' : 'pickQuestion';
|
||
settings[key] = !settings[key];
|
||
// 至少保留一个
|
||
if (!settings.pickPerson && !settings.pickQuestion) {
|
||
settings[key] = true;
|
||
toast('至少需要开启一个模式', 'error');
|
||
return;
|
||
}
|
||
Store.saveSettings(settings);
|
||
updateModeToggleUI();
|
||
});
|
||
});
|
||
|
||
// 题目不重复开关
|
||
els.questionNoRepeat.addEventListener('change', () => {
|
||
settings.questionNoRepeat = els.questionNoRepeat.checked;
|
||
Store.saveSettings(settings);
|
||
});
|
||
|
||
// 卡片墙筛选
|
||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
wallFilter = btn.dataset.filter;
|
||
renderWall();
|
||
});
|
||
});
|
||
|
||
// 历史清空
|
||
els.clearHistoryBtn.addEventListener('click', () => {
|
||
Store.history = [];
|
||
Store.pickedIds.clear();
|
||
Store.save();
|
||
renderWall();
|
||
renderHistory();
|
||
toast('已清空记录', 'success');
|
||
});
|
||
|
||
// 刷新:运行时从服务器重拉学生名单与题库(教师在管理端改动后即时同步)
|
||
els.refreshBtn.addEventListener('click', () => {
|
||
if (rolling) { toast('抽取进行中,请稍后再试', 'error'); return; }
|
||
Store.load().then(() => {
|
||
renderWall();
|
||
renderHistory();
|
||
renderGroupSelect();
|
||
renderSubjectSelects();
|
||
updateModeToggleUI();
|
||
toast('已刷新学生名单与题库', 'success');
|
||
});
|
||
});
|
||
|
||
// 加载示例
|
||
els.loadDemoBtn.addEventListener('click', loadDemo);
|
||
|
||
// 设置
|
||
els.speedRange.addEventListener('input', () => {
|
||
settings.speed = parseInt(els.speedRange.value);
|
||
els.speedVal.textContent = speedLabel(settings.speed);
|
||
Store.saveSettings(settings);
|
||
});
|
||
els.soundToggle.addEventListener('change', () => {
|
||
settings.sound = els.soundToggle.checked;
|
||
Store.saveSettings(settings);
|
||
});
|
||
els.historyToggle.addEventListener('change', () => {
|
||
settings.history = els.historyToggle.checked;
|
||
Store.saveSettings(settings);
|
||
});
|
||
els.loadDemoBtn2.addEventListener('click', loadDemo);
|
||
els.clearAllBtn.addEventListener('click', clearAll);
|
||
|
||
// 键盘快捷键
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === ' ' && document.querySelector('.view-roll.active') && !isInputFocused()) {
|
||
e.preventDefault();
|
||
startRoll();
|
||
}
|
||
});
|
||
|
||
window.addEventListener('resize', () => { if (confettiParticles.length) resizeConfetti(); });
|
||
}
|
||
|
||
function isInputFocused() {
|
||
const el = document.activeElement;
|
||
return el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA');
|
||
}
|
||
|
||
/* ============ 初始化 ============ */
|
||
function init() {
|
||
// 数据改为后端 API 加载(失败回退本地缓存),加载完成后再渲染
|
||
Store.load().then(() => {
|
||
// 恢复设置到 UI
|
||
els.modeSwitch.querySelectorAll('.mode-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === settings.mode));
|
||
els.multiConfig.hidden = !(settings.pickPerson && settings.mode === 'multi');
|
||
els.stepVal.textContent = settings.multiCount;
|
||
els.speedRange.value = settings.speed;
|
||
els.speedVal.textContent = speedLabel(settings.speed);
|
||
els.soundToggle.checked = settings.sound;
|
||
els.historyToggle.checked = settings.history;
|
||
els.questionNoRepeat.checked = settings.questionNoRepeat;
|
||
|
||
bindEvents();
|
||
renderWall();
|
||
renderHistory();
|
||
renderGroupSelect();
|
||
renderSubjectSelects();
|
||
updateModeToggleUI();
|
||
});
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
})();
|