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);
})();