diff --git a/api.php b/api.php
index 81a0a4b..49482f0 100644
--- a/api.php
+++ b/api.php
@@ -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',
+ 'snapshot_save', 'snapshot_delete', 'unmatched_map', 'project_config_save',
];
if (in_array($action, $WRITE_ACTIONS, true) && !ny_csrf_ok()) {
ny_json(['ok' => false, 'msg' => '安全校验失败(CSRF token 缺失或不匹配),请刷新页面后重试'], 403);
@@ -194,9 +194,11 @@ try {
$v = (string)$in['export_sort'];
$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'])) {
- $new['full_scores'] = $in['full_scores'];
+ // 说明:「各试卷独立满分」已移出设置,改为在主页面针对当前试卷配置
+ // (project_config / project_config_save),设置里只保留「全局默认满分」。
+ 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['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/');
@@ -211,18 +213,6 @@ try {
$fs = 100;
}
$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 + 重连;保存满分/布局等不应触发登录卷王
$connKeys = ['base_url', 'api_prefix', 'username', 'password', 'verify_ssl'];
$connChanged = false;
@@ -329,15 +319,13 @@ try {
$c = cfg();
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
$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);
- if ($fs < 1) {
- $fs = 100;
- }
+ // 满分:本卷独立优先,其次全局默认
+ [$fs, $passRate] = ny_resolve_score_cfg($projectId, $c);
$result['config'] = [
'refresh_interval' => (int)$c['refresh_interval'],
'base_url' => $c['base_url'],
'full_score' => $fs,
+ 'pass_rate' => $passRate,
];
// 缓存来源标记:便于前端/排查时知道这次是实时拉取还是命中短期缓存
$result['_cache'] = ['cached' => $built['cached'], 'at' => $built['at']];
@@ -353,11 +341,7 @@ try {
$c = cfg();
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
$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);
- if ($fs < 1) {
- $fs = 100;
- }
+ [$fs, $passRate] = ny_resolve_score_cfg($projectId, $c);
$students = $result['roster'] ?: [];
$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);
$submitted = count($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));
$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;
$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;
@@ -473,6 +458,7 @@ try {
ny_ok([
'project' => $result['project'],
'full_score' => $fs,
+ 'pass_rate' => $passRate,
'overall' => $result['stats'],
'classes' => $classes,
'updated_at' => $result['updated_at'],
@@ -632,6 +618,74 @@ try {
}
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_absent':
diff --git a/assets/app.js b/assets/app.js
index 5e5fdbc..8887525 100644
--- a/assets/app.js
+++ b/assets/app.js
@@ -261,6 +261,7 @@
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;
// 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据"
// (纳入 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 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;
let maxScore = null, minScore = null;
@@ -490,6 +492,9 @@
set('#sMax', st.maxScore == null ? '-' : st.maxScore);
set('#sMin', st.minScore == null ? '-' : st.minScore);
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));
}
@@ -854,10 +859,20 @@
const el = document.documentElement;
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
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 {
+ // exitFullscreen 挂在 document 上(documentElement.exitFullscreen 恒为 undefined)
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() {
@@ -890,9 +905,9 @@
// 布局
const layout = c.layout || LS.get(LS_LAYOUT, 'default');
(document.querySelector('input[name=cfgLayout][value="' + layout + '"]') || {}).checked = true;
- // 满分:默认 + 各试卷独立
+ // 全局默认满分 / 合格率(每卷的独立配置移到主页面「试卷设置」)
$('#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.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');
}
- // 渲染"各试卷独立满分"列表
- // 注意:#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 = '暂无试卷,请先连接卷王系统并选择问卷';
- setFullScoresHint('给某卷填入数字即可单独覆盖默认满分;留空=用默认');
- return;
- }
- // 排除文件夹项:模式为文件夹的无可答卷
- const surveys = state.projects.filter(p => !((p.mode || '').toLowerCase().includes('folder')));
- if (!surveys.length) {
- box.innerHTML = '尚未获取到可答卷';
- setFullScoresHint('给某卷填入数字即可单独覆盖默认满分;留空=用默认');
- return;
- }
- box.innerHTML = surveys.map(p => {
- const val = scores[p.id] != null ? scores[p.id] : '';
- return '
' +
- '' +
- '' +
- '
';
- }).join('');
- setFullScoresHint('默认分值为空时使用上方"默认满分";填写即单独覆盖该卷');
- }
-
/* ---------------- 名单 ---------------- */
let rosterDraft = [];
const rosterSel = new Set(); // 勾选待删除的行下标(对应 rosterDraft 索引)
@@ -1163,8 +1145,11 @@
include_temp_save: $('#cfgTemp').checked,
verify_ssl: $('#cfgSsl').checked,
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',
- full_scores: collectFullScores(),
username: $('#cfgUser').value.trim(),
history_display: $('#cfgHistoryDisplay').value,
export_sort: $('#cfgExportSort').value,
@@ -1245,15 +1230,6 @@
$('#btnTestConn').addEventListener('click', testConnection);
// 收集"各试卷独立满分" -> {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');
zone.addEventListener('click', () => fileInput.click());
@@ -1503,6 +1479,167 @@
$('#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 = '| ' + (showRank ? '名次' : '') + ' | 学号 | 姓名 | ' +
+ (groupBy === 'none' ? '班级 | 组别 | ' : '') +
+ '成绩 | ' + (showAtt ? '考试次数 | ' : '') + '提交时间 |
';
+
+ let html = '';
+ let rank = 0;
+ const renderRows = (list, offsetRank) => list.map((r, i) => {
+ const rk = (r.score == null) ? '' : (offsetRank + i + 1);
+ return '' + (showRank ? '| ' + rk + ' | ' : '') +
+ '' + esc(r.sid || '') + ' | ' +
+ '' + esc(r.name || '') + ' | ' +
+ (groupBy === 'none' ? '' + esc(r.cls || '') + ' | ' + esc(r.grp || '') + ' | ' : '') +
+ '' + (r.score == null ? '—' : r.score) + ' | ' +
+ (showAtt ? '' + (r.attempts || 1) + ' | ' : '') +
+ '' + esc(r.submit_at || '') + ' |
';
+ }).join('');
+
+ if (groupBy === 'none') {
+ html = '' + head + '' + renderRows(rows, 0) + '
';
+ } 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 += '' + esc(k) +
+ ' (' + st.n + ' 人 · 平均 ' + st.avg + ' · 合格率 ' + st.passRate + '%)
';
+ html += '' + head + '' + renderRows(list, 0) + '
';
+ });
+ }
+
+ 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('' + esc(title) + '' +
+ '' +
+ '' + esc(title) + '
' +
+ '' + esc(scope) + ' 共 ' + all.n + ' 人 · 平均 ' + all.avg +
+ ' · 合格率 ' + all.passRate + '%(合格线:满分的 ' + passRate + '%,即 ' +
+ (Math.round(passRate / 100 * full * 10) / 10) + ' 分) · 打印时间:' + new Date().toLocaleString() + '
' +
+ html +
+ 'window.onload=function(){setTimeout(function(){window.print()},300)}' +
+ '');
+ 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() {
const a = state.anomalies || { fast_high: [], frequent: [], identical: [] };
diff --git a/assets/style.css b/assets/style.css
index 6bed70f..bffb853 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -53,7 +53,12 @@ body {
border-bottom: 1px solid var(--border);
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 {
width: 38px; height: 38px;
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; }
/* ---------- 三端自适应(桌面 / 平板 / 手机) ---------- */
+
+/* 大屏笔记本:略收边距,统计卡片保持一行 */
@media (max-width: 1180px) {
.wrap { padding: 16px 16px 40px; }
.topbar { padding: 10px 16px; }
}
+/* 平板横屏(769~1024):卡片改 4 列、工具栏字段占满整行 */
@media (max-width: 1024px) {
.grid-2 { grid-template-columns: 1fr; }
.field.grow, .field.grow-2 { flex: 1 1 100%; min-width: 0; }
+ .cards { grid-template-columns: repeat(4, 1fr); }
+ .toolbar { gap: 14px; }
}
+/* 平板竖屏 / 大手机(<=768):整体收紧,工具栏与按钮自动换行 */
@media (max-width: 768px) {
body { font-size: 13px; }
.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 .btn { padding: 6px 10px; font-size: 12px; }
.btn b { font-size: 10px; }
.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.btns { flex: 1 1 100%; }
+ .field.btns { flex: 1 1 100%; flex-wrap: wrap; row-gap: 8px; }
.field.btns select { width: 100%; }
+ /* 导出区的复选标签在小屏独占一行,避免和按钮挤成一团 */
+ .field.btns > label[style*="cursor:pointer"] { flex: 1 1 auto; }
.cards { grid-template-columns: repeat(3, 1fr); gap: 8px; }
.card { padding: 10px 12px; }
.card b { font-size: 20px; }
- .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.grid { font-size: 12px; }
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; }
.form-grid { grid-template-columns: 1fr; }
.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; }
+ /* 名单表在窄屏可横向滚动,避免 6 列挤爆 */
+ #rosterTable { min-width: 560px; }
}
+/* 手机(<=480):卡片两列,进一步压缩字号与内边距 */
@media (max-width: 480px) {
- .cards { grid-template-columns: repeat(2, 1fr); }
- .brand-text strong { font-size: 14px; }
- .topbar .logo { width: 32px; height: 32px; font-size: 13px; }
+ .cards { grid-template-columns: repeat(2, 1fr); gap: 8px; }
+ .brand-title { font-size: 14px; }
.btn { padding: 6px 9px; font-size: 12px; }
.field label { font-size: 11px; }
.roster-bar { gap: 8px; }
.roster-bar input[type=text] { width: 120px; }
.panel-head h2 { font-size: 14px; }
#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;
box-shadow: var(--shadow);
}
-.dash-hero .brand strong { color: #fff; font-size: 17px; }
-.dash-hero .brand small { color: rgba(255, 255, 255, .72); }
+.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; }
diff --git a/bootstrap.php b/bootstrap.php
index f887d30..dc91032 100644
--- a/bootstrap.php
+++ b/bootstrap.php
@@ -227,7 +227,9 @@ function ny_default_config(): array
'dedupe' => true,
'name_keywords' => '姓名,名字,学生姓名,考生姓名,您的姓名,姓名:,姓名:',
'full_score' => 100,
- 'full_scores' => [],
+ // 全局默认合格率(百分比,如 60 表示"≥满分的 60%"算合格)。
+ // 每份试卷可在主页面单独覆盖(project_config),试卷之间互相隔离。
+ 'pass_rate' => 60,
'layout' => 'default',
// 历史成绩展示方式:click=点击成员弹窗查看(默认);inline=成绩明细内直接展开每次考试
'history_display' => 'click',
@@ -258,16 +260,45 @@ function ny_load_config(): array
{
$GLOBALS['__ny_cfg_source'] = 'default';
- // 默认配置 + 文件覆盖(DB 后端本批次已下线,统一走文件存储)
- $cfg = ny_default_config();
- $saved = ny_read_json(ny_config_file(), null);
+ $cfg = ny_default_config();
+ $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)) {
foreach ($cfg as $k => $v) {
if (array_key_exists($k, $saved)) {
$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['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
@@ -276,8 +307,52 @@ function ny_load_config(): array
function ny_save_config(array $cfg): bool
{
- $GLOBALS['__ny_cfg_source'] = 'file';
- return ny_write_json(ny_config_file(), $cfg);
+ // 双写: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';
+ }
+ 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];
}
/** 安全的字符串化(避免数组/对象转字符串报错) */
diff --git a/dashboard.php b/dashboard.php
index a830fcc..39078ed 100644
--- a/dashboard.php
+++ b/dashboard.php
@@ -25,11 +25,7 @@ if (!ny_is_auth()) {
-
NY
-
- 班级看板
- 选择一个问卷与班级查看考试整体情况
-
+
NY问卷考试系统数据平台
@@ -47,7 +43,9 @@ if (!ny_is_auth()) {
-
+
+ 选择一个问卷与班级查看考试整体情况
+
最后更新:—
@@ -60,7 +58,7 @@ if (!ny_is_auth()) {
缺考-
提交率-
平均分-
-
合格率(≥60%满分)-
+
合格率(≥60%满分)-
分组对比 / 考试成员
@@ -200,6 +198,9 @@ async function refreshClassSel(){
try {
const data = await api('dashboard', { projectId: pid });
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);
sel.innerHTML = classes.length
? classes.map(c => '
').join('')
@@ -285,8 +286,9 @@ function renderPlaceholderBoard(){
$('#boardSummary').innerHTML = cards.map(c =>
'
' + c[0] + '' + c[1] + '
').join('');
$('#boardBody').innerHTML = '
请选择问卷与班级查看考试整体情况
';
- $('#heroTitle').textContent = '班级看板';
- $('#heroSub').textContent = '选择一个问卷与班级查看考试整体情况';
+ // 注意:heroTitle 已随导航栏精简移除,只保留 heroSub(承载当前问卷/班级提示)
+ const _hs = document.getElementById('heroSub');
+ if (_hs) _hs.textContent = '选择一个问卷与班级查看考试整体情况';
const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); }
}
@@ -327,9 +329,8 @@ function renderBoard(){
return;
}
- // 顶部品牌区:动态标题
+ // 顶部提示:当前班级 / 问卷(heroTitle 已移除,只更新 heroSub)
const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || '';
- $('#heroTitle').textContent = cls + ' · 班级看板';
// 构建"学号 -> 多次刷题明细"映射(班级级 members 已含全班所有人的 attempts_list)
const MAP = {};
@@ -342,7 +343,8 @@ function renderBoard(){
const overall = DASH.overall || {};
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 +
(totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : '');
@@ -354,7 +356,7 @@ function renderBoard(){
'
缺考' + c.absent + '
' +
'
提交率' + c.rate + '%
' +
'
平均分' + (c.avg != null ? c.avg : '-') + '
' +
- '
合格率' + c.pass_rate + '%
';
+ '
合格率(≥' + (DASH && DASH.pass_rate != null ? DASH.pass_rate : 60) + '%满分)' + c.pass_rate + '%
';
const groups = (c.groups || []).filter(g => g.group !== '(未分组)');
const box = $('#boardBody');
@@ -392,10 +394,21 @@ function toggleFullscreen(){
const el = document.documentElement;
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
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 {
- const ex = el.exitFullscreen || el.webkitExitFullscreen;
- if (ex) ex.call(document);
+ // 注意:exitFullscreen 挂在 document 上,不是 documentElement 上
+ // (写成 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(){
diff --git a/draw.php b/draw.php
index b500af8..d7645f7 100644
--- a/draw.php
+++ b/draw.php
@@ -26,11 +26,7 @@ $rosterCount = Roster::count();
-
NY
-
- 随机抽取
- 名单 / 已提交人员 → 卷王白名单
-
+
NY问卷考试系统数据平台
名单管理 = (int)$rosterCount ?>
diff --git a/index.php b/index.php
index 9759476..c861a4d 100644
--- a/index.php
+++ b/index.php
@@ -22,11 +22,7 @@ $rosterCount = Roster::count();
@@ -197,9 +195,14 @@ $rosterCount = Roster::count();
实际生效上限 200,超出会自动夹到 200(防止单页内存耗尽)
-
+
-
未选中试卷时的默认值
+
全局默认;每份试卷可在主页面「试卷设置」中单独覆盖
+
+
+
+
+
达到满分的该百分比即算合格;每份试卷可单独覆盖
-
-
-
- 未选中试卷时,此处为空;选中某套试卷后保存即在设置中为该卷单独设定满分
-
-
-
给某卷填入数字即可单独覆盖默认满分;留空=用默认
-
@@ -435,6 +429,74 @@ $rosterCount = Roster::count();
+
+
+
+
打印成绩明细
+
+
+
+
+
+
+
+
+
分组后每个班/组单独一段,段首显示小计(人数/平均分/合格率)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
试卷设置
+
+
以下设置仅对当前选中的这份试卷生效,各试卷之间互不影响。留空表示跟随全局默认。
+
+
+
+
+
+
免考学生不计入「名单总人数」与「缺考名单」
+
+
+
+
+
+
diff --git a/lib/DataService.php b/lib/DataService.php
index 787f5de..c7a60d4 100644
--- a/lib/DataService.php
+++ b/lib/DataService.php
@@ -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}
+ */
+ 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
{
$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();
+ // 免考:从名单中剔除,使其既不计入「总人数」也不计入「缺考」
+ if ($exempt !== []) {
+ $students = array_values(array_filter(
+ $students,
+ static fn(array $s): bool => !isset($exempt[trim((string)($s['sid'] ?? ''))])
+ ));
+ }
// 姓名 -> 学号 / 班级(规范化)
$studentInfo = [];
foreach ($students as $s) {
@@ -313,6 +375,12 @@ class DataService
'anomalies' => $anomalies,
'roster' => $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'),
];
}
diff --git a/lib/Db.php b/lib/Db.php
index abb4f1f..d8e4fb9 100644
--- a/lib/Db.php
+++ b/lib/Db.php
@@ -1,83 +1,411 @@
'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
{
- return [];
+ $pdo = self::pdo();
+ if (!$pdo) {
+ return [];
+ }
+ try {
+ $p = self::prefix();
+ $st = $pdo->query("SELECT k, v FROM {$p}settings");
+ $out = [];
+ foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
+ $out[(string)$r['k']] = (string)$r['v'];
+ }
+ return $out;
+ } catch (Throwable $e) {
+ self::$err = $e->getMessage();
+ return [];
+ }
}
- 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
{
- return [];
+ $pdo = self::pdo();
+ if (!$pdo) {
+ 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
{
- return 0;
+ $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 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();
+ }
}
}
diff --git a/lib/Roster.php b/lib/Roster.php
index 165a192..dcf9677 100644
--- a/lib/Roster.php
+++ b/lib/Roster.php
@@ -19,10 +19,49 @@ class Roster
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 */
public static function all(): array
{
if (self::useDb()) {
+ self::migrateFileToDbOnce();
return Db::rosterAll();
}
$d = ny_read_json(self::file(), null);
@@ -105,7 +144,10 @@ class Roster
public static function fingerprint(): string
{
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();
$mt = is_file($f) ? (string)filemtime($f) : '0';
diff --git a/tools/test-config-save.js b/tools/test-config-save.js
index 70afd36..c06764d 100644
--- a/tools/test-config-save.js
+++ b/tools/test-config-save.js
@@ -20,6 +20,17 @@ const hasToken = fs.existsSync(tokenFile);
const tokenSnap = hasToken ? fs.readFileSync(tokenFile, 'utf8') : null;
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({
base_url: 'http://127.0.0.1:19911',
api_prefix: '/api',
@@ -51,6 +62,14 @@ function cleanup() {
try {
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
} 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('SIGINT', () => { cleanup(); process.exit(1); });
@@ -235,35 +254,44 @@ async function login() {
const after = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
chk('token 保持未变', before === after, 'changed=' + (before !== after));
- console.log('\n--- 4) 各试卷独立满分:仅当 apply_pid 显式指定时写 ---');
- // 不传 apply_pid,也不传 full_scores → full_scores 应保留原值(保持空映射)
- const cs2 = await req('POST', '/api.php?action=config_save', {
- full_score: 90,
+ console.log('\n--- 4) 试卷独立配置(满分/合格率/免考)已从设置移出,改走 project_config ---');
+ // 每卷满分已不在 config_save 里;改为 project_config_save(试卷间隔离)
+ const pc1 = await req('POST', '/api.php?action=project_config_save&projectId=p1', {
+ full_score: 150, pass_rate: 70, exempt: 'S1,S2',
});
- const _cs2fs = cs2.body && cs2.body.data && cs2.body.data.config
- ? cs2.body.data.config.full_scores : undefined;
- chk('未传 apply_pid → full_scores 保持空映射(不被覆盖为非空)',
- _cs2fs === undefined || Object.keys(_cs2fs).length === 0,
- JSON.stringify(_cs2fs));
- // 显式 apply_pid='p1', apply_score=85 → 写入
- const cs3 = await req('POST', '/api.php?action=config_save', {
- full_score: 90,
- apply_pid: 'p1',
- apply_score: 85,
+ chk('project_config_save 200', pc1.status === 200, 'status=' + pc1.status + ' ' + JSON.stringify(pc1.body));
+ chk('生效满分=150', pc1.body && pc1.body.data && pc1.body.data.effective
+ && pc1.body.data.effective.full_score === 150,
+ JSON.stringify(pc1.body && pc1.body.data && pc1.body.data.effective));
+ chk('生效合格率=70', pc1.body && pc1.body.data && pc1.body.data.effective
+ && Number(pc1.body.data.effective.pass_rate) === 70,
+ JSON.stringify(pc1.body && pc1.body.data && pc1.body.data.effective));
+
+ // 隔离性:p2 未配置 → 跟随全局(注:上面的 config_save 已把全局满分改成 95,
+ // 所以这里不能写死 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
- && cs3.body.data.config.full_scores && cs3.body.data.config.full_scores.p1 === 85,
- JSON.stringify(cs3.body.data && cs3.body.data.config && cs3.body.data.config.full_scores));
- // 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));
+ chk('清除后 p1 回到全局满分(=' + gFs + ')', pc4.body && pc4.body.data && pc4.body.data.effective
+ && pc4.body.data.effective.full_score === gFs,
+ JSON.stringify(pc4.body && pc4.body.data && pc4.body.data.effective) + ' global=' + gFs);
console.log('\n--- 5) 修改连接信息:应清 token + 重新登录(ping)---');
const tokBefore = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';