feat: 登录门认证 + 每套试卷独立满分等增强
- 登录门:进入页面先输入系统网址/API路由/账号/密码,测试连接通过后才显示登录按钮,登录后才进入主界面;密码每次需输入,防止依赖一次配置直接查看数据 - 每套试卷独立满分配置(设置中按当前试卷单独设置,合格率按 ≥60% 满分计算) - 班级联动统计与面板计数(成绩明细/缺考名单标题计数随班级过滤) - 移除最高/最低分获得者显示 - 平均用时不换行(避免“秒”字换行) - 卷王地址默认 https://exam.nxxy335.top,默认勾选校验 HTTPS 证书 - 统计卡片含合格率(≥60% 满分) - .gitignore 排除 data/(账号密码/令牌/学生名单,禁止入库)
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const engine = require('php-parser');
|
||||
|
||||
const parser = new engine({
|
||||
parser: { suppressErrors: true, version: 803 },
|
||||
ast: { withPositions: true },
|
||||
});
|
||||
|
||||
const files = process.argv.slice(2);
|
||||
let bad = 0;
|
||||
for (const f of files) {
|
||||
const code = fs.readFileSync(f, 'utf8');
|
||||
try {
|
||||
const ast = parser.parseCode(code, f);
|
||||
const errs = (ast.errors || []).filter(e => e && e.message);
|
||||
if (errs.length) {
|
||||
bad++;
|
||||
console.log('[SYNTAX] ' + f);
|
||||
errs.slice(0, 5).forEach(e => console.log(' line ' + (e.line || '?') + ': ' + e.message));
|
||||
} else {
|
||||
console.log('[OK] ' + path.basename(f));
|
||||
}
|
||||
} catch (e) {
|
||||
bad++;
|
||||
console.log('[FAIL] ' + f + ' :: ' + e.message);
|
||||
}
|
||||
}
|
||||
process.exit(bad ? 1 : 0);
|
||||
@@ -0,0 +1,138 @@
|
||||
// 模拟卷王(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 = '';
|
||||
|
||||
const roster = JSON.parse(fs.readFileSync(require('path').join(__dirname, '..', 'data', 'roster.json'), 'utf8')).students;
|
||||
|
||||
// 构造答卷:前 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',
|
||||
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 },
|
||||
];
|
||||
|
||||
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 !== 'admin' || 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 } });
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// 用 JS 复现 XlsxReader 的 ZIP/XML 解析逻辑,验证对真实 导入模板.xlsx 的处理
|
||||
const fs = require('fs');
|
||||
|
||||
function u16(d, o) { const s = d.subarray(o, o + 2); return s.length < 2 ? 0 : s.readUInt16LE(0); }
|
||||
function u32(d, o) { const s = d.subarray(o, o + 4); return s.length < 4 ? 0 : s.readUInt32LE(0); }
|
||||
|
||||
function parseZipRaw(data) {
|
||||
let eocd = -1;
|
||||
for (let i = data.length - 22; i >= 0; i--) {
|
||||
if (data[i] === 0x50 && data[i + 1] === 0x4b && data[i + 2] === 0x05 && data[i + 3] === 0x06) { eocd = i; break; }
|
||||
}
|
||||
if (eocd < 0) throw new Error('no EOCD');
|
||||
const count = u16(data, eocd + 10);
|
||||
const offset = u32(data, eocd + 16);
|
||||
const out = {};
|
||||
let p = offset;
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (!(data[p] === 0x50 && data[p + 1] === 0x4b && data[p + 2] === 0x01 && data[p + 3] === 0x02)) break;
|
||||
const method = u16(data, p + 10);
|
||||
const compSize = u32(data, p + 20);
|
||||
const nameLen = u16(data, p + 28);
|
||||
const extraLen = u16(data, p + 30);
|
||||
const commentLen = u16(data, p + 32);
|
||||
const localOff = u32(data, p + 42);
|
||||
const name = data.subarray(p + 46, p + 46 + nameLen).toString('binary');
|
||||
const lNameLen = u16(data, localOff + 26);
|
||||
const lExtraLen = u16(data, localOff + 28);
|
||||
const start = localOff + 30 + lNameLen + lExtraLen;
|
||||
const raw = data.subarray(start, start + compSize);
|
||||
let content;
|
||||
if (method === 0) content = raw;
|
||||
else content = require('zlib').inflateRawSync(raw);
|
||||
out[name.replace(/\\/g, '/')] = content.toString('utf8');
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function decodeEntities(s) {
|
||||
return s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
.replace(/'/g, "'").replace(/'/g, "'").replace(/&/g, '&');
|
||||
}
|
||||
|
||||
function parseSharedStrings(xml) {
|
||||
const out = [];
|
||||
const re = /<si>([\s\S]*?)<\/si>/g;
|
||||
let m;
|
||||
while ((m = re.exec(xml))) {
|
||||
const si = m[1];
|
||||
const tre = /<t[^>]*>([\s\S]*?)<\/t>/g;
|
||||
let t, parts = [];
|
||||
while ((t = tre.exec(si))) parts.push(decodeEntities(t[1]));
|
||||
out.push(parts.join(''));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function colToIndex(letters) {
|
||||
let n = 0;
|
||||
for (let i = 0; i < letters.length; i++) n = n * 26 + (letters.charCodeAt(i) - 64);
|
||||
return n - 1;
|
||||
}
|
||||
|
||||
function parseSheet(xml, shared) {
|
||||
const rows = [];
|
||||
const rowRe = /<row\b[^>]*>([\s\S]*?)<\/row>/g;
|
||||
let rm;
|
||||
while ((rm = rowRe.exec(xml))) {
|
||||
const rowXml = rm[1];
|
||||
const cells = {};
|
||||
const cRe = /<c(?:\s([^>]*?))?(?:\/>|>([\s\S]*?)<\/c>)/g;
|
||||
let cm, autoIdx = 0;
|
||||
while ((cm = cRe.exec(rowXml))) {
|
||||
const attrs = cm[1] || '';
|
||||
const inner = cm[2] || '';
|
||||
let idx = autoIdx;
|
||||
const r = /\br="([A-Z]+)\d*"/.exec(attrs);
|
||||
if (r) idx = colToIndex(r[1]);
|
||||
autoIdx = idx + 1;
|
||||
const tm = /\bt="([^"]+)"/.exec(attrs);
|
||||
const type = tm ? tm[1] : '';
|
||||
let val = '';
|
||||
if (type === 'inlineStr') {
|
||||
const tre = /<t[^>]*>([\s\S]*?)<\/t>/g;
|
||||
let t, parts = [];
|
||||
while ((t = tre.exec(inner))) parts.push(decodeEntities(t[1]));
|
||||
val = parts.join('');
|
||||
} else {
|
||||
const vm = /<v>([\s\S]*?)<\/v>/.exec(inner);
|
||||
val = vm ? decodeEntities(vm[1]) : '';
|
||||
if (type === 's') val = shared[parseInt(val, 10)] || '';
|
||||
else if (type === 'b') val = parseInt(val, 10) === 1 ? 'TRUE' : 'FALSE';
|
||||
}
|
||||
cells[idx] = val;
|
||||
}
|
||||
const keys = Object.keys(cells).map(Number);
|
||||
if (!keys.length) continue;
|
||||
const max = Math.max(...keys);
|
||||
const line = [];
|
||||
for (let i = 0; i <= max; i++) line.push(cells[i] || '');
|
||||
rows.push(line);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const file = process.argv[2];
|
||||
const entries = parseZipRaw(fs.readFileSync(file));
|
||||
console.log('entries:', Object.keys(entries).join(', '));
|
||||
const shared = entries['xl/sharedStrings.xml'] ? parseSharedStrings(entries['xl/sharedStrings.xml']) : [];
|
||||
const sheet = entries['xl/worksheets/sheet1.xml'] || entries[Object.keys(entries).find(k => /^xl\/worksheets\/sheet\d+\.xml$/.test(k))];
|
||||
const rows = parseSheet(sheet, shared);
|
||||
console.log('rows:', rows.length);
|
||||
console.log('first 3:', JSON.stringify(rows.slice(0, 3), null, 0));
|
||||
console.log('last 2:', JSON.stringify(rows.slice(-2), null, 0));
|
||||
Reference in New Issue
Block a user