Files
ny-exam-platform/assets/app.js
T
nxxy335 e031b3858e feat: 新数据高亮每 30 秒自动清除一次
- 新增独立定时器 startHlAutoClear:每 hlAutoClearSec(默认30)秒扫描一次,
  若仍有高亮则清空并重新渲染表格与计数徽标;无高亮时不打扰。
- 既保留'比闪一下停留更久'的可见性,又避免绿色底纹长期堆积失去重点。
- 清除按钮 tooltip 同步说明'每 30 秒自动清除一次'。
- 与手动清除(#btnClearHl)、切换项目重置高亮互不冲突。
2026-09-10 10:55:19 +08:00

1171 lines
48 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: '',
connFail: 0,
historyDisplay: 'click', // click=点击成员弹窗查看历史;inline=成绩明细内全部展开
exportSort: 'time', // time=按提交时间;sid=按学号
highlight: new Set(), // 出现过新提交/新数据的学号,绿色底纹
hlTimer: null, // 自动清除高亮的定时器
hlAutoClearSec: 30, // 新数据高亮每隔多少秒自动清除一次
};
/* ---------------- 本地存储 ---------------- */
// 浏览器关闭后仍然保留,账号/密码等敏感配置除外;进入页面时优先读缓存,再后台异步刷新
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_INTERVAL = 'ny_interval'; // 用户手动调整的刷新间隔(跟随本机)
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) {
const err = new Error(json.msg || '请求失败');
err.auth = !!(json.extra && json.extra.auth);
err.extra = json.extra || null;
throw err;
}
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 rowSig(r) {
return [
r.sid, r.score, r.submit_at, r.attempts || 1, r.last_submit_ts || 0,
].join('|');
}
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;
// 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据"
// (纳入 attempts 与 last_submit_ts 才能发现"重考但分数更低"这种提交)
const fromCache = !!opts.fromCache;
const cur = new Set(state.rows.map(r => r.sid).filter(Boolean));
// 缓存渲染不参与变更检测:本地缓存可能是旧数据,与上一轮真实数据比对会产生假高亮
if (state.hasPrev && !fromCache) {
state.newSids = new Set(Array.from(cur).filter(s => !state.prevSids.has(s)));
state.rows.forEach(r => {
const sid = String(r.sid || '');
if (sid === '') return;
const prev = state.prevMap[sid];
if (prev === undefined || prev !== rowSig(r)) {
state.highlight.add(sid); // 累积:一直常亮到用户清除或切换项目
}
});
} else if (!state.hasPrev) {
state.newSids = new Set();
}
if (!fromCache) state.hasPrev = true;
renderNameFields(d);
renderStats();
// 仅真实数据才更新指纹基准(缓存可能是旧数据,不能作为比对基准)
if (!fromCache) {
const map = {};
state.rows.forEach(r => { map[String(r.sid)] = rowSig(r); });
state.prevMap = map;
state.prevSids = cur;
}
renderTable();
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);
state.connFail = 0;
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 (e.auth) {
// 卷王账号未配置或登录已失效:引导重新登录,避免卡在"已显示本地缓存"的死状态
showAlert('卷王账号未配置或登录已失效,正在跳转登录页…', 'error');
setTimeout(() => { location.href = 'login.php'; }, 900);
return;
}
if (!cached) {
if (!silent) setConn('conn-err', '数据获取失败');
showAlert(e.message);
} else {
// 后台静默刷新失败:保留缓存数据,但把连接指示转为"中断"告警,成功即恢复
state.connFail++;
if (state.connFail >= 1) setConn('conn-warn', '⚠️ 实时刷新中断 · 已显示本地缓存');
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';
}
const TEXT_KEYS = { sid: 1, cls: 1, grp: 1, name: 1 };
function cmpText(x, y) {
return String(x).localeCompare(String(y), 'zh', { numeric: true });
}
/** 学号比较:纯数字按数值,否则自然排序 */
function cmpSid(a, b) {
const s1 = String(a.sid == null ? '' : a.sid);
const s2 = String(b.sid == null ? '' : b.sid);
if (s1 !== '' && s2 !== '' && !isNaN(Number(s1)) && !isNaN(Number(s2))) {
const d = Number(s1) - Number(s2);
if (d !== 0) return d;
}
return cmpText(s1, s2);
}
/** 某排序键是否视为"空值"(空值恒排末尾,不受升降序影响) */
function isEmptyVal(key, v) {
if (v === null || v === undefined || v === '') return true;
// 时间戳 0 表示未知时间
if ((key === 'submit_ts' || key === 'last_submit_ts') && Number(v) === 0) return true;
return false;
}
/**
* 统一比较器:
* - 空值恒排末尾(不再因为 -1 混在中间)
* - 主键相同时按学号做次级排序,保证顺序稳定
*/
function cmpBy(key, dir, a, b) {
const x = a[key], y = b[key];
const xe = isEmptyVal(key, x), ye = isEmptyVal(key, y);
if (xe && ye) return cmpSid(a, b) * dir;
if (xe) return 1; // 空值永远在后
if (ye) return -1;
let d;
if (TEXT_KEYS[key]) {
d = cmpText(x, y);
} else {
const nx = Number(x), ny = Number(y);
d = (isNaN(nx) || isNaN(ny)) ? cmpText(x, y) : (nx - ny);
}
if (d !== 0) return d * dir;
return cmpSid(a, b) * dir;
}
/** 展开为"每次考试一行"(历史成绩全部展示模式) */
function expandAttempts(rows) {
const out = [];
rows.forEach(r => {
const list = (r.attempts_list && r.attempts_list.length) ? r.attempts_list : null;
if (!list) {
out.push(Object.assign({}, r, { idx: 1, is_best: true, _att: false }));
return;
}
list.forEach(a => {
out.push({
sid: r.sid, name: r.name, cls: r.cls, grp: r.grp,
score: (a.score === undefined ? null : a.score),
submit_ts: a.submit_ts || 0,
submit_at: a.submit_at || '-',
duration: (a.duration === undefined ? null : a.duration),
duration_txt: a.duration_txt || '-',
attempts: r.attempts || 1,
best_score: (r.best_score === undefined ? null : r.best_score),
last_submit_ts: r.last_submit_ts || 0,
last_submit_at: r.last_submit_at || '-',
idx: a.idx || 1,
is_best: !!a.is_best,
answer_id: a.answer_id || '',
temp_save: !!a.temp_save,
_att: true,
});
});
});
return out;
}
/** 依据筛选 + 展示模式,得到待渲染的行 */
function buildRows() {
const kw = $('#kw').value.trim().toLowerCase();
const clsFilter = $('#clsSel').value;
const grpFilter = $('#grpSel').value;
let base = state.rows.slice();
if (clsFilter) base = base.filter(r => (r.cls || '') === clsFilter);
if (grpFilter) base = base.filter(r => (r.grp || '') === grpFilter);
if (kw) base = base.filter(r =>
String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw));
if (state.historyDisplay === 'inline') {
return expandAttempts(base);
}
// 一人一行:提交时间显示"最近一次提交",使"按提交时间排序"= 谁最近交的
return base.map(r => Object.assign({}, r, {
submit_ts: (r.last_submit_ts || r.submit_ts || 0),
submit_at: (r.last_submit_at && r.last_submit_at !== '-') ? r.last_submit_at : (r.submit_at || '-'),
_att: false,
}));
}
const COL_COUNT = 8;
/** 更新"清除高亮"按钮的可见性与计数 */
function renderHlBadge() {
const btn = $('#btnClearHl');
const badge = $('#hlBadge');
if (!btn) return;
const n = state.highlight.size;
if (badge) badge.textContent = n;
btn.hidden = (n === 0);
}
function renderTable() {
const rows = buildRows();
const k = state.sortKey, dir = state.sortAsc ? 1 : -1;
rows.sort((a, b) => cmpBy(k, dir, a, b));
const tb = $('#tbody');
if (!rows.length) {
tb.innerHTML = '<tr><td colspan="' + COL_COUNT + '" class="empty">' +
(state.rows.length ? '没有匹配的记录' : '暂无提交记录') + '</td></tr>';
return;
}
tb.innerHTML = rows.map(r => {
const sid = String(r.sid || '');
const isNew = sid !== '' && state.newSids.has(sid);
const hl = sid !== '' && state.highlight.has(sid);
const attempts = (r.attempts || 1);
const clsName = [hl ? 'new' : '', 'clickable'].filter(Boolean).join(' ');
// 姓名后缀标记
const tags = [];
if (isNew) tags.push('<span class="tag tag-new">新</span>');
if (r._att && r.is_best && attempts > 1) tags.push('<span class="tag tag-best">最佳</span>');
if (r._att && r.temp_save) tags.push('<span class="tag tag-gray">暂存</span>');
// 考试次数:全部展示模式下额外标出"第几次"
const attCell = r._att && attempts > 1
? attempts + ' <small style="color:var(--text-3)">第' + r.idx + '次</small>'
: String(attempts);
return '<tr class="' + clsName + '" data-sid="' + esc(sid) + '" title="点击查看历史成绩">' +
'<td class="sid">' + esc(r.sid) + '</td>' +
'<td class="name">' + esc(r.name) + (tags.length ? ' ' + tags.join(' ') : '') + '</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 att">' + attCell + '</td>' +
'<td class="num">' + esc(r.submit_at) + '</td>' +
'<td class="num">' + esc(r.duration_txt) + '</td>' +
'</tr>';
}).join('');
$$('#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>' : '');
});
renderHlBadge();
}
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('');
}
/* 历史成绩弹窗(主统计页成绩明细):任意成员都可点开,含只考过 1 次的 */
function openAttemptsModal(sid) {
const r = (state.rows || []).find(x => String(x.sid) === String(sid));
if (!r) return;
const attempts = (r.attempts || 1);
let list = r.attempts_list || [];
if (!list.length) {
// 后端未返回明细时,用代表行兜底成一条
list = [{
idx: 1, is_best: true, score: r.score, submit_at: r.submit_at,
duration_txt: r.duration_txt, temp_save: !!r.temp_save,
}];
}
// 新 -> 旧 展示
const desc = list.slice().sort((a, b) => (b.submit_ts || 0) - (a.submit_ts || 0));
const rows = desc.map((a, i) => {
const sc = (a.score == null) ? '<span class="muted">未判分</span>' : esc(a.score);
const best = (a.is_best && attempts > 1) ? ' <span class="tag tag-best">最佳</span>' : '';
const tmp = a.temp_save ? ' <span class="tag tag-gray">暂存</span>' : '';
const no = attempts > 1 ? (a.idx || (desc.length - i)) : 1;
return '<tr>' +
'<td class="num">' + (attempts > 1 ? '第 ' + no + ' 次' : '-') + '</td>' +
'<td class="num">' + esc(a.submit_at || '-') + '</td>' +
'<td class="num">' + sc + best + tmp + '</td>' +
'<td class="num">' + esc(a.duration_txt || '-') + '</td>' +
'</tr>';
}).join('');
const bestScore = (r.best_score == null) ? r.score : r.best_score;
$('#attTitle').textContent = (r.name || '') + '(' + r.sid + ') · 共 ' + attempts + ' 次考试' +
(bestScore == null ? '' : ' · 最高 ' + bestScore + ' 分');
$('#attBody').innerHTML =
'<table class="grid compact"><thead><tr><th style="width:80px">次序</th><th>提交时间</th><th>成绩</th><th>考试时长</th></tr></thead><tbody>' +
rows + '</tbody></table>';
$('#attemptsModal').classList.add('open');
}
function closeAttemptsModal() {
const m = $('#attemptsModal');
if (m) m.classList.remove('open');
}
/* ---------------- 自动刷新 ---------------- */
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);
}
/* ---------------- 新数据高亮自动清除 ---------------- */
// 绿色高亮用于标记"有更新的成员",停留更易察觉(不再只闪一下),
// 但长时间堆积会失去重点——故每 hlAutoClearSec 秒自动清除一次。
function startHlAutoClear() {
if (state.hlTimer) clearInterval(state.hlTimer);
const sec = state.hlAutoClearSec || 30;
state.hlTimer = setInterval(() => {
if (state.highlight.size === 0) return; // 无高亮时不打扰
state.highlight = new Set();
renderTable();
renderHlBadge();
}, 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 : {});
// 历史成绩展示方式 + 导出排序
state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click';
state.exportSort = (c.export_sort === 'sid') ? 'sid' : 'time';
$('#cfgHistoryDisplay').value = state.historyDisplay;
$('#cfgExportSort').value = state.exportSort;
if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort;
// 连接信息(账号回显;密码不回显)
$('#cfgBase').value = (c.base_url || '') + (c.api_prefix || '/api');
$('#cfgUser').value = c.username || '';
$('#cfgPass').value = '';
const conn = c.db_configured
? (c.db_connected
? '数据库:已连接 (' + (c.db_driver || '') + ')'
: ('数据库:未连接 · ' + (c.db_error || '未知错误')))
: '数据库:未配置(使用文件存储)';
$('#cfgConnInfo').textContent = '配置来源:' + (c.storage || 'default') + ' · ' + conn;
$('#pingResult').classList.add('hidden');
} 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;
state.highlight = new Set(); // 换了姓名题,旧高亮无意义
LS.set(LS_LASTQID, $('#nameFieldSel').value || '');
refresh();
});
$('#projectSel').addEventListener('change', () => {
// 切换项目后清掉前一项目的 prevMap 与高亮缓存
state.prevMap = {}; state.prevSids = new Set(); state.hasPrev = false;
state.highlight = new Set(); // 换项目,绿色高亮归零
// 用缓存数据先快速渲染,再后台拉新数据
refresh();
});
$('#intervalSel').addEventListener('change', () => {
LS.set(LS_INTERVAL, parseInt($('#intervalSel').value, 10) || 15);
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();
});
});
/* 成绩明细行点击 → 历史成绩弹窗(事件委托,避免内联 onclick 找不到 IIFE 内函数) */
$('#tbody').addEventListener('click', e => {
const tr = e.target.closest('tr[data-sid]');
if (!tr) return;
const sid = tr.getAttribute('data-sid');
if (sid) openAttemptsModal(sid);
});
/* 清除高亮 */
$('#btnClearHl').addEventListener('click', () => {
state.highlight = new Set();
renderTable();
renderHlBadge();
showAlert('已清除新数据高亮', 'info');
});
/* 导出排序切换(即时保存到服务端配置) */
$('#exportSortSel').addEventListener('change', async () => {
state.exportSort = $('#exportSortSel').value;
if ($('#cfgExportSort')) $('#cfgExportSort').value = state.exportSort;
try {
await api('config_save', { method: 'POST', body: { export_sort: state.exportSort } });
} catch (e) { /* 静默:本地已生效 */ }
});
// 导出跟随当前班级 / 组别筛选(统计卡片与表格显示什么,就导出什么)
$('#btnExport').addEventListener('click', () => {
const pid = $('#projectSel').value;
if (!pid) return showAlert('请先选择问卷 / 考试');
const cls = $('#clsSel').value, grp = $('#grpSel').value;
const sort = (state.exportSort === 'sid') ? 'sid' : 'time';
location.href = 'api.php?action=export&projectId=' + encodeURIComponent(pid) +
'&nameQid=' + encodeURIComponent($('#nameFieldSel').value) +
'&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp) +
'&sort=' + sort;
showAlert(exportTip('成绩明细', cls, grp) + ',排序:' + (sort === 'sid' ? '按学号' : '按提交时间'), '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 btnSave = $('#btnSaveCfg');
btnSave.disabled = true; btnSave.textContent = '保存中…';
const pass = $('#cfgPass').value;
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(),
username: $('#cfgUser').value.trim(),
history_display: $('#cfgHistoryDisplay').value,
export_sort: $('#cfgExportSort').value,
};
if (pass !== '') payload.password = pass; // 留空 = 不修改
let resp;
try {
resp = await api('config_save', { method: 'POST', body: payload });
} catch (e) { showAlert(e.message); btnSave.disabled = false; btnSave.textContent = '保存'; return; }
$('#cfgPass').value = ''; // 提交后清空密码框
const note = (resp && resp.conn_changed) ? '账号/连接已更新' : '设置已保存';
if (resp && resp.ping) {
if (resp.ping.ok) {
showAlert(note + ',测连接成功(' + resp.ping.projects + ' 个项目 / ' + resp.ping.elapsed + 'ms)', 'info');
} else {
showAlert(note + ',但测连接失败:' + resp.ping.msg, 'error');
}
} else {
showAlert(note, 'info');
}
// 应用布局偏好(写入容器 + 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();
// 展示方式可能变化 → 立即重渲染表格(不重新拉数据)
state.historyDisplay = payload.history_display;
state.exportSort = payload.export_sort;
if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort;
renderTable();
// ping 失败时保留弹窗,让用户能立即改回
if (!resp || !resp.ping || resp.ping.ok) {
$('#settingsModal').classList.remove('open');
}
btnSave.disabled = false; btnSave.textContent = '保存';
});
/* 独立测试连接(使用弹窗内尚未保存的账号/密码,不落盘) */
async function testConnection() {
const btn = $('#btnTestConn');
const box = $('#pingResult');
if (!btn || !box) return;
btn.disabled = true; btn.textContent = '测试中…';
box.classList.remove('hidden', 'err'); box.classList.add('loading');
box.textContent = '正在连接卷王系统测试…';
try {
// 仅传可编辑的连接字段;base_url / api_prefix 在弹窗中只读,沿用已保存值
const r = await api('config_test', {
method: 'POST',
body: {
username: $('#cfgUser').value.trim(),
password: $('#cfgPass').value,
verify_ssl: $('#cfgSsl').checked
}
});
box.classList.remove('loading');
if (r && r.connected) {
box.classList.remove('err');
box.textContent = '✅ 连接成功:检测到 ' + r.projects + ' 个问卷 / 用时 ' + r.elapsed + 'ms';
} else {
box.classList.add('err');
box.textContent = '❌ 连接失败:' + ((r && r.msg) || '未知错误');
}
} catch (e) {
box.classList.remove('loading'); box.classList.add('err');
box.textContent = '❌ 测试请求异常:' + e.message;
} finally {
btn.disabled = false; btn.textContent = '测试连接';
}
}
$('#btnTestConn').addEventListener('click', testConnection);
// 收集"各试卷独立满分" -> {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');
else {
const iv = LS.get(LS_INTERVAL, 0);
if (iv) {
const opts = $$('#intervalSel option').map(o => parseInt(o.value, 10));
if (opts.indexOf(iv) >= 0) $('#intervalSel').value = String(iv);
}
}
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();
startHlAutoClear(); // 新数据高亮每 30s 自动清除一次
loadProjects();
// 4) 读取展示偏好(历史成绩展示方式 / 导出排序),避免必须打开设置弹窗才生效
(async () => {
try {
const c = await api('config_get');
state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click';
state.exportSort = (c.export_sort === 'sid') ? 'sid' : 'time';
if ($('#cfgHistoryDisplay')) $('#cfgHistoryDisplay').value = state.historyDisplay;
if ($('#cfgExportSort')) $('#cfgExportSort').value = state.exportSort;
if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort;
renderTable();
} catch (e) { /* 用默认值即可 */ }
})();
});
})();