diff --git a/api.php b/api.php
index 9690e66..246996b 100644
--- a/api.php
+++ b/api.php
@@ -87,6 +87,8 @@ function ny_build_data(array $c, string $projectId, string $nameQid, bool $force
(string)($c['name_keywords'] ?? ''),
(int)($c['page_size'] ?? 100),
Roster::fingerprint(),
+ // 本卷独立配置(免考名单/满分/合格率)变化也应让缓存失效,否则改免考后可能短暂看到旧数据
+ json_encode(DataService::projectConfig($projectId) ?? []),
]);
if ($ttl > 0 && !$force) {
@@ -110,11 +112,16 @@ function ny_build_data(array $c, string $projectId, string $nameQid, bool $force
// 等不到(构建很慢或锁不被支持)就自己构建,保证可用性
}
- $result = DataService::build($c, $projectId, $nameQid);
- if ($ttl > 0) {
- DataCache::write($key, $result);
+ // try/finally:无论 build 是否抛异常都释放构建锁,避免异常时锁句柄挂到进程退出造成惊群
+ $result = null;
+ try {
+ $result = DataService::build($c, $projectId, $nameQid);
+ if ($ttl > 0) {
+ DataCache::write($key, $result);
+ }
+ } finally {
+ DataCache::releaseLock($lock);
}
- DataCache::releaseLock($lock);
return ['result' => $result, 'cached' => false, 'at' => time()];
}
@@ -152,11 +159,13 @@ try {
$c['db_driver'] = class_exists('Db') ? Db::driver() : '';
$c['db_configured'] = class_exists('Db') && Db::isConfigured();
$c['db_connected'] = class_exists('Db') && Db::isConnected();
- $c['db_error'] = class_exists('Db') ? Db::lastError() : '';
+ // 仅在未连接时回传错误详情(供前端诊断连接失败原因);
+ // 已连接时一律不回传 lastError,避免把 SQL/文件路径等内部信息泄露给客户端。
+ $c['db_error'] = (class_exists('Db') && !Db::isConnected()) ? Db::lastError() : '';
ny_ok($c);
case 'config_save':
- $raw = (string)file_get_contents('php://input');
+ $raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
@@ -250,7 +259,7 @@ try {
// ---------------- 用弹窗内(未保存)的凭据测试连接 ----------------
case 'config_test':
- $raw = (string)file_get_contents('php://input');
+ $raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
@@ -502,12 +511,49 @@ try {
}
// 解析完立即删除:Web 目录下不再留存任何上传文件
@unlink($dest);
- $mode = (string)($_POST['mode'] ?? 'replace');
+ // 默认追加:与「导入名单默认为追加」一致(学号名单管理导入)
+ $mode = (string)($_POST['mode'] ?? 'append');
if ($mode === 'append') {
- $merged = array_merge(Roster::all(), $res['students']);
- Roster::save($merged);
+ // 追加:仅加入名单中尚不存在的成员(无学号时按姓名判重),避免重复行。
+ // 学号与姓名分两套判重键:带学号者以 sid 判重,无学号者以姓名判重,
+ // 避免「已存在带学号 A 的行」被「同名但空学号的导入行」误判为不重复而重复插入。
+ $existing = Roster::all();
+ $seenSid = [];
+ $seenName = [];
+ foreach ($existing as $s) {
+ $sid = strtolower(trim((string)($s['sid'] ?? '')));
+ $name = strtolower(trim((string)($s['name'] ?? '')));
+ if ($sid !== '') {
+ $seenSid[$sid] = true;
+ } elseif ($name !== '') {
+ $seenName[$name] = true;
+ }
+ }
+ $added = [];
+ foreach ($res['students'] as $s) {
+ $sid = strtolower(trim((string)($s['sid'] ?? '')));
+ $name = strtolower(trim((string)($s['name'] ?? '')));
+ $dup = false;
+ if ($sid !== '') {
+ if (isset($seenSid[$sid])) { $dup = true; }
+ else { $seenSid[$sid] = true; }
+ } elseif ($name !== '') {
+ if (isset($seenName[$name])) { $dup = true; }
+ else { $seenName[$name] = true; }
+ }
+ if ($dup) {
+ continue;
+ }
+ $added[] = $s;
+ }
+ if (!Roster::save(array_merge($existing, $added))) {
+ ny_fail('名单追加失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
+ }
+ $res['count'] = count($added); // 仅计入真正新增的人数
} else {
- Roster::save($res['students']);
+ if (!Roster::save($res['students'])) {
+ ny_fail('名单导入失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
+ }
}
ny_ok([
'students' => Roster::all(),
@@ -517,8 +563,13 @@ try {
'duplicates' => $res['duplicates'] ?? 0,
]);
+ // ---------------- 从卷王白名单导入待考名单(已移除)----------------
+ // 现改为:DataService::build 自动读取每套试卷卷内设置的「答题白名单」,
+ // 白名单成员即「应考名单」,未设置时回退到全名单(按现在的方案显示)。
+ // 名单管理里的「⬇ 从白名单导入」按钮已删除。
+
case 'roster_save':
- $raw = (string)file_get_contents('php://input');
+ $raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in) || !isset($in['students']) || !is_array($in['students'])) {
ny_fail('参数错误');
@@ -536,7 +587,9 @@ try {
'login' => trim((string)($s['login'] ?? '')),
];
}
- Roster::save($students);
+ if (!Roster::save($students)) {
+ ny_fail('名单保存失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
+ }
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta()]);
case 'roster_clear':
@@ -545,7 +598,7 @@ try {
// ---------------- 未匹配答卷 → 加入名单 ----------------
case 'unmatched_map':
- $raw = (string)file_get_contents('php://input');
+ $raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
@@ -575,7 +628,9 @@ try {
if (!$found) {
$roster[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login];
}
- Roster::save($roster);
+ if (!Roster::save($roster)) {
+ ny_fail('映射保存失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
+ }
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta(), 'mapped' => $name]);
// ---------------- 试卷独立配置(满分 / 合格率 / 免考名单)——仅对当前试卷生效 ----------------
@@ -603,7 +658,7 @@ try {
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
- $raw = (string)file_get_contents('php://input');
+ $raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
@@ -741,11 +796,22 @@ try {
// ---------------- 数据行 ----------------
$sortMode = (string)($_GET['sort'] ?? '');
if ($isLatest) {
- // 每人一行:成绩取「最近一次提交」的分数(不受 dedupe 影响),并附最高分与考试次数
+ // 每人一行:成绩取「最近一次真实提交」的分数(不受 dedupe 影响),并附最高分与考试次数。
+ // 注意:include_temp_save 开启时 attempts_list 可能含暂存草稿,取最新成绩应跳过 temp_save,
+ // 否则导出的"最新成绩"可能是一份草稿分。
$all = [];
foreach ($rows as $r) {
$list = array_values((array)($r['attempts_list'] ?? []));
- $last = $list !== [] ? $list[count($list) - 1] : null;
+ $last = null;
+ for ($i = count($list) - 1; $i >= 0; $i--) {
+ if (empty($list[$i]['temp_save'])) {
+ $last = $list[$i];
+ break;
+ }
+ }
+ if ($last === null && $list !== []) {
+ $last = $list[count($list) - 1]; // 全是暂存时退回最后一份
+ }
$all[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
@@ -846,7 +912,7 @@ try {
// ---------------- 随机抽取:导出白名单(xlsx,列:姓名 / 登陆账号) ----------------
case 'draw_export':
- $raw = (string)file_get_contents('php://input');
+ $raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST + $_GET;
diff --git a/assets/app.js b/assets/app.js
index c829a82..7dc1719 100644
--- a/assets/app.js
+++ b/assets/app.js
@@ -252,19 +252,56 @@
].join('|');
}
+ /**
+ * 整份数据指纹:仅覆盖"会影响 DOM 渲染"的字段。
+ * 用于 applyData 的无变化守卫——自动刷新拿到与屏幕完全一致的数据时,跳过昂贵整表重绘。
+ * 含 project id/name 防止"不同项目数据恰好相同"导致误跳过。
+ */
+ function dataSig(d) {
+ const rows = d.rows || [];
+ const roster = d.roster || [];
+ const unmatched = d.unmatched || [];
+ const questions = d.questions || [];
+ const rs = rows.map(rowSig).join('|');
+ const ros = roster.map(r => (r.sid || '') + ':' + (r.cls || '') + ':' + (r.grp || '')).join('|');
+ const um = unmatched.map(u => (u.name || '') + ':' + (u.score == null ? '-' : u.score)).join('|');
+ const q = questions.length + ':' + questions.map(q => q.id).join(',');
+ const proj = (d.project && (d.project.id || d.project.name)) || '';
+ return [
+ proj, d.updated_at || '', rows.length, rs, ros, um, q,
+ (d.absent || []).length, d.whitelist_driven ? 1 : 0, d.whitelist_total || 0,
+ ].join('§');
+ }
+
function applyData(d, opts) {
opts = opts || {};
+ const fromCache = !!opts.fromCache;
state.questions = d.questions || [];
state.rows = d.rows || [];
state.absent = d.absent || [];
state.unmatched = d.unmatched || [];
state.roster = d.roster || [];
+ state.whitelistDriven = !!d.whitelist_driven;
+ state.whitelistTotal = (d.whitelist_total || 0);
state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100;
state.passRate = (d.config && d.config.pass_rate != null) ? (parseFloat(d.config.pass_rate) || 60) : 60;
+ // 性能守卫:数据指纹未变化则跳过昂贵整表重绘(自动刷新拿到与屏幕一致的数据时不再每轮重建 DOM)。
+ // 缓存预渲染路径照常保留——刷新间隙保持视觉稳定;仅当屏幕上已是同一份数据时才跳过。
+ const sig = dataSig(d);
+ if (sig === state._renderedSig) {
+ if (!fromCache) { // 真实刷新且数据未变:仅刷新轻量元信息,不重建 DOM
+ state.hasPrev = true; // 维持指纹基准,供下次 diff
+ $('#lastUpdate').textContent = d.updated_at || '—';
+ state.projectName = (d.project && d.project.name) || '';
+ setConn('conn-on', '实时 · ' + (d.project && d.project.name || ''));
+ showAlert('');
+ }
+ return;
+ }
+
// 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据"
// (纳入 attempts 与 last_submit_ts 才能发现"重考但分数更低"这种提交)
- const fromCache = !!opts.fromCache;
const cur = new Set(state.rows.map(r => r.sid).filter(Boolean));
// 缓存渲染不参与变更检测:本地缓存可能是旧数据,与上一轮真实数据比对会产生假高亮
if (state.hasPrev && !fromCache) {
@@ -291,7 +328,10 @@
state.prevMap = map;
state.prevSids = cur;
}
+ // 仅在有新数据时做 FLIP 行动画(新行滑入、其它行下移);搜索/排序重排不触发
+ const flipPrev = (state.newSids.size > 0) ? captureFlip($('#tbody')) : null;
renderTable();
+ if (flipPrev) playFlip($('#tbody'), flipPrev);
populateClasses();
populateGroups();
renderAbsent();
@@ -300,8 +340,9 @@
$('#lastUpdate').textContent = d.updated_at || '—';
state.projectName = (d.project && d.project.name) || '';
- setConn('conn-on', (opts.fromCache ? '缓存 · ' : '') + '实时 · ' + (d.project && d.project.name || ''));
- if (!opts.fromCache) showAlert('');
+ setConn('conn-on', (fromCache ? '缓存 · ' : '') + '实时 · ' + (d.project && d.project.name || ''));
+ if (!fromCache) showAlert('');
+ state._renderedSig = sig; // 记录当前屏幕指纹,下次相同则跳过整表重绘
}
async function refresh(silent, force) {
@@ -652,6 +693,42 @@
btn.hidden = (n === 0);
}
+ // FLIP 行动画:新数据行平滑滑动到新位置,其它行顺势下移(尊重 prefers-reduced-motion)
+ const REDUCED_MOTION = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
+ function captureFlip(container) {
+ const m = new Map();
+ if (!container) return m;
+ const trs = container.querySelectorAll('tr[data-sid]');
+ for (const tr of trs) {
+ const sid = tr.getAttribute('data-sid');
+ if (sid) m.set(sid, tr.getBoundingClientRect().top);
+ }
+ return m;
+ }
+ function playFlip(container, prev) {
+ if (!container || !prev || REDUCED_MOTION) return;
+ const trs = container.querySelectorAll('tr[data-sid]');
+ for (const tr of trs) {
+ const sid = tr.getAttribute('data-sid');
+ if (!sid) continue;
+ const oldTop = prev.get(sid);
+ if (oldTop === undefined) {
+ tr.classList.add('row-drop-in'); // 全新行:轻微落入
+ continue;
+ }
+ const newTop = tr.getBoundingClientRect().top;
+ const dy = oldTop - newTop;
+ if (Math.abs(dy) < 1) continue;
+ tr.style.transition = 'none';
+ tr.style.transform = 'translateY(' + dy + 'px)';
+ void tr.offsetWidth; // 强制回流,确保起始位生效
+ requestAnimationFrame(() => {
+ tr.style.transition = 'transform .42s cubic-bezier(.22,.61,.36,1)';
+ tr.style.transform = '';
+ });
+ }
+ }
+
function renderTable() {
const rows = buildRows();
const k = state.sortKey, dir = state.sortAsc ? 1 : -1;
@@ -662,8 +739,9 @@
if (!rows.length) {
tb.innerHTML = '
| ' +
(state.rows.length ? '没有匹配的记录' : '暂无提交记录') + ' |
';
+ _lastRange = ''; // 空表也重置窗口签名,下次有数据必重绘
} else {
- renderVirtualRows(tb, rows);
+ renderVirtualRows(tb, rows, true); // 排序/搜索/数据变更:强制重绘
}
$$('#dataTable th.sortable').forEach(th => {
@@ -690,7 +768,7 @@
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 clsName = [hl ? 'new' : '', (_dropAnim && isNew) ? 'row-drop-in' : '', 'clickable'].filter(Boolean).join(' ');
const tags = [];
if (isNew) tags.push('新');
@@ -720,43 +798,60 @@
const ROW_H_FALLBACK = 41; // 首帧未测量前的行高估值
const VS_OVERSCAN = 12; // 视口上下各多渲染的行数,减少快速滚动时的白屏
let measuredRowH = 0; // 实测行高(渲染后回写),CSS/字体/缩放变化都能自动适应
- function renderVirtualRows(tb, rows) {
- const scroller = tb.closest('.table-scroll');
- if (!scroller) {
- // 兜底:找不到滚动容器时退回全量渲染
- tb.innerHTML = rows.map(rowHtml).join('');
- return;
- }
- const scrollTop = scroller.scrollTop;
- const viewH = scroller.clientHeight;
- const total = rows.length;
- const rh = measuredRowH || ROW_H_FALLBACK;
- let start = Math.floor(scrollTop / rh) - VS_OVERSCAN;
- let end = Math.ceil((scrollTop + viewH) / rh) + VS_OVERSCAN;
- if (start < 0) start = 0;
- if (end > total) end = total;
- if (start >= end) start = Math.max(0, end - 1);
+ let _lastRange = ''; // 上次可视窗口签名(start:end:total:rh),未变化则跳过重绘,消除滚动闪烁
+ let _dropAnim = true; // 仅"数据/排序/搜索变更"的强制重绘才让新行播放落入动画;滚动复用 DOM 时不重播,避免滑动时一行行闪
+ function renderVirtualRows(tb, rows, force) {
+ const prevDrop = _dropAnim;
+ _dropAnim = !!force; // 滚动复用(force=false)时关闭落入动画,节点重建不再触发 replay
+ try {
+ const scroller = tb.closest('.table-scroll');
+ if (!scroller) {
+ // 兜底:找不到滚动容器时退回全量渲染
+ tb.innerHTML = rows.map(rowHtml).join('');
+ return;
+ }
+ const scrollTop = scroller.scrollTop;
+ const viewH = scroller.clientHeight;
+ const total = rows.length;
+ const rh = measuredRowH || ROW_H_FALLBACK;
+ let start = Math.floor(scrollTop / rh) - VS_OVERSCAN;
+ let end = Math.ceil((scrollTop + viewH) / rh) + VS_OVERSCAN;
+ if (start < 0) start = 0;
+ if (end > total) end = total;
+ if (start >= end) start = Math.max(0, end - 1);
- const frag = [];
- if (start > 0) {
- frag.push(' |
');
- }
- for (let i = start; i < end; i++) {
- frag.push(rowHtml(rows[i]));
- }
- if (end < total) {
- frag.push(' |
');
- }
- tb.innerHTML = frag.join('');
+ // 可视窗口未变(含小幅滚动仍在 overscan 内)→ 直接复用现有 DOM:
+ // 避免每帧重写 innerHTML 触发整块重绘,这是"滑一下闪一下"的根源。
+ const range = start + ':' + end + ':' + total + ':' + rh;
+ if (!force && range === _lastRange && tb.firstChild) return;
+ _lastRange = range;
- // 渲染后回写真实行高:换字体、改样式、页面缩放时窗口定位都不会漂移
- const tr0 = tb.querySelector('tr[data-sid]');
- if (tr0) {
- const h = tr0.offsetHeight;
- if (h > 12 && h < 300) measuredRowH = h;
+ const frag = [];
+ if (start > 0) {
+ frag.push(' |
');
+ }
+ for (let i = start; i < end; i++) {
+ frag.push(rowHtml(rows[i]));
+ }
+ if (end < total) {
+ frag.push(' |
');
+ }
+ tb.innerHTML = frag.join('');
+
+ // 渲染后回写真实行高:换字体、改样式、页面缩放时窗口定位都不会漂移。
+ // 仅在尚未测量时回写一次,避免滚动中反复改动行高导致 spacer 高度抖动(也会造成观感闪烁)。
+ if (measuredRowH === 0) {
+ const tr0 = tb.querySelector('tr[data-sid]');
+ if (tr0) {
+ const h = tr0.offsetHeight;
+ if (h > 12 && h < 300) measuredRowH = h;
+ }
+ }
+ } finally {
+ _dropAnim = prevDrop;
}
}
- window.addEventListener('resize', () => { measuredRowH = 0; });
+ window.addEventListener('resize', () => { measuredRowH = 0; _lastRange = ''; });
/** 供滚动监听调用:滚动时重算可视窗口(不重新排序,直接复用 state._sorted) */
function onTableScroll() {
@@ -787,13 +882,14 @@
// 按班统计缺考人数(未按班级筛选时显示概览)
const hint = $('#absentHint');
if (hint) {
+ const wlNote = state.whitelistDriven ? ('白名单应考 ' + (state.whitelistTotal || list.length) + ' 人 · ') : '';
if (!clsFilter && list.length) {
const byCls = {};
list.forEach(a => { const c = (a.cls || '未分班'); byCls[c] = (byCls[c] || 0) + 1; });
const parts = Object.keys(byCls).sort().map(c => c + ' ' + byCls[c] + ' 人');
- hint.textContent = '共 ' + list.length + ' 人 · ' + parts.join(' ');
+ hint.textContent = wlNote + '共 ' + list.length + ' 人 · ' + parts.join(' ');
} else {
- hint.textContent = list.length ? '共 ' + list.length + ' 人' : '';
+ hint.textContent = wlNote + (list.length ? '共 ' + list.length + ' 人' : '');
}
}
}
@@ -815,7 +911,7 @@
'' + esc(u.name) + ' | ' +
'' + (u.score == null ? '-' : u.score) + ' | ' +
'' + esc(u.submit_at) + ' | ' +
- ' | ').join('');
+ ' | ').join('');
}
/* 历史成绩弹窗(主统计页成绩明细):任意成员都可点开,含只考过 1 次的 */
@@ -854,15 +950,15 @@
$('#attTitle').textContent = (r.name || '') + '(' + r.sid + ') · 共 ' + attempts + ' 次考试' +
(bestScore == null ? '' : ' · 最高 ' + bestScore + ' 分');
$('#attBody').innerHTML =
- '| 次序 | 提交时间 | 成绩 | 考试时长 |
' +
+ '' +
+ '| 次序 | ' +
+ '提交时间 | ' +
+ '成绩 | ' +
+ '考试时长 | ' +
+ '
' +
rows + '
';
$('#attemptsModal').classList.add('open');
}
- function closeAttemptsModal() {
- const m = $('#attemptsModal');
- if (m) m.classList.remove('open');
- }
-
/* ---------------- 自动刷新 ---------------- */
// 失败退避:连续失败时延长下次刷新间隔(1x → 2x → 4x,封顶 8x),成功后复位。
// 用 setTimeout 链而非 setInterval,便于动态调整间隔;后台标签页暂停刷新,回到前台立即补一次。
@@ -994,7 +1090,7 @@
String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw));
const tb = $('#rosterBody');
if (!rows.length) {
- tb.innerHTML = '| 暂无名单,请上传文件 |
';
+ tb.innerHTML = '| 暂无名单,请上传文件 |
';
$('#btnDelSel').hidden = true;
return;
}
@@ -1006,8 +1102,7 @@
' | ' +
' | ' +
' | ' +
- ' | ' +
- ' | ';
+ ' | ';
}).join('');
updateDelSelBtn();
}
@@ -1298,7 +1393,7 @@
async function uploadRoster(file) {
const fd = new FormData();
fd.append('file', file);
- const mode = (document.querySelector('input[name=impMode]:checked') || {}).value || 'replace';
+ const mode = (document.querySelector('input[name=impMode]:checked') || {}).value || 'append';
fd.append('mode', mode);
try {
const d = await api('roster_import', { form: fd });
@@ -1311,6 +1406,19 @@
$('#rosterSearch').addEventListener('input', renderRoster);
+ // 下载名单导入模板(CSV,带 BOM 便于 Excel 正确识别 UTF-8)
+ $('#btnTpl').addEventListener('click', () => {
+ const header = ['学号', '姓名', '班级', '组别', '登陆账号'];
+ const sample = ['2024001', '张三', '高一(1)班', 'A', 'zhangsan'];
+ const csv = '' + [header.join(','), sample.join(',')].join('\r\n');
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = '名单导入模板.csv';
+ document.body.appendChild(a); a.click(); a.remove();
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
+ });
+
// 新增学生:弹出表单,填写后追加(不再直接在表格底部插入空行)
$('#btnAddRow').addEventListener('click', () => {
$('#addSid').value = ''; $('#addName').value = '';
@@ -1432,7 +1540,6 @@
} catch (err) { showAlert(err.message); }
});
- /* 成绩快照 / 趋势 */
function curPid() { return $('#projectSel').value; }
/* 成绩明细打印:默认按成绩降序,可选按班级/组别分组对比 */
@@ -1496,8 +1603,46 @@
].concat(extra || []).join(' | ');
}
+ // 展开每位学生的每次提交为独立一行(用于「打印全部记录」),与 buildRows 内的 expandAttempts 区分命名避免覆盖
+ function expandAttemptsForPrint(rows, desc) {
+ const out = [];
+ rows.forEach(r => {
+ const list = (r.attempts_list || []);
+ if (!list.length) {
+ const one = Object.assign({}, r);
+ one.idx = 1; one.is_best = true;
+ out.push(one);
+ return;
+ }
+ list.forEach(a => {
+ out.push({
+ sid: r.sid, name: r.name, cls: r.cls, grp: r.grp,
+ score: a.score, submit_ts: a.submit_ts, submit_at: a.submit_at,
+ duration: a.duration, duration_txt: a.duration_txt,
+ answer_id: a.answer_id, temp_save: a.temp_save,
+ attempts: r.attempts, best_score: r.best_score,
+ idx: a.idx, is_best: a.is_best,
+ last_submit_ts: r.last_submit_ts, last_submit_at: r.last_submit_at,
+ });
+ });
+ });
+ out.sort((x, y) => {
+ let d = (y.submit_ts || 0) - (x.submit_ts || 0);
+ if (!desc) d = -d;
+ if (d !== 0) return d;
+ const a = String(x.sid || ''), b = String(y.sid || '');
+ const na = /^\d+$/.test(a), nb = /^\d+$/.test(b);
+ if (na && nb) return Number(a) - Number(b);
+ return a.localeCompare(b, 'zh');
+ });
+ return out;
+ }
+
function printTable() {
- const rows = (state._sorted || []).slice();
+ const scope = $('#ptScope').value;
+ let rows = (scope === 'all')
+ ? expandAttemptsForPrint(state._sorted || [], true)
+ : (state._sorted || []).slice();
if (!rows.length) return showAlert('当前没有可打印的成绩', 'warn');
const sortMode = $('#ptSort').value;
@@ -1587,7 +1732,7 @@
let body = '' + esc(title) + '
' +
(proj ? '' + esc(proj) + '
' : '') +
'' +
'查看成员(' + e.count + ')
' +
'' +
' ' +
'';
diff --git a/assets/style.css b/assets/style.css
index 433a647..3df4967 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -6,14 +6,22 @@
--text: #1d2433;
--text-2: #5b6577;
--text-3: #8b95a7;
+ /* 主色阶:--primary 基准、--primary-2 悬停/激活深一档、--primary-3 浅色渐变端 */
--primary: #2f6bff;
+ --primary-2: #2559db;
+ --primary-3: #5b8bff;
--primary-weak: #eaf0ff;
+ --brand-2: #6a4bff; /* 品牌渐变收尾(紫)—— logo/面板条/登录条 */
--ok: #12a86a;
+ --ok-2: #3ec99a; /* 成功渐变亮端(toast) */
--ok-weak: #e7f7ef;
--warn: #e08c1a;
+ --warn-2: #f0a54a; /* 警告渐变亮端(toast) */
--warn-weak: #fdf3e2;
--danger: #e0483d;
+ --danger-2: #ef6c64; /* 危险渐变亮端(toast) */
--danger-weak: #fdecea;
+ --ink: #1b2440; /* 登录页深底 */
--shadow: 0 1px 2px rgba(20, 30, 60, .05), 0 8px 24px rgba(20, 30, 60, .06);
--shadow-lg: 0 12px 32px rgba(20, 30, 60, .12);
--radius: 12px;
@@ -120,7 +128,7 @@ body {
color: #fff;
}
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
-.btn-primary:hover { background: #2559db; border-color: #2559db; }
+.btn-primary:hover { background: var(--primary-2); border-color: var(--primary-2); }
.btn.sm { padding: 5px 10px; font-size: 12px; }
.btn.xs { padding: 3px 8px; font-size: 11px; }
.btn.danger { color: var(--danger); border-color: #f3cfcb; }
@@ -260,8 +268,6 @@ select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px
.dot.stale { background: var(--danger); animation: blink 1s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
-.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
-@media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } }
/* ---------- 三 panel 布局容器(默认 / 成绩明细与缺考名单同行) ---------- */
.layout {
@@ -296,25 +302,31 @@ select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px
}
/* ---------- 表格 ---------- */
-.table-scroll { overflow: auto; max-height: 62vh; }
+.table-scroll { overflow: auto; max-height: 62vh; scrollbar-gutter: stable; }
.table-scroll.short { max-height: 320px; }
.table-scroll.mid { max-height: 380px; }
-table.grid { width: 100%; border-collapse: collapse; font-size: 13px; }
+table.grid { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; table-layout: fixed; }
table.grid th {
position: sticky; top: 0; z-index: 2;
background: linear-gradient(180deg, #fbfcfe, #f4f7fc); color: var(--text-2);
text-align: left; font-weight: 600; font-size: 12px;
- padding: 11px 14px; border-bottom: 1px solid var(--border);
- white-space: nowrap;
+ padding: 11px 14px;
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+ /* 用 box-shadow 画分隔线而非 border:border 在 border-collapse:separate + sticky 下
+ 不会与下方 td 边框产生折叠重绘缝隙(消除吸顶表头在滚动边界的 1px 双线/错位观感) */
+ background-clip: padding-box;
box-shadow: inset 0 -1px 0 var(--border);
}
table.grid th.sortable { cursor: pointer; user-select: none; }
table.grid th.sortable:hover { color: var(--primary); }
table.grid th .arrow { color: var(--primary); margin-left: 3px; }
-table.grid td { padding: 10px 14px; border-bottom: 1px solid var(--border-2); }
+table.grid td { padding: 10px 14px; border-bottom: 1px solid var(--border-2); vertical-align: middle; background-clip: padding-box; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+/* 含按钮的操作列:不能被 ellipsis 的 overflow:hidden 裁掉按钮 */
+table.grid td.act { overflow: visible; text-overflow: clip; }
/* 虚拟滚动占位行:不占视觉空间、不响应 hover/条纹、不显示边框 */
table.grid tbody tr.vspacer td { padding: 0; border: 0; background: transparent !important; }
+table.grid tbody tr.vspacer:nth-child(even) td { background: transparent !important; }
table.grid tbody tr.vspacer:hover td { background: transparent !important; }
table.grid tbody tr:nth-child(even) td { background: #fbfcff; }
table.grid tbody tr:hover td { background: var(--primary-weak); }
@@ -324,6 +336,7 @@ td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
td.sid { font-weight: 600; color: var(--text-2); font-variant-numeric: tabular-nums; }
/* 数值列右对齐:成绩 / 次数 / 时长纵向扫读时更容易对齐比对 */
table.grid td.num, table.grid td.score { text-align: right; }
+/* 表头对齐 = data-k 派生,与单元格的正则保持一致;避免"表头左对齐、数据右对齐"造成的视觉错列 */
table.grid thead th[data-k="score"],
table.grid thead th[data-k="attempts"],
table.grid thead th[data-k="submit_ts"],
@@ -335,17 +348,22 @@ td.score { font-weight: 700; font-variant-numeric: tabular-nums; }
.score-high { color: var(--ok); }
.score-mid { color: var(--warn); }
.score-low { color: var(--danger); }
-.diff { animation: flash 1.6s ease-out; }
-@keyframes flash { from { background: #fff6d9; } to { background: transparent; } }
+
+/* 新数据行动画:FLIP 滑动(行移动) + 全新行落入;尊重 prefers-reduced-motion */
+@keyframes rowDrop { from { opacity: 0; transform: translateY(-12px); } to { opacity: 1; transform: translateY(0); } }
+table.grid tbody tr.row-drop-in { animation: rowDrop .42s ease; }
+@media (prefers-reduced-motion: reduce) {
+ table.grid tbody tr.row-drop-in { animation: none; }
+}
/* ---------- Toast 顶部飘窗通知 ---------- */
.toast {
- background: linear-gradient(135deg, #2f6bff, #5b8bff);
+ background: linear-gradient(135deg, var(--primary), var(--primary-3));
color: #fff;
}
-.toast-ok { background: linear-gradient(135deg, #12a86a, #3ec99a); color: #fff; }
-.toast-error{ background: linear-gradient(135deg, #e0483d, #ef6c64); color: #fff; }
-.toast-warn { background: linear-gradient(135deg, #e08c1a, #f0a54a); color: #fff; }
+.toast-ok { background: linear-gradient(135deg, var(--ok), var(--ok-2)); color: #fff; }
+.toast-error{ background: linear-gradient(135deg, var(--danger), var(--danger-2)); color: #fff; }
+.toast-warn { background: linear-gradient(135deg, var(--warn), var(--warn-2)); color: #fff; }
@keyframes toastIn {
from { opacity: 0; transform: translateY(-12px) scale(.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
@@ -405,6 +423,9 @@ table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
.x { border: 0; background: none; font-size: 22px; line-height: 1; cursor: pointer; color: var(--text-3); }
.x:hover { color: var(--text); }
.modal-body { padding: 18px; overflow: auto; }
+/* 弹窗内的表格不做吸顶:弹窗自身是滚动容器,吸顶表头会与内容错层(且贴边观感差) */
+.modal-body table.grid th { position: static; }
+.modal-body table.grid { box-shadow: inset 0 0 0 1px var(--border-2); border-radius: 8px; }
.modal-foot {
display: flex; align-items: center; gap: 8px;
padding: 12px 18px; border-top: 1px solid var(--border); background: #fbfcfe;
@@ -419,11 +440,6 @@ table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
.fi label .req { color: var(--danger, #e5484d); margin-left: 2px; }
.fi-inline { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; }
.fi-inline .chk { font-weight: 500; font-size: 13px; color: var(--text); }
-.fullscores { display: flex; flex-direction: column; gap: 6px; }
-.fullscores .row { display: flex; align-items: center; gap: 8px; }
-.fullscores label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.fullscores input { width: 90px; flex: none; }
-.fullscores .empty { padding: 6px 0; color: var(--text-3); font-size: 12px; text-align: left; }
.hint { margin: 0; font-size: 11px; color: var(--text-3); }
.fi.checks { flex-direction: row; flex-wrap: wrap; gap: 18px; }
.fi.checks label { display: flex; align-items: center; gap: 6px; font-weight: 500; font-size: 13px; color: var(--text); }
@@ -487,9 +503,6 @@ table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
.login-box .fi { margin-bottom: 14px; }
.login-actions { display: flex; gap: 10px; margin-top: 6px; }
.login-actions .btn { flex: 1; }
-.login-msg { margin-top: 12px; font-size: 12px; min-height: 18px; white-space: pre-wrap; }
-.login-msg.ok { color: var(--ok); }
-.login-msg.err { color: var(--danger); }
/* ---------- 全屏适配 ---------- */
body.fs-on .wrap { max-width: none; padding-top: 8px; }
@@ -509,7 +522,6 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
/* 平板横屏(769~1024):卡片改 4 列、工具栏字段占满整行 */
@media (max-width: 1024px) {
- .grid-2 { grid-template-columns: 1fr; }
.field.grow, .field.grow-2 { flex: 1 1 100%; min-width: 0; }
.cards { grid-template-columns: repeat(4, 1fr); }
.toolbar { gap: 14px; }
@@ -529,8 +541,6 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
.field { flex: 1 1 100%; min-width: 0; }
.field.btns { flex: 1 1 100%; flex-wrap: wrap; row-gap: 8px; }
.field.btns select { width: 100%; }
- /* 导出区的复选标签在小屏独占一行,避免和按钮挤成一团 */
- .field.btns > label[style*="cursor:pointer"] { flex: 1 1 auto; }
.cards { grid-template-columns: repeat(3, 1fr); gap: 8px; }
.card { padding: 10px 12px; }
.card b { font-size: 20px; }
@@ -612,15 +622,6 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
.sec-title { margin: 22px 0 4px; font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.sec-title .tag-gray { background: var(--border-2); color: var(--text-3); padding: 1px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; }
-.class-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; margin-top: 14px; }
-.class-card {
- background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
- padding: 16px 18px; box-shadow: var(--shadow); transition: transform .18s, box-shadow .18s;
-}
-.class-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-lg); }
-.class-card h3 { margin: 0 0 2px; font-size: 16px; display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
-.class-card .rate-num { font-size: 15px; font-weight: 800; white-space: nowrap; }
-.class-card .sub { font-size: 12px; color: var(--text-3); margin-bottom: 12px; }
.bar { height: 9px; border-radius: 999px; background: var(--border-2); overflow: hidden; }
.bar > i { display: block; height: 100%; border-radius: 999px; transition: width .5s ease; }
.class-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; margin-top: 14px; }
@@ -628,23 +629,10 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
.class-metrics .m span { color: var(--text-3); font-size: 11px; }
.class-metrics .m b { font-size: 17px; font-weight: 700; }
.metric-ok b { color: var(--ok); }
-.metric-warn b { color: var(--warn); }
.metric-danger b { color: var(--danger); }
.empty { text-align: center; color: var(--text-3); padding: 40px 0; }
-/* 班级卡片:可点击进入该班详情 */
-.class-card.clickable { cursor: pointer; }
-.class-card.clickable:hover { border-color: var(--primary); }
-.grp-tag {
- display: inline-block; margin-top: 12px; padding: 2px 10px; border-radius: 999px;
- font-size: 11px; font-weight: 600; color: var(--primary); background: var(--primary-weak);
-}
-
-/* 班级详情头部 */
-.detail-head { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-top: 18px; }
-.detail-head h2 { margin: 0; font-size: 18px; font-weight: 700; }
-
/* 分组对比:A 在左、B 在右 */
.group-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 14px; }
@media (max-width: 860px) { .group-compare { grid-template-columns: 1fr; } }
@@ -679,8 +667,8 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
.rk-3 { background: #fbe9dd; color: #b45309; }
/* 防溢出:网格子项允许收缩,长内容不撑破页面 */
-.class-grid, .group-compare, .cards, .gpanel, .class-card { min-width: 0; }
-.gpanel, .class-card { overflow: hidden; }
+.group-compare, .cards, .gpanel { min-width: 0; }
+.gpanel { overflow: hidden; }
.member-list td.name { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 看板全屏:整页不溢出屏幕(外层锁 100vh + overflow:hidden,内部区域自行滚动) */
@@ -693,13 +681,9 @@ body.dash.fs-on .sec-title { margin: 12px 0 4px; font-size: 14px; }
body.dash.fs-on .cards { gap: 10px; margin-top: 10px; }
body.dash.fs-on .card { padding: 10px 12px; }
body.dash.fs-on .card b { font-size: 20px; }
-body.dash.fs-on #overview, body.dash.fs-on #detail {
- flex: 1 1 auto; min-height: 0; overflow: auto;
-}
-body.dash.fs-on .class-grid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
body.dash.fs-on .group-compare { gap: 12px; }
body.dash.fs-on .gpanel { padding: 12px 14px; }
-body.dash.fs-on .member-list { max-height: 30vh; }
+body.dash.fs-on .member-list { max-height: none; }
/* ================= 随机抽取页 ================= */
diff --git a/bootstrap.php b/bootstrap.php
index 099ca8e..33d0241 100644
--- a/bootstrap.php
+++ b/bootstrap.php
@@ -126,6 +126,19 @@ function ny_csrf_token(): string
return $_SESSION['ny_csrf'];
}
+/**
+ * 取请求原始 body(仅读一次并缓存)。
+ * 写接口既要校验 CSRF(从 body 取 _csrf)又要解析业务 JSON,二者都需读 php://input;
+ * 大多数 SAPI 下 php://input 可重复读,但个别环境只能读一次,因此这里统一缓存避免第二次读为空。
+ */
+function ny_request_body(): string
+{
+ if (!isset($GLOBALS['__ny_body'])) {
+ $GLOBALS['__ny_body'] = (string)@file_get_contents('php://input');
+ }
+ return $GLOBALS['__ny_body'];
+}
+
/** 校验 CSRF token(写操作调用)。失败返回 false 并应拒绝请求。 */
function ny_csrf_ok(): bool
{
@@ -136,7 +149,7 @@ function ny_csrf_ok(): bool
$given = (string)$_POST['_csrf'];
} else {
// JSON body 里的 _csrf(api.php 写接口多走 JSON)
- $raw = (string)file_get_contents('php://input');
+ $raw = ny_request_body();
if ($raw !== '') {
$j = json_decode($raw, true);
if (is_array($j) && isset($j['_csrf'])) {
@@ -286,6 +299,12 @@ function ny_save_config(array $cfg): bool
}
}
$fileOk = ny_write_json(ny_config_file(), $cfg);
+ // 尽量收紧配置文件权限(仅属主可读写),降低明文密码被其它本地用户读取的风险。
+ // Windows 下 chmod 基本无效(真正的防护是 data/ 目录禁止 Web 访问,见 .htaccess / 部署文档);
+ // 明文密码仍需保留以便卷王 token 失效时平台自动重新登录,无法改为只存哈希。
+ if ($fileOk && is_file(ny_config_file())) {
+ @chmod(ny_config_file(), 0600);
+ }
if (!$ok) {
$GLOBALS['__ny_cfg_source'] = 'file';
}
diff --git a/dashboard.php b/dashboard.php
index 3a2cfc1..6608eb3 100644
--- a/dashboard.php
+++ b/dashboard.php
@@ -94,6 +94,36 @@ function rateColor(r){
return 'var(--danger)';
}
+/* FLIP 行移动画:新数据行滑到新排名、其它行顺势下移(尊重 prefers-reduced-motion) */
+const REDUCED_MOTION = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
+function captureFlip(container){
+ const m = new Map();
+ if (!container) return m;
+ container.querySelectorAll('tr[data-sid]').forEach(tr => {
+ const sid = tr.getAttribute('data-sid');
+ if (sid) m.set(sid, tr.getBoundingClientRect().top);
+ });
+ return m;
+}
+function playFlip(container, prev){
+ if (!container || !prev || REDUCED_MOTION) return;
+ container.querySelectorAll('tr[data-sid]').forEach(tr => {
+ const sid = tr.getAttribute('data-sid');
+ if (!sid) return;
+ const oldTop = prev.get(sid);
+ if (oldTop === undefined) { tr.classList.add('row-drop-in'); return; }
+ const dy = oldTop - tr.getBoundingClientRect().top;
+ if (Math.abs(dy) < 1) return;
+ tr.style.transition = 'none';
+ tr.style.transform = 'translateY(' + dy + 'px)';
+ void tr.offsetWidth;
+ requestAnimationFrame(() => {
+ tr.style.transition = 'transform .42s cubic-bezier(.22,.61,.36,1)';
+ tr.style.transform = '';
+ });
+ });
+}
+
/* URL 状态:?pid=&class=&auto=1|0 */
function readUrl(){
const u = new URL(location.href);
@@ -237,7 +267,7 @@ function membersTable(members){
return '暂无考试成员
';
}
return '' +
- '| 排名 | 姓名 | 成绩 |
' +
+ '| 排名 | 姓名 | 成绩 |
' +
members.map(m => {
const rk = (m.rank == null ? '' : m.rank);
const badge = rk === 1 ? 'rk rk-1' : (rk === 2 ? 'rk rk-2' : (rk === 3 ? 'rk rk-3' : 'rk'));
@@ -255,7 +285,7 @@ function membersTable(members){
const tag = att ? ' 刷' + m.attempts + '次' : '';
// 注意:不要把 sid 直接拼进 onclick="...",JSON.stringify 产生的双引号会提前闭合
// HTML 属性(onclick="openAttempts("123")"),点击必然报错。改用 data-sid + 事件委托。
- return '' +
+ return '
' +
'| ' + rk + ' | ' +
'' + esc(m.name) + tag + ' | ' +
'' + (sc == null ? '-' : sc) + ' | ' +
@@ -280,7 +310,11 @@ function openAttempts(sid){
}).join('');
$('#attTitle').textContent = info.name + ' · 共 ' + (info.attempts || list.length) + ' 次提交';
$('#attBody').innerHTML = list.length
- ? ''
+ ? '' +
+ '| 提交时间 | ' +
+ '成绩 | ' +
+ '考试时长 | ' +
+ '
' + rows + '
'
: '无提交明细
';
document.querySelector('#attemptsModal').classList.add('open');
}
@@ -306,13 +340,19 @@ document.addEventListener('keydown', e => {
});
/* ---------- 看板占位(未选问卷/班级) ---------- */
+/* 占位卡片配色:与真实看板卡片(renderBoard)一一对应,保证空态左侧色条一致 */
+const PLACEHOLDER_ACCENT = {
+ '总人数': 'accent-blue', '已提交': 'accent-green', '缺考': 'accent-orange',
+ '提交率': 'accent-purple', '平均分': 'accent-teal', '合格率': 'accent-indigo'
+};
function renderPlaceholderBoard(){
const cards = [
['总人数','-'],['已提交','-'],['缺考','-'],
['提交率','-'],['平均分','-'],['合格率','-']
];
$('#boardSummary').innerHTML = cards.map(c =>
- '' + c[0] + '' + c[1] + '
').join('');
+ // 占位卡片与真实看板卡片顺序、配色一致(accent-* 决定左侧色条),避免空态蓝条与真实卡片不一致
+ '' + c[0] + '' + c[1] + '
').join('');
$('#boardBody').innerHTML = '请选择问卷与班级查看考试整体情况
';
// 注意:heroTitle 已随导航栏精简移除,只保留 heroSub(承载当前问卷/班级提示)
const _hs = document.getElementById('heroSub');
@@ -480,7 +520,10 @@ async function refreshBoard(manual){
sel.disabled = !classes.length;
if (classes.length) { sel.value = classes[0]; CUR.cls = classes[0]; writeUrl({ cls: classes[0] }); }
}
+ // 刷新前捕获各行位置,刷新后做 FLIP:新数据行滑入、其它行下移
+ const flipPrev = captureFlip($('#boardBody'));
renderBoard();
+ playFlip($('#boardBody'), flipPrev);
$('#lastUpdate').textContent = fresh.updated_at || '-';
markLive();
} catch (e) {
diff --git a/draw.php b/draw.php
index c2d7857..f816c01 100644
--- a/draw.php
+++ b/draw.php
@@ -110,19 +110,18 @@ $rosterCount = Roster::count();
- | 序号 |
+ 序号 |
学号 |
姓名 |
- 班级 |
- 组别 |
- 登陆账号 |
- 操作 |
+ 班级 |
+ 组别 |
+ 操作 |
- | 尚未抽取 |
+ | 尚未抽取 |
- 两个下载与「📋 复制名单」均为两列「姓名 / 登陆账号」,登陆账号取名单中导入的「登陆账号」列。表中「登陆账号」列显示为空即表示名单里没有填,导出时会自动回退用学号,请先到主系统「名单管理」补全,否则学生无法登录考试。
+ 抽取结果表不显示登陆账号;「⬇ 白名单导入模板」「⬇ 白名单列表」与「📋 复制名单」均导出两列「姓名 / 登陆账号」,登陆账号取名单中导入的「登陆账号」列。若名单未填登陆账号,导出时会自动回退用学号,请先到主系统「名单管理」补全,否则学生无法登录考试。
@@ -139,7 +138,7 @@ $rosterCount = Roster::count();
diff --git a/index.php b/index.php
index df4fa04..4e75445 100644
--- a/index.php
+++ b/index.php
@@ -125,14 +125,14 @@ $rosterCount = Roster::count();
- | 学号 |
- 姓名 |
- 班级 |
- 组别 |
- 成绩 |
- 考试次数 |
- 提交时间 |
- 考试时长 |
+ 学号 |
+ 姓名 |
+ 班级 |
+ 组别 |
+ 成绩 |
+ 考试次数 |
+ 提交时间 |
+ 考试时长 |
| 请选择问卷 / 考试 |
@@ -150,7 +150,7 @@ $rosterCount = Roster::count();
@@ -160,7 +160,7 @@ $rosterCount = Roster::count();
未匹配答卷 姓名不在名单中
@@ -289,9 +289,10 @@ $rosterCount = Roster::count();
@@ -344,7 +345,7 @@ $rosterCount = Roster::count();
-
+
@@ -420,9 +421,17 @@ $rosterCount = Roster::count();
+
+
+
+
全部记录会把每位学生的每次提交各打一行,完整留存历史;按最新成绩则每人仅显示当前代表成绩。
+
-
+
diff --git a/lib/DataCache.php b/lib/DataCache.php
index 78f3494..3b29fa4 100644
--- a/lib/DataCache.php
+++ b/lib/DataCache.php
@@ -103,15 +103,4 @@ final class DataCache
}
}
- /** 清空所有数据缓存(名单变更 / 手动清缓存时使用) */
- public static function clear(): int
- {
- $n = 0;
- foreach (glob(self::dir() . '/data_*.json') ?: [] as $f) {
- if (@unlink($f)) {
- $n++;
- }
- }
- return $n;
- }
}
diff --git a/lib/DataService.php b/lib/DataService.php
index 916cc65..8b30ab1 100644
--- a/lib/DataService.php
+++ b/lib/DataService.php
@@ -30,9 +30,15 @@ class DataService
return $out;
}
- // children 在根或子节点
+ // 不同卷王版本题目可能挂在 children / questions / list 下,统一兼容
$self = $node;
- $children = (isset($node['children']) && is_array($node['children'])) ? $node['children'] : null;
+ $children = null;
+ foreach (['children', 'questions', 'list', 'items'] as $ck) {
+ if (isset($node[$ck]) && is_array($node[$ck])) {
+ $children = $node[$ck];
+ break;
+ }
+ }
$id = isset($self['id']) ? (string)$self['id'] : '';
$title = isset($self['title']) ? self::cleanTitle((string)$self['title']) : '';
@@ -138,26 +144,15 @@ class DataService
}
}
- $students = Roster::all();
- // 免考:从名单中剔除,使其既不计入「总人数」也不计入「缺考」
+ // 基础名单(全名单):用于答卷匹配,不受白名单影响
+ $rosterAll = Roster::all();
if ($exempt !== []) {
- $students = array_values(array_filter(
- $students,
+ $rosterAll = array_values(array_filter(
+ $rosterAll,
static fn(array $s): bool => !isset($exempt[trim((string)($s['sid'] ?? ''))])
));
}
- // 姓名 -> 学号 / 班级(规范化)
- $studentInfo = [];
- foreach ($students as $s) {
- $k = ny_norm_name((string)$s['name']);
- if ($k !== '') {
- $studentInfo[$k] = [
- 'sid' => (string)$s['sid'],
- 'cls' => (string)($s['cls'] ?? ''),
- 'grp' => (string)($s['grp'] ?? ''),
- ];
- }
- }
+ // 注:$students(应考名单)与 $studentInfo 在解析出 $survey(含白名单)后统一构建,见下方
$project = $client->getProject($projectId);
$survey = $project['survey'] ?? null;
@@ -167,6 +162,73 @@ class DataService
$survey = $decoded;
}
}
+
+ // 卷内「答题白名单」:决定「应考名单」(缺/待考名单来源)。
+ // 未设置白名单时「按现在的方案显示」(使用全名单)。
+ $paperWhitelist = [];
+ try {
+ $paperWhitelist = $client->getWhitelist($projectId, $survey);
+ } catch (Throwable $e) {
+ $paperWhitelist = [];
+ }
+ $useWhitelist = ($paperWhitelist !== []);
+ $whitelistTotal = 0;
+
+ if ($useWhitelist) {
+ // 以学号 / 登陆账号 / 姓名(大小写不敏感)把白名单成员匹配回名单,
+ // 命中的取名单里的姓名/班级/组别;未命中的以白名单值作占位,避免漏统计。
+ $rosterMap = [];
+ foreach ($rosterAll as $s) {
+ $sid = strtolower(trim((string)($s['sid'] ?? '')));
+ $login = strtolower(trim((string)($s['login'] ?? '')));
+ $name = strtolower(trim((string)($s['name'] ?? '')));
+ if ($sid) { $rosterMap['sid:' . $sid] = $s; }
+ if ($login) { $rosterMap['login:' . $login] = $s; }
+ if ($name) { $rosterMap['name:' . $name] = $s; }
+ }
+ $students = [];
+ $seen = [];
+ foreach ($paperWhitelist as $v) {
+ $key = strtolower(trim((string)$v));
+ $m = $rosterMap['sid:' . $key] ?? $rosterMap['login:' . $key] ?? $rosterMap['name:' . $key] ?? null;
+ if ($m !== null) {
+ $sid = trim((string)($m['sid'] ?? ''));
+ if ($sid !== '' && isset($seen[$sid])) {
+ continue;
+ }
+ if ($sid !== '') { $seen[$sid] = true; }
+ $students[] = $m;
+ } else {
+ if (isset($seen['wk:' . $key])) {
+ continue;
+ }
+ $seen['wk:' . $key] = true;
+ $students[] = ['sid' => $v, 'name' => $v, 'cls' => '', 'grp' => '', 'login' => ''];
+ }
+ }
+ $whitelistTotal = count($students);
+ } else {
+ $students = $rosterAll;
+ }
+
+ // 姓名 -> 学号 / 班级(规范化):全名单 + 白名单独有成员都纳入,
+ // 保证所有提交(含白名单外提交)都能匹配到姓名。
+ $studentInfo = [];
+ $addInfo = static function (array $s) use (&$studentInfo): void {
+ $k = ny_norm_name((string)($s['name'] ?? ''));
+ if ($k !== '') {
+ $studentInfo[$k] = [
+ 'sid' => (string)$s['sid'],
+ 'cls' => (string)($s['cls'] ?? ''),
+ 'grp' => (string)($s['grp'] ?? ''),
+ ];
+ }
+ };
+ foreach ($rosterAll as $s) { $addInfo($s); }
+ if ($useWhitelist) {
+ foreach ($students as $s) { $addInfo($s); }
+ }
+
$questions = self::flattenQuestions($survey);
$keywords = (string)($cfg['name_keywords'] ?? '姓名');
@@ -218,7 +280,7 @@ class DataService
continue;
}
$v = ny_flatten_value($answerMap[$qid]);
- if ($v !== '' && isset($nameMap[ny_norm_name($v)])) {
+ if ($v !== '' && isset($studentInfo[ny_norm_name($v)])) {
$name = $v;
break;
}
@@ -227,7 +289,7 @@ class DataService
if ($name === '' && $questions === []) {
foreach ($answerMap as $v) {
$s = ny_flatten_value($v);
- if ($s !== '' && isset($nameMap[ny_norm_name($s)])) {
+ if ($s !== '' && isset($studentInfo[ny_norm_name($s)])) {
$name = $s;
break;
}
@@ -252,8 +314,8 @@ class DataService
$ts = ny_to_timestamp($a['updateAt'] ?? null);
}
- // 考试时长
- $ai = $a['metaInfo']['answerInfo'] ?? [];
+ // 考试时长(metaInfo 可能缺失,先整体判空避免每答案两条 Undefined key 警告)
+ $ai = ($a['metaInfo'] ?? [])['answerInfo'] ?? [];
$start = is_array($ai) ? (int)($ai['startTime'] ?? 0) : 0;
$end = is_array($ai) ? (int)($ai['endTime'] ?? 0) : 0;
$dur = null;
@@ -371,6 +433,8 @@ class DataService
'stats' => $stats,
'roster' => $students,
'roster_count' => count($students),
+ 'whitelist_driven' => $useWhitelist,
+ 'whitelist_total' => $whitelistTotal,
// 本卷独立配置(前端据此回显满分/合格率/免考);full_score 已按本卷解析
'project_cfg' => [
'full_score' => $pCfg['full_score'] ?? null,
diff --git a/lib/Db.php b/lib/Db.php
index d8e4fb9..a4ca7c5 100644
--- a/lib/Db.php
+++ b/lib/Db.php
@@ -56,17 +56,6 @@ class Db
return self::$err;
}
- /** 连接参数(SQLite 固定,保留接口以兼容既有调用) */
- public static function params(): array
- {
- return ['driver' => 'sqlite', 'file' => self::dbFile()];
- }
-
- public static function saveParams(array $p): bool
- {
- return true; // SQLite 无需外部配置
- }
-
/** 建立/复用连接;失败返回 null 而不抛异常(上层要能优雅降级) */
public static function connect(array $p = []): PDO
{
@@ -180,25 +169,6 @@ class Db
// ---------------- 设置 ----------------
- public static function settingsAll(): array
- {
- $pdo = self::pdo();
- if (!$pdo) {
- return [];
- }
- try {
- $p = self::prefix();
- $st = $pdo->query("SELECT k, v FROM {$p}settings");
- $out = [];
- foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
- $out[(string)$r['k']] = (string)$r['v'];
- }
- return $out;
- } catch (Throwable $e) {
- self::$err = $e->getMessage();
- return [];
- }
- }
/** SQLite 用 ON CONFLICT 做 upsert(其它方言见历史版本) */
public static function settingsSet(string $k, string $v): void
@@ -265,12 +235,16 @@ class Db
}
}
- /** 全量替换名单(roster_save 语义) */
- public static function rosterReplace(array $students): void
+ /**
+ * 全量替换名单(roster_save 语义)
+ * @return bool 成功 true;DB 不可用或事务失败 false(失败原因见 lastError)
+ */
+ public static function rosterReplace(array $students): bool
{
$pdo = self::pdo();
if (!$pdo) {
- return;
+ self::$err = '数据库未连接';
+ return false;
}
try {
$p = self::prefix();
@@ -296,11 +270,13 @@ class Db
]);
}
$pdo->commit();
+ return true;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
self::$err = $e->getMessage();
+ return false;
}
}
@@ -321,29 +297,6 @@ class Db
// ---------------- 每卷独立配置(满分 / 合格率 / 免考名单) ----------------
- /** @return array 全部试卷配置 [project_id => cfg] */
- public static function projectConfigAll(): array
- {
- $pdo = self::pdo();
- if (!$pdo) {
- return [];
- }
- try {
- $p = self::prefix();
- $st = $pdo->query("SELECT project_id, config_json FROM {$p}project_config");
- $out = [];
- foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
- $j = json_decode((string)($r['config_json'] ?? '{}'), true);
- if (is_array($j)) {
- $out[(string)$r['project_id']] = $j;
- }
- }
- return $out;
- } catch (Throwable $e) {
- self::$err = $e->getMessage();
- return [];
- }
- }
/** @return array 单卷配置(缺省返回空结构) */
public static function projectConfigGet(string $projectId): array
@@ -394,18 +347,4 @@ class Db
}
}
- public static function projectConfigDelete(string $projectId): void
- {
- $pdo = self::pdo();
- if (!$pdo) {
- return;
- }
- try {
- $p = self::prefix();
- $st = $pdo->prepare("DELETE FROM {$p}project_config WHERE project_id = ?");
- $st->execute([$projectId]);
- } catch (Throwable $e) {
- self::$err = $e->getMessage();
- }
- }
}
diff --git a/lib/Roster.php b/lib/Roster.php
index dcf9677..da59bac 100644
--- a/lib/Roster.php
+++ b/lib/Roster.php
@@ -110,9 +110,23 @@ class Roster
'login' => trim((string)($s['login'] ?? '')),
];
}
+ // 按学号去重(同 sid 取最后一条):roster 表 sid 为主键,重复 sid 会触发约束冲突导致整批回滚。
+ // 去重避免「保存成功但名单实际没变」的静默丢失。
+ $bySid = [];
+ $noSid = [];
+ foreach ($clean as $s) {
+ if ((string)($s['sid'] ?? '') !== '') {
+ $bySid[$s['sid']] = $s;
+ } else {
+ $noSid[] = $s;
+ }
+ }
+ $clean = array_values($bySid);
+ foreach ($noSid as $s) {
+ $clean[] = $s;
+ }
if (self::useDb()) {
- Db::rosterReplace($clean);
- return true;
+ return Db::rosterReplace($clean);
}
return ny_write_json(self::file(), [
'students' => $clean,
@@ -157,31 +171,6 @@ class Roster
return $mt . ':' . $sz . ':' . $hash;
}
- /**
- * 首次使用时,若名单为空且存在 导入模板.xlsx,自动导入
- */
- public static function ensureSeeded(): bool
- {
- if (self::count() > 0) {
- return false;
- }
- $candidates = [
- dirname(NY_ROOT) . '/导入模板.xlsx',
- NY_ROOT . '/导入模板.xlsx',
- NY_DATA . '/导入模板.xlsx',
- ];
- foreach ($candidates as $f) {
- if (is_file($f)) {
- try {
- $res = self::importFile($f, 'xlsx');
- return $res['count'] > 0;
- } catch (Throwable $e) {
- // 忽略
- }
- }
- }
- return false;
- }
/**
* 解析上传文件 -> 学生数组
diff --git a/lib/SurveyKingClient.php b/lib/SurveyKingClient.php
index e96fb13..13c1ed8 100644
--- a/lib/SurveyKingClient.php
+++ b/lib/SurveyKingClient.php
@@ -483,6 +483,100 @@ class SurveyKingClient
return is_array($r['data']) ? $r['data'] : [];
}
+ /**
+ * 取问卷设置的「答题白名单」成员列表(待考名单来源)
+ *
+ * 卷王不同版本字段形状不一,这里尽量兼容:
+ * - survey.surveySetting.whiteLists:{ 题ID: [值,...] }(社区版常见)
+ * - survey.surveySetting.whiteList:{ 题ID: [值,...] }
+ * - 也可能直接是字符串或嵌套数组
+ * 返回去重后的纯值数组(顺序保持首次出现),值本身通常是学号 / 姓名 / 登陆账号。
+ * 未设置白名单返回空数组(上层据此「按现在的方案显示」)。
+ *
+ * @param string $projectId 问卷 ID
+ * @param array|null $survey 可传入已解析的 survey 对象,避免重复请求 API(DataService::build 已取过 project)
+ * @return array
+ */
+ public function getWhitelist(string $projectId, ?array $survey = null): array
+ {
+ if ($survey === null) {
+ $proj = $this->getProject($projectId);
+ if (!is_array($proj)) {
+ return [];
+ }
+ $survey = $proj['survey'] ?? null;
+ if (is_string($survey) && $survey !== '') {
+ $decoded = json_decode($survey, true);
+ if (is_array($decoded)) {
+ $survey = $decoded;
+ }
+ }
+ }
+ // 兼容 { surveySetting: ... } 与 { survey: { surveySetting: ... } } 两种结构,
+ // 也允许白名单直接挂在 survey 顶层(whiteLists / whiteList / whitelist)。
+ $setting = null;
+ if (is_array($survey)) {
+ if (isset($survey['surveySetting']) && is_array($survey['surveySetting'])) {
+ $setting = $survey['surveySetting'];
+ } elseif (isset($survey['survey']) && is_array($survey['survey']) && isset($survey['survey']['surveySetting'])) {
+ $setting = $survey['survey']['surveySetting'];
+ } else {
+ // 顶层直接是白名单结构
+ foreach (['whiteLists', 'whiteList', 'whitelist'] as $wk) {
+ if (isset($survey[$wk]) && (is_array($survey[$wk]) || is_string($survey[$wk]))) {
+ $setting = $survey;
+ break;
+ }
+ }
+ }
+ }
+ if (!is_array($setting)) {
+ return [];
+ }
+ return self::parseWhitelist($setting);
+ }
+
+ /**
+ * 从 surveySetting 中解析白名单成员(去重、保留首次出现顺序)。
+ * @return array
+ */
+ public static function parseWhitelist(array $setting): array
+ {
+ $raw = $setting['whiteLists'] ?? $setting['whiteList'] ?? $setting['whitelist'] ?? null;
+ if ($raw === null || $raw === [] || $raw === '') {
+ return [];
+ }
+
+ $out = [];
+ $walk = static function ($node) use (&$out, &$walk): void {
+ if (is_array($node)) {
+ // 列表或对象都逐值递归(兼容 { 题ID: [值] })
+ foreach ($node as $v) {
+ $walk($v);
+ }
+ return;
+ }
+ if (is_string($node) && trim($node) !== '') {
+ $out[] = trim($node);
+ } elseif (is_scalar($node) && $node !== '' && $node !== null) {
+ $out[] = (string)$node;
+ }
+ };
+ $walk($raw);
+
+ // 去重(大小写不敏感),保留首次出现顺序
+ $seen = [];
+ $res = [];
+ foreach ($out as $item) {
+ $key = strtolower($item);
+ if (!isset($seen[$key])) {
+ $seen[$key] = true;
+ $res[] = $item;
+ }
+ }
+ return $res;
+ }
+
/** 答卷列表(自动翻页取全量) */
public function listAnswers(string $projectId): array
{
diff --git a/lib/XlsxWriter.php b/lib/XlsxWriter.php
index cad0e88..2c1772d 100644
--- a/lib/XlsxWriter.php
+++ b/lib/XlsxWriter.php
@@ -15,6 +15,8 @@ final class XlsxWriter
private array $strIndex = [];
/** @var array 索引 => 共享字符串 */
private array $strings = [];
+ /** @var array 索引 => 出现次数(sst 的 count 用,区别于 uniqueCount) */
+ private array $strCount = [];
/** @var array 工作表(按输出顺序) */
private array $sheets = [];
@@ -156,8 +158,11 @@ final class XlsxWriter
$i = count($this->strings);
$this->strIndex[$s] = $i;
$this->strings[$i] = $s;
+ $this->strCount[$i] = 0;
}
- return $this->strIndex[$s];
+ $i = $this->strIndex[$s];
+ $this->strCount[$i] = ($this->strCount[$i] ?? 0) + 1;
+ return $i;
}
private function sheetXml(array $cells, int $maxRow, int $maxCol, array $widths = []): string
@@ -287,14 +292,15 @@ final class XlsxWriter
private function sharedStringsXml(): string
{
- $count = count($this->strings);
+ $unique = count($this->strings);
+ $total = array_sum($this->strCount);
$body = '';
foreach ($this->strings as $s) {
$body .= '' . self::xml((string)$s) . '';
}
return '' . "\n" .
- '' . $body . '';
+ '' . $body . '';
}
private function coreXml(string $title): string
diff --git a/tests/run-all.sh b/tests/run-all.sh
index 63aa169..9b2f515 100644
--- a/tests/run-all.sh
+++ b/tests/run-all.sh
@@ -30,7 +30,7 @@ echo "================ Node e2e(需 mock-server + PHP 内置服务器) =====
# 彻底避免污染生产 data/ny.db(曾因此把登录页连接信息写成 127.0.0.1)。
NY_DATA_TMP="$(pwd)/.ny-test-data"
mkdir -p "$NY_DATA_TMP"
-for f in tools/test-config-save.js tools/test-upload-guard.js; do
+for f in tools/test-config-save.js tools/test-upload-guard.js tools/test-whitelist.js; do
echo ""
echo ">>> $f (NY_DATA=$NY_DATA_TMP)"
NY_DATA="$NY_DATA_TMP" "$NODE" "$f"
diff --git a/tools/mock-server.js b/tools/mock-server.js
index ffd5306..2cebab3 100644
--- a/tools/mock-server.js
+++ b/tools/mock-server.js
@@ -43,6 +43,10 @@ answers.push({
const SURVEY = {
id: 'root', title: '高一数学月考', type: 'survey',
+ surveySetting: {
+ // 答题白名单:待考名单来源(键为题目 ID,值为允许的答案列表;这里用学号作标识)
+ whiteLists: { q1: ['90001', '90002', '90003', '90004', '90005'] },
+ },
children: [
{ id: 'pg1', type: 'pagination', title: '基本信息', children: [
{ id: 'q1', type: 'text', title: '姓名
' },
diff --git a/tools/test-whitelist.js b/tools/test-whitelist.js
new file mode 100644
index 0000000..57b106b
--- /dev/null
+++ b/tools/test-whitelist.js
@@ -0,0 +1,211 @@
+// e2e: 卷内「答题白名单」自动驱动缺/待考名单(DataService::build)
+// 运行:node tools/test-whitelist.js (NY_DEBUG=1 可打印调试信息)
+//
+// 覆盖:
+// 1) p1 设置了卷内白名单 → 应考名单恰好为白名单成员,缺考仅来自白名单(不含白名单外成员)
+// 2) p2 未设置白名单 → 按现状(全名单)显示,白名单外成员仍在应考/缺考中
+// 3) 已移除的 roster_import_whitelist 接口不再可用(按钮已删除,改为自动驱动)
+// 4) 上传防护不被破坏(导入后 data/uploads 即清)
+const http = require('http');
+const net = require('net');
+const fs = require('fs');
+const path = require('path');
+const { spawn } = require('child_process');
+
+const ROOT = path.resolve(__dirname, '..');
+const PHP_PORT = 18082;
+const MOCK_PORT = 19911;
+const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
+const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
+const DEBUG = !!process.env.NY_DEBUG;
+
+function resolveDataDir() {
+ let p = process.env.NY_DATA || path.join(ROOT, 'data');
+ if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + p.slice(2);
+ p = path.resolve(p);
+ process.env.NY_DATA = p;
+ return p;
+}
+const DATA = resolveDataDir();
+fs.mkdirSync(DATA, { recursive: true });
+const UPLOADS = path.join(DATA, 'uploads');
+
+const moved = [], copied = [];
+for (const f of ['ny.db', 'cache/token.json']) {
+ const p = path.join(DATA, f);
+ if (fs.existsSync(p)) {
+ const bak = p + '.nybak';
+ try { fs.renameSync(p, bak); moved.push([p, bak]); } catch (e) {}
+ }
+}
+for (const f of ['config.json', 'roster.json', 'project_config.json']) {
+ const p = path.join(DATA, f);
+ if (fs.existsSync(p)) {
+ const bak = p + '.nybak';
+ try { fs.copyFileSync(p, bak); copied.push([p, bak]); } catch (e) {}
+ }
+}
+const cfgFile = path.join(DATA, 'config.json');
+fs.writeFileSync(cfgFile, JSON.stringify({
+ base_url: 'http://127.0.0.1:' + MOCK_PORT,
+ api_prefix: '/api',
+ username: 'mock',
+ password: 'surveyking',
+ verify_ssl: false,
+ timeout: 10,
+ refresh_interval: 15,
+ page_size: 50,
+ include_temp_save: false,
+ dedupe: true,
+ name_keywords: '姓名,名字',
+ full_score: 100,
+ pass_rate: 60,
+ layout: 'default',
+}, null, 2));
+
+let mock = null, srv = null, cleanedUp = false;
+function killAll() { for (const p of [srv, mock]) { try { p && p.kill('SIGKILL'); } catch (e) {} } }
+function cleanup() {
+ if (cleanedUp) return;
+ cleanedUp = true;
+ killAll();
+ for (const f of ['ny.db', 'ny.db-wal', 'ny.db-shm', 'config.json', 'cache/token.json']) {
+ const p = path.join(DATA, f);
+ try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch (e) {}
+ }
+ for (const [p, bak] of moved.concat(copied)) {
+ try { if (fs.existsSync(bak)) fs.copyFileSync(bak, p); } catch (e) {}
+ try { if (fs.existsSync(bak)) fs.unlinkSync(bak); } catch (e) {}
+ }
+}
+process.on('exit', cleanup);
+process.on('SIGINT', () => { cleanup(); process.exit(1); });
+process.on('uncaughtException', (e) => { console.error('FATAL', e); cleanup(); process.exit(2); });
+
+mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
+const phpArgs = INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PHP_PORT, '-t', ROOT]);
+srv = spawn(PHP, phpArgs, { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
+
+function waitPort(port, timeoutMs) {
+ const deadline = Date.now() + timeoutMs;
+ return new Promise((resolve) => {
+ (function attempt() {
+ const s = net.connect({ host: '127.0.0.1', port });
+ const done = (ok) => { s.destroy(); resolve(ok); };
+ s.setTimeout(800, () => done(false));
+ s.on('connect', () => done(true));
+ s.on('error', () => { if (Date.now() > deadline) return done(false); setTimeout(attempt, 300); });
+ s.on('timeout', () => { if (Date.now() > deadline) return done(false); setTimeout(attempt, 300); });
+ })();
+ });
+}
+
+const cookieJar = [];
+let CSRF = '';
+function baseHeaders() {
+ const h = {};
+ if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
+ return h;
+}
+function absorb(res) {
+ for (const sc of res.headers['set-cookie'] || []) {
+ const pair = sc.split(';')[0];
+ const name = pair.split('=')[0];
+ const i = cookieJar.findIndex(x => x.startsWith(name + '='));
+ if (i >= 0) cookieJar[i] = pair; else cookieJar.push(pair);
+ }
+}
+function request(method, urlPath, headers, bodyBuf) {
+ return new Promise((resolve, reject) => {
+ const h = Object.assign({}, headers || {});
+ if (bodyBuf) h['Content-Length'] = bodyBuf.length;
+ const r = http.request({ host: '127.0.0.1', port: PHP_PORT, method, path: urlPath, headers: h }, res => {
+ const chunks = [];
+ res.on('data', c => chunks.push(c));
+ res.on('end', () => { absorb(res); const raw = Buffer.concat(chunks).toString('utf8'); let j = null; try { j = JSON.parse(raw); } catch (e) {} resolve({ status: res.statusCode, body: j, raw }); });
+ });
+ r.on('error', reject);
+ if (bodyBuf) r.write(bodyBuf);
+ r.end();
+ });
+}
+const get = (p) => request('GET', p, baseHeaders());
+function formPost(p, fields) {
+ const body = Buffer.from(new URLSearchParams(fields).toString());
+ return request('POST', p, Object.assign({ 'Content-Type': 'application/x-www-form-urlencoded' }, baseHeaders()), body);
+}
+function upload(urlPath, fields, file) {
+ const boundary = '----nyUpload' + Math.random().toString(16).slice(2);
+ const parts = [];
+ for (const [k, v] of Object.entries(fields)) parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${file.filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`));
+ parts.push(Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content));
+ parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
+ return request('POST', urlPath, Object.assign({ 'Content-Type': 'multipart/form-data; boundary=' + boundary }, baseHeaders()), Buffer.concat(parts));
+}
+
+let pass = 0, fail = 0;
+function chk(name, cond, extra) {
+ if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
+ else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
+}
+
+(async () => {
+ const mockUp = await waitPort(MOCK_PORT, 15000);
+ const phpUp = await waitPort(PHP_PORT, 15000);
+ chk('mock 卷王服务已就绪', mockUp);
+ chk('PHP 内置服务器已就绪', phpUp);
+ if (!mockUp || !phpUp) { console.log(' 服务未就绪,终止测试'); cleanup(); process.exit(1); }
+
+ for (let i = 0; i < 3 && CSRF === ''; i++) {
+ await formPost('/login.php', { username: 'mock', password: 'surveyking' });
+ const ac = await get('/api.php?action=auth_check');
+ if (ac.body && ac.body.data && ac.body.data.auth === true) {
+ const cg = await get('/api.php?action=config_get');
+ CSRF = (cg.body && cg.body.data && cg.body.data.csrf_token) || '';
+ }
+ }
+ chk('已登录并取到 CSRF token', CSRF !== '');
+ if (CSRF === '') { console.log(' 登录失败,终止测试'); cleanup(); process.exit(1); }
+
+ console.log('\n--- 1) 先导入一份名单(含白名单内 90001-90005 + 一个白名单外 90099) ---');
+ const csv = '学号,姓名,班级\n90001,甲,1班\n90002,乙,1班\n90003,丙,1班\n90004,丁,1班\n90005,戊,1班\n90099,外,1班\n';
+ const imp = await upload('/api.php?action=roster_import', { mode: 'replace', _csrf: CSRF }, {
+ filename: 'roster.csv', content: Buffer.from('\xEF\xBB\xBF' + csv, 'utf8'),
+ });
+ chk('名单导入成功', !!(imp.body && imp.body.ok === true), (imp.body && (imp.body.error || imp.body.msg)) || '');
+ chk('导入人数为 6', !!(imp.body && imp.body.data && imp.body.data.imported === 6), imp.body ? String(imp.body.data && imp.body.data.imported) : '');
+
+ console.log('\n--- 2) p1 卷内白名单驱动应考名单 ---');
+ const d1 = await get('/api.php?action=data&projectId=p1');
+ chk('data(p1) 成功', !!(d1.body && d1.body.ok === true), (d1.body && (d1.body.error || d1.body.msg)) || d1.raw.slice(0, 120));
+ const d1d = (d1.body && d1.body.data) || {};
+ chk('p1 标记为白名单驱动', d1d.whitelist_driven === true, 'whitelist_driven=' + d1d.whitelist_driven);
+ chk('p1 白名单应考 5 人', d1d.whitelist_total === 5, 'whitelist_total=' + d1d.whitelist_total);
+ const r1 = (d1d.roster || []).map(s => String(s.sid)).sort();
+ chk('p1 应考名单恰好 90001-90005', JSON.stringify(r1) === JSON.stringify(['90001', '90002', '90003', '90004', '90005']), JSON.stringify(r1));
+ const a1 = (d1d.absent || []).map(s => String(s.sid)).sort();
+ chk('p1 缺考仅来自白名单(不含 90099)', !a1.includes('90099'), JSON.stringify(a1));
+ chk('p1 缺考是白名单子集', a1.every(s => r1.includes(s)), JSON.stringify(a1));
+
+ console.log('\n--- 3) p2 未设置白名单 → 按现状(全名单)---');
+ const d2 = await get('/api.php?action=data&projectId=p2');
+ chk('data(p2) 成功', !!(d2.body && d2.body.ok === true), (d2.body && (d2.body.error || d2.body.msg)) || '');
+ const d2d = (d2.body && d2.body.data) || {};
+ chk('p2 非白名单驱动', d2d.whitelist_driven === false, 'whitelist_driven=' + d2d.whitelist_driven);
+ const r2 = (d2d.roster || []).map(s => String(s.sid)).sort();
+ chk('p2 含白名单外成员 90099', r2.includes('90099'), JSON.stringify(r2));
+
+ console.log('\n--- 4) 已移除的 roster_import_whitelist 接口不再可用 ---');
+ const removed = await formPost('/api.php?action=roster_import_whitelist', { projectId: 'p1', mode: 'replace', _csrf: CSRF });
+ chk('roster_import_whitelist 已停用', !!(removed.body && removed.body.ok === false), (removed.body && (removed.body.error || removed.body.msg)) || '');
+
+ console.log('\n--- 5) 上传防护仍有效(导入后 data/uploads 即清) ---');
+ const left = fs.existsSync(UPLOADS) ? fs.readdirSync(UPLOADS).filter(f => f !== '.htaccess' && f !== 'index.html') : [];
+ chk('data/uploads 无残留', left.length === 0, JSON.stringify(left));
+
+ console.log('');
+ console.log(`结果:${pass} 通过 / ${fail} 失败(共 ${pass + fail})`);
+ cleanup();
+ process.exit(fail === 0 ? 0 : 1);
+})();