文档部署到 /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; 看板顶栏窄屏下拉与按钮换行,消除横向滚动
This commit is contained in:
@@ -128,7 +128,7 @@ if ($action !== 'auth_check' && !ny_is_auth()) {
|
||||
// CSRF 防护:所有写操作必须携带会话绑定的 token(GET 读取类接口不受影响)
|
||||
$WRITE_ACTIONS = [
|
||||
'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()) {
|
||||
ny_json(['ok' => false, 'msg' => '安全校验失败(CSRF token 缺失或不匹配),请刷新页面后重试'], 403);
|
||||
@@ -578,64 +578,6 @@ try {
|
||||
Roster::save($roster);
|
||||
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':
|
||||
$projectId = (string)($_GET['projectId'] ?? '');
|
||||
|
||||
+239
-220
@@ -259,7 +259,6 @@
|
||||
state.absent = d.absent || [];
|
||||
state.unmatched = d.unmatched || [];
|
||||
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.passRate = (d.config && d.config.pass_rate != null) ? (parseFloat(d.config.pass_rate) || 60) : 60;
|
||||
|
||||
@@ -488,6 +487,7 @@
|
||||
set('#sTotal', st.total);
|
||||
set('#sSubmit', st.submitted);
|
||||
set('#sAbsent', st.absent);
|
||||
set('#sRate', st.rate + '%');
|
||||
set('#sAvg', st.avg == null ? '-' : st.avg);
|
||||
set('#sMax', st.maxScore == null ? '-' : st.maxScore);
|
||||
set('#sMin', st.minScore == null ? '-' : st.minScore);
|
||||
@@ -521,10 +521,19 @@
|
||||
return h + ' 小时 ' + String(m % 60).padStart(2, '0') + ' 分';
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩着色:按「占本卷满分的百分比」判断,而不是写死 90 / 60 分。
|
||||
* 否则把满分改成 50 分(或卷子本身是 20 分制)时,整列都会被误判为不及格标红。
|
||||
*/
|
||||
function scoreClass(v) {
|
||||
if (v == null) return '';
|
||||
if (v >= 90) return 'score-high';
|
||||
if (v >= 60) return 'score-mid';
|
||||
const full = state.fullScore || 100;
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -662,6 +671,16 @@
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -698,8 +717,9 @@
|
||||
* 虚拟滚动:只渲染可视窗口内的行,用顶部/底部 spacer 撑起完整滚动高度。
|
||||
* 行数很多(尤其「全部展开」历史模式下)时,避免一次性插入成千上万 DOM 节点导致卡顿。
|
||||
*/
|
||||
const ROW_H = 41; // 估算单行高度(含 padding/border),用于窗口定位
|
||||
const ROW_H_FALLBACK = 41; // 首帧未测量前的行高估值
|
||||
const VS_OVERSCAN = 12; // 视口上下各多渲染的行数,减少快速滚动时的白屏
|
||||
let measuredRowH = 0; // 实测行高(渲染后回写),CSS/字体/缩放变化都能自动适应
|
||||
function renderVirtualRows(tb, rows) {
|
||||
const scroller = tb.closest('.table-scroll');
|
||||
if (!scroller) {
|
||||
@@ -710,24 +730,33 @@
|
||||
const scrollTop = scroller.scrollTop;
|
||||
const viewH = scroller.clientHeight;
|
||||
const total = rows.length;
|
||||
let start = Math.floor(scrollTop / ROW_H) - VS_OVERSCAN;
|
||||
let end = Math.ceil((scrollTop + viewH) / ROW_H) + VS_OVERSCAN;
|
||||
const rh = measuredRowH || ROW_H_FALLBACK;
|
||||
let start = Math.floor(scrollTop / rh) - VS_OVERSCAN;
|
||||
let end = Math.ceil((scrollTop + viewH) / rh) + VS_OVERSCAN;
|
||||
if (start < 0) start = 0;
|
||||
if (end > total) end = total;
|
||||
if (start >= end) start = Math.max(0, end - 1);
|
||||
|
||||
const frag = [];
|
||||
if (start > 0) {
|
||||
frag.push('<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++) {
|
||||
frag.push(rowHtml(rows[i]));
|
||||
}
|
||||
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('');
|
||||
|
||||
// 渲染后回写真实行高:换字体、改样式、页面缩放时窗口定位都不会漂移
|
||||
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) */
|
||||
function onTableScroll() {
|
||||
@@ -771,6 +800,12 @@
|
||||
|
||||
function renderUnmatched() {
|
||||
const tb = $('#unmatchedBody');
|
||||
const hint = $('#unmatchedHint');
|
||||
if (hint) {
|
||||
hint.textContent = state.unmatched.length
|
||||
? ('共 ' + state.unmatched.length + ' 份 · 姓名与名单对不上,核对后可点「加入名单」补录')
|
||||
: '姓名不在名单中';
|
||||
}
|
||||
if (!state.unmatched.length) {
|
||||
tb.innerHTML = '<tr><td colspan="4" class="empty">无</td></tr>';
|
||||
return;
|
||||
@@ -914,8 +949,9 @@
|
||||
$('#cfgHistoryDisplay').value = state.historyDisplay;
|
||||
$('#cfgExportSort').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 || '';
|
||||
$('#cfgPass').value = '';
|
||||
const conn = c.db_configured
|
||||
@@ -1124,12 +1160,23 @@
|
||||
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 => {
|
||||
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 () => {
|
||||
@@ -1150,6 +1197,8 @@
|
||||
return (isNaN(v) || v < 1 || v > 100) ? 60 : v;
|
||||
})(),
|
||||
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(),
|
||||
history_display: $('#cfgHistoryDisplay').value,
|
||||
export_sort: $('#cfgExportSort').value,
|
||||
@@ -1203,10 +1252,12 @@
|
||||
box.classList.remove('hidden', 'err'); box.classList.add('loading');
|
||||
box.textContent = '正在连接卷王系统测试…';
|
||||
try {
|
||||
// 仅传可编辑的连接字段;base_url / api_prefix 在弹窗中只读,沿用已保存值
|
||||
// 全部连接字段都从弹窗当前值取(未保存也能测)
|
||||
const r = await api('config_test', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
base_url: $('#cfgBase').value.trim(),
|
||||
api_prefix: ($('#cfgPrefix') ? $('#cfgPrefix').value.trim() : '/api') || '/api',
|
||||
username: $('#cfgUser').value.trim(),
|
||||
password: $('#cfgPass').value,
|
||||
verify_ssl: $('#cfgSsl').checked
|
||||
@@ -1384,102 +1435,67 @@
|
||||
/* 成绩快照 / 趋势 */
|
||||
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() {
|
||||
const rows = (state._sorted || []).slice();
|
||||
if (!rows.length) return showAlert('当前没有可打印的成绩', 'warn');
|
||||
@@ -1488,23 +1504,27 @@
|
||||
const groupBy = $('#ptGroup').value; // none | cls | grp
|
||||
const showRank = $('#ptRank').checked;
|
||||
const showAtt = $('#ptAttempts').checked;
|
||||
const showPass = $('#ptPass').checked;
|
||||
const withAbsent= $('#ptAbsent').checked;
|
||||
const orient = $('#ptOrient').value;
|
||||
const full = state.fullScore || 100;
|
||||
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);
|
||||
if (an && bn) return 0;
|
||||
if (an && bn) return sidCmp(a, b);
|
||||
if (an) 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_asc') return (a.score - b.score) || ((b.submit_ts || 0) - (a.submit_ts || 0));
|
||||
if (sortMode === 'sid') return String(a.sid).localeCompare(String(b.sid), 'zh', { numeric: true });
|
||||
return ((b.submit_ts || 0) - (a.submit_ts || 0)); // time
|
||||
};
|
||||
rows.sort(cmp);
|
||||
if (sortMode === 'score_desc') return (b.score - a.score) || sidCmp(a, b);
|
||||
if (sortMode === 'score_asc') return (a.score - b.score) || sidCmp(a, b);
|
||||
if (sortMode === 'sid') return sidCmp(a, b);
|
||||
return ((b.submit_ts || 0) - (a.submit_ts || 0)) || sidCmp(a, b); // time
|
||||
});
|
||||
|
||||
const passOf = r => (r.score != null && r.score >= (passRate / 100) * full);
|
||||
const passOf = r => (r.score != null && r.score >= passLine);
|
||||
const statOf = list => {
|
||||
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;
|
||||
@@ -1512,29 +1532,40 @@
|
||||
return {
|
||||
n: list.length,
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
const head = '<tr><th>' + (showRank ? '名次' : '') + '</th><th>学号</th><th>姓名</th>' +
|
||||
(groupBy === 'none' ? '<th>班级</th><th>组别</th>' : '') +
|
||||
'<th>成绩</th>' + (showAtt ? '<th>考试次数</th>' : '') + '<th>提交时间</th></tr>';
|
||||
const head = '<tr>' + (showRank ? '<th class="c" style="width:44px">名次</th>' : '') +
|
||||
'<th style="width:96px">学号</th><th style="width:76px">姓名</th>' +
|
||||
(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;
|
||||
const renderRows = (list, offsetRank) => list.map((r, i) => {
|
||||
const rk = (r.score == null) ? '' : (offsetRank + i + 1);
|
||||
return '<tr>' + (showRank ? '<td class="c">' + rk + '</td>' : '') +
|
||||
// 名次:整份名单连续编号(分组时也连续),无成绩不参与排名
|
||||
let seq = 0;
|
||||
const renderRows = list => list.map(r => {
|
||||
const ranked = (r.score != null);
|
||||
if (ranked) seq++;
|
||||
const ok = passOf(r);
|
||||
return '<tr>' + (showRank ? '<td class="c">' + (ranked ? seq : '—') + '</td>' : '') +
|
||||
'<td>' + esc(r.sid || '') + '</td>' +
|
||||
'<td>' + esc(r.name || '') + '</td>' +
|
||||
(groupBy === 'none' ? '<td>' + esc(r.cls || '') + '</td><td>' + esc(r.grp || '') + '</td>' : '') +
|
||||
'<td class="c"><b>' + (r.score == null ? '—' : r.score) + '</b></td>' +
|
||||
(groupBy === 'none' ? '<td>' + esc(r.cls || '') + '</td><td class="c">' + esc(r.grp || '') + '</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>' : '') +
|
||||
'<td>' + esc(r.submit_at || '') + '</td></tr>';
|
||||
'<td>' + esc(r.submit_at || '—') + '</td></tr>';
|
||||
}).join('');
|
||||
|
||||
let html = '';
|
||||
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 {
|
||||
const keyOf = (groupBy === 'cls') ? (r => (r.cls || '未分班')) : (r => (r.grp || '未分组'));
|
||||
const groups = {};
|
||||
@@ -1544,45 +1575,54 @@
|
||||
const list = groups[k];
|
||||
const st = statOf(list);
|
||||
html += '<h3 class="grp">' + esc(k) +
|
||||
' <small>(' + st.n + ' 人 · 平均 ' + st.avg + ' · 合格率 ' + st.passRate + '%)</small></h3>';
|
||||
html += '<table><thead>' + head + '</thead><tbody>' + renderRows(list, 0) + '</tbody></table>';
|
||||
' <small>(' + st.n + ' 人 · 平均 ' + st.avg + ' · 最高 ' + st.max + ' · 最低 ' + st.min +
|
||||
' · 合格 ' + st.pass + ' 人 / ' + st.passRate + '%)</small></h3>';
|
||||
html += '<table><thead>' + head + '</thead><tbody>' + renderRows(list) + '</tbody></table>';
|
||||
});
|
||||
}
|
||||
|
||||
const all = statOf(rows);
|
||||
const title = (state.projectName ? state.projectName + ' · ' : '') + '成绩明细';
|
||||
const cls = $('#clsSel').value, grp = $('#grpSel').value;
|
||||
const scope = (cls ? cls + '班 ' : '') + (grp ? grp + '组' : '');
|
||||
const proj = state.projectName || '';
|
||||
const title = ($('#ptTitle').value.trim() || (proj ? proj + ' 成绩明细' : '成绩明细'));
|
||||
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 (!w) return showAlert('浏览器拦截了弹窗,请允许后重试', 'error');
|
||||
w.document.write('<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>' + esc(title) + '</title>' +
|
||||
'<style>' +
|
||||
'body{font-family:"Microsoft YaHei",sans-serif;margin:20px;color:#000}' +
|
||||
'h2{margin:0 0 4px;font-size:18px}' +
|
||||
'.meta{font-size:12px;color:#555;margin-bottom:14px}' +
|
||||
'h3.grp{font-size:14px;margin:18px 0 6px;padding:6px 8px;background:#f0f0f0;border-left:4px solid #333}' +
|
||||
'h3.grp small{font-weight:400;color:#555}' +
|
||||
'table{border-collapse:collapse;width:100%;margin-bottom:10px;font-size:12px}' +
|
||||
'th,td{border:1px solid #666;padding:5px 8px;text-align:left}' +
|
||||
'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>' +
|
||||
'<h2>' + esc(title) + '</h2>' +
|
||||
'<div class="meta">' + esc(scope) + ' 共 ' + all.n + ' 人 · 平均 ' + all.avg +
|
||||
' · 合格率 ' + all.passRate + '%(合格线:满分的 ' + passRate + '%,即 ' +
|
||||
(Math.round(passRate / 100 * full * 10) / 10) + ' 分) · 打印时间:' + new Date().toLocaleString() + '</div>' +
|
||||
html +
|
||||
'<scr' + 'ipt>window.onload=function(){setTimeout(function(){window.print()},300)}</scr' + 'ipt>' +
|
||||
'</body></html>');
|
||||
w.document.close();
|
||||
if (withAbsent) {
|
||||
const absent = state._absentList || [];
|
||||
body += '<h3 class="grp" style="page-break-before:always">缺考名单 <small>(' + absent.length + ' 人)</small></h3>';
|
||||
if (!absent.length) {
|
||||
body += '<p>无缺考学生。</p>';
|
||||
} 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>' +
|
||||
absent.map(a => '<tr><td>' + esc(a.sid || '') + '</td><td>' + esc(a.name || '') + '</td><td>' +
|
||||
esc(a.cls || '') + '</td><td class="c">' + esc(a.grp || '') + '</td><td></td></tr>').join('') +
|
||||
'</tbody></table>';
|
||||
}
|
||||
}
|
||||
|
||||
body += '<div class="sign">教师签字:____________ 教研组长:____________ 日期:____ 年 ____ 月 ____ 日</div>';
|
||||
body += '<div class="foot">' + esc(NY.appName || '') + ' · 本文档由系统生成,仅供教学参考</div>';
|
||||
|
||||
printDoc(title, body, { orient: orient });
|
||||
showAlert('已调起打印,如未弹出请在打印对话框中选择正确打印机', 'info');
|
||||
}
|
||||
|
||||
$('#btnPrintTable').addEventListener('click', () => {
|
||||
if (!curPid()) return showAlert('请先选择问卷 / 考试');
|
||||
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');
|
||||
});
|
||||
$('#btnPtPrint').addEventListener('click', () => {
|
||||
@@ -1640,54 +1680,6 @@
|
||||
} 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) {
|
||||
const done = () => showAlert(what + '已复制到剪贴板', 'info');
|
||||
@@ -1714,20 +1706,47 @@
|
||||
copyText(list.map(a => a.name).join('、'), '姓名');
|
||||
});
|
||||
$('#btnPrintAbsent').addEventListener('click', () => {
|
||||
const list = state._absentList || [];
|
||||
const list = (state._absentList || []).slice();
|
||||
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 project = state.projectName || '';
|
||||
const title = (project ? project + ' · ' : '') + '缺考名单(' + list.length + ' 人)';
|
||||
const w = window.open('', '_blank');
|
||||
if (!w) return showAlert('浏览器拦截了弹窗,请允许后重试', 'error');
|
||||
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>' +
|
||||
'<table><thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>组别</th></tr></thead><tbody>' + rows + '</tbody></table>' +
|
||||
'<script>window.onload=function(){setTimeout(function(){window.print()},300)}<\/script></body></html>');
|
||||
w.document.close();
|
||||
|
||||
const sidCmp = (a, b) => String(a.sid || '').localeCompare(String(b.sid || ''), 'zh', { numeric: true });
|
||||
list.sort((a, b) => {
|
||||
const ac = String(a.cls || ''), bc = String(b.cls || '');
|
||||
if (ac !== bc) return ac.localeCompare(bc, 'zh', { numeric: true });
|
||||
return sidCmp(a, b);
|
||||
});
|
||||
|
||||
const proj = state.projectName || '';
|
||||
const title = (proj ? proj + ' · ' : '') + '缺考名单(' + list.length + ' 人)';
|
||||
const rowHtml = r => '<tr><td>' + esc(r.sid || '') + '</td><td>' + esc(r.name || '') + '</td><td>' +
|
||||
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
@@ -35,6 +35,7 @@
|
||||
log: [], // 历史批次,新的在前
|
||||
customRatios: {}, // 自定义比例:{ 组名: 权重 }
|
||||
drawing: false,
|
||||
all: false, // 「全部」模式:人数始终跟随当前候选池(手工改输入框即解除)
|
||||
};
|
||||
|
||||
/* ---------------- 接口 ---------------- */
|
||||
@@ -82,7 +83,7 @@
|
||||
/** 兼容旧 showAlert 调用 → 走 toast(draw.js 内部已有 esc,此处直接复用) */
|
||||
function showAlert(msg, type) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -345,6 +346,11 @@
|
||||
|
||||
function renderStats() {
|
||||
const pool = currentPool();
|
||||
// 「全部」模式:候选池变化(改筛选 / 改来源 / 改排除)时人数跟着变
|
||||
if (state.all) {
|
||||
const ni = $('#numInput');
|
||||
if (ni) ni.value = String(Math.max(1, pool.length));
|
||||
}
|
||||
$('#dPool').textContent = pool.length;
|
||||
$('#dPicked').textContent = state.picked.length;
|
||||
$('#dHistory').textContent = state.history.size;
|
||||
@@ -395,9 +401,11 @@
|
||||
showAlert('当前筛选下没有可抽取的人员(可关闭「排除已抽中」、调整班级 / 组别或排除名单)');
|
||||
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 > pool.length) n = pool.length;
|
||||
let capped = false;
|
||||
if (n > pool.length) { n = pool.length; capped = true; }
|
||||
|
||||
state.drawing = true;
|
||||
const stage = $('#drawStage');
|
||||
@@ -437,9 +445,15 @@
|
||||
picked.forEach(m => state.history.add(String(m.sid)));
|
||||
|
||||
stage.className = 'draw-stage done';
|
||||
stage.textContent = n === 1
|
||||
? (picked[0].name || picked[0].sid)
|
||||
: ('🎉 已抽取 ' + n + ' 人');
|
||||
if (n === 1 && picked.length) {
|
||||
// 只抽 1 人时把学号 / 班级 / 组别一起显示出来,教师不用再去表里找
|
||||
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();
|
||||
renderStats();
|
||||
@@ -449,24 +463,35 @@
|
||||
state.drawing = false;
|
||||
btn.disabled = false;
|
||||
const tail = detail.length ? '(' + detail.join(' / ') + ')' : '';
|
||||
if (capped) {
|
||||
showAlert('候选池只有 ' + pool.length + ' 人,已按 ' + n + ' 人抽取(可关闭「排除已抽中」或调整筛选扩大候选)', 'warn');
|
||||
} else {
|
||||
showAlert('已抽取 ' + n + ' 人' + tail + (n > 1 ? ',可点击右上角按钮下载白名单' : ''), 'info');
|
||||
}
|
||||
}
|
||||
|
||||
function renderResult() {
|
||||
const tb = $('#drawBody');
|
||||
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;
|
||||
}
|
||||
tb.innerHTML = state.picked.map((m, i) =>
|
||||
'<tr>' +
|
||||
tb.innerHTML = state.picked.map((m, i) => {
|
||||
// 登陆账号为空 → 导出会回退到学号,这里显式标红提示教师先去名单里补全
|
||||
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="sid">' + esc(m.sid) + '</td>' +
|
||||
'<td class="name">' + esc(m.name) + '</td>' +
|
||||
'<td>' + esc(m.cls || '') + '</td>' +
|
||||
'<td>' + esc(m.grp || '') + '</td>' +
|
||||
'<td class="sid">' + loginCell + '</td>' +
|
||||
'<td><button class="btn sm danger" data-drop="' + i + '" title="移出结果并加入排除名单">排除</button></td>' +
|
||||
'</tr>').join('');
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* ================= 历史记录 ================= */
|
||||
@@ -627,6 +652,7 @@
|
||||
cls: $('#clsSel').value,
|
||||
grp: $('#grpSel').value,
|
||||
num: $('#numInput').value,
|
||||
all: state.all,
|
||||
exclude: $('#excludePicked').checked,
|
||||
mode: $('#balanceMode').value,
|
||||
customRatios: cr,
|
||||
@@ -700,6 +726,8 @@
|
||||
});
|
||||
});
|
||||
$('#numInput').addEventListener('change', saveSettings);
|
||||
// 手工改人数即解除「全部」模式
|
||||
$('#numInput').addEventListener('input', () => { state.all = false; });
|
||||
$('#excludePicked').addEventListener('change', () => { renderStats(); saveSettings(); });
|
||||
$('#balanceMode').addEventListener('change', () => {
|
||||
renderRatioBox();
|
||||
@@ -718,9 +746,10 @@
|
||||
b.addEventListener('click', () => {
|
||||
const v = b.dataset.num;
|
||||
if (v === 'all') {
|
||||
const pool = currentPool();
|
||||
$('#numInput').value = String(Math.max(1, pool.length));
|
||||
state.all = true;
|
||||
$('#numInput').value = String(Math.max(1, currentPool().length));
|
||||
} else {
|
||||
state.all = false;
|
||||
$('#numInput').value = v;
|
||||
}
|
||||
saveSettings();
|
||||
@@ -865,11 +894,23 @@
|
||||
}
|
||||
});
|
||||
|
||||
/* --- 弹窗通用关闭 --- */
|
||||
/* --- 弹窗通用关闭:只关最上层那一个,避免连带关掉下层的弹窗 --- */
|
||||
document.addEventListener('click', e => {
|
||||
if (e.target.matches('[data-close]') || e.target.classList.contains('modal')) {
|
||||
$$('.modal').forEach(m => m.classList.remove('open'));
|
||||
}
|
||||
const isClose = (e.target.matches && e.target.matches('[data-close]')) ||
|
||||
(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 show = (mode === 'custom') && g.key;
|
||||
box.classList.toggle('hidden', !show);
|
||||
if (!show) return;
|
||||
// 隐藏时必须清空:否则旧的 data-gk 输入框还会被 saveSettings 读走,
|
||||
// 下次切成自定义比例时会误用上一批分组的权重
|
||||
if (!show) { box.innerHTML = ''; return; }
|
||||
box.innerHTML =
|
||||
'<p class="hint" style="margin:0 0 6px">按上面分组的顺序填写权重,例:组A=3,组B=1。留空=不计该组。</p>' +
|
||||
g.gk.map((k, i) => {
|
||||
@@ -912,6 +955,7 @@
|
||||
const mode = s.mode || (s.balance === false ? 'off' : 'weight');
|
||||
$('#balanceMode').value = mode;
|
||||
if (s.num) $('#numInput').value = s.num;
|
||||
state.all = !!s.all;
|
||||
if (s.source === 'project') {
|
||||
state.source = 'project';
|
||||
$('#sourceSel').value = 'project';
|
||||
|
||||
+44
-1
@@ -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); }
|
||||
.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 input { opacity: 0; width: 0; height: 0; }
|
||||
.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.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
td.sid { font-weight: 600; color: var(--text-2); font-variant-numeric: tabular-nums; }
|
||||
/* 数值列右对齐:成绩 / 次数 / 时长纵向扫读时更容易对齐比对 */
|
||||
table.grid td.num, table.grid td.score { text-align: right; }
|
||||
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.score { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.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 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-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 .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 .btn { background: rgba(255, 255, 255, .14); border-color: transparent; color: #fff; }
|
||||
.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);
|
||||
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 {
|
||||
from { transform: translateY(-1px) scale(.995); }
|
||||
to { transform: translateY(1px) scale(1.005); }
|
||||
|
||||
+1
-2
@@ -200,7 +200,7 @@ function ny_is_installed(): bool
|
||||
}
|
||||
|
||||
// ---------------- 目录初始化 ----------------
|
||||
foreach (['', '/cache', '/uploads', '/snapshots'] as $sub) {
|
||||
foreach (['', '/cache', '/uploads'] as $sub) {
|
||||
$dir = NY_DATA . $sub;
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0777, true);
|
||||
@@ -499,7 +499,6 @@ spl_autoload_register(function (string $class): void {
|
||||
'Roster' => NY_ROOT . '/lib/Roster.php',
|
||||
'DataService' => NY_ROOT . '/lib/DataService.php',
|
||||
'DataCache' => NY_ROOT . '/lib/DataCache.php',
|
||||
'Snapshot' => NY_ROOT . '/lib/Snapshot.php',
|
||||
'Db' => NY_ROOT . '/lib/Db.php',
|
||||
];
|
||||
if (isset($map[$class]) && is_file($map[$class])) {
|
||||
|
||||
+65
-20
@@ -40,6 +40,8 @@ if (!ny_is_auth()) {
|
||||
</select>
|
||||
<button class="btn" id="btnRefresh">刷新</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>
|
||||
|
||||
@@ -237,13 +239,23 @@ function membersTable(members){
|
||||
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>' +
|
||||
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 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 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="name">' + esc(m.name) + tag + '</td>' +
|
||||
'<td class="score num ' + cls + '">' + (sc == null ? '-' : sc) + '</td>' +
|
||||
@@ -276,6 +288,22 @@ function closeAttempts(){
|
||||
const m = document.querySelector('#attemptsModal');
|
||||
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(){
|
||||
@@ -302,7 +330,8 @@ function markStale(msg){
|
||||
const d = $('#liveDot');
|
||||
if (d) { d.classList.remove('on'); d.classList.add('stale'); }
|
||||
const lu = $('#lastUpdate');
|
||||
if (lu) lu.textContent = '刷新中断(仍显示上次数据)';
|
||||
// 具体原因放进 title(鼠标悬停可见),正文保持简短
|
||||
if (lu) { lu.textContent = '刷新中断(仍显示上次数据)'; if (msg) lu.title = String(msg); }
|
||||
}
|
||||
function handleAuthFail(){
|
||||
$('#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(){
|
||||
const pid = $('#projectSel').value;
|
||||
const cls = $('#classSel').value;
|
||||
@@ -345,25 +378,25 @@ function renderBoard(){
|
||||
const totalAttempts = (overall.attempts_total || 0);
|
||||
const _hs2 = document.getElementById('heroSub');
|
||||
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 + ' 次提交' : '');
|
||||
|
||||
// 6 卡汇总
|
||||
const color = rateColor(c.rate || 0);
|
||||
$('#boardSummary').innerHTML =
|
||||
'<div class="card accent-blue"><span>总人数</span><b>' + c.total + '</b></div>' +
|
||||
'<div class="card ok accent-green"><span>已提交</span><b>' + c.submitted + '</b></div>' +
|
||||
'<div class="card warn accent-orange"><span>缺考</span><b>' + c.absent + '</b></div>' +
|
||||
'<div class="card accent-purple"><span>提交率</span><b style="color:' + color + '">' + c.rate + '%</b></div>' +
|
||||
'<div class="card accent-teal"><span>平均分</span><b>' + (c.avg != null ? 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-blue"><span>总人数</span><b>' + dv(c.total, 0) + '</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>' + dv(c.absent, 0) + '</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>' + dv(c.avg) + '</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 box = $('#boardBody');
|
||||
if (!groups.length) {
|
||||
box.innerHTML = '<div class="gpanel">' +
|
||||
'<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),以下按成绩从高到低排列' +
|
||||
(c.attempts > c.submitted ? ' · 共 ' + c.attempts + ' 次刷题' : '') + '</div>' +
|
||||
membersTable(c.members) + '</div>';
|
||||
@@ -372,17 +405,17 @@ function renderBoard(){
|
||||
const gc = rateColor(g.rate || 0);
|
||||
return '<div class="gpanel">' +
|
||||
'<div class="ghead"><strong>' + esc(g.group) + ' 组</strong>' +
|
||||
'<span class="rate-num" style="color:' + gc + '">' + g.rate + '%</span></div>' +
|
||||
'<div class="sub">已提交 ' + g.submitted + ' / 总人数 ' + g.total + ' · 缺考 ' + g.absent +
|
||||
'<span class="rate-num" style="color:' + gc + '">' + dv(g.rate, 0) + '%</span></div>' +
|
||||
'<div class="sub">已提交 ' + dv(g.submitted, 0) + ' / 总人数 ' + dv(g.total, 0) + ' · 缺考 ' + dv(g.absent, 0) +
|
||||
(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="m"><span>平均分</span><b>' + (g.avg != null ? g.avg : '-') + '</b></div>' +
|
||||
'<div class="m"><span>合格率</span><b>' + g.pass_rate + '%</b></div>' +
|
||||
'<div class="m metric-ok"><span>最高分</span><b>' + (g.max != null ? g.max : '-') + '</b></div>' +
|
||||
'<div class="m metric-danger"><span>最低分</span><b>' + (g.min != null ? g.min : '-') + '</b></div>' +
|
||||
'<div class="m"><span>平均分</span><b>' + dv(g.avg) + '</b></div>' +
|
||||
'<div class="m"><span>合格率</span><b>' + pv(g.pass_rate) + '</b></div>' +
|
||||
'<div class="m metric-ok"><span>最高分</span><b>' + dv(g.max) + '</b></div>' +
|
||||
'<div class="m metric-danger"><span>最低分</span><b>' + dv(g.min) + '</b></div>' +
|
||||
'</div>' +
|
||||
'<div class="mem-title">考试成员 · 按成绩降序(' + g.submitted + ' 人)</div>' +
|
||||
'<div class="mem-title">考试成员 · 按成绩降序(' + dv(g.submitted, 0) + ' 人)</div>' +
|
||||
membersTable(g.members) +
|
||||
'</div>';
|
||||
}).join('') + '</div>';
|
||||
@@ -429,6 +462,8 @@ function schedule(){
|
||||
async function refreshBoard(manual){
|
||||
const pid = $('#projectSel').value;
|
||||
if (!pid) return;
|
||||
// 标签页在后台时不做自动轮询(省流量、也避免无人值守时反复请求卷王)
|
||||
if (!manual && document.hidden) return;
|
||||
const btn = $('#btnRefresh');
|
||||
if (manual && btn) { btn.disabled = true; btn.textContent = '刷新中…'; }
|
||||
try {
|
||||
@@ -474,6 +509,16 @@ $('#btnFullscreen').addEventListener('click', toggleFullscreen);
|
||||
document.addEventListener('fullscreenchange', onFsChange);
|
||||
document.addEventListener('webkitfullscreenchange', onFsChange);
|
||||
|
||||
/* 切回前台:立刻补一次数据并重排定时器;切到后台:停掉轮询 */
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
} else {
|
||||
refreshBoard();
|
||||
schedule();
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- 偏好恢复(自动刷新开关 + 间隔,含 ?interval= 深链) ---------- */
|
||||
function restoreDashPrefs(){
|
||||
const qs = new URLSearchParams(location.search);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { defineConfig } from 'vitepress'
|
||||
|
||||
// 部署到子路径时,构建前设置环境变量即可:
|
||||
// DOCS_BASE=/ny-exam-docs/ npm run docs:build
|
||||
const base = process.env.DOCS_BASE || '/'
|
||||
// 站点部署在域名的 /ny-exam-platform 子路径下,故 base 默认为该路径。
|
||||
// 本地预览时用 DOCS_BASE=/ 覆盖:DOCS_BASE=/ npm run docs:build
|
||||
const base = process.env.DOCS_BASE || '/ny-exam-platform/'
|
||||
// 输出目录。默认 .vitepress/dist;若需要保留旧产物(或环境禁止删除目录),
|
||||
// 可用 DOCS_OUT=.vitepress/dist2 npm run docs:build 输出到新目录。
|
||||
const outDir = process.env.DOCS_OUT || '.vitepress/dist'
|
||||
@@ -72,7 +72,6 @@ export default defineConfig({
|
||||
{ text: '名单管理', link: '/guide/roster' },
|
||||
{ text: '成绩明细与统计口径', link: '/guide/scores' },
|
||||
{ text: '试卷设置(满分/合格率/免考)', link: '/guide/project-config' },
|
||||
{ text: '快照、趋势与异常检测', link: '/guide/snapshot' },
|
||||
{ text: '导出与打印', link: '/guide/export-print' }
|
||||
]
|
||||
},
|
||||
|
||||
+2
-12
@@ -16,7 +16,7 @@
|
||||
## 写操作白名单
|
||||
|
||||
`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 | 方法 | 参数 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `projects` | GET | — | 卷王项目(问卷 / 考试)列表 |
|
||||
| `data` | GET | `projectId`、`nameQid`、`force` | 主系统数据:统计、成绩行、名单、缺考、未匹配、异常 |
|
||||
| `data` | GET | `projectId`、`nameQid`、`force` | 主系统数据:统计、成绩行、名单、缺考、未匹配 |
|
||||
| `dashboard` | GET | `projectId`、`nameQid`、`force` | 按班级 / 组别聚合的看板数据 |
|
||||
|
||||
`data` 响应关键字段:
|
||||
@@ -50,7 +50,6 @@ rows[] { sid, name, cls, grp, score, best_score, attempts, attempts_list[]
|
||||
roster[] { sid, name, cls, grp, login }
|
||||
absent[] 缺考名单
|
||||
unmatched[] 未匹配答卷
|
||||
anomalies { fast_high[], frequent[], identical[] }
|
||||
project_cfg 本卷配置
|
||||
_cache { cached, at }
|
||||
```
|
||||
@@ -72,15 +71,6 @@ _cache { cached, at }
|
||||
| `project_config` | GET | `projectId` | 返回 `cfg`、`effective`(生效值)、`global`(全局默认) |
|
||||
| `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 | 方法 | 参数 | 说明 |
|
||||
|
||||
@@ -19,11 +19,10 @@ ny-exam-platform/
|
||||
├─ bootstrap.php 常量、自动加载、配置读写、CSRF、限流、公共函数
|
||||
├─ lib/
|
||||
│ ├─ SurveyKingClient.php 卷王 API 客户端(登录 / 令牌 / 项目 / 答卷)
|
||||
│ ├─ DataService.php 数据组装:姓名→学号匹配、统计、去重、异常检测
|
||||
│ ├─ DataService.php 数据组装:姓名→学号匹配、统计、去重
|
||||
│ ├─ Roster.php 名单管理(xlsx/csv 导入、列识别、持久化)
|
||||
│ ├─ Db.php SQLite 封装(三方言兼容的 DDL 与 upsert)
|
||||
│ ├─ DataCache.php 结果缓存(TTL + flock 防惊群)
|
||||
│ ├─ Snapshot.php 成绩快照与趋势
|
||||
│ ├─ XlsxReader.php 零依赖 XLSX 读取
|
||||
│ └─ XlsxWriter.php 零依赖 XLSX 写入(支持多工作表)
|
||||
├─ assets/ style.css / app.js / draw.js
|
||||
@@ -33,8 +32,7 @@ ny-exam-platform/
|
||||
│ ├─ roster.json 名单(文件备份)
|
||||
│ ├─ project_config.json 每卷配置(文件备份)
|
||||
│ ├─ cache/ 卷王令牌 + 结果缓存
|
||||
│ ├─ uploads/ 导入临时目录(解析后即删)
|
||||
│ └─ snapshots/ 成绩快照
|
||||
│ └─ uploads/ 导入临时目录(解析后即删)
|
||||
├─ tests/ PHP 自测 + 一键运行脚本
|
||||
├─ tools/ mock 服务与 Node e2e
|
||||
└─ docs/ 本文档站(VitePress)
|
||||
@@ -53,7 +51,7 @@ ny-exam-platform/
|
||||
│ ├─► Roster::all() 读名单
|
||||
│ ├─► 姓名 → 学号匹配
|
||||
│ ├─► dedupe(决定代表成绩)
|
||||
│ └─► 统计 / 异常检测
|
||||
│ └─► 统计
|
||||
└─► ny_resolve_score_cfg() 解析本卷满分与合格率
|
||||
└─► ny_ok(JSON)
|
||||
```
|
||||
|
||||
@@ -80,7 +80,6 @@ PRAGMA foreign_keys = ON;
|
||||
| 配置 | `ny_settings['config']` | `data/config.json` |
|
||||
| 名单 | `ny_roster` | `data/roster.json` |
|
||||
| 每卷配置 | `ny_project_config` | `data/project_config.json` |
|
||||
| 成绩快照 | 文件系统 `data/snapshots/` | — |
|
||||
| 结果缓存 | 文件系统 `data/cache/` | — |
|
||||
| 卷王令牌 | 文件系统 `data/cache/token.json` | — |
|
||||
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ bash tests/run-all.sh
|
||||
| 文件 | 类型 | 覆盖 |
|
||||
| --- | --- | --- |
|
||||
| `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-config-save.js` | Node e2e | 配置保存、连接变化检测、每卷配置隔离、CSRF |
|
||||
| `tools/test-draw-alloc.js` | Node e2e | 随机抽取名额分配(加权 / 自定义 / 守恒 fuzz) |
|
||||
|
||||
+10
-1
@@ -13,6 +13,14 @@
|
||||
| 自动刷新开关 + 间隔 | 与主页面一致的 5 / 10 / 15 / 30 / 60 / 120 秒 |
|
||||
| 刷新 | 立即拉取一次(跳过缓存) |
|
||||
| ⛶ 全屏 | 进入 / 退出全屏,布局自动铺满 |
|
||||
| ← 主系统 | 返回主页面(成绩明细 / 名单 / 抽取);全屏状态下也可直接返回 |
|
||||
| 退出 | 退出登录 |
|
||||
|
||||
::: tip 后台标签页会自动暂停
|
||||
切换到其他标签页后看板会**停止轮询**,切回来时立即补拉一次数据并重新开始计时——
|
||||
投屏时长开也不会在无人观看时反复请求卷王。自动刷新失败时,「最后更新」会显示
|
||||
**刷新中断(仍显示上次数据)**,鼠标悬停可看到具体原因。
|
||||
:::
|
||||
|
||||
## 汇总卡片
|
||||
|
||||
@@ -25,7 +33,8 @@
|
||||
看板主体按**班级**分块,每块内再按**组别**对比,指标与统计口径完全相同:
|
||||
总人数、已提交、缺考、提交率、平均分、合格率、最高分、最低分、平均用时。
|
||||
|
||||
点击班级内任意成员可查看其**每一次提交**(时间、成绩、时长),最高分有标记。
|
||||
点击考过多次的成员(带「刷 N 次」标签)可查看其**每一次提交**(时间、成绩、时长),最佳成绩有标记。
|
||||
按 `Esc` 或点击弹窗外部可关闭。
|
||||
|
||||
> 未分班的人归入「(未分班)」,未分组的人归入「(未分组)」,不会丢失。
|
||||
|
||||
|
||||
+11
-3
@@ -44,7 +44,15 @@
|
||||
|
||||
顶部卡片:`候选人数 / 本次抽取 / 累计已抽取 / 已排除 / 抽取来源`。
|
||||
|
||||
结果表格:`序号 / 学号 / 姓名 / 班级 / 组别 / 操作`。
|
||||
结果表格:`序号 / 学号 / 姓名 / 班级 / 组别 / 登陆账号 / 操作`。
|
||||
|
||||
「登陆账号」列让你在导出前就能核对:显示为 `学号 ⚠` 表示名单里没填这一列,
|
||||
导出时会自动回退用学号——**这会导致学生登不进考试**,请先回主系统「名单管理」补全。
|
||||
|
||||
::: tip 「全部」按钮会跟随候选池
|
||||
点 **全部** 后抽取人数进入跟随模式:切换班级 / 组别 / 来源时人数自动更新为当前
|
||||
候选池大小。手工改动人数输入框即解除该模式。
|
||||
:::
|
||||
|
||||
| 按钮 | 说明 |
|
||||
| --- | --- |
|
||||
@@ -57,8 +65,8 @@
|
||||
|
||||
## 登陆账号说明
|
||||
|
||||
名单中的**登陆账号**列只在这里用于导出白名单,主系统界面任何位置都不展示。
|
||||
若名单里没有这一列,导出的白名单该字段为空。
|
||||
名单中的**登陆账号**列在这里既用于导出白名单,也会直接显示在结果表格中(便于导出前核对)。
|
||||
若名单里没有这一列,抽取结果里会用 `学号 ⚠` 标出,导出时回退用学号。
|
||||
|
||||
::: warning 模板文件名
|
||||
卷王模板中该列表头为「登陆账号」(不是"登录账号"),导出时已按此命名。
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
## 导出成绩明细
|
||||
|
||||
工具栏 **导出成绩**,生成 `.xlsx`(零依赖,仓库内自行实现 XLSX 写入,不需要 `zip` 扩展也能用)。
|
||||
工具栏下方「导出」条里的 **导出成绩**,生成 `.xlsx`(零依赖,仓库内自行实现 XLSX 写入,不需要 `zip` 扩展也能用)。
|
||||
导出控件单独成行,与筛选、刷新分开,不会挤在一起。
|
||||
|
||||
### 三种可选模式
|
||||
|
||||
@@ -41,13 +42,30 @@
|
||||
|
||||
## 打印成绩明细
|
||||
|
||||
成绩明细面板右上角 **打印**:
|
||||
成绩明细面板右上角 **打印**,弹窗里可设置:
|
||||
|
||||
- 默认按**成绩降序**;
|
||||
- 可选「按班级 / 组别分组对比」,每组段首给出小计;
|
||||
- 走浏览器打印通道,可直接另存为 PDF。
|
||||
| 选项 | 说明 |
|
||||
| --- | --- |
|
||||
| 打印标题 | 默认「试卷名 + 成绩明细」,可改成学校/考试正式名称 |
|
||||
| 排序方式 | 成绩降序(默认)/ 升序 / 学号 / 提交时间 |
|
||||
| 分组对比 | 按班级或组别分段,段首给出小计(人数/平均/最高/最低/合格人数与合格率) |
|
||||
| 纸张方向 | A4 纵向(列少清晰)或横向(列多不折行) |
|
||||
| 显示名次 | 全表连续编号,不分组也不会因为分段重新从 1 开始 |
|
||||
| 显示「是否合格」列 | 低于合格线的成绩与「不合格」标红,一眼定位 |
|
||||
| 显示考试次数 | 便于发现反复重考的人 |
|
||||
| 附缺考名单 | 成绩表后**另起一页**附缺考名单,带空白备注列供手写 |
|
||||
|
||||
缺考名单面板右上角 **打印** 同理。
|
||||
其它细节:
|
||||
|
||||
- 表头在**每一页自动重复**,行不会被切断到两页;
|
||||
- 页眉含范围(班级/组别)、打印时间、人数、平均分、最高/最低、合格人数与合格线;
|
||||
- 页脚含**教师 / 教研组长签字栏与日期**,可直接签字归档;
|
||||
- 打印走隐藏 iframe 调起,**不会被浏览器的弹窗拦截挡掉**,也不会多开标签页。
|
||||
|
||||
## 打印缺考名单
|
||||
|
||||
缺考名单面板右上角 **打印**:按班级分组、班内按学号排序,同样带页眉与签字栏,
|
||||
备注列留空供手写说明(请假、缓考等)。
|
||||
|
||||
## 直接调用导出接口
|
||||
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@
|
||||
|
||||
**Q:可以把平台搬到另一台服务器吗?**
|
||||
|
||||
可以。整个目录复制过去,并把 `data/` 一起带上(里面有配置、名单、快照),确保 `data/` 可写。
|
||||
可以。整个目录复制过去,并把 `data/` 一起带上(里面有配置、名单、每卷配置),确保 `data/` 可写。
|
||||
|
||||
## 安全
|
||||
|
||||
|
||||
@@ -97,11 +97,10 @@ Apache 下 `data/` 默认即被保护。仍需确认:
|
||||
|
||||
| 路径 | 需要 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `data/` | 可写 | 配置、名单、令牌、快照的根目录 |
|
||||
| `data/` | 可写 | 配置、名单、令牌的根目录 |
|
||||
| `data/ny.db` | 可写 | SQLite 主库(启用 `pdo_sqlite` 时) |
|
||||
| `data/uploads/` | 可写 | 导入名单时的临时落盘,解析后立即删除 |
|
||||
| `data/cache/` | 可写 | 卷王令牌缓存、结果缓存 |
|
||||
| `data/snapshots/` | 可写 | 成绩快照 |
|
||||
|
||||
```bash
|
||||
chown -R www:www data
|
||||
|
||||
@@ -26,12 +26,10 @@ NY 问卷考试系统数据平台是一个**零第三方运行依赖**的 PHP 8
|
||||
| 试卷切换 | 下拉选择卷王中的问卷 / 考试,自动拉取该卷全部答卷 |
|
||||
| 姓名关联 | 自动识别答卷中的「姓名」题,也可手动指定字段;未匹配答卷单独面板 + 一键补入名单 |
|
||||
| 统计卡片 | 名单总人数、已提交、缺考、平均分、最高分、最低分、合格率、平均用时;选择班级后全部联动 |
|
||||
| 成绩明细 | 虚拟滚动(无分页)、点击表头排序、搜索、考试次数、新提交绿色高亮(30 秒自动清除) |
|
||||
| 缺考名单 | 名单中有但未提交的人,支持复制学号 / 姓名、打印 |
|
||||
| 成绩明细 | 虚拟滚动(无分页)、点击表头排序、搜索(提示同步显示命中数)、考试次数、新提交绿色高亮(30 秒自动清除) |
|
||||
| 缺考名单 | 名单中有但未提交的人,支持复制学号 / 姓名、打印(按班级分组) |
|
||||
| 试卷设置 | 为当前选中的试卷单独设置满分 / 合格率 / 免考名单,试卷间互相隔离 |
|
||||
| 快照 / 趋势 | 保存带标签的时间点快照,横向对比每名学生成绩变化 |
|
||||
| 异常检测 | 短时长高分、高频重考、雷同成绩三类线索 |
|
||||
| 导出 / 打印 | 成绩明细、缺考名单 `.xlsx`;成绩明细按成绩降序打印,可选分组对比 |
|
||||
| 导出 / 打印 | 成绩明细、缺考名单 `.xlsx`;成绩明细按成绩降序打印,支持分组小计、合格标记、附缺考名单、签字栏 |
|
||||
|
||||
### 数据看板 `dashboard.php`
|
||||
|
||||
|
||||
+15
-1
@@ -9,6 +9,7 @@
|
||||
| 名单总人数 | 名单条目数;被当前试卷设为**免考**的人不计入 |
|
||||
| 已提交 | 匹配到名单、且提交了答卷的人数(去重后的代表成绩行数) |
|
||||
| 缺考 | `max(0, 名单总人数 − 已提交)` |
|
||||
| 提交率 | `已提交 ÷ 名单总人数 × 100%`——考试进行中最该盯的指标 |
|
||||
| 提交率 | `已提交 ÷ 名单总人数 × 100%` |
|
||||
| 平均分 | 所有**有成绩**的答卷成绩平均值;无成绩者不计入分母 |
|
||||
| 最高分 / 最低分 | 同上,取自所有有成绩的答卷 |
|
||||
@@ -40,7 +41,20 @@
|
||||
| 提交时间 | **最近一次**提交时间(重考低分也会让这行按最新时间排序到最上面) |
|
||||
| 考试时长 | 最近一次答卷的时长;后台手动录入等没有起止时间的答卷为 `-` |
|
||||
|
||||
点击任意一行可查看该学生的**每一次提交**(时间、成绩、时长),最高分有标记。
|
||||
点击任意一行可查看该学生的**每一次提交**(时间、成绩、时长),最佳成绩有标记。
|
||||
|
||||
### 成绩颜色
|
||||
|
||||
成绩按**占本卷满分的百分比**着色,而不是写死的 90 / 60 分:
|
||||
|
||||
| 颜色 | 条件 |
|
||||
| --- | --- |
|
||||
| 绿色 | ≥ 满分的 90% |
|
||||
| 橙色 | ≥ 合格率线(默认满分的 60%,可在[每卷配置](./project-config.md)里改) |
|
||||
| 红色 | 低于合格率线 |
|
||||
|
||||
这样把满分改成 50 分或 20 分制时,不会因为绝对分数偏低而整列误标红。
|
||||
数据看板的成员榜使用同一套规则。
|
||||
|
||||
## 排序与搜索
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 卷王系统地址 | `https://exam.nxxy335.top` | 结尾不带 `/` |
|
||||
| 接口前缀 | `/api` | 社区版 `/api`;企业版可能用 `/admin-api` |
|
||||
| 卷王系统地址 | `https://exam.nxxy335.top` | 结尾不带 `/`,可直接在设置里修改 |
|
||||
| 接口前缀 | `/api` | 社区版 `/api`;企业版可能用 `/admin-api`;可直接在设置里修改 |
|
||||
| 登录账号 | `admin` | 卷王后台管理员账号 |
|
||||
| 登录密码 | `123456` | 不回显;留空表示不修改 |
|
||||
| 校验 HTTPS 证书 | 勾选 | 自签名证书取消勾选 |
|
||||
|
||||
@@ -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)
|
||||
+1
-4
@@ -32,9 +32,6 @@ features:
|
||||
- icon: ⚙️
|
||||
title: 每卷独立配置
|
||||
details: 满分、合格率、免考名单按试卷隔离设置,互不干扰;未设置时回落到全局默认值。
|
||||
- icon: 📈
|
||||
title: 快照与趋势
|
||||
details: 随时保存成绩快照,横向对比每名学生在多个时间点的成绩变化,并给出异常线索。
|
||||
- icon: 🎲
|
||||
title: 随机抽取
|
||||
details: 按班级 / 组别均衡或加权抽取,累计去重、排除管理、历史批次与白名单导出。
|
||||
@@ -52,7 +49,7 @@ features:
|
||||
|
||||
| 页面 | 入口 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 主系统 | `index.php` | 成绩明细、统计卡片、名单管理、快照 / 趋势 / 异常、导出与打印 |
|
||||
| 主系统 | `index.php` | 成绩明细、统计卡片、名单管理、试卷设置、导出与打印 |
|
||||
| 数据看板 | `dashboard.php` | 大屏展示,适合投屏实时监控提交进度 |
|
||||
| 随机抽取 | `draw.php` | 从名单或某试卷已提交人员中随机抽取,支持白名单导出 |
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## P0:屏蔽数据目录(必做)
|
||||
|
||||
`data/` 位于 Web 根目录内,里面有 `ny.db`(配置 / 名单 / 卷王账号密码)、
|
||||
`config.json`、`roster.json`、令牌缓存与快照。**必须禁止直接访问。**
|
||||
`config.json`、`roster.json`、令牌缓存。**必须禁止直接访问。**
|
||||
|
||||
### nginx(生产环境默认,必做)
|
||||
|
||||
@@ -132,7 +132,6 @@ deny all;
|
||||
| `data/ny.db` | 配置、名单、每卷配置(SQLite) |
|
||||
| `data/config.json` | 配置(文件备份) |
|
||||
| `data/roster.json` | 名单(文件备份) |
|
||||
| `data/snapshots/` | 成绩快照 |
|
||||
|
||||
## P2:改掉卷王默认口令
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
`X-CSRF-Token` 头中自动携带;
|
||||
- 后端对写操作维护**白名单** `$WRITE_ACTIONS`,命中白名单的请求一律校验:
|
||||
`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` 占位。
|
||||
|
||||
> **nginx 不读取 `.htaccess`**,必须显式配置。见 [部署加固清单](./hardening.md)。
|
||||
@@ -70,7 +70,7 @@
|
||||
| 全站 HTTPS | 登录密码经 RSA 加密,但仍建议全站 TLS |
|
||||
| IP 白名单 | 内网使用时在 nginx 层限制来源 IP |
|
||||
| 隐藏版本号 | 关闭 `expose_php`、隐藏 nginx 版本号 |
|
||||
| 定期备份 | 备份 `data/`(配置、名单、快照、SQLite 库) |
|
||||
| 定期备份 | 备份 `data/`(配置、名单、每卷配置、SQLite 库) |
|
||||
| 最小权限 | PHP-FPM 进程只对 `data/` 可写,其它目录只读 |
|
||||
| 改默认密码 | 卷王后台默认 `admin/123456` 务必修改 |
|
||||
|
||||
|
||||
@@ -110,18 +110,19 @@ $rosterCount = Roster::count();
|
||||
<table class="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:70px">序号</th>
|
||||
<th style="width:26%">学号</th>
|
||||
<th style="width:22%">姓名</th>
|
||||
<th style="width:64px">序号</th>
|
||||
<th style="width:22%">学号</th>
|
||||
<th style="width:18%">姓名</th>
|
||||
<th>班级</th>
|
||||
<th>组别</th>
|
||||
<th style="width:90px">操作</th>
|
||||
<th style="width:22%">登陆账号</th>
|
||||
<th style="width:88px">操作</th>
|
||||
</tr>
|
||||
</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>
|
||||
</div>
|
||||
<p class="hint">两个下载均为两列「<b>姓名</b> / <b>登陆账号</b>」,登陆账号取名单中导入的「登陆账号」列(缺失时回退学号),按文本格式保存,可直接导入卷王白名单。</p>
|
||||
<p class="hint">两个下载与「📋 复制名单」均为两列「<b>姓名</b> / <b>登陆账号</b>」,登陆账号取名单中导入的「登陆账号」列。<b>表中「登陆账号」列显示为空即表示名单里没有填,导出时会自动回退用学号</b>,请先到主系统「名单管理」补全,否则学生无法登录考试。</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -70,39 +70,40 @@ $rosterCount = Roster::count();
|
||||
</div>
|
||||
<div class="field btns">
|
||||
<button class="btn btn-primary" id="btnRefresh">立即刷新</button>
|
||||
<button class="btn btn-primary" id="btnProjCfg" title="为当前选中的试卷单独设置满分 / 合格率 / 免考名单">试卷设置</button>
|
||||
</div>
|
||||
<div class="field grow-2">
|
||||
<label>搜索</label>
|
||||
<input type="text" id="kw" placeholder="输入学号或姓名筛选">
|
||||
</div>
|
||||
<div class="field btns" style="flex-wrap:wrap;gap:8px">
|
||||
<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>
|
||||
|
||||
<section class="subbar">
|
||||
<span class="subbar-label">导出</span>
|
||||
<label class="subbar-item">排序
|
||||
<select id="exportSortSel">
|
||||
<option value="time">按提交时间</option>
|
||||
<option value="sid">按学号</option>
|
||||
<option value="score_desc">按成绩降序</option>
|
||||
<option value="score_asc">按成绩升序</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-2);cursor:pointer" title="每人只导一行:最近一次成绩 + 最高分 + 考试次数">
|
||||
<input type="checkbox" id="exportLatest" style="width:auto;height:auto">按最新成绩
|
||||
<label class="subbar-item" title="每人只导一行:最近一次成绩 + 最高分 + 考试次数">
|
||||
<input type="checkbox" id="exportLatest">按最新成绩
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-2);cursor:pointer" title="按班级拆分多个工作表(xlsx 多表)">
|
||||
<input type="checkbox" id="exportSplit" style="width:auto;height:auto">按班级分表
|
||||
<label class="subbar-item" title="按班级拆分多个工作表(xlsx 多表)">
|
||||
<input type="checkbox" id="exportSplit">按班级分表
|
||||
</label>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn" id="btnExport">导出成绩</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 class="cards" id="statCards">
|
||||
<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 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-green"><span>最高分</span><b id="sMax">-</b></div>
|
||||
<div class="card accent-red"><span>最低分</span><b id="sMin">-</b></div>
|
||||
@@ -156,7 +157,7 @@ $rosterCount = Roster::count();
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<table class="grid compact">
|
||||
<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 class="fi full">
|
||||
<label>系统地址(不可在此修改)</label>
|
||||
<input type="text" id="cfgBase" readonly>
|
||||
<p class="hint">如需修改请编辑 <code>data/config.json</code>(保存后清 token 自动重连)</p>
|
||||
<label>系统地址</label>
|
||||
<input type="text" id="cfgBase" placeholder="https://exam.nxxy335.top" spellcheck="false">
|
||||
<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 class="fi">
|
||||
<label>登录账号</label>
|
||||
@@ -261,7 +267,7 @@ $rosterCount = Roster::count();
|
||||
<div id="pingResult" class="ping-result hidden"></div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<span class="hint" style="align-self:center">只有修改账号/密码才会触发重连与测连接</span>
|
||||
<span class="hint" style="align-self:center">修改地址 / 前缀 / 账号 / 密码后保存会自动重新连接</span>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn" id="btnTestConn">测试连接</button>
|
||||
<button class="btn" data-close>取消</button>
|
||||
@@ -380,60 +386,15 @@ $rosterCount = Roster::count();
|
||||
</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>×</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>×</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-box">
|
||||
<div class="modal-head"><h3>打印成绩明细</h3><button class="x" data-close>×</button></div>
|
||||
<div class="modal-body">
|
||||
<div class="fi">
|
||||
<label>打印标题</label>
|
||||
<input type="text" id="ptTitle" placeholder="默认:试卷名 + 成绩明细">
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>排序方式</label>
|
||||
<select id="ptSort">
|
||||
@@ -450,12 +411,22 @@ $rosterCount = Roster::count();
|
||||
<option value="cls">按班级分组</option>
|
||||
<option value="grp">按组别分组</option>
|
||||
</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 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="ptAbsent" title="成绩表后另起一页附缺考名单,含备注列供手写"> 附缺考名单(另起一页)</label>
|
||||
</div>
|
||||
<p class="hint">打印内容<b>不受页面当前筛选外的影响</b>,始终按当前班级/组别筛选结果输出;表头会在每页自动重复,末尾附签字栏。</p>
|
||||
<p class="hint" id="ptHint"></p>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
@@ -497,21 +468,6 @@ $rosterCount = Roster::count();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 异常检测线索 -->
|
||||
<div class="modal" id="anomalyModal">
|
||||
<div class="modal-box wide">
|
||||
<div class="modal-head"><h3>异常检测线索</h3><button class="x" data-close>×</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>
|
||||
window.NY = {
|
||||
appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>,
|
||||
|
||||
@@ -356,9 +356,6 @@ class DataService
|
||||
'attempts_total'=> $attemptsTotal,
|
||||
];
|
||||
|
||||
// 异常检测(防作弊线索,仅供教师人工判断,不做自动判定)
|
||||
$anomalies = self::detectAnomalies($rows, $stats);
|
||||
|
||||
return [
|
||||
'project' => [
|
||||
'id' => ny_str($project['id'] ?? $projectId),
|
||||
@@ -372,7 +369,6 @@ class DataService
|
||||
'absent' => $absent,
|
||||
'unmatched' => $unmatched,
|
||||
'stats' => $stats,
|
||||
'anomalies' => $anomalies,
|
||||
'roster' => $students,
|
||||
'roster_count' => count($students),
|
||||
// 本卷独立配置(前端据此回显满分/合格率/免考);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。
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -4,9 +4,18 @@
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
PHP="${PHP_BIN:-C:/Users/Zhang/.workbuddy/binaries/php/php.exe}"
|
||||
NODE="${NODE_BIN:-C:/Users/Zhang/.workbuddy/binaries/node/versions/22.22.2-2/node.exe}"
|
||||
# 运行期路径:优先用环境变量,其次用本机工作区路径,最后退回 PATH 里的 php / node
|
||||
# (工作区 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"
|
||||
echo "PHP = $PHP"
|
||||
echo "NODE = $NODE"
|
||||
|
||||
echo "================ PHP 自测 ================"
|
||||
for f in tests/test-aggregate.php tests/test-new-features.php tools/test-xlsxwriter.php; do
|
||||
|
||||
+22
-74
@@ -1,10 +1,9 @@
|
||||
<?php
|
||||
/**
|
||||
* 新功能回归测试:
|
||||
* 1) 异常检测 detectAnomalies(短时长高分 / 高频重考 / 雷同成绩)
|
||||
* 2) 成绩快照 Snapshot::summarize / trend
|
||||
* 3) 成绩明细排序:再次提交应置顶(last_submit_ts 排序)
|
||||
* 4) XlsxWriter 多工作表 renderMulti(分表 + 名字去重/截断)
|
||||
* 1) 成绩明细排序:再次提交应置顶(last_submit_ts 排序)
|
||||
* 2) XlsxWriter 多工作表 renderMulti(分表 + 名字去重/截断)
|
||||
* 3) 名单导入列识别 rowsToStudents(表头识别 / 去重 / Excel 数字尾巴)
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/../bootstrap.php';
|
||||
@@ -18,75 +17,6 @@ function check(string $name, bool $cond, string $detail = ''): void
|
||||
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) 排序:再次提交置顶 ============ */
|
||||
echo "\n== 排序(last_submit_ts 置顶)==\n";
|
||||
// 甲:代表成绩=最高分(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));
|
||||
|
||||
/* ============ 4) XlsxWriter 多工作表 ============ */
|
||||
/* ============ 2) XlsxWriter 多工作表 ============ */
|
||||
echo "\n== XlsxWriter 多工作表 ==\n";
|
||||
$sheets = [
|
||||
['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('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 "通过 {$pass} · 失败 {$fail}\n";
|
||||
exit($fail === 0 ? 0 : 1);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user