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

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

测试/样例/文档同步更新,全量回归通过。
2026-09-27 22:04:17 +08:00

182 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 模拟卷王(SurveyKing)社区版 API,用于端到端测试
const http = require('http');
const crypto = require('crypto');
const fs = require('fs');
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 1024,
publicKeyEncoding: { type: 'spki', format: 'der' },
privateKeyEncoding: { type: 'pkcs8', format: 'der' },
});
const PUB_B64 = Buffer.from(publicKey).toString('base64');
const PASSWORD = 'surveyking';
let TOKEN = '';
/* 名单:优先用 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) => {
let name = s.name;
if (i === 3) name = ' ' + s.name + ' '; // 带空格
if (i === 7) name = s.name; // 正常
const start = Date.parse('2026-09-08T09:00:00+08:00') + i * 1000;
const end = start + (600 + i * 37) * 1000;
return {
id: 'a' + (i + 1),
projectId: 'p1',
answer: { q1: name, q2: '高一(1)班', q3: 'B' },
examScore: [100, 92, 88, 76, 61, 59, 95, 83, 72, 68][i % 10] + (i % 3),
tempSave: i === 5 ? 0 : 1,
metaInfo: { answerInfo: { startTime: start, endTime: end }, clientInfo: { browser: 'Chrome' } },
createAt: new Date(end).toISOString().replace('T', ' ').slice(0, 19),
createByName: '',
};
});
// 一个不在名单里的答卷
answers.push({
id: 'ax', projectId: 'p1', answer: { q1: '路过的同学', q2: '其他', q3: 'A' },
examScore: 55, tempSave: 1,
metaInfo: { answerInfo: { startTime: Date.parse('2026-09-08T09:30:00+08:00'), endTime: Date.parse('2026-09-08T09:41:20+08:00') } },
createAt: '2026-09-08 09:41:20', createByName: '',
});
const SURVEY = {
id: 'root', title: '高一数学月考', type: 'survey',
surveySetting: {},
children: [
{ id: 'pg1', type: 'pagination', title: '基本信息', children: [
{ id: 'q1', type: 'text', title: '<p>姓名</p>' },
{ id: 'q2', type: 'text', title: '班级' },
]},
{ id: 'pg2', type: 'pagination', title: '试题', children: [
{ id: 'q3', type: 'radio', title: '1+1=?', children: [{ id: 'o1', title: 'A' }, { id: 'o2', title: 'B' }] },
]},
],
};
const PROJECTS = [
{ id: 'p1', name: '高一数学月考', mode: 'exam', status: 1, total: answers.length },
{ id: 'p2', name: '课后满意度调查', mode: 'survey', status: 1, total: 5 },
];
// p1 的「受访者」参与者 = 应考名单(真实卷王模型:t_project_partner type=RESPONDENT_IMP_USER=3)
// status: 0未访问 / 1已访问未答 / 2已答题
const PARTNERS = {
p1: [
{ id: 'pp1', type: 3, userName: '90001', userId: '90001', status: 0 },
{ id: 'pp2', type: 3, userName: '90002', userId: '90002', status: 0 },
{ id: 'pp3', type: 3, userName: '90003', userId: '90003', status: 0 },
{ id: 'pp4', type: 3, userName: '90004', userId: '90004', status: 0 },
{ id: 'pp5', type: 3, userName: '90005', userId: '90005', status: 0 },
],
p2: [],
};
function send(res, code, obj, headers) {
res.writeHead(code, Object.assign({ 'Content-Type': 'application/json; charset=utf-8' }, headers || {}));
res.end(JSON.stringify(obj));
}
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
const p = u.pathname;
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
// 鉴权:模拟社区版 v1.13 —— 只读 sk-token Cookie,不读 Authorization 头
if (p.startsWith('/api/') && !p.startsWith('/api/public/') && p !== '/api/system') {
const jar = {};
(req.headers.cookie || '').split(';').forEach(kv => {
const i = kv.indexOf('=');
if (i > 0) jar[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
});
if (!jar['sk-token'] || jar['sk-token'] !== TOKEN) {
return send(res, 200, {
code: 401, data: null,
msg: 'Full authentication is required to access this resource'
});
}
}
if (p === '/api/system') {
return send(res, 200, { code: 0, data: { name: '卷王', version: '1.13.0', publicKey: PUB_B64 } });
}
if (p === '/api/public/login') {
let j = {};
try { j = JSON.parse(body); } catch (e) {}
let pwd = j.password;
let plain = pwd;
try {
plain = crypto.privateDecrypt(
{ key: privateKey, format: 'der', type: 'pkcs8', padding: crypto.constants.RSA_PKCS1_PADDING },
Buffer.from(pwd, 'base64')
).toString('utf8');
} catch (e) { plain = '<<解密失败>>'; }
if (j.username !== 'mock' || plain !== PASSWORD) {
return send(res, 200, { code: 500, data: null, msg: '账号或密码错误(收到明文=' + plain + ')' });
}
TOKEN = 'mock.jwt.token.' + Date.now();
// 真实 v1.13:Authorization 头和 Set-Cookie 都下发,但服务端鉴权只读 Cookie
return send(res, 200, { code: 0, data: { userId: '1' } }, {
'Set-Cookie': 'sk-token=' + TOKEN + '; Path=/; HttpOnly; SameSite=Lax',
Authorization: TOKEN,
});
}
if (p === '/api/project/list') {
return send(res, 200, { code: 0, data: { total: PROJECTS.length, list: PROJECTS } });
}
if (p === '/api/project') {
const id = u.searchParams.get('id');
const proj = PROJECTS.find(x => x.id === id);
if (!proj) return send(res, 200, { code: 404, data: null, msg: 'not found' });
return send(res, 200, { code: 0, data: Object.assign({}, proj, { survey: id === 'p1' ? SURVEY : null }) });
}
if (p === '/api/answer/list') {
const pid = u.searchParams.get('projectId');
const cur = parseInt(u.searchParams.get('current') || '1', 10);
const size = parseInt(u.searchParams.get('pageSize') || '200', 10);
const all = pid === 'p1' ? answers : [];
const slice = all.slice((cur - 1) * size, cur * size);
return send(res, 200, { code: 0, data: { total: all.length, list: slice } });
}
if (p === '/api/project/partner/list') {
const pid = u.searchParams.get('projectId');
const list = PARTNERS[pid] || [];
return send(res, 200, { code: 0, data: { total: list.length, list } });
}
return send(res, 200, { code: 404, data: null, msg: 'no route ' + p });
});
});
server.listen(19911, '127.0.0.1', () => {
console.log('mock 卷王已启动 http://127.0.0.1:19911 密码=' + PASSWORD
+ '\n名单来源:' + ROSTER.from + '(' + roster.length + ' 人)');
});