feat: 成绩明细打印 + SQLite + 每卷独立配置(满分/合格率/免考) + 导航精简

- 成绩明细打印:默认成绩降序,可选按班级/组别分组对比(段首小计)
- 修复看板退出全屏无反应(exitFullscreen 挂在 document 而非 documentElement)
- 导航栏精简:三页统一为单一主标题,移除 Logo 与副标题
- 优化三端布局:重排断点、竖排隐藏 spacer、触摸设备热区
- 全面启用 SQLite(data/ny.db):配置/名单/每卷配置入库,无 pdo_sqlite 自动降级文件
- 新增独立合格率配置(全局 + 每卷),合格率不再写死 60%
- 新增免考配置,仅对当前试卷生效,试卷间隔离
- 试卷独立满分从设置移出,改到主页面「试卷设置」
- 测试:新增 4 条每卷配置隔离断言,全套 113 项通过
This commit is contained in:
2026-09-10 20:22:17 +08:00
parent 0fd880624f
commit 157f257728
11 changed files with 1049 additions and 210 deletions
+82 -28
View File
@@ -128,7 +128,7 @@ if ($action !== 'auth_check' && !ny_is_auth()) {
// CSRF 防护:所有写操作必须携带会话绑定的 token(GET 读取类接口不受影响) // CSRF 防护:所有写操作必须携带会话绑定的 token(GET 读取类接口不受影响)
$WRITE_ACTIONS = [ $WRITE_ACTIONS = [
'config_save', 'roster_save', 'roster_import', 'roster_clear', 'config_save', 'roster_save', 'roster_import', 'roster_clear',
'snapshot_save', 'snapshot_delete', 'unmatched_map', 'snapshot_save', 'snapshot_delete', 'unmatched_map', 'project_config_save',
]; ];
if (in_array($action, $WRITE_ACTIONS, true) && !ny_csrf_ok()) { if (in_array($action, $WRITE_ACTIONS, true) && !ny_csrf_ok()) {
ny_json(['ok' => false, 'msg' => '安全校验失败(CSRF token 缺失或不匹配),请刷新页面后重试'], 403); ny_json(['ok' => false, 'msg' => '安全校验失败(CSRF token 缺失或不匹配),请刷新页面后重试'], 403);
@@ -194,9 +194,11 @@ try {
$v = (string)$in['export_sort']; $v = (string)$in['export_sort'];
$new['export_sort'] = in_array($v, ['time','sid','score_desc','score_asc'], true) ? $v : 'time'; $new['export_sort'] = in_array($v, ['time','sid','score_desc','score_asc'], true) ? $v : 'time';
} }
// 各试卷独立满分(覆盖整个 full_scores 映射) // 说明:「各试卷独立满分」已移出设置,改为在主页面针对当前试卷配置
if (array_key_exists('full_scores', $in) && is_array($in['full_scores'])) { // (project_config / project_config_save),设置里只保留「全局默认满分」。
$new['full_scores'] = $in['full_scores']; if (array_key_exists('pass_rate', $in)) {
$pr = (float)$in['pass_rate'];
$new['pass_rate'] = ($pr >= 1 && $pr <= 100) ? $pr : 60;
} }
$new['base_url'] = rtrim((string)$new['base_url'], '/'); $new['base_url'] = rtrim((string)$new['base_url'], '/');
$new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/'); $new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/');
@@ -211,18 +213,6 @@ try {
$fs = 100; $fs = 100;
} }
$new['full_score'] = $fs; $new['full_score'] = $fs;
// 各试卷独立满分:仅当显式指定 apply_pid 时才写入/清除该卷(修复 bug:之前会误用默认满分覆盖当前卷)
$applyPid = trim((string)($in['apply_pid'] ?? ''));
if ($applyPid !== '') {
$scores = is_array($new['full_scores'] ?? null) ? $new['full_scores'] : [];
$applyScore = (int)($in['apply_score'] ?? 0);
if ($applyScore > 0) {
$scores[$applyPid] = $applyScore;
} else {
unset($scores[$applyPid]);
}
$new['full_scores'] = $scores;
}
// 仅当连接信息真的变了才清 token + 重连;保存满分/布局等不应触发登录卷王 // 仅当连接信息真的变了才清 token + 重连;保存满分/布局等不应触发登录卷王
$connKeys = ['base_url', 'api_prefix', 'username', 'password', 'verify_ssl']; $connKeys = ['base_url', 'api_prefix', 'username', 'password', 'verify_ssl'];
$connChanged = false; $connChanged = false;
@@ -329,15 +319,13 @@ try {
$c = cfg(); $c = cfg();
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force'])); $built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
$result = $built['result']; $result = $built['result'];
$fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : []; // 满分:本卷独立优先,其次全局默认
$fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100); [$fs, $passRate] = ny_resolve_score_cfg($projectId, $c);
if ($fs < 1) {
$fs = 100;
}
$result['config'] = [ $result['config'] = [
'refresh_interval' => (int)$c['refresh_interval'], 'refresh_interval' => (int)$c['refresh_interval'],
'base_url' => $c['base_url'], 'base_url' => $c['base_url'],
'full_score' => $fs, 'full_score' => $fs,
'pass_rate' => $passRate,
]; ];
// 缓存来源标记:便于前端/排查时知道这次是实时拉取还是命中短期缓存 // 缓存来源标记:便于前端/排查时知道这次是实时拉取还是命中短期缓存
$result['_cache'] = ['cached' => $built['cached'], 'at' => $built['at']]; $result['_cache'] = ['cached' => $built['cached'], 'at' => $built['at']];
@@ -353,11 +341,7 @@ try {
$c = cfg(); $c = cfg();
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force'])); $built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
$result = $built['result']; $result = $built['result'];
$fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : []; [$fs, $passRate] = ny_resolve_score_cfg($projectId, $c);
$fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100);
if ($fs < 1) {
$fs = 100;
}
$students = $result['roster'] ?: []; $students = $result['roster'] ?: [];
$rows = $result['rows'] ?: []; $rows = $result['rows'] ?: [];
@@ -374,13 +358,14 @@ try {
} }
// 通用统计器:给定学生集合 + 答卷集合,算一组指标 // 通用统计器:给定学生集合 + 答卷集合,算一组指标
$statOf = static function (array $stu, array $rs, int $fs): array { // 合格率阈值可配(全局 pass_rate 或本卷独立),不再写死 60%
$statOf = static function (array $stu, array $rs, int $fs) use ($passRate): array {
$total = count($stu); $total = count($stu);
$submitted = count($rs); $submitted = count($rs);
$attempts = array_sum(array_map(static fn($r) => (int)($r['attempts'] ?? 1), $rs)); $attempts = array_sum(array_map(static fn($r) => (int)($r['attempts'] ?? 1), $rs));
$scores = array_values(array_filter(array_map(static fn($r) => $r['score'] ?? null, $rs), static fn($v) => $v !== null)); $scores = array_values(array_filter(array_map(static fn($r) => $r['score'] ?? null, $rs), static fn($v) => $v !== null));
$avg = $scores !== [] ? round(array_sum($scores) / count($scores), 2) : null; $avg = $scores !== [] ? round(array_sum($scores) / count($scores), 2) : null;
$pass = count(array_filter($rs, static fn($r) => ($r['score'] ?? null) !== null && ($r['score'] >= 0.6 * $fs))); $pass = count(array_filter($rs, static fn($r) => ($r['score'] ?? null) !== null && ($r['score'] >= ($passRate / 100) * $fs)));
$passRate = $submitted > 0 ? round($pass / $submitted * 100, 1) : 0; $passRate = $submitted > 0 ? round($pass / $submitted * 100, 1) : 0;
$durs = array_values(array_filter(array_map(static fn($r) => $r['duration'] ?? null, $rs), static fn($v) => $v !== null)); $durs = array_values(array_filter(array_map(static fn($r) => $r['duration'] ?? null, $rs), static fn($v) => $v !== null));
$avgDur = $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null; $avgDur = $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null;
@@ -473,6 +458,7 @@ try {
ny_ok([ ny_ok([
'project' => $result['project'], 'project' => $result['project'],
'full_score' => $fs, 'full_score' => $fs,
'pass_rate' => $passRate,
'overall' => $result['stats'], 'overall' => $result['stats'],
'classes' => $classes, 'classes' => $classes,
'updated_at' => $result['updated_at'], 'updated_at' => $result['updated_at'],
@@ -632,6 +618,74 @@ try {
} }
ny_ok(Snapshot::trend($snaps)); ny_ok(Snapshot::trend($snaps));
// ---------------- 试卷独立配置(满分 / 合格率 / 免考名单)——仅对当前试卷生效 ----------------
case 'project_config':
$projectId = (string)($_GET['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
$c = cfg();
[$fs, $pr] = ny_resolve_score_cfg($projectId, $c);
$pc = DataService::projectConfig($projectId);
ny_ok([
'project_id' => $projectId,
'cfg' => $pc,
// 解析后的生效值(前端回显用;null 表示"跟随全局")
'effective' => ['full_score' => $fs, 'pass_rate' => $pr],
'global' => [
'full_score' => (int)($c['full_score'] ?? 100),
'pass_rate' => (float)($c['pass_rate'] ?? 60),
],
]);
case 'project_config_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;
}
$old = DataService::projectConfig($projectId);
// 满分:留空/0 = 清除(跟随全局)
$fsIn = $in['full_score'] ?? null;
$fs = ($fsIn === null || $fsIn === '' || (int)$fsIn < 1) ? null : (int)$fsIn;
// 合格率:留空 = 清除(跟随全局);否则夹到 1~100
$prIn = $in['pass_rate'] ?? null;
$pr = ($prIn === null || $prIn === '') ? null : max(1, min(100, (float)$prIn));
// 免考名单:支持数组或逗号/换行分隔的字符串
$exIn = $in['exempt'] ?? null;
$exempt = [];
if (is_array($exIn)) {
foreach ($exIn as $v) {
$v = trim((string)$v);
if ($v !== '') {
$exempt[] = $v;
}
}
} elseif (is_string($exIn) && trim($exIn) !== '') {
foreach (preg_split('/[,,\s]+/u', trim($exIn)) ?: [] as $v) {
$v = trim($v);
if ($v !== '') {
$exempt[] = $v;
}
}
}
$new = [
'full_score' => $fs,
'pass_rate' => $pr,
'exempt' => array_values(array_unique($exempt)),
];
DataService::projectConfigSave($projectId, $new);
$c2 = cfg();
[$effFs, $effPr] = ny_resolve_score_cfg($projectId, $c2);
ny_ok([
'cfg' => $new,
'effective' => ['full_score' => $effFs, 'pass_rate' => $effPr],
]);
// ---------------- 导出(支持按班级 / 组别筛选,与页面当前筛选保持一致) ---------------- // ---------------- 导出(支持按班级 / 组别筛选,与页面当前筛选保持一致) ----------------
case 'export': case 'export':
case 'export_absent': case 'export_absent':
+185 -48
View File
@@ -261,6 +261,7 @@
state.roster = d.roster || []; state.roster = d.roster || [];
state.anomalies = d.anomalies || { fast_high: [], frequent: [], identical: [] }; state.anomalies = d.anomalies || { fast_high: [], frequent: [], identical: [] };
state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100; state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100;
state.passRate = (d.config && d.config.pass_rate != null) ? (parseFloat(d.config.pass_rate) || 60) : 60;
// 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据" // 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据"
// (纳入 attempts 与 last_submit_ts 才能发现"重考但分数更低"这种提交) // (纳入 attempts 与 last_submit_ts 才能发现"重考但分数更低"这种提交)
@@ -456,7 +457,8 @@
const avg = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 100) / 100 : null; const avg = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 100) / 100 : null;
const full = state.fullScore || 100; const full = state.fullScore || 100;
const passCount = rows.filter(r => r.score !== null && r.score !== undefined && r.score >= 0.6 * full).length; const pr = (state.passRate == null ? 60 : state.passRate); // 合格率阈值可配
const passCount = rows.filter(r => r.score !== null && r.score !== undefined && r.score >= (pr / 100) * full).length;
const passRate = submitted > 0 ? Math.round(passCount / submitted * 1000) / 10 : 0; const passRate = submitted > 0 ? Math.round(passCount / submitted * 1000) / 10 : 0;
let maxScore = null, minScore = null; let maxScore = null, minScore = null;
@@ -490,6 +492,9 @@
set('#sMax', st.maxScore == null ? '-' : st.maxScore); set('#sMax', st.maxScore == null ? '-' : st.maxScore);
set('#sMin', st.minScore == null ? '-' : st.minScore); set('#sMin', st.minScore == null ? '-' : st.minScore);
set('#sPass', st.passRate + '%'); set('#sPass', st.passRate + '%');
// 合格率阈值可配(全局或本卷独立),卡片标题实时反映当前生效值
const lbl = $('#sPassLabel');
if (lbl) lbl.textContent = '(≥' + (state.passRate == null ? 60 : state.passRate) + '%满分)';
set('#sDur', st.avgDur == null ? '-' : fmtDur(st.avgDur)); set('#sDur', st.avgDur == null ? '-' : fmtDur(st.avgDur));
} }
@@ -854,10 +859,20 @@
const el = document.documentElement; const el = document.documentElement;
if (!document.fullscreenElement && !document.webkitFullscreenElement) { if (!document.fullscreenElement && !document.webkitFullscreenElement) {
const req = el.requestFullscreen || el.webkitRequestFullscreen; const req = el.requestFullscreen || el.webkitRequestFullscreen;
if (req) req.call(el); if (req) {
const p = req.call(el);
// 部分浏览器返回 Promise,被拒绝时静默吞掉会让用户以为"点了没反应"
if (p && typeof p.catch === 'function') {
p.catch(() => { showAlert('当前浏览器/环境不允许进入全屏,请按 F11 手动切换', 'warn'); });
}
}
} else { } else {
// exitFullscreen 挂在 document 上(documentElement.exitFullscreen 恒为 undefined)
const exit = document.exitFullscreen || document.webkitExitFullscreen; const exit = document.exitFullscreen || document.webkitExitFullscreen;
if (exit) exit.call(document); if (exit) {
const p = exit.call(document);
if (p && typeof p.catch === 'function') p.catch(() => {});
}
} }
} }
function onFsChange() { function onFsChange() {
@@ -890,9 +905,9 @@
// 布局 // 布局
const layout = c.layout || LS.get(LS_LAYOUT, 'default'); const layout = c.layout || LS.get(LS_LAYOUT, 'default');
(document.querySelector('input[name=cfgLayout][value="' + layout + '"]') || {}).checked = true; (document.querySelector('input[name=cfgLayout][value="' + layout + '"]') || {}).checked = true;
// 满分:默认 + 各试卷独立 // 全局默认满分 / 合格率(每卷的独立配置移到主页面「试卷设置」)
$('#cfgFull').value = c.full_score || 100; $('#cfgFull').value = c.full_score || 100;
renderFullScores((c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {}); if ($('#cfgPassRate')) $('#cfgPassRate').value = (c.pass_rate == null ? 60 : c.pass_rate);
// 历史成绩展示方式 + 导出排序 // 历史成绩展示方式 + 导出排序
state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click'; state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click';
state.exportSort = (c.export_sort === 'sid') ? 'sid' : ((c.export_sort === 'score_desc') ? 'score_desc' : ((c.export_sort === 'score_asc') ? 'score_asc' : 'time')); state.exportSort = (c.export_sort === 'sid') ? 'sid' : ((c.export_sort === 'score_desc') ? 'score_desc' : ((c.export_sort === 'score_asc') ? 'score_asc' : 'time'));
@@ -914,39 +929,6 @@
$('#settingsModal').classList.add('open'); $('#settingsModal').classList.add('open');
} }
// 渲染"各试卷独立满分"列表
// 注意:#fullScoresHint 必须位于 #fullScoresBox 之外(见 index.php),
// 否则 box.innerHTML 覆盖会销毁它,下面的 textContent 赋值会抛 "Cannot set properties of null"。
// 这里仍保留 null 守卫,避免任何 DOM 结构变动导致整个设置弹窗打不开。
function setFullScoresHint(text) {
const hint = $('#fullScoresHint');
if (hint) hint.textContent = text;
}
function renderFullScores(scores) {
const box = $('#fullScoresBox');
if (!box) return;
if (!state.projects.length) {
box.innerHTML = '<span class="hint">暂无试卷,请先连接卷王系统并选择问卷</span>';
setFullScoresHint('给某卷填入数字即可单独覆盖默认满分;留空=用默认');
return;
}
// 排除文件夹项:模式为文件夹的无可答卷
const surveys = state.projects.filter(p => !((p.mode || '').toLowerCase().includes('folder')));
if (!surveys.length) {
box.innerHTML = '<span class="hint">尚未获取到可答卷</span>';
setFullScoresHint('给某卷填入数字即可单独覆盖默认满分;留空=用默认');
return;
}
box.innerHTML = surveys.map(p => {
const val = scores[p.id] != null ? scores[p.id] : '';
return '<div class="row">' +
'<label title="' + esc(p.name) + '">' + esc(p.name) + '</label>' +
'<input type="number" min="1" max="1000" data-pid="' + esc(p.id) + '" value="' + esc(val) + '" placeholder="默认">' +
'</div>';
}).join('');
setFullScoresHint('默认分值为空时使用上方"默认满分";填写即单独覆盖该卷');
}
/* ---------------- 名单 ---------------- */ /* ---------------- 名单 ---------------- */
let rosterDraft = []; let rosterDraft = [];
const rosterSel = new Set(); // 勾选待删除的行下标(对应 rosterDraft 索引) const rosterSel = new Set(); // 勾选待删除的行下标(对应 rosterDraft 索引)
@@ -1163,8 +1145,11 @@
include_temp_save: $('#cfgTemp').checked, include_temp_save: $('#cfgTemp').checked,
verify_ssl: $('#cfgSsl').checked, verify_ssl: $('#cfgSsl').checked,
full_score: parseInt($('#cfgFull').value, 10) || 100, full_score: parseInt($('#cfgFull').value, 10) || 100,
pass_rate: (function () {
const v = $('#cfgPassRate') ? parseFloat($('#cfgPassRate').value) : NaN;
return (isNaN(v) || v < 1 || v > 100) ? 60 : v;
})(),
layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default', layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default',
full_scores: collectFullScores(),
username: $('#cfgUser').value.trim(), username: $('#cfgUser').value.trim(),
history_display: $('#cfgHistoryDisplay').value, history_display: $('#cfgHistoryDisplay').value,
export_sort: $('#cfgExportSort').value, export_sort: $('#cfgExportSort').value,
@@ -1245,15 +1230,6 @@
$('#btnTestConn').addEventListener('click', testConnection); $('#btnTestConn').addEventListener('click', testConnection);
// 收集"各试卷独立满分" -> {projectId: score} // 收集"各试卷独立满分" -> {projectId: score}
function collectFullScores() {
const out = {};
$$('#fullScoresBox input[data-pid]').forEach(inp => {
const v = parseInt(inp.value, 10);
if (inp.dataset.pid && !isNaN(v) && v > 0) out[inp.dataset.pid] = v;
});
return out;
}
/* 名单上传 */ /* 名单上传 */
const zone = $('#dropZone'), fileInput = $('#rosterFile'); const zone = $('#dropZone'), fileInput = $('#rosterFile');
zone.addEventListener('click', () => fileInput.click()); zone.addEventListener('click', () => fileInput.click());
@@ -1503,6 +1479,167 @@
$('#trendModal').classList.add('open'); $('#trendModal').classList.add('open');
}); });
/* 成绩明细打印:默认按成绩降序,可选按班级/组别分组对比 */
function printTable() {
const rows = (state._sorted || []).slice();
if (!rows.length) return showAlert('当前没有可打印的成绩', 'warn');
const sortMode = $('#ptSort').value;
const groupBy = $('#ptGroup').value; // none | cls | grp
const showRank = $('#ptRank').checked;
const showAtt = $('#ptAttempts').checked;
const full = state.fullScore || 100;
const passRate = (state.passRate == null ? 60 : state.passRate);
// 排序(打印独立于页面排序,默认成绩降序;null 成绩恒排末尾)
const cmp = (a, b) => {
const an = (a.score == null), bn = (b.score == null);
if (an && bn) return 0;
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);
const passOf = r => (r.score != null && r.score >= (passRate / 100) * full);
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;
const pass = list.filter(passOf).length;
return {
n: list.length,
avg: avg == null ? '—' : avg.toFixed(1),
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>';
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>' : '') +
'<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>' +
(showAtt ? '<td class="c">' + (r.attempts || 1) + '</td>' : '') +
'<td>' + esc(r.submit_at || '') + '</td></tr>';
}).join('');
if (groupBy === 'none') {
html = '<table><thead>' + head + '</thead><tbody>' + renderRows(rows, 0) + '</tbody></table>';
} else {
const keyOf = (groupBy === 'cls') ? (r => (r.cls || '未分班')) : (r => (r.grp || '未分组'));
const groups = {};
rows.forEach(r => { const k = keyOf(r); (groups[k] = groups[k] || []).push(r); });
Object.keys(groups).sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true }))
.forEach(k => {
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>';
});
}
const all = statOf(rows);
const title = (state.projectName ? state.projectName + ' · ' : '') + '成绩明细';
const cls = $('#clsSel').value, grp = $('#grpSel').value;
const scope = (cls ? cls + '班 ' : '') + (grp ? grp + '组' : '');
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();
}
$('#btnPrintTable').addEventListener('click', () => {
if (!curPid()) return showAlert('请先选择问卷 / 考试');
const n = (state._sorted || []).length;
$('#ptHint').textContent = n ? ('当前将打印 ' + n + ' 条记录') : '暂无数据';
$('#printTableModal').classList.add('open');
});
$('#btnPtPrint').addEventListener('click', () => {
$('#printTableModal').classList.remove('open');
printTable();
});
/* 试卷独立设置(满分 / 合格率 / 免考名单)——仅对当前选中试卷生效 */
async function openProjCfg() {
const pid = curPid();
if (!pid) return showAlert('请先选择问卷 / 考试');
const pname = (($('#projectSel').selectedOptions && $('#projectSel').selectedOptions[0]) || {}).textContent || '';
$('#projCfgSub').textContent = pname ? ('· ' + pname) : '';
try {
const d = await api('project_config', { params: { projectId: pid } });
const cfg = d.cfg || {}, eff = d.effective || {}, g = d.global || {};
$('#pcFull').value = (cfg.full_score == null ? '' : cfg.full_score);
$('#pcPassRate').value = (cfg.pass_rate == null ? '' : cfg.pass_rate);
$('#pcExempt').value = (cfg.exempt || []).join('\n');
$('#pcFullHint').textContent = '当前生效:' + (eff.full_score || '-') +
'(全局默认 ' + (g.full_score || '-') + ')';
$('#pcPassHint').textContent = '当前生效:' + (eff.pass_rate || '-') +
'%(全局默认 ' + (g.pass_rate || '-') + '%)';
} catch (e) { showAlert(e.message); return; }
$('#projCfgModal').classList.add('open');
}
$('#btnProjCfg').addEventListener('click', openProjCfg);
$('#btnPcSave').addEventListener('click', async () => {
const pid = curPid();
if (!pid) return showAlert('请先选择问卷 / 考试');
try {
await api('project_config_save', { method: 'POST', params: { projectId: pid }, body: {
full_score: $('#pcFull').value.trim(),
pass_rate: $('#pcPassRate').value.trim(),
exempt: $('#pcExempt').value,
}});
$('#projCfgModal').classList.remove('open');
showAlert('本卷设置已保存(仅对当前试卷生效)', 'info');
refresh(false, true); // 免考会改变名单/缺考口径,需强制重拉
} catch (e) { showAlert(e.message); }
});
$('#btnPcReset').addEventListener('click', async () => {
const pid = curPid();
if (!pid) return;
if (!confirm('确定清除本卷的全部独立设置(恢复跟随全局默认)?')) return;
try {
await api('project_config_save', { method: 'POST', params: { projectId: pid }, body: {
full_score: '', pass_rate: '', exempt: '',
}});
$('#projCfgModal').classList.remove('open');
showAlert('本卷设置已清除', 'info');
refresh(false, true);
} catch (e) { showAlert(e.message); }
});
/* 异常检测线索 */ /* 异常检测线索 */
function renderAnomaly() { function renderAnomaly() {
const a = state.anomalies || { fast_high: [], frequent: [], identical: [] }; const a = state.anomalies || { fast_high: [], frequent: [], identical: [] };
+46 -10
View File
@@ -53,7 +53,12 @@ body {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
box-shadow: 0 2px 12px rgba(20, 30, 60, .05); box-shadow: 0 2px 12px rgba(20, 30, 60, .05);
} }
.brand { display: flex; align-items: center; gap: 12px; } .brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
/* 导航栏主标题(已移除 Logo 与副标题,只保留单一主标题) */
.brand-title {
margin: 0; font-size: 16px; font-weight: 700; letter-spacing: .3px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.logo { .logo {
width: 38px; height: 38px; width: 38px; height: 38px;
border-radius: 10px; border-radius: 10px;
@@ -469,32 +474,47 @@ body.fs-on .card b { font-size: 28px; }
body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; } body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
/* ---------- 三端自适应(桌面 / 平板 / 手机) ---------- */ /* ---------- 三端自适应(桌面 / 平板 / 手机) ---------- */
/* 大屏笔记本:略收边距,统计卡片保持一行 */
@media (max-width: 1180px) { @media (max-width: 1180px) {
.wrap { padding: 16px 16px 40px; } .wrap { padding: 16px 16px 40px; }
.topbar { padding: 10px 16px; } .topbar { padding: 10px 16px; }
} }
/* 平板横屏(769~1024):卡片改 4 列、工具栏字段占满整行 */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.grid-2 { grid-template-columns: 1fr; } .grid-2 { grid-template-columns: 1fr; }
.field.grow, .field.grow-2 { flex: 1 1 100%; min-width: 0; } .field.grow, .field.grow-2 { flex: 1 1 100%; min-width: 0; }
.cards { grid-template-columns: repeat(4, 1fr); }
.toolbar { gap: 14px; }
} }
/* 平板竖屏 / 大手机(<=768):整体收紧,工具栏与按钮自动换行 */
@media (max-width: 768px) { @media (max-width: 768px) {
body { font-size: 13px; } body { font-size: 13px; }
.topbar { flex-wrap: wrap; gap: 10px; } .topbar { flex-wrap: wrap; gap: 10px; }
.brand-text small { display: none; } .brand { flex: 1 1 100%; }
.brand-title { font-size: 15px; }
.top-right { flex-wrap: wrap; gap: 8px; } .top-right { flex-wrap: wrap; gap: 8px; }
.top-right .btn { padding: 6px 10px; font-size: 12px; } .top-right .btn { padding: 6px 10px; font-size: 12px; }
.btn b { font-size: 10px; } .btn b { font-size: 10px; }
.wrap { padding: 12px 12px 36px; } .wrap { padding: 12px 12px 36px; }
.toolbar { padding: 12px; gap: 10px; } .toolbar { padding: 12px; gap: 10px 12px; }
.field { flex: 1 1 100%; min-width: 0; } .field { flex: 1 1 100%; min-width: 0; }
.field.btns { flex: 1 1 100%; } .field.btns { flex: 1 1 100%; flex-wrap: wrap; row-gap: 8px; }
.field.btns select { width: 100%; } .field.btns select { width: 100%; }
/* 导出区的复选标签在小屏独占一行,避免和按钮挤成一团 */
.field.btns > label[style*="cursor:pointer"] { flex: 1 1 auto; }
.cards { grid-template-columns: repeat(3, 1fr); gap: 8px; } .cards { grid-template-columns: repeat(3, 1fr); gap: 8px; }
.card { padding: 10px 12px; } .card { padding: 10px 12px; }
.card b { font-size: 20px; } .card b { font-size: 20px; }
.panel-head { flex-direction: column; align-items: flex-start; gap: 6px; } .card span { font-size: 11px; }
/* 面板头部(含按钮)竖排,按钮自动换行 */
.panel-head { flex-direction: column; align-items: flex-start; gap: 8px; }
/* 竖排时 .spacer(flex:1)会把内容上下撑开,必须隐藏 */
.panel-head > .spacer { display: none; }
.panel-head .updated { flex-wrap: wrap; width: 100%; }
.panel-head .btn, .panel-head .updated .btn { flex: 0 0 auto; }
.table-scroll { max-height: 56vh; } .table-scroll { max-height: 56vh; }
table.grid { font-size: 12px; } table.grid { font-size: 12px; }
table.grid th { padding: 8px 10px; white-space: nowrap; } table.grid th { padding: 8px 10px; white-space: nowrap; }
@@ -503,19 +523,36 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
.modal-box, .modal-box.wide { width: 100%; max-height: 92vh; } .modal-box, .modal-box.wide { width: 100%; max-height: 92vh; }
.form-grid { grid-template-columns: 1fr; } .form-grid { grid-template-columns: 1fr; }
.modal-body { padding: 14px; } .modal-body { padding: 14px; }
/* 弹窗底栏在小屏换行,按钮可点满宽 */
.modal-foot { flex-wrap: wrap; padding: 10px 14px; }
.modal-foot > .spacer { display: none; }
.modal-foot .btn { flex: 1 1 auto; }
.roster-bar { flex-wrap: wrap; }
.roster-bar .chk { font-size: 11px; } .roster-bar .chk { font-size: 11px; }
/* 名单表在窄屏可横向滚动,避免 6 列挤爆 */
#rosterTable { min-width: 560px; }
} }
/* 手机(<=480):卡片两列,进一步压缩字号与内边距 */
@media (max-width: 480px) { @media (max-width: 480px) {
.cards { grid-template-columns: repeat(2, 1fr); } .cards { grid-template-columns: repeat(2, 1fr); gap: 8px; }
.brand-text strong { font-size: 14px; } .brand-title { font-size: 14px; }
.topbar .logo { width: 32px; height: 32px; font-size: 13px; }
.btn { padding: 6px 9px; font-size: 12px; } .btn { padding: 6px 9px; font-size: 12px; }
.field label { font-size: 11px; } .field label { font-size: 11px; }
.roster-bar { gap: 8px; } .roster-bar { gap: 8px; }
.roster-bar input[type=text] { width: 120px; } .roster-bar input[type=text] { width: 120px; }
.panel-head h2 { font-size: 14px; } .panel-head h2 { font-size: 14px; }
#rosterBody input { font-size: 12px; } #rosterBody input { font-size: 12px; }
.modal-box { max-height: 94vh; }
.modal-body { padding: 12px; }
/* 工具条按钮在小屏铺满,便于点按 */
.field.btns .btn { flex: 1 1 auto; }
}
/* 触摸设备:增大点按热区,去掉 hover 残留高亮 */
@media (hover: none) and (pointer: coarse) {
.btn { min-height: 34px; }
table.grid tbody tr:hover td { background: inherit; }
} }
/* ---------- 数据看板 ---------- */ /* ---------- 数据看板 ---------- */
@@ -525,8 +562,7 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
.dash-hero .brand strong { color: #fff; font-size: 17px; } .dash-hero .brand-title { color: #fff; font-size: 17px; }
.dash-hero .brand small { color: rgba(255, 255, 255, .72); }
.dash-hero a.back { color: #fff; opacity: .9; font-size: 13px; text-decoration: none; font-weight: 600; } .dash-hero a.back { color: #fff; opacity: .9; font-size: 13px; text-decoration: none; font-weight: 600; }
.dash-hero a.back:hover { opacity: 1; text-decoration: underline; } .dash-hero a.back:hover { opacity: 1; text-decoration: underline; }
.dash-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .dash-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+80 -5
View File
@@ -227,7 +227,9 @@ function ny_default_config(): array
'dedupe' => true, 'dedupe' => true,
'name_keywords' => '姓名,名字,学生姓名,考生姓名,您的姓名,姓名:,姓名:', 'name_keywords' => '姓名,名字,学生姓名,考生姓名,您的姓名,姓名:,姓名:',
'full_score' => 100, 'full_score' => 100,
'full_scores' => [], // 全局默认合格率(百分比,如 60 表示"≥满分的 60%"算合格)。
// 每份试卷可在主页面单独覆盖(project_config),试卷之间互相隔离。
'pass_rate' => 60,
'layout' => 'default', 'layout' => 'default',
// 历史成绩展示方式:click=点击成员弹窗查看(默认);inline=成绩明细内直接展开每次考试 // 历史成绩展示方式:click=点击成员弹窗查看(默认);inline=成绩明细内直接展开每次考试
'history_display' => 'click', 'history_display' => 'click',
@@ -258,16 +260,45 @@ function ny_load_config(): array
{ {
$GLOBALS['__ny_cfg_source'] = 'default'; $GLOBALS['__ny_cfg_source'] = 'default';
// 默认配置 + 文件覆盖(DB 后端本批次已下线,统一走文件存储)
$cfg = ny_default_config(); $cfg = ny_default_config();
$saved = ny_read_json(ny_config_file(), null); $file = ny_config_file();
$saved = null;
// 1) 优先读 SQLite(data/ny.db 的 ny_settings.config)
if (class_exists('Db') && Db::pdo() !== null) {
$raw = Db::settingsGet('config', '');
if ($raw !== '') {
$j = json_decode($raw, true);
if (is_array($j)) {
$saved = $j;
$GLOBALS['__ny_cfg_source'] = 'db';
}
}
// DB 可用但尚无记录 → 把已有 config.json 迁进来(老数据不丢,只迁一次)
if ($saved === null) {
$fromFile = ny_read_json($file, null);
if (is_array($fromFile)) {
Db::settingsSet('config', json_encode($fromFile, JSON_UNESCAPED_UNICODE));
$saved = $fromFile;
$GLOBALS['__ny_cfg_source'] = 'db';
}
}
}
// 2) DB 不可用时退回文件
if ($saved === null) {
$saved = ny_read_json($file, null);
if (is_array($saved)) {
$GLOBALS['__ny_cfg_source'] = 'file';
}
}
if (is_array($saved)) { if (is_array($saved)) {
foreach ($cfg as $k => $v) { foreach ($cfg as $k => $v) {
if (array_key_exists($k, $saved)) { if (array_key_exists($k, $saved)) {
$cfg[$k] = is_bool($v) ? (bool)$saved[$k] : $saved[$k]; $cfg[$k] = is_bool($v) ? (bool)$saved[$k] : $saved[$k];
} }
} }
$GLOBALS['__ny_cfg_source'] = 'file';
} }
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/'); $cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/'); $cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
@@ -276,8 +307,52 @@ function ny_load_config(): array
function ny_save_config(array $cfg): bool function ny_save_config(array $cfg): bool
{ {
// 双写:SQLite 为主(成功即算成功),文件为备份(DB 挂掉时还能读回来)
$ok = false;
if (class_exists('Db') && Db::pdo() !== null) {
try {
Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE));
$GLOBALS['__ny_cfg_source'] = 'db';
$ok = true;
} catch (Throwable $e) {
$ok = false;
}
}
$fileOk = ny_write_json(ny_config_file(), $cfg);
if (!$ok) {
$GLOBALS['__ny_cfg_source'] = 'file'; $GLOBALS['__ny_cfg_source'] = 'file';
return ny_write_json(ny_config_file(), $cfg); }
return $ok || $fileOk;
}
/**
* 解析「本卷满分 + 合格率」:
* 优先取该试卷的独立配置(试卷之间互相隔离),没有则用全局配置。
*
* @return array{0:int,1:float} [满分, 合格率百分比(如 60 表示 60%)]
*/
function ny_resolve_score_cfg(string $projectId, array $c): array
{
$pCfg = class_exists('DataService') ? DataService::projectConfig($projectId) : [];
$fs = (int)($pCfg['full_score'] ?? 0);
if ($fs < 1) {
$fs = (int)($c['full_score'] ?? 100);
}
if ($fs < 1) {
$fs = 100;
}
$pr = $pCfg['pass_rate'] ?? null;
if ($pr === null || $pr === '') {
$pr = $c['pass_rate'] ?? 60;
}
$pr = (float)$pr;
if ($pr <= 0 || $pr > 100) {
$pr = 60;
}
return [$fs, $pr];
} }
/** 安全的字符串化(避免数组/对象转字符串报错) */ /** 安全的字符串化(避免数组/对象转字符串报错) */
+29 -16
View File
@@ -25,11 +25,7 @@ if (!ny_is_auth()) {
<div class="dash-hero"> <div class="dash-hero">
<div class="brand"> <div class="brand">
<span class="logo">NY</span> <h1 class="brand-title">NY问卷考试系统数据平台</h1>
<div class="brand-text">
<strong id="heroTitle">班级看板</strong>
<small id="heroSub">选择一个问卷与班级查看考试整体情况</small>
</div>
</div> </div>
<div class="dash-controls"> <div class="dash-controls">
<select id="projectSel" style="min-width:160px"><option value="">请选择问卷 / 考试</option></select> <select id="projectSel" style="min-width:160px"><option value="">请选择问卷 / 考试</option></select>
@@ -47,7 +43,9 @@ if (!ny_is_auth()) {
</div> </div>
</div> </div>
<div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;"> <div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
<span id="heroSub">选择一个问卷与班级查看考试整体情况</span>
<span class="spacer" style="flex:1"></span>
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i> 最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
</div> </div>
@@ -60,7 +58,7 @@ if (!ny_is_auth()) {
<div class="card warn accent-orange"><span>缺考</span><b id="bAbsent">-</b></div> <div class="card warn accent-orange"><span>缺考</span><b id="bAbsent">-</b></div>
<div class="card accent-purple"><span>提交率</span><b id="bRate">-</b></div> <div class="card accent-purple"><span>提交率</span><b id="bRate">-</b></div>
<div class="card accent-teal"><span>平均分</span><b id="bAvg">-</b></div> <div class="card accent-teal"><span>平均分</span><b id="bAvg">-</b></div>
<div class="card accent-indigo"><span>合格率(≥60%满分)</span><b id="bPass">-</b></div> <div class="card accent-indigo"><span>合格率<span id="bPassLabel">(≥60%满分)</span></span><b id="bPass">-</b></div>
</section> </section>
<div class="sec-title">分组对比 / 考试成员</div> <div class="sec-title">分组对比 / 考试成员</div>
@@ -200,6 +198,9 @@ async function refreshClassSel(){
try { try {
const data = await api('dashboard', { projectId: pid }); const data = await api('dashboard', { projectId: pid });
DASH = data; DASH = data;
// 合格率阈值可配(全局或本卷独立),卡片标题实时反映
const bl = document.getElementById('bPassLabel');
if (bl) bl.textContent = '(≥' + (data.pass_rate == null ? 60 : data.pass_rate) + '%满分)';
const classes = (data.classes || []).map(c => c.class); const classes = (data.classes || []).map(c => c.class);
sel.innerHTML = classes.length sel.innerHTML = classes.length
? classes.map(c => '<option value="' + esc(c) + '">' + esc(c) + '</option>').join('') ? classes.map(c => '<option value="' + esc(c) + '">' + esc(c) + '</option>').join('')
@@ -285,8 +286,9 @@ function renderPlaceholderBoard(){
$('#boardSummary').innerHTML = cards.map(c => $('#boardSummary').innerHTML = cards.map(c =>
'<div class="card"><span>' + c[0] + '</span><b>' + c[1] + '</b></div>').join(''); '<div class="card"><span>' + c[0] + '</span><b>' + c[1] + '</b></div>').join('');
$('#boardBody').innerHTML = '<div class="empty">请选择问卷与班级查看考试整体情况</div>'; $('#boardBody').innerHTML = '<div class="empty">请选择问卷与班级查看考试整体情况</div>';
$('#heroTitle').textContent = '班级看板'; // 注意:heroTitle 已随导航栏精简移除,只保留 heroSub(承载当前问卷/班级提示)
$('#heroSub').textContent = '选择一个问卷与班级查看考试整体情况'; const _hs = document.getElementById('heroSub');
if (_hs) _hs.textContent = '选择一个问卷与班级查看考试整体情况';
const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); } const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); }
} }
@@ -327,9 +329,8 @@ function renderBoard(){
return; return;
} }
// 顶部品牌区:动态标题 // 顶部提示:当前班级 / 问卷(heroTitle 已移除,只更新 heroSub)
const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || ''; const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || '';
$('#heroTitle').textContent = cls + ' · 班级看板';
// 构建"学号 -> 多次刷题明细"映射(班级级 members 已含全班所有人的 attempts_list) // 构建"学号 -> 多次刷题明细"映射(班级级 members 已含全班所有人的 attempts_list)
const MAP = {}; const MAP = {};
@@ -342,7 +343,8 @@ function renderBoard(){
const overall = DASH.overall || {}; const overall = DASH.overall || {};
const totalAttempts = (overall.attempts_total || 0); const totalAttempts = (overall.attempts_total || 0);
$('#heroSub').textContent = '问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' + const _hs2 = document.getElementById('heroSub');
if (_hs2) _hs2.textContent = cls + ' · 问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' +
'总 ' + c.total + ' / 已交 ' + c.submitted + ' / 缺考 ' + c.absent + '总 ' + c.total + ' / 已交 ' + c.submitted + ' / 缺考 ' + c.absent +
(totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : ''); (totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : '');
@@ -354,7 +356,7 @@ function renderBoard(){
'<div class="card warn accent-orange"><span>缺考</span><b>' + c.absent + '</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-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-teal"><span>平均分</span><b>' + (c.avg != null ? c.avg : '-') + '</b></div>' +
'<div class="card accent-indigo"><span>合格率</span><b>' + c.pass_rate + '%</b></div>'; '<div class="card accent-indigo"><span>合格率(≥' + (DASH && DASH.pass_rate != null ? DASH.pass_rate : 60) + '%满分)</span><b>' + c.pass_rate + '%</b></div>';
const groups = (c.groups || []).filter(g => g.group !== '(未分组)'); const groups = (c.groups || []).filter(g => g.group !== '(未分组)');
const box = $('#boardBody'); const box = $('#boardBody');
@@ -392,10 +394,21 @@ function toggleFullscreen(){
const el = document.documentElement; const el = document.documentElement;
if (!document.fullscreenElement && !document.webkitFullscreenElement) { if (!document.fullscreenElement && !document.webkitFullscreenElement) {
const req = el.requestFullscreen || el.webkitRequestFullscreen; const req = el.requestFullscreen || el.webkitRequestFullscreen;
if (req) req.call(el); if (req) {
const p = req.call(el);
// 部分浏览器(尤其 Safari)会返回 Promise,被拒绝时静默吞掉会让人以为"点了没反应"
if (p && typeof p.catch === 'function') {
p.catch(() => { alert('当前浏览器/环境不允许进入全屏,请按 F11 手动切换'); });
}
}
} else { } else {
const ex = el.exitFullscreen || el.webkitExitFullscreen; // 注意:exitFullscreen 挂在 document 上,不是 documentElement 上
if (ex) ex.call(document); // (写成 el.exitFullscreen 恒为 undefined → 退出全屏点了没反应)
const ex = document.exitFullscreen || document.webkitExitFullscreen;
if (ex) {
const p = ex.call(document);
if (p && typeof p.catch === 'function') p.catch(() => {});
}
} }
} }
function onFsChange(){ function onFsChange(){
+1 -5
View File
@@ -26,11 +26,7 @@ $rosterCount = Roster::count();
<header class="topbar"> <header class="topbar">
<div class="brand"> <div class="brand">
<span class="logo">NY</span> <h1 class="brand-title">NY问卷考试系统数据平台</h1>
<div class="brand-text">
<strong>随机抽取</strong>
<small>名单 / 已提交人员 → 卷王白名单</small>
</div>
</div> </div>
<div class="top-right"> <div class="top-right">
<a class="btn" href="index.php#roster" title="前往主系统管理学号名单">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></a> <a class="btn" href="index.php#roster" title="前往主系统管理学号名单">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></a>
+79 -17
View File
@@ -22,11 +22,7 @@ $rosterCount = Roster::count();
<header class="topbar"> <header class="topbar">
<div class="brand"> <div class="brand">
<span class="logo">NY</span> <h1 class="brand-title">NY问卷考试系统数据平台</h1>
<div class="brand-text">
<strong>NY问卷考试系统数据平台</strong>
<small>卷王问卷系统 · 实时数据统计</small>
</div>
</div> </div>
<div class="top-right"> <div class="top-right">
<div id="conn" class="conn conn-off"><i></i><span>未连接</span></div> <div id="conn" class="conn conn-off"><i></i><span>未连接</span></div>
@@ -99,6 +95,7 @@ $rosterCount = Roster::count();
<button class="btn" id="btnSnapshot" title="保存当前成绩为一份带标签的时间点快照">存快照</button> <button class="btn" id="btnSnapshot" title="保存当前成绩为一份带标签的时间点快照">存快照</button>
<button class="btn" id="btnTrend" title="查看本卷各快照间每名学生的成绩趋势">趋势</button> <button class="btn" id="btnTrend" title="查看本卷各快照间每名学生的成绩趋势">趋势</button>
<button class="btn" id="btnAnomaly" title="异常检测线索:短时长高分 / 高频重考 / 雷同成绩">异常</button> <button class="btn" id="btnAnomaly" title="异常检测线索:短时长高分 / 高频重考 / 雷同成绩">异常</button>
<button class="btn btn-primary" id="btnProjCfg" title="为当前选中的试卷单独设置满分 / 合格率 / 免考名单">试卷设置</button>
</div> </div>
</section> </section>
@@ -109,7 +106,7 @@ $rosterCount = Roster::count();
<div class="card accent-teal"><span>平均分</span><b id="sAvg">-</b></div> <div class="card accent-teal"><span>平均分</span><b id="sAvg">-</b></div>
<div class="card accent-green"><span>最高分</span><b id="sMax">-</b></div> <div class="card accent-green"><span>最高分</span><b id="sMax">-</b></div>
<div class="card accent-red"><span>最低分</span><b id="sMin">-</b></div> <div class="card accent-red"><span>最低分</span><b id="sMin">-</b></div>
<div class="card accent-indigo"><span>合格率(≥60%满分)</span><b id="sPass">-</b></div> <div class="card accent-indigo"><span>合格率<span id="sPassLabel">(≥60%满分)</span></span><b id="sPass">-</b></div>
<div class="card accent-gray"><span>平均用时</span><b id="sDur">-</b></div> <div class="card accent-gray"><span>平均用时</span><b id="sDur">-</b></div>
</section> </section>
@@ -119,6 +116,7 @@ $rosterCount = Roster::count();
<h2>成绩明细 <small id="tableHint"></small></h2> <h2>成绩明细 <small id="tableHint"></small></h2>
<div class="updated"> <div class="updated">
<button class="btn sm" id="btnClearHl" hidden title="清除新数据的绿色高亮标记(每 30 秒自动清除一次)">清除高亮 <b id="hlBadge">0</b></button> <button class="btn sm" id="btnClearHl" hidden title="清除新数据的绿色高亮标记(每 30 秒自动清除一次)">清除高亮 <b id="hlBadge">0</b></button>
<button class="btn sm" id="btnPrintTable" title="打印成绩明细(默认按成绩降序,可选分组对比)">打印</button>
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i> 最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
</div> </div>
</div> </div>
@@ -197,9 +195,14 @@ $rosterCount = Roster::count();
<p class="hint">实际生效上限 200,超出会自动夹到 200(防止单页内存耗尽)</p> <p class="hint">实际生效上限 200,超出会自动夹到 200(防止单页内存耗尽)</p>
</div> </div>
<div class="fi"> <div class="fi">
<label>默认满分(用于合格率)</label> <label>全局默认满分</label>
<input type="number" id="cfgFull" min="1" max="1000"> <input type="number" id="cfgFull" min="1" max="1000">
<p class="hint" id="cfgFullHint">未选中试卷时的默认值</p> <p class="hint" id="cfgFullHint">全局默认;每份试卷可在主页面「试卷设置」中单独覆盖</p>
</div>
<div class="fi">
<label>全局默认合格率(%)</label>
<input type="number" id="cfgPassRate" min="1" max="100" step="1">
<p class="hint">达到满分的该百分比即算合格;每份试卷可单独覆盖</p>
</div> </div>
<div class="fi"> <div class="fi">
<label>历史成绩展示方式</label> <label>历史成绩展示方式</label>
@@ -224,15 +227,6 @@ $rosterCount = Roster::count();
<input type="text" id="cfgKeywords"> <input type="text" id="cfgKeywords">
<p class="hint">英文逗号分隔,用于自动识别"姓名"题目</p> <p class="hint">英文逗号分隔,用于自动识别"姓名"题目</p>
</div> </div>
<div class="fi full">
<label>各试卷独立满分</label>
<div id="fullScoresBox" class="fullscores">
<span class="hint">未选中试卷时,此处为空;选中某套试卷后保存即在设置中为该卷单独设定满分</span>
</div>
<!-- 注意:#fullScoresHint 必须放在 #fullScoresBox 之外,
否则 renderFullScores() 的 box.innerHTML 覆盖会把它销毁,导致 textContent 赋值报 null -->
<p class="hint" id="fullScoresHint">给某卷填入数字即可单独覆盖默认满分;留空=用默认</p>
</div>
<div class="fi full checks"> <div class="fi full checks">
<label><input type="checkbox" id="cfgDedupe"> 同一学号只保留最新一份答卷</label> <label><input type="checkbox" id="cfgDedupe"> 同一学号只保留最新一份答卷</label>
<label><input type="checkbox" id="cfgTemp"> 包含"暂存"未提交的答卷</label> <label><input type="checkbox" id="cfgTemp"> 包含"暂存"未提交的答卷</label>
@@ -435,6 +429,74 @@ $rosterCount = Roster::count();
</div> </div>
</div> </div>
<!-- 成绩明细打印设置 -->
<div class="modal" id="printTableModal">
<div class="modal-box">
<div class="modal-head"><h3>打印成绩明细</h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<div class="fi">
<label>排序方式</label>
<select id="ptSort">
<option value="score_desc" selected>按成绩降序(默认)</option>
<option value="score_asc">按成绩升序</option>
<option value="sid">按学号</option>
<option value="time">按提交时间</option>
</select>
</div>
<div class="fi">
<label>分组对比</label>
<select id="ptGroup">
<option value="none" selected>不分组(连续打印)</option>
<option value="cls">按班级分组</option>
<option value="grp">按组别分组</option>
</select>
<p class="hint">分组后每个班/组单独一段,段首显示小计(人数/平均分/合格率)</p>
</div>
<div class="fi checks">
<label><input type="checkbox" id="ptRank" checked> 显示名次</label>
<label><input type="checkbox" id="ptAttempts" checked> 显示考试次数</label>
</div>
<p class="hint" id="ptHint"></p>
</div>
<div class="modal-foot">
<span class="spacer"></span>
<button class="btn" data-close>取消</button>
<button class="btn btn-primary" id="btnPtPrint">打印</button>
</div>
</div>
</div>
<!-- 试卷独立设置(满分 / 合格率 / 免考名单)——仅对当前选中试卷生效 -->
<div class="modal" id="projCfgModal">
<div class="modal-box">
<div class="modal-head"><h3>试卷设置 <small id="projCfgSub"></small></h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<p class="hint" style="margin:0 0 12px">以下设置<b>仅对当前选中的这份试卷生效</b>,各试卷之间互不影响。留空表示跟随全局默认。</p>
<div class="fi">
<label>本卷满分</label>
<input type="number" id="pcFull" min="1" max="1000" placeholder="留空=用全局默认">
<p class="hint" id="pcFullHint"></p>
</div>
<div class="fi">
<label>本卷合格率(%)</label>
<input type="number" id="pcPassRate" min="1" max="100" step="1" placeholder="留空=用全局默认">
<p class="hint" id="pcPassHint"></p>
</div>
<div class="fi">
<label>免考学生(学号)</label>
<textarea id="pcExempt" rows="4" placeholder="每行一个学号,也可用逗号分隔"></textarea>
<p class="hint">免考学生不计入「名单总人数」与「缺考名单」</p>
</div>
</div>
<div class="modal-foot">
<button class="btn danger" id="btnPcReset">清除本卷设置</button>
<span class="spacer"></span>
<button class="btn" data-close>取消</button>
<button class="btn btn-primary" id="btnPcSave">保存</button>
</div>
</div>
</div>
<!-- 异常检测线索 --> <!-- 异常检测线索 -->
<div class="modal" id="anomalyModal"> <div class="modal" id="anomalyModal">
<div class="modal-box wide"> <div class="modal-box wide">
+68
View File
@@ -79,11 +79,73 @@ class DataService
/** /**
* 组装结果 * 组装结果
*/ */
/**
* 读取某份试卷的独立配置(满分 / 合格率 / 免考名单)。
* 优先 SQLite(ny_project_config 表);不可用时退回 config.json 里的 project_config 字段,
* 保证没有 pdo_sqlite 的环境也能用(只是隔离粒度仍在,只是落到文件)。
*
* @return array{full_score:?int,pass_rate:?float,exempt:array<int,string>}
*/
public static function projectConfig(string $projectId): array
{
$def = ['full_score' => null, 'pass_rate' => null, 'exempt' => []];
if ($projectId === '') {
return $def;
}
if (class_exists('Db') && Db::pdo() !== null) {
return Db::projectConfigGet($projectId);
}
// 文件回退
$all = [];
$saved = ny_read_json(NY_DATA . '/project_config.json', null);
if (is_array($saved)) {
$all = $saved;
}
$one = $all[$projectId] ?? null;
return is_array($one) ? array_merge($def, $one) : $def;
}
/** 写入某份试卷的独立配置 */
public static function projectConfigSave(string $projectId, array $cfg): void
{
if ($projectId === '') {
return;
}
if (class_exists('Db') && Db::pdo() !== null) {
Db::projectConfigSet($projectId, $cfg);
return;
}
$all = [];
$saved = ny_read_json(NY_DATA . '/project_config.json', null);
if (is_array($saved)) {
$all = $saved;
}
$all[$projectId] = $cfg;
ny_write_json(NY_DATA . '/project_config.json', $all);
}
public static function build(array $cfg, string $projectId, string $nameQid = ''): array public static function build(array $cfg, string $projectId, string $nameQid = ''): array
{ {
$client = new SurveyKingClient($cfg); $client = new SurveyKingClient($cfg);
// 每卷独立配置(满分 / 合格率 / 免考名单):试卷之间互相隔离
$pCfg = self::projectConfig($projectId);
$exempt = [];
foreach ((array)($pCfg['exempt'] ?? []) as $sid) {
$sid = trim((string)$sid);
if ($sid !== '') {
$exempt[$sid] = true;
}
}
$students = Roster::all(); $students = Roster::all();
// 免考:从名单中剔除,使其既不计入「总人数」也不计入「缺考」
if ($exempt !== []) {
$students = array_values(array_filter(
$students,
static fn(array $s): bool => !isset($exempt[trim((string)($s['sid'] ?? ''))])
));
}
// 姓名 -> 学号 / 班级(规范化) // 姓名 -> 学号 / 班级(规范化)
$studentInfo = []; $studentInfo = [];
foreach ($students as $s) { foreach ($students as $s) {
@@ -313,6 +375,12 @@ class DataService
'anomalies' => $anomalies, 'anomalies' => $anomalies,
'roster' => $students, 'roster' => $students,
'roster_count' => count($students), 'roster_count' => count($students),
// 本卷独立配置(前端据此回显满分/合格率/免考);full_score 已按本卷解析
'project_cfg' => [
'full_score' => $pCfg['full_score'] ?? null,
'pass_rate' => $pCfg['pass_rate'] ?? null,
'exempt' => array_values(array_map('strval', array_keys($exempt))),
],
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
]; ];
} }
+376 -48
View File
@@ -1,83 +1,411 @@
<?php <?php
/** /**
* 数据库适配层(旧:pgsql/mysql/sqlite 三方言支持) * 数据库适配层 —— SQLite 单文件后端(零配置、无需额外服务)
* 本批次已下线,仅保留文件存储。如未来需要接入 DB,请在此文件实现 PDO 单例。 *
* 设计要点:
* 1. 数据文件固定为 data/ny.db(SQLite),不需要用户配置任何连接信息。
* 2. 若环境没有 pdo_sqlite 扩展,整个层自动降级为「不可用」,
* 上层(Roster / 配置)会回退到 JSON 文件存储 —— 保证老环境不会挂。
* 3. 所有建表/加列都是幂等的(IF NOT EXISTS / 探测列后 ALTER),可重复执行。
* 4. 首次启用时自动把已有的 data/roster.json、data/config.json 迁移进来,
* 老数据不丢(迁移只发生在目标表为空时)。
*
* 表结构:
* ny_settings (k TEXT PK, v TEXT) 全局配置(键 'config' 存整份配置 JSON)
* ny_roster (sid TEXT PK, name, cls, grp, login)
* ny_project_config (project_id TEXT PK, config_json TEXT) 每份试卷独立配置(满分/合格率/免考,试卷间隔离)
*/ */
declare(strict_types=1); declare(strict_types=1);
class Db class Db
{ {
/** @var PDO|null 当前不可用,始终返回 null */
private static ?PDO $pdo = null; private static ?PDO $pdo = null;
private static string $err = '';
public static function isConfigured(): bool private static bool $initTried = false;
{
return false;
}
public static function isConnected(): bool
{
return false;
}
public static function driver(): string
{
return '';
}
public static function lastError(): string
{
return '';
}
public static function params(): array
{
return [];
}
public static function saveParams(array $p): bool
{
return false;
}
public static function connect(array $p): PDO
{
throw new RuntimeException('数据库后端已下线,请使用文件存储(data/config.json / data/roster.json)');
}
public static function pdo(): ?PDO
{
return null;
}
public static function prefix(): string public static function prefix(): string
{ {
return 'ny_'; return 'ny_';
} }
public static function migrate(): int /** 数据文件路径 */
public static function dbFile(): string
{ {
return 0; return NY_DATA . '/ny.db';
} }
public static function ensureRosterLogin(): void {} /** 是否具备启用条件(有 pdo_sqlite 扩展) */
public static function isConfigured(): bool
{
return extension_loaded('pdo_sqlite');
}
/** 是否已真正连上 */
public static function isConnected(): bool
{
return self::pdo() !== null;
}
public static function driver(): string
{
return self::pdo() !== null ? 'sqlite' : '';
}
public static function lastError(): string
{
return self::$err;
}
/** 连接参数(SQLite 固定,保留接口以兼容既有调用) */
public static function params(): array
{
return ['driver' => 'sqlite', 'file' => self::dbFile()];
}
public static function saveParams(array $p): bool
{
return true; // SQLite 无需外部配置
}
/** 建立/复用连接;失败返回 null 而不抛异常(上层要能优雅降级) */
public static function connect(array $p = []): PDO
{
if (self::$pdo instanceof PDO) {
return self::$pdo;
}
if (!self::isConfigured()) {
self::$err = 'PHP 未启用 pdo_sqlite 扩展,已回退到文件存储';
throw new RuntimeException(self::$err);
}
$file = self::dbFile();
$dir = dirname($file);
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
try {
$pdo = new PDO('sqlite:' . $file, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
// WAL + 合理超时,避免多人同时刷新时 "database is locked"
$pdo->exec('PRAGMA journal_mode=WAL');
$pdo->exec('PRAGMA synchronous=NORMAL');
$pdo->exec('PRAGMA busy_timeout=5000');
$pdo->exec('PRAGMA foreign_keys=ON');
self::$pdo = $pdo;
self::$err = '';
self::migrate();
return $pdo;
} catch (Throwable $e) {
self::$pdo = null;
self::$err = $e->getMessage();
throw new RuntimeException('SQLite 连接失败:' . $e->getMessage());
}
}
/** 获取连接(不可用返回 null) */
public static function pdo(): ?PDO
{
if (self::$pdo instanceof PDO) {
return self::$pdo;
}
if (self::$initTried) {
return null; // 已经试过且失败,不再反复重试(每次请求最多试一次)
}
self::$initTried = true;
try {
return self::connect();
} catch (Throwable $e) {
return null;
}
}
/** 建表 + 索引(幂等,可重复调用) */
public static function migrate(): int
{
$pdo = self::$pdo;
if (!$pdo) {
return 0;
}
$p = self::prefix();
$pdo->exec("CREATE TABLE IF NOT EXISTS {$p}settings (
k TEXT PRIMARY KEY,
v TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL DEFAULT 0
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$p}roster (
sid TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
cls TEXT NOT NULL DEFAULT '',
grp TEXT NOT NULL DEFAULT '',
login TEXT NOT NULL DEFAULT '',
sort INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$p}project_config (
project_id TEXT PRIMARY KEY,
config_json TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL DEFAULT 0
)");
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_{$p}roster_cls ON {$p}roster(cls)");
self::ensureRosterLogin();
return 3;
}
/** 旧库缺少 login 列时补上(幂等) */
public static function ensureRosterLogin(): void
{
$pdo = self::$pdo;
if (!$pdo) {
return;
}
$p = self::prefix();
try {
$cols = $pdo->query("PRAGMA table_info({$p}roster)")->fetchAll(PDO::FETCH_ASSOC);
$has = false;
foreach ($cols as $c) {
if (($c['name'] ?? '') === 'login') {
$has = true;
break;
}
}
if (!$has) {
$pdo->exec("ALTER TABLE {$p}roster ADD COLUMN login TEXT NOT NULL DEFAULT ''");
}
} catch (Throwable $e) {
self::$err = $e->getMessage();
}
}
// ---------------- 设置 ----------------
public static function settingsAll(): array public static function settingsAll(): array
{ {
$pdo = self::pdo();
if (!$pdo) {
return []; return [];
} }
try {
$p = self::prefix();
$st = $pdo->query("SELECT k, v FROM {$p}settings");
$out = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
$out[(string)$r['k']] = (string)$r['v'];
}
return $out;
} catch (Throwable $e) {
self::$err = $e->getMessage();
return [];
}
}
public static function settingsSet(string $k, string $v): void {} /** SQLite 用 ON CONFLICT 做 upsert(其它方言见历史版本) */
public static function settingsSet(string $k, string $v): void
{
$pdo = self::pdo();
if (!$pdo) {
return;
}
try {
$p = self::prefix();
$st = $pdo->prepare(
"INSERT INTO {$p}settings (k, v, updated_at) VALUES (?, ?, ?)
ON CONFLICT(k) DO UPDATE SET v=excluded.v, updated_at=excluded.updated_at"
);
$st->execute([$k, $v, time()]);
} catch (Throwable $e) {
self::$err = $e->getMessage();
}
}
public static function settingsGet(string $k, string $def = ''): string
{
$pdo = self::pdo();
if (!$pdo) {
return $def;
}
try {
$p = self::prefix();
$st = $pdo->prepare("SELECT v FROM {$p}settings WHERE k = ?");
$st->execute([$k]);
$v = $st->fetchColumn();
return $v === false ? $def : (string)$v;
} catch (Throwable $e) {
self::$err = $e->getMessage();
return $def;
}
}
// ---------------- 名单 ----------------
public static function rosterAll(): array public static function rosterAll(): array
{ {
$pdo = self::pdo();
if (!$pdo) {
return []; return [];
} }
try {
$p = self::prefix();
$st = $pdo->query("SELECT sid, name, cls, grp, login FROM {$p}roster ORDER BY sort ASC, sid ASC");
$out = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
$out[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
'grp' => (string)($r['grp'] ?? ''),
'login' => (string)($r['login'] ?? ''),
];
}
return $out;
} catch (Throwable $e) {
self::$err = $e->getMessage();
return [];
}
}
public static function rosterReplace(array $students): void {} /** 全量替换名单(roster_save 语义) */
public static function rosterReplace(array $students): void
{
$pdo = self::pdo();
if (!$pdo) {
return;
}
try {
$p = self::prefix();
$pdo->beginTransaction();
$pdo->exec("DELETE FROM {$p}roster");
$st = $pdo->prepare(
"INSERT INTO {$p}roster (sid, name, cls, grp, login, sort, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)"
);
$i = 0;
foreach ($students as $s) {
if (!is_array($s)) {
continue;
}
$st->execute([
(string)($s['sid'] ?? ''),
(string)($s['name'] ?? ''),
(string)($s['cls'] ?? ''),
(string)($s['grp'] ?? ''),
(string)($s['login'] ?? ''),
$i++,
time(),
]);
}
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
self::$err = $e->getMessage();
}
}
public static function rosterCount(): int public static function rosterCount(): int
{ {
$pdo = self::pdo();
if (!$pdo) {
return 0;
}
try {
$p = self::prefix();
return (int)$pdo->query("SELECT COUNT(*) FROM {$p}roster")->fetchColumn();
} catch (Throwable $e) {
self::$err = $e->getMessage();
return 0; return 0;
} }
} }
// ---------------- 每卷独立配置(满分 / 合格率 / 免考名单) ----------------
/** @return array<string,array> 全部试卷配置 [project_id => cfg] */
public static function projectConfigAll(): array
{
$pdo = self::pdo();
if (!$pdo) {
return [];
}
try {
$p = self::prefix();
$st = $pdo->query("SELECT project_id, config_json FROM {$p}project_config");
$out = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
$j = json_decode((string)($r['config_json'] ?? '{}'), true);
if (is_array($j)) {
$out[(string)$r['project_id']] = $j;
}
}
return $out;
} catch (Throwable $e) {
self::$err = $e->getMessage();
return [];
}
}
/** @return array 单卷配置(缺省返回空结构) */
public static function projectConfigGet(string $projectId): array
{
$def = ['full_score' => null, 'pass_rate' => null, 'exempt' => []];
if ($projectId === '') {
return $def;
}
$pdo = self::pdo();
if (!$pdo) {
return $def;
}
try {
$p = self::prefix();
$st = $pdo->prepare("SELECT config_json FROM {$p}project_config WHERE project_id = ?");
$st->execute([$projectId]);
$v = $st->fetchColumn();
if ($v === false) {
return $def;
}
$j = json_decode((string)$v, true);
return is_array($j) ? array_merge($def, $j) : $def;
} catch (Throwable $e) {
self::$err = $e->getMessage();
return $def;
}
}
/** 写入单卷配置(整份覆盖;null 表示"用全局默认") */
public static function projectConfigSet(string $projectId, array $cfg): void
{
if ($projectId === '') {
return;
}
$pdo = self::pdo();
if (!$pdo) {
return;
}
try {
$p = self::prefix();
$st = $pdo->prepare(
"INSERT INTO {$p}project_config (project_id, config_json, updated_at) VALUES (?, ?, ?)
ON CONFLICT(project_id) DO UPDATE SET config_json=excluded.config_json, updated_at=excluded.updated_at"
);
$st->execute([$projectId, json_encode($cfg, JSON_UNESCAPED_UNICODE), time()]);
} catch (Throwable $e) {
self::$err = $e->getMessage();
}
}
public static function projectConfigDelete(string $projectId): void
{
$pdo = self::pdo();
if (!$pdo) {
return;
}
try {
$p = self::prefix();
$st = $pdo->prepare("DELETE FROM {$p}project_config WHERE project_id = ?");
$st->execute([$projectId]);
} catch (Throwable $e) {
self::$err = $e->getMessage();
}
}
}
+43 -1
View File
@@ -19,10 +19,49 @@ class Roster
return class_exists('Db') && Db::pdo() !== null; return class_exists('Db') && Db::pdo() !== null;
} }
/**
* 首次启用 SQLite 时,把已有的 data/roster.json 迁进数据库(老数据不丢)。
* 只在「DB 名单为空 且 文件有数据」时执行一次,之后由 DB 全权负责。
*/
private static function migrateFileToDbOnce(): void
{
if (!self::useDb()) {
return;
}
try {
if (Db::rosterCount() > 0) {
return; // DB 已有数据,不覆盖
}
$d = ny_read_json(self::file(), null);
if (!is_array($d) || empty($d['students']) || !is_array($d['students'])) {
return;
}
$clean = [];
foreach ($d['students'] as $s) {
if (!is_array($s)) {
continue;
}
$clean[] = [
'sid' => trim((string)($s['sid'] ?? '')),
'name' => trim((string)($s['name'] ?? '')),
'cls' => trim((string)($s['cls'] ?? '')),
'grp' => trim((string)($s['grp'] ?? '')),
'login' => trim((string)($s['login'] ?? '')),
];
}
if ($clean !== []) {
Db::rosterReplace($clean);
}
} catch (Throwable $e) {
// 迁移失败不影响主流程:仍可退回文件读取
}
}
/** @return array<int,array{sid:string,name:string,cls:string}> */ /** @return array<int,array{sid:string,name:string,cls:string}> */
public static function all(): array public static function all(): array
{ {
if (self::useDb()) { if (self::useDb()) {
self::migrateFileToDbOnce();
return Db::rosterAll(); return Db::rosterAll();
} }
$d = ny_read_json(self::file(), null); $d = ny_read_json(self::file(), null);
@@ -105,7 +144,10 @@ class Roster
public static function fingerprint(): string public static function fingerprint(): string
{ {
if (self::useDb()) { if (self::useDb()) {
return 'db:' . self::count(); // 人数 + 内容哈希:名单内容变了指纹才变(只靠人数会在"换人"时误命中缓存)
$n = self::count();
$hash = md5(json_encode(self::all(), JSON_UNESCAPED_UNICODE));
return 'db:' . $n . ':' . $hash;
} }
$f = self::file(); $f = self::file();
$mt = is_file($f) ? (string)filemtime($f) : '0'; $mt = is_file($f) ? (string)filemtime($f) : '0';
+55 -27
View File
@@ -20,6 +20,17 @@ const hasToken = fs.existsSync(tokenFile);
const tokenSnap = hasToken ? fs.readFileSync(tokenFile, 'utf8') : null; const tokenSnap = hasToken ? fs.readFileSync(tokenFile, 'utf8') : null;
if (hasToken) fs.renameSync(tokenFile, tokenBak); if (hasToken) fs.renameSync(tokenFile, tokenBak);
// SQLite 优先:若不先移走 data/ny.db,里面的旧配置会盖掉本测试写入的 config.json,
// 导致登录用的是上一次遗留的账号密码(表现为"登录成功但 auth_check=false")。
// 同时清掉登录限流记录,避免上一轮失败把本轮锁住。
const dbFile = path.join(ROOT, 'data', 'ny.db');
const dbBak = dbFile + '.bak';
const hasDb = fs.existsSync(dbFile);
if (hasDb) { try { fs.renameSync(dbFile, dbBak); } catch (e) {} }
const throttleFile = path.join(ROOT, 'data', 'login_fail.json');
if (fs.existsSync(throttleFile)) { try { fs.unlinkSync(throttleFile); } catch (e) {} }
fs.writeFileSync(cfgFile, JSON.stringify({ fs.writeFileSync(cfgFile, JSON.stringify({
base_url: 'http://127.0.0.1:19911', base_url: 'http://127.0.0.1:19911',
api_prefix: '/api', api_prefix: '/api',
@@ -51,6 +62,14 @@ function cleanup() {
try { try {
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile); if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
} catch(e) { console.error('cleanup token failed:', e.message); } } catch(e) { console.error('cleanup token failed:', e.message); }
// 恢复 SQLite(本轮测试新建的 ny.db 丢弃,换回原有的)
try {
if (fs.existsSync(dbFile)) fs.unlinkSync(dbFile);
if (hasDb && fs.existsSync(dbBak)) fs.renameSync(dbBak, dbFile);
} catch(e) { console.error('cleanup db failed:', e.message); }
try {
if (fs.existsSync(throttleFile)) fs.unlinkSync(throttleFile);
} catch(e) {}
} }
process.on('exit', cleanup); process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(1); }); process.on('SIGINT', () => { cleanup(); process.exit(1); });
@@ -235,35 +254,44 @@ async function login() {
const after = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : ''; const after = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
chk('token 保持未变', before === after, 'changed=' + (before !== after)); chk('token 保持未变', before === after, 'changed=' + (before !== after));
console.log('\n--- 4) 各试卷独立满分:仅当 apply_pid 显式指定时写 ---'); console.log('\n--- 4) 试卷独立配置(满分/合格率/免考)已从设置移出,改走 project_config ---');
// 不传 apply_pid,也不传 full_scores → full_scores 应保留原值(保持空映射) // 每卷满分已不在 config_save 里;改为 project_config_save(试卷间隔离)
const cs2 = await req('POST', '/api.php?action=config_save', { const pc1 = await req('POST', '/api.php?action=project_config_save&projectId=p1', {
full_score: 90, full_score: 150, pass_rate: 70, exempt: 'S1,S2',
}); });
const _cs2fs = cs2.body && cs2.body.data && cs2.body.data.config chk('project_config_save 200', pc1.status === 200, 'status=' + pc1.status + ' ' + JSON.stringify(pc1.body));
? cs2.body.data.config.full_scores : undefined; chk('生效满分=150', pc1.body && pc1.body.data && pc1.body.data.effective
chk('未传 apply_pid → full_scores 保持空映射(不被覆盖为非空)', && pc1.body.data.effective.full_score === 150,
_cs2fs === undefined || Object.keys(_cs2fs).length === 0, JSON.stringify(pc1.body && pc1.body.data && pc1.body.data.effective));
JSON.stringify(_cs2fs)); chk('生效合格率=70', pc1.body && pc1.body.data && pc1.body.data.effective
// 显式 apply_pid='p1', apply_score=85 → 写入 && Number(pc1.body.data.effective.pass_rate) === 70,
const cs3 = await req('POST', '/api.php?action=config_save', { JSON.stringify(pc1.body && pc1.body.data && pc1.body.data.effective));
full_score: 90,
apply_pid: 'p1', // 隔离性:p2 未配置 → 跟随全局(注:上面的 config_save 已把全局满分改成 95,
apply_score: 85, // 所以这里不能写死 100,必须与响应里的 global 比对)
const pc2 = await req('GET', '/api.php?action=project_config&projectId=p2');
const gFs = pc2.body && pc2.body.data && pc2.body.data.global ? pc2.body.data.global.full_score : null;
const gPr = pc2.body && pc2.body.data && pc2.body.data.global ? pc2.body.data.global.pass_rate : null;
chk('p2 隔离:满分跟随全局(=' + gFs + ')', pc2.body && pc2.body.data && pc2.body.data.effective
&& pc2.body.data.effective.full_score === gFs,
JSON.stringify(pc2.body && pc2.body.data && pc2.body.data.effective) + ' global=' + gFs);
chk('p2 隔离:合格率跟随全局(=' + gPr + ')', pc2.body && pc2.body.data && pc2.body.data.effective
&& Number(pc2.body.data.effective.pass_rate) === Number(gPr),
JSON.stringify(pc2.body && pc2.body.data && pc2.body.data.effective) + ' global=' + gPr);
// 免考名单往返
const pc3 = await req('GET', '/api.php?action=project_config&projectId=p1');
const ex = pc3.body && pc3.body.data && pc3.body.data.cfg && pc3.body.data.cfg.exempt;
chk('免考名单往返 = [S1,S2]', Array.isArray(ex) && ex.length === 2 && ex[0] === 'S1' && ex[1] === 'S2',
JSON.stringify(ex));
// 清除本卷设置 → 回到全局
const pc4 = await req('POST', '/api.php?action=project_config_save&projectId=p1', {
full_score: '', pass_rate: '', exempt: '',
}); });
chk('apply_pid=85 → full_scores.p1=85', cs3.body && cs3.body.data && cs3.body.data.config chk('清除后 p1 回到全局满分(=' + gFs + ')', pc4.body && pc4.body.data && pc4.body.data.effective
&& cs3.body.data.config.full_scores && cs3.body.data.config.full_scores.p1 === 85, && pc4.body.data.effective.full_score === gFs,
JSON.stringify(cs3.body.data && cs3.body.data.config && cs3.body.data.config.full_scores)); JSON.stringify(pc4.body && pc4.body.data && pc4.body.data.effective) + ' global=' + gFs);
// apply_pid='p1', apply_score=0 → 清除
const cs4 = await req('POST', '/api.php?action=config_save', {
full_score: 90,
apply_pid: 'p1',
apply_score: 0,
});
chk('apply_pid=0 → 清除 p1',
cs4.body && cs4.body.data && cs4.body.data.config
&& (!cs4.body.data.config.full_scores || cs4.body.data.config.full_scores.p1 === undefined),
JSON.stringify(cs4.body.data && cs4.body.data.config && cs4.body.data.config.full_scores));
console.log('\n--- 5) 修改连接信息:应清 token + 重新登录(ping)---'); console.log('\n--- 5) 修改连接信息:应清 token + 重新登录(ping)---');
const tokBefore = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : ''; const tokBefore = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';