#!/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, '"'); 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 = [ '', '数据大屏首页(预览)', '', '', '', '', '
', '
', '

NY问卷考试系统数据平台

', '
', ' ', ' ', ' ', ' ', ' ', ' ', ' ← 主系统', ' 退出', '
', '
', '
', ' 共 ' + classes.length + ' 个班级 · 点击卡片进入该班大屏', ' ', ' 最后更新:' + esc(data.updated_at || '-') + '', '
', '
静态预览:这张页面由生产环境 dashboard.php 的 clsCardHtml() 渲染,', ' 数据取自 mock 卷王。真实站点上点卡片会整页跳转到 dashboard.php?pid=…&class=…,', ' 进入该班独立大屏(那里不再有班级选择、返回主系统与退出,浏览器后退键也被锁住)。
', '
', '
', '
总人数0
', '
已提交0
', '
缺考0
', '
平均分-
', '
合格率-
', '
', '
班级入口 点击卡片进入该班独立大屏
', '
' + cards + '
', '
', '
', '', '', ].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); })();