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
+55 -27
View File
@@ -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') : '';