From 94ac3815793ab188738188b33f1487052f3b751b Mon Sep 17 00:00:00 2001 From: nxxy335 Date: Sun, 13 Sep 2026 10:25:48 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=89=B9=E6=AC=A1=20L-R?= =?UTF-8?q?=EF=BC=9A=E9=99=90=E6=B5=81=E7=A7=BB=E9=99=A4/=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95=E9=A9=B1=E5=8A=A8/=E6=89=93=E5=8D=B0?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA/=E5=85=A8=E5=B1=8F/=E8=A1=A8=E6=A0=BC?= =?UTF-8?q?=E5=AF=B9=E9=BD=90/=E4=B8=BB=E9=A2=98=E5=8F=98=E9=87=8F/?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E9=97=AA=E7=83=81=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除登录失败限流;fix-config.php 修正 e2e 串味的 127.0.0.1 连接 - 白名单驱动缺/待考名单(DataService + SurveyKingClient::getWhitelist,复用已取 survey) - 打印成绩明细增强(范围/合格列默认关/去页脚);看板全屏占满;表格 vertical-align:middle;隐藏登陆账号;导入默认追加 - 主题变量收口(:root 新增 brand/primary-2/ok-2 等,替换硬编码渐变);删死规则 - 表头/数据列对齐修复(index 未匹配表、dashboard 成员榜、draw 结果/历史/排除表);看板占位卡片配色一致 - 修复滚动闪烁:row-drop-in 动画经 _dropAnim 门控,仅强制重绘时播放 - 测试:新增 test-whitelist.js;run-all.sh 全绿 132 --- api.php | 104 +++++++++++++--- assets/app.js | 256 ++++++++++++++++++++++++++++++--------- assets/draw.js | 13 +- assets/style.css | 92 ++++++-------- bootstrap.php | 21 +++- dashboard.php | 51 +++++++- draw.php | 15 ++- index.php | 39 +++--- lib/DataCache.php | 11 -- lib/DataService.php | 108 +++++++++++++---- lib/Db.php | 79 ++---------- lib/Roster.php | 43 +++---- lib/SurveyKingClient.php | 94 ++++++++++++++ lib/XlsxWriter.php | 14 ++- tests/run-all.sh | 2 +- tools/mock-server.js | 4 + tools/test-whitelist.js | 211 ++++++++++++++++++++++++++++++++ 17 files changed, 856 insertions(+), 301 deletions(-) create mode 100644 tools/test-whitelist.js 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) + '
' : '') + '
' + esc(printMetaLine([ - '共 ' + all.n + ' 人', + (scope === 'all' ? '共 ' + all.n + ' 条记录' : '共 ' + all.n + ' 人'), '平均 ' + all.avg, '最高 ' + all.max + ' / 最低 ' + all.min, '合格 ' + all.pass + ' 人(' + all.passRate + '%)', @@ -1609,7 +1754,6 @@ } body += '
教师签字:____________  教研组长:____________  日期:____ 年 ____ 月 ____ 日
'; - body += '
' + esc(NY.appName || '') + ' · 本文档由系统生成,仅供教学参考
'; printDoc(title, body, { orient: orient }); showAlert('已调起打印,如未弹出请在打印对话框中选择正确打印机', 'info'); @@ -1619,7 +1763,7 @@ if (!curPid()) return showAlert('请先选择问卷 / 考试'); const n = (state._sorted || []).length; const a = (state._absentList || []).length; - $('#ptHint').textContent = '当前将打印 ' + n + ' 条成绩' + (a ? '(可附带 ' + a + ' 名缺考学生)' : ''); + $('#ptHint').textContent = '当前可打印 ' + n + ' 名学生的成绩' + (a ? '(可附带 ' + a + ' 名缺考学生)' : '') + ';弹窗中可切换为「打印全部记录」'; if (!$('#ptTitle').value.trim() && state.projectName) { $('#ptTitle').value = state.projectName + ' 成绩明细'; } diff --git a/assets/draw.js b/assets/draw.js index 6390c24..8dd1f2c 100644 --- a/assets/draw.js +++ b/assets/draw.js @@ -473,23 +473,18 @@ function renderResult() { const tb = $('#drawBody'); if (!state.picked.length) { - tb.innerHTML = '尚未抽取'; + tb.innerHTML = '尚未抽取'; return; } + // 抽取结果表不显示登陆账号(仅导出白名单才含该列),故只渲染 5 个数据列 + 操作列 tb.innerHTML = state.picked.map((m, i) => { - // 登陆账号为空 → 导出会回退到学号,这里显式标红提示教师先去名单里补全 - const login = String(m.login || ''); - const loginCell = login - ? esc(login) - : '' + esc(m.sid || '') + ' ⚠'; return '' + '' + (i + 1) + '' + '' + esc(m.sid) + '' + '' + esc(m.name) + '' + '' + esc(m.cls || '') + '' + '' + esc(m.grp || '') + '' + - '' + loginCell + '' + - '' + + '' + ''; }).join(''); } @@ -538,7 +533,7 @@ '
' + '
查看成员(' + e.count + ')' + '
' + - '' + + '' + '' + rows + '
序号学号姓名班级组别
序号学号姓名班级组别
' + '
' + ''; 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 '' + '' + '' + '' + @@ -280,7 +310,11 @@ function openAttempts(sid){ }).join(''); $('#attTitle').textContent = info.name + ' · 共 ' + (info.attempts || list.length) + ' 次提交'; $('#attBody').innerHTML = list.length - ? '
排名姓名成绩
排名姓名成绩
' + rk + '' + esc(m.name) + tag + '' + (sc == null ? '-' : sc) + '
' + rows + '
提交时间成绩考试时长
' + ? '' + + '' + + '' + + '' + + '' + 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();
+
- - + +
@@ -302,7 +303,7 @@ $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); +})();