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