导出-每次考试/多考试对比: - 合格次数阈值等标记项抽为常显区块,三类成绩表共用 - 附缺考名单扩展到多考试导出(取所选考试全都没交的人) - 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列 - 列宽按实际内容估算,合并区空位只给最小宽度 - 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底 - 打印标题改为「成绩明细」,缺考名单分页单独成页 - 打印样式加 print-color-adjust:exact,绿底/红字可正常印出 数据大屏: - ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传 - 单班大屏移除退出按钮,仅首页保留 - 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到) 改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿 测试/样例/文档同步更新,全量回归通过。
202 lines
10 KiB
JavaScript
202 lines
10 KiB
JavaScript
#!/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);
|
||
})();
|