feat(export/dashboard): 导出增强与大屏公开化

导出-每次考试/多考试对比:
- 合格次数阈值等标记项抽为常显区块,三类成绩表共用
- 附缺考名单扩展到多考试导出(取所选考试全都没交的人)
- 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列
- 列宽按实际内容估算,合并区空位只给最小宽度
- 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底
- 打印标题改为「成绩明细」,缺考名单分页单独成页
- 打印样式加 print-color-adjust:exact,绿底/红字可正常印出

数据大屏:
- ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传
- 单班大屏移除退出按钮,仅首页保留
- 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到)
  改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿

测试/样例/文档同步更新,全量回归通过。
This commit is contained in:
2026-09-27 22:04:17 +08:00
parent 0ba9c7c33c
commit d05fd4a9b5
34 changed files with 4290 additions and 305 deletions
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env node
/**
* 生成数据大屏首页的可打开预览:docs/samples/dash-home-preview.html
*
* 关键点与 make-samples.js 一致——卡片**直接调用 dashboard.php 里生产环境的 clsCardHtml()**,
* 不是另写一份渲染逻辑,所以预览长什么样,线上首页就长什么样,不会两边漂移。
* 数据取自本机 mock-server(tools/mock-server.js)+ PHP 内置服务器,名单与答卷都是真跑出来的。
*
* 用法:node tools/make-dash-sample.js
*/
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const http = require('http');
const { spawn } = require('child_process');
const ROOT = path.join(__dirname, '..');
const PORT = 18095;
const INI = process.env.PHP_INI || '';
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
/* ---------- 临时数据目录:绝不动项目 data/ ---------- */
let p = process.env.NY_DATA || path.join(os.tmpdir(), 'ny-dash-sample-' + process.pid);
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2);
const DATA = path.resolve(p);
process.env.NY_DATA = DATA;
fs.mkdirSync(DATA, { recursive: true });
fs.writeFileSync(path.join(DATA, 'config.json'), JSON.stringify({
base_url: 'http://127.0.0.1:19911',
api_prefix: '/api',
username: '', password: '',
verify_ssl: false, timeout: 10,
refresh_interval: 15, page_size: 50,
dedupe: true, include_temp_save: false,
name_keywords: '姓名,名字',
full_score: 100, layout: 'default',
}, null, 2));
/* 名单:mock 的「卷内参与者」只放行 90001-90005,应考名单会被收敛到这几人,
所以把 5 人分散到 4 个班,其余人做无答卷的填充(让卡片同时能看到缺考数)。 */
const STUDENTS = [];
[['90001', '高一(1)班'], ['90002', '高一(2)班'], ['90003', '高一(3)班'],
['90004', '高一(4)班'], ['90005', '高一(1)班']].forEach(([sid, cls]) =>
STUDENTS.push({ sid, name: sid, cls, grp: 'A组', login: '' }));
['高一(1)班', '高一(2)班', '高一(3)班', '高一(4)班'].forEach((cls, ci) => {
for (let i = 1; i <= 11; i++) {
const sid = String(9200 + ci * 100 + i);
STUDENTS.push({ sid, name: sid, cls, grp: i % 2 ? 'A组' : 'B组', login: '' });
}
});
fs.writeFileSync(path.join(DATA, 'roster.json'), JSON.stringify({ students: STUDENTS, updated_at: '2026-01-01 00:00:00' }));
let mock = null, srv = null, cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
try { srv && srv.kill('SIGKILL'); } catch (e) {}
try { mock && mock.kill('SIGKILL'); } catch (e) {}
try { fs.rmSync(DATA, { recursive: true, force: true }); } catch (e) {}
}
process.on('exit', cleanup);
process.on('uncaughtException', e => { console.error('FATAL', e); cleanup(); process.exit(2); });
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'ignore' });
srv = spawn(PHP, INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]), { cwd: ROOT, stdio: 'ignore' });
/* ---------- 极简 HTTP ---------- */
const jar = [];
function collect(res) {
for (const sc of (res.headers['set-cookie'] || [])) {
const pair = sc.split(';')[0];
const i = jar.findIndex(x => x.startsWith(pair.split('=')[0] + '='));
if (i >= 0) jar[i] = pair; else jar.push(pair);
}
}
const cookie = () => (jar.length ? jar.join('; ') : '');
function req(method, p) {
return new Promise((resolve, reject) => {
const h = { 'Accept': 'application/json' };
if (cookie()) h['Cookie'] = cookie();
const r = http.request({ host: '127.0.0.1', port: PORT, method, path: p, headers: h }, res => {
const bufs = [];
res.on('data', c => bufs.push(c));
res.on('end', () => { collect(res); resolve({ raw: Buffer.concat(bufs).toString('utf8') }); });
});
r.on('error', reject);
r.end();
});
}
// 登录必须走表单编码(login.php 读 $_POST),只发一个空 POST 是登不上的
function formPost(p, fields) {
return new Promise((resolve, reject) => {
const body = new URLSearchParams(fields).toString();
const h = { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body) };
if (cookie()) h['Cookie'] = cookie();
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path: p, headers: h }, res => {
const bufs = [];
res.on('data', c => bufs.push(c));
res.on('end', () => { collect(res); resolve({ raw: Buffer.concat(bufs).toString('utf8') }); });
});
r.on('error', reject);
r.write(body); r.end();
});
}
const delay = ms => new Promise(r => setTimeout(r, ms));
/* ---------- 抽出生产环境的卡片渲染函数 ---------- */
const dashSrc = fs.readFileSync(path.join(ROOT, 'dashboard.php'), 'utf8');
const m = dashSrc.match(/function clsCardHtml\(c\)\{[\s\S]*?\n\}/);
if (!m) { console.error('未能从 dashboard.php 抽取 clsCardHtml'); cleanup(); process.exit(1); }
const esc = s => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const dv = (v, d) => (v === undefined || v === null || v === '' ? (d === undefined ? '-' : d) : v);
const pv = v => dv(v, '-') + (v === undefined || v === null || v === '' ? '' : '%');
const rateColor = r => (r >= 90 ? 'var(--ok)' : (r >= 60 ? 'var(--warn)' : 'var(--danger)'));
const clsCardHtml = new Function('esc', 'dv', 'pv', 'rateColor', m[0] + ' return clsCardHtml;')(esc, dv, pv, rateColor);
(async () => {
await delay(2200);
await formPost('/login.php', { username: 'mock', password: 'surveyking' }); // 顺带把账密写进临时配置
const pr = await req('GET', '/api.php?action=projects');
const projects = ((JSON.parse(pr.raw).data) || []).filter(x => x.id && x.id !== 'p2');
if (!projects.length) { console.error('mock 没返回可用问卷,原始响应:\n' + pr.raw.slice(0, 800)); cleanup(); process.exit(1); }
const project = projects[0];
const dr = await req('GET', '/api.php?action=dashboard&projectId=' + encodeURIComponent(project.id));
const data = JSON.parse(dr.raw).data || {};
const classes = data.classes || [];
if (!classes.length) { console.error('dashboard 没返回班级,名单是否为空?'); cleanup(); process.exit(1); }
const cards = classes.map(clsCardHtml).join('');
const projectName = project.name || project.id;
const html = [
'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">',
'<title>数据大屏首页(预览)</title>',
'<meta name="viewport" content="width=device-width, initial-scale=1">',
'<link rel="stylesheet" href="../../assets/style.css">',
'<style>',
'/* 预览页专属:补上 hero 里样式类所需的小件(原页面由 dashboard.php 内联给出) */',
'.brand-text{display:flex;flex-direction:column;gap:2px}',
'.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--border-2);vertical-align:1px}',
'.dot.on{background:var(--ok)}',
'.dot.stale{background:var(--danger)}',
'.spacer{flex:1}',
'.preview-tip{margin:12px 0 0;padding:8px 12px;border:1px dashed var(--border);border-radius:var(--radius);',
' font-size:12px;color:var(--text-3);background:var(--panel-weak,#f7f8fc)}',
'</style></head>',
'<body class="dash dash-home">',
'<div class="wrap">',
' <div class="dash-hero">',
' <div class="brand"><h1 class="brand-title">NY问卷考试系统数据平台</h1></div>',
' <div class="dash-controls">',
' <select id="projectSel"><option value="' + esc(project.id) + '">' + esc(project.name) +
'(' + project.total + ' 份)</option></select>',
' <span id="classSelWrap"><select id="classSel" disabled><option value="">请先选问卷</option></select></span>',
' <label class="switch"><input type="checkbox" id="autoRefresh" checked><span></span></label>',
' <select id="intervalSel"><option value="15" selected>15 秒</option></select>',
' <button class="btn" id="btnRefresh">刷新</button>',
' <button class="btn" id="btnFullscreen">⛶ 全屏</button>',
' <a class="btn back" id="linkIndex" href="index.php">← 主系统</a>',
' <a class="btn back" id="linkLogout" href="logout.php">退出</a>',
' </div>',
' </div>',
' <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">共 ' + classes.length + ' 个班级 · 点击卡片进入该班大屏</span>',
' <span class="spacer"></span>',
' 最后更新:<span id="lastUpdate">' + esc(data.updated_at || '-') + '</span><i id="liveDot" class="dot on"></i>',
' </div>',
' <div class="preview-tip">静态预览:这张页面由生产环境 dashboard.php 的 <code>clsCardHtml()</code> 渲染,',
' 数据取自 mock 卷王。真实站点上点卡片会整页跳转到 <code>dashboard.php?pid=…&amp;class=…</code>,',
' 进入该班独立大屏(那里不再有班级选择、返回主系统与退出,浏览器后退键也被锁住)。</div>',
' <div id="board">',
' <section class="cards" id="boardSummary">',
' <div class="card accent-blue"><span>总人数</span><b>0</b></div>',
' <div class="card ok accent-green"><span>已提交</span><b>0</b></div>',
' <div class="card warn accent-orange"><span>缺考</span><b>0</b></div>',
' <div class="card accent-teal"><span>平均分</span><b>-</b></div>',
' <div class="card accent-indigo"><span>合格率</span><b>-</b></div>',
' </section>',
' <div class="sec-title" id="boardTitle">班级入口 <span class="tag-gray">点击卡片进入该班独立大屏</span></div>',
' <div id="boardBody"><div class="cls-grid">' + cards + '</div></div>',
' </div>',
'</div>',
'<script>/* 预览页:把"全屏/刷新"按钮做成 plainly 不可点,仅示意版式 */',
'document.addEventListener(\'DOMContentLoaded\', function () {',
' ["#btnFullscreen", "#btnRefresh", "#projectSel", "#classSelWrap"].forEach(function (s) {',
' var el = document.querySelector(s); if (el) el.setAttribute("disabled", "disabled");',
' });',
'});</script>',
'</body></html>',
].join('\n');
const out = path.join(ROOT, 'docs', 'samples', 'dash-home-preview.html');
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, html, 'utf8');
console.log('已生成 ' + path.relative(ROOT, out));
console.log(' 问卷:' + projectName + ' 班级:' + classes.map(c => c.class).join('、'));
cleanup();
process.exit(0);
})();
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env node
/**
* 把 tools/make-samples.php 产出的数据渲染成可打开的样例页面。
*
* 关键点:页面里的表格**直接调用 assets/app.js 里生产环境的 sheetToHtml()**——
* 不是另写一份渲染逻辑,所以预览长什么样,打印就长什么样,不会两边漂移。
* 配色 / 字号沿用 printDoc() 里打印用的那套样式。
*
* 用法:node tools/make-samples.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const data = JSON.parse(fs.readFileSync(path.join(ROOT, 'docs', 'samples', 'sheet-data.json'), 'utf8'));
/* ---- 抽出生产环境的 sheetToHtml ---- */
const appSrc = fs.readFileSync(path.join(ROOT, 'assets', 'app.js'), 'utf8');
const m = appSrc.match(/function sheetToHtml\(sheet\)\s*\{[\s\S]*?\n \}/);
if (!m) {
console.error('未能从 assets/app.js 抽取 sheetToHtml');
process.exit(1);
}
const esc = s => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// eslint-disable-next-line no-new-func
const sheetToHtml = new Function('esc', 'return ' + m[0])(esc);
/* ---- 沿用 printDoc() 里的打印配色 ---- */
const PRINT_CSS = [
'body{font-family:"Microsoft YaHei","PingFang SC",sans-serif;color:#222;background:#f7f7f7;margin:0;padding:24px}',
'.page{background:#fff;border:1px solid #e3e3e3;border-radius:6px;padding:20px 22px;margin:0 auto 20px;max-width:1100px}',
'.page h2{font-size:16px;margin:0 0 4px}',
'.page .sub{font-size:12px;color:#777;margin:0 0 14px}',
// 与 printDoc() 里的打印样式保持一致(表头渲染成 <td>,所以底色写 thead td)
'table.bt{border-collapse:collapse;border:1px solid #999;width:100%;background:#fff;font-size:11px}',
'table.bt td,table.bt th{border:1px solid #999;text-align:center;padding:3px 5px}',
'table.bt th,table.bt thead td{background:#eaeaea;font-weight:700}',
'table.bt thead tr:first-child td{background:#eaeaea}',
'table.bt td.absent{color:#888}',
'table.bt td.max{color:#d0021b;font-weight:700}',
'table.bt td.pass{background:#63c384;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}',
'html,body,.pass,.absent,.max{-webkit-print-color-adjust:exact;print-color-adjust:exact}',
'.legend{margin:10px 0 0;font-size:12px;color:#555}',
'.legend i{display:inline-block;width:11px;height:11px;vertical-align:-1px;margin:0 4px 0 12px}',
'.legend .r{color:#d0021b;font-weight:700}',
'.legend .g{background:#63c384;font-weight:700}',
'.sign{margin-top:16px;font-size:12px}',
].join('');
function render(item) {
const s = item.sheet;
const table = sheetToHtml(s);
const meta = item.meta || {};
const title = esc(item.title);
const sub = meta.exam_name
? esc(meta.exam_name) + ' · 合格线 ' + meta.pass_line + ' 分,达 ' + meta.pass_times + ' 次标绿'
: '合格线 ' + meta.pass_line + ' 分,达 ' + meta.pass_times + ' 次标绿';
return [
'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">',
'<title>' + title + '(预览)</title><style>' + PRINT_CSS + '</style></head><body>',
'<div class="page"><h2>' + title + '</h2><p class="sub">' + sub + '</p>',
table,
'<p class="legend">标记说明:<span class="r">最高分</span> 标红、'
+ '<i class="r">红字</i>;<span class="g">合格次数</span> 始终显示(含 0 次),'
+ '达 ' + meta.pass_times + ' 次标绿、<i class="g">绿底</i>;未达标显示次数、不标绿。',
'<p class="sign">教师签字:______________  教研组长签字:______________  日期:____________</p>',
'</div></body></html>',
].join('\n');
}
const outDir = path.join(ROOT, 'docs', 'samples');
fs.writeFileSync(path.join(outDir, 'preview-multi-block.html'), render(data.block));
fs.writeFileSync(path.join(outDir, 'preview-single-wide.html'), render(data.wide));
console.log('已生成:');
console.log(' docs/samples/preview-multi-block.html (多考试块表)');
console.log(' docs/samples/preview-single-wide.html (单场宽表)');
+126
View File
@@ -0,0 +1,126 @@
<?php
/**
* 生成「导出 / 打印」新版式的可打开样例:
* 1) docs/samples/export-multi-block.xlsx —— 多考试块表(两行表头 + 合格次数 + 红绿标记)
* 2) docs/samples/export-single-wide.xlsx —— 单场宽表(每人一行、第几次做列名)
* 3) docs/samples/sheet-data.json —— 供 make-samples.js 渲染预览 HTML
*
* 数据是一次性写死的示例数据(与 tests/test-multi-export.php 的截图场景同一套口径),
* 不是真实导出结果,仅用于看版式。
*
* 运行:php tools/make-samples.php
*/
declare(strict_types=1);
require __DIR__ . '/../bootstrap.php';
require __DIR__ . '/../lib/multi_export.php';
/** 构造一名学生的若干次考试记录(成绩按时间升序) */
function sample_stu(string $sid, string $name, float $best, array $scoresAsc, array $tempMarks = []): array
{
$list = [];
$t = 1000000;
foreach ($scoresAsc as $i => $sc) {
$t += 600;
$list[] = [
'idx' => $i + 1,
'submit_ts' => $t,
'score' => $sc,
'temp_save' => !empty($tempMarks[$i]),
'duration' => 120,
];
}
return [
'sid' => $sid,
'name' => $name,
'cls' => '1班',
'grp' => 'A',
'score' => end($scoresAsc),
'best_score' => $best,
'attempts' => count($scoresAsc),
'attempts_list' => $list,
];
}
/* ---- 示例数据:与需求截图同口径(合格线 110,阈值 1 次) ---- */
$exams = [
'm' => ['name' => '第一次月考·数学', 'rows' => [
sample_stu('356001', '张三', 136.0, [112.0, 100.0, 136.0, 127.0]),
sample_stu('356002', '李四', 0.0, []), // 整场未考
sample_stu('356003', '王五', 95.0, [95.0]), // 未达合格线 → 不标绿
]],
'e' => ['name' => '第一次月考·英语', 'rows' => [
sample_stu('356001', '张三', 112.0, [112.0, 99.0]),
sample_stu('356002', '李四', 133.0, [130.0, 129.0, 133.0]),
]],
];
$opts = [
'pass_line' => 110, // 本卷满分 130 × 合格率 85%
'pass_times' => 1, // 达标阈值
'mark_best' => true, // 最高分标红
'pass_row' => true, // 输出「合格次数」行
];
/* ---- 1) 多考试块表 ---- */
$block = ny_assemble_block_sheet($exams, $opts);
$blockBin = XlsxWriter::renderMulti([[
'name' => '每次考试',
'rows' => $block['rows'],
'widths' => $block['widths'] ?? [],
'merges' => $block['merges'],
'header_rows' => $block['header_rows'],
'cell_styles' => $block['cell_styles'] ?? [],
]]);
$blockPath = __DIR__ . '/../docs/samples/export-multi-block.xlsx';
file_put_contents($blockPath, $blockBin);
/* ---- 2) 单场宽表(只导出一场考试时的版式) ---- */
$wideRes = ny_assemble_attempt_wide_sheet($exams['m'], $opts);
$wideBin = XlsxWriter::renderMulti([[
'name' => '每次考试',
'rows' => $wideRes['rows'],
'widths' => $wideRes['widths'] ?? [],
'merges' => $wideRes['merges'],
'header_rows' => $wideRes['header_rows'],
'cell_styles' => $wideRes['cell_styles'] ?? [],
]]);
$widePath = __DIR__ . '/../docs/samples/export-single-wide.xlsx';
file_put_contents($widePath, $wideBin);
/* ---- 3) 交给 Node 渲染预览 HTML(用 app.js 里的 sheetToHtml,避免两份实现漂移) ---- */
$data = [
'block' => [
'title' => '多考试导出 · 每次考试(两行表头)',
'sheet' => [
'rows' => $block['rows'],
'merges' => $block['merges'],
'header_rows' => $block['header_rows'],
'cell_styles' => $block['cell_styles'] ?? [],
],
'meta' => [
'pass_line' => $opts['pass_line'],
'pass_times' => $opts['pass_times'],
],
],
'wide' => [
'title' => '单场导出 · 每次考试(每人一行的宽表)',
'sheet' => [
'rows' => $wideRes['rows'],
'merges' => $wideRes['merges'],
'header_rows' => $wideRes['header_rows'],
'cell_styles' => $wideRes['cell_styles'] ?? [],
],
'meta' => [
'pass_line' => $opts['pass_line'],
'pass_times' => $opts['pass_times'],
'exam_name' => $exams['m']['name'],
],
],
];
file_put_contents(__DIR__ . '/../docs/samples/sheet-data.json', json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
echo '块表:' . count($block['rows']) . ' 行 / ' . count($block['merges']) . ' 处合并 / '
. count($block['cell_styles'] ?? []) . " 处标记 → " . realpath($blockPath) . PHP_EOL;
echo '宽表:' . count($wideRes['rows']) . ' 行 / ' . count($wideRes['merges']) . ' 处合并 / '
. count($wideRes['cell_styles'] ?? []) . " 处标记 → " . realpath($widePath) . PHP_EOL;
echo '预览数据:' . realpath(__DIR__ . '/../docs/samples/sheet-data.json') . PHP_EOL;
+26 -2
View File
@@ -13,7 +13,30 @@ const PUB_B64 = Buffer.from(publicKey).toString('base64');
const PASSWORD = 'surveyking';
let TOKEN = '';
const roster = JSON.parse(fs.readFileSync(require('path').join(__dirname, '..', 'data', 'roster.json'), 'utf8')).students;
/* 名单:优先用 data/roster.json(真实名单,答卷也按它生成)。
没有该文件时用内置样例名单兜底,免得整个 e2e 因为缺文件连不上——
mock 只关心名单的条数与姓名,真实字段由 demoRoster() 补齐。 */
function demoRoster() {
const out = [];
const classes = ['高一(1)班', '高一(2)班'];
const groups = ['A组', 'B组'];
for (let i = 1; i <= 40; i++) {
// 名字必须与下方 PARTNERS 的 userName 一致,否则答卷匹配不到名单
const id = String(90000 + i);
out.push({ sid: id, name: id, cls: classes[i % 2], grp: groups[i % 2] });
}
return out;
}
function loadRoster() {
const p = require('path').join(__dirname, '..', 'data', 'roster.json');
try {
const list = JSON.parse(fs.readFileSync(p, 'utf8')).students;
if (Array.isArray(list) && list.length) return { list, from: 'data/roster.json' };
} catch (e) { /* 没有名单文件,走内置兜底 */ }
return { list: demoRoster(), from: '内置样例名单(data/roster.json 不存在)' };
}
const ROSTER = loadRoster();
const roster = ROSTER.list;
// 构造答卷:前 30 人提交,其中 2 人姓名有空格/错别字
const answers = roster.slice(0, 30).map((s, i) => {
@@ -153,5 +176,6 @@ const server = http.createServer((req, res) => {
});
server.listen(19911, '127.0.0.1', () => {
console.log('mock 卷王已启动 http://127.0.0.1:19911 密码=' + PASSWORD);
console.log('mock 卷王已启动 http://127.0.0.1:19911 密码=' + PASSWORD
+ '\n名单来源:' + ROSTER.from + '(' + roster.length + ' 人)');
});
+150
View File
@@ -0,0 +1,150 @@
/* 多考试打印渲染回归测试
*
* 「多考试打印」的版式由 assets/app.js 的 sheetToHtml() 负责:把服务端装配好的
* 二维表 + merges(1-based 闭区间)+ cell_styles(red=最高分红字 / green=合格绿底)
* 翻成带 colspan / rowspan 的 HTML。
* 本测试直接从 app.js 抽取该函数源码执行(不复制一份实现,避免两边漂移),
* 用与 tests/test-multi-export.php 同一套版式校验渲染结果。
*
* 运行:node tools/test-block-print.js
*/
const fs = require('fs');
const path = require('path');
let pass = 0, fail = 0;
function chk(name, ok, extra) {
if (ok) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
}
/* ---------- 1) 从生产代码里抽取 sheetToHtml ---------- */
const appPath = path.join(__dirname, '..', 'assets', 'app.js');
const appSrc = fs.readFileSync(appPath, 'utf8');
const m = appSrc.match(/function sheetToHtml\(sheet\)\s*\{[\s\S]*?\n \}/);
chk('能从 assets/app.js 抽到 sheetToHtml', !!m);
if (!m) {
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
process.exit(1);
}
const PRINT_CSS = (appSrc.match(/function printDoc\(title, bodyHtml, opts\)\s*\{[\s\S]*?\n \}/) || [''])[0];
const esc = s => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// eslint-disable-next-line no-new-func
const sheetToHtml = new Function('esc', 'return ' + m[0])(esc);
/* ---------- 2) 真实装配结果(截图版式:共享「第几次」列 + 合格次数行) ----------
列:1 学号 | 2 姓名 | 3 第几次 | 4-5 数学 | 6-7 英语
合格线 110,阈值 1 次。 */
const FIXTURE = {
header_rows: 2,
rows: [
['学号', '姓名', '第几次', '数学', '', '英语', ''],
['', '', '', '成绩', '', '成绩', ''],
// 张三:数学 3 次(112/100/136),英语 1 次(112)
['356001', '张三', '1', '112', '', '112', ''],
['', '', '2', '100', '', '', ''],
['', '', '3', '136', '', '', ''],
['', '', '合格次数', '3', '', '1', ''],
// 李四:数学整场未考,英语 3 次(130/129/133)
['356002', '李四', '1', '未考', '', '130', ''],
['', '', '2', '', '', '129', ''],
['', '', '3', '', '', '133', ''],
['', '', '合格次数', '', '', '3', ''],
// 王五:数学 1 次(88,未达合格线),英语整场未考
['356003', '王五', '1', '88', '', '未考', ''],
['', '', '合格次数', '0', '', '', ''],
],
merges: [
[1, 1, 2, 1], [1, 2, 2, 2], [1, 3, 2, 3], [1, 4, 1, 5], [1, 6, 1, 7], // 表头
[3, 1, 6, 1], [3, 2, 6, 2], [3, 3, 5, 3], // 张三:学号/姓名并入末行,「第几次」只到末次考试
[7, 1, 10, 1], [7, 2, 10, 2], [7, 4, 10, 5], // 李四:学号/姓名并入末行;数学整场「未考」跨 2 列 × 4 行
[11, 6, 11, 7], // 王五:英语整场「未考」跨 2 列
],
cell_styles: {
'5,4': 'red', // 张三 数学最高分 136(1-based 行 5) → 红字
'9,6': 'red', // 李四 英语最高分 133(1-based 行 9) → 红字
'6,4': 'green', // 张三 数学合格次数 3 → 绿底
'6,6': 'green', // 张三 英语合格次数 1 → 绿底
'10,6': 'green', // 李四 英语合格次数 3 → 绿底
},
};
const html = sheetToHtml(FIXTURE);
const thead = (html.match(/<thead>([\s\S]*?)<\/thead>/) || [])[1] || '';
const tbody = (html.match(/<tbody>([\s\S]*?)<\/tbody>/) || [])[1] || '';
const count = (s, needle) => s.split(needle).length - 1;
/* ---------- 3) 断言 ---------- */
chk('表头 2 行进 <thead>(多页打印自动重复)', count(thead, '<tr>') === 2 && count(tbody, '<tr>') === 10,
'thead=' + count(thead, '<tr>') + ' tbody=' + count(tbody, '<tr>'));
chk('学号表头纵向合并 2 行', thead.indexOf('<td rowspan="2">学号</td>') >= 0);
chk('姓名表头纵向合并 2 行', thead.indexOf('<td rowspan="2">姓名</td>') >= 0);
chk('第几次表头纵向合并 2 行', thead.indexOf('<td rowspan="2">第几次</td>') >= 0);
chk('考试名称横跨 2 列',
thead.indexOf('<td colspan="2">数学</td>') >= 0 && thead.indexOf('<td colspan="2">英语</td>') >= 0);
chk('第 2 行表头是「成绩」小标题', thead.indexOf('<td>成绩</td>') >= 0);
chk('张三学号纵向合并 4 行(3 次考试 + 合格次数行)', tbody.indexOf('<td rowspan="4">356001</td>') >= 0);
chk('李四学号纵向合并 4 行', tbody.indexOf('<td rowspan="4">356002</td>') >= 0);
chk('王五学号不合并(只有 1 次考试)', tbody.indexOf('<td>356003</td>') >= 0);
chk('「合格次数」标签落在「第几次」列且 3 处都在', count(tbody, '<td>合格次数</td>') === 3,
'实际 ' + count(tbody, '<td>合格次数</td>'));
chk('未考合并 2 列 × 4 行(李四·数学)',
tbody.indexOf('<td rowspan="4" colspan="2" class="absent">未考</td>') >= 0);
chk('未考合并 2 列 × 1 行(王五·英语)',
tbody.indexOf('<td colspan="2" class="absent">未考</td>') >= 0);
chk('最高分红字 2 处(张三 136 / 李四 133)', count(tbody, 'class="max">') === 2,
'实际 ' + count(tbody, 'class="max">'));
chk('合格次数绿底 3 处(张三 2 场 + 李四 1 场)', count(tbody, 'class="pass">') === 3,
'实际 ' + count(tbody, 'class="pass">'));
chk('王五数学 0 次未达标 → 不标绿', tbody.indexOf('>0</td>') >= 0 && tbody.indexOf('class="pass">0') < 0);
/* td 数守恒:总格位数 − 被合并吞掉的格位。合并一旦算错(重叠 / 漏格)这里就会炸 */
const covered = new Set();
FIXTURE.merges.forEach(mg => {
for (let r = mg[0] - 1; r <= mg[2] - 1; r++) {
for (let c = mg[1] - 1; c <= mg[3] - 1; c++) {
if (r === mg[0] - 1 && c === mg[1] - 1) continue;
covered.add(r + '|' + c);
}
}
});
const capacity = FIXTURE.rows.reduce((s, row) => s + row.length, 0);
const eaten = FIXTURE.rows.reduce((s, row, r) =>
s + row.filter((_, c) => covered.has(r + '|' + c)).length, 0);
chk('td 数守恒(' + capacity + ' − ' + eaten + ' = ' + (capacity - eaten) + ')',
count(html, '<td') === capacity - eaten, '实际 ' + count(html, '<td'));
/* 标记只落在 cell_styles 指定的格子上:合并首格带上 class 且 rowspan/colspan 完整,
同行未参与合并的格子不受牵连,越界键被忽略 */
const markHtml = sheetToHtml({
rows: [['a', 'b', 'c'], ['1', '2', '3']],
merges: [[1, 1, 2, 2]],
cell_styles: { '1,1': 'green', '99,99': 'red' },
header_rows: 1,
});
chk('合并首格带标记:class 正确且 rowspan/colspan 完整',
markHtml.indexOf('<td rowspan="2" colspan="2" class="pass">a</td>') >= 0, markHtml);
chk('同行未合并格不受相邻合并影响', markHtml.indexOf('<td>c</td>') >= 0);
chk('越界标记键被忽略', markHtml.indexOf('class="max"') < 0);
chk('打印 CSS 含块表样式(居中 / 未考灰 / 最高分红 / 合格绿)',
PRINT_CSS.indexOf('table.bt') >= 0 && PRINT_CSS.indexOf('td.absent') >= 0
&& PRINT_CSS.indexOf('td.max') >= 0 && PRINT_CSS.indexOf('td.pass') >= 0);
/* 绿底印不印得出来,取决于 print-color-adjust 有没有写上——浏览器默认关闭"背景图形" */
chk('打印 CSS 声明了 print-color-adjust:exact(否则底纹不会被打印)',
/print-color-adjust:\s*exact/.test(PRINT_CSS) && PRINT_CSS.indexOf('-webkit-print-color-adjust') >= 0);
chk('绿底单元格自己也声明了 print-color-adjust',
/td\.pass\{[^}]*print-color-adjust:\s*exact/.test(PRINT_CSS));
/* 缺考名单等附加工作表需要另起一页,否则会挤在成绩表底部 */
chk('打印 CSS 让缺考名单(h3.grp.page-break)另起一页',
/h3\.grp\.page-break\{[^}]*page-break-before:\s*always/.test(PRINT_CSS));
/* 表头渲染成 <td>,底色必须写 thead td,写成 th 是死规则、两行表头会变白 */
chk('表头底色命中实际渲染的 <td>(thead td,不是死规则 th)',
/table\.bt thead td\{background:/.test(PRINT_CSS));
chk('HTML 已转义',
sheetToHtml({ rows: [['<b>x</b>']], merges: [], header_rows: 1 }).indexOf('&lt;b&gt;') >= 0);
console.log('');
console.log('结果:' + pass + ' 通过,' + fail + ' 失败');
module.exports = { sheetToHtml: sheetToHtml, PRINT_CSS: PRINT_CSS };
process.exit(fail === 0 ? 0 : 1);
+2 -2
View File
@@ -7,8 +7,8 @@ const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const PORT = 18081;
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
// 1) 备份 / 准备文件存储模式
// 数据目录默认用项目下的 data/,但优先读 NY_DATA 环境变量(bootstrap.php 同样支持):
+296
View File
@@ -0,0 +1,296 @@
// e2e: 数据大屏两种形态 —— 首页班级入口 + 单班独立大屏(含后退锁定 / 顶栏控件收敛)
//
// 覆盖点:
// 1) 无 ?class= → dash-home:仍显示班级下拉,顶栏出现"选择考试 → 点卡片进入"文案
// 2) ?class=X → dash-class:顶栏隐藏班级选择 / ← 主系统,页面内不出现回首页的链接
// 3) 后退锁定:lockHistory() 只在 IS_CLASS 时挂,首页不受影响
// 4) 卡片渲染:直接把 dashboard.php 里的 clsCardHtml 抽出来跑,断言卡片结构与跳转参数
// 5) 公开访问:带 ?class= 的班级大屏免登录,且接口只回该班数据(其它班级不泄漏)
//
// 运行:node tools/test-dashboard-nav.js
const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const PORT = 18091;
const INI = process.env.PHP_INI || '';
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
const MOCK = { user: 'mock', pass: 'surveyking' };
// NY_DATA 指向临时目录:名单/配置/缓存一律写临时目录,绝不碰项目 data/
let p = process.env.NY_DATA || path.join(os.tmpdir(), 'ny-dash-nav-' + process.pid);
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2);
const DATA = path.resolve(p);
process.env.NY_DATA = DATA;
fs.mkdirSync(DATA, { recursive: true });
const cfgFile = path.join(DATA, 'config.json');
// 名单:5 人有答卷(与 mock 的 PARTNERS 90001-90005 对齐),再补 30 名无答卷者凑出 3 个班
const ROSTER = { students: [], updated_at: '2026-01-01 00:00:00' };
const back = [
['90001', '高一(1)班', 'A组'], ['90002', '高一(1)班', 'A组'], ['90003', '高一(1)班', 'B组'],
['90004', '高一(2)班', 'A组'], ['90005', '高一(2)班', 'B组'],
];
back.forEach(([sid, cls, grp]) => ROSTER.students.push({ sid, name: sid, cls, grp, login: '' }));
['高一(1)班', '高一(2)班', '高一(3)班'].forEach((cls, ci) => {
for (let i = 1; i <= 10; i++) {
const sid = String(9100 + ci * 100 + i);
ROSTER.students.push({ sid, name: sid, cls, grp: i % 2 ? 'A组' : 'B组', login: '' });
}
});
try { fs.unlinkSync(cfgFile); } catch (e) {}
// 起始配置:连的是本机 mock,账号留空(登录时再写入,与 test-login-loop 同口径)
fs.writeFileSync(cfgFile, JSON.stringify({
base_url: 'http://127.0.0.1:19911',
api_prefix: '/api',
username: '',
password: '',
verify_ssl: false,
timeout: 10,
refresh_interval: 15,
page_size: 50,
dedupe: true,
include_temp_save: false,
name_keywords: '姓名,名字',
full_score: 100,
layout: 'default',
}, null, 2));
fs.writeFileSync(path.join(DATA, 'roster.json'), JSON.stringify(ROSTER));
let cleanedUp = false, mock = null, srv = null;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
try { srv && srv.kill('SIGKILL'); } catch (e) {}
try { mock && mock.kill('SIGKILL'); } catch (e) {}
try { fs.rmSync(DATA, { recursive: true, force: true }); } catch (e) {}
}
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(1); });
process.on('uncaughtException', e => { console.error('FATAL', e); cleanup(); process.exit(2); });
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'ignore' });
srv = spawn(PHP, INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]), { cwd: ROOT, stdio: 'ignore' });
let pass = 0, fail = 0;
function chk(name, cond, extra) {
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
}
const cookieJar = [];
function collect(res) {
for (const sc of (res.headers['set-cookie'] || [])) {
const pair = sc.split(';')[0];
const name = pair.split('=')[0];
const i = cookieJar.findIndex(x => x.startsWith(name + '='));
if (i >= 0) cookieJar[i] = pair; else cookieJar.push(pair);
}
}
const jarHeader = () => (cookieJar.length ? cookieJar.map(c => c.replace(/;.*$/, '')).join('; ') : '');
// jar 省略 → 用当前登录会话;传 [] → 完全不带 Cookie 的匿名请求(验公开访问)
function get(p, jar) {
return new Promise((resolve, reject) => {
const h = { 'Accept': 'text/html,application/json' };
const ck = jar === undefined ? jarHeader()
: (jar || []).map(c => c.replace(/;.*$/, '')).join('; ');
if (ck) h['Cookie'] = ck;
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'GET', path: p, headers: h }, res => {
const bufs = [];
res.on('data', c => bufs.push(c));
res.on('end', () => {
// 匿名请求不回灌 Cookie:否则会用它自己的新会话覆盖掉登录会话
if (jar === undefined) collect(res);
resolve({ status: res.statusCode, raw: Buffer.concat(bufs).toString('utf8'), loc: res.headers.location });
});
});
r.on('error', reject);
r.end();
});
}
function post(p, fields) {
return new Promise((resolve, reject) => {
const data = new URLSearchParams(fields).toString();
const h = { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(data) };
if (jarHeader()) h['Cookie'] = jarHeader();
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path: p, headers: h }, res => {
const bufs = [];
res.on('data', c => bufs.push(c));
res.on('end', () => { collect(res); resolve({ status: res.statusCode, raw: Buffer.concat(bufs).toString('utf8') }); });
});
r.on('error', reject);
r.write(data); r.end();
});
}
const delay = ms => new Promise(r => setTimeout(r, ms));
const dashSrc = fs.readFileSync(path.join(ROOT, 'dashboard.php'), 'utf8');
const cssSrc = fs.readFileSync(path.join(ROOT, 'assets', 'style.css'), 'utf8');
(async () => {
await delay(2200);
console.log('\n--- 0) 登录 ---');
await post('/login.php', { username: MOCK.user, password: MOCK.pass });
const ac = await get('/api.php?action=auth_check');
chk('已登录', /"auth"\s*:\s*true/.test(ac.raw), ac.raw.slice(0, 120));
console.log('\n--- 1) 首页形态(无 ?class=)---');
const home = await get('/dashboard.php');
chk('首页返回 200', home.status === 200, 'status=' + home.status);
chk('body 带 dash-home、不带 dash-class',
/<body class="dash dash-home">/.test(home.raw) && !/dash dash-class/.test(home.raw),
(home.raw.match(/<body class="[^"]*"/) || [''])[0]);
chk('首页仍显示班级下拉(机构形态不变)', /id="classSelWrap"/.test(home.raw));
chk('首页保留「← 主系统 / 退出」入口(仅单班页隐藏)',
/id="linkIndex" href="index\.php"/.test(home.raw) && /id="linkLogout" href="logout\.php"/.test(home.raw));
chk('首页首屏提示为"先选考试再点班级"', /先选择一份问卷 \/ 考试,再点击班级卡片进入该班大屏/.test(home.raw));
chk('首页把 renderBoard 分流到 renderClassCards',
/if \(!IS_CLASS\) \{ renderClassCards\(\); return; \}/.test(dashSrc));
chk('首页自动刷新照旧可用(autoRefresh/intervalSel 都在)',
/id="autoRefresh"/.test(home.raw) && /id="intervalSel"/.test(home.raw) && /id="btnFullscreen"/.test(home.raw));
console.log('\n--- 2) 单班大屏形态(?pid=p1&class=高一(1)班)---');
const one = await get('/dashboard.php?pid=p1&class=' + encodeURIComponent('高一(1)班'));
chk('单班页返回 200', one.status === 200, 'status=' + one.status);
chk('body 带 dash-class、不带 dash-home',
/<body class="dash dash-class">/.test(one.raw) && !/dash dash-home/.test(one.raw),
(one.raw.match(/<body class="[^"]*"/) || [''])[0]);
chk('顶栏显示当前班级徽标', /id="heroClass"[^>]*>高一\(1\)班/.test(one.raw),
(one.raw.match(/id="heroClass"[^>]*/) || [''])[0]);
chk('页面内没有任何指向大屏首页的链接', !/href="dashboard\.php"/.test(one.raw));
chk('lockHistory() 在启动时被调起', /lockHistory\(\);/.test(dashSrc));
chk('后退锁定只在单班形态生效', /if \(!IS_CLASS \|\| historyLocked\) return;/.test(dashSrc));
chk('lockHistory 用 pushState 把本页重新推回栈顶',
/history\.pushState\(\{ nyLock: 1 \}, '', location\.href\)/.test(dashSrc) &&
/popstate/.test(dashSrc));
chk('首页形态不锁后退(IS_CLASS 为假时不 push)', !/if \(IS_CLASS(?![\s\S]{0,40}return;\})[\s\S]{0,40}pushState/.test(
dashSrc.slice(dashSrc.indexOf('function lockHistory'), dashSrc.indexOf('function lockHistory') + 400)));
console.log('\n--- 3) 顶栏控件收敛(CSS 隐藏规则)---');
chk('隐藏班级选择', /body\.dash-class #classSelWrap,\s*\n?\s*body\.dash-class #linkIndex \{ display: none !important; \}/.test(cssSrc));
chk('隐藏「← 主系统」', /body\.dash-class #linkIndex/.test(cssSrc));
chk('已不再靠 CSS 藏退出按钮(该形态下根本不渲染)', !/#linkLogout/.test(cssSrc));
chk('首页隐藏 5 张汇总卡', /body\.dash-home #boardSummary \{ display: none; \}/.test(cssSrc));
chk('单班页仍保留试卷/自动刷新/全屏三个控件',
/id="projectSel"/.test(one.raw) && /id="autoRefresh"/.test(one.raw) && /id="btnFullscreen"/.test(one.raw));
chk('单班页的「← 主系统」靠 CSS 隐藏',
/id="linkIndex"/.test(one.raw) && /display: none !important/.test(
cssSrc.slice(cssSrc.indexOf('body.dash-class #classSelWrap'), cssSrc.indexOf('body.dash-home #boardSummary'))));
chk('单班页(已登录)仍渲染退出按钮,由 CSS 隐藏', /id="linkLogout"/.test(one.raw));
console.log('\n--- 4) 班级卡片渲染(把 dashboard.php 里的 clsCardHtml 抽出来实跑)---');
const m = dashSrc.match(/function clsCardHtml\(c\)\{[\s\S]*?\n\}/);
chk('能抽出 clsCardHtml 函数体', !!m);
if (m) {
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const dv = (v, d) => (v === undefined || v === null || v === '' ? (d === undefined ? '-' : d) : v);
const pv = v => dv(v, '-') + (v === undefined || v === null || v === '' ? '' : '%');
const rateColor = r => (r >= 90 ? '#12a86a' : (r >= 60 ? '#e08c1a' : '#e0483d'));
const fn = new Function('esc', 'dv', 'pv', 'rateColor', m[0] + ' return clsCardHtml;')(esc, dv, pv, rateColor);
const html = fn({
class: '高一(1)班', total: 20, submitted: 3, absent: 17, rate: 15,
attempts: 5, avg: 78.5, pass_rate: 66.7,
});
chk('卡片带上班级名与进入入口', /data-cls="高一\(1\)班"/.test(html) && /cls-go/.test(html));
chk('卡片是按钮语义(role/tabindex)', /role="button"/.test(html) && /tabindex="0"/.test(html));
chk('5 项指标齐全', (html.match(/cls-kpi /g) || []).length === 5, '实际 ' + (html.match(/cls-kpi /g) || []).length + ' 项');
chk('提交率进度条按 rate 取值', /cls-bar"><i style="width:15%/.test(html), (html.match(/width:\d+%/) || [''])[0]);
chk('多次刷题徽标透传', /共 5 次刷题/.test(html));
chk('无成绩时的缺考数照常显示', /<span>缺考<\/span><b>17<\/b>/.test(html));
}
console.log('\n--- 5) 进入班级的跳转 ---');
const e = dashSrc.match(/function enterClass\(cls\)\{[\s\S]*?\n\}/);
chk('能抽出 enterClass 函数体', !!e);
if (e) {
chk('跳转时带上 pid + class + auto',
/searchParams\.set\('pid'/.test(e[0]) && /searchParams\.set\('class'/.test(e[0]) && /searchParams\.set\('auto'/.test(e[0]));
chk('是整页跳转(location.href,不是前端路由)', /location\.href = u\.pathname \+ u\.search;/.test(e[0]));
chk('卡片点击/回车都走事件委托(网格重绘不掉监听)',
/box\.addEventListener\('click'/.test(dashSrc) && /box\.addEventListener\('keydown'/.test(dashSrc) &&
/closest\('\.cls-card'\)/.test(dashSrc));
}
chk('首页卡片容器类名与样式一致(cls-grid)',
/<div class="cls-grid">/.test(dashSrc) && /\.cls-grid \{/.test(cssSrc));
console.log('\n--- 5.5) 成绩红绿口径(必须跟随后台配置的满分成数 / 合格率)---');
const sgSrc = dashSrc.match(/function scoreGrade\(sc\)\{[\s\S]*?\n\}/);
chk('能从 dashboard.php 抽出 scoreGrade', !!sgSrc);
if (sgSrc) {
// scoreGrade 里读的是脚本作用域的 DASH(let 声明,不在 window 上),
// 这里把 DASH 作为形参传进去,还原同一条作用域链
// eslint-disable-next-line no-new-func
const mk = new Function('DASH', sgSrc[0] + '; return scoreGrade;');
const g100 = mk({ full_score: 100, pass_rate: 60 });
const g150 = mk({ full_score: 150, pass_rate: 80 });
const gCur = mk({ full_score: 100, pass_rate: 95 });
chk('默认口径:95 绿 / 70 黄 / 50 红',
g100(95).cls === 'score-high' && g100(70).cls === 'score-mid' && g100(50).cls === 'score-low',
[95, 70, 50].map(v => v + '→' + g100(v).cls).join(' '));
// 后台把合格率改成 80、满分改成 150:100 分只占 66.7%,必须判红,不能再按 100 分制当成优秀
chk('后台改「满分 150 / 合格率 80%」后,100 分判红(不再按 100 分制当优秀)',
g150(100).cls === 'score-low', '100→' + g150(100).cls);
chk('同一配置下 130 分(86.7%)判黄、145 分(96.7%)判绿',
g150(130).cls === 'score-mid' && g150(145).cls === 'score-high',
[130, 145].map(v => v + '→' + g150(v).cls).join(' '));
// 合格率 85%:88 分合格但没到 90 分优秀线 → 黄
chk('合格率 85% 时 88 分判黄(合格但未达优秀线,绿线仍是 90)',
mk({ full_score: 100, pass_rate: 85 })(88).cls === 'score-mid',
'88→' + mk({ full_score: 100, pass_rate: 85 })(88).cls);
// 合格率 95%:合格线本身高过 90 分,绿线跟着抬到 95 —— 92/94 都不及格判红,96 判绿
chk('合格线高过 90% 时绿线跟着抬(合格率 95% → 92/94 判红、96 判绿)',
gCur(92).cls === 'score-low' && gCur(94).cls === 'score-low' && gCur(96).cls === 'score-high',
[92, 94, 96].map(v => v + '→' + gCur(v).cls).join(' '));
chk('缺成绩/越界分数不套样式',
g100(null).cls === '' && g100(undefined).cls === '' && g100('abc').cls === '');
chk('合格线按满分折算后回传(150×80% = 120)', g150(100).line === 120, 'line=' + g150(100).line);
chk('scoreGrade 不再通过 window.DASH 取值(let 声明取不到 window,只能读自由变量 DASH)',
!/window\.DASH/.test(sgSrc[0]));
chk('membersTable 也不再走 window.DASH',
!/window\.DASH/.test(dashSrc.slice(dashSrc.indexOf('function membersTable'), dashSrc.indexOf('function membersTable') + 1600)));
}
console.log('\n--- 6) 公开访问:班级大屏免登录 ---');
const CLS2 = encodeURIComponent('高一(2)班');
const anonPage = await get('/dashboard.php?pid=p1&class=' + CLS2, []);
chk('未登录访问班级大屏返回 200(不再被弹到登录页)',
anonPage.status === 200 && !anonPage.loc, 'status=' + anonPage.status + ' loc=' + (anonPage.loc || ''));
chk('匿名页同样是 dash-class 形态', /<body class="dash dash-class">/.test(anonPage.raw));
chk('公开页不再渲染退出按钮', !/id="linkLogout"/.test(anonPage.raw));
chk('公开页渲染「公开 · 无需登录」角标', /class="pub-tag"/.test(anonPage.raw));
chk('取数带 class(公开访问后端要按班级裁剪,两处调用都不能漏)',
(dashSrc.match(/api\('dashboard',\s*dashParams\(/g) || []).length === 2,
'实际 ' + (dashSrc.match(/api\('dashboard',\s*dashParams\(/g) || []).length + ' 处');
chk('dashParams 在单班形态下补充 class 参数',
/if \(IS_CLASS && CUR\.cls\) p\.class = CUR\.cls;/.test(dashSrc));
const anonApi = await get('/api.php?action=dashboard&projectId=p1&class=' + CLS2, []);
const aj = JSON.parse(anonApi.raw);
chk('未登录调用 dashboard 接口成功', aj.ok === true, anonApi.raw.slice(0, 120));
chk('classes 只回指定班级',
Array.isArray(aj.data.classes) && aj.data.classes.length === 1 && aj.data.classes[0].class === '高一(2)班',
'返回 ' + (aj.data.classes || []).map(x => x.class).join('/'));
chk('别班数据没有泄漏', !JSON.stringify(aj.data.classes).includes('高一(1)班'));
chk('overall 已换成该班汇总且键名兼容',
aj.data.overall && typeof aj.data.overall.attempts_total === 'number',
'attempts_total=' + (aj.data.overall || {}).attempts_total);
chk('overall 不含全卷名单总数', aj.data.overall && aj.data.overall.roster_total === aj.data.classes[0].total,
'roster_total=' + (aj.data.overall || {}).roster_total + ' class.total=' + (aj.data.classes[0] || {}).total);
chk('响应带 public 标记', aj.data.public === true);
const anonNoCls = await get('/api.php?action=dashboard&projectId=p1', []);
chk('未登录且不指定 class → 拒绝(不能拿全卷数据)',
JSON.parse(anonNoCls.raw).ok === false, anonNoCls.raw.slice(0, 120));
const homeAnon = await get('/dashboard.php', []);
chk('未登录访问大屏首页仍然跳转登录页',
/login\.php/.test(homeAnon.loc || ''), 'loc=' + (homeAnon.loc || ''));
console.log('\n通过 ' + pass + ' 项,失败 ' + fail + ' 项');
cleanup();
process.exit(fail ? 1 : 0);
})();
+2 -2
View File
@@ -17,8 +17,8 @@ const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const PORT = 18087;
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
const MOCK = { user: 'mock', pass: 'surveyking' };
// NY_DATA 指向临时目录:Git Bash 风格 /c/... 需还原成 C:\...,并写回 env 给 PHP 子进程。
+2 -2
View File
@@ -12,8 +12,8 @@ const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const PHP_PORT = 18081;
const MOCK_PORT = 19911;
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
const DEBUG = !!process.env.NY_DEBUG;
// 数据目录优先读 NY_DATA 环境变量(与 bootstrap.php 一致),让测试整体指向临时目录,
+2 -2
View File
@@ -17,8 +17,8 @@ const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const PHP_PORT = 18082;
const MOCK_PORT = 19911;
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
const DEBUG = !!process.env.NY_DEBUG;
function resolveDataDir() {
+162
View File
@@ -99,6 +99,168 @@ if ($parts !== []) {
@unlink($manualOut);
}
// 4) 合并单元格(「每次考试」导出用):写入 mergeCells,且被合并的非首格必须留空
$mergedRows = [
['学号', '姓名', '考试名称', '第几次', '成绩', '最高分'],
['2021001', '张三', '数学期中', '第1次', '90', '是'],
['', '', '', '第2次', '80', ''], // 学号/姓名/考试名称被合并占用
['', '', '英语期中', '第1次', '85', '是'],
];
$mOut = __DIR__ . '/_xlsxwriter_merge.xlsx';
$mBin = XlsxWriter::renderMulti([[
'name' => '每次考试',
'rows' => $mergedRows,
'widths' => [20, 14, 26, 10, 10, 8],
// A2:A3、B2:B3 纵向合并;C2:C2 为退化区间(应被丢弃);越界区间也应被丢弃
'merges' => [[2, 1, 3, 1], [2, 2, 3, 2], [2, 3, 2, 3], [2, 6, 99, 6]],
]]);
file_put_contents($mOut, $mBin);
chk('合并表 ZIP 魔数 PK', substr($mBin, 0, 2) === 'PK');
// 取 sheet XML:优先 ZipArchive;没有 zip 扩展时借 XlsxReader 的纯 PHP 解包(stored/deflate 都能读)
$mParts = [];
if (class_exists('ZipArchive')) {
$mz = new ZipArchive();
if ($mz->open($mOut) === true) {
for ($i = 0; $i < $mz->numFiles; $i++) {
$n = $mz->getNameIndex($i);
$mParts[$n] = $mz->getFromIndex($i);
}
$mz->close();
}
}
if ($mParts === []) {
try {
$rm = new ReflectionMethod('XlsxReader', 'readZip');
$rm->setAccessible(true);
$mParts = (array)$rm->invoke(null, $mOut);
} catch (Throwable $e) {
$mParts = [];
}
}
$sheetXml = (string)($mParts['xl/worksheets/sheet1.xml'] ?? '');
if ($sheetXml !== '') {
chk('写入 mergeCells 且计数为 2(退化/越界区间被丢弃)',
strpos($sheetXml, '<mergeCells count="2">') !== false, '未找到 mergeCells count=2');
chk('合并区间 A2:A3 / B2:B3 正确',
strpos($sheetXml, '<mergeCell ref="A2:A3"/>') !== false
&& strpos($sheetXml, '<mergeCell ref="B2:B3"/>') !== false);
chk('mergeCells 位于 sheetData 之后',
strpos($sheetXml, '</sheetData><mergeCells') !== false);
chk('被合并的非首格只带样式、不带值(A3)',
strpos($sheetXml, '<c r="A3" s="2"/>') !== false, '未找到 <c r="A3" s="2"/>');
chk('区块首格仍然保留(A2 / B2 在)',
strpos($sheetXml, 'r="A2"') !== false && strpos($sheetXml, 'r="B2"') !== false);
chk('英语块第 4 行考试名称未被吞(C4 在)', strpos($sheetXml, 'r="C4"') !== false);
chk('样式表已写入细边框', strpos((string)($mParts['xl/styles.xml'] ?? ''), '<left style="thin">') !== false);
} else {
chk('(跳过)无 ZipArchive,合并单元格 XML 未校验', true);
}
$mBack = XlsxReader::read($mOut);
chk('含合并表可被 XlsxReader 解析', count($mBack) === count($mergedRows), '行数=' . count($mBack));
chk('合并表首行内容正确', ($mBack[1][1] ?? '') === '张三' && ($mBack[3][2] ?? '') === '英语期中',
json_encode($mBack[1] ?? [], JSON_UNESCAPED_UNICODE));
@unlink($mOut);
/* 5) 两行表头 + 交叉合并(「多考试导出」块表用):header_rows=2 时两行都按表头样式输出,
并用 cell_styles 标记「最高分红字 / 合格次数绿底」 */
$blockRows = [
['学号', '姓名', '第几次', '数学', '', '英语', ''],
['', '', '', '成绩', '', '成绩', ''],
['356001', '张三', '1', '112', '112'],
['', '', '2', '100', '100'],
['', '', '3', '136', '133'],
['', '', '合格次数', '3', '', '2', ''],
['356002', '李四', '1', '未考', '', '130'],
];
$bOut = __DIR__ . '/_xlsxwriter_block.xlsx';
$bBin = XlsxWriter::renderMulti([[
'name' => '每次考试',
'rows' => $blockRows,
'widths' => [20, 14, 9, 10, 11, 10, 11],
'merges' => [
[1, 1, 2, 1], [1, 2, 2, 2], [1, 3, 2, 3], [1, 4, 1, 5], [1, 6, 1, 7], // 表头
[3, 1, 6, 1], [3, 2, 6, 2], [3, 3, 5, 3], // 张三 学号/姓名/第几次
// 「第几次」不并入合格次数行,
// 否则 C6 的「合格次数」标签会被吞掉
[7, 4, 7, 5], // 李四 数学「未考」跨 2 列
],
'header_rows' => 2,
'cell_styles' => [
'5,4' => 'red', // 张三数学最高分 136 → 红字
'6,4' => 'green', // 张三数学合格次数 → 绿底
'6,6' => 'green', // 张三英语合格次数 → 绿底
'8,2' => 'nope', // 未知标记应被静默忽略
],
]]);
file_put_contents($bOut, $bBin);
chk('两行表头表 ZIP 魔数 PK', substr($bBin, 0, 2) === 'PK');
$bParts = [];
if (class_exists('ZipArchive')) {
$bz = new ZipArchive();
if ($bz->open($bOut) === true) {
for ($i = 0; $i < $bz->numFiles; $i++) {
$bn = $bz->getNameIndex($i);
$bParts[$bn] = $bz->getFromIndex($i);
}
$bz->close();
}
}
$bXml = (string)($bParts['xl/worksheets/sheet1.xml'] ?? '');
$bStyles = (string)($bParts['xl/styles.xml'] ?? '');
if ($bXml !== '') {
chk('第 2 行表头也用表头样式(s="1")',
strpos($bXml, '<c r="A2" s="1"/>') !== false && strpos($bXml, 'r="D2" s="1"') !== false);
chk('数据行用文本样式(s="2")', strpos($bXml, 'r="A3" s="2"') !== false);
chk('表头合并区间齐备',
strpos($bXml, '<mergeCell ref="A1:A2"/>') !== false
&& strpos($bXml, '<mergeCell ref="D1:E1"/>') !== false
&& strpos($bXml, '<mergeCell ref="F1:G1"/>') !== false
&& strpos($bXml, '<mergeCell ref="D7:E7"/>') !== false);
chk('mergeCells 计数 = 9', strpos($bXml, '<mergeCells count="9">') !== false);
chk('「第几次」不并入合格次数行(C6 仍带值)',
strpos($bXml, '<c r="C6" s="2" t="s"><v>16</v></c>') !== false);
// ---- 单元格级样式:最高分红字(s="3")/ 合格次数绿底(s="4") ----
chk('最高分标红用红字样式(D5 s="3")', strpos($bXml, '<c r="D5" s="3"') !== false);
chk('合格次数达标用绿底样式(D6 s="4")', strpos($bXml, '<c r="D6" s="4"') !== false);
chk('合格次数达标用绿底样式(F6 s="4")', strpos($bXml, '<c r="F6" s="4"') !== false);
chk('未标记格仍用普通文本样式(D3 s="2")', strpos($bXml, '<c r="D3" s="2"') !== false);
// 样式表里必须真的存在红字字体与绿色填充,否则 Excel 打开是一片黑白
chk('styles 含红色字体(FFD0021B)', strpos($bStyles, 'FFD0021B') !== false);
chk('styles 含绿色填充(FF63C384)', strpos($bStyles, 'FF63C384') !== false);
chk('cellXfs 数量 = 6', strpos($bStyles, '<cellXfs count="6">') !== false);
// XML 必须合法:结构错一个字符 Excel 就会提示"修复",用 DOM 解析一遍最省事
if (class_exists('DOMDocument')) {
$okXml = true;
foreach (['sheet1.xml' => $bXml, 'styles.xml' => $bStyles] as $part => $xml) {
$doc = new DOMDocument();
if ($xml === '' || !@$doc->loadXML($xml)) {
$okXml = false;
chk('XML 可解析:' . $part, false, '解析失败');
}
}
if ($okXml) {
chk('XML 可解析(sheet / styles)', true);
}
}
} else {
chk('(跳过)无 ZipArchive,两行表头 XML 未校验', true);
}
$bBack = XlsxReader::read($bOut);
chk('两行表头表可被读回', count($bBack) === count($blockRows), '行数=' . count($bBack));
chk('两行表头表内容正确',
($bBack[2][0] ?? '') === '356001'
&& ($bBack[5][2] ?? '') === '合格次数' // 合格次数标签不被合并吞掉
&& ($bBack[6][3] ?? '') === '未考'
&& ($bBack[4][3] ?? '') === '136' && ($bBack[4][4] ?? '') === '133',
json_encode($bBack[6] ?? [], JSON_UNESCAPED_UNICODE));
@unlink($bOut);
@unlink($out);
echo PHP_EOL . "结果:$pass 通过,$fail 失败" . PHP_EOL;
exit($fail === 0 ? 0 : 1);