Files
ny-exam-platform/assets/app.js
T
nxxy335 ba0e64ed51 feat: 导出按班级/组别筛选 + 随机抽取页 + 零依赖 XLSX 导出
导出跟随分类筛选
- api.php export / export_absent 新增 cls、grp 参数,按当前筛选过滤 rows
- 缺考口径与页面完全一致:筛选后名单 − 筛选后已提交(按学号匹配)
- 导出文件名附带班级 / 组别(如 成绩明细_高一数学月考_高一(1)班_A组_时间戳.csv)
- app.js 两个导出按钮追加 cls/grp 参数并提示导出范围

随机抽取页 draw.php
- 来源:学号名单 / 某试卷已提交人员;支持班级、组别筛选与抽取人数设置
- 「排除已抽中」累计去重,抽取结果存入 localStorage,刷新后仍可下载
- 抽取动画(滚动定格),结果表格含序号 / 学号 / 姓名 / 班级 / 组别
- 下载「白名单导入模板.xlsx」「白名单列表.xlsx」:两列 姓名 / 登陆账号

XLSX 导出能力
- 新增 lib/XlsxWriter.php:零依赖生成 xlsx,有 zip 扩展走 ZipArchive,
  无则回退手写 ZIP(stored);单元格按共享字符串 + 文本格式(numFmtId=49)
  写入,长学号不会变科学计数法
- 新增 api.php action=draw_export,输出与「白名单列表.xlsx」模板一致的结构

其他
- index.php 顶栏新增「随机抽取」入口;支持 index.php#roster 直接打开名单管理
- tools/test-xlsxwriter.php 自测:18 项全通过(含无 ZipArchive 的手写打包路径)
2026-09-09 11:11:14 +08:00

