feat(export/dashboard): 导出增强与大屏公开化
导出-每次考试/多考试对比: - 合格次数阈值等标记项抽为常显区块,三类成绩表共用 - 附缺考名单扩展到多考试导出(取所选考试全都没交的人) - 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列 - 列宽按实际内容估算,合并区空位只给最小宽度 - 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底 - 打印标题改为「成绩明细」,缺考名单分页单独成页 - 打印样式加 print-color-adjust:exact,绿底/红字可正常印出 数据大屏: - ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传 - 单班大屏移除退出按钮,仅首页保留 - 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到) 改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿 测试/样例/文档同步更新,全量回归通过。
This commit is contained in:
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
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=…&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);
|
||||
})();
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
// 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 (单场宽表)');
|
||||
@@ -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
@@ -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 + ' 人)');
|
||||
});
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
// 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('<b>') >= 0);
|
||||
|
||||
console.log('');
|
||||
console.log('结果:' + pass + ' 通过,' + fail + ' 失败');
|
||||
module.exports = { sheetToHtml: sheetToHtml, PRINT_CSS: PRINT_CSS };
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -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 同样支持):
|
||||
|
||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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);
|
||||
})();
|
||||
@@ -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 子进程。
|
||||
|
||||
@@ -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 一致),让测试整体指向临时目录,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user