Compare commits

...
2 Commits
Author SHA1 Message Date
nxxy335 804b04de60 移除登录失败限流;修复连接信息被 e2e 串味为 127.0.0.1
- bootstrap.php/login.php:整体移除「5 次失败锁 10 分钟」限流,登录失败仅即时提示原因
- 新增 tools/fix-config.php:一键双写修正连接地址(默认 exam.nxxy335.top),清空账号密码让用户重输真实卷王凭据
- NY_DATA 隔离落地:run-all.sh 给写配置的 e2e 指向临时目录;修 Windows Git Bash /c/... 路径规范化 bug;.ny-test-data 加 .gitignore
- 清理 dead throttle 引用(e2e 脚本 + docs)
- 修 login.php 真实 bug:ny_is_auth() 分支在加载 $cfg 前引用导致 Undefined variable 警告
- 全量测试 116 通过(PHP 44 + e2e 72)
2026-09-11 17:25:22 +08:00
nxxy335 25a2e8a42c 文档部署到 /ny-exam-platform 子路径 + 移除快照/趋势/异常 + 打印与 UI 全面完善
文档站(#88)
- base 默认改为 /ny-exam-platform/,可用 DOCS_BASE=/ 覆盖本地预览
- 新增 DOCS_OUT 环境变量,避免构建时清空输出目录失败导致产物半截
- 新增 tools/docs-preview.js:按子路径重写路由的本地预览服务
- 删除 docs/guide/snapshot.md,同步清理全部相关文档

移除存快照 / 趋势 / 异常(#89)
- 后端:删除 api.php 的 snapshot_save/list/delete/trend 四个 action 与
  $WRITE_ACTIONS 中的同名条目;删除 lib/Snapshot.php 与自动加载注册;
  DataService 移除 detectAnomalies() 及其调用
- 前端:移除三个按钮与三个弹窗、相关 JS 处理与 state 字段
- 测试:移除对应用例,补充 5 条名单导入列识别断言(17+9+18+32+29+11 全过)

打印完善(#90)
- 改用隐藏 iframe 输出,规避弹窗拦截;@page A4 支持纵向/横向
- 表头每页重复、行内不跨页断开、全表连续名次、可选「是否合格」列
- 分组小计(人数/平均/最高/最低/合格人数与合格率)、可附缺考名单另起一页
- 末尾签字栏;缺考名单打印改为同一套输出

UI / 交互 / Bug 修复(#91)
- dashboard.php:补「← 主系统 / 退出」入口(此前全屏后无法离开);
  修复成员下钻 onclick 里 JSON.stringify 双引号提前闭合属性导致点击报错,
  改为 data-sid 事件委托;补 Esc 关弹窗;后台标签页暂停轮询、切回自动补拉;
  刷新中断原因写入 title;数值字段统一兜底,避免 NaN / undefined%
- draw.php / draw.js:结果表新增「登陆账号」列(导出前可核对,缺失显示 学号 ⚠);
  弹窗只关最上层且支持 Esc;「全部」改为跟随候选池,手工改人数即解除;
  候选池不足时给出明确提示;自定义比例框隐藏时清空,避免旧权重被误读
- app.js / dashboard.php:成绩着色改为按「占本卷满分的百分比」判断,
  修复满分不是 100 时整列误标红
- style.css:数值列右对齐;新增 subbar、draw-stage-sub;
  看板顶栏窄屏下拉与按钮换行,消除横向滚动
2026-09-11 16:23:08 +08:00
35 changed files with 851 additions and 996 deletions
+3
View File
@@ -1,6 +1,9 @@
# 运行时数据:含账号密码、API 令牌、学生名单,禁止入库 # 运行时数据:含账号密码、API 令牌、学生名单,禁止入库
data/ data/
# e2e 隔离用的临时数据目录(run-all.sh 自动创建,不入库)
.ny-test-data/
# 依赖与系统文件 # 依赖与系统文件
node_modules/ node_modules/
vendor/ vendor/
+1 -59
View File
@@ -128,7 +128,7 @@ if ($action !== 'auth_check' && !ny_is_auth()) {
// CSRF 防护:所有写操作必须携带会话绑定的 token(GET 读取类接口不受影响) // CSRF 防护:所有写操作必须携带会话绑定的 token(GET 读取类接口不受影响)
$WRITE_ACTIONS = [ $WRITE_ACTIONS = [
'config_save', 'roster_save', 'roster_import', 'roster_clear', 'config_save', 'roster_save', 'roster_import', 'roster_clear',
'snapshot_save', 'snapshot_delete', 'unmatched_map', 'project_config_save', 'unmatched_map', 'project_config_save',
]; ];
if (in_array($action, $WRITE_ACTIONS, true) && !ny_csrf_ok()) { if (in_array($action, $WRITE_ACTIONS, true) && !ny_csrf_ok()) {
ny_json(['ok' => false, 'msg' => '安全校验失败(CSRF token 缺失或不匹配),请刷新页面后重试'], 403); ny_json(['ok' => false, 'msg' => '安全校验失败(CSRF token 缺失或不匹配),请刷新页面后重试'], 403);
@@ -578,64 +578,6 @@ try {
Roster::save($roster); Roster::save($roster);
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta(), 'mapped' => $name]); ny_ok(['students' => Roster::all(), 'meta' => Roster::meta(), 'mapped' => $name]);
// ---------------- 成绩快照 / 趋势 ----------------
case 'snapshot_save':
$projectId = (string)($_GET['projectId'] ?? $_POST['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
$raw = (string)file_get_contents('php://input');
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
}
$label = trim((string)($in['label'] ?? ''));
// 复用缓存构建(不强制,快照本就是"当前看到的成绩")
$c = cfg();
$nameQid = (string)($_GET['nameQid'] ?? '');
$built = ny_build_data($c, $projectId, $nameQid);
$snap = Snapshot::summarize($built['result'], $label);
$file = Snapshot::save($projectId, $snap);
ny_ok(['file' => $file, 'label' => $snap['label'], 'at_txt' => $snap['at_txt'], 'count' => count($snap['students'])]);
case 'snapshot_list':
$projectId = (string)($_GET['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
ny_ok(['snapshots' => Snapshot::list($projectId)]);
case 'snapshot_delete':
$projectId = (string)($_GET['projectId'] ?? $_POST['projectId'] ?? '');
$raw = (string)file_get_contents('php://input');
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
}
$file = (string)($in['file'] ?? '');
if ($projectId === '' || $file === '') {
ny_fail('参数错误');
}
ny_ok(['deleted' => Snapshot::delete($projectId, $file)]);
case 'snapshot_trend':
$projectId = (string)($_GET['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
// 支持跨项目对比:projectId 可用逗号分隔多个项目,取各自快照混合趋势
$pids = array_values(array_filter(array_map('trim', explode(',', $projectId))));
$snaps = [];
foreach ($pids as $pid) {
foreach (Snapshot::list($pid) as $s) {
$full = Snapshot::get($pid, $s['file']);
if ($full !== null) {
$snaps[] = $full;
}
}
}
ny_ok(Snapshot::trend($snaps));
// ---------------- 试卷独立配置(满分 / 合格率 / 免考名单)——仅对当前试卷生效 ---------------- // ---------------- 试卷独立配置(满分 / 合格率 / 免考名单)——仅对当前试卷生效 ----------------
case 'project_config': case 'project_config':
$projectId = (string)($_GET['projectId'] ?? ''); $projectId = (string)($_GET['projectId'] ?? '');
+239 -220
View File
@@ -259,7 +259,6 @@
state.absent = d.absent || []; state.absent = d.absent || [];
state.unmatched = d.unmatched || []; state.unmatched = d.unmatched || [];
state.roster = d.roster || []; state.roster = d.roster || [];
state.anomalies = d.anomalies || { fast_high: [], frequent: [], identical: [] };
state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100; 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; state.passRate = (d.config && d.config.pass_rate != null) ? (parseFloat(d.config.pass_rate) || 60) : 60;
@@ -488,6 +487,7 @@
set('#sTotal', st.total); set('#sTotal', st.total);
set('#sSubmit', st.submitted); set('#sSubmit', st.submitted);
set('#sAbsent', st.absent); set('#sAbsent', st.absent);
set('#sRate', st.rate + '%');
set('#sAvg', st.avg == null ? '-' : st.avg); set('#sAvg', st.avg == null ? '-' : st.avg);
set('#sMax', st.maxScore == null ? '-' : st.maxScore); set('#sMax', st.maxScore == null ? '-' : st.maxScore);
set('#sMin', st.minScore == null ? '-' : st.minScore); set('#sMin', st.minScore == null ? '-' : st.minScore);
@@ -521,10 +521,19 @@
return h + ' 小时 ' + String(m % 60).padStart(2, '0') + ' 分'; return h + ' 小时 ' + String(m % 60).padStart(2, '0') + ' 分';
} }
/**
* 成绩着色:按「占本卷满分的百分比」判断,而不是写死 90 / 60 分。
* 否则把满分改成 50 分(或卷子本身是 20 分制)时,整列都会被误判为不及格标红。
*/
function scoreClass(v) { function scoreClass(v) {
if (v == null) return ''; if (v == null) return '';
if (v >= 90) return 'score-high'; const full = state.fullScore || 100;
if (v >= 60) return 'score-mid'; if (!(full > 0)) return '';
const pct = (Number(v) / full) * 100;
if (!isFinite(pct)) return '';
const pass = (state.passRate == null ? 60 : Number(state.passRate));
if (pct >= 90) return 'score-high';
if (pct >= pass) return 'score-mid';
return 'score-low'; return 'score-low';
} }
@@ -662,6 +671,16 @@
th.innerHTML = th.textContent.replace(/[\s↑↓]+$/, '') + (isCur ? ' <span class="arrow">' + (state.sortAsc ? '↑' : '↓') + '</span>' : ''); th.innerHTML = th.textContent.replace(/[\s↑↓]+$/, '') + (isCur ? ' <span class="arrow">' + (state.sortAsc ? '↑' : '↓') + '</span>' : '');
}); });
// 搜索时提示要跟上,否则会出现"表格只有 3 行、提示却写共 50 人"的困惑
const kwNow = ($('#kw').value || '').trim();
if (kwNow) {
const base = state.rows.length;
const hintEl = $('#tableHint');
if (hintEl) hintEl.textContent = '搜索「' + kwNow + '」命中 ' + rows.length + ' 人(本卷共 ' + base + ' 人已提交)';
} else {
renderHints();
}
renderHlBadge(); renderHlBadge();
} }
@@ -698,8 +717,9 @@
* 虚拟滚动:只渲染可视窗口内的行,用顶部/底部 spacer 撑起完整滚动高度。 * 虚拟滚动:只渲染可视窗口内的行,用顶部/底部 spacer 撑起完整滚动高度。
* 行数很多(尤其「全部展开」历史模式下)时,避免一次性插入成千上万 DOM 节点导致卡顿。 * 行数很多(尤其「全部展开」历史模式下)时,避免一次性插入成千上万 DOM 节点导致卡顿。
*/ */
const ROW_H = 41; // 估算单行高度(含 padding/border),用于窗口定位 const ROW_H_FALLBACK = 41; // 首帧未测量前的行高估值
const VS_OVERSCAN = 12; // 视口上下各多渲染的行数,减少快速滚动时的白屏 const VS_OVERSCAN = 12; // 视口上下各多渲染的行数,减少快速滚动时的白屏
let measuredRowH = 0; // 实测行高(渲染后回写),CSS/字体/缩放变化都能自动适应
function renderVirtualRows(tb, rows) { function renderVirtualRows(tb, rows) {
const scroller = tb.closest('.table-scroll'); const scroller = tb.closest('.table-scroll');
if (!scroller) { if (!scroller) {
@@ -710,24 +730,33 @@
const scrollTop = scroller.scrollTop; const scrollTop = scroller.scrollTop;
const viewH = scroller.clientHeight; const viewH = scroller.clientHeight;
const total = rows.length; const total = rows.length;
let start = Math.floor(scrollTop / ROW_H) - VS_OVERSCAN; const rh = measuredRowH || ROW_H_FALLBACK;
let end = Math.ceil((scrollTop + viewH) / ROW_H) + VS_OVERSCAN; let start = Math.floor(scrollTop / rh) - VS_OVERSCAN;
let end = Math.ceil((scrollTop + viewH) / rh) + VS_OVERSCAN;
if (start < 0) start = 0; if (start < 0) start = 0;
if (end > total) end = total; if (end > total) end = total;
if (start >= end) start = Math.max(0, end - 1); if (start >= end) start = Math.max(0, end - 1);
const frag = []; const frag = [];
if (start > 0) { if (start > 0) {
frag.push('<tr class="vspacer" style="height:' + (start * ROW_H) + 'px;pointer-events:none"><td colspan="' + COL_COUNT + '"></td></tr>'); frag.push('<tr class="vspacer" style="height:' + (start * rh) + 'px;pointer-events:none"><td colspan="' + COL_COUNT + '"></td></tr>');
} }
for (let i = start; i < end; i++) { for (let i = start; i < end; i++) {
frag.push(rowHtml(rows[i])); frag.push(rowHtml(rows[i]));
} }
if (end < total) { if (end < total) {
frag.push('<tr class="vspacer" style="height:' + ((total - end) * ROW_H) + 'px;pointer-events:none"><td colspan="' + COL_COUNT + '"></td></tr>'); frag.push('<tr class="vspacer" style="height:' + ((total - end) * rh) + 'px;pointer-events:none"><td colspan="' + COL_COUNT + '"></td></tr>');
} }
tb.innerHTML = frag.join(''); tb.innerHTML = frag.join('');
// 渲染后回写真实行高:换字体、改样式、页面缩放时窗口定位都不会漂移
const tr0 = tb.querySelector('tr[data-sid]');
if (tr0) {
const h = tr0.offsetHeight;
if (h > 12 && h < 300) measuredRowH = h;
} }
}
window.addEventListener('resize', () => { measuredRowH = 0; });
/** 供滚动监听调用:滚动时重算可视窗口(不重新排序,直接复用 state._sorted) */ /** 供滚动监听调用:滚动时重算可视窗口(不重新排序,直接复用 state._sorted) */
function onTableScroll() { function onTableScroll() {
@@ -771,6 +800,12 @@
function renderUnmatched() { function renderUnmatched() {
const tb = $('#unmatchedBody'); const tb = $('#unmatchedBody');
const hint = $('#unmatchedHint');
if (hint) {
hint.textContent = state.unmatched.length
? ('共 ' + state.unmatched.length + ' 份 · 姓名与名单对不上,核对后可点「加入名单」补录')
: '姓名不在名单中';
}
if (!state.unmatched.length) { if (!state.unmatched.length) {
tb.innerHTML = '<tr><td colspan="4" class="empty">无</td></tr>'; tb.innerHTML = '<tr><td colspan="4" class="empty">无</td></tr>';
return; return;
@@ -914,8 +949,9 @@
$('#cfgHistoryDisplay').value = state.historyDisplay; $('#cfgHistoryDisplay').value = state.historyDisplay;
$('#cfgExportSort').value = state.exportSort; $('#cfgExportSort').value = state.exportSort;
if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort; if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort;
// 连接信息(账号回显;密码不回显) // 连接信息(地址 / 前缀 / 账号可编辑;密码不回显)
$('#cfgBase').value = (c.base_url || '') + (c.api_prefix || '/api'); $('#cfgBase').value = c.base_url || '';
if ($('#cfgPrefix')) $('#cfgPrefix').value = c.api_prefix || '/api';
$('#cfgUser').value = c.username || ''; $('#cfgUser').value = c.username || '';
$('#cfgPass').value = ''; $('#cfgPass').value = '';
const conn = c.db_configured const conn = c.db_configured
@@ -1124,12 +1160,23 @@
showAlert(exportTip('缺考名单', cls, grp), 'info'); showAlert(exportTip('缺考名单', cls, grp), 'info');
}); });
/* 弹窗关闭 */ /* 弹窗关闭:只关最上层那一个(新增学生等弹窗是叠在名单管理之上的,
关全部会连带把底层弹窗一起关掉,不符合预期) */
function closeTopModal() {
const open = $$('.modal.open');
if (!open.length) return false;
open[open.length - 1].classList.remove('open');
return true;
}
document.addEventListener('click', e => { document.addEventListener('click', e => {
if (e.target.matches('[data-close]') || e.target.classList.contains('modal')) { if (e.target.matches('[data-close]') || e.target.classList.contains('modal')) {
$$('.modal').forEach(m => m.classList.remove('open')); closeTopModal();
} }
}); });
// Esc 关闭最上层弹窗
document.addEventListener('keydown', e => {
if (e.key === 'Escape' || e.key === 'Esc') closeTopModal();
});
/* 设置保存 */ /* 设置保存 */
$('#btnSaveCfg').addEventListener('click', async () => { $('#btnSaveCfg').addEventListener('click', async () => {
@@ -1150,6 +1197,8 @@
return (isNaN(v) || v < 1 || v > 100) ? 60 : v; return (isNaN(v) || v < 1 || v > 100) ? 60 : v;
})(), })(),
layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default', layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default',
base_url: $('#cfgBase').value.trim(),
api_prefix: ($('#cfgPrefix') ? $('#cfgPrefix').value.trim() : '/api') || '/api',
username: $('#cfgUser').value.trim(), username: $('#cfgUser').value.trim(),
history_display: $('#cfgHistoryDisplay').value, history_display: $('#cfgHistoryDisplay').value,
export_sort: $('#cfgExportSort').value, export_sort: $('#cfgExportSort').value,
@@ -1203,10 +1252,12 @@
box.classList.remove('hidden', 'err'); box.classList.add('loading'); box.classList.remove('hidden', 'err'); box.classList.add('loading');
box.textContent = '正在连接卷王系统测试…'; box.textContent = '正在连接卷王系统测试…';
try { try {
// 仅传可编辑的连接字段;base_url / api_prefix 在弹窗中只读,沿用已保存值 // 全部连接字段都从弹窗当前值取(未保存也能测)
const r = await api('config_test', { const r = await api('config_test', {
method: 'POST', method: 'POST',
body: { body: {
base_url: $('#cfgBase').value.trim(),
api_prefix: ($('#cfgPrefix') ? $('#cfgPrefix').value.trim() : '/api') || '/api',
username: $('#cfgUser').value.trim(), username: $('#cfgUser').value.trim(),
password: $('#cfgPass').value, password: $('#cfgPass').value,
verify_ssl: $('#cfgSsl').checked verify_ssl: $('#cfgSsl').checked
@@ -1384,102 +1435,67 @@
/* 成绩快照 / 趋势 */ /* 成绩快照 / 趋势 */
function curPid() { return $('#projectSel').value; } function curPid() { return $('#projectSel').value; }
async function loadSnapshots() {
const pid = curPid();
if (!pid) return;
try {
const d = await api('snapshot_list', { params: { projectId: pid } });
const list = d.snapshots || [];
const tb = $('#snapBody');
if (!list.length) {
tb.innerHTML = '<tr><td colspan="5" class="empty">暂无快照,点击「保存当前快照」创建第一份</td></tr>';
return;
}
tb.innerHTML = list.map(s =>
'<tr>' +
'<td>' + esc(s.label) + '</td>' +
'<td class="num">' + esc(s.at_txt) + '</td>' +
'<td class="num">' + s.count + '</td>' +
'<td>' + esc((s.project && s.project.name) || '') + '</td>' +
'<td><span class="link-del" data-snapdel="' + esc(s.file) + '">删除</span></td></tr>').join('');
} catch (e) { showAlert(e.message); }
}
$('#btnSnapshot').addEventListener('click', () => {
if (!curPid()) return showAlert('请先选择问卷 / 考试');
$('#snapLabel').value = '';
$('#snapTitle').textContent = '成绩快照 · ' + (state.projectName || '');
loadSnapshots();
$('#snapshotModal').classList.add('open');
});
$('#btnSnapSave').addEventListener('click', async () => {
const pid = curPid();
if (!pid) return showAlert('请先选择问卷 / 考试');
try {
const d = await api('snapshot_save', { method: 'POST', params: { projectId: pid, nameQid: $('#nameFieldSel').value }, body: { label: $('#snapLabel').value.trim() } });
showAlert('快照已保存:' + d.label + '(' + d.count + ' 人)', 'info');
$('#snapLabel').value = '';
loadSnapshots();
} catch (e) { showAlert(e.message); }
});
$('#btnSnapRefresh').addEventListener('click', loadSnapshots);
$('#snapBody').addEventListener('click', async e => {
const f = e.target.getAttribute && e.target.getAttribute('data-snapdel');
if (f == null) return;
if (!confirm('确定删除该快照?此操作不可撤销。')) return;
try {
await api('snapshot_delete', { method: 'POST', params: { projectId: curPid() }, body: { file: f } });
showAlert('快照已删除', 'info');
loadSnapshots();
} catch (err) { showAlert(err.message); }
});
async function renderTrend() {
const pid = curPid();
if (!pid) return showAlert('请先选择问卷 / 考试');
const body = $('#trendBody'), head = $('#trendHead');
body.innerHTML = '<tr><td colspan="10" class="empty">加载中…</td></tr>';
try {
const d = await api('snapshot_trend', { params: { projectId: pid } });
const cols = d.columns || [], rows = d.rows || [];
$('#trendSub').textContent = state.projectName || '';
if (!cols.length) {
head.innerHTML = ''; body.innerHTML = '<tr><td colspan="10" class="empty">还没有快照,请先保存快照</td></tr>';
return;
}
head.innerHTML = '<tr><th>学号</th><th>姓名</th><th>班级</th>' +
cols.map(c => '<th title="' + esc(c.at_txt) + '">' + esc(c.label) + '</th>').join('') + '</tr>';
const colSpan = cols.length + 3;
if (!rows.length) {
body.innerHTML = '<tr><td colspan="' + colSpan + '" class="empty">无数据</td></tr>';
return;
}
body.innerHTML = rows.map(r => {
let cells = '<td class="sid">' + esc(r.sid) + '</td><td>' + esc(r.name) + '</td><td>' + esc(r.cls || '') + '</td>';
cols.forEach(c => {
const v = r.scores[c.key];
cells += '<td class="num">' + (v == null ? '—' : v) + '</td>';
});
return '<tr>' + cells + '</tr>';
}).join('');
} catch (e) { showAlert(e.message); }
}
$('#btnSnapTrend').addEventListener('click', () => {
$('#snapshotModal').classList.remove('open');
renderTrend();
$('#trendModal').classList.add('open');
});
$('#btnTrend').addEventListener('click', () => {
if (!curPid()) return showAlert('请先选择问卷 / 考试');
renderTrend();
$('#trendModal').classList.add('open');
});
/* 成绩明细打印:默认按成绩降序,可选按班级/组别分组对比 */ /* 成绩明细打印:默认按成绩降序,可选按班级/组别分组对比 */
/* ============ 打印(统一出口) ============
* 用隐藏 iframe 输出而不是 window.open:不会被浏览器的弹窗拦截挡掉,
* 也不会多开一个标签页。打印内容独立于页面排序,默认按成绩降序。
*/
function printDoc(title, bodyHtml, opts) {
opts = opts || {};
const orient = opts.orient === 'landscape' ? 'landscape' : 'portrait';
const css =
'@page{size:A4 ' + orient + ';margin:14mm 10mm}' +
'html,body{margin:0;padding:0}' +
'body{font-family:"Microsoft YaHei","微软雅黑",sans-serif;color:#000;font-size:12px;line-height:1.5}' +
'h2{margin:0 0 6px;font-size:17px;text-align:center}' +
'.sub{text-align:center;font-size:12px;color:#444;margin-bottom:4px}' +
'.meta{font-size:11px;color:#444;margin:0 0 10px;padding-bottom:6px;border-bottom:1px solid #999}' +
'table{border-collapse:collapse;width:100%;font-size:11px;margin-bottom:8px}' +
'thead{display:table-header-group}' + // 跨页自动重复表头
'tr{page-break-inside:avoid}' +
'th,td{border:1px solid #555;padding:4px 6px;text-align:left;vertical-align:middle}' +
'th{background:#eaeaea;font-weight:700;white-space:nowrap}' +
'td.c,th.c{text-align:center}' +
'td.score{font-weight:700}' +
'td.fail{color:#b00}' +
'h3.grp{font-size:13px;margin:14px 0 5px;padding:5px 8px;background:#f2f2f2;border-left:4px solid #333;page-break-after:avoid}' +
'h3.grp small{font-weight:400;color:#555}' +
'.sign{margin-top:16px;font-size:12px;color:#333;page-break-inside:avoid}' +
'.foot{margin-top:8px;font-size:10px;color:#777;text-align:center}';
const html = '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">' +
'<title>' + esc(title) + '</title><style>' + css + '</style></head><body>' + bodyHtml + '</body></html>';
const old = document.getElementById('__nyPrintFrame');
if (old && old.parentNode) old.parentNode.removeChild(old);
const f = document.createElement('iframe');
f.id = '__nyPrintFrame';
f.setAttribute('title', '打印');
f.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden';
document.body.appendChild(f);
const doc = f.contentWindow && f.contentWindow.document;
if (!doc) { // 极端情况兜底
const w = window.open('', '_blank');
if (!w) return showAlert('打印失败,请允许弹窗后重试', 'error');
w.document.write(html); w.document.close();
return;
}
doc.open(); doc.write(html); doc.close();
setTimeout(function () {
try { f.contentWindow.focus(); f.contentWindow.print(); }
catch (e) { window.print(); }
}, 250);
}
function printMetaLine(extra) {
const cls = $('#clsSel').value, grp = $('#grpSel').value;
const scope = [cls ? (cls + '班') : '', grp ? (grp + '组') : ''].filter(Boolean).join(' · ');
return [
scope ? ('范围:' + scope) : '范围:全部',
'打印时间:' + new Date().toLocaleString(),
].concat(extra || []).join(' | ');
}
function printTable() { function printTable() {
const rows = (state._sorted || []).slice(); const rows = (state._sorted || []).slice();
if (!rows.length) return showAlert('当前没有可打印的成绩', 'warn'); if (!rows.length) return showAlert('当前没有可打印的成绩', 'warn');
@@ -1488,23 +1504,27 @@
const groupBy = $('#ptGroup').value; // none | cls | grp const groupBy = $('#ptGroup').value; // none | cls | grp
const showRank = $('#ptRank').checked; const showRank = $('#ptRank').checked;
const showAtt = $('#ptAttempts').checked; const showAtt = $('#ptAttempts').checked;
const showPass = $('#ptPass').checked;
const withAbsent= $('#ptAbsent').checked;
const orient = $('#ptOrient').value;
const full = state.fullScore || 100; const full = state.fullScore || 100;
const passRate = (state.passRate == null ? 60 : state.passRate); const passRate = (state.passRate == null ? 60 : state.passRate);
const passLine = Math.round((passRate / 100) * full * 10) / 10;
const sidCmp = (a, b) => String(a.sid || '').localeCompare(String(b.sid || ''), 'zh', { numeric: true });
// 排序(打印独立于页面排序,默认成绩降序;null 成绩恒排末尾) // 排序:打印独立于页面排序;无成绩恒排末尾;同分按学号,便于核对
const cmp = (a, b) => { rows.sort((a, b) => {
const an = (a.score == null), bn = (b.score == null); const an = (a.score == null), bn = (b.score == null);
if (an && bn) return 0; if (an && bn) return sidCmp(a, b);
if (an) return 1; if (an) return 1;
if (bn) return -1; if (bn) return -1;
if (sortMode === 'score_desc') return (b.score - a.score) || ((b.submit_ts || 0) - (a.submit_ts || 0)); if (sortMode === 'score_desc') return (b.score - a.score) || sidCmp(a, b);
if (sortMode === 'score_asc') return (a.score - b.score) || ((b.submit_ts || 0) - (a.submit_ts || 0)); if (sortMode === 'score_asc') return (a.score - b.score) || sidCmp(a, b);
if (sortMode === 'sid') return String(a.sid).localeCompare(String(b.sid), 'zh', { numeric: true }); if (sortMode === 'sid') return sidCmp(a, b);
return ((b.submit_ts || 0) - (a.submit_ts || 0)); // time return ((b.submit_ts || 0) - (a.submit_ts || 0)) || sidCmp(a, b); // time
}; });
rows.sort(cmp);
const passOf = r => (r.score != null && r.score >= (passRate / 100) * full); const passOf = r => (r.score != null && r.score >= passLine);
const statOf = list => { const statOf = list => {
const sc = list.map(r => r.score).filter(v => v != null); const sc = list.map(r => r.score).filter(v => v != null);
const avg = sc.length ? (sc.reduce((x, y) => x + y, 0) / sc.length) : null; const avg = sc.length ? (sc.reduce((x, y) => x + y, 0) / sc.length) : null;
@@ -1512,29 +1532,40 @@
return { return {
n: list.length, n: list.length,
avg: avg == null ? '—' : avg.toFixed(1), avg: avg == null ? '—' : avg.toFixed(1),
max: sc.length ? Math.max.apply(null, sc) : '—',
min: sc.length ? Math.min.apply(null, sc) : '—',
pass: pass,
passRate: list.length ? Math.round(pass / list.length * 1000) / 10 : 0, passRate: list.length ? Math.round(pass / list.length * 1000) / 10 : 0,
}; };
}; };
const head = '<tr><th>' + (showRank ? '名次' : '') + '</th><th>学号</th><th>姓名</th>' + const head = '<tr>' + (showRank ? '<th class="c" style="width:44px">名次</th>' : '') +
(groupBy === 'none' ? '<th>班级</th><th>组别</th>' : '') + '<th style="width:96px">学号</th><th style="width:76px">姓名</th>' +
'<th>成绩</th>' + (showAtt ? '<th>考试次数</th>' : '') + '<th>提交时间</th></tr>'; (groupBy === 'none' ? '<th style="width:70px">班级</th><th class="c" style="width:48px">组别</th>' : '') +
'<th class="c" style="width:60px">成绩</th>' +
(showPass ? '<th class="c" style="width:56px">是否合格</th>' : '') +
(showAtt ? '<th class="c" style="width:64px">考试次数</th>' : '') +
'<th>提交时间</th></tr>';
let html = ''; // 名次:整份名单连续编号(分组时也连续),无成绩不参与排名
let rank = 0; let seq = 0;
const renderRows = (list, offsetRank) => list.map((r, i) => { const renderRows = list => list.map(r => {
const rk = (r.score == null) ? '' : (offsetRank + i + 1); const ranked = (r.score != null);
return '<tr>' + (showRank ? '<td class="c">' + rk + '</td>' : '') + if (ranked) seq++;
const ok = passOf(r);
return '<tr>' + (showRank ? '<td class="c">' + (ranked ? seq : '—') + '</td>' : '') +
'<td>' + esc(r.sid || '') + '</td>' + '<td>' + esc(r.sid || '') + '</td>' +
'<td>' + esc(r.name || '') + '</td>' + '<td>' + esc(r.name || '') + '</td>' +
(groupBy === 'none' ? '<td>' + esc(r.cls || '') + '</td><td>' + esc(r.grp || '') + '</td>' : '') + (groupBy === 'none' ? '<td>' + esc(r.cls || '') + '</td><td class="c">' + esc(r.grp || '') + '</td>' : '') +
'<td class="c"><b>' + (r.score == null ? '—' : r.score) + '</b></td>' + '<td class="c score' + (ranked && !ok ? ' fail' : '') + '">' + (r.score == null ? '—' : r.score) + '</td>' +
(showPass ? '<td class="c' + (ranked && !ok ? ' fail' : '') + '">' + (r.score == null ? '—' : (ok ? '合格' : '不合格')) + '</td>' : '') +
(showAtt ? '<td class="c">' + (r.attempts || 1) + '</td>' : '') + (showAtt ? '<td class="c">' + (r.attempts || 1) + '</td>' : '') +
'<td>' + esc(r.submit_at || '') + '</td></tr>'; '<td>' + esc(r.submit_at || '—') + '</td></tr>';
}).join(''); }).join('');
let html = '';
if (groupBy === 'none') { if (groupBy === 'none') {
html = '<table><thead>' + head + '</thead><tbody>' + renderRows(rows, 0) + '</tbody></table>'; html = '<table><thead>' + head + '</thead><tbody>' + renderRows(rows) + '</tbody></table>';
} else { } else {
const keyOf = (groupBy === 'cls') ? (r => (r.cls || '未分班')) : (r => (r.grp || '未分组')); const keyOf = (groupBy === 'cls') ? (r => (r.cls || '未分班')) : (r => (r.grp || '未分组'));
const groups = {}; const groups = {};
@@ -1544,45 +1575,54 @@
const list = groups[k]; const list = groups[k];
const st = statOf(list); const st = statOf(list);
html += '<h3 class="grp">' + esc(k) + html += '<h3 class="grp">' + esc(k) +
' <small>(' + st.n + ' 人 · 平均 ' + st.avg + ' · 合格率 ' + st.passRate + '%)</small></h3>'; ' <small>(' + st.n + ' 人 · 平均 ' + st.avg + ' · 最高 ' + st.max + ' · 最低 ' + st.min +
html += '<table><thead>' + head + '</thead><tbody>' + renderRows(list, 0) + '</tbody></table>'; ' · 合格 ' + st.pass + ' 人 / ' + st.passRate + '%)</small></h3>';
html += '<table><thead>' + head + '</thead><tbody>' + renderRows(list) + '</tbody></table>';
}); });
} }
const all = statOf(rows); const all = statOf(rows);
const title = (state.projectName ? state.projectName + ' · ' : '') + '成绩明细'; const proj = state.projectName || '';
const cls = $('#clsSel').value, grp = $('#grpSel').value; const title = ($('#ptTitle').value.trim() || (proj ? proj + ' 成绩明细' : '成绩明细'));
const scope = (cls ? cls + '班 ' : '') + (grp ? grp + '组' : ''); let body = '<h2>' + esc(title) + '</h2>' +
(proj ? '<div class="sub">' + esc(proj) + '</div>' : '') +
'<div class="meta">' + esc(printMetaLine([
'共 ' + all.n + ' 人',
'平均 ' + all.avg,
'最高 ' + all.max + ' / 最低 ' + all.min,
'合格 ' + all.pass + ' 人(' + all.passRate + '%)',
'合格线 ' + passLine + ' 分(满分的 ' + passRate + '%)',
])) + '</div>' + html;
const w = window.open('', '_blank'); if (withAbsent) {
if (!w) return showAlert('浏览器拦截了弹窗,请允许后重试', 'error'); const absent = state._absentList || [];
w.document.write('<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>' + esc(title) + '</title>' + body += '<h3 class="grp" style="page-break-before:always">缺考名单 <small>(' + absent.length + ' 人)</small></h3>';
'<style>' + if (!absent.length) {
'body{font-family:"Microsoft YaHei",sans-serif;margin:20px;color:#000}' + body += '<p>无缺考学生。</p>';
'h2{margin:0 0 4px;font-size:18px}' + } else {
'.meta{font-size:12px;color:#555;margin-bottom:14px}' + body += '<table><thead><tr><th style="width:96px">学号</th><th style="width:76px">姓名</th>' +
'h3.grp{font-size:14px;margin:18px 0 6px;padding:6px 8px;background:#f0f0f0;border-left:4px solid #333}' + '<th style="width:70px">班级</th><th class="c" style="width:48px">组别</th><th>备注</th></tr></thead><tbody>' +
'h3.grp small{font-weight:400;color:#555}' + absent.map(a => '<tr><td>' + esc(a.sid || '') + '</td><td>' + esc(a.name || '') + '</td><td>' +
'table{border-collapse:collapse;width:100%;margin-bottom:10px;font-size:12px}' + esc(a.cls || '') + '</td><td class="c">' + esc(a.grp || '') + '</td><td></td></tr>').join('') +
'th,td{border:1px solid #666;padding:5px 8px;text-align:left}' + '</tbody></table>';
'th{background:#e8e8e8;font-weight:700}' + }
'td.c{text-align:center}' + }
'@media print{body{margin:0}h3.grp{page-break-after:avoid}table{page-break-inside:auto}tr{page-break-inside:avoid}}' +
'</style></head><body>' + body += '<div class="sign">教师签字:____________  教研组长:____________  日期:____ 年 ____ 月 ____ 日</div>';
'<h2>' + esc(title) + '</h2>' + body += '<div class="foot">' + esc(NY.appName || '') + ' · 本文档由系统生成,仅供教学参考</div>';
'<div class="meta">' + esc(scope) + ' 共 ' + all.n + ' 人 · 平均 ' + all.avg +
' · 合格率 ' + all.passRate + '%(合格线:满分的 ' + passRate + '%,即 ' + printDoc(title, body, { orient: orient });
(Math.round(passRate / 100 * full * 10) / 10) + ' 分) · 打印时间:' + new Date().toLocaleString() + '</div>' + showAlert('已调起打印,如未弹出请在打印对话框中选择正确打印机', 'info');
html +
'<scr' + 'ipt>window.onload=function(){setTimeout(function(){window.print()},300)}</scr' + 'ipt>' +
'</body></html>');
w.document.close();
} }
$('#btnPrintTable').addEventListener('click', () => { $('#btnPrintTable').addEventListener('click', () => {
if (!curPid()) return showAlert('请先选择问卷 / 考试'); if (!curPid()) return showAlert('请先选择问卷 / 考试');
const n = (state._sorted || []).length; const n = (state._sorted || []).length;
$('#ptHint').textContent = n ? ('当前将打印 ' + n + ' 条记录') : '暂无数据'; const a = (state._absentList || []).length;
$('#ptHint').textContent = '当前将打印 ' + n + ' 条成绩' + (a ? '(可附带 ' + a + ' 名缺考学生)' : '');
if (!$('#ptTitle').value.trim() && state.projectName) {
$('#ptTitle').value = state.projectName + ' 成绩明细';
}
$('#printTableModal').classList.add('open'); $('#printTableModal').classList.add('open');
}); });
$('#btnPtPrint').addEventListener('click', () => { $('#btnPtPrint').addEventListener('click', () => {
@@ -1640,54 +1680,6 @@
} catch (e) { showAlert(e.message); } } catch (e) { showAlert(e.message); }
}); });
/* 异常检测线索 */
function renderAnomaly() {
const a = state.anomalies || { fast_high: [], frequent: [], identical: [] };
const box = $('#anomalyBody');
const sec = [];
const empty = '<span class="muted">无</span>';
// 1) 短时长高得分
sec.push('<h4 style="margin:14px 0 6px">⚡ 用时异常短却得分高(' + a.fast_high.length + ')</h4>');
sec.push('<div class="table-scroll short"><table class="grid compact">' +
'<thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>成绩</th><th>用时</th></tr></thead><tbody>');
if (!a.fast_high.length) sec.push('<tr><td colspan="5" class="empty">' + empty + '</td></tr>');
else a.fast_high.forEach(x => {
sec.push('<tr><td class="sid">' + esc(x.sid) + '</td><td>' + esc(x.name) + '</td><td>' + esc(x.cls || '') +
'</td><td class="num">' + x.score + '</td><td class="num">' + esc(x.duration_txt || (x.duration + 's')) + '</td></tr>');
});
sec.push('</tbody></table></div>');
// 2) 高频重考
sec.push('<h4 style="margin:14px 0 6px">🔄 高频重考(' + a.frequent.length + ')</h4>');
sec.push('<div class="table-scroll short"><table class="grid compact">' +
'<thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>考试次数</th><th>最高分</th></tr></thead><tbody>');
if (!a.frequent.length) sec.push('<tr><td colspan="5" class="empty">' + empty + '</td></tr>');
else a.frequent.forEach(x => {
sec.push('<tr><td class="sid">' + esc(x.sid) + '</td><td>' + esc(x.name) + '</td><td>' + esc(x.cls || '') +
'</td><td class="num">' + x.attempts + '</td><td class="num">' + (x.best_score == null ? '-' : x.best_score) + '</td></tr>');
});
sec.push('</tbody></table></div>');
// 3) 雷同成绩
sec.push('<h4 style="margin:14px 0 6px">⚠️ 多人成绩雷同(' + a.identical.length + ' 组)</h4>');
sec.push('<div class="table-scroll short"><table class="grid compact">' +
'<thead><tr><th>成绩</th><th>人数</th><th>学生</th></tr></thead><tbody>');
if (!a.identical.length) sec.push('<tr><td colspan="3" class="empty">' + empty + '</td></tr>');
else a.identical.forEach(g => {
const names = g.members.map(m => m.name + '(' + m.sid + ')').join('、');
sec.push('<tr><td class="num">' + g.score + '</td><td class="num">' + g.count + '</td><td>' + esc(names) + '</td></tr>');
});
sec.push('</tbody></table></div>');
box.innerHTML = sec.join('');
}
$('#btnAnomaly').addEventListener('click', () => {
if (!curPid()) return showAlert('请先选择问卷 / 考试');
renderAnomaly();
$('#anomalyModal').classList.add('open');
});
/* 缺考名单增强:复制学号 / 复制姓名 / 打印 */ /* 缺考名单增强:复制学号 / 复制姓名 / 打印 */
function copyText(text, what) { function copyText(text, what) {
const done = () => showAlert(what + '已复制到剪贴板', 'info'); const done = () => showAlert(what + '已复制到剪贴板', 'info');
@@ -1714,20 +1706,47 @@
copyText(list.map(a => a.name).join('、'), '姓名'); copyText(list.map(a => a.name).join('、'), '姓名');
}); });
$('#btnPrintAbsent').addEventListener('click', () => { $('#btnPrintAbsent').addEventListener('click', () => {
const list = state._absentList || []; const list = (state._absentList || []).slice();
if (!list.length) return showAlert('当前没有缺考学生', 'warn'); if (!list.length) return showAlert('当前没有缺考学生', 'warn');
const rows = list.map(a =>
'<tr><td>' + esc(a.sid) + '</td><td>' + esc(a.name) + '</td><td>' + esc(a.cls || '') + '</td><td>' + esc(a.grp || '') + '</td></tr>').join(''); const sidCmp = (a, b) => String(a.sid || '').localeCompare(String(b.sid || ''), 'zh', { numeric: true });
const project = state.projectName || ''; list.sort((a, b) => {
const title = (project ? project + ' · ' : '') + '缺考名单(' + list.length + ' 人)'; const ac = String(a.cls || ''), bc = String(b.cls || '');
const w = window.open('', '_blank'); if (ac !== bc) return ac.localeCompare(bc, 'zh', { numeric: true });
if (!w) return showAlert('浏览器拦截了弹窗,请允许后重试', 'error'); return sidCmp(a, b);
w.document.write('<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>' + esc(title) + '</title>' + });
'<style>body{font-family:sans-serif;margin:24px}h2{margin:0 0 8px}table{border-collapse:collapse;width:100%}td,th{border:1px solid #333;padding:6px 10px;text-align:left;font-size:13px}th{background:#eee}</style></head><body>' +
'<h2>' + esc(title) + '</h2><p>打印时间:' + new Date().toLocaleString() + '</p>' + const proj = state.projectName || '';
'<table><thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>组别</th></tr></thead><tbody>' + rows + '</tbody></table>' + const title = (proj ? proj + ' · ' : '') + '缺考名单(' + list.length + ' 人)';
'<script>window.onload=function(){setTimeout(function(){window.print()},300)}<\/script></body></html>'); const rowHtml = r => '<tr><td>' + esc(r.sid || '') + '</td><td>' + esc(r.name || '') + '</td><td>' +
w.document.close(); esc(r.cls || '') + '</td><td class="c">' + esc(r.grp || '') + '</td><td></td></tr>';
// 按班级分组,班内按学号;无班级时不分组
const hasCls = list.some(r => String(r.cls || '') !== '');
let body = '';
if (hasCls) {
const groups = {};
list.forEach(r => { const k = String(r.cls || '未分班'); (groups[k] = groups[k] || []).push(r); });
Object.keys(groups).sort((a, b) => a.localeCompare(b, 'zh', { numeric: true })).forEach(k => {
body += '<h3 class="grp">' + esc(k) + ' <small>(' + groups[k].length + ' 人)</small></h3>' +
'<table><thead><tr><th style="width:96px">学号</th><th style="width:76px">姓名</th>' +
'<th style="width:70px">班级</th><th class="c" style="width:48px">组别</th><th>备注</th></tr></thead><tbody>' +
groups[k].map(rowHtml).join('') + '</tbody></table>';
});
} else {
body = '<table><thead><tr><th style="width:96px">学号</th><th style="width:76px">姓名</th>' +
'<th style="width:70px">班级</th><th class="c" style="width:48px">组别</th><th>备注</th></tr></thead><tbody>' +
list.map(rowHtml).join('') + '</tbody></table>';
}
const meta = '<h2>' + esc(title) + '</h2>' +
(proj ? '<div class="sub">' + esc(proj) + '</div>' : '') +
'<div class="meta">' + esc(printMetaLine(['共 ' + list.length + ' 人'])) + '</div>';
const sign = '<div class="sign">教师签字:____________  日期:____ 年 ____ 月 ____ 日</div>' +
'<div class="foot">' + esc(NY.appName || '') + ' · 缺考名单,请核对后签字确认</div>';
printDoc(title, meta + body + sign, { orient: 'portrait' });
showAlert('已调起打印,如未弹出请在打印对话框中选择正确打印机', 'info');
}); });
/* 启动 */ /* 启动 */
+61 -17
View File
@@ -35,6 +35,7 @@
log: [], // 历史批次,新的在前 log: [], // 历史批次,新的在前
customRatios: {}, // 自定义比例:{ 组名: 权重 } customRatios: {}, // 自定义比例:{ 组名: 权重 }
drawing: false, drawing: false,
all: false, // 「全部」模式:人数始终跟随当前候选池(手工改输入框即解除)
}; };
/* ---------------- 接口 ---------------- */ /* ---------------- 接口 ---------------- */
@@ -82,7 +83,7 @@
/** 兼容旧 showAlert 调用 → 走 toast(draw.js 内部已有 esc,此处直接复用) */ /** 兼容旧 showAlert 调用 → 走 toast(draw.js 内部已有 esc,此处直接复用) */
function showAlert(msg, type) { function showAlert(msg, type) {
if (msg === '' || msg == null) return; if (msg === '' || msg == null) return;
const t = type === 'info' ? 'ok' : (type === 'error' ? 'error' : ''); const t = type === 'info' ? 'ok' : (type === 'error' ? 'error' : (type === 'warn' ? 'warn' : ''));
showToast(msg, t); showToast(msg, t);
} }
@@ -345,6 +346,11 @@
function renderStats() { function renderStats() {
const pool = currentPool(); const pool = currentPool();
// 「全部」模式:候选池变化(改筛选 / 改来源 / 改排除)时人数跟着变
if (state.all) {
const ni = $('#numInput');
if (ni) ni.value = String(Math.max(1, pool.length));
}
$('#dPool').textContent = pool.length; $('#dPool').textContent = pool.length;
$('#dPicked').textContent = state.picked.length; $('#dPicked').textContent = state.picked.length;
$('#dHistory').textContent = state.history.size; $('#dHistory').textContent = state.history.size;
@@ -395,9 +401,11 @@
showAlert('当前筛选下没有可抽取的人员(可关闭「排除已抽中」、调整班级 / 组别或排除名单)'); showAlert('当前筛选下没有可抽取的人员(可关闭「排除已抽中」、调整班级 / 组别或排除名单)');
return; return;
} }
let n = parseInt($('#numInput').value, 10); // 「全部」模式:人数始终等于当前候选池,切换筛选后不会沿用旧数字
let n = state.all ? pool.length : parseInt($('#numInput').value, 10);
if (!n || n < 1) n = 1; if (!n || n < 1) n = 1;
if (n > pool.length) n = pool.length; let capped = false;
if (n > pool.length) { n = pool.length; capped = true; }
state.drawing = true; state.drawing = true;
const stage = $('#drawStage'); const stage = $('#drawStage');
@@ -437,9 +445,15 @@
picked.forEach(m => state.history.add(String(m.sid))); picked.forEach(m => state.history.add(String(m.sid)));
stage.className = 'draw-stage done'; stage.className = 'draw-stage done';
stage.textContent = n === 1 if (n === 1 && picked.length) {
? (picked[0].name || picked[0].sid) // 只抽 1 人时把学号 / 班级 / 组别一起显示出来,教师不用再去表里找
: ('🎉 已抽取 ' + n + ' 人'); const p0 = picked[0];
const meta = [p0.sid, p0.cls, p0.grp ? (p0.grp + '组') : ''].filter(Boolean).join(' · ');
stage.innerHTML = '<div>' + esc(p0.name || p0.sid) +
(meta ? '<div class="draw-stage-sub">' + esc(meta) + '</div>' : '') + '</div>';
} else {
stage.textContent = '🎉 已抽取 ' + n + ' 人';
}
renderResult(); renderResult();
renderStats(); renderStats();
@@ -449,24 +463,35 @@
state.drawing = false; state.drawing = false;
btn.disabled = false; btn.disabled = false;
const tail = detail.length ? '(' + detail.join(' / ') + ')' : ''; const tail = detail.length ? '(' + detail.join(' / ') + ')' : '';
if (capped) {
showAlert('候选池只有 ' + pool.length + ' 人,已按 ' + n + ' 人抽取(可关闭「排除已抽中」或调整筛选扩大候选)', 'warn');
} else {
showAlert('已抽取 ' + n + ' 人' + tail + (n > 1 ? ',可点击右上角按钮下载白名单' : ''), 'info'); showAlert('已抽取 ' + n + ' 人' + tail + (n > 1 ? ',可点击右上角按钮下载白名单' : ''), 'info');
} }
}
function renderResult() { function renderResult() {
const tb = $('#drawBody'); const tb = $('#drawBody');
if (!state.picked.length) { if (!state.picked.length) {
tb.innerHTML = '<tr><td colspan="6" class="empty">尚未抽取</td></tr>'; tb.innerHTML = '<tr><td colspan="7" class="empty">尚未抽取</td></tr>';
return; return;
} }
tb.innerHTML = state.picked.map((m, i) => tb.innerHTML = state.picked.map((m, i) => {
'<tr>' + // 登陆账号为空 → 导出会回退到学号,这里显式标红提示教师先去名单里补全
const login = String(m.login || '');
const loginCell = login
? esc(login)
: '<span class="muted" title="名单未填登陆账号,导出将回退用学号">' + esc(m.sid || '') + ' ⚠</span>';
return '<tr>' +
'<td class="num">' + (i + 1) + '</td>' + '<td class="num">' + (i + 1) + '</td>' +
'<td class="sid">' + esc(m.sid) + '</td>' + '<td class="sid">' + esc(m.sid) + '</td>' +
'<td class="name">' + esc(m.name) + '</td>' + '<td class="name">' + esc(m.name) + '</td>' +
'<td>' + esc(m.cls || '') + '</td>' + '<td>' + esc(m.cls || '') + '</td>' +
'<td>' + esc(m.grp || '') + '</td>' + '<td>' + esc(m.grp || '') + '</td>' +
'<td class="sid">' + loginCell + '</td>' +
'<td><button class="btn sm danger" data-drop="' + i + '" title="移出结果并加入排除名单">排除</button></td>' + '<td><button class="btn sm danger" data-drop="' + i + '" title="移出结果并加入排除名单">排除</button></td>' +
'</tr>').join(''); '</tr>';
}).join('');
} }
/* ================= 历史记录 ================= */ /* ================= 历史记录 ================= */
@@ -627,6 +652,7 @@
cls: $('#clsSel').value, cls: $('#clsSel').value,
grp: $('#grpSel').value, grp: $('#grpSel').value,
num: $('#numInput').value, num: $('#numInput').value,
all: state.all,
exclude: $('#excludePicked').checked, exclude: $('#excludePicked').checked,
mode: $('#balanceMode').value, mode: $('#balanceMode').value,
customRatios: cr, customRatios: cr,
@@ -700,6 +726,8 @@
}); });
}); });
$('#numInput').addEventListener('change', saveSettings); $('#numInput').addEventListener('change', saveSettings);
// 手工改人数即解除「全部」模式
$('#numInput').addEventListener('input', () => { state.all = false; });
$('#excludePicked').addEventListener('change', () => { renderStats(); saveSettings(); }); $('#excludePicked').addEventListener('change', () => { renderStats(); saveSettings(); });
$('#balanceMode').addEventListener('change', () => { $('#balanceMode').addEventListener('change', () => {
renderRatioBox(); renderRatioBox();
@@ -718,9 +746,10 @@
b.addEventListener('click', () => { b.addEventListener('click', () => {
const v = b.dataset.num; const v = b.dataset.num;
if (v === 'all') { if (v === 'all') {
const pool = currentPool(); state.all = true;
$('#numInput').value = String(Math.max(1, pool.length)); $('#numInput').value = String(Math.max(1, currentPool().length));
} else { } else {
state.all = false;
$('#numInput').value = v; $('#numInput').value = v;
} }
saveSettings(); saveSettings();
@@ -865,11 +894,23 @@
} }
}); });
/* --- 弹窗通用关闭 --- */ /* --- 弹窗通用关闭:只关最上层那一个,避免连带关掉下层的弹窗 --- */
document.addEventListener('click', e => { document.addEventListener('click', e => {
if (e.target.matches('[data-close]') || e.target.classList.contains('modal')) { const isClose = (e.target.matches && e.target.matches('[data-close]')) ||
$$('.modal').forEach(m => m.classList.remove('open')); (e.target.classList && e.target.classList.contains('modal'));
} if (!isClose) return;
const open = $$('.modal.open');
if (!open.length) return;
const top = open[open.length - 1];
// 点遮罩时只响应最上层弹窗自身的遮罩
if (e.target.classList && e.target.classList.contains('modal') && e.target !== top) return;
top.classList.remove('open');
});
/* Esc 关闭最上层弹窗 */
document.addEventListener('keydown', e => {
if (e.key !== 'Escape' && e.key !== 'Esc') return;
const open = $$('.modal.open');
if (open.length) open[open.length - 1].classList.remove('open');
}); });
} }
@@ -885,7 +926,9 @@
const mode = $('#balanceMode').value; const mode = $('#balanceMode').value;
const show = (mode === 'custom') && g.key; const show = (mode === 'custom') && g.key;
box.classList.toggle('hidden', !show); box.classList.toggle('hidden', !show);
if (!show) return; // 隐藏时必须清空:否则旧的 data-gk 输入框还会被 saveSettings 读走,
// 下次切成自定义比例时会误用上一批分组的权重
if (!show) { box.innerHTML = ''; return; }
box.innerHTML = box.innerHTML =
'<p class="hint" style="margin:0 0 6px">按上面分组的顺序填写权重,例:组A=3,组B=1。留空=不计该组。</p>' + '<p class="hint" style="margin:0 0 6px">按上面分组的顺序填写权重,例:组A=3,组B=1。留空=不计该组。</p>' +
g.gk.map((k, i) => { g.gk.map((k, i) => {
@@ -912,6 +955,7 @@
const mode = s.mode || (s.balance === false ? 'off' : 'weight'); const mode = s.mode || (s.balance === false ? 'off' : 'weight');
$('#balanceMode').value = mode; $('#balanceMode').value = mode;
if (s.num) $('#numInput').value = s.num; if (s.num) $('#numInput').value = s.num;
state.all = !!s.all;
if (s.source === 'project') { if (s.source === 'project') {
state.source = 'project'; state.source = 'project';
$('#sourceSel').value = 'project'; $('#sourceSel').value = 'project';
+44 -1
View File
@@ -162,6 +162,24 @@ select, input[type=text], input[type=number], input[type=password] {
select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47,107,255,.12); } select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47,107,255,.12); }
.field.btns select { width: 100px; } .field.btns select { width: 100px; }
/* ---------- 副工具栏:导出等次要操作独立成行,避免挤在主工具栏里 ---------- */
.subbar {
display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
margin-top: 10px; padding: 10px 14px;
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow);
}
.subbar-label {
font-size: 12px; font-weight: 700; color: var(--text-3); letter-spacing: .5px;
padding-right: 12px; border-right: 1px solid var(--border);
}
.subbar-item {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--text-2); cursor: pointer; white-space: nowrap;
}
.subbar-item input[type=checkbox] { width: auto; height: auto; margin: 0; cursor: pointer; }
.subbar-item select { width: auto; min-width: 112px; height: 30px; padding: 0 8px; font-size: 12px; }
.switch { position: relative; display: inline-block; width: 40px; height: 22px; flex: none; } .switch { position: relative; display: inline-block; width: 40px; height: 22px; flex: none; }
.switch input { opacity: 0; width: 0; height: 0; } .switch input { opacity: 0; width: 0; height: 0; }
.switch span { .switch span {
@@ -304,6 +322,14 @@ table.grid.compact td, table.grid.compact th { padding: 7px 12px; }
td.empty { text-align: center; color: var(--text-3); padding: 32px 0; } td.empty { text-align: center; color: var(--text-3); padding: 32px 0; }
td.num { font-variant-numeric: tabular-nums; white-space: nowrap; } td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
td.sid { font-weight: 600; color: var(--text-2); font-variant-numeric: tabular-nums; } 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; }
table.grid thead th[data-k="score"],
table.grid thead th[data-k="attempts"],
table.grid thead th[data-k="submit_ts"],
table.grid thead th[data-k="duration"] { text-align: right; }
table.grid td.score { font-weight: 700; font-size: 14px; }
table.grid td.name { font-weight: 600; }
td.name { font-weight: 600; } td.name { font-weight: 600; }
td.score { font-weight: 700; font-variant-numeric: tabular-nums; } td.score { font-weight: 700; font-variant-numeric: tabular-nums; }
.score-high { color: var(--ok); } .score-high { color: var(--ok); }
@@ -565,8 +591,20 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
.dash-hero .brand-title { color: #fff; font-size: 17px; } .dash-hero .brand-title { color: #fff; font-size: 17px; }
.dash-hero a.back { color: #fff; opacity: .9; font-size: 13px; text-decoration: none; font-weight: 600; } .dash-hero a.back { color: #fff; opacity: .9; font-size: 13px; text-decoration: none; font-weight: 600; }
.dash-hero a.back:hover { opacity: 1; text-decoration: underline; } .dash-hero a.back:hover { opacity: 1; text-decoration: underline; }
.dash-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .dash-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.dash-controls select { width: 230px; height: 38px; } .dash-controls select { width: 230px; height: 38px; }
/* 看板顶栏的「主系统 / 退出」用描边区分于同排的半透明操作按钮 */
.dash-controls .btn.back {
background: transparent; border-color: rgba(255, 255, 255, .45); font-weight: 600;
}
.dash-controls .btn.back:hover { background: rgba(255, 255, 255, .18); border-color: #fff; }
/* 窄屏:下拉与按钮换行铺开,避免 230px 固定宽把顶栏撑出横向滚动条 */
@media (max-width: 860px) {
.dash-hero { padding: 14px 16px; }
.dash-controls { width: 100%; gap: 8px; }
.dash-controls select { width: auto; flex: 1 1 160px; min-width: 0; }
.dash-controls .btn { flex: 0 0 auto; }
}
.dash-controls .inline { color: #fff; } .dash-controls .inline { color: #fff; }
.dash-controls .btn { background: rgba(255, 255, 255, .14); border-color: transparent; color: #fff; } .dash-controls .btn { background: rgba(255, 255, 255, .14); border-color: transparent; color: #fff; }
.dash-controls .btn:hover { background: rgba(255, 255, 255, .24); } .dash-controls .btn:hover { background: rgba(255, 255, 255, .24); }
@@ -689,6 +727,11 @@ body.dash.fs-on .member-list { max-height: 30vh; }
background: linear-gradient(180deg, #f2fbf5, #e9f7ee); background: linear-gradient(180deg, #f2fbf5, #e9f7ee);
font-size: 26px; font-size: 26px;
} }
/* 只抽 1 人时的副行(学号 · 班级 · 组别),字号小于主名字 */
.draw-stage-sub {
margin-top: 8px; font-size: 14px; font-weight: 600;
color: var(--text-2); letter-spacing: 0;
}
@keyframes drawShake { @keyframes drawShake {
from { transform: translateY(-1px) scale(.995); } from { transform: translateY(-1px) scale(.995); }
to { transform: translateY(1px) scale(1.005); } to { transform: translateY(1px) scale(1.005); }
+12 -50
View File
@@ -9,7 +9,14 @@ const NY_APP_NAME = 'NY问卷考试系统数据平台';
const NY_VERSION = '1.0.0'; const NY_VERSION = '1.0.0';
define('NY_ROOT', str_replace('\\', '/', __DIR__)); define('NY_ROOT', str_replace('\\', '/', __DIR__));
define('NY_DATA', NY_ROOT . '/data'); // 数据目录默认指向项目下的 data/;测试可通过 NY_DATA 环境变量整体重定向到临时目录,
// 避免 e2e 把 mock 服务的连接配置写进生产库(曾因此把生产 base_url 改成 127.0.0.1 导致登录失败)。
$nyData = NY_ROOT . '/data';
$nyDataEnv = getenv('NY_DATA');
if ($nyDataEnv !== false && $nyDataEnv !== '') {
$nyData = rtrim(str_replace('\\', '/', $nyDataEnv), '/');
}
define('NY_DATA', $nyData);
// ---------------- 环境预检 ---------------- // ---------------- 环境预检 ----------------
if (PHP_VERSION_ID < 80000) { if (PHP_VERSION_ID < 80000) {
@@ -141,53 +148,9 @@ function ny_csrf_ok(): bool
return $given !== '' && hash_equals($token, $given); return $given !== '' && hash_equals($token, $given);
} }
// ---------------- 登录失败限流 ---------------- // ---------------- 登录失败限流(已移除) ----------------
/** // 注:单机内部工具场景下,5 次失败锁 10 分钟的限制常因配置写错/测环境串味而把正常用户挡在门外,
* 登录失败限流:按 IP 记录连续失败次数与时间戳(写入 data/login_fail.json)。 // 且无实际爆破价值(仅内网使用)。应需求已整体移除:登录不再限流,失败只即时提示原因。
* 5 次失败后锁 10 分钟。仅防御暴力破解,不做超强语义(单机小工具足够)。
*/
function ny_login_throttle_check(): array
{
$path = NY_DATA . '/login_fail.json';
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown');
$rec = ['count' => 0, 'until' => 0];
if (is_file($path)) {
$j = json_decode((string)file_get_contents($path), true);
if (is_array($j) && isset($j[$ip])) {
$rec = array_merge($rec, $j[$ip]);
}
}
return $rec;
}
function ny_login_throttle_hit(): void
{
$path = NY_DATA . '/login_fail.json';
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown');
$all = is_file($path) ? (json_decode((string)file_get_contents($path), true) ?: []) : [];
$rec = isset($all[$ip]) ? $all[$ip] : ['count' => 0, 'until' => 0];
$rec['count'] = ((int)($rec['count'] ?? 0)) + 1;
if ($rec['count'] >= 5) {
$rec['count'] = 0; // 重新计数,进入冷却
$rec['until'] = time() + 600; // 锁 10 分钟
}
$all[$ip] = $rec;
@file_put_contents($path, json_encode($all, JSON_UNESCAPED_UNICODE), LOCK_EX);
}
function ny_login_throttle_reset(): void
{
$path = NY_DATA . '/login_fail.json';
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown');
if (!is_file($path)) {
return;
}
$all = json_decode((string)file_get_contents($path), true) ?: [];
if (isset($all[$ip])) {
unset($all[$ip]);
@file_put_contents($path, json_encode($all, JSON_UNESCAPED_UNICODE), LOCK_EX);
}
}
/** 判断是否已安装(只看文件 config:base_url 非空即视为已具备连接配置) */ /** 判断是否已安装(只看文件 config:base_url 非空即视为已具备连接配置) */
function ny_is_installed(): bool function ny_is_installed(): bool
@@ -200,7 +163,7 @@ function ny_is_installed(): bool
} }
// ---------------- 目录初始化 ---------------- // ---------------- 目录初始化 ----------------
foreach (['', '/cache', '/uploads', '/snapshots'] as $sub) { foreach (['', '/cache', '/uploads'] as $sub) {
$dir = NY_DATA . $sub; $dir = NY_DATA . $sub;
if (!is_dir($dir)) { if (!is_dir($dir)) {
@mkdir($dir, 0777, true); @mkdir($dir, 0777, true);
@@ -499,7 +462,6 @@ spl_autoload_register(function (string $class): void {
'Roster' => NY_ROOT . '/lib/Roster.php', 'Roster' => NY_ROOT . '/lib/Roster.php',
'DataService' => NY_ROOT . '/lib/DataService.php', 'DataService' => NY_ROOT . '/lib/DataService.php',
'DataCache' => NY_ROOT . '/lib/DataCache.php', 'DataCache' => NY_ROOT . '/lib/DataCache.php',
'Snapshot' => NY_ROOT . '/lib/Snapshot.php',
'Db' => NY_ROOT . '/lib/Db.php', 'Db' => NY_ROOT . '/lib/Db.php',
]; ];
if (isset($map[$class]) && is_file($map[$class])) { if (isset($map[$class]) && is_file($map[$class])) {
+65 -20
View File
@@ -40,6 +40,8 @@ if (!ny_is_auth()) {
</select> </select>
<button class="btn" id="btnRefresh">刷新</button> <button class="btn" id="btnRefresh">刷新</button>
<button class="btn" id="btnFullscreen" title="切换全屏">⛶ 全屏</button> <button class="btn" id="btnFullscreen" title="切换全屏">⛶ 全屏</button>
<a class="btn back" href="index.php" title="返回主系统(成绩明细 / 名单 / 抽取)">← 主系统</a>
<a class="btn back" href="logout.php" title="退出登录">退出</a>
</div> </div>
</div> </div>
@@ -237,13 +239,23 @@ function membersTable(members){
return '<div class="member-list"><table class="grid compact">' + return '<div class="member-list"><table class="grid compact">' +
'<thead><tr><th style="width:60px">排名</th><th>姓名</th><th style="width:80px">成绩</th></tr></thead><tbody>' + '<thead><tr><th style="width:60px">排名</th><th>姓名</th><th style="width:80px">成绩</th></tr></thead><tbody>' +
members.map(m => { members.map(m => {
const rk = m.rank; 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')); const badge = rk === 1 ? 'rk rk-1' : (rk === 2 ? 'rk rk-2' : (rk === 3 ? 'rk rk-3' : 'rk'));
const sc = m.score; const sc = m.score;
const cls = sc == null ? '' : (sc >= 90 ? 'score-high' : (sc >= 60 ? 'score-mid' : 'score-low')); // 同样按「占满分的百分比」着色,满分不是 100 时不会整列误标红
const cls = (sc == null) ? '' : (function () {
const full = (window.DASH && DASH.full_score) ? Number(DASH.full_score) : 100;
if (!(full > 0)) return '';
const pct = Number(sc) / full * 100;
if (!isFinite(pct)) return '';
const pass = (window.DASH && DASH.pass_rate != null) ? Number(DASH.pass_rate) : 60;
return pct >= 90 ? 'score-high' : (pct >= pass ? 'score-mid' : 'score-low');
})();
const att = (m.attempts || 1) > 1; const att = (m.attempts || 1) > 1;
const tag = att ? ' <span class="tag tag-att" title="查看 ' + m.attempts + ' 次提交明细">刷' + m.attempts + '次</span>' : ''; const tag = att ? ' <span class="tag tag-att" title="查看 ' + m.attempts + ' 次提交明细">刷' + m.attempts + '次</span>' : '';
return '<tr' + (att ? ' class="clickable" onclick="openAttempts(' + JSON.stringify(m.sid) + ')"' : '') + '>' + // 注意:不要把 sid 直接拼进 onclick="...",JSON.stringify 产生的双引号会提前闭合
// HTML 属性(onclick="openAttempts("123")"),点击必然报错。改用 data-sid + 事件委托。
return '<tr' + (att ? ' class="clickable" data-sid="' + esc(m.sid) + '" title="查看提交明细"' : '') + '>' +
'<td class="num"><span class="' + badge + '">' + rk + '</span></td>' + '<td class="num"><span class="' + badge + '">' + rk + '</span></td>' +
'<td class="name">' + esc(m.name) + tag + '</td>' + '<td class="name">' + esc(m.name) + tag + '</td>' +
'<td class="score num ' + cls + '">' + (sc == null ? '-' : sc) + '</td>' + '<td class="score num ' + cls + '">' + (sc == null ? '-' : sc) + '</td>' +
@@ -276,6 +288,22 @@ function closeAttempts(){
const m = document.querySelector('#attemptsModal'); const m = document.querySelector('#attemptsModal');
if (m) m.classList.remove('open'); if (m) m.classList.remove('open');
} }
// 成员榜下钻:事件委托(#boardBody 常驻,内部表格会被整体重绘)
(function bindMemberDrill(){
const box = document.querySelector('#boardBody');
if (!box) return;
box.addEventListener('click', e => {
const tr = e.target.closest ? e.target.closest('tr[data-sid]') : null;
if (!tr || !box.contains(tr)) return;
openAttempts(tr.getAttribute('data-sid'));
});
})();
// Esc 关闭最上层弹窗
document.addEventListener('keydown', e => {
if (e.key !== 'Escape' && e.key !== 'Esc') return;
const open = Array.prototype.slice.call(document.querySelectorAll('.modal.open'));
if (open.length) open[open.length - 1].classList.remove('open');
});
/* ---------- 看板占位(未选问卷/班级) ---------- */ /* ---------- 看板占位(未选问卷/班级) ---------- */
function renderPlaceholderBoard(){ function renderPlaceholderBoard(){
@@ -302,7 +330,8 @@ function markStale(msg){
const d = $('#liveDot'); const d = $('#liveDot');
if (d) { d.classList.remove('on'); d.classList.add('stale'); } if (d) { d.classList.remove('on'); d.classList.add('stale'); }
const lu = $('#lastUpdate'); const lu = $('#lastUpdate');
if (lu) lu.textContent = '刷新中断(仍显示上次数据)'; // 具体原因放进 title(鼠标悬停可见),正文保持简短
if (lu) { lu.textContent = '刷新中断(仍显示上次数据)'; if (msg) lu.title = String(msg); }
} }
function handleAuthFail(){ function handleAuthFail(){
$('#boardBody').innerHTML = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>'; $('#boardBody').innerHTML = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
@@ -310,6 +339,10 @@ function handleAuthFail(){
} }
/* ---------- 渲染单班看板 ---------- */ /* ---------- 渲染单班看板 ---------- */
/** 数值兜底:后端字段缺失时显示占位符,避免界面出现 NaN / undefined */
function dv(v, dash){ return (v === undefined || v === null || v === '' || Number.isNaN(v)) ? (dash || '-') : v; }
function pv(v){ return dv(v, '-') + (v === undefined || v === null || v === '' ? '' : '%'); }
function renderBoard(){ function renderBoard(){
const pid = $('#projectSel').value; const pid = $('#projectSel').value;
const cls = $('#classSel').value; const cls = $('#classSel').value;
@@ -345,25 +378,25 @@ function renderBoard(){
const totalAttempts = (overall.attempts_total || 0); const totalAttempts = (overall.attempts_total || 0);
const _hs2 = document.getElementById('heroSub'); const _hs2 = document.getElementById('heroSub');
if (_hs2) _hs2.textContent = cls + ' · 问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' + if (_hs2) _hs2.textContent = cls + ' · 问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' +
'总 ' + c.total + ' / 已交 ' + c.submitted + ' / 缺考 ' + c.absent + '总 ' + dv(c.total, 0) + ' / 已交 ' + dv(c.submitted, 0) + ' / 缺考 ' + dv(c.absent, 0) +
(totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : ''); (totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : '');
// 6 卡汇总 // 6 卡汇总
const color = rateColor(c.rate || 0); const color = rateColor(c.rate || 0);
$('#boardSummary').innerHTML = $('#boardSummary').innerHTML =
'<div class="card accent-blue"><span>总人数</span><b>' + c.total + '</b></div>' + '<div class="card accent-blue"><span>总人数</span><b>' + dv(c.total, 0) + '</b></div>' +
'<div class="card ok accent-green"><span>已提交</span><b>' + c.submitted + '</b></div>' + '<div class="card ok accent-green"><span>已提交</span><b>' + dv(c.submitted, 0) + '</b></div>' +
'<div class="card warn accent-orange"><span>缺考</span><b>' + c.absent + '</b></div>' + '<div class="card warn accent-orange"><span>缺考</span><b>' + dv(c.absent, 0) + '</b></div>' +
'<div class="card accent-purple"><span>提交率</span><b style="color:' + color + '">' + c.rate + '%</b></div>' + '<div class="card accent-purple"><span>提交率</span><b style="color:' + color + '">' + dv(c.rate, 0) + '%</b></div>' +
'<div class="card accent-teal"><span>平均分</span><b>' + (c.avg != null ? c.avg : '-') + '</b></div>' + '<div class="card accent-teal"><span>平均分</span><b>' + dv(c.avg) + '</b></div>' +
'<div class="card accent-indigo"><span>合格率(≥' + (DASH && DASH.pass_rate != null ? DASH.pass_rate : 60) + '%满分)</span><b>' + c.pass_rate + '%</b></div>'; '<div class="card accent-indigo"><span>合格率(≥' + (DASH && DASH.pass_rate != null ? DASH.pass_rate : 60) + '%满分)</span><b>' + pv(c.pass_rate) + '</b></div>';
const groups = (c.groups || []).filter(g => g.group !== '(未分组)'); const groups = (c.groups || []).filter(g => g.group !== '(未分组)');
const box = $('#boardBody'); const box = $('#boardBody');
if (!groups.length) { if (!groups.length) {
box.innerHTML = '<div class="gpanel">' + box.innerHTML = '<div class="gpanel">' +
'<div class="ghead"><strong>本班考试成员</strong>' + '<div class="ghead"><strong>本班考试成员</strong>' +
'<span class="rate-num">' + c.submitted + ' 人</span></div>' + '<span class="rate-num">' + dv(c.submitted, 0) + ' 人</span></div>' +
'<div class="sub">该班级未设置分组(A/B),以下按成绩从高到低排列' + '<div class="sub">该班级未设置分组(A/B),以下按成绩从高到低排列' +
(c.attempts > c.submitted ? ' · 共 ' + c.attempts + ' 次刷题' : '') + '</div>' + (c.attempts > c.submitted ? ' · 共 ' + c.attempts + ' 次刷题' : '') + '</div>' +
membersTable(c.members) + '</div>'; membersTable(c.members) + '</div>';
@@ -372,17 +405,17 @@ function renderBoard(){
const gc = rateColor(g.rate || 0); const gc = rateColor(g.rate || 0);
return '<div class="gpanel">' + return '<div class="gpanel">' +
'<div class="ghead"><strong>' + esc(g.group) + ' 组</strong>' + '<div class="ghead"><strong>' + esc(g.group) + ' 组</strong>' +
'<span class="rate-num" style="color:' + gc + '">' + g.rate + '%</span></div>' + '<span class="rate-num" style="color:' + gc + '">' + dv(g.rate, 0) + '%</span></div>' +
'<div class="sub">已提交 ' + g.submitted + ' / 总人数 ' + g.total + ' · 缺考 ' + g.absent + '<div class="sub">已提交 ' + dv(g.submitted, 0) + ' / 总人数 ' + dv(g.total, 0) + ' · 缺考 ' + dv(g.absent, 0) +
(g.attempts > g.submitted ? ' · 共 ' + g.attempts + ' 次刷题' : '') + '</div>' + (g.attempts > g.submitted ? ' · 共 ' + g.attempts + ' 次刷题' : '') + '</div>' +
'<div class="bar"><i style="width:' + g.rate + '%;background:' + gc + '"></i></div>' + '<div class="bar"><i style="width:' + dv(g.rate, 0) + '%;background:' + gc + '"></i></div>' +
'<div class="class-metrics">' + '<div class="class-metrics">' +
'<div class="m"><span>平均分</span><b>' + (g.avg != null ? g.avg : '-') + '</b></div>' + '<div class="m"><span>平均分</span><b>' + dv(g.avg) + '</b></div>' +
'<div class="m"><span>合格率</span><b>' + g.pass_rate + '%</b></div>' + '<div class="m"><span>合格率</span><b>' + pv(g.pass_rate) + '</b></div>' +
'<div class="m metric-ok"><span>最高分</span><b>' + (g.max != null ? g.max : '-') + '</b></div>' + '<div class="m metric-ok"><span>最高分</span><b>' + dv(g.max) + '</b></div>' +
'<div class="m metric-danger"><span>最低分</span><b>' + (g.min != null ? g.min : '-') + '</b></div>' + '<div class="m metric-danger"><span>最低分</span><b>' + dv(g.min) + '</b></div>' +
'</div>' + '</div>' +
'<div class="mem-title">考试成员 · 按成绩降序(' + g.submitted + ' 人)</div>' + '<div class="mem-title">考试成员 · 按成绩降序(' + dv(g.submitted, 0) + ' 人)</div>' +
membersTable(g.members) + membersTable(g.members) +
'</div>'; '</div>';
}).join('') + '</div>'; }).join('') + '</div>';
@@ -429,6 +462,8 @@ function schedule(){
async function refreshBoard(manual){ async function refreshBoard(manual){
const pid = $('#projectSel').value; const pid = $('#projectSel').value;
if (!pid) return; if (!pid) return;
// 标签页在后台时不做自动轮询(省流量、也避免无人值守时反复请求卷王)
if (!manual && document.hidden) return;
const btn = $('#btnRefresh'); const btn = $('#btnRefresh');
if (manual && btn) { btn.disabled = true; btn.textContent = '刷新中…'; } if (manual && btn) { btn.disabled = true; btn.textContent = '刷新中…'; }
try { try {
@@ -474,6 +509,16 @@ $('#btnFullscreen').addEventListener('click', toggleFullscreen);
document.addEventListener('fullscreenchange', onFsChange); document.addEventListener('fullscreenchange', onFsChange);
document.addEventListener('webkitfullscreenchange', onFsChange); document.addEventListener('webkitfullscreenchange', onFsChange);
/* 切回前台:立刻补一次数据并重排定时器;切到后台:停掉轮询 */
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (timer) { clearInterval(timer); timer = null; }
} else {
refreshBoard();
schedule();
}
});
/* ---------- 偏好恢复(自动刷新开关 + 间隔,含 ?interval= 深链) ---------- */ /* ---------- 偏好恢复(自动刷新开关 + 间隔,含 ?interval= 深链) ---------- */
function restoreDashPrefs(){ function restoreDashPrefs(){
const qs = new URLSearchParams(location.search); const qs = new URLSearchParams(location.search);
+3 -4
View File
@@ -1,8 +1,8 @@
import { defineConfig } from 'vitepress' import { defineConfig } from 'vitepress'
// 部署到子路径时,构建前设置环境变量即可: // 站点部署在域名的 /ny-exam-platform 子路径下,故 base 默认为该路径。
// DOCS_BASE=/ny-exam-docs/ npm run docs:build // 本地预览时用 DOCS_BASE=/ 覆盖:DOCS_BASE=/ npm run docs:build
const base = process.env.DOCS_BASE || '/' const base = process.env.DOCS_BASE || '/ny-exam-platform/'
// 输出目录。默认 .vitepress/dist;若需要保留旧产物(或环境禁止删除目录), // 输出目录。默认 .vitepress/dist;若需要保留旧产物(或环境禁止删除目录),
// 可用 DOCS_OUT=.vitepress/dist2 npm run docs:build 输出到新目录。 // 可用 DOCS_OUT=.vitepress/dist2 npm run docs:build 输出到新目录。
const outDir = process.env.DOCS_OUT || '.vitepress/dist' const outDir = process.env.DOCS_OUT || '.vitepress/dist'
@@ -72,7 +72,6 @@ export default defineConfig({
{ text: '名单管理', link: '/guide/roster' }, { text: '名单管理', link: '/guide/roster' },
{ text: '成绩明细与统计口径', link: '/guide/scores' }, { text: '成绩明细与统计口径', link: '/guide/scores' },
{ text: '试卷设置(满分/合格率/免考)', link: '/guide/project-config' }, { text: '试卷设置(满分/合格率/免考)', link: '/guide/project-config' },
{ text: '快照、趋势与异常检测', link: '/guide/snapshot' },
{ text: '导出与打印', link: '/guide/export-print' } { text: '导出与打印', link: '/guide/export-print' }
] ]
}, },
+2 -12
View File
@@ -16,7 +16,7 @@
## 写操作白名单 ## 写操作白名单
`config_save`、`roster_save`、`roster_import`、`roster_clear`、 `config_save`、`roster_save`、`roster_import`、`roster_clear`、
`snapshot_save`、`snapshot_delete`、`unmatched_map`、`project_config_save` `unmatched_map`、`project_config_save`
## 接口清单 ## 接口清单
@@ -38,7 +38,7 @@
| Action | 方法 | 参数 | 说明 | | Action | 方法 | 参数 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `projects` | GET | — | 卷王项目(问卷 / 考试)列表 | | `projects` | GET | — | 卷王项目(问卷 / 考试)列表 |
| `data` | GET | `projectId`、`nameQid`、`force` | 主系统数据:统计、成绩行、名单、缺考、未匹配、异常 | | `data` | GET | `projectId`、`nameQid`、`force` | 主系统数据:统计、成绩行、名单、缺考、未匹配 |
| `dashboard` | GET | `projectId`、`nameQid`、`force` | 按班级 / 组别聚合的看板数据 | | `dashboard` | GET | `projectId`、`nameQid`、`force` | 按班级 / 组别聚合的看板数据 |
`data` 响应关键字段: `data` 响应关键字段:
@@ -50,7 +50,6 @@ rows[] { sid, name, cls, grp, score, best_score, attempts, attempts_list[]
roster[] { sid, name, cls, grp, login } roster[] { sid, name, cls, grp, login }
absent[] 缺考名单 absent[] 缺考名单
unmatched[] 未匹配答卷 unmatched[] 未匹配答卷
anomalies { fast_high[], frequent[], identical[] }
project_cfg 本卷配置 project_cfg 本卷配置
_cache { cached, at } _cache { cached, at }
``` ```
@@ -72,15 +71,6 @@ _cache { cached, at }
| `project_config` | GET | `projectId` | 返回 `cfg`、`effective`(生效值)、`global`(全局默认) | | `project_config` | GET | `projectId` | 返回 `cfg`、`effective`(生效值)、`global`(全局默认) |
| `project_config_save` | POST | JSON `{ full_score, pass_rate, exempt[] }` | 留空/0 = 清除该项,回落全局 | | `project_config_save` | POST | JSON `{ full_score, pass_rate, exempt[] }` | 留空/0 = 清除该项,回落全局 |
### 快照
| Action | 方法 | 参数 | 说明 |
| --- | --- | --- | --- |
| `snapshot_save` | POST | `projectId`、JSON `{ label }` | 保存当前成绩快照 |
| `snapshot_list` | GET | `projectId` | 列表 |
| `snapshot_delete` | POST | `projectId`、JSON `{ file }` | 删除 |
| `snapshot_trend` | GET | `projectId`(可逗号分隔多个,跨卷对比) | 趋势 |
### 导出 ### 导出
| Action | 方法 | 参数 | 说明 | | Action | 方法 | 参数 | 说明 |
+3 -5
View File
@@ -19,11 +19,10 @@ ny-exam-platform/
├─ bootstrap.php 常量、自动加载、配置读写、CSRF、限流、公共函数 ├─ bootstrap.php 常量、自动加载、配置读写、CSRF、限流、公共函数
├─ lib/ ├─ lib/
│ ├─ SurveyKingClient.php 卷王 API 客户端(登录 / 令牌 / 项目 / 答卷) │ ├─ SurveyKingClient.php 卷王 API 客户端(登录 / 令牌 / 项目 / 答卷)
│ ├─ DataService.php 数据组装:姓名→学号匹配、统计、去重、异常检测 │ ├─ DataService.php 数据组装:姓名→学号匹配、统计、去重
│ ├─ Roster.php 名单管理(xlsx/csv 导入、列识别、持久化) │ ├─ Roster.php 名单管理(xlsx/csv 导入、列识别、持久化)
│ ├─ Db.php SQLite 封装(三方言兼容的 DDL 与 upsert) │ ├─ Db.php SQLite 封装(三方言兼容的 DDL 与 upsert)
│ ├─ DataCache.php 结果缓存(TTL + flock 防惊群) │ ├─ DataCache.php 结果缓存(TTL + flock 防惊群)
│ ├─ Snapshot.php 成绩快照与趋势
│ ├─ XlsxReader.php 零依赖 XLSX 读取 │ ├─ XlsxReader.php 零依赖 XLSX 读取
│ └─ XlsxWriter.php 零依赖 XLSX 写入(支持多工作表) │ └─ XlsxWriter.php 零依赖 XLSX 写入(支持多工作表)
├─ assets/ style.css / app.js / draw.js ├─ assets/ style.css / app.js / draw.js
@@ -33,8 +32,7 @@ ny-exam-platform/
│ ├─ roster.json 名单(文件备份) │ ├─ roster.json 名单(文件备份)
│ ├─ project_config.json 每卷配置(文件备份) │ ├─ project_config.json 每卷配置(文件备份)
│ ├─ cache/ 卷王令牌 + 结果缓存 │ ├─ cache/ 卷王令牌 + 结果缓存
│ ├─ uploads/ 导入临时目录(解析后即删) │ └─ uploads/ 导入临时目录(解析后即删)
│ └─ snapshots/ 成绩快照
├─ tests/ PHP 自测 + 一键运行脚本 ├─ tests/ PHP 自测 + 一键运行脚本
├─ tools/ mock 服务与 Node e2e ├─ tools/ mock 服务与 Node e2e
└─ docs/ 本文档站(VitePress) └─ docs/ 本文档站(VitePress)
@@ -53,7 +51,7 @@ ny-exam-platform/
│ ├─► Roster::all() 读名单 │ ├─► Roster::all() 读名单
│ ├─► 姓名 → 学号匹配 │ ├─► 姓名 → 学号匹配
│ ├─► dedupe(决定代表成绩) │ ├─► dedupe(决定代表成绩)
│ └─► 统计 / 异常检测 │ └─► 统计
└─► ny_resolve_score_cfg() 解析本卷满分与合格率 └─► ny_resolve_score_cfg() 解析本卷满分与合格率
└─► ny_ok(JSON) └─► ny_ok(JSON)
``` ```
-1
View File
@@ -80,7 +80,6 @@ PRAGMA foreign_keys = ON;
| 配置 | `ny_settings['config']` | `data/config.json` | | 配置 | `ny_settings['config']` | `data/config.json` |
| 名单 | `ny_roster` | `data/roster.json` | | 名单 | `ny_roster` | `data/roster.json` |
| 每卷配置 | `ny_project_config` | `data/project_config.json` | | 每卷配置 | `ny_project_config` | `data/project_config.json` |
| 成绩快照 | 文件系统 `data/snapshots/` | — |
| 结果缓存 | 文件系统 `data/cache/` | — | | 结果缓存 | 文件系统 `data/cache/` | — |
| 卷王令牌 | 文件系统 `data/cache/token.json` | — | | 卷王令牌 | 文件系统 `data/cache/token.json` | — |
+1 -3
View File
@@ -13,7 +13,7 @@ bash tests/run-all.sh
| 文件 | 类型 | 覆盖 | | 文件 | 类型 | 覆盖 |
| --- | --- | --- | | --- | --- | --- |
| `tests/test-aggregate.php` | PHP | 聚合、去重、考试次数、排序 | | `tests/test-aggregate.php` | PHP | 聚合、去重、考试次数、排序 |
| `tests/test-new-features.php` | PHP | 异常检测、快照、趋势、XLSX 多表 | | `tests/test-new-features.php` | PHP | 再次提交置顶排序、XLSX 多表、名单导入列识别 |
| `tools/test-xlsxwriter.php` | PHP | XLSX 写入与 ZIP 打包 | | `tools/test-xlsxwriter.php` | PHP | XLSX 写入与 ZIP 打包 |
| `tools/test-config-save.js` | Node e2e | 配置保存、连接变化检测、每卷配置隔离、CSRF | | `tools/test-config-save.js` | Node e2e | 配置保存、连接变化检测、每卷配置隔离、CSRF |
| `tools/test-draw-alloc.js` | Node e2e | 随机抽取名额分配(加权 / 自定义 / 守恒 fuzz) | | `tools/test-draw-alloc.js` | Node e2e | 随机抽取名额分配(加权 / 自定义 / 守恒 fuzz) |
@@ -64,8 +64,6 @@ e2e 会备份并修改 `data/ny.db`、`data/config.json`、`data/roster.json`,
```bash ```bash
git status data/ # data/ 已被 gitignore,需手工核对 git status data/ # data/ 已被 gitignore,需手工核对
``` ```
另外测试前请删除 `data/login_fail.json`,否则可能触发登录限流。
::: :::
## 写新测试时注意 ## 写新测试时注意
+10 -1
View File
@@ -13,6 +13,14 @@
| 自动刷新开关 + 间隔 | 与主页面一致的 5 / 10 / 15 / 30 / 60 / 120 秒 | | 自动刷新开关 + 间隔 | 与主页面一致的 5 / 10 / 15 / 30 / 60 / 120 秒 |
| 刷新 | 立即拉取一次(跳过缓存) | | 刷新 | 立即拉取一次(跳过缓存) |
| ⛶ 全屏 | 进入 / 退出全屏,布局自动铺满 | | ⛶ 全屏 | 进入 / 退出全屏,布局自动铺满 |
| ← 主系统 | 返回主页面(成绩明细 / 名单 / 抽取);全屏状态下也可直接返回 |
| 退出 | 退出登录 |
::: tip 后台标签页会自动暂停
切换到其他标签页后看板会**停止轮询**,切回来时立即补拉一次数据并重新开始计时——
投屏时长开也不会在无人观看时反复请求卷王。自动刷新失败时,「最后更新」会显示
**刷新中断(仍显示上次数据)**,鼠标悬停可看到具体原因。
:::
## 汇总卡片 ## 汇总卡片
@@ -25,7 +33,8 @@
看板主体按**班级**分块,每块内再按**组别**对比,指标与统计口径完全相同: 看板主体按**班级**分块,每块内再按**组别**对比,指标与统计口径完全相同:
总人数、已提交、缺考、提交率、平均分、合格率、最高分、最低分、平均用时。 总人数、已提交、缺考、提交率、平均分、合格率、最高分、最低分、平均用时。
点击班级内任意成员可查看其**每一次提交**(时间、成绩、时长),最高分有标记。 点击考过多次的成员(带「刷 N 次」标签)可查看其**每一次提交**(时间、成绩、时长),最佳成绩有标记。
按 `Esc` 或点击弹窗外部可关闭。
> 未分班的人归入「(未分班)」,未分组的人归入「(未分组)」,不会丢失。 > 未分班的人归入「(未分班)」,未分组的人归入「(未分组)」,不会丢失。
+11 -3
View File
@@ -44,7 +44,15 @@
顶部卡片:`候选人数 / 本次抽取 / 累计已抽取 / 已排除 / 抽取来源`。 顶部卡片:`候选人数 / 本次抽取 / 累计已抽取 / 已排除 / 抽取来源`。
结果表格:`序号 / 学号 / 姓名 / 班级 / 组别 / 操作`。 结果表格:`序号 / 学号 / 姓名 / 班级 / 组别 / 登陆账号 / 操作`。
「登陆账号」列让你在导出前就能核对:显示为 `学号 ⚠` 表示名单里没填这一列,
导出时会自动回退用学号——**这会导致学生登不进考试**,请先回主系统「名单管理」补全。
::: tip 「全部」按钮会跟随候选池
点 **全部** 后抽取人数进入跟随模式:切换班级 / 组别 / 来源时人数自动更新为当前
候选池大小。手工改动人数输入框即解除该模式。
:::
| 按钮 | 说明 | | 按钮 | 说明 |
| --- | --- | | --- | --- |
@@ -57,8 +65,8 @@
## 登陆账号说明 ## 登陆账号说明
名单中的**登陆账号**列只在这里用于导出白名单,主系统界面任何位置都不展示。 名单中的**登陆账号**列在这里既用于导出白名单,也会直接显示在结果表格中(便于导出前核对)。
若名单里没有这一列,导出的白名单该字段为空。 若名单里没有这一列,抽取结果里会用 `学号 ⚠` 标出,导出时回退用学号。
::: warning 模板文件名 ::: warning 模板文件名
卷王模板中该列表头为「登陆账号」(不是"登录账号"),导出时已按此命名。 卷王模板中该列表头为「登陆账号」(不是"登录账号"),导出时已按此命名。
+24 -6
View File
@@ -2,7 +2,8 @@
## 导出成绩明细 ## 导出成绩明细
工具栏 **导出成绩**,生成 `.xlsx`(零依赖,仓库内自行实现 XLSX 写入,不需要 `zip` 扩展也能用)。 工具栏下方「导出」条里的 **导出成绩**,生成 `.xlsx`(零依赖,仓库内自行实现 XLSX 写入,不需要 `zip` 扩展也能用)。
导出控件单独成行,与筛选、刷新分开,不会挤在一起。
### 三种可选模式 ### 三种可选模式
@@ -41,13 +42,30 @@
## 打印成绩明细 ## 打印成绩明细
成绩明细面板右上角 **打印**: 成绩明细面板右上角 **打印**,弹窗里可设置:
- 默认按**成绩降序**; | 选项 | 说明 |
- 可选「按班级 / 组别分组对比」,每组段首给出小计; | --- | --- |
- 走浏览器打印通道,可直接另存为 PDF。 | 打印标题 | 默认「试卷名 + 成绩明细」,可改成学校/考试正式名称 |
| 排序方式 | 成绩降序(默认)/ 升序 / 学号 / 提交时间 |
| 分组对比 | 按班级或组别分段,段首给出小计(人数/平均/最高/最低/合格人数与合格率) |
| 纸张方向 | A4 纵向(列少清晰)或横向(列多不折行) |
| 显示名次 | 全表连续编号,不分组也不会因为分段重新从 1 开始 |
| 显示「是否合格」列 | 低于合格线的成绩与「不合格」标红,一眼定位 |
| 显示考试次数 | 便于发现反复重考的人 |
| 附缺考名单 | 成绩表后**另起一页**附缺考名单,带空白备注列供手写 |
缺考名单面板右上角 **打印** 同理。 其它细节:
- 表头在**每一页自动重复**,行不会被切断到两页;
- 页眉含范围(班级/组别)、打印时间、人数、平均分、最高/最低、合格人数与合格线;
- 页脚含**教师 / 教研组长签字栏与日期**,可直接签字归档;
- 打印走隐藏 iframe 调起,**不会被浏览器的弹窗拦截挡掉**,也不会多开标签页。
## 打印缺考名单
缺考名单面板右上角 **打印**:按班级分组、班内按学号排序,同样带页眉与签字栏,
备注列留空供手写说明(请假、缓考等)。
## 直接调用导出接口 ## 直接调用导出接口
+4 -3
View File
@@ -18,9 +18,10 @@
务必从登录响应的 `Set-Cookie` 中取出 `sk-token`,并在后续每个请求带上 务必从登录响应的 `Set-Cookie` 中取出 `sk-token`,并在后续每个请求带上
`Cookie: sk-token=<token>`。本平台已处理此细节。 `Cookie: sk-token=<token>`。本平台已处理此细节。
**Q:登录一直失败并被锁住** **Q:登录一直失败**
连续 5 次失败会锁定 10 分钟。等待即可;如需立即解锁,删除 `data/login_fail.json`。 登录失败只会在登录页即时提示原因(账号/密码错、地址不可达等),不会因多次失败被锁。
若登录页连接信息显示成 `127.0.0.1` 等错误地址,用 `php tools/fix-config.php` 修正即可。
**Q:卷王改了密码后平台一直提示未登录** **Q:卷王改了密码后平台一直提示未登录**
@@ -86,7 +87,7 @@
**Q:可以把平台搬到另一台服务器吗?** **Q:可以把平台搬到另一台服务器吗?**
可以。整个目录复制过去,并把 `data/` 一起带上(里面有配置、名单、快照),确保 `data/` 可写。 可以。整个目录复制过去,并把 `data/` 一起带上(里面有配置、名单、每卷配置),确保 `data/` 可写。
## 安全 ## 安全
+1 -2
View File
@@ -97,11 +97,10 @@ Apache 下 `data/` 默认即被保护。仍需确认:
| 路径 | 需要 | 说明 | | 路径 | 需要 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `data/` | 可写 | 配置、名单、令牌、快照的根目录 | | `data/` | 可写 | 配置、名单、令牌的根目录 |
| `data/ny.db` | 可写 | SQLite 主库(启用 `pdo_sqlite` 时) | | `data/ny.db` | 可写 | SQLite 主库(启用 `pdo_sqlite` 时) |
| `data/uploads/` | 可写 | 导入名单时的临时落盘,解析后立即删除 | | `data/uploads/` | 可写 | 导入名单时的临时落盘,解析后立即删除 |
| `data/cache/` | 可写 | 卷王令牌缓存、结果缓存 | | `data/cache/` | 可写 | 卷王令牌缓存、结果缓存 |
| `data/snapshots/` | 可写 | 成绩快照 |
```bash ```bash
chown -R www:www data chown -R www:www data
+3 -5
View File
@@ -26,12 +26,10 @@ NY 问卷考试系统数据平台是一个**零第三方运行依赖**的 PHP 8
| 试卷切换 | 下拉选择卷王中的问卷 / 考试,自动拉取该卷全部答卷 | | 试卷切换 | 下拉选择卷王中的问卷 / 考试,自动拉取该卷全部答卷 |
| 姓名关联 | 自动识别答卷中的「姓名」题,也可手动指定字段;未匹配答卷单独面板 + 一键补入名单 | | 姓名关联 | 自动识别答卷中的「姓名」题,也可手动指定字段;未匹配答卷单独面板 + 一键补入名单 |
| 统计卡片 | 名单总人数、已提交、缺考、平均分、最高分、最低分、合格率、平均用时;选择班级后全部联动 | | 统计卡片 | 名单总人数、已提交、缺考、平均分、最高分、最低分、合格率、平均用时;选择班级后全部联动 |
| 成绩明细 | 虚拟滚动(无分页)、点击表头排序、搜索、考试次数、新提交绿色高亮(30 秒自动清除) | | 成绩明细 | 虚拟滚动(无分页)、点击表头排序、搜索(提示同步显示命中数)、考试次数、新提交绿色高亮(30 秒自动清除) |
| 缺考名单 | 名单中有但未提交的人,支持复制学号 / 姓名、打印 | | 缺考名单 | 名单中有但未提交的人,支持复制学号 / 姓名、打印(按班级分组) |
| 试卷设置 | 为当前选中的试卷单独设置满分 / 合格率 / 免考名单,试卷间互相隔离 | | 试卷设置 | 为当前选中的试卷单独设置满分 / 合格率 / 免考名单,试卷间互相隔离 |
| 快照 / 趋势 | 保存带标签的时间点快照,横向对比每名学生成绩变化 | | 导出 / 打印 | 成绩明细、缺考名单 `.xlsx`;成绩明细按成绩降序打印,支持分组小计、合格标记、附缺考名单、签字栏 |
| 异常检测 | 短时长高分、高频重考、雷同成绩三类线索 |
| 导出 / 打印 | 成绩明细、缺考名单 `.xlsx`;成绩明细按成绩降序打印,可选分组对比 |
### 数据看板 `dashboard.php` ### 数据看板 `dashboard.php`
+15 -1
View File
@@ -9,6 +9,7 @@
| 名单总人数 | 名单条目数;被当前试卷设为**免考**的人不计入 | | 名单总人数 | 名单条目数;被当前试卷设为**免考**的人不计入 |
| 已提交 | 匹配到名单、且提交了答卷的人数(去重后的代表成绩行数) | | 已提交 | 匹配到名单、且提交了答卷的人数(去重后的代表成绩行数) |
| 缺考 | `max(0, 名单总人数 − 已提交)` | | 缺考 | `max(0, 名单总人数 − 已提交)` |
| 提交率 | `已提交 ÷ 名单总人数 × 100%`——考试进行中最该盯的指标 |
| 提交率 | `已提交 ÷ 名单总人数 × 100%` | | 提交率 | `已提交 ÷ 名单总人数 × 100%` |
| 平均分 | 所有**有成绩**的答卷成绩平均值;无成绩者不计入分母 | | 平均分 | 所有**有成绩**的答卷成绩平均值;无成绩者不计入分母 |
| 最高分 / 最低分 | 同上,取自所有有成绩的答卷 | | 最高分 / 最低分 | 同上,取自所有有成绩的答卷 |
@@ -40,7 +41,20 @@
| 提交时间 | **最近一次**提交时间(重考低分也会让这行按最新时间排序到最上面) | | 提交时间 | **最近一次**提交时间(重考低分也会让这行按最新时间排序到最上面) |
| 考试时长 | 最近一次答卷的时长;后台手动录入等没有起止时间的答卷为 `-` | | 考试时长 | 最近一次答卷的时长;后台手动录入等没有起止时间的答卷为 `-` |
点击任意一行可查看该学生的**每一次提交**(时间、成绩、时长),最高分有标记。 点击任意一行可查看该学生的**每一次提交**(时间、成绩、时长),最佳成绩有标记。
### 成绩颜色
成绩按**占本卷满分的百分比**着色,而不是写死的 90 / 60 分:
| 颜色 | 条件 |
| --- | --- |
| 绿色 | ≥ 满分的 90% |
| 橙色 | ≥ 合格率线(默认满分的 60%,可在[每卷配置](./project-config.md)里改) |
| 红色 | 低于合格率线 |
这样把满分改成 50 分或 20 分制时,不会因为绝对分数偏低而整列误标红。
数据看板的成员榜使用同一套规则。
## 排序与搜索 ## 排序与搜索
+2 -2
View File
@@ -6,8 +6,8 @@
| 配置项 | 默认值 | 说明 | | 配置项 | 默认值 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| 卷王系统地址 | `https://exam.nxxy335.top` | 结尾不带 `/` | | 卷王系统地址 | `https://exam.nxxy335.top` | 结尾不带 `/`,可直接在设置里修改 |
| 接口前缀 | `/api` | 社区版 `/api`;企业版可能用 `/admin-api` | | 接口前缀 | `/api` | 社区版 `/api`;企业版可能用 `/admin-api`;可直接在设置里修改 |
| 登录账号 | `admin` | 卷王后台管理员账号 | | 登录账号 | `admin` | 卷王后台管理员账号 |
| 登录密码 | `123456` | 不回显;留空表示不修改 | | 登录密码 | `123456` | 不回显;留空表示不修改 |
| 校验 HTTPS 证书 | 勾选 | 自签名证书取消勾选 | | 校验 HTTPS 证书 | 勾选 | 自签名证书取消勾选 |
-72
View File
@@ -1,72 +0,0 @@
# 快照、趋势与异常检测
这一组功能用于**跨时间**看成绩,而不是只看当前这一刻。
## 快照
工具栏 **存快照** 打开快照面板,填写标签(留空自动用「快照 MM-DD HH:II」)后保存。
一份快照包含:
- 保存时间与标签;
- 试卷 ID 与名称;
- 当时的整体统计(总人数、已提交、平均分、合格率……);
- 每名学生的 `学号 / 姓名 / 班级 / 组别 / 成绩 / 最高分 / 考试次数 / 最近提交时间`(按学号排序)。
存储位置:`data/snapshots/<projectId>/<unix秒>_<md5(标签)>.json`。
快照**按试卷分目录**,所以不同试卷的快照互不干扰。
| 操作 | 位置 |
| --- | --- |
| 保存当前快照 | 快照弹窗 → 保存当前快照 |
| 刷新列表 | 快照弹窗 → 刷新列表 |
| 删除某份 | 列表每行的删除按钮 |
## 趋势
**趋势** 面板把同一试卷的多份快照横向铺开,按学号对齐,可以直接看出每名学生
在几个时间点上的分数变化(上升 / 下降 / 缺考)。
典型用法:
1. 月考卷 A 考完 → 存快照「10月月考」;
2. 期中卷 B 考完 → 存快照「期中」;
3. 打开趋势,逐人对比两次成绩。
## 异常检测
工具栏 **异常** 打开异常线索面板。它**不是判定作弊**,只是把值得人工复核的对象挑出来,
避免在上千行数据里肉眼找。
### 短时长高分
命中条件:
```
有时长记录
且 时长 < max(60秒, 平均时长 × 40%)
且 成绩 ≥ 满分提示值 × 85%
```
> 「满分提示值」取当前数据中的最高分(`max_score`),至少为 1。
### 高频重考
命中条件:`考试次数 ≥ max(4, ceil(平均考试次数 + 3))`
即:至少 4 次才算高频,且要明显高于整体平均水平。
### 雷同成绩
同一分数出现 **≥ 3 人** 时列为疑似,按人数降序排列并列出成员。
::: warning 请人工复核
异常检测基于统计特征,误报很正常(例如题目全是选择题时同分很常见)。
请把它当作"值得看一眼"的线索,不要直接作为处理依据。
:::
## 相关
- [成绩明细与统计口径](./scores.md)
- [导出与打印](./export-print.md)
+2 -5
View File
@@ -32,9 +32,6 @@ features:
- icon: ⚙️ - icon: ⚙️
title: 每卷独立配置 title: 每卷独立配置
details: 满分、合格率、免考名单按试卷隔离设置,互不干扰;未设置时回落到全局默认值。 details: 满分、合格率、免考名单按试卷隔离设置,互不干扰;未设置时回落到全局默认值。
- icon: 📈
title: 快照与趋势
details: 随时保存成绩快照,横向对比每名学生在多个时间点的成绩变化,并给出异常线索。
- icon: 🎲 - icon: 🎲
title: 随机抽取 title: 随机抽取
details: 按班级 / 组别均衡或加权抽取,累计去重、排除管理、历史批次与白名单导出。 details: 按班级 / 组别均衡或加权抽取,累计去重、排除管理、历史批次与白名单导出。
@@ -43,7 +40,7 @@ features:
details: 零依赖生成 .xlsx,支持多工作表、最新成绩视图、成绩明细与缺考名单打印。 details: 零依赖生成 .xlsx,支持多工作表、最新成绩视图、成绩明细与缺考名单打印。
- icon: 🔒 - icon: 🔒
title: 安全内建 title: 安全内建
details: 会话登录、CSRF 令牌、登录失败限流,上传文件白名单 + 随机名 + 解析后即删。 details: 会话登录、CSRF 令牌,上传文件白名单 + 随机名 + 解析后即删。
--- ---
## 三分钟了解它 ## 三分钟了解它
@@ -52,7 +49,7 @@ features:
| 页面 | 入口 | 用途 | | 页面 | 入口 | 用途 |
| --- | --- | --- | | --- | --- | --- |
| 主系统 | `index.php` | 成绩明细、统计卡片、名单管理、快照 / 趋势 / 异常、导出与打印 | | 主系统 | `index.php` | 成绩明细、统计卡片、名单管理、试卷设置、导出与打印 |
| 数据看板 | `dashboard.php` | 大屏展示,适合投屏实时监控提交进度 | | 数据看板 | `dashboard.php` | 大屏展示,适合投屏实时监控提交进度 |
| 随机抽取 | `draw.php` | 从名单或某试卷已提交人员中随机抽取,支持白名单导出 | | 随机抽取 | `draw.php` | 从名单或某试卷已提交人员中随机抽取,支持白名单导出 |
+1 -2
View File
@@ -5,7 +5,7 @@
## P0:屏蔽数据目录(必做) ## P0:屏蔽数据目录(必做)
`data/` 位于 Web 根目录内,里面有 `ny.db`(配置 / 名单 / 卷王账号密码)、 `data/` 位于 Web 根目录内,里面有 `ny.db`(配置 / 名单 / 卷王账号密码)、
`config.json`、`roster.json`、令牌缓存与快照。**必须禁止直接访问。** `config.json`、`roster.json`、令牌缓存。**必须禁止直接访问。**
### nginx(生产环境默认,必做) ### nginx(生产环境默认,必做)
@@ -132,7 +132,6 @@ deny all;
| `data/ny.db` | 配置、名单、每卷配置(SQLite) | | `data/ny.db` | 配置、名单、每卷配置(SQLite) |
| `data/config.json` | 配置(文件备份) | | `data/config.json` | 配置(文件备份) |
| `data/roster.json` | 名单(文件备份) | | `data/roster.json` | 名单(文件备份) |
| `data/snapshots/` | 成绩快照 |
## P2:改掉卷王默认口令 ## P2:改掉卷王默认口令
+7 -7
View File
@@ -11,11 +11,11 @@
- 卷王令牌缓存在 `data/cache/token.json`;令牌失效时平台会自动重新登录; - 卷王令牌缓存在 `data/cache/token.json`;令牌失效时平台会自动重新登录;
若卷王侧也认证失败,前端会先调用 `logout.php` 清会话再跳转登录页,避免出现跳转死循环。 若卷王侧也认证失败,前端会先调用 `logout.php` 清会话再跳转登录页,避免出现跳转死循环。
## 登录限流 ## 登录失败处理
连续 **5 次**登录失败 → 锁定 **10 分钟**。状态记录在 `data/login_fail.json`。 登录失败(卷王账号/密码错、地址不可达等)只会在登录页即时提示原因,**不做失败次数限流**——
单机内网工具场景下,限流常因配置写错或测试串味把正常用户挡在门外,且无实际防爆破价值。
应急解锁:删除 `data/login_fail.json`。 若怀疑连接配置被测试污染导致连不上真实卷王,用 `php tools/fix-config.php` 一键修正连接地址。
## CSRF 防护 ## CSRF 防护
@@ -24,7 +24,7 @@
`X-CSRF-Token` 头中自动携带; `X-CSRF-Token` 头中自动携带;
- 后端对写操作维护**白名单** `$WRITE_ACTIONS`,命中白名单的请求一律校验: - 后端对写操作维护**白名单** `$WRITE_ACTIONS`,命中白名单的请求一律校验:
`config_save` / `roster_save` / `roster_import` / `roster_clear` / `config_save` / `roster_save` / `roster_import` / `roster_clear` /
`snapshot_save` / `snapshot_delete` / `unmatched_map` / `project_config_save`。 `unmatched_map` / `project_config_save`。
## 文件上传 ## 文件上传
@@ -58,7 +58,7 @@
## 目录保护 ## 目录保护
程序启动时会在 `data/`、`data/cache/`、`data/uploads/`、`data/snapshots/` 程序启动时会在 `data/`、`data/cache/`、`data/uploads/`
自动生成 `.htaccess`(`Deny from all`)与 `index.html` 占位。 自动生成 `.htaccess`(`Deny from all`)与 `index.html` 占位。
> **nginx 不读取 `.htaccess`**,必须显式配置。见 [部署加固清单](./hardening.md)。 > **nginx 不读取 `.htaccess`**,必须显式配置。见 [部署加固清单](./hardening.md)。
@@ -70,7 +70,7 @@
| 全站 HTTPS | 登录密码经 RSA 加密,但仍建议全站 TLS | | 全站 HTTPS | 登录密码经 RSA 加密,但仍建议全站 TLS |
| IP 白名单 | 内网使用时在 nginx 层限制来源 IP | | IP 白名单 | 内网使用时在 nginx 层限制来源 IP |
| 隐藏版本号 | 关闭 `expose_php`、隐藏 nginx 版本号 | | 隐藏版本号 | 关闭 `expose_php`、隐藏 nginx 版本号 |
| 定期备份 | 备份 `data/`(配置、名单、快照、SQLite 库) | | 定期备份 | 备份 `data/`(配置、名单、每卷配置、SQLite 库) |
| 最小权限 | PHP-FPM 进程只对 `data/` 可写,其它目录只读 | | 最小权限 | PHP-FPM 进程只对 `data/` 可写,其它目录只读 |
| 改默认密码 | 卷王后台默认 `admin/123456` 务必修改 | | 改默认密码 | 卷王后台默认 `admin/123456` 务必修改 |
+7 -6
View File
@@ -110,18 +110,19 @@ $rosterCount = Roster::count();
<table class="grid"> <table class="grid">
<thead> <thead>
<tr> <tr>
<th style="width:70px">序号</th> <th style="width:64px">序号</th>
<th style="width:26%">学号</th> <th style="width:22%">学号</th>
<th style="width:22%">姓名</th> <th style="width:18%">姓名</th>
<th>班级</th> <th>班级</th>
<th>组别</th> <th>组别</th>
<th style="width:90px">操作</th> <th style="width:22%">登陆账号</th>
<th style="width:88px">操作</th>
</tr> </tr>
</thead> </thead>
<tbody id="drawBody"><tr><td colspan="6" class="empty">尚未抽取</td></tr></tbody> <tbody id="drawBody"><tr><td colspan="7" class="empty">尚未抽取</td></tr></tbody>
</table> </table>
</div> </div>
<p class="hint">两个下载均为两列「<b>姓名</b> / <b>登陆账号</b>」,登陆账号取名单中导入的「登陆账号」列(缺失时回退学号),按文本格式保存,可直接导入卷王白名单。</p> <p class="hint">两个下载与「📋 复制名单」均为两列「<b>姓名</b> / <b>登陆账号</b>」,登陆账号取名单中导入的「登陆账号」列。<b>表中「登陆账号」列显示为空即表示名单里没有填,导出时会自动回退用学号</b>,请先到主系统「名单管理」补全,否则学生无法登录考试。</p>
</section> </section>
</main> </main>
+39 -83
View File
@@ -70,39 +70,40 @@ $rosterCount = Roster::count();
</div> </div>
<div class="field btns"> <div class="field btns">
<button class="btn btn-primary" id="btnRefresh">立即刷新</button> <button class="btn btn-primary" id="btnRefresh">立即刷新</button>
<button class="btn btn-primary" id="btnProjCfg" title="为当前选中的试卷单独设置满分 / 合格率 / 免考名单">试卷设置</button>
</div> </div>
<div class="field grow-2"> <div class="field grow-2">
<label>搜索</label> <label>搜索</label>
<input type="text" id="kw" placeholder="输入学号或姓名筛选"> <input type="text" id="kw" placeholder="输入学号或姓名筛选">
</div> </div>
<div class="field btns" style="flex-wrap:wrap;gap:8px"> </section>
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-2)">导出排序
<select id="exportSortSel" style="height:30px;border-radius:8px;border:1px solid var(--border);padding:0 6px;background:#fff"> <section class="subbar">
<span class="subbar-label">导出</span>
<label class="subbar-item">排序
<select id="exportSortSel">
<option value="time">按提交时间</option> <option value="time">按提交时间</option>
<option value="sid">按学号</option> <option value="sid">按学号</option>
<option value="score_desc">按成绩降序</option> <option value="score_desc">按成绩降序</option>
<option value="score_asc">按成绩升序</option> <option value="score_asc">按成绩升序</option>
</select> </select>
</label> </label>
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-2);cursor:pointer" title="每人只导一行:最近一次成绩 + 最高分 + 考试次数"> <label class="subbar-item" title="每人只导一行:最近一次成绩 + 最高分 + 考试次数">
<input type="checkbox" id="exportLatest" style="width:auto;height:auto">按最新成绩 <input type="checkbox" id="exportLatest">按最新成绩
</label> </label>
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-2);cursor:pointer" title="按班级拆分多个工作表(xlsx 多表)"> <label class="subbar-item" title="按班级拆分多个工作表(xlsx 多表)">
<input type="checkbox" id="exportSplit" style="width:auto;height:auto">按班级分表 <input type="checkbox" id="exportSplit">按班级分表
</label> </label>
<span class="spacer"></span>
<button class="btn" id="btnExport">导出成绩</button> <button class="btn" id="btnExport">导出成绩</button>
<button class="btn" id="btnExportAbsent">导出缺考</button> <button class="btn" id="btnExportAbsent">导出缺考</button>
<button class="btn" id="btnSnapshot" title="保存当前成绩为一份带标签的时间点快照">存快照</button>
<button class="btn" id="btnTrend" title="查看本卷各快照间每名学生的成绩趋势">趋势</button>
<button class="btn" id="btnAnomaly" title="异常检测线索:短时长高分 / 高频重考 / 雷同成绩">异常</button>
<button class="btn btn-primary" id="btnProjCfg" title="为当前选中的试卷单独设置满分 / 合格率 / 免考名单">试卷设置</button>
</div>
</section> </section>
<section class="cards" id="statCards"> <section class="cards" id="statCards">
<div class="card accent-blue"><span>名单总人数</span><b id="sTotal">-</b></div> <div class="card accent-blue"><span>名单总人数</span><b id="sTotal">-</b></div>
<div class="card ok accent-green"><span>已提交</span><b id="sSubmit">-</b></div> <div class="card ok accent-green"><span>已提交</span><b id="sSubmit">-</b></div>
<div class="card warn accent-orange"><span>缺考</span><b id="sAbsent">-</b></div> <div class="card warn accent-orange"><span>缺考</span><b id="sAbsent">-</b></div>
<div class="card accent-purple"><span>提交率</span><b id="sRate">-</b></div>
<div class="card accent-teal"><span>平均分</span><b id="sAvg">-</b></div> <div class="card accent-teal"><span>平均分</span><b id="sAvg">-</b></div>
<div class="card accent-green"><span>最高分</span><b id="sMax">-</b></div> <div class="card accent-green"><span>最高分</span><b id="sMax">-</b></div>
<div class="card accent-red"><span>最低分</span><b id="sMin">-</b></div> <div class="card accent-red"><span>最低分</span><b id="sMin">-</b></div>
@@ -156,7 +157,7 @@ $rosterCount = Roster::count();
</section> </section>
<section class="panel" id="unmatchedPanel"> <section class="panel" id="unmatchedPanel">
<div class="panel-head"><h2>未匹配答卷 <small>姓名不在名单中</small></h2></div> <div class="panel-head"><h2>未匹配答卷 <small id="unmatchedHint">姓名不在名单中</small></h2></div>
<div class="table-scroll short"> <div class="table-scroll short">
<table class="grid compact"> <table class="grid compact">
<thead><tr><th>姓名</th><th>成绩</th><th>提交时间</th><th style="width:90px">操作</th></tr></thead> <thead><tr><th>姓名</th><th>成绩</th><th>提交时间</th><th style="width:90px">操作</th></tr></thead>
@@ -244,9 +245,14 @@ $rosterCount = Roster::count();
</div> </div>
</div> </div>
<div class="fi full"> <div class="fi full">
<label>系统地址(不可在此修改)</label> <label>系统地址</label>
<input type="text" id="cfgBase" readonly> <input type="text" id="cfgBase" placeholder="https://exam.nxxy335.top" spellcheck="false">
<p class="hint">如需修改请编辑 <code>data/config.json</code>(保存后清 token 自动重连)</p> <p class="hint">卷王后台地址,结尾不要带 <code>/</code>;修改后保存会自动重连</p>
</div>
<div class="fi">
<label>接口前缀</label>
<input type="text" id="cfgPrefix" placeholder="/api" spellcheck="false">
<p class="hint">社区版 <code>/api</code>;企业版可能是 <code>/admin-api</code></p>
</div> </div>
<div class="fi"> <div class="fi">
<label>登录账号</label> <label>登录账号</label>
@@ -261,7 +267,7 @@ $rosterCount = Roster::count();
<div id="pingResult" class="ping-result hidden"></div> <div id="pingResult" class="ping-result hidden"></div>
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
<span class="hint" style="align-self:center">只有修改账号/密码才会触发重连与测连接</span> <span class="hint" style="align-self:center">修改地址 / 前缀 / 账号 / 密码后保存会自动重新连接</span>
<span class="spacer"></span> <span class="spacer"></span>
<button class="btn" id="btnTestConn">测试连接</button> <button class="btn" id="btnTestConn">测试连接</button>
<button class="btn" data-close>取消</button> <button class="btn" data-close>取消</button>
@@ -380,60 +386,15 @@ $rosterCount = Roster::count();
</div> </div>
</div> </div>
<!-- 成绩快照 / 趋势 -->
<div class="modal" id="snapshotModal">
<div class="modal-box wide">
<div class="modal-head"><h3 id="snapTitle">成绩快照</h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<div class="fi" style="margin-bottom:10px">
<label>快照标签(可选)</label>
<input type="text" id="snapLabel" placeholder="如:第一次考试、补考后…">
</div>
<div class="snap-toolbar" style="display:flex;gap:8px;margin-bottom:12px">
<button class="btn sm btn-primary" id="btnSnapSave">保存当前快照</button>
<span class="spacer"></span>
<button class="btn sm" id="btnSnapRefresh">刷新列表</button>
<button class="btn sm" id="btnSnapTrend">查看趋势</button>
</div>
<div class="table-scroll mid">
<table class="grid compact" id="snapTable">
<thead><tr><th>标签</th><th>保存时间</th><th>人数</th><th>项目</th><th style="width:70px">操作</th></tr></thead>
<tbody id="snapBody"><tr><td colspan="5" class="empty">暂无快照</td></tr></tbody>
</table>
</div>
</div>
<div class="modal-foot">
<span class="spacer"></span>
<button class="btn" data-close>关闭</button>
</div>
</div>
</div>
<!-- 跨快照趋势 -->
<div class="modal" id="trendModal">
<div class="modal-box wide">
<div class="modal-head"><h3>成绩趋势 <small id="trendSub"></small></h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<div class="table-scroll" style="max-height:60vh">
<table class="grid compact" id="trendTable">
<thead id="trendHead"></thead>
<tbody id="trendBody"><tr><td colspan="10" class="empty">加载中…</td></tr></tbody>
</table>
</div>
<p class="hint" id="trendHint">趋势值为每名学生的「最高分」,未出现该快照的记「—」</p>
</div>
<div class="modal-foot">
<span class="spacer"></span>
<button class="btn" data-close>关闭</button>
</div>
</div>
</div>
<!-- 成绩明细打印设置 --> <!-- 成绩明细打印设置 -->
<div class="modal" id="printTableModal"> <div class="modal" id="printTableModal">
<div class="modal-box"> <div class="modal-box">
<div class="modal-head"><h3>打印成绩明细</h3><button class="x" data-close>&times;</button></div> <div class="modal-head"><h3>打印成绩明细</h3><button class="x" data-close>&times;</button></div>
<div class="modal-body"> <div class="modal-body">
<div class="fi">
<label>打印标题</label>
<input type="text" id="ptTitle" placeholder="默认:试卷名 + 成绩明细">
</div>
<div class="fi"> <div class="fi">
<label>排序方式</label> <label>排序方式</label>
<select id="ptSort"> <select id="ptSort">
@@ -450,12 +411,22 @@ $rosterCount = Roster::count();
<option value="cls">按班级分组</option> <option value="cls">按班级分组</option>
<option value="grp">按组别分组</option> <option value="grp">按组别分组</option>
</select> </select>
<p class="hint">分组后每个班/组单独一段,段首显示小计(人数/平均分/合格率)</p> <p class="hint">分组后每个班/组单独一段,段首显示小计(人数/平均/最高/最低/合格率)</p>
</div>
<div class="fi">
<label>纸张方向</label>
<select id="ptOrient">
<option value="portrait" selected>纵向(A4,列少时清晰)</option>
<option value="landscape">横向(A4,列多时不易折行)</option>
</select>
</div> </div>
<div class="fi checks"> <div class="fi checks">
<label><input type="checkbox" id="ptRank" checked> 显示名次</label> <label><input type="checkbox" id="ptRank" checked> 显示名次(全表连续编号)</label>
<label><input type="checkbox" id="ptPass" checked> 显示「是否合格」列</label>
<label><input type="checkbox" id="ptAttempts" checked> 显示考试次数</label> <label><input type="checkbox" id="ptAttempts" checked> 显示考试次数</label>
<label><input type="checkbox" id="ptAbsent" title="成绩表后另起一页附缺考名单,含备注列供手写"> 附缺考名单(另起一页)</label>
</div> </div>
<p class="hint">打印内容<b>不受页面当前筛选外的影响</b>,始终按当前班级/组别筛选结果输出;表头会在每页自动重复,末尾附签字栏。</p>
<p class="hint" id="ptHint"></p> <p class="hint" id="ptHint"></p>
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
@@ -497,21 +468,6 @@ $rosterCount = Roster::count();
</div> </div>
</div> </div>
<!-- 异常检测线索 -->
<div class="modal" id="anomalyModal">
<div class="modal-box wide">
<div class="modal-head"><h3>异常检测线索</h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<p class="hint" style="margin:0 0 12px">以下为自动统计的「防作弊线索」,仅供人工复核判断,不代表结论。</p>
<div id="anomalyBody"></div>
</div>
<div class="modal-foot">
<span class="spacer"></span>
<button class="btn" data-close>关闭</button>
</div>
</div>
</div>
<script> <script>
window.NY = { window.NY = {
appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>, appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>,
-87
View File
@@ -356,9 +356,6 @@ class DataService
'attempts_total'=> $attemptsTotal, 'attempts_total'=> $attemptsTotal,
]; ];
// 异常检测(防作弊线索,仅供教师人工判断,不做自动判定)
$anomalies = self::detectAnomalies($rows, $stats);
return [ return [
'project' => [ 'project' => [
'id' => ny_str($project['id'] ?? $projectId), 'id' => ny_str($project['id'] ?? $projectId),
@@ -372,7 +369,6 @@ class DataService
'absent' => $absent, 'absent' => $absent,
'unmatched' => $unmatched, 'unmatched' => $unmatched,
'stats' => $stats, 'stats' => $stats,
'anomalies' => $anomalies,
'roster' => $students, 'roster' => $students,
'roster_count' => count($students), 'roster_count' => count($students),
// 本卷独立配置(前端据此回显满分/合格率/免考);full_score 已按本卷解析 // 本卷独立配置(前端据此回显满分/合格率/免考);full_score 已按本卷解析
@@ -385,89 +381,6 @@ class DataService
]; ];
} }
/**
* 异常检测(防作弊线索):基于聚合后的学生行,输出三类线索供教师人工复核。
* 1) 短时长高得分:用时远低于平均(<平均 40% 且 <180s)却接近满分;
* 2) 高频重考:考试次数明显多于多数人(>1 且 >= 平均次数+3);
* 3) 雷同成绩:多人分数完全相同(疑似互抄,需满足该分数下人数 >=3)。
*
* 返回值:['fast_high'=>[], 'frequent'=>[], 'identical'=>[['score'=>x,'count'=>n,'names'=>[...]]]]
*/
public static function detectAnomalies(array $rows, array $stats): array
{
$avgDur = (int)($stats['avg_duration'] ?? 0);
$fullHint = max(1, (float)($stats['max_score'] ?? 100));
$fastHigh = [];
$frequent = [];
$attemptsAll = [];
foreach ($rows as $r) {
$dur = $r['duration'] ?? null;
$score = $r['score'] ?? null;
$attempts = (int)($r['attempts'] ?? 1);
$attemptsAll[] = $attempts;
// 短时长高得分:有时长记录、明显快于平均、分数 >= 满分 85%
if ($dur !== null && $avgDur > 0 && $dur < max(60, (int)($avgDur * 0.4))
&& $score !== null && $score >= $fullHint * 0.85) {
$fastHigh[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
'score' => $score,
'duration' => $dur,
'duration_txt' => (string)($r['duration_txt'] ?? ''),
];
}
// 高频重考:考试次数明显偏多
if ($attempts > 1) {
$frequent[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
'attempts' => $attempts,
'best_score' => $r['best_score'] ?? null,
];
}
}
// 高频重考阈值:平均次数 + 3(且至少 4 次才够"高频")
$avgAttempts = $attemptsAll !== [] ? array_sum($attemptsAll) / count($attemptsAll) : 0;
$freqThreshold = max(4, (int)ceil($avgAttempts + 3));
$frequent = array_values(array_filter($frequent, static fn($f) => $f['attempts'] >= $freqThreshold));
usort($frequent, static fn($a, $b) => $b['attempts'] <=> $a['attempts']);
// 雷同成绩:按分数分组,出现 >=3 人同分(且分数不为 null)视为疑似
$scoreGroups = [];
foreach ($rows as $r) {
$score = $r['score'] ?? null;
if ($score === null) {
continue;
}
$key = (string)$score;
$scoreGroups[$key][] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
];
}
$identical = [];
foreach ($scoreGroups as $score => $members) {
if (count($members) >= 3) {
$identical[] = ['score' => (float)$score, 'count' => count($members), 'members' => $members];
}
}
usort($identical, static fn($a, $b) => $b['count'] <=> $a['count']);
return [
'fast_high' => $fastHigh,
'frequent' => $frequent,
'identical' => $identical,
];
}
/** /**
* 把聚合后的学生行展开为"每次考试一行",用于导出全部考试记录 / 明细内全部展示。 * 把聚合后的学生行展开为"每次考试一行",用于导出全部考试记录 / 明细内全部展示。
* 每行携带该次考试的成绩、提交时间、时长,以及该学生的总考试次数 attempts 与第几次 idx。 * 每行携带该次考试的成绩、提交时间、时长,以及该学生的总考试次数 attempts 与第几次 idx。
-164
View File
@@ -1,164 +0,0 @@
<?php
/**
* 成绩快照:把某次「拉取到的成绩」落盘成一份带标签的时间点存档,
* 用于跨时间 / 跨试卷的趋势对比(例如某学生几次考试的分数变化)。
*
* 存储位置:data/snapshots/<projectId>/<unix秒>_<md5(标签)>.json
* 每份快照内容为 { label, at, project:{id,name}, stats, students:[{sid,name,cls,grp,score,best_score,attempts,last_submit_at}] }
*/
declare(strict_types=1);
final class Snapshot
{
private static function dir(string $projectId): string
{
$d = NY_DATA . '/snapshots/' . preg_replace('/[^A-Za-z0-9_-]/', '_', $projectId);
if (!is_dir($d)) {
@mkdir($d, 0777, true);
}
return $d;
}
/** 从聚合结果里抽取轻量学生摘要(只保留趋势对比需要的字段) */
public static function summarize(array $result, string $label): array
{
$students = [];
foreach ((array)($result['rows'] ?? []) as $r) {
$students[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
'grp' => (string)($r['grp'] ?? ''),
'score' => ($r['score'] ?? null) === null ? null : (float)$r['score'],
'best_score' => ($r['best_score'] ?? null) === null ? null : (float)$r['best_score'],
'attempts' => (int)($r['attempts'] ?? 1),
'last_submit_at' => (string)($r['last_submit_at'] ?? ($r['submit_at'] ?? '-')),
];
}
// 按学号排序,保证趋势表行序稳定
usort($students, static function ($a, $b): int {
$sa = $a['sid']; $sb = $b['sid'];
if (is_numeric($sa) && is_numeric($sb)) {
return $sa <=> $sb;
}
return strnatcmp((string)$sa, (string)$sb);
});
return [
'label' => trim($label) === '' ? '快照 ' . date('m-d H:i') : trim($label),
'at' => time(),
'at_txt' => date('Y-m-d H:i:s'),
'project' => [
'id' => (string)($result['project']['id'] ?? ''),
'name' => (string)($result['project']['name'] ?? ''),
],
'stats' => $result['stats'] ?? [],
'students' => $students,
];
}
/** 保存一份快照,返回文件名 */
public static function save(string $projectId, array $snap): string
{
$dir = self::dir($projectId);
$name = time() . '_' . substr(md5($snap['label'] . $snap['at']), 0, 8) . '.json';
@file_put_contents($dir . '/' . $name, json_encode($snap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
return $name;
}
/** 列出某项目的全部快照(按时间倒序) */
public static function list(string $projectId): array
{
$dir = self::dir($projectId);
$out = [];
foreach (glob($dir . '/*.json') ?: [] as $f) {
$raw = @file_get_contents($f);
if ($raw === false) {
continue;
}
$j = json_decode($raw, true);
if (!is_array($j)) {
continue;
}
$out[] = [
'file' => basename($f),
'label' => (string)($j['label'] ?? basename($f)),
'at' => (int)($j['at'] ?? 0),
'at_txt' => (string)($j['at_txt'] ?? ''),
'project' => $j['project'] ?? [],
'count' => count((array)($j['students'] ?? [])),
];
}
usort($out, static fn($a, $b) => ($b['at'] <=> $a['at']));
return $out;
}
/** 读取单份快照完整内容 */
public static function get(string $projectId, string $file): ?array
{
$file = basename($file); // 防路径穿越
$path = self::dir($projectId) . '/' . $file;
if (!is_file($path)) {
return null;
}
$j = json_decode((string)@file_get_contents($path), true);
return is_array($j) ? $j : null;
}
/** 删除单份快照 */
public static function delete(string $projectId, string $file): bool
{
$file = basename($file);
$path = self::dir($projectId) . '/' . $file;
if (is_file($path)) {
return @unlink($path);
}
return false;
}
/**
* 跨快照趋势:以学号为行、各快照为列,输出每个学生在每份快照里的成绩。
* 用 best_score(真实最高分)作为趋势值,未出现记 null。
*/
public static function trend(array $snapshots): array
{
// 按时间升序排,最早的列在最前
$snaps = $snapshots;
usort($snaps, static fn($a, $b) => ($a['at'] <=> $b['at']));
$cols = [];
$bySid = [];
foreach ($snaps as $sn) {
$colKey = (string)$sn['at'] . ':' . (string)$sn['label'];
$cols[] = ['key' => $colKey, 'label' => (string)$sn['label'], 'at_txt' => (string)($sn['at_txt'] ?? '')];
foreach ((array)($sn['students'] ?? []) as $st) {
$sid = (string)($st['sid'] ?? '');
if ($sid === '') {
continue;
}
if (!isset($bySid[$sid])) {
$bySid[$sid] = [
'sid' => $sid,
'name' => (string)($st['name'] ?? ''),
'cls' => (string)($st['cls'] ?? ''),
'scores' => [],
];
}
$bySid[$sid]['scores'][$colKey] = ($st['best_score'] ?? null) === null
? null
: (float)$st['best_score'];
}
}
$rows = array_values($bySid);
// 按学号排序
usort($rows, static function ($a, $b): int {
if (is_numeric($a['sid']) && is_numeric($b['sid'])) {
return $a['sid'] <=> $b['sid'];
}
return strnatcmp((string)$a['sid'], (string)$b['sid']);
});
return ['columns' => $cols, 'rows' => $rows];
}
}
+3 -10
View File
@@ -7,6 +7,9 @@
declare(strict_types=1); declare(strict_types=1);
require __DIR__ . '/bootstrap.php'; require __DIR__ . '/bootstrap.php';
// 先加载配置:下面已登录分支要用 $cfg 构造 SurveyKingClient 探活,必须在前面就准备好
$cfg = ny_load_config();
if (ny_is_auth()) { if (ny_is_auth()) {
// 已登录但卷王 token 可能已过期:用当前 token 探一次卷王 /system,能 200 即视为有效,否则清掉本地会话强制重新登录 // 已登录但卷王 token 可能已过期:用当前 token 探一次卷王 /system,能 200 即视为有效,否则清掉本地会话强制重新登录
try { try {
@@ -23,17 +26,10 @@ if (ny_is_auth()) {
} }
} }
$cfg = ny_load_config();
$err = ''; $err = '';
$ok = false; $ok = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 登录失败限流:5 次失败锁 10 分钟,防止暴力破解
$throttle = ny_login_throttle_check();
if ($throttle['until'] > time()) {
$wait = (int)(($throttle['until'] - time()) / 60) + 1;
$err = '尝试次数过多,请 ' . $wait . ' 分钟后再试。';
} else {
$user = trim((string)($_POST['username'] ?? $cfg['username'])); $user = trim((string)($_POST['username'] ?? $cfg['username']));
$pwd = (string)($_POST['password'] ?? ''); $pwd = (string)($_POST['password'] ?? '');
// 允许用户在本页调整账号;密码必须输入 // 允许用户在本页调整账号;密码必须输入
@@ -53,15 +49,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$cfg['password'] = $pwd; $cfg['password'] = $pwd;
ny_save_config($cfg); ny_save_config($cfg);
ny_login(); ny_login();
ny_login_throttle_reset();
$ok = true; $ok = true;
} catch (Throwable $e) { } catch (Throwable $e) {
ny_login_throttle_hit();
$err = '登录失败:' . $e->getMessage(); $err = '登录失败:' . $e->getMessage();
} }
} }
} }
}
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
+22 -5
View File
@@ -4,9 +4,18 @@
set -e set -e
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
PHP="${PHP_BIN:-C:/Users/Zhang/.workbuddy/binaries/php/php.exe}" # 运行期路径:优先用环境变量,其次用本机工作区路径,最后退回 PATH 里的 php / node
NODE="${NODE_BIN:-C:/Users/Zhang/.workbuddy/binaries/node/versions/22.22.2-2/node.exe}" # (工作区 node 的版本目录会随升级变化,写死路径会直接 No such file or directory)
pick() {
local cand="$1" fallback="$2"
if [ -n "$cand" ] && [ -x "$cand" ]; then echo "$cand"; return; fi
command -v "$fallback" 2>/dev/null || echo "$fallback"
}
PHP="$(pick "${PHP_BIN:-C:/Users/Zhang/.workbuddy/binaries/php/php.exe}" php)"
NODE="$(pick "${NODE_BIN:-}" node)"
INI="-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite" INI="-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite"
echo "PHP = $PHP"
echo "NODE = $NODE"
echo "================ PHP 自测 ================" echo "================ PHP 自测 ================"
for f in tests/test-aggregate.php tests/test-new-features.php tools/test-xlsxwriter.php; do for f in tests/test-aggregate.php tests/test-new-features.php tools/test-xlsxwriter.php; do
@@ -17,11 +26,19 @@ done
echo "" echo ""
echo "================ Node e2e(需 mock-server + PHP 内置服务器) ================" echo "================ Node e2e(需 mock-server + PHP 内置服务器) ================"
for f in tools/test-config-save.js tools/test-draw-alloc.js tools/test-upload-guard.js; do # e2e 会把 mock 连接配置写进数据目录;用 NY_DATA 把这两个会改写配置的测试整体指向临时目录,
# 彻底避免污染生产 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
echo "" echo ""
echo ">>> $f" echo ">>> $f (NY_DATA=$NY_DATA_TMP)"
"$NODE" "$f" NY_DATA="$NY_DATA_TMP" "$NODE" "$f"
done done
# test-draw-alloc.js 不读写连接配置,沿用项目 data/ 即可
echo ""
echo ">>> tools/test-draw-alloc.js"
"$NODE" "tools/test-draw-alloc.js"
echo "" echo ""
echo "全部测试完成。" echo "全部测试完成。"
+22 -74
View File
@@ -1,10 +1,9 @@
<?php <?php
/** /**
* 新功能回归测试: * 新功能回归测试:
* 1) 异常检测 detectAnomalies(短时长高分 / 高频重考 / 雷同成绩) * 1) 成绩明细排序:再次提交应置顶(last_submit_ts 排序)
* 2) 成绩快照 Snapshot::summarize / trend * 2) XlsxWriter 多工作表 renderMulti(分表 + 名字去重/截断)
* 3) 成绩明细排序:再次提交应置顶(last_submit_ts 排序) * 3) 名单导入列识别 rowsToStudents(表头识别 / 去重 / Excel 数字尾巴)
* 4) XlsxWriter 多工作表 renderMulti(分表 + 名字去重/截断)
*/ */
declare(strict_types=1); declare(strict_types=1);
require __DIR__ . '/../bootstrap.php'; require __DIR__ . '/../bootstrap.php';
@@ -18,75 +17,6 @@ function check(string $name, bool $cond, string $detail = ''): void
else { $fail++; echo " ❌ {$name} {$detail}\n"; } else { $fail++; echo " ❌ {$name} {$detail}\n"; }
} }
/* ============ 1) 异常检测 ============ */
echo "\n== 异常检测 ==\n";
// 构造:3 人同分(雷同)、1 人短时长高分、1 人高频重考
function mkRow(string $sid, string $name, ?float $score, ?int $duration, int $attempts): array
{
return [
'sid' => $sid, 'name' => $name, 'cls' => '1班', 'grp' => 'A',
'score' => $score, 'duration' => $duration, 'duration_txt' => $duration === null ? '-' : ($duration . '秒'),
'attempts' => $attempts, 'best_score' => $score,
];
}
$rows = [
mkRow('S01', '甲', 90.0, 600, 1),
mkRow('S02', '乙', 90.0, 610, 1), // 与甲同分
mkRow('S03', '丙', 90.0, 620, 1), // 三人同分 → 雷同
mkRow('S04', '丁', 95.0, 30, 1), // 短时长(30s)+ 高分
mkRow('S05', '戊', 70.0, 500, 9), // 高频重考 9 次
];
$stats = ['avg_duration' => 600, 'max_score' => 100.0];
$anom = DataService::detectAnomalies($rows, $stats);
check('雷同成绩:捕获 90 分 3 人组', count($anom['identical']) >= 1 && (float)$anom['identical'][0]['score'] === 90.0,
json_encode($anom['identical'], JSON_UNESCAPED_UNICODE));
check('雷同成绩:人数 = 3', (int)($anom['identical'][0]['count'] ?? 0) === 3);
check('短时长高分:捕获丁', count($anom['fast_high']) >= 1 && ($anom['fast_high'][0]['sid'] ?? '') === 'S04',
json_encode($anom['fast_high'], JSON_UNESCAPED_UNICODE));
check('高频重考:捕获戊(9 次)', count($anom['frequent']) >= 1 && ($anom['frequent'][0]['sid'] ?? '') === 'S05',
json_encode($anom['frequent'], JSON_UNESCAPED_UNICODE));
check('正常学生(甲 1 次)不被误报高频', !in_array('S01', array_column($anom['frequent'], 'sid'), true));
/* ============ 2) 快照 summarize / trend ============ */
echo "\n== 成绩快照 ==\n";
$result1 = [
'project' => ['id' => 'P1', 'name' => '数学测验'],
'stats' => ['avg_score' => 85],
'rows' => [
['sid' => 'S01', 'name' => '甲', 'cls' => '1班', 'grp' => 'A', 'score' => 90, 'best_score' => 95, 'attempts' => 2, 'last_submit_at' => '2026-09-10 10:00:00'],
['sid' => 'S02', 'name' => '乙', 'cls' => '1班', 'grp' => 'A', 'score' => 80, 'best_score' => 80, 'attempts' => 1, 'last_submit_at' => '2026-09-10 10:05:00'],
],
];
$result2 = [
'project' => ['id' => 'P1', 'name' => '数学测验'],
'stats' => ['avg_score' => 88],
'rows' => [
['sid' => 'S01', 'name' => '甲', 'cls' => '1班', 'grp' => 'A', 'score' => 92, 'best_score' => 96, 'attempts' => 3, 'last_submit_at' => '2026-09-10 12:00:00'],
['sid' => 'S03', 'name' => '丙', 'cls' => '2班', 'grp' => 'B', 'score' => 88, 'best_score' => 88, 'attempts' => 1, 'last_submit_at' => '2026-09-10 12:10:00'],
],
];
$snap1 = Snapshot::summarize($result1, '第一次');
$snap2 = Snapshot::summarize($result2, '第二次');
$snap1['at'] = 100; $snap2['at'] = 200; // 固定时间戳便于排序
check('summarize 抽取学生数 = 2', count($snap1['students']) === 2);
check('summarize 保留 best_score', (float)$snap1['students'][0]['best_score'] === 95.0);
check('summarize 按学号排序', $snap1['students'][0]['sid'] === 'S01' && $snap1['students'][1]['sid'] === 'S02');
$trend = Snapshot::trend([$snap1, $snap2]);
check('trend 列数 = 2(两份快照)', count($trend['columns']) === 2, 'got ' . count($trend['columns']));
check('trend 列按时间升序(先第一次后第二次)', $trend['columns'][0]['label'] === '第一次' && $trend['columns'][1]['label'] === '第二次');
check('trend 行数 = 3(S01/S02/S03 并集)', count($trend['rows']) === 3, 'got ' . count($trend['rows']));
$s01 = null; foreach ($trend['rows'] as $r) { if ($r['sid'] === 'S01') $s01 = $r; }
check('S01 两次快照成绩 = 95 / 96', $s01 !== null && $s01['scores'][$trend['columns'][0]['key']] == 95 && $s01['scores'][$trend['columns'][1]['key']] == 96);
$s02 = null; foreach ($trend['rows'] as $r) { if ($r['sid'] === 'S02') $s02 = $r; }
check('S02 第二次快照缺席记 null', $s02 !== null && !array_key_exists($trend['columns'][1]['key'], $s02['scores']));
/* ============ 3) 排序:再次提交置顶 ============ */ /* ============ 3) 排序:再次提交置顶 ============ */
echo "\n== 排序(last_submit_ts 置顶)==\n"; echo "\n== 排序(last_submit_ts 置顶)==\n";
// 甲:代表成绩=最高分(98,早提交);最近一次提交=重考 70(晚提交)→ last_submit_ts 更晚,应排在乙前 // 甲:代表成绩=最高分(98,早提交);最近一次提交=重考 70(晚提交)→ last_submit_ts 更晚,应排在乙前
@@ -101,7 +31,7 @@ usort($aggRows, static function (array $x, array $y) use ($sortTs): int {
}); });
check('重考后的甲(last_submit_ts=5000)排在乙前', $aggRows[0]['sid'] === 'S01', json_encode($aggRows, JSON_UNESCAPED_UNICODE)); check('重考后的甲(last_submit_ts=5000)排在乙前', $aggRows[0]['sid'] === 'S01', json_encode($aggRows, JSON_UNESCAPED_UNICODE));
/* ============ 4) XlsxWriter 多工作表 ============ */ /* ============ 2) XlsxWriter 多工作表 ============ */
echo "\n== XlsxWriter 多工作表 ==\n"; echo "\n== XlsxWriter 多工作表 ==\n";
$sheets = [ $sheets = [
['name' => '1班', 'rows' => [['学号', '姓名'], ['S01', '甲']], 'widths' => [16, 14]], ['name' => '1班', 'rows' => [['学号', '姓名'], ['S01', '甲']], 'widths' => [16, 14]],
@@ -132,6 +62,24 @@ if (class_exists('ZipArchive')) {
check('产出 5 个工作表文件', count($names) === 5, 'got ' . count($names) . ': ' . json_encode($names)); check('产出 5 个工作表文件', count($names) === 5, 'got ' . count($names) . ': ' . json_encode($names));
check('sheet 文件名连续 sheet1..sheet5', in_array('xl/worksheets/sheet5.xml', $names, true)); check('sheet 文件名连续 sheet1..sheet5', in_array('xl/worksheets/sheet5.xml', $names, true));
/* ============ 3) 名单导入列识别 ============ */
echo "\n== 名单导入列识别 ==\n";
$meta = [];
$parsed = Roster::rowsToStudents([
['学号', '姓名', '班级', '组别', '登陆账号'],
['2024012001.0', '张三', '1班', 'A', 'stu001'],
['2024012002', '李四', '1班', 'B', 'stu002'],
['2024012001', '张三重复', '1班', 'A', 'stu001'], // 学号重复 → 跳过
['', ''], // 空行 → 跳过
], $meta);
check('识别到 2 人(重复学号与空行被跳过)', count($parsed) === 2, json_encode($parsed, JSON_UNESCAPED_UNICODE));
check('学号去掉 Excel 数字尾巴 .0', ($parsed[0]['sid'] ?? '') === '2024012001', (string)($parsed[0]['sid'] ?? ''));
check('识别班级 / 组别 / 登陆账号列',
($parsed[0]['cls'] ?? '') === '1班' && ($parsed[0]['grp'] ?? '') === 'A' && ($parsed[0]['login'] ?? '') === 'stu001',
json_encode($parsed[0] ?? [], JSON_UNESCAPED_UNICODE));
check('重复学号记入 duplicates', (int)($meta['duplicates'] ?? 0) === 1, (string)($meta['duplicates'] ?? ''));
check('空行记入 skipped', (int)($meta['skipped'] ?? 0) === 1, (string)($meta['skipped'] ?? ''));
echo "\n========================================\n"; echo "\n========================================\n";
echo "通过 {$pass} · 失败 {$fail}\n"; echo "通过 {$pass} · 失败 {$fail}\n";
exit($fail === 0 ? 0 : 1); exit($fail === 0 ? 0 : 1);
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env node
/**
* 文档站本地预览服务
*
* 站点是按 /ny-exam-platform 子路径构建的(base: '/ny-exam-platform/'),
* 直接把 dist 目录当根来跑静态服务会导致所有资源 404。
* 这里做一层前缀重写:把 /ny-exam-platform/<path> 映射到 dist 目录下的 <path>,
* 根路径 / 自动跳转到 /ny-exam-platform/。
*
* 用法:
* node tools/docs-preview.js [端口] [dist目录]
* node tools/docs-preview.js 5199 docs/.vitepress/dist-final
*/
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = parseInt(process.argv[2], 10) || 5199;
const DIST = path.resolve(process.argv[3] || path.join(__dirname, '..', 'docs', '.vitepress', 'dist-final'));
const BASE = (process.env.DOCS_BASE || '/ny-exam-platform').replace(/\/+$/, '');
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.txt': 'text/plain; charset=utf-8',
'.map': 'application/json; charset=utf-8',
};
if (!fs.existsSync(DIST)) {
console.error('[docs-preview] 找不到构建产物:' + DIST);
console.error('请先构建:DOCS_OUT=.vitepress/dist-final npx vitepress build .');
process.exit(1);
}
/** 把 URL 中的前缀剥掉,得到 dist 内的相对路径 */
function toRel(urlPath) {
let p = decodeURIComponent(urlPath.split('?')[0].split('#')[0]);
if (p === BASE || p === BASE + '/') return '/index.html';
if (p.startsWith(BASE + '/')) p = p.slice(BASE.length);
return p;
}
/** 依次尝试:原样 / .html / 目录下 index.html,模拟静态托管的行为 */
function resolveFile(rel) {
const clean = path.normalize(rel).replace(/^([/\\])+/, '');
const full = path.join(DIST, clean);
if (full.indexOf(DIST) !== 0) return null; // 防目录穿越
const candidates = [full, full + '.html', path.join(full, 'index.html')];
for (const c of candidates) {
try {
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
} catch (e) { /* 忽略 */ }
}
return null;
}
const server = http.createServer((req, res) => {
const urlPath = req.url || '/';
// 根路径 / 直接跳到子路径首页
if (urlPath === '/' || urlPath === '') {
res.writeHead(302, { Location: BASE + '/' });
res.end();
return;
}
const rel = toRel(urlPath);
const file = resolveFile(rel);
if (!file) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('404 Not Found: ' + urlPath);
return;
}
const ext = path.extname(file).toLowerCase();
res.writeHead(200, {
'Content-Type': MIME[ext] || 'application/octet-stream',
'Cache-Control': 'no-cache',
});
fs.createReadStream(file).pipe(res);
});
server.listen(PORT, '127.0.0.1', () => {
console.log('[docs-preview] 文档站已启动');
console.log(' 访问地址:http://127.0.0.1:' + PORT + BASE + '/');
console.log(' 构建目录:' + DIST);
});
server.on('error', e => {
console.error('[docs-preview] 启动失败:' + e.message);
process.exit(1);
});
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* 修复 / 重置系统连接配置(终端一次性工具)
*
* 背景:
* 连接配置同时存在于 data/ny.db(SQLite,加载时优先)与 data/config.json(文件备份)。
* ny_load_config() 以 DB 为准,因此一旦部署实例的 ny.db 被 e2e 测试串味或误改写成
* 127.0.0.1 / mock 地址,登录页就会显示「系统连接信息已配置(127.0.0.1/api)」且连不上真实卷王。
* 本工具通过 ny_save_config 双写,一次性把两处都纠正回正确的生产地址。
*
* 用法:
* php tools/fix-config.php [--base-url=https://exam.nxxy335.top] [--api-prefix=/api] [--keep-creds]
*
* 默认行为:
* - base_url 修正为 https://exam.nxxy335.top(可用 --base-url 覆盖)
* - api_prefix 修正为 /api
* - 账号/密码清空(绝不臆造真实卷王凭据),需在登录页重新输入真实账号
* - 加 --keep-creds 则保留现有账号密码不动
*
* 测试隔离:可用 NY_DATA=/tmp/xxx 指向临时目录,不影响生产库。
*/
declare(strict_types=1);
require __DIR__ . '/../bootstrap.php';
$args = array_slice($argv, 1);
$baseUrl = 'https://exam.nxxy335.top';
$apiPrefix = '/api';
$keepCreds = false;
foreach ($args as $a) {
if (strncmp($a, '--base-url=', 11) === 0) {
$baseUrl = substr($a, 11);
} elseif (strncmp($a, '--api-prefix=', 13) === 0) {
$apiPrefix = substr($a, 13);
} elseif ($a === '--keep-creds') {
$keepCreds = true;
} elseif ($a === '--help' || $a === '-h') {
echo "用法: php tools/fix-config.php [--base-url=URL] [--api-prefix=/api] [--keep-creds]\n";
exit(0);
}
}
$cfg = ny_load_config();
$before = (string)($cfg['base_url'] ?? '');
$cfg['base_url'] = rtrim(trim($baseUrl), '/');
$cfg['api_prefix'] = '/' . ltrim(trim($apiPrefix), '/');
if (!$keepCreds) {
$cfg['username'] = '';
$cfg['password'] = '';
}
$ok = ny_save_config($cfg);
if (!$ok) {
fwrite(STDERR, "保存失败:DB 与文件均不可写?请检查 " . NY_DATA . " 目录权限。\n");
exit(1);
}
$host = (string) parse_url($cfg['base_url'], PHP_URL_HOST);
echo "修复前 base_url = " . ($before === '' ? '(空)' : $before) . "\n";
echo "修复后 base_url = " . $cfg['base_url'] . "\n";
echo "api_prefix = " . $cfg['api_prefix'] . "\n";
echo "登录页将显示 = 系统连接信息已配置(" . $host . $cfg['api_prefix'] . ")\n";
echo "账号/密码 = " . ($keepCreds ? '保持不变' : '已清空(请在登录页重新输入真实卷王账号)') . "\n";
echo "配置来源 = " . ny_config_source() . "\n";
echo "OK\n";
+16 -10
View File
@@ -11,10 +11,23 @@ const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite'; const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
// 1) 备份 / 准备文件存储模式 // 1) 备份 / 准备文件存储模式
const cfgFile = path.join(ROOT, 'data', 'config.json'); // 数据目录默认用项目下的 data/,但优先读 NY_DATA 环境变量(bootstrap.php 同样支持):
// 测试统一把 NY_DATA 指向临时目录,彻底避免把 mock 配置写进生产库(曾导致生产 base_url 被改成 127.0.0.1)。
// Git Bash 风格路径 /c/Users/... 在 Windows 上 path.resolve 会误判成 C:\c\...,需还原成 C:\Users\...;
// 规范化后写回 process.env.NY_DATA,让子进程(PHP 内置服务器)也拿到正确的 Windows 绝对路径。
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 cfgFile = path.join(DATA, 'config.json');
const cfgBak = cfgFile + '.bak'; const cfgBak = cfgFile + '.bak';
const origCfg = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, 'utf8') : null; const origCfg = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, 'utf8') : null;
const tokenFile = path.join(ROOT, 'data', 'cache', 'token.json'); const tokenFile = path.join(DATA, 'cache', 'token.json');
const tokenBak = tokenFile + '.bak'; const tokenBak = tokenFile + '.bak';
const hasToken = fs.existsSync(tokenFile); const hasToken = fs.existsSync(tokenFile);
const tokenSnap = hasToken ? fs.readFileSync(tokenFile, 'utf8') : null; const tokenSnap = hasToken ? fs.readFileSync(tokenFile, 'utf8') : null;
@@ -22,15 +35,11 @@ if (hasToken) fs.renameSync(tokenFile, tokenBak);
// SQLite 优先:若不先移走 data/ny.db,里面的旧配置会盖掉本测试写入的 config.json, // SQLite 优先:若不先移走 data/ny.db,里面的旧配置会盖掉本测试写入的 config.json,
// 导致登录用的是上一次遗留的账号密码(表现为"登录成功但 auth_check=false")。 // 导致登录用的是上一次遗留的账号密码(表现为"登录成功但 auth_check=false")。
// 同时清掉登录限流记录,避免上一轮失败把本轮锁住。 const dbFile = path.join(DATA, 'ny.db');
const dbFile = path.join(ROOT, 'data', 'ny.db');
const dbBak = dbFile + '.bak'; const dbBak = dbFile + '.bak';
const hasDb = fs.existsSync(dbFile); const hasDb = fs.existsSync(dbFile);
if (hasDb) { try { fs.renameSync(dbFile, dbBak); } catch (e) {} } if (hasDb) { try { fs.renameSync(dbFile, dbBak); } catch (e) {} }
const throttleFile = path.join(ROOT, 'data', 'login_fail.json');
if (fs.existsSync(throttleFile)) { try { fs.unlinkSync(throttleFile); } catch (e) {} }
fs.writeFileSync(cfgFile, JSON.stringify({ fs.writeFileSync(cfgFile, JSON.stringify({
base_url: 'http://127.0.0.1:19911', base_url: 'http://127.0.0.1:19911',
api_prefix: '/api', api_prefix: '/api',
@@ -67,9 +76,6 @@ function cleanup() {
if (fs.existsSync(dbFile)) fs.unlinkSync(dbFile); if (fs.existsSync(dbFile)) fs.unlinkSync(dbFile);
if (hasDb && fs.existsSync(dbBak)) fs.renameSync(dbBak, dbFile); if (hasDb && fs.existsSync(dbBak)) fs.renameSync(dbBak, dbFile);
} catch(e) { console.error('cleanup db failed:', e.message); } } catch(e) { console.error('cleanup db failed:', e.message); }
try {
if (fs.existsSync(throttleFile)) fs.unlinkSync(throttleFile);
} catch(e) {}
} }
process.on('exit', cleanup); process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(1); }); process.on('SIGINT', () => { cleanup(); process.exit(1); });
+14 -12
View File
@@ -16,9 +16,20 @@ const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.e
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite'; 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; const DEBUG = !!process.env.NY_DEBUG;
const DATA = path.join(ROOT, 'data'); // 数据目录优先读 NY_DATA 环境变量(与 bootstrap.php 一致),让测试整体指向临时目录,
// 不污染生产库(曾因写 mock 配置把生产 base_url 改成 127.0.0.1)。
// Git Bash 风格 /c/Users/... 在 Windows 上 path.resolve 会误判成 C:\c\...,需还原;并把规范化后的
// Windows 绝对路径写回 process.env.NY_DATA,让子进程(PHP 内置服务器)也拿到正确路径。
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 UPLOADS = path.join(DATA, 'uploads');
const throttleFile = path.join(DATA, 'login_fail.json');
// ---------- 备份 / 还原运行时数据 ---------- // ---------- 备份 / 还原运行时数据 ----------
// ny.db 必须移走:配置是 DB 优先,留着库里的真实配置会盖掉本测试写入的 config.json。 // ny.db 必须移走:配置是 DB 优先,留着库里的真实配置会盖掉本测试写入的 config.json。
@@ -41,13 +52,6 @@ for (const f of ['config.json', 'roster.json', 'project_config.json']) {
catch (e) { console.error(' 备份失败(复制)' + f + ':' + e.message); } catch (e) { console.error(' 备份失败(复制)' + f + ':' + e.message); }
} }
} }
// 清空登录限流记录:用覆盖写而不是删除(部分环境下删除会被安全策略拦掉)
function clearThrottle() {
try { fs.writeFileSync(throttleFile, '{}'); } catch (e) {}
try { if (fs.existsSync(throttleFile)) fs.unlinkSync(throttleFile); } catch (e) {}
}
clearThrottle();
const cfgFile = path.join(DATA, 'config.json'); const cfgFile = path.join(DATA, 'config.json');
fs.writeFileSync(cfgFile, JSON.stringify({ fs.writeFileSync(cfgFile, JSON.stringify({
base_url: 'http://127.0.0.1:' + MOCK_PORT, base_url: 'http://127.0.0.1:' + MOCK_PORT,
@@ -81,7 +85,6 @@ function cleanup() {
const p = path.join(DATA, f); const p = path.join(DATA, f);
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch (e) {} try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch (e) {}
} }
clearThrottle();
// 统一用「覆盖写回 + 尽力删除备份」的方式还原,避免删除被拦导致还原失败 // 统一用「覆盖写回 + 尽力删除备份」的方式还原,避免删除被拦导致还原失败
for (const [p, bak] of moved.concat(copied)) { 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.copyFileSync(bak, p); } catch (e) {}
@@ -199,9 +202,8 @@ function chk(name, cond, extra) {
process.exit(1); process.exit(1);
} }
// 登录(失败会触发 5 次锁定,所以每次尝试前清掉限流记录) // 登录(最多重试 3 次)
for (let i = 0; i < 3 && CSRF === ''; i++) { for (let i = 0; i < 3 && CSRF === ''; i++) {
clearThrottle(); // 上一次尝试若失败会累计限流,这里先清零
const lg = await formPost('/login.php', { username: 'mock', password: 'surveyking' }); const lg = await formPost('/login.php', { username: 'mock', password: 'surveyking' });
const ac = await get('/api.php?action=auth_check'); const ac = await get('/api.php?action=auth_check');
if (DEBUG) { if (DEBUG) {