832 lines
34 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.
/* NY 问卷考试系统数据平台 - 前端逻辑 */
(function () {
'use strict';
const $ = (s, r) => (r || document).querySelector(s);
const $$ = (s, r) => Array.prototype.slice.call((r || document).querySelectorAll(s));
const state = {
projects: [],
questions: [],
rows: [],
absent: [],
unmatched: [],
roster: [], // 完整名单(含班级),用于按班级重算统计
prevMap: {}, // sid -> 摘要,用于高亮变化
prevSids: new Set(), // 上一次刷新出现的 sid
newSids: new Set(), // 本次刷新新增的 sid(绿色底纹)
hasPrev: false,
fullScore: 100,
sortKey: 'submit_ts',
sortAsc: false,
timer: null,
loading: false,
lastSig: ''
};
/* ---------------- 本地存储 ---------------- */
// 浏览器关闭后仍然保留,账号/密码等敏感配置除外;进入页面时优先读缓存,再后台异步刷新
const LS = {
get(k, def) {
try { const v = localStorage.getItem(k); return v == null ? def : JSON.parse(v); }
catch (e) { return def; }
},
set(k, v) {
try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { /* 隐私模式 */ }
},
del(k) { try { localStorage.removeItem(k); } catch (e) {} }
};
const LS_PROJECTS = 'ny_projects';
const LS_LASTPID = 'ny_last_pid';
const LS_LASTQID = 'ny_last_qid';
const LS_FILTERS = 'ny_filters'; // {cls, grp} -> 跟随 projectId
const LS_LAYOUT = 'ny_layout';
const LS_ROSTER = 'ny_roster_cache'; // 名单 cache(用于首屏补齐班级/组别下拉)
const dataKey = pid => 'ny_data_' + pid;
/* ---------------- 通用 ---------------- */
async function api(action, opts) {
opts = opts || {};
const init = { method: opts.method || 'GET' };
if (opts.body) {
init.headers = { 'Content-Type': 'application/json' };
init.body = JSON.stringify(opts.body);
}
if (opts.form) {
init.method = 'POST';
init.body = opts.form;
}
let url = 'api.php?action=' + encodeURIComponent(action);
if (opts.params) {
for (const k in opts.params) {
if (opts.params[k] !== '' && opts.params[k] != null) {
url += '&' + k + '=' + encodeURIComponent(opts.params[k]);
}
}
}
const res = await fetch(url, init);
const text = await res.text();
let json;
try { json = JSON.parse(text); } catch (e) {
throw new Error('服务端返回异常(可能 PHP 报错):' + text.slice(0, 300));
}
if (!json.ok) throw new Error(json.msg || '请求失败');
return json.data;
}
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function setConn(cls, text) {
const el = $('#conn');
el.className = 'conn ' + cls;
el.innerHTML = '<i></i><span>' + esc(text) + '</span>';
}
function showAlert(msg, type) {
const el = $('#alert');
if (!msg) { el.classList.add('hidden'); return; }
el.textContent = msg;
el.className = 'alert' + (type === 'info' ? ' info' : '');
}
// 导出提示:说明本次导出的筛选范围
function exportTip(what, cls, grp) {
const parts = [];
if (cls) parts.push(cls + (cls.indexOf('班') >= 0 ? '' : '班'));
if (grp) parts.push(grp + (grp.indexOf('组') >= 0 ? '' : '组'));
return '已导出' + what + (parts.length ? '(' + parts.join(' / ') + ')' : '(全部班级 / 组别)');
}
/* ---------------- 问卷列表 ---------------- */
// 把数组渲染到 #projectSel(抽出便于缓存复用)
function renderProjects() {
const sel = $('#projectSel');
const keep = sel.value;
sel.innerHTML = '';
if (!state.projects.length) {
sel.innerHTML = '<option value="">(没有获取到问卷,请检查设置)</option>';
} else {
const ph = document.createElement('option');
ph.value = ''; ph.textContent = '请选择问卷 / 考试';
sel.appendChild(ph);
// 双策略识别文件夹:
// 1) mode 字段含 'folder'(卷王默认)
// 2) 被其他项目作为 parent_id 引用(部分版本无 mode 字段,回退推断)
const isFolder = p => (p.mode || '').toLowerCase().includes('folder');
const parentIds = new Set();
state.projects.forEach(p => {
const pid = (p.parent_id || '0');
if (pid && pid !== '0' && pid !== '') parentIds.add(pid);
});
const folderSet = new Set();
state.projects.forEach(p => {
if (isFolder(p) || parentIds.has(p.id)) folderSet.add(p.id);
});
const childOf = fid => state.projects.filter(p =>
!folderSet.has(p.id) && (p.parent_id || '0') === fid);
const mkOpt = p => {
const o = document.createElement('option');
o.value = p.id;
o.textContent = (p.mode ? '[' + p.mode + '] ' : '') + p.name + '(' + (p.total || 0) + '份)';
return o;
};
// 根项(不在任何文件夹里的)
state.projects.filter(p => !folderSet.has(p.id))
.forEach(p => sel.appendChild(mkOpt(p)));
// 文件夹及子项(空文件夹不显示,避免点击报错)
state.projects.filter(p => folderSet.has(p.id)).forEach(f => {
const kids = childOf(f.id);
if (!kids.length) return;
const og = document.createElement('optgroup');
og.label = '📁 ' + f.name;
kids.forEach(p => og.appendChild(mkOpt(p)));
sel.appendChild(og);
});
}
if (keep && state.projects.some(p => p.id === keep)) sel.value = keep;
else if (state.deeplinkProject && state.projects.some(p => p.id === state.deeplinkProject)) sel.value = state.deeplinkProject;
else {
const last = LS.get(LS_LASTPID, '');
if (last && state.projects.some(p => p.id === last)) sel.value = last;
}
const surveyCount = state.projects.filter(p => !((p.mode || '').toLowerCase().includes('folder'))).length;
setConn('conn-on', (state._fromCache ? '缓存 · ' : '') + '已连接 · ' + surveyCount + ' 个问卷');
showAlert('');
if (sel.value) refresh();
}
async function loadProjects() {
setConn('conn-load', '连接中…');
// 1) 先读本地缓存 → 立刻渲染,避免空白
const cached = LS.get(LS_PROJECTS, []);
if (Array.isArray(cached) && cached.length) {
state.projects = cached;
state._fromCache = true;
renderProjects();
}
// 2) 再异步刷新
try {
const list = await api('projects');
state.projects = list || [];
state._fromCache = false;
LS.set(LS_PROJECTS, state.projects);
renderProjects();
} catch (e) {
if (!cached || !cached.length) {
setConn('conn-err', '连接失败');
showAlert('无法连接卷王系统:' + e.message + '\n请点击右上角【设置】检查系统地址、接口前缀与账号密码。');
} else {
showAlert('⚠️ 后台刷新失败,已显示本地缓存:' + e.message);
}
}
}
/* ---------------- 数据刷新 ---------------- */
function applyData(d, opts) {
opts = opts || {};
state.questions = d.questions || [];
state.rows = d.rows || [];
state.absent = d.absent || [];
state.unmatched = d.unmatched || [];
state.roster = d.roster || [];
state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100;
const cur = new Set(state.rows.map(r => r.sid).filter(Boolean));
if (state.hasPrev) state.newSids = new Set(Array.from(cur).filter(s => !state.prevSids.has(s)));
else state.newSids = new Set();
state.hasPrev = true;
renderNameFields(d);
renderStats();
renderTable();
state.prevSids = cur;
populateClasses();
populateGroups();
renderAbsent();
renderUnmatched();
renderHints();
$('#lastUpdate').textContent = d.updated_at || '—';
setConn('conn-on', (opts.fromCache ? '缓存 · ' : '') + '实时 · ' + (d.project && d.project.name || ''));
if (!opts.fromCache) showAlert('');
}
async function refresh(silent) {
const pid = $('#projectSel').value;
if (!pid) return;
if (state.loading && !silent) return;
state.loading = true;
if (!silent) setConn('conn-load', '拉取数据…');
// 1) 先读本地缓存 → 立即渲染,刷新期间保持视觉稳定
const cached = LS.get(dataKey(pid), null);
if (cached && cached.updated_at) {
try {
applyData(cached, { fromCache: true });
} catch (e) { /* 旧格式缓存忽略 */ }
}
// 2) 后台异步拉真实数据
try {
const d = await api('data', { params: { projectId: pid, nameQid: $('#nameFieldSel').value } });
applyData(d);
LS.set(dataKey(pid), d);
LS.set(LS_LASTPID, pid);
LS.set(LS_LASTQID, $('#nameFieldSel').value || '');
// 名单缓存一份(便于刷新前/失败时仍能渲染班级/组别下拉)
LS.set(LS_ROSTER, { ts: Date.now(), students: d.roster || [] });
} catch (e) {
if (!cached) {
if (!silent) setConn('conn-err', '数据获取失败');
showAlert(e.message);
} else {
showAlert('⚠️ 后台刷新失败,已显示本地缓存:' + e.message);
}
} finally {
state.loading = false;
$('#liveDot').classList.add('on');
setTimeout(() => $('#liveDot').classList.remove('on'), 900);
}
}
function renderNameFields(d) {
const sel = $('#nameFieldSel');
const keep = sel.value;
sel.innerHTML = '<option value="">自动识别</option>';
const cands = d.name_candidates || [];
cands.forEach(q => {
const o = document.createElement('option');
o.value = q.id;
o.textContent = '姓名题:' + (q.title || q.id);
sel.appendChild(o);
});
(d.questions || []).forEach(q => {
if (cands.some(c => c.id === q.id)) return;
const o = document.createElement('option');
o.value = q.id;
o.textContent = (q.title || q.id) + '(' + (q.type || '') + ')';
sel.appendChild(o);
});
const lastQid = LS.get(LS_LASTQID, '');
if (keep) sel.value = keep;
else if (state.deeplinkQid) sel.value = state.deeplinkQid;
else if (lastQid && (cands.some(c => c.id === lastQid) || (d.questions || []).some(q => q.id === lastQid))) sel.value = lastQid;
else if (d.name_qid) sel.value = d.name_qid;
}
function populateClasses() {
const sel = $('#clsSel');
const cur = sel.value;
const set = new Set();
(state.roster || []).forEach(s => { if (s.cls) set.add(s.cls); });
const vals = Array.from(set).sort((a, b) => a.localeCompare(b, 'zh', { numeric: true }));
sel.innerHTML = '<option value="">全部班级</option>' +
vals.map(v => '<option value="' + esc(v) + '">' + esc(v) + '</option>').join('');
// 优先保留用户当前已选项;否则按本地缓存恢复(跟随 projectId)
const pid = $('#projectSel').value;
const cache = pid ? (LS.get(LS_FILTERS, {})[pid] || {}) : {};
if (vals.indexOf(cur) >= 0) sel.value = cur;
else if (cache.cls && vals.indexOf(cache.cls) >= 0) sel.value = cache.cls;
else sel.value = '';
}
// 组别下拉:仅显示当前所选班级内出现的组别("全部班级" 时显示所有组别)
function populateGroups() {
const cls = $('#clsSel').value;
const grpSel = $('#grpSel');
if (!grpSel) return;
const cur = grpSel.value;
const set = new Set();
(state.roster || []).forEach(s => {
if (cls && (s.cls || '') !== cls) return;
if (s.grp) set.add(s.grp);
});
const vals = Array.from(set).sort();
grpSel.innerHTML = '<option value="">全部组别</option>' +
vals.map(v => '<option value="' + esc(v) + '">' + esc(v) + '</option>').join('');
const pid = $('#projectSel').value;
const cache = pid ? (LS.get(LS_FILTERS, {})[pid] || {}) : {};
if (vals.indexOf(cur) >= 0) grpSel.value = cur;
else if (cache.grp && vals.indexOf(cache.grp) >= 0) grpSel.value = cache.grp;
else grpSel.value = '';
}
function rememberFilters() {
const pid = $('#projectSel').value;
if (!pid) return;
const all = LS.get(LS_FILTERS, {});
all[pid] = { cls: $('#clsSel').value, grp: $('#grpSel').value, ts: Date.now() };
LS.set(LS_FILTERS, all);
}
// 按当前所选班级 + 组别(或无)客户端重算统计卡片
function computeDisplayStats(cls, grp) {
const allStudents = state.roster || [];
let students = cls ? allStudents.filter(s => (s.cls || '') === cls) : allStudents;
let rows = cls ? state.rows.filter(r => (r.cls || '') === cls) : state.rows;
if (grp) {
students = students.filter(s => (s.grp || '') === grp);
rows = rows.filter(r => (r.grp || '') === grp);
}
const total = students.length;
const submitted = rows.length;
const absent = Math.max(0, total - submitted);
const rate = total > 0 ? Math.round(submitted / total * 1000) / 10 : 0;
const scores = rows.map(r => r.score).filter(v => v !== null && v !== undefined);
const avg = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 100) / 100 : null;
const full = state.fullScore || 100;
const passCount = rows.filter(r => r.score !== null && r.score !== undefined && r.score >= 0.6 * full).length;
const passRate = submitted > 0 ? Math.round(passCount / submitted * 1000) / 10 : 0;
let maxScore = null, minScore = null;
const maxNames = [], minNames = [];
rows.forEach(r => {
if (r.score === null || r.score === undefined) return;
if (maxScore === null || r.score > maxScore) { maxScore = r.score; maxNames.length = 0; maxNames.push(r.name); }
else if (r.score === maxScore) maxNames.push(r.name);
if (minScore === null || r.score < minScore) { minScore = r.score; minNames.length = 0; minNames.push(r.name); }
else if (r.score === minScore) minNames.push(r.name);
});
const durs = rows.map(r => r.duration).filter(v => v !== null && v !== undefined);
const avgDur = durs.length ? Math.round(durs.reduce((a, b) => a + b, 0) / durs.length) : null;
return {
total, submitted, absent, rate, avg, passRate,
maxScore, minScore, maxNames, minNames, avgDur
};
}
function renderStats() {
const cls = $('#clsSel').value;
const grp = $('#grpSel').value;
const st = computeDisplayStats(cls, grp);
const set = (id, v) => { const el = $(id); if (el) el.textContent = v; };
set('#sTotal', st.total);
set('#sSubmit', st.submitted);
set('#sAbsent', st.absent);
set('#sAvg', st.avg == null ? '-' : st.avg);
set('#sMax', st.maxScore == null ? '-' : st.maxScore);
set('#sMin', st.minScore == null ? '-' : st.minScore);
set('#sPass', st.passRate + '%');
set('#sDur', st.avgDur == null ? '-' : fmtDur(st.avgDur));
}
// 各面板计数文字按所选班级 + 组别过滤(成绩明细 / 缺考名单)
// 缺考数与统计卡片保持一致:过滤后名单 − 过滤后提交(而非二次过滤后端 absent 数组)
function renderHints() {
const cls = $('#clsSel').value;
const grp = $('#grpSel').value;
const rowsForCls = cls ? state.rows.filter(r => (r.cls || '') === cls) : state.rows;
const rosterForCls = cls ? (state.roster || []).filter(s => (s.cls || '') === cls) : (state.roster || []);
const rowsForGrp = grp ? rowsForCls.filter(r => (r.grp || '') === grp) : rowsForCls;
const rosterForGrp = grp ? rosterForCls.filter(s => (s.grp || '') === grp) : rosterForCls;
const absentForGrp = Math.max(0, rosterForGrp.length - rowsForGrp.length);
$('#tableHint').textContent = '共 ' + rowsForGrp.length + ' 人已提交 · 名单 ' + rosterForGrp.length + ' 人';
$('#absentHint').textContent = absentForGrp ? '共 ' + absentForGrp + ' 人' : '';
}
function fmtDur(sec) {
if (sec == null) return '-';
if (sec < 60) return sec + ' 秒';
const m = Math.floor(sec / 60), s = sec % 60;
if (m < 60) return m + ' 分 ' + String(s).padStart(2, '0') + ' 秒';
const h = Math.floor(m / 60);
return h + ' 小时 ' + String(m % 60).padStart(2, '0') + ' 分';
}
function scoreClass(v) {
if (v == null) return '';
if (v >= 90) return 'score-high';
if (v >= 60) return 'score-mid';
return 'score-low';
}
function renderTable() {
const kw = $('#kw').value.trim().toLowerCase();
const clsFilter = $('#clsSel').value;
const grpFilter = $('#grpSel').value;
let rows = state.rows.slice();
if (clsFilter) rows = rows.filter(r => (r.cls || '') === clsFilter);
if (grpFilter) rows = rows.filter(r => (r.grp || '') === grpFilter);
if (kw) rows = rows.filter(r =>
String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw));
const k = state.sortKey, dir = state.sortAsc ? 1 : -1;
rows.sort((a, b) => {
let x = a[k], y = b[k];
if (k === 'sid' || k === 'cls') {
const na = parseFloat(x), nb = parseFloat(y);
if (!isNaN(na) && !isNaN(nb)) return (na - nb) * dir;
return String(x == null ? '' : x).localeCompare(String(y == null ? '' : y), 'zh', { numeric: true }) * dir;
}
if (x == null) x = -1;
if (y == null) y = -1;
if (typeof x === 'string') return x.localeCompare(String(y), 'zh', { numeric: true }) * dir;
return (x - y) * dir;
});
const tb = $('#tbody');
if (!rows.length) {
tb.innerHTML = '<tr><td colspan="7" class="empty">' +
(state.rows.length ? '没有匹配的记录' : '暂无提交记录') + '</td></tr>';
return;
}
tb.innerHTML = rows.map(r => {
const sig = r.sid + '|' + r.score + '|' + r.submit_at;
const changed = state.prevMap[r.sid] && state.prevMap[r.sid] !== sig;
const isNew = r.sid && state.newSids.has(r.sid);
const clsName = (changed ? 'diff ' : '') + (isNew ? 'new' : '');
return '<tr class="' + clsName.trim() + '">' +
'<td class="sid">' + esc(r.sid) + '</td>' +
'<td class="name">' + esc(r.name) + '</td>' +
'<td>' + esc(r.cls || '') + '</td>' +
'<td>' + esc(r.grp || '') + '</td>' +
'<td class="score num ' + scoreClass(r.score) + '">' + (r.score == null ? '-' : r.score) + '</td>' +
'<td class="num">' + esc(r.submit_at) + '</td>' +
'<td class="num">' + esc(r.duration_txt) + '</td>' +
'</tr>';
}).join('');
const map = {};
state.rows.forEach(r => { map[r.sid] = r.sid + '|' + r.score + '|' + r.submit_at; });
state.prevMap = map;
$$('#dataTable th.sortable').forEach(th => {
const isCur = th.dataset.k === state.sortKey;
th.innerHTML = th.textContent.replace(/[\s↑↓]+$/, '') + (isCur ? ' <span class="arrow">' + (state.sortAsc ? '↑' : '↓') + '</span>' : '');
});
}
function renderAbsent() {
const clsFilter = $('#clsSel').value;
const grpFilter = $('#grpSel').value;
// 与统计卡片保持一致:缺考 = 过滤后名单中未出现在提交记录里的学生(按 sid 匹配)
const rowsForCls = clsFilter ? state.rows.filter(r => (r.cls || '') === clsFilter) : state.rows;
const rowsForGrp = grpFilter ? rowsForCls.filter(r => (r.grp || '') === grpFilter) : rowsForCls;
const submittedSids = new Set(rowsForGrp.map(r => r.sid).filter(Boolean));
const rosterForCls = clsFilter ? (state.roster || []).filter(s => (s.cls || '') === clsFilter) : (state.roster || []);
const rosterForGrp = grpFilter ? rosterForCls.filter(s => (s.grp || '') === grpFilter) : rosterForCls;
const list = rosterForGrp.filter(s => !submittedSids.has(s.sid));
const tb = $('#absentBody');
if (!list.length) {
tb.innerHTML = '<tr><td colspan="4" class="empty">' + (clsFilter ? '本班全部已提交 🎉' : '全部已提交 🎉') + '</td></tr>';
return;
}
tb.innerHTML = list.map(a =>
'<tr><td class="sid">' + esc(a.sid) + '</td><td>' + esc(a.name) + '</td><td>' + esc(a.cls || '') + '</td><td>' + esc(a.grp || '') + '</td></tr>').join('');
}
function renderUnmatched() {
const tb = $('#unmatchedBody');
if (!state.unmatched.length) {
tb.innerHTML = '<tr><td colspan="3" class="empty">无</td></tr>';
return;
}
tb.innerHTML = state.unmatched.map(u =>
'<tr><td>' + esc(u.name) + '</td><td class="num">' + (u.score == null ? '-' : u.score) +
'</td><td class="num">' + esc(u.submit_at) + '</td></tr>').join('');
}
/* ---------------- 自动刷新 ---------------- */
function schedule() {
if (state.timer) clearInterval(state.timer);
if (!$('#autoRefresh').checked) return;
const sec = parseInt($('#intervalSel').value, 10) || 15;
state.timer = setInterval(() => refresh(true), sec * 1000);
}
/* ---------------- 全屏 ---------------- */
function toggleFullscreen() {
const el = document.documentElement;
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
const req = el.requestFullscreen || el.webkitRequestFullscreen;
if (req) req.call(el);
} else {
const exit = document.exitFullscreen || document.webkitExitFullscreen;
if (exit) exit.call(document);
}
}
function onFsChange() {
const on = !!(document.fullscreenElement || document.webkitFullscreenElement);
document.body.classList.toggle('fs-on', on);
const btn = $('#btnFullscreen');
if (btn) btn.textContent = on ? '⛶ 退出全屏' : '⛶ 全屏';
}
/* ---------------- 布局切换 ---------------- */
function applyLayout(mode) {
const m = (mode === 'side') ? 'side' : 'default';
const lay = document.getElementById('dataLayout');
if (lay) lay.setAttribute('data-layout', m);
LS.set(LS_LAYOUT, m);
}
/* ---------------- 设置 ---------------- */
async function openSettings() {
try {
const c = await api('config_get');
$('#cfgPageSize').value = c.page_size || 200;
$('#cfgInterval').value = c.refresh_interval || 15;
$('#cfgTimeout').value = c.timeout || 20;
$('#cfgKeywords').value = c.name_keywords || '姓名';
$('#cfgDedupe').checked = !!c.dedupe;
$('#cfgTemp').checked = !!c.include_temp_save;
$('#cfgSsl').checked = !!c.verify_ssl;
// 布局
const layout = c.layout || LS.get(LS_LAYOUT, 'default');
(document.querySelector('input[name=cfgLayout][value="' + layout + '"]') || {}).checked = true;
// 满分:默认 + 各试卷独立
$('#cfgFull').value = c.full_score || 100;
renderFullScores((c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {});
const pid = $('#projectSel').value;
const pObj = state.projects.find(p => p.id === pid);
$('#cfgFullHint').textContent = pid
? ('当前选中「' + (pObj ? pObj.name : pid) + '」,如需单独设满分请在上方"各试卷独立满分"修改')
: '未选中试卷时,作为所有试卷的默认值';
} catch (e) { showAlert(e.message); }
$('#settingsModal').classList.add('open');
}
// 渲染"各试卷独立满分"列表
function renderFullScores(scores) {
const box = $('#fullScoresBox');
if (!box) return;
if (!state.projects.length) {
box.innerHTML = '<span class="hint">暂无试卷,请先连接卷王系统并选择问卷</span>';
return;
}
// 排除文件夹项:模式为文件夹的无可答卷
const surveys = state.projects.filter(p => !((p.mode || '').toLowerCase().includes('folder')));
if (!surveys.length) {
box.innerHTML = '<span class="hint">尚未获取到可答卷</span>';
return;
}
box.innerHTML = surveys.map(p => {
const val = scores[p.id] != null ? scores[p.id] : '';
return '<div class="row">' +
'<label title="' + esc(p.name) + '">' + esc(p.name) + '</label>' +
'<input type="number" min="1" max="1000" data-pid="' + esc(p.id) + '" value="' + esc(val) + '" placeholder="默认">' +
'</div>';
}).join('');
$('#fullScoresHint').textContent = '默认分值为空时使用上方"默认满分";填写即单独覆盖该卷';
}
/* ---------------- 名单 ---------------- */
let rosterDraft = [];
async function openRoster() {
try {
const d = await api('roster');
rosterDraft = d.students || [];
renderRoster();
$('#rosterMeta').textContent = '共 ' + rosterDraft.length + ' 人' +
(d.meta && d.meta.updated_at ? ' · 更新于 ' + d.meta.updated_at : '');
} catch (e) { showAlert(e.message); }
$('#rosterModal').classList.add('open');
}
function renderRoster() {
const kw = $('#rosterSearch').value.trim().toLowerCase();
const rows = rosterDraft.filter(r => !kw ||
String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw));
const tb = $('#rosterBody');
if (!rows.length) {
tb.innerHTML = '<tr><td colspan="4" class="empty">暂无名单,请上传文件</td></tr>';
return;
}
tb.innerHTML = rows.map((r, i) => {
const gi = rosterDraft.indexOf(r);
return '<tr data-i="' + gi + '">' +
'<td><input value="' + esc(r.sid) + '" data-f="sid"></td>' +
'<td><input value="' + esc(r.name) + '" data-f="name"></td>' +
'<td><input value="' + esc(r.cls || '') + '" data-f="cls" placeholder="可选"></td>' +
'<td><input value="' + esc(r.grp || '') + '" data-f="grp" placeholder="A/B"></td>' +
'<td><span class="link-del" data-del="' + gi + '">删除</span></td></tr>';
}).join('');
}
/* ---------------- 事件绑定 ---------------- */
document.addEventListener('DOMContentLoaded', function () {
$('#btnRefresh').addEventListener('click', () => refresh(false));
$('#btnSettings').addEventListener('click', openSettings);
$('#btnRoster').addEventListener('click', openRoster);
$('#btnFullscreen').addEventListener('click', toggleFullscreen);
document.addEventListener('fullscreenchange', onFsChange);
if (document.webkitFullscreenEnabled === undefined && !document.documentElement.requestFullscreen && !document.documentElement.webkitRequestFullscreen) {
$('#btnFullscreen').style.display = 'none';
}
$('#kw').addEventListener('input', renderTable);
$('#clsSel').addEventListener('change', () => {
populateGroups();
rememberFilters();
renderStats();
renderTable();
renderAbsent();
renderHints();
});
$('#grpSel').addEventListener('change', () => {
rememberFilters();
renderStats();
renderTable();
renderAbsent();
renderHints();
});
$('#nameFieldSel').addEventListener('change', () => {
state.prevMap = {}; state.prevSids = new Set(); state.hasPrev = false;
LS.set(LS_LASTQID, $('#nameFieldSel').value || '');
refresh();
});
$('#projectSel').addEventListener('change', () => {
// 切换项目后清掉前一项目的 prevMap 与高亮缓存
state.prevMap = {}; state.prevSids = new Set(); state.hasPrev = false;
// 用缓存数据先快速渲染,再后台拉新数据
refresh();
});
$('#intervalSel').addEventListener('change', schedule);
$('#autoRefresh').addEventListener('change', e => {
if (e.target.checked) { schedule(); refresh(true); }
else if (state.timer) clearInterval(state.timer);
});
$$('#dataTable th.sortable').forEach(th => {
th.addEventListener('click', () => {
if (state.sortKey === th.dataset.k) state.sortAsc = !state.sortAsc;
else { state.sortKey = th.dataset.k; state.sortAsc = true; }
renderTable();
});
});
// 导出跟随当前班级 / 组别筛选(统计卡片与表格显示什么,就导出什么)
$('#btnExport').addEventListener('click', () => {
const pid = $('#projectSel').value;
if (!pid) return showAlert('请先选择问卷 / 考试');
const cls = $('#clsSel').value, grp = $('#grpSel').value;
location.href = 'api.php?action=export&projectId=' + encodeURIComponent(pid) +
'&nameQid=' + encodeURIComponent($('#nameFieldSel').value) +
'&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp);
showAlert(exportTip('成绩明细', cls, grp), 'info');
});
$('#btnExportAbsent').addEventListener('click', () => {
const pid = $('#projectSel').value;
if (!pid) return showAlert('请先选择问卷 / 考试');
const cls = $('#clsSel').value, grp = $('#grpSel').value;
location.href = 'api.php?action=export_absent&projectId=' + encodeURIComponent(pid) +
'&nameQid=' + encodeURIComponent($('#nameFieldSel').value) +
'&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp);
showAlert(exportTip('缺考名单', cls, grp), 'info');
});
/* 弹窗关闭 */
document.addEventListener('click', e => {
if (e.target.matches('[data-close]') || e.target.classList.contains('modal')) {
$$('.modal').forEach(m => m.classList.remove('open'));
}
});
/* 设置保存 */
$('#btnSaveCfg').addEventListener('click', async () => {
const payload = {
page_size: parseInt($('#cfgPageSize').value, 10) || 200,
refresh_interval: parseInt($('#cfgInterval').value, 10) || 15,
timeout: parseInt($('#cfgTimeout').value, 10) || 20,
name_keywords: $('#cfgKeywords').value.trim(),
dedupe: $('#cfgDedupe').checked,
include_temp_save: $('#cfgTemp').checked,
verify_ssl: $('#cfgSsl').checked,
full_score: parseInt($('#cfgFull').value, 10) || 100,
layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default',
full_scores: collectFullScores(),
project_id: $('#projectSel').value
};
try {
await api('config_save', { method: 'POST', body: payload });
$('#settingsModal').classList.remove('open');
// 应用布局偏好(写入容器 + localStorage)
applyLayout(payload.layout);
const iv = payload.refresh_interval;
const opts = $$('#intervalSel option').map(o => parseInt(o.value, 10));
let best = opts[0];
opts.forEach(v => { if (Math.abs(v - iv) < Math.abs(best - iv)) best = v; });
$('#intervalSel').value = String(best);
schedule();
loadProjects();
} catch (e) { showAlert(e.message); }
});
// 收集"各试卷独立满分" -> {projectId: score}
function collectFullScores() {
const out = {};
$$('#fullScoresBox input[data-pid]').forEach(inp => {
const v = parseInt(inp.value, 10);
if (inp.dataset.pid && !isNaN(v) && v > 0) out[inp.dataset.pid] = v;
});
return out;
}
/* 名单上传 */
const zone = $('#dropZone'), fileInput = $('#rosterFile');
zone.addEventListener('click', () => fileInput.click());
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('over'); });
zone.addEventListener('dragleave', () => zone.classList.remove('over'));
zone.addEventListener('drop', e => {
e.preventDefault(); zone.classList.remove('over');
if (e.dataTransfer.files.length) uploadRoster(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length) uploadRoster(fileInput.files[0]);
fileInput.value = '';
});
async function uploadRoster(file) {
const fd = new FormData();
fd.append('file', file);
const mode = (document.querySelector('input[name=impMode]:checked') || {}).value || 'replace';
fd.append('mode', mode);
try {
const d = await api('roster_import', { form: fd });
rosterDraft = d.students || [];
renderRoster();
$('#rosterMeta').textContent = '共 ' + rosterDraft.length + ' 人 · 导入完成';
$('#rosterBadge').textContent = rosterDraft.length;
} catch (e) { showAlert(e.message); }
}
$('#rosterSearch').addEventListener('input', renderRoster);
$('#btnAddRow').addEventListener('click', () => {
rosterDraft.push({ sid: '', name: '', cls: '' });
renderRoster();
});
$('#btnClearRoster').addEventListener('click', async () => {
if (!confirm('确定清空全部名单?')) return;
try {
const d = await api('roster_clear', { method: 'POST' });
rosterDraft = [];
renderRoster();
$('#rosterBadge').textContent = '0';
$('#rosterMeta').textContent = '共 0 人';
} catch (e) { showAlert(e.message); }
});
$('#rosterBody').addEventListener('input', e => {
const tr = e.target.closest('tr');
if (!tr) return;
const i = parseInt(tr.dataset.i, 10);
if (isNaN(i)) return;
rosterDraft[i][e.target.dataset.f] = e.target.value;
});
$('#rosterBody').addEventListener('click', e => {
const id = e.target.getAttribute && e.target.getAttribute('data-del');
if (id == null) return;
rosterDraft.splice(parseInt(id, 10), 1);
renderRoster();
});
$('#btnSaveRoster').addEventListener('click', async () => {
try {
const d = await api('roster_save', { method: 'POST', body: { students: rosterDraft } });
rosterDraft = d.students || [];
renderRoster();
$('#rosterBadge').textContent = rosterDraft.length;
$('#rosterMeta').textContent = '共 ' + rosterDraft.length + ' 人 · 已保存';
if ($('#projectSel').value) refresh(true);
} catch (e) { showAlert(e.message); }
});
/* 启动 */
// 1) 恢复布局偏好(仅样式,不涉及数据)
applyLayout(LS.get(LS_LAYOUT, 'default'));
// 2) 支持 ?projectId=xxx&nameQid=yyy 直接打开某份问卷
const qs = new URLSearchParams(location.search);
if (qs.get('interval')) $('#intervalSel').value = qs.get('interval');
state.deeplinkProject = qs.get('projectId') || '';
state.deeplinkQid = qs.get('nameQid') || '';
// 退出登录:跳转到独立 logout.php(清服务端会话)
$('#btnLogout').addEventListener('click', () => { location.href = 'logout.php'; });
// 3) 支持 index.php#roster 直接打开名单管理(随机抽取页的「名单管理」入口用)
if (location.hash === '#roster') {
openRoster();
if (history.replaceState) history.replaceState(null, '', location.pathname + location.search);
}
// 启动:主界面在 PHP 端已做会话校验(未登录会跳到 login.php),此处直接加载数据
schedule();
loadProjects();
});
})();