diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0c619fd
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+# 运行时数据:含账号密码、API 令牌、学生名单,禁止入库
+data/
+
+# 依赖与系统文件
+node_modules/
+vendor/
+*.log
+.DS_Store
+Thumbs.db
diff --git a/api.php b/api.php
new file mode 100644
index 0000000..43b900c
--- /dev/null
+++ b/api.php
@@ -0,0 +1,224 @@
+ true, 'config' => ny_load_config()]);
+
+ // ---------------- 连接测试 ----------------
+ case 'ping':
+ $client = new SurveyKingClient(cfg());
+ $res = $client->ping();
+ $res['session'] = $client->sessionInfo();
+ ny_ok($res);
+
+ case 'diag':
+ $client = new SurveyKingClient(cfg());
+ ny_ok($client->diagnose());
+
+ // ---------------- 问卷列表 ----------------
+ case 'projects':
+ $client = new SurveyKingClient(cfg());
+ $list = $client->listProjects();
+ $out = [];
+ foreach ($list as $p) {
+ if (!is_array($p)) {
+ continue;
+ }
+ $out[] = [
+ 'id' => ny_str($p['id'] ?? ''),
+ 'name' => ny_str($p['name'] ?? '') ?: '未命名',
+ 'mode' => ny_str($p['mode'] ?? ''),
+ 'total' => (int)($p['total'] ?? 0),
+ 'status'=> (int)($p['status'] ?? 0),
+ ];
+ }
+ ny_ok($out);
+
+ // ---------------- 数据 ----------------
+ case 'data':
+ $projectId = (string)($_GET['projectId'] ?? '');
+ if ($projectId === '') {
+ ny_fail('请先选择问卷/考试');
+ }
+ $nameQid = (string)($_GET['nameQid'] ?? '');
+ $c = cfg();
+ $result = DataService::build($c, $projectId, $nameQid);
+ $fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : [];
+ $fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100);
+ if ($fs < 1) {
+ $fs = 100;
+ }
+ $result['config'] = [
+ 'refresh_interval' => (int)$c['refresh_interval'],
+ 'base_url' => $c['base_url'],
+ 'full_score' => $fs,
+ ];
+ ny_ok($result);
+
+ // ---------------- 名单 ----------------
+ case 'roster':
+ ny_ok(['students' => Roster::all(), 'meta' => Roster::meta()]);
+
+ case 'roster_import':
+ if (empty($_FILES['file']) || !is_uploaded_file($_FILES['file']['tmp_name'])) {
+ ny_fail('请选择要导入的文件');
+ }
+ $f = $_FILES['file'];
+ $ext = strtolower(pathinfo((string)$f['name'], PATHINFO_EXTENSION));
+ $dest = NY_DATA . '/uploads/roster_' . date('Ymd_His') . '.' . ($ext !== '' ? $ext : 'dat');
+ if (!@move_uploaded_file($f['tmp_name'], $dest)) {
+ ny_fail('文件保存失败,请检查 data/uploads 目录权限');
+ }
+ try {
+ $res = Roster::importFile($dest, $ext);
+ } catch (Throwable $e) {
+ @unlink($dest);
+ ny_fail($e->getMessage());
+ }
+ $mode = (string)($_POST['mode'] ?? 'replace');
+ if ($mode === 'append') {
+ $merged = array_merge(Roster::all(), $res['students']);
+ Roster::save($merged);
+ } else {
+ Roster::save($res['students']);
+ }
+ ny_ok(['students' => Roster::all(), 'meta' => Roster::meta(), 'imported' => $res['count']]);
+
+ case 'roster_save':
+ $raw = (string)file_get_contents('php://input');
+ $in = json_decode($raw, true);
+ if (!is_array($in) || !isset($in['students']) || !is_array($in['students'])) {
+ ny_fail('参数错误');
+ }
+ $students = [];
+ foreach ($in['students'] as $s) {
+ if (!is_array($s)) {
+ continue;
+ }
+ $students[] = [
+ 'sid' => (string)($s['sid'] ?? ''),
+ 'name' => (string)($s['name'] ?? ''),
+ 'cls' => trim((string)($s['cls'] ?? '')),
+ ];
+ }
+ Roster::save($students);
+ ny_ok(['students' => Roster::all(), 'meta' => Roster::meta()]);
+
+ case 'roster_clear':
+ Roster::clear();
+ ny_ok(['students' => [], 'meta' => Roster::meta()]);
+
+ // ---------------- 导出 ----------------
+ case 'export':
+ case 'export_absent':
+ $projectId = (string)($_GET['projectId'] ?? '');
+ if ($projectId === '') {
+ ny_fail('请先选择问卷/考试');
+ }
+ $nameQid = (string)($_GET['nameQid'] ?? '');
+ $c = cfg();
+ $result = DataService::build($c, $projectId, $nameQid);
+ $filename = ($action === 'export_absent' ? '缺考名单' : '成绩明细')
+ . '_' . preg_replace('/[\\\\\/:*?"<>|]/u', '_', (string)$result['project']['name'])
+ . '_' . date('Ymd_His') . '.csv';
+
+ header('Content-Type: text/csv; charset=utf-8');
+ header('Content-Disposition: attachment; filename="' . $filename . '"');
+ header('Cache-Control: no-store');
+ echo "\xEF\xBB\xBF";
+ $out = fopen('php://output', 'w');
+
+ if ($action === 'export_absent') {
+ fputcsv($out, ['学号', '姓名', '班级']);
+ foreach ($result['absent'] as $r) {
+ fputcsv($out, [$r['sid'], $r['name'], $r['cls'] ?? '']);
+ }
+ } else {
+ fputcsv($out, ['学号', '姓名', '班级', '成绩', '提交时间', '考试时长', '考试时长(秒)']);
+ foreach ($result['rows'] as $r) {
+ fputcsv($out, [
+ $r['sid'],
+ $r['name'],
+ $r['cls'] ?? '',
+ $r['score'] === null ? '' : $r['score'],
+ $r['submit_at'],
+ $r['duration_txt'],
+ $r['duration'] === null ? '' : $r['duration'],
+ ]);
+ }
+ }
+ fclose($out);
+ exit;
+
+ default:
+ ny_fail('未知操作:' . $action);
+ }
+} catch (Throwable $e) {
+ ny_fail($e->getMessage(), [
+ 'trace' => defined('NY_DEBUG') && NY_DEBUG ? $e->getTraceAsString() : null,
+ ]);
+}
diff --git a/assets/app.js b/assets/app.js
new file mode 100644
index 0000000..6acb325
--- /dev/null
+++ b/assets/app.js
@@ -0,0 +1,692 @@
+/* NY 问卷考试系统数据平台 - 前端逻辑 */
+(function () {
+ 'use strict';
+
+ const $ = (s, r) => (r || document).querySelector(s);
+ const $$ = (s, r) => Array.prototype.slice.call((r || document).querySelectorAll(s));
+
+ const state = {
+ projects: [],
+ questions: [],
+ rows: [],
+ absent: [],
+ unmatched: [],
+ roster: [], // 完整名单(含班级),用于按班级重算统计
+ prevMap: {}, // sid -> 摘要,用于高亮变化
+ prevSids: new Set(), // 上一次刷新出现的 sid
+ newSids: new Set(), // 本次刷新新增的 sid(绿色底纹)
+ hasPrev: false,
+ fullScore: 100,
+ sortKey: 'submit_ts',
+ sortAsc: false,
+ timer: null,
+ loading: false,
+ lastSig: ''
+ };
+
+ /* ---------------- 通用 ---------------- */
+ async function api(action, opts) {
+ opts = opts || {};
+ const init = { method: opts.method || 'GET' };
+ if (opts.body) {
+ init.headers = { 'Content-Type': 'application/json' };
+ init.body = JSON.stringify(opts.body);
+ }
+ if (opts.form) {
+ init.method = 'POST';
+ init.body = opts.form;
+ }
+ let url = 'api.php?action=' + encodeURIComponent(action);
+ if (opts.params) {
+ for (const k in opts.params) {
+ if (opts.params[k] !== '' && opts.params[k] != null) {
+ url += '&' + k + '=' + encodeURIComponent(opts.params[k]);
+ }
+ }
+ }
+ const res = await fetch(url, init);
+ const text = await res.text();
+ let json;
+ try { json = JSON.parse(text); } catch (e) {
+ throw new Error('服务端返回异常(可能 PHP 报错):' + text.slice(0, 300));
+ }
+ if (!json.ok) throw new Error(json.msg || '请求失败');
+ return json.data;
+ }
+
+ function esc(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+ }
+
+ function setConn(cls, text) {
+ const el = $('#conn');
+ el.className = 'conn ' + cls;
+ el.innerHTML = '' + esc(text) + '';
+ }
+
+ function showAlert(msg, type) {
+ const el = $('#alert');
+ if (!msg) { el.classList.add('hidden'); return; }
+ el.textContent = msg;
+ el.className = 'alert' + (type === 'info' ? ' info' : '');
+ }
+
+ /* ---------------- 问卷列表 ---------------- */
+ async function loadProjects() {
+ setConn('conn-load', '连接中…');
+ try {
+ const list = await api('projects');
+ state.projects = list || [];
+ const sel = $('#projectSel');
+ const keep = sel.value;
+ sel.innerHTML = '';
+ if (!state.projects.length) {
+ sel.innerHTML = '';
+ } else {
+ const ph = document.createElement('option');
+ ph.value = ''; ph.textContent = '请选择问卷 / 考试';
+ sel.appendChild(ph);
+ state.projects.forEach(p => {
+ const o = document.createElement('option');
+ o.value = p.id;
+ o.textContent = (p.mode ? '[' + p.mode + '] ' : '') + p.name + '(' + (p.total || 0) + '份)';
+ sel.appendChild(o);
+ });
+ }
+ if (keep && state.projects.some(p => p.id === keep)) sel.value = keep;
+ else if (state.deeplinkProject && state.projects.some(p => p.id === state.deeplinkProject)) sel.value = state.deeplinkProject;
+ setConn('conn-on', '已连接 · ' + state.projects.length + ' 个问卷');
+ showAlert('');
+ if (sel.value) refresh();
+ } catch (e) {
+ setConn('conn-err', '连接失败');
+ showAlert('无法连接卷王系统:' + e.message + '\n请点击右上角【设置】检查系统地址、接口前缀与账号密码。');
+ }
+ }
+
+ /* ---------------- 数据刷新 ---------------- */
+ async function refresh(silent) {
+ const pid = $('#projectSel').value;
+ if (!pid) return;
+ if (state.loading) return;
+ state.loading = true;
+ if (!silent) setConn('conn-load', '拉取数据…');
+ try {
+ const d = await api('data', { params: { projectId: pid, nameQid: $('#nameFieldSel').value } });
+ state.questions = d.questions || [];
+ state.rows = d.rows || [];
+ state.absent = d.absent || [];
+ state.unmatched = d.unmatched || [];
+ state.roster = d.roster || [];
+ state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100;
+
+ // 计算本次刷新新增的 sid(用于绿色底纹);首次加载不标记
+ const cur = new Set(state.rows.map(r => r.sid).filter(Boolean));
+ if (state.hasPrev) state.newSids = new Set(Array.from(cur).filter(s => !state.prevSids.has(s)));
+ else state.newSids = new Set();
+ state.hasPrev = true;
+
+ renderNameFields(d);
+ renderStats();
+ renderTable();
+ state.prevSids = cur;
+ populateClasses();
+ renderAbsent();
+ renderUnmatched();
+ renderHints();
+
+ $('#lastUpdate').textContent = d.updated_at;
+ setConn('conn-on', '实时 · ' + d.project.name);
+ showAlert('');
+ } catch (e) {
+ if (!silent) setConn('conn-err', '数据获取失败');
+ showAlert(e.message);
+ } finally {
+ state.loading = false;
+ $('#liveDot').classList.add('on');
+ setTimeout(() => $('#liveDot').classList.remove('on'), 900);
+ }
+ }
+
+ function renderNameFields(d) {
+ const sel = $('#nameFieldSel');
+ const keep = sel.value;
+ sel.innerHTML = '';
+ const cands = d.name_candidates || [];
+ cands.forEach(q => {
+ const o = document.createElement('option');
+ o.value = q.id;
+ o.textContent = '姓名题:' + (q.title || q.id);
+ sel.appendChild(o);
+ });
+ (d.questions || []).forEach(q => {
+ if (cands.some(c => c.id === q.id)) return;
+ const o = document.createElement('option');
+ o.value = q.id;
+ o.textContent = (q.title || q.id) + '(' + (q.type || '') + ')';
+ sel.appendChild(o);
+ });
+ if (keep) sel.value = keep;
+ else if (state.deeplinkQid) sel.value = state.deeplinkQid;
+ else if (d.name_qid) sel.value = d.name_qid;
+ }
+
+ function populateClasses() {
+ const sel = $('#clsSel');
+ const cur = sel.value;
+ const set = new Set();
+ (state.roster || []).forEach(s => { if (s.cls) set.add(s.cls); });
+ const vals = Array.from(set).sort((a, b) => a.localeCompare(b, 'zh', { numeric: true }));
+ sel.innerHTML = '' +
+ vals.map(v => '').join('');
+ if (vals.indexOf(cur) >= 0) sel.value = cur;
+ else sel.value = '';
+ }
+
+ // 按当前所选班级(或无)客户端重算统计卡片
+ function computeDisplayStats(cls) {
+ const allStudents = state.roster || [];
+ const rows = cls ? state.rows.filter(r => (r.cls || '') === cls) : state.rows;
+ const students = cls ? allStudents.filter(s => (s.cls || '') === cls) : allStudents;
+ const total = students.length;
+ const submitted = rows.length;
+ const absent = Math.max(0, total - submitted);
+ const rate = total > 0 ? Math.round(submitted / total * 1000) / 10 : 0;
+
+ const scores = rows.map(r => r.score).filter(v => v !== null && v !== undefined);
+ const avg = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 100) / 100 : null;
+
+ const full = state.fullScore || 100;
+ const passCount = rows.filter(r => r.score !== null && r.score !== undefined && r.score >= 0.6 * full).length;
+ const passRate = submitted > 0 ? Math.round(passCount / submitted * 1000) / 10 : 0;
+
+ let maxScore = null, minScore = null;
+ const maxNames = [], minNames = [];
+ rows.forEach(r => {
+ if (r.score === null || r.score === undefined) return;
+ if (maxScore === null || r.score > maxScore) { maxScore = r.score; maxNames.length = 0; maxNames.push(r.name); }
+ else if (r.score === maxScore) maxNames.push(r.name);
+ if (minScore === null || r.score < minScore) { minScore = r.score; minNames.length = 0; minNames.push(r.name); }
+ else if (r.score === minScore) minNames.push(r.name);
+ });
+
+ const durs = rows.map(r => r.duration).filter(v => v !== null && v !== undefined);
+ const avgDur = durs.length ? Math.round(durs.reduce((a, b) => a + b, 0) / durs.length) : null;
+
+ return {
+ total, submitted, absent, rate, avg, passRate,
+ maxScore, minScore, maxNames, minNames, avgDur
+ };
+ }
+
+ function renderStats() {
+ const cls = $('#clsSel').value;
+ const st = computeDisplayStats(cls);
+ const set = (id, v) => { $(id).textContent = v; };
+ set('#sTotal', st.total);
+ set('#sSubmit', st.submitted);
+ set('#sAbsent', st.absent);
+ set('#sRate', st.rate + '%');
+ set('#sAvg', st.avg == null ? '-' : st.avg);
+ set('#sMax', st.maxScore == null ? '-' : st.maxScore);
+ set('#sMin', st.minScore == null ? '-' : st.minScore);
+ set('#sPass', st.passRate + '%');
+ set('#sDur', st.avgDur == null ? '-' : fmtDur(st.avgDur));
+ }
+
+ // 各面板计数文字按所选班级过滤(成绩明细 / 缺考名单)
+ function renderHints() {
+ const cls = $('#clsSel').value;
+ const rowsForCls = cls ? state.rows.filter(r => (r.cls || '') === cls) : state.rows;
+ const rosterForCls = cls ? (state.roster || []).filter(s => (s.cls || '') === cls) : (state.roster || []);
+ const absentForCls = cls ? state.absent.filter(a => (a.cls || '') === cls) : state.absent;
+ $('#tableHint').textContent = '共 ' + rowsForCls.length + ' 人已提交 · 名单 ' + rosterForCls.length + ' 人';
+ $('#absentHint').textContent = absentForCls.length ? '共 ' + absentForCls.length + ' 人' : '';
+ }
+
+ function fmtDur(sec) {
+ if (sec == null) return '-';
+ if (sec < 60) return sec + ' 秒';
+ const m = Math.floor(sec / 60), s = sec % 60;
+ if (m < 60) return m + ' 分 ' + String(s).padStart(2, '0') + ' 秒';
+ const h = Math.floor(m / 60);
+ return h + ' 小时 ' + String(m % 60).padStart(2, '0') + ' 分';
+ }
+
+ function scoreClass(v) {
+ if (v == null) return '';
+ if (v >= 90) return 'score-high';
+ if (v >= 60) return 'score-mid';
+ return 'score-low';
+ }
+
+ function renderTable() {
+ const kw = $('#kw').value.trim().toLowerCase();
+ const clsFilter = $('#clsSel').value;
+ let rows = state.rows.slice();
+
+ if (clsFilter) rows = rows.filter(r => (r.cls || '') === clsFilter);
+ if (kw) rows = rows.filter(r =>
+ String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw));
+
+ const k = state.sortKey, dir = state.sortAsc ? 1 : -1;
+ rows.sort((a, b) => {
+ let x = a[k], y = b[k];
+ if (k === 'sid' || k === 'cls') {
+ const na = parseFloat(x), nb = parseFloat(y);
+ if (!isNaN(na) && !isNaN(nb)) return (na - nb) * dir;
+ return String(x == null ? '' : x).localeCompare(String(y == null ? '' : y), 'zh', { numeric: true }) * dir;
+ }
+ if (x == null) x = -1;
+ if (y == null) y = -1;
+ if (typeof x === 'string') return x.localeCompare(String(y), 'zh', { numeric: true }) * dir;
+ return (x - y) * dir;
+ });
+
+ const tb = $('#tbody');
+ if (!rows.length) {
+ tb.innerHTML = '
| ' +
+ (state.rows.length ? '没有匹配的记录' : '暂无提交记录') + ' |
';
+ return;
+ }
+ tb.innerHTML = rows.map(r => {
+ const sig = r.sid + '|' + r.score + '|' + r.submit_at;
+ const changed = state.prevMap[r.sid] && state.prevMap[r.sid] !== sig;
+ const isNew = r.sid && state.newSids.has(r.sid);
+ const clsName = (changed ? 'diff ' : '') + (isNew ? 'new' : '');
+ return '' +
+ '| ' + esc(r.sid) + ' | ' +
+ '' + esc(r.name) + ' | ' +
+ '' + esc(r.cls || '') + ' | ' +
+ '' + (r.score == null ? '-' : r.score) + ' | ' +
+ '' + esc(r.submit_at) + ' | ' +
+ '' + esc(r.duration_txt) + ' | ' +
+ '
';
+ }).join('');
+
+ const map = {};
+ state.rows.forEach(r => { map[r.sid] = r.sid + '|' + r.score + '|' + r.submit_at; });
+ state.prevMap = map;
+
+ $$('#dataTable th.sortable').forEach(th => {
+ const isCur = th.dataset.k === state.sortKey;
+ th.innerHTML = th.textContent.replace(/[\s↑↓]+$/, '') + (isCur ? ' ' + (state.sortAsc ? '↑' : '↓') + '' : '');
+ });
+ }
+
+ function renderAbsent() {
+ const clsFilter = $('#clsSel').value;
+ const list = clsFilter ? state.absent.filter(a => (a.cls || '') === clsFilter) : state.absent;
+ const tb = $('#absentBody');
+ if (!list.length) {
+ tb.innerHTML = '| ' + (clsFilter ? '本班全部已提交 🎉' : '全部已提交 🎉') + ' |
';
+ return;
+ }
+ tb.innerHTML = list.map(a =>
+ '| ' + esc(a.sid) + ' | ' + esc(a.name) + ' | ' + esc(a.cls || '') + ' |
').join('');
+ }
+
+ function renderUnmatched() {
+ const tb = $('#unmatchedBody');
+ if (!state.unmatched.length) {
+ tb.innerHTML = '| 无 |
';
+ return;
+ }
+ tb.innerHTML = state.unmatched.map(u =>
+ '| ' + esc(u.name) + ' | ' + (u.score == null ? '-' : u.score) +
+ ' | ' + esc(u.submit_at) + ' |
').join('');
+ }
+
+ /* ---------------- 自动刷新 ---------------- */
+ function schedule() {
+ if (state.timer) clearInterval(state.timer);
+ if (!$('#autoRefresh').checked) return;
+ const sec = parseInt($('#intervalSel').value, 10) || 15;
+ state.timer = setInterval(() => refresh(true), sec * 1000);
+ }
+
+ /* ---------------- 全屏 ---------------- */
+ function toggleFullscreen() {
+ const el = document.documentElement;
+ if (!document.fullscreenElement && !document.webkitFullscreenElement) {
+ const req = el.requestFullscreen || el.webkitRequestFullscreen;
+ if (req) req.call(el);
+ } else {
+ const exit = document.exitFullscreen || document.webkitExitFullscreen;
+ if (exit) exit.call(document);
+ }
+ }
+ function onFsChange() {
+ const on = !!(document.fullscreenElement || document.webkitFullscreenElement);
+ document.body.classList.toggle('fs-on', on);
+ const btn = $('#btnFullscreen');
+ if (btn) btn.textContent = on ? '⛶ 退出全屏' : '⛶ 全屏';
+ }
+
+ /* ---------------- 设置 ---------------- */
+ async function openSettings() {
+ try {
+ const c = await api('config_get');
+ $('#cfgBase').value = c.base_url || '';
+ $('#cfgPrefix').value = c.api_prefix || '/api';
+ $('#cfgUser').value = c.username || '';
+ $('#cfgPwd').value = '';
+ $('#cfgPageSize').value = c.page_size || 200;
+ $('#cfgInterval').value = c.refresh_interval || 15;
+ $('#cfgTimeout').value = c.timeout || 20;
+ $('#cfgKeywords').value = c.name_keywords || '姓名';
+ $('#cfgDedupe').checked = !!c.dedupe;
+ $('#cfgTemp').checked = !!c.include_temp_save;
+ $('#cfgSsl').checked = !!c.verify_ssl;
+ const pid = $('#projectSel').value;
+ const scores = (c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {};
+ $('#cfgFull').value = (pid && scores[pid] != null) ? scores[pid] : (c.full_score || 100);
+ const pObj = state.projects.find(p => p.id === pid);
+ $('#cfgFullHint').textContent = pid
+ ? ('应用于当前试卷:' + (pObj ? pObj.name : pid))
+ : '未选择试卷时,作为所有试卷的默认值';
+ } catch (e) { showAlert(e.message); }
+ $('#pingResult').classList.add('hidden');
+ $('#settingsModal').classList.add('open');
+ }
+
+ /* ---------------- 名单 ---------------- */
+ let rosterDraft = [];
+
+ async function openRoster() {
+ try {
+ const d = await api('roster');
+ rosterDraft = d.students || [];
+ renderRoster();
+ $('#rosterMeta').textContent = '共 ' + rosterDraft.length + ' 人' +
+ (d.meta && d.meta.updated_at ? ' · 更新于 ' + d.meta.updated_at : '');
+ } catch (e) { showAlert(e.message); }
+ $('#rosterModal').classList.add('open');
+ }
+
+ function renderRoster() {
+ const kw = $('#rosterSearch').value.trim().toLowerCase();
+ const rows = rosterDraft.filter(r => !kw ||
+ String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw));
+ const tb = $('#rosterBody');
+ if (!rows.length) {
+ tb.innerHTML = '| 暂无名单,请上传文件 |
';
+ return;
+ }
+ tb.innerHTML = rows.map((r, i) => {
+ const gi = rosterDraft.indexOf(r);
+ return '' +
+ ' | ' +
+ ' | ' +
+ ' | ' +
+ '删除 |
';
+ }).join('');
+ }
+
+ /* ---------------- 登录门 ---------------- */
+ async function showLoginGate() {
+ const gate = $('#loginGate');
+ if (gate) gate.classList.remove('hidden');
+ try {
+ const c = await api('config_get');
+ $('#lgBase').value = c.base_url || '';
+ $('#lgPrefix').value = c.api_prefix || '/api';
+ $('#lgUser').value = c.username || '';
+ } catch (e) { /* 取不到配置也可继续,手动填写 */ }
+ $('#lgPwd').value = '';
+ $('#lgLogin').disabled = true;
+ const msg = $('#lgMsg');
+ msg.textContent = '';
+ msg.className = 'login-msg';
+ }
+
+ async function loginTest() {
+ const base = $('#lgBase').value.trim();
+ const prefix = $('#lgPrefix').value.trim();
+ const user = $('#lgUser').value.trim();
+ const pwd = $('#lgPwd').value;
+ const msg = $('#lgMsg');
+ if (!base || !prefix || !user || !pwd) {
+ msg.textContent = '请填写系统网址、API 路由、账号与密码';
+ msg.className = 'login-msg err';
+ return;
+ }
+ msg.textContent = '正在测试连接…';
+ msg.className = 'login-msg';
+ try {
+ await api('config_save', { method: 'POST', body: { base_url: base, api_prefix: prefix, username: user, password: pwd } });
+ const r = await api('ping');
+ msg.textContent = '连接成功!读取到 ' + r.projects + ' 个问卷/考试,可登录';
+ msg.className = 'login-msg ok';
+ $('#lgLogin').disabled = false;
+ } catch (e) {
+ msg.textContent = '连接失败:' + e.message;
+ msg.className = 'login-msg err';
+ $('#lgLogin').disabled = true;
+ }
+ }
+
+ function loginEnter() {
+ const gate = $('#loginGate');
+ if (gate) gate.classList.add('hidden');
+ schedule();
+ loadProjects();
+ }
+
+ /* ---------------- 事件绑定 ---------------- */
+ document.addEventListener('DOMContentLoaded', function () {
+
+ $('#projectSel').addEventListener('change', () => { state.prevMap = {}; refresh(); });
+ $('#nameFieldSel').addEventListener('change', () => { state.prevMap = {}; refresh(); });
+ $('#btnRefresh').addEventListener('click', () => refresh(false));
+ $('#btnSettings').addEventListener('click', openSettings);
+ $('#btnRoster').addEventListener('click', openRoster);
+ $('#btnFullscreen').addEventListener('click', toggleFullscreen);
+ document.addEventListener('fullscreenchange', onFsChange);
+ if (document.webkitFullscreenEnabled === undefined && !document.documentElement.requestFullscreen && !document.documentElement.webkitRequestFullscreen) {
+ $('#btnFullscreen').style.display = 'none';
+ }
+ $('#kw').addEventListener('input', renderTable);
+ $('#clsSel').addEventListener('change', () => {
+ renderStats();
+ renderTable();
+ renderAbsent();
+ renderHints();
+ });
+ $('#intervalSel').addEventListener('change', schedule);
+ $('#autoRefresh').addEventListener('change', e => {
+ if (e.target.checked) { schedule(); refresh(true); }
+ else if (state.timer) clearInterval(state.timer);
+ });
+
+ $$('#dataTable th.sortable').forEach(th => {
+ th.addEventListener('click', () => {
+ if (state.sortKey === th.dataset.k) state.sortAsc = !state.sortAsc;
+ else { state.sortKey = th.dataset.k; state.sortAsc = true; }
+ renderTable();
+ });
+ });
+
+ $('#btnExport').addEventListener('click', () => {
+ const pid = $('#projectSel').value;
+ if (!pid) return showAlert('请先选择问卷 / 考试');
+ location.href = 'api.php?action=export&projectId=' + encodeURIComponent(pid) +
+ '&nameQid=' + encodeURIComponent($('#nameFieldSel').value);
+ });
+ $('#btnExportAbsent').addEventListener('click', () => {
+ const pid = $('#projectSel').value;
+ if (!pid) return showAlert('请先选择问卷 / 考试');
+ location.href = 'api.php?action=export_absent&projectId=' + encodeURIComponent(pid) +
+ '&nameQid=' + encodeURIComponent($('#nameFieldSel').value);
+ });
+
+ /* 弹窗关闭 */
+ document.addEventListener('click', e => {
+ if (e.target.matches('[data-close]') || e.target.classList.contains('modal')) {
+ $$('.modal').forEach(m => m.classList.remove('open'));
+ }
+ });
+
+ /* 设置保存 */
+ $('#btnSaveCfg').addEventListener('click', async () => {
+ const payload = {
+ base_url: $('#cfgBase').value.trim(),
+ api_prefix: $('#cfgPrefix').value.trim(),
+ username: $('#cfgUser').value.trim(),
+ password: $('#cfgPwd').value,
+ page_size: parseInt($('#cfgPageSize').value, 10) || 200,
+ refresh_interval: parseInt($('#cfgInterval').value, 10) || 15,
+ timeout: parseInt($('#cfgTimeout').value, 10) || 20,
+ name_keywords: $('#cfgKeywords').value.trim(),
+ dedupe: $('#cfgDedupe').checked,
+ include_temp_save: $('#cfgTemp').checked,
+ verify_ssl: $('#cfgSsl').checked,
+ full_score: parseInt($('#cfgFull').value, 10) || 100,
+ project_id: $('#projectSel').value
+ };
+ try {
+ await api('config_save', { method: 'POST', body: payload });
+ $('#settingsModal').classList.remove('open');
+ const iv = payload.refresh_interval;
+ const opts = $$('#intervalSel option').map(o => parseInt(o.value, 10));
+ let best = opts[0];
+ opts.forEach(v => { if (Math.abs(v - iv) < Math.abs(best - iv)) best = v; });
+ $('#intervalSel').value = String(best);
+ schedule();
+ loadProjects();
+ } catch (e) { showAlert(e.message); }
+ });
+
+ $('#btnPing').addEventListener('click', async () => {
+ const el = $('#pingResult');
+ el.classList.remove('hidden', 'err');
+ el.textContent = '正在测试…';
+ try {
+ const payload = {
+ base_url: $('#cfgBase').value.trim(),
+ api_prefix: $('#cfgPrefix').value.trim(),
+ username: $('#cfgUser').value.trim(),
+ password: $('#cfgPwd').value,
+ timeout: parseInt($('#cfgTimeout').value, 10) || 20,
+ verify_ssl: $('#cfgSsl').checked
+ };
+ await api('config_save', { method: 'POST', body: payload });
+ const r = await api('ping');
+ el.textContent = '连接成功!读取到 ' + r.projects + ' 个问卷/考试,耗时 ' + r.elapsed + ' ms';
+ } catch (e) {
+ el.classList.add('err');
+ el.textContent = '连接失败:' + e.message;
+ }
+ });
+
+ $('#btnDiag').addEventListener('click', async () => {
+ const el = $('#pingResult');
+ el.classList.remove('hidden', 'err');
+ el.textContent = '诊断中…';
+ try {
+ const payload = {
+ base_url: $('#cfgBase').value.trim(),
+ api_prefix: $('#cfgPrefix').value.trim(),
+ username: $('#cfgUser').value.trim(),
+ password: $('#cfgPwd').value,
+ timeout: parseInt($('#cfgTimeout').value, 10) || 20,
+ verify_ssl: $('#cfgSsl').checked
+ };
+ await api('config_save', { method: 'POST', body: payload });
+ const d = await api('diag');
+ const lines = (d.steps || []).map(s => (s.ok ? '✅ ' : '❌ ') + s.step + '\n ' + s.detail);
+ const failed = (d.steps || []).some(s => !s.ok);
+ el.classList.toggle('err', failed);
+ el.textContent = lines.join('\n');
+ } catch (e) {
+ el.classList.add('err');
+ el.textContent = '诊断失败:' + e.message;
+ }
+ });
+
+ /* 名单上传 */
+ const zone = $('#dropZone'), fileInput = $('#rosterFile');
+ zone.addEventListener('click', () => fileInput.click());
+ zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('over'); });
+ zone.addEventListener('dragleave', () => zone.classList.remove('over'));
+ zone.addEventListener('drop', e => {
+ e.preventDefault(); zone.classList.remove('over');
+ if (e.dataTransfer.files.length) uploadRoster(e.dataTransfer.files[0]);
+ });
+ fileInput.addEventListener('change', () => {
+ if (fileInput.files.length) uploadRoster(fileInput.files[0]);
+ fileInput.value = '';
+ });
+
+ async function uploadRoster(file) {
+ const fd = new FormData();
+ fd.append('file', file);
+ const mode = (document.querySelector('input[name=impMode]:checked') || {}).value || 'replace';
+ fd.append('mode', mode);
+ try {
+ const d = await api('roster_import', { form: fd });
+ rosterDraft = d.students || [];
+ renderRoster();
+ $('#rosterMeta').textContent = '共 ' + rosterDraft.length + ' 人 · 导入完成';
+ $('#rosterBadge').textContent = rosterDraft.length;
+ } catch (e) { showAlert(e.message); }
+ }
+
+ $('#rosterSearch').addEventListener('input', renderRoster);
+ $('#btnAddRow').addEventListener('click', () => {
+ rosterDraft.push({ sid: '', name: '', cls: '' });
+ renderRoster();
+ });
+ $('#btnClearRoster').addEventListener('click', async () => {
+ if (!confirm('确定清空全部名单?')) return;
+ try {
+ const d = await api('roster_clear', { method: 'POST' });
+ rosterDraft = [];
+ renderRoster();
+ $('#rosterBadge').textContent = '0';
+ $('#rosterMeta').textContent = '共 0 人';
+ } catch (e) { showAlert(e.message); }
+ });
+
+ $('#rosterBody').addEventListener('input', e => {
+ const tr = e.target.closest('tr');
+ if (!tr) return;
+ const i = parseInt(tr.dataset.i, 10);
+ if (isNaN(i)) return;
+ rosterDraft[i][e.target.dataset.f] = e.target.value;
+ });
+ $('#rosterBody').addEventListener('click', e => {
+ const id = e.target.getAttribute && e.target.getAttribute('data-del');
+ if (id == null) return;
+ rosterDraft.splice(parseInt(id, 10), 1);
+ renderRoster();
+ });
+
+ $('#btnSaveRoster').addEventListener('click', async () => {
+ try {
+ const d = await api('roster_save', { method: 'POST', body: { students: rosterDraft } });
+ rosterDraft = d.students || [];
+ renderRoster();
+ $('#rosterBadge').textContent = rosterDraft.length;
+ $('#rosterMeta').textContent = '共 ' + rosterDraft.length + ' 人 · 已保存';
+ if ($('#projectSel').value) refresh(true);
+ } catch (e) { showAlert(e.message); }
+ });
+
+ /* 启动 */
+ // 支持 ?projectId=xxx&nameQid=yyy 直接打开某份问卷
+ const qs = new URLSearchParams(location.search);
+ if (qs.get('interval')) $('#intervalSel').value = qs.get('interval');
+ state.deeplinkProject = qs.get('projectId') || '';
+ state.deeplinkQid = qs.get('nameQid') || '';
+
+ // 登录门
+ $('#lgTest').addEventListener('click', loginTest);
+ $('#lgLogin').addEventListener('click', loginEnter);
+ $('#lgPwd').addEventListener('keydown', e => { if (e.key === 'Enter') loginTest(); });
+
+ // 启动:先显示登录门,不直接拉取数据(防止依赖一次配置直接查看数据)
+ showLoginGate();
+ });
+})();
diff --git a/assets/style.css b/assets/style.css
new file mode 100644
index 0000000..1364cf3
--- /dev/null
+++ b/assets/style.css
@@ -0,0 +1,364 @@
+:root {
+ --bg: #f4f6fa;
+ --panel: #ffffff;
+ --border: #e4e8f0;
+ --border-2: #eef1f6;
+ --text: #1d2433;
+ --text-2: #5b6577;
+ --text-3: #8b95a7;
+ --primary: #2f6bff;
+ --primary-weak: #eaf0ff;
+ --ok: #12a86a;
+ --ok-weak: #e7f7ef;
+ --warn: #e08c1a;
+ --warn-weak: #fdf3e2;
+ --danger: #e0483d;
+ --danger-weak: #fdecea;
+ --shadow: 0 1px 2px rgba(20, 30, 60, .05), 0 8px 24px rgba(20, 30, 60, .06);
+ --radius: 12px;
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0;
+ padding: 0;
+ background: var(--bg);
+ color: var(--text);
+ font: 14px/1.55 "PingFang SC", "Microsoft YaHei", "Segoe UI", system-ui, -apple-system, sans-serif;
+ -webkit-font-smoothing: antialiased;
+}
+
+/* ---------- 顶栏 ---------- */
+.topbar {
+ position: sticky;
+ top: 0;
+ z-index: 30;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 12px 24px;
+ background: #fff;
+ border-bottom: 1px solid var(--border);
+}
+.brand { display: flex; align-items: center; gap: 12px; }
+.logo {
+ width: 38px; height: 38px;
+ border-radius: 10px;
+ background: linear-gradient(135deg, #2f6bff, #6a4bff);
+ color: #fff; font-weight: 800; font-size: 15px;
+ display: grid; place-items: center;
+ letter-spacing: .5px;
+}
+.brand-text { display: flex; flex-direction: column; }
+.brand-text strong { font-size: 16px; letter-spacing: .3px; }
+.brand-text small { color: var(--text-3); font-size: 12px; }
+.top-right { display: flex; align-items: center; gap: 10px; }
+
+.conn {
+ display: inline-flex; align-items: center; gap: 7px;
+ padding: 5px 12px; border-radius: 999px;
+ font-size: 12px; font-weight: 600;
+ background: var(--border-2); color: var(--text-2);
+}
+.conn i { width: 8px; height: 8px; border-radius: 50%; background: var(--text-3); }
+.conn-on { background: var(--ok-weak); color: var(--ok); }
+.conn-on i { background: var(--ok); box-shadow: 0 0 0 3px rgba(18,168,106,.15); }
+.conn-off { background: var(--border-2); color: var(--text-2); }
+.conn-err { background: var(--danger-weak); color: var(--danger); }
+.conn-err i { background: var(--danger); }
+.conn-load i { background: var(--warn); animation: blink 1s infinite; }
+@keyframes blink { 50% { opacity: .25; } }
+
+/* ---------- 按钮 ---------- */
+.btn {
+ border: 1px solid var(--border);
+ background: #fff;
+ color: var(--text);
+ padding: 7px 14px;
+ border-radius: 8px;
+ font-size: 13px;
+ cursor: pointer;
+ transition: .15s;
+ font-family: inherit;
+}
+.btn:hover { border-color: #c9d2e3; background: #fafbfe; }
+.btn:active { transform: translateY(1px); }
+.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
+.btn-primary:hover { background: #2559db; border-color: #2559db; }
+.btn.sm { padding: 5px 10px; font-size: 12px; }
+.btn.danger { color: var(--danger); border-color: #f3cfcb; }
+.btn.danger:hover { background: var(--danger-weak); }
+.btn b {
+ display: inline-block; margin-left: 4px; padding: 0 6px;
+ background: var(--primary-weak); color: var(--primary);
+ border-radius: 999px; font-size: 11px;
+}
+
+/* ---------- 布局 ---------- */
+.wrap { max-width: 1440px; margin: 0 auto; padding: 20px 24px 48px; }
+
+.toolbar {
+ display: flex; flex-wrap: wrap; align-items: flex-end; gap: 14px;
+ background: var(--panel); border: 1px solid var(--border);
+ border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow);
+}
+.field { display: flex; flex-direction: column; gap: 6px; }
+.field.grow { flex: 1 1 260px; min-width: 220px; }
+.field.grow-2 { flex: 1 1 200px; min-width: 180px; }
+.field.btns { flex-direction: row; gap: 8px; align-items: center; }
+.field label { font-size: 12px; color: var(--text-2); font-weight: 600; }
+.inline { display: flex; align-items: center; gap: 10px; }
+
+select, input[type=text], input[type=number], input[type=password] {
+ height: 36px; padding: 0 10px;
+ border: 1px solid var(--border); border-radius: 8px;
+ background: #fff; color: var(--text); font-size: 13px;
+ font-family: inherit; outline: none; width: 100%;
+}
+select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47,107,255,.12); }
+.field.btns select { width: 100px; }
+
+.switch { position: relative; display: inline-block; width: 40px; height: 22px; flex: none; }
+.switch input { opacity: 0; width: 0; height: 0; }
+.switch span {
+ position: absolute; inset: 0; cursor: pointer;
+ background: #d3d9e6; border-radius: 999px; transition: .2s;
+}
+.switch span::before {
+ content: ''; position: absolute; width: 18px; height: 18px;
+ left: 2px; top: 2px; background: #fff; border-radius: 50%; transition: .2s;
+}
+.switch input:checked + span { background: var(--primary); }
+.switch input:checked + span::before { transform: translateX(18px); }
+
+/* ---------- 提示 ---------- */
+.alert {
+ margin-top: 14px; padding: 12px 16px; border-radius: 10px;
+ background: var(--danger-weak); color: #a3342c; border: 1px solid #f5d3cf;
+ font-size: 13px; white-space: pre-wrap;
+}
+.alert.info { background: var(--primary-weak); color: #1a45ad; border-color: #ccdaff; }
+.hidden { display: none !important; }
+
+/* ---------- 卡片 ---------- */
+.cards {
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
+ gap: 12px; margin-top: 16px;
+}
+.card {
+ background: var(--panel); border: 1px solid var(--border);
+ border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow);
+ display: flex; flex-direction: column; gap: 4px;
+}
+.card span { font-size: 12px; color: var(--text-3); }
+.card b { font-size: 24px; font-weight: 700; letter-spacing: .5px; white-space: nowrap; }
+.card.ok b { color: var(--ok); }
+.card.warn b { color: var(--warn); }
+
+/* ---------- 面板 ---------- */
+.panel {
+ margin-top: 16px; background: var(--panel);
+ border: 1px solid var(--border); border-radius: var(--radius);
+ box-shadow: var(--shadow); overflow: hidden;
+}
+.panel-head {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 12px 16px; border-bottom: 1px solid var(--border-2);
+}
+.panel-head h2 { margin: 0; font-size: 15px; font-weight: 700; }
+.panel-head h2 small { font-weight: 500; color: var(--text-3); font-size: 12px; margin-left: 8px; }
+.updated { font-size: 12px; color: var(--text-3); display: flex; align-items: center; gap: 6px; }
+.dot { width: 7px; height: 7px; border-radius: 50%; background: #cbd3e2; display: inline-block; }
+.dot.on { background: var(--ok); animation: pulse 1.6s infinite; }
+@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
+
+.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
+@media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } }
+
+/* ---------- 表格 ---------- */
+.table-scroll { overflow: auto; max-height: 62vh; }
+.table-scroll.short { max-height: 320px; }
+.table-scroll.mid { max-height: 380px; }
+
+table.grid { width: 100%; border-collapse: collapse; font-size: 13px; }
+table.grid th {
+ position: sticky; top: 0; z-index: 2;
+ background: #fafbfe; color: var(--text-2);
+ text-align: left; font-weight: 600; font-size: 12px;
+ padding: 10px 14px; border-bottom: 1px solid var(--border);
+ white-space: nowrap;
+}
+table.grid th.sortable { cursor: pointer; user-select: none; }
+table.grid th.sortable:hover { color: var(--primary); }
+table.grid th .arrow { color: var(--primary); margin-left: 3px; }
+table.grid td { padding: 9px 14px; border-bottom: 1px solid var(--border-2); }
+table.grid tbody tr:hover td { background: #fafbfe; }
+table.grid.compact td, table.grid.compact th { padding: 7px 12px; }
+td.empty { text-align: center; color: var(--text-3); padding: 32px 0; }
+td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
+td.sid { font-weight: 600; color: var(--text-2); font-variant-numeric: tabular-nums; }
+td.name { font-weight: 600; }
+td.score { font-weight: 700; font-variant-numeric: tabular-nums; }
+.score-high { color: var(--ok); }
+.score-mid { color: var(--warn); }
+.score-low { color: var(--danger); }
+.diff { animation: flash 1.6s ease-out; }
+@keyframes flash { from { background: #fff6d9; } to { background: transparent; } }
+
+/* 新增数据绿色底纹(每次刷新后标记,下一轮刷新前持续显示) */
+table.grid tbody tr.new td { background: #e7f7ee; }
+table.grid tbody tr.new:hover td { background: #d6f2e2; }
+
+.tag {
+ display: inline-block; padding: 1px 8px; border-radius: 999px;
+ font-size: 11px; font-weight: 600;
+}
+.tag-ok { background: var(--ok-weak); color: var(--ok); }
+.tag-warn { background: var(--warn-weak); color: var(--warn); }
+.tag-gray { background: var(--border-2); color: var(--text-3); }
+
+/* ---------- 弹窗 ---------- */
+.modal {
+ position: fixed; inset: 0; z-index: 100;
+ background: rgba(20, 28, 48, .45);
+ display: none; align-items: center; justify-content: center;
+ padding: 24px; backdrop-filter: blur(2px);
+}
+.modal.open { display: flex; }
+.modal-box {
+ background: #fff; border-radius: 14px; width: 640px; max-width: 100%;
+ max-height: 90vh; display: flex; flex-direction: column;
+ box-shadow: 0 24px 64px rgba(15, 23, 42, .28);
+}
+.modal-box.wide { width: 820px; }
+.modal-head {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 14px 18px; border-bottom: 1px solid var(--border);
+}
+.modal-head h3 { margin: 0; font-size: 15px; }
+.x { border: 0; background: none; font-size: 22px; line-height: 1; cursor: pointer; color: var(--text-3); }
+.x:hover { color: var(--text); }
+.modal-body { padding: 18px; overflow: auto; }
+.modal-foot {
+ display: flex; align-items: center; gap: 8px;
+ padding: 12px 18px; border-top: 1px solid var(--border); background: #fbfcfe;
+ border-radius: 0 0 14px 14px;
+}
+.spacer { flex: 1; }
+
+.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
+.fi { display: flex; flex-direction: column; gap: 6px; }
+.fi.full { grid-column: 1 / -1; }
+.fi label { font-size: 12px; font-weight: 600; color: var(--text-2); }
+.hint { margin: 0; font-size: 11px; color: var(--text-3); }
+.fi.checks { flex-direction: row; flex-wrap: wrap; gap: 18px; }
+.fi.checks label { display: flex; align-items: center; gap: 6px; font-weight: 500; font-size: 13px; color: var(--text); }
+.ping-result {
+ margin-top: 14px; padding: 10px 12px; border-radius: 8px;
+ font-size: 12px; white-space: pre-wrap;
+ background: var(--ok-weak); color: #0d7a4d; border: 1px solid #c8ecda;
+}
+.ping-result.err { background: var(--danger-weak); color: #a3342c; border-color: #f5d3cf; }
+
+/* ---------- 名单 ---------- */
+.upload-zone {
+ border: 2px dashed #cfd8ea; border-radius: 10px; padding: 22px;
+ text-align: center; cursor: pointer; transition: .15s; background: #fbfcfe;
+}
+.upload-zone:hover, .upload-zone.over { border-color: var(--primary); background: var(--primary-weak); }
+.uz-inner { display: flex; flex-direction: column; gap: 4px; }
+.uz-inner strong { font-size: 13px; }
+.uz-inner span { font-size: 12px; color: var(--text-3); }
+.roster-bar {
+ display: flex; align-items: center; gap: 10px; margin: 14px 0 10px; flex-wrap: wrap;
+}
+.roster-bar input[type=text] { width: 180px; }
+.chk { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-2); }
+#rosterBody input {
+ width: 100%; border: 1px solid transparent; background: transparent;
+ padding: 4px 6px; border-radius: 6px; font-size: 13px; font-family: inherit; color: var(--text);
+}
+#rosterBody input:focus { border-color: var(--primary); background: #fff; outline: none; }
+.link-del { color: var(--danger); cursor: pointer; font-size: 12px; }
+
+/* ---------- 登录门 ---------- */
+.login-gate {
+ position: fixed; inset: 0; z-index: 200;
+ display: flex; align-items: center; justify-content: center;
+ padding: 24px; background: linear-gradient(135deg, #1b2440, #2f6bff);
+}
+.login-gate.hidden { display: none; }
+.login-box {
+ width: 420px; max-width: 100%;
+ background: #fff; border-radius: 16px; padding: 28px 26px;
+ box-shadow: 0 24px 64px rgba(15, 23, 42, .35);
+}
+.login-brand { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
+.login-tip { margin: 0 0 18px; font-size: 12px; color: var(--text-3); line-height: 1.5; }
+.login-box .fi { margin-bottom: 14px; }
+.login-actions { display: flex; gap: 10px; margin-top: 4px; }
+.login-actions .btn { flex: 1; }
+.login-msg { margin-top: 12px; font-size: 12px; min-height: 18px; white-space: pre-wrap; }
+.login-msg.ok { color: var(--ok); }
+.login-msg.err { color: var(--danger); }
+
+/* ---------- 全屏适配 ---------- */
+body.fs-on .wrap { max-width: none; padding-top: 8px; }
+body.fs-on .table-scroll { max-height: calc(100vh - 230px); }
+body.fs-on .table-scroll.short { max-height: 40vh; }
+body.fs-on .cards { gap: 14px; }
+body.fs-on .card b { font-size: 28px; }
+body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
+
+/* ---------- 三端自适应(桌面 / 平板 / 手机) ---------- */
+@media (max-width: 1180px) {
+ .wrap { padding: 16px 16px 40px; }
+ .topbar { padding: 10px 16px; }
+}
+
+@media (max-width: 1024px) {
+ .grid-2 { grid-template-columns: 1fr; }
+ .field.grow, .field.grow-2 { flex: 1 1 100%; min-width: 0; }
+}
+
+@media (max-width: 768px) {
+ body { font-size: 13px; }
+ .topbar { flex-wrap: wrap; gap: 10px; }
+ .brand-text small { display: none; }
+ .top-right { flex-wrap: wrap; gap: 8px; }
+ .top-right .btn { padding: 6px 10px; font-size: 12px; }
+ .btn b { font-size: 10px; }
+ .wrap { padding: 12px 12px 36px; }
+ .toolbar { padding: 12px; gap: 10px; }
+ .field { flex: 1 1 100%; min-width: 0; }
+ .field.btns { flex: 1 1 100%; }
+ .field.btns select { width: 100%; }
+ .cards { grid-template-columns: repeat(3, 1fr); gap: 8px; }
+ .card { padding: 10px 12px; }
+ .card b { font-size: 20px; }
+ .panel-head { flex-direction: column; align-items: flex-start; gap: 6px; }
+ .table-scroll { max-height: 56vh; }
+ table.grid { font-size: 12px; }
+ table.grid th { padding: 8px 10px; white-space: nowrap; }
+ table.grid td { padding: 7px 10px; }
+ .modal { padding: 10px; }
+ .modal-box, .modal-box.wide { width: 100%; max-height: 92vh; }
+ .form-grid { grid-template-columns: 1fr; }
+ .modal-body { padding: 14px; }
+ .roster-bar .chk { font-size: 11px; }
+}
+
+@media (max-width: 480px) {
+ .cards { grid-template-columns: repeat(2, 1fr); }
+ .brand-text strong { font-size: 14px; }
+ .topbar .logo { width: 32px; height: 32px; font-size: 13px; }
+ .btn { padding: 6px 9px; font-size: 12px; }
+ .field label { font-size: 11px; }
+ .roster-bar { gap: 8px; }
+ .roster-bar input[type=text] { width: 120px; }
+ .panel-head h2 { font-size: 14px; }
+ #rosterBody input { font-size: 12px; }
+}
+
diff --git a/bootstrap.php b/bootstrap.php
new file mode 100644
index 0000000..f53afb6
--- /dev/null
+++ b/bootstrap.php
@@ -0,0 +1,239 @@
+= 8.0
+ */
+declare(strict_types=1);
+
+const NY_APP_NAME = 'NY问卷考试系统数据平台';
+const NY_VERSION = '1.0.0';
+
+define('NY_ROOT', str_replace('\\', '/', __DIR__));
+define('NY_DATA', NY_ROOT . '/data');
+
+// ---------------- 环境预检 ----------------
+if (PHP_VERSION_ID < 80000) {
+ http_response_code(500);
+ header('Content-Type: text/html; charset=utf-8');
+ echo '环境不满足
本平台需要 PHP 8.0 及以上,当前版本:' . PHP_VERSION . '
';
+ exit;
+}
+$missing = [];
+foreach (['curl' => 'curl', 'json' => 'json', 'mbstring' => 'mbstring'] as $fn => $ext) {
+ if (!extension_loaded($ext)) {
+ $missing[] = $ext;
+ }
+}
+if ($missing !== []) {
+ http_response_code(500);
+ header('Content-Type: text/html; charset=utf-8');
+ echo '缺少 PHP 扩展
请在 php.ini 中启用:' . implode('、', $missing) . '
'
+ . 'Windows:去掉 extension=' . $missing[0] . ' 前面的分号并重启服务。'
+ . '(zip 扩展可选,未启用时会用内置解析器读取 xlsx)
';
+ exit;
+}
+
+date_default_timezone_set('Asia/Shanghai');
+mb_internal_encoding('UTF-8');
+
+// ---------------- 目录初始化 ----------------
+foreach (['', '/cache', '/uploads'] as $sub) {
+ $dir = NY_DATA . $sub;
+ if (!is_dir($dir)) {
+ @mkdir($dir, 0777, true);
+ }
+}
+// 保护数据目录(Apache)
+if (!file_exists(NY_DATA . '/.htaccess')) {
+ @file_put_contents(NY_DATA . '/.htaccess', "Deny from all\n");
+}
+
+// ---------------- 默认配置 ----------------
+function ny_default_config(): array
+{
+ return [
+ 'base_url' => 'https://exam.nxxy335.top',
+ 'api_prefix' => '/api',
+ 'username' => 'admin',
+ 'password' => '123456',
+ 'verify_ssl' => true,
+ 'timeout' => 20,
+ 'refresh_interval' => 15,
+ 'page_size' => 200,
+ 'include_temp_save' => false,
+ 'dedupe' => true,
+ 'name_keywords' => '姓名,名字,学生姓名,考生姓名,您的姓名,姓名:,姓名:',
+ 'full_score' => 100,
+ 'full_scores' => [],
+ ];
+}
+
+function ny_config_file(): string
+{
+ return NY_DATA . '/config.json';
+}
+
+function ny_load_config(): array
+{
+ $cfg = ny_default_config();
+ $saved = ny_read_json(ny_config_file(), null);
+ if (is_array($saved)) {
+ foreach ($cfg as $k => $v) {
+ if (array_key_exists($k, $saved)) {
+ $cfg[$k] = is_bool($v) ? (bool)$saved[$k] : $saved[$k];
+ }
+ }
+ }
+ $cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
+ $cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
+ return $cfg;
+}
+
+function ny_save_config(array $cfg): bool
+{
+ return ny_write_json(ny_config_file(), $cfg);
+}
+
+/** 安全的字符串化(避免数组/对象转字符串报错) */
+function ny_str(mixed $v): string
+{
+ if ($v === null || is_array($v) || is_object($v)) {
+ return '';
+ }
+ return trim((string)$v);
+}
+
+// ---------------- JSON 文件读写 ----------------
+function ny_read_json(string $file, mixed $default = null): mixed
+{
+ if (!is_file($file)) {
+ return $default;
+ }
+ $raw = @file_get_contents($file);
+ if ($raw === false || trim($raw) === '') {
+ return $default;
+ }
+ $data = json_decode($raw, true);
+ return $data === null ? $default : $data;
+}
+
+function ny_write_json(string $file, mixed $data): bool
+{
+ $tmp = $file . '.tmp';
+ $ok = @file_put_contents($tmp, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX);
+ if ($ok === false) {
+ return false;
+ }
+ return @rename($tmp, $file) || @copy($tmp, $file);
+}
+
+// ---------------- 输出 ----------------
+function ny_json(mixed $data, int $http = 200): void
+{
+ http_response_code($http);
+ header('Content-Type: application/json; charset=utf-8');
+ header('Cache-Control: no-store');
+ echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
+}
+
+function ny_ok(mixed $data = null): void
+{
+ ny_json(['ok' => true, 'data' => $data]);
+ exit;
+}
+
+function ny_fail(string $msg, mixed $extra = null): void
+{
+ ny_json(['ok' => false, 'msg' => $msg, 'extra' => $extra]);
+ exit;
+}
+
+// ---------------- 工具函数 ----------------
+/** 规范化姓名:去空白、去全角空格、统一大小写 */
+function ny_norm_name(string $s): string
+{
+ $s = str_replace(["\xC2\xA0", ' '], '', $s);
+ $s = preg_replace('/\s+/u', '', $s) ?? '';
+ return mb_strtolower(trim($s));
+}
+
+/** 把任意结构的答卷值压平成字符串 */
+function ny_flatten_value(mixed $v): string
+{
+ if ($v === null) {
+ return '';
+ }
+ if (is_bool($v)) {
+ return $v ? '1' : '';
+ }
+ if (is_int($v) || is_float($v)) {
+ return trim((string)$v);
+ }
+ if (is_string($v)) {
+ return trim($v);
+ }
+ if (is_array($v)) {
+ foreach (['value', 'text', 'label', 'name', 'content', 'title'] as $k) {
+ if (isset($v[$k]) && (is_string($v[$k]) || is_numeric($v[$k]))) {
+ return trim((string)$v[$k]);
+ }
+ }
+ $parts = [];
+ foreach ($v as $item) {
+ $s = ny_flatten_value($item);
+ if ($s !== '') {
+ $parts[] = $s;
+ }
+ }
+ return implode(',', $parts);
+ }
+ return trim((string)$v);
+}
+
+/** 时间字段 -> 时间戳(秒) */
+function ny_to_timestamp(mixed $v): int
+{
+ if ($v === null || $v === '') {
+ return 0;
+ }
+ if (is_numeric($v)) {
+ $n = (int)$v;
+ if ($n > 1000000000000) { // 毫秒
+ return intdiv($n, 1000);
+ }
+ return $n;
+ }
+ $t = strtotime((string)$v);
+ return $t === false ? 0 : $t;
+}
+
+/** 秒 -> 12分34秒 */
+function ny_format_duration(?int $sec): string
+{
+ if ($sec === null || $sec < 0) {
+ return '-';
+ }
+ if ($sec < 60) {
+ return $sec . '秒';
+ }
+ $m = intdiv($sec, 60);
+ $s = $sec % 60;
+ if ($m < 60) {
+ return $m . '分' . str_pad((string)$s, 2, '0', STR_PAD_LEFT) . '秒';
+ }
+ $h = intdiv($m, 60);
+ return $h . '小时' . str_pad((string)($m % 60), 2, '0', STR_PAD_LEFT) . '分' . str_pad((string)$s, 2, '0', STR_PAD_LEFT) . '秒';
+}
+
+// ---------------- 类自动加载 ----------------
+spl_autoload_register(function (string $class): void {
+ $map = [
+ 'XlsxReader' => NY_ROOT . '/lib/XlsxReader.php',
+ 'SurveyKingClient' => NY_ROOT . '/lib/SurveyKingClient.php',
+ 'Roster' => NY_ROOT . '/lib/Roster.php',
+ 'DataService' => NY_ROOT . '/lib/DataService.php',
+ ];
+ if (isset($map[$class]) && is_file($map[$class])) {
+ require $map[$class];
+ }
+});
diff --git a/docs/预览图/1-首页.png b/docs/预览图/1-首页.png
new file mode 100644
index 0000000..fe65c91
Binary files /dev/null and b/docs/预览图/1-首页.png differ
diff --git a/docs/预览图/2-成绩明细与统计.png b/docs/预览图/2-成绩明细与统计.png
new file mode 100644
index 0000000..a5bbd38
Binary files /dev/null and b/docs/预览图/2-成绩明细与统计.png differ
diff --git a/docs/预览图/3-系统设置.png b/docs/预览图/3-系统设置.png
new file mode 100644
index 0000000..b8d7f75
Binary files /dev/null and b/docs/预览图/3-系统设置.png differ
diff --git a/docs/预览图/4-名单管理.png b/docs/预览图/4-名单管理.png
new file mode 100644
index 0000000..2c4b6ce
Binary files /dev/null and b/docs/预览图/4-名单管理.png differ
diff --git a/index.php b/index.php
new file mode 100644
index 0000000..4117fc4
--- /dev/null
+++ b/index.php
@@ -0,0 +1,268 @@
+
+
+
+
+
+
+= htmlspecialchars(NY_APP_NAME) ?>
+
+
+
+
+
+
+
+
+
+
+
NY
+
+ NY问卷考试系统数据平台
+ 卷王问卷系统 · 实时数据统计
+
+
+
请输入卷王系统连接信息,完成连接测试后登录。密码需每次输入,避免依赖一次配置直接查看数据。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 名单总人数-
+ 已提交-
+ 缺考-
+ 提交率-
+ 平均分-
+ 最高分-
+ 最低分-
+ 合格率(≥60%满分)-
+ 平均用时-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
学号名单管理
+
+
+
+
+ 点击或拖拽文件到此上传
+ 支持 .xlsx / .csv,需包含「学号」「姓名」两列(「班级」可选)
+
+
+
+
+
共 0 人
+
+
+
+
+
+
+
+
+
diff --git a/lib/DataService.php b/lib/DataService.php
new file mode 100644
index 0000000..a8e7159
--- /dev/null
+++ b/lib/DataService.php
@@ -0,0 +1,311 @@
+ 学号 / 姓名 / 成绩 / 提交时间 / 考试时长
+ */
+declare(strict_types=1);
+
+class DataService
+{
+ /** 容器型题目(分页、题组、说明)不参与取值 */
+ private const CONTAINER_TYPES = [
+ 'pagination', 'page', 'group', 'survey', 'divider', 'tree',
+ ];
+
+ /** 题目标题可能是富文本,去掉标签便于展示与匹配 */
+ private static function cleanTitle(string $t): string
+ {
+ $t = strip_tags($t);
+ $t = html_entity_decode($t, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ return trim(preg_replace('/\s+/u', ' ', $t) ?? '');
+ }
+
+ /**
+ * 展平题目树
+ * @return array
+ */
+ public static function flattenQuestions(mixed $node, string $path = ''): array
+ {
+ $out = [];
+ if (!is_array($node)) {
+ return $out;
+ }
+
+ // children 在根或子节点
+ $self = $node;
+ $children = (isset($node['children']) && is_array($node['children'])) ? $node['children'] : null;
+
+ $id = isset($self['id']) ? (string)$self['id'] : '';
+ $title = isset($self['title']) ? self::cleanTitle((string)$self['title']) : '';
+ $type = isset($self['type']) ? (string)$self['type'] : '';
+
+ $isRoot = ($path === '');
+ if (!$isRoot && $id !== '' && !in_array(strtolower($type), self::CONTAINER_TYPES, true)) {
+ $out[] = [
+ 'id' => $id,
+ 'title' => $title !== '' ? $title : $id,
+ 'type' => $type,
+ 'path' => $path,
+ ];
+ }
+
+ if ($children !== null) {
+ foreach ($children as $idx => $child) {
+ $subPath = $path === '' ? (string)($idx + 1) : $path . '-' . ($idx + 1);
+ foreach (self::flattenQuestions($child, $subPath) as $q) {
+ $out[] = $q;
+ }
+ }
+ }
+ return $out;
+ }
+
+ /** 根据关键词猜测"姓名"题 */
+ public static function guessNameQuestion(array $questions, string $keywords): array
+ {
+ $kws = array_values(array_filter(array_map('trim', preg_split('/[,,]/u', $keywords) ?: [])));
+ $candidates = [];
+ foreach ($questions as $q) {
+ $t = (string)$q['title'];
+ foreach ($kws as $kw) {
+ if ($kw !== '' && mb_stripos($t, $kw) !== false) {
+ $candidates[] = $q;
+ break;
+ }
+ }
+ }
+ return $candidates;
+ }
+
+ /**
+ * 组装结果
+ */
+ public static function build(array $cfg, string $projectId, string $nameQid = ''): array
+ {
+ $client = new SurveyKingClient($cfg);
+
+ $students = Roster::all();
+ // 姓名 -> 学号 / 班级(规范化)
+ $studentInfo = [];
+ foreach ($students as $s) {
+ $k = ny_norm_name((string)$s['name']);
+ if ($k !== '') {
+ $studentInfo[$k] = [
+ 'sid' => (string)$s['sid'],
+ 'cls' => (string)($s['cls'] ?? ''),
+ ];
+ }
+ }
+
+ $project = $client->getProject($projectId);
+ $survey = $project['survey'] ?? null;
+ if (is_string($survey) && $survey !== '') {
+ $decoded = json_decode($survey, true);
+ if (is_array($decoded)) {
+ $survey = $decoded;
+ }
+ }
+ $questions = self::flattenQuestions($survey);
+
+ $keywords = (string)($cfg['name_keywords'] ?? '姓名');
+ $nameCandidates = self::guessNameQuestion($questions, $keywords);
+ $autoQid = $nameCandidates[0]['id'] ?? '';
+
+ // 指定优先
+ $pickedQid = $nameQid !== '' ? $nameQid : $autoQid;
+ $candidateIds = [];
+ if ($pickedQid !== '') {
+ $candidateIds[] = $pickedQid;
+ }
+ foreach ($nameCandidates as $q) {
+ if (!in_array($q['id'], $candidateIds, true)) {
+ $candidateIds[] = $q['id'];
+ }
+ }
+
+ $answers = $client->listAnswers($projectId);
+
+ $includeTemp = !empty($cfg['include_temp_save']);
+ $dedupe = !empty($cfg['dedupe']);
+
+ $rows = [];
+ $unmatched = [];
+ $seenSid = [];
+
+ foreach ($answers as $a) {
+ $tempSave = isset($a['tempSave']) ? (int)$a['tempSave'] : 1;
+ if (!$includeTemp && $tempSave === 0) {
+ continue; // 跳过暂存
+ }
+ $answerMap = isset($a['answer']) && is_array($a['answer']) ? $a['answer'] : [];
+
+ // 姓名:优先关键词命中的题目 -> 全量扫描能匹配名单的答案 -> 登录用户名
+ $name = '';
+ foreach ($candidateIds as $qid) {
+ if (isset($answerMap[$qid])) {
+ $v = ny_flatten_value($answerMap[$qid]);
+ if ($v !== '') {
+ $name = $v;
+ break;
+ }
+ }
+ }
+ if ($name === '') {
+ foreach ($questions as $q) {
+ $qid = (string)$q['id'];
+ if (!array_key_exists($qid, $answerMap)) {
+ continue;
+ }
+ $v = ny_flatten_value($answerMap[$qid]);
+ if ($v !== '' && isset($nameMap[ny_norm_name($v)])) {
+ $name = $v;
+ break;
+ }
+ }
+ }
+ if ($name === '' && $questions === []) {
+ foreach ($answerMap as $v) {
+ $s = ny_flatten_value($v);
+ if ($s !== '' && isset($nameMap[ny_norm_name($s)])) {
+ $name = $s;
+ break;
+ }
+ }
+ }
+ if ($name === '') {
+ $name = ny_str($a['createByName'] ?? '');
+ }
+ if ($name === '') {
+ $name = '(未填写姓名)';
+ }
+
+ // 成绩
+ $score = null;
+ if (isset($a['examScore']) && is_numeric($a['examScore'])) {
+ $score = (float)$a['examScore'];
+ }
+
+ // 提交时间
+ $ts = ny_to_timestamp($a['createAt'] ?? null);
+ if ($ts === 0) {
+ $ts = ny_to_timestamp($a['updateAt'] ?? null);
+ }
+
+ // 考试时长
+ $ai = $a['metaInfo']['answerInfo'] ?? [];
+ $start = is_array($ai) ? (int)($ai['startTime'] ?? 0) : 0;
+ $end = is_array($ai) ? (int)($ai['endTime'] ?? 0) : 0;
+ $dur = null;
+ if ($start > 0 && $end > $start) {
+ $dur = (int)round(($end - $start) / 1000);
+ }
+
+ $key = ny_norm_name($name);
+ $info = $studentInfo[$key] ?? null;
+ $sid = $info['sid'] ?? '';
+ $cls = $info['cls'] ?? '';
+
+ $row = [
+ 'sid' => $sid,
+ 'name' => $name,
+ 'cls' => $cls,
+ 'score' => $score,
+ 'submit_ts' => $ts,
+ 'submit_at' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : '-',
+ 'duration' => $dur,
+ 'duration_txt'=> ny_format_duration($dur),
+ 'answer_id' => ny_str($a['id'] ?? ''),
+ 'matched' => $sid !== '',
+ 'temp_save' => $tempSave === 0,
+ ];
+
+ if ($sid === '') {
+ $unmatched[] = $row;
+ continue;
+ }
+
+ if ($dedupe && isset($seenSid[$sid])) {
+ $idx = $seenSid[$sid];
+ // 保留提交时间最新的一份
+ if ($ts >= ($rows[$idx]['submit_ts'] ?? 0)) {
+ $rows[$idx] = $row;
+ $seenSid[$sid] = $idx;
+ }
+ continue;
+ }
+ $seenSid[$sid] = count($rows);
+ $rows[] = $row;
+ }
+
+ // 成绩明细默认按提交时间降序(最新在前),学号作为次级排序
+ usort($rows, static function (array $x, array $y): int {
+ $d = ($y['submit_ts'] ?? 0) <=> ($x['submit_ts'] ?? 0);
+ if ($d !== 0) {
+ return $d;
+ }
+ $a = (string)$x['sid'];
+ $b = (string)$y['sid'];
+ if (is_numeric($a) && is_numeric($b)) {
+ return $a <=> $b;
+ }
+ return strnatcmp($a, $b);
+ });
+
+ // 缺考名单
+ $submittedSids = [];
+ foreach ($rows as $r) {
+ $submittedSids[(string)$r['sid']] = true;
+ }
+ $absent = [];
+ foreach ($students as $s) {
+ if (!isset($submittedSids[(string)$s['sid']])) {
+ $absent[] = [
+ 'sid' => (string)$s['sid'],
+ 'name' => (string)$s['name'],
+ 'cls' => (string)($s['cls'] ?? ''),
+ ];
+ }
+ }
+ usort($absent, static function (array $x, array $y): int {
+ $a = (string)$x['sid'];
+ $b = (string)$y['sid'];
+ if (is_numeric($a) && is_numeric($b)) {
+ return $a <=> $b;
+ }
+ return strnatcmp($a, $b);
+ });
+
+ // 统计
+ $scores = array_values(array_filter(array_map(static fn($r) => $r['score'], $rows), static fn($v) => $v !== null));
+ $durs = array_values(array_filter(array_map(static fn($r) => $r['duration'], $rows), static fn($v) => $v !== null));
+ $stats = [
+ 'roster_total' => count($students),
+ 'submitted' => count($rows),
+ 'absent' => count($absent),
+ 'unmatched' => count($unmatched),
+ 'submit_rate' => count($students) > 0 ? round(count($rows) / count($students) * 100, 1) : 0,
+ 'avg_score' => $scores !== [] ? round(array_sum($scores) / count($scores), 2) : null,
+ 'max_score' => $scores !== [] ? max($scores) : null,
+ 'min_score' => $scores !== [] ? min($scores) : null,
+ 'avg_duration' => $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null,
+ 'answer_total' => count($answers),
+ ];
+
+ return [
+ 'project' => [
+ 'id' => ny_str($project['id'] ?? $projectId),
+ 'name' => ny_str($project['name'] ?? ''),
+ 'mode' => ny_str($project['mode'] ?? ''),
+ ],
+ 'questions' => $questions,
+ 'name_qid' => $pickedQid,
+ 'name_candidates' => array_map(static fn($q) => ['id' => $q['id'], 'title' => $q['title']], $nameCandidates),
+ 'rows' => $rows,
+ 'absent' => $absent,
+ 'unmatched' => $unmatched,
+ 'stats' => $stats,
+ 'roster' => $students,
+ 'roster_count' => count($students),
+ 'updated_at' => date('Y-m-d H:i:s'),
+ ];
+ }
+}
diff --git a/lib/Roster.php b/lib/Roster.php
new file mode 100644
index 0000000..d7cd38f
--- /dev/null
+++ b/lib/Roster.php
@@ -0,0 +1,229 @@
+ {"students":[{"sid":"1","name":"张三","cls":"计算机2401"}], "updated_at":"..."}
+ * cls 为班级(可空),用于一份考试涉及多个班级时区分。
+ */
+declare(strict_types=1);
+
+class Roster
+{
+ private static function file(): string
+ {
+ return NY_DATA . '/roster.json';
+ }
+
+ /** @return array */
+ public static function all(): array
+ {
+ $d = ny_read_json(self::file(), null);
+ if (!is_array($d) || !isset($d['students']) || !is_array($d['students'])) {
+ return [];
+ }
+ $out = [];
+ foreach ($d['students'] as $s) {
+ if (!is_array($s)) {
+ continue;
+ }
+ $out[] = [
+ 'sid' => trim((string)($s['sid'] ?? '')),
+ 'name' => trim((string)($s['name'] ?? '')),
+ 'cls' => trim((string)($s['cls'] ?? '')),
+ ];
+ }
+ return $out;
+ }
+
+ public static function meta(): array
+ {
+ $d = ny_read_json(self::file(), null);
+ return [
+ 'count' => count(self::all()),
+ 'updated_at' => is_array($d) ? (string)($d['updated_at'] ?? '') : '',
+ ];
+ }
+
+ public static function save(array $students): bool
+ {
+ $clean = [];
+ foreach ($students as $s) {
+ $sid = trim((string)($s['sid'] ?? ''));
+ $name = trim((string)($s['name'] ?? ''));
+ if ($sid === '' && $name === '') {
+ continue;
+ }
+ $clean[] = [
+ 'sid' => $sid,
+ 'name' => $name,
+ 'cls' => trim((string)($s['cls'] ?? '')),
+ ];
+ }
+ return ny_write_json(self::file(), [
+ 'students' => $clean,
+ 'updated_at' => date('Y-m-d H:i:s'),
+ ]);
+ }
+
+ public static function clear(): bool
+ {
+ return ny_write_json(self::file(), ['students' => [], 'updated_at' => date('Y-m-d H:i:s')]);
+ }
+
+ public static function count(): int
+ {
+ return count(self::all());
+ }
+
+ /**
+ * 首次使用时,若名单为空且存在 导入模板.xlsx,自动导入
+ */
+ public static function ensureSeeded(): bool
+ {
+ if (self::count() > 0) {
+ return false;
+ }
+ $candidates = [
+ dirname(NY_ROOT) . '/导入模板.xlsx',
+ NY_ROOT . '/导入模板.xlsx',
+ NY_DATA . '/导入模板.xlsx',
+ ];
+ foreach ($candidates as $f) {
+ if (is_file($f)) {
+ try {
+ $res = self::importFile($f, 'xlsx');
+ return $res['count'] > 0;
+ } catch (Throwable $e) {
+ // 忽略
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * 解析上传文件 -> 学生数组
+ * @return array{count:int,students:array}
+ */
+ public static function importFile(string $file, string $ext = ''): array
+ {
+ if ($ext === '') {
+ $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
+ }
+ $rows = [];
+ if ($ext === 'xlsx' || $ext === 'xlsm') {
+ $rows = XlsxReader::read($file);
+ } elseif ($ext === 'csv' || $ext === 'txt') {
+ $rows = self::parseCsv($file);
+ } else {
+ // 尝试按 xlsx 解析,失败再按 csv
+ try {
+ $rows = XlsxReader::read($file);
+ } catch (Throwable $e) {
+ $rows = self::parseCsv($file);
+ }
+ }
+
+ if ($rows === []) {
+ throw new RuntimeException('文件为空或无法解析');
+ }
+ return ['count' => count($rows), 'students' => self::rowsToStudents($rows)];
+ }
+
+ public static function parseCsv(string $file): array
+ {
+ $raw = (string)@file_get_contents($file);
+ $raw = preg_replace('/^\xEF\xBB\xBF/', '', $raw) ?? $raw;
+ $rows = [];
+ if (($fh = fopen('php://memory', 'r+')) === false) {
+ return [];
+ }
+ fwrite($fh, $raw);
+ rewind($fh);
+ while (($line = fgetcsv($fh, 0, ',')) !== false) {
+ if ($line === [null] || (count($line) === 1 && trim((string)$line[0]) === '')) {
+ continue;
+ }
+ $rows[] = array_map(static fn($v) => self::conv((string)$v), $line);
+ }
+ fclose($fh);
+ if ($rows === []) {
+ // 可能是 tab 分隔
+ foreach (preg_split('/\r?\n/', $raw) ?: [] as $line) {
+ if (trim($line) === '') {
+ continue;
+ }
+ $rows[] = array_map([self::class, 'conv'], explode("\t", $line));
+ }
+ }
+ return $rows;
+ }
+
+ private static function conv(string $v): string
+ {
+ $v = trim($v);
+ $enc = mb_detect_encoding($v, ['UTF-8', 'GB18030', 'GBK', 'BIG5'], true);
+ if ($enc !== false && strtoupper($enc) !== 'UTF-8') {
+ $v = mb_convert_encoding($v, 'UTF-8', $enc);
+ }
+ return $v;
+ }
+
+ /**
+ * 识别 学号/姓名/班级 列
+ * @return array
+ */
+ public static function rowsToStudents(array $rows): array
+ {
+ $sidCol = -1;
+ $nameCol = -1;
+ $clsCol = -1;
+ $headerIdx = -1;
+
+ // 找表头
+ foreach ($rows as $i => $row) {
+ $joined = implode('|', array_map('strval', $row));
+ if (preg_match('/学\s*号|编\s*号|序\s*号|no\.?|id/i', $joined)
+ && preg_match('/姓\s*名|名\s*字|学生|成员/i', $joined)) {
+ $headerIdx = $i;
+ break;
+ }
+ }
+ $start = 0;
+ if ($headerIdx >= 0) {
+ $start = $headerIdx + 1;
+ foreach ($rows[$headerIdx] as $j => $cell) {
+ $c = (string)$cell;
+ if ($sidCol < 0 && preg_match('/学\s*号|编\s*号|序\s*号|^\s*no\.?\s*$|^\s*id\s*$/i', $c)) {
+ $sidCol = $j;
+ }
+ if ($nameCol < 0 && preg_match('/姓\s*名|名\s*字|学生姓名/i', $c)) {
+ $nameCol = $j;
+ }
+ if ($clsCol < 0 && preg_match('/班\s*级|班级|class/i', $c)) {
+ $clsCol = $j;
+ }
+ }
+ }
+ if ($sidCol < 0) {
+ $sidCol = 0;
+ }
+ if ($nameCol < 0) {
+ $nameCol = $sidCol === 0 ? 1 : 0;
+ }
+
+ $out = [];
+ for ($i = $start; $i < count($rows); $i++) {
+ $row = $rows[$i];
+ $sid = trim((string)($row[$sidCol] ?? ''));
+ $name = trim((string)($row[$nameCol] ?? ''));
+ if ($sid === '' && $name === '') {
+ continue;
+ }
+ // 学号去掉 .0 尾巴(Excel 数字读取)
+ $sid = preg_replace('/\.0+$/', '', $sid) ?? $sid;
+ $cls = $clsCol >= 0 ? trim((string)($row[$clsCol] ?? '')) : '';
+ $out[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls];
+ }
+ return $out;
+ }
+}
diff --git a/lib/SurveyKingClient.php b/lib/SurveyKingClient.php
new file mode 100644
index 0000000..feed323
--- /dev/null
+++ b/lib/SurveyKingClient.php
@@ -0,0 +1,527 @@
+ data.publicKey(RSA 公钥,Base64/X.509)
+ * 2) 用公钥 RSA/ECB/PKCS1Padding 加密密码并 Base64
+ * 3) POST {base}{prefix}/public/login -> 登录成功后在 Set-Cookie 里下发 sk-token(JWT)
+ * 注意:v1.13 服务端只从 Cookie 的 sk-token 取令牌,不读 Authorization 头
+ * (响应头也会回显 Authorization,但被服务端忽略)
+ * 兼容旧版 / 企业版:
+ * POST {prefix}/system/auth/login -> data.accessToken
+ * POST /admin-api/oauth2/token -> data.access_token
+ */
+declare(strict_types=1);
+
+class SurveyKingClient
+{
+ /** 社区版会话 Cookie 名称(AppConsts.TOKEN_NAME) */
+ public const TOKEN_COOKIE = 'sk-token';
+
+ private array $cfg;
+ private ?string $token = null;
+ private string $tokenFile;
+ private bool $retried = false;
+ /** Cookie 会话:社区版 v1.13 的服务端只认 Cookie 里的 sk-token,不读 Authorization 头 */
+ private array $cookies = [];
+ private int $expireAt = 0;
+ private string $loginVia = '';
+ /** @var array */
+ public array $log = [];
+
+ public function __construct(array $cfg)
+ {
+ $this->cfg = $cfg;
+ $this->tokenFile = NY_DATA . '/cache/token.json';
+ }
+
+ /** 会话信息(用于诊断) */
+ public function sessionInfo(): array
+ {
+ return [
+ 'has_token' => $this->token !== null && $this->token !== '',
+ 'token_head' => $this->token !== null ? substr($this->token, 0, 14) . '…' : '',
+ 'cookies' => array_keys($this->cookies),
+ 'login_via' => $this->loginVia,
+ 'expire_at' => $this->expireAt > 0 ? date('Y-m-d H:i:s', $this->expireAt) : '',
+ ];
+ }
+
+ public function setConfig(array $cfg): void
+ {
+ $this->cfg = $cfg;
+ }
+
+ // ---------------- 地址 ----------------
+ public function base(): string
+ {
+ return rtrim((string)$this->cfg['base_url'], '/');
+ }
+
+ public function apiBase(): string
+ {
+ return $this->base() . '/' . trim((string)$this->cfg['api_prefix'], '/');
+ }
+
+ private function timeout(): int
+ {
+ return max(3, (int)($this->cfg['timeout'] ?? 20));
+ }
+
+ private function cookieHeader(): string
+ {
+ $parts = [];
+ foreach ($this->cookies as $k => $v) {
+ $parts[] = $k . '=' . $v;
+ }
+ return implode('; ', $parts);
+ }
+
+ private function persistSession(): void
+ {
+ if ($this->token === null || $this->token === '') {
+ return;
+ }
+ ny_write_json($this->tokenFile, [
+ 'token' => $this->token,
+ 'expire_at' => $this->expireAt,
+ 'cookies' => $this->cookies,
+ 'login_via' => $this->loginVia,
+ ]);
+ }
+
+ // ---------------- 底层 HTTP ----------------
+ /**
+ * @return array{status:int,headers:array,data:mixed,raw:string,code:int,msg:string}
+ */
+ private function raw(string $path, array $query = [], string $method = 'GET', mixed $body = null): array
+ {
+ if (!function_exists('curl_init')) {
+ throw new RuntimeException('PHP 未启用 curl 扩展,无法访问卷王接口');
+ }
+ $url = $this->apiBase() . $path;
+ if ($query !== []) {
+ $url .= (str_contains($url, '?') ? '&' : '?') . http_build_query($query);
+ }
+
+ $headers = ['Accept: application/json', 'Accept-Charset: utf-8'];
+ if ($method === 'POST') {
+ $headers[] = 'Content-Type: application/json; charset=utf-8';
+ }
+ // 令牌两种都发:老版本读 Authorization 头,v1.13 只读 sk-token Cookie
+ if ($this->token !== null && $this->token !== '') {
+ $headers[] = 'Authorization: Bearer ' . $this->token;
+ }
+ if ($this->cookies !== []) {
+ $headers[] = 'Cookie: ' . $this->cookieHeader();
+ }
+ $verify = !empty($this->cfg['verify_ssl']);
+
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HEADER => true,
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_TIMEOUT => $this->timeout(),
+ CURLOPT_CONNECTTIMEOUT => min(10, $this->timeout()),
+ CURLOPT_SSL_VERIFYPEER => $verify,
+ CURLOPT_SSL_VERIFYHOST => $verify ? 2 : 0,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_ENCODING => '',
+ CURLOPT_PROXY => self::proxyFor($url),
+ ]);
+ if ($method === 'POST') {
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE));
+ }
+
+ $resp = curl_exec($ch);
+ $err = curl_error($ch);
+ $info = curl_getinfo($ch);
+ curl_close($ch);
+
+ if ($resp === false) {
+ throw new RuntimeException('请求失败:' . $url . ' —— ' . $err);
+ }
+
+ $headerSize = (int)($info['header_size'] ?? 0);
+ $rawHeader = substr((string)$resp, 0, $headerSize);
+ $rawBody = substr((string)$resp, $headerSize);
+ $status = (int)($info['http_code'] ?? 0);
+
+ $h = [];
+ foreach (preg_split('/\r?\n/', $rawHeader) ?: [] as $line) {
+ if (str_contains($line, ':')) {
+ [$k, $v] = explode(':', $line, 2);
+ $h[strtolower(trim($k))] = trim($v);
+ }
+ }
+
+ // 收集 Set-Cookie(可能有多个,上面的解析只保留最后一个)
+ if (preg_match_all('/^Set-Cookie:\s*(.+)$/mi', $rawHeader, $sc) > 0) {
+ foreach ($sc[1] as $one) {
+ $pair = trim(explode(';', trim($one))[0]);
+ $eq = strpos($pair, '=');
+ if ($pair === '' || $eq === false) {
+ continue;
+ }
+ $name = trim(substr($pair, 0, $eq));
+ $val = trim(substr($pair, $eq + 1));
+ if ($val === '' || strtolower($val) === 'deleted' || strtolower($val) === '""') {
+ unset($this->cookies[$name]); // 服务端主动清除
+ continue;
+ }
+ $this->cookies[$name] = $val;
+ }
+ $this->persistSession();
+ }
+
+ $decoded = json_decode($rawBody, true);
+ $data = $decoded;
+ $code = 0;
+ $msg = '';
+ if (is_array($decoded) && array_key_exists('code', $decoded)) {
+ $code = (int)$decoded['code'];
+ $data = $decoded['data'] ?? null;
+ $msg = (string)($decoded['msg'] ?? $decoded['message'] ?? '');
+ }
+
+ return ['status' => $status, 'headers' => $h, 'data' => $data, 'raw' => $rawBody, 'code' => $code, 'msg' => $msg];
+ }
+
+ /**
+ * 代理设置:本机/内网地址不走系统代理(否则同机部署时会连不上卷王)
+ */
+ private static function proxyFor(string $url): string
+ {
+ $host = parse_url($url, PHP_URL_HOST);
+ if (!is_string($host) || $host === '') {
+ return '';
+ }
+ $host = strtolower(trim($host, '[]'));
+ $isLoopback = in_array($host, ['localhost', '::1', '127.0.0.1'], true)
+ || str_starts_with($host, '127.')
+ || str_ends_with($host, '.localhost');
+ return $isLoopback ? '' : (string)(getenv('http_proxy') ?: '');
+ }
+
+ /**
+ * 带鉴权与 401 自动重登的请求
+ */
+ public function request(string $path, array $query = [], string $method = 'GET', mixed $body = null): array
+ {
+ $this->getToken();
+
+ $r = $this->raw($path, $query, $method, $body);
+
+ $needRelogin = in_array($r['status'], [401, 403], true)
+ || in_array($r['code'], [401, 403], true);
+
+ if ($needRelogin && !$this->retried) {
+ $this->retried = true;
+ try {
+ $this->login(true);
+ $r = $this->raw($path, $query, $method, $body);
+ } finally {
+ $this->retried = false;
+ }
+ }
+
+ if ($r['status'] >= 400 && $r['status'] !== 401 && $r['status'] !== 403) {
+ throw new RuntimeException('接口 ' . $path . ' 返回 HTTP ' . $r['status'] . ':' . mb_substr($r['raw'], 0, 300));
+ }
+ if ($r['code'] !== 0 && $r['code'] !== 200) {
+ throw new RuntimeException('接口 ' . $path . ' 返回错误:' . ($r['msg'] !== '' ? $r['msg'] : 'code=' . $r['code']));
+ }
+ return $r;
+ }
+
+ // ---------------- 登录 ----------------
+ public function getToken(bool $force = false): string
+ {
+ if (!$force && $this->token !== null && $this->token !== '') {
+ return $this->token;
+ }
+ if (!$force) {
+ $c = ny_read_json($this->tokenFile, null);
+ if (is_array($c) && !empty($c['token']) && (int)($c['expire_at'] ?? 0) > time() + 60) {
+ $this->token = (string)$c['token'];
+ $this->expireAt = (int)($c['expire_at'] ?? 0);
+ $this->cookies = is_array($c['cookies'] ?? null) ? $c['cookies'] : [];
+ $this->loginVia = (string)($c['login_via'] ?? '');
+ return $this->token;
+ }
+ }
+ $this->login($force);
+ return (string)$this->token;
+ }
+
+ public function login(bool $force = false): string
+ {
+ $this->token = null;
+ $this->cookies = [];
+ $this->expireAt = 0;
+ $this->loginVia = '';
+ $username = (string)($this->cfg['username'] ?? '');
+ $password = (string)($this->cfg['password'] ?? '');
+ if ($username === '') {
+ throw new RuntimeException('未配置卷王登录账号');
+ }
+
+ $errors = [];
+
+ // 1) 取 RSA 公钥
+ $publicKey = '';
+ try {
+ $r = $this->raw('/system');
+ if (is_array($r['data']) && !empty($r['data']['publicKey'])) {
+ $publicKey = (string)$r['data']['publicKey'];
+ }
+ } catch (Throwable $e) {
+ $errors[] = '获取公钥失败:' . $e->getMessage();
+ }
+
+ // 2) 社区版 /public/login(令牌可能在 Authorization 头、响应体或 sk-token Cookie 中)
+ try {
+ $pwd = $this->encryptPassword($password, $publicKey);
+ $r = $this->raw('/public/login', [], 'POST', ['username' => $username, 'password' => $pwd]);
+ $tok = $r['headers']['authorization'] ?? '';
+ $via = 'public/login · Authorization 头';
+ if ($tok === '' && is_array($r['data'])) {
+ $tok = (string)($r['data']['accessToken'] ?? $r['data']['token'] ?? $r['data']['access_token'] ?? '');
+ $via = 'public/login · 响应体';
+ }
+ if ($tok === '' && isset($this->cookies[self::TOKEN_COOKIE])) {
+ // v1.13:令牌只放在 Cookie 里
+ $tok = (string)$this->cookies[self::TOKEN_COOKIE];
+ $via = 'public/login · Cookie ' . self::TOKEN_COOKIE;
+ }
+ if ($tok !== '' && $r['status'] < 400) {
+ $this->loginVia = $via;
+ return $this->saveToken($tok);
+ }
+ $errors[] = 'public/login:HTTP ' . $r['status'] . ' ' . ($r['msg'] !== '' ? $r['msg'] : mb_substr($r['raw'], 0, 160));
+ } catch (Throwable $e) {
+ $errors[] = 'public/login:' . $e->getMessage();
+ }
+
+ // 3) 旧版 /system/auth/login
+ try {
+ $r = $this->raw('/system/auth/login', [], 'POST', ['username' => $username, 'password' => $password]);
+ $tok = '';
+ if (is_array($r['data'])) {
+ $tok = (string)($r['data']['accessToken'] ?? $r['data']['token'] ?? $r['data']['access_token'] ?? '');
+ }
+ if ($tok === '') {
+ $tok = $r['headers']['authorization'] ?? '';
+ }
+ if ($tok === '' && isset($this->cookies[self::TOKEN_COOKIE])) {
+ $tok = (string)$this->cookies[self::TOKEN_COOKIE];
+ }
+ if ($tok !== '') {
+ $this->loginVia = 'system/auth/login';
+ return $this->saveToken($tok);
+ }
+ $errors[] = 'system/auth/login:HTTP ' . $r['status'] . ' ' . ($r['msg'] !== '' ? $r['msg'] : mb_substr($r['raw'], 0, 160));
+ } catch (Throwable $e) {
+ $errors[] = 'system/auth/login:' . $e->getMessage();
+ }
+
+ // 4) 企业版 OAuth2
+ try {
+ $url = $this->base() . '/admin-api/oauth2/token';
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_POST => true,
+ CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
+ CURLOPT_POSTFIELDS => http_build_query([
+ 'grant_type' => 'password',
+ 'username' => $username,
+ 'password' => $password,
+ ]),
+ CURLOPT_TIMEOUT => $this->timeout(),
+ CURLOPT_SSL_VERIFYPEER => !empty($this->cfg['verify_ssl']),
+ CURLOPT_SSL_VERIFYHOST => !empty($this->cfg['verify_ssl']) ? 2 : 0,
+ ]);
+ $body = curl_exec($ch);
+ curl_close($ch);
+ $j = json_decode((string)$body, true);
+ if (is_array($j) && !empty($j['data']['access_token'])) {
+ $this->loginVia = 'admin-api/oauth2/token';
+ return $this->saveToken((string)$j['data']['access_token']);
+ }
+ $errors[] = 'oauth2/token:' . mb_substr((string)$body, 0, 160);
+ } catch (Throwable $e) {
+ $errors[] = 'oauth2/token:' . $e->getMessage();
+ }
+
+ throw new RuntimeException("登录卷王失败。请检查【设置】中的网址/账号/密码。\n" . implode("\n", $errors));
+ }
+
+ private function saveToken(string $token): string
+ {
+ $token = trim($token);
+ $token = preg_replace('/^Bearer\s+/i', '', $token) ?? $token;
+ $this->token = $token;
+ // JWT 过期时间兜底 8 小时
+ $exp = time() + 8 * 3600;
+ $parts = explode('.', $token);
+ if (count($parts) === 3) {
+ $payload = json_decode((string)@base64_decode(strtr($parts[1], '-_', '+/')), true);
+ if (is_array($payload) && !empty($payload['exp'])) {
+ $exp = (int)$payload['exp'];
+ }
+ }
+ $this->expireAt = $exp;
+ $this->persistSession();
+ return $token;
+ }
+
+ /**
+ * 逐步诊断,定位连接/鉴权问题
+ * @return array{steps:array}
+ */
+ public function diagnose(): array
+ {
+ $steps = [];
+
+ try {
+ $r = $this->raw('/system');
+ $has = is_array($r['data']) && !empty($r['data']['publicKey']);
+ $steps[] = [
+ 'step' => 'GET ' . $this->apiBase() . '/system',
+ 'ok' => $r['status'] < 400,
+ 'detail' => 'HTTP ' . $r['status'] . ' · RSA公钥=' . ($has ? '已获取' : '无') . ' · ' . mb_substr($r['raw'], 0, 150),
+ ];
+ } catch (Throwable $e) {
+ $steps[] = ['step' => 'GET /system', 'ok' => false, 'detail' => $e->getMessage()];
+ return ['steps' => $steps];
+ }
+
+ try {
+ $this->login(true);
+ $steps[] = [
+ 'step' => '登录',
+ 'ok' => true,
+ 'detail' => '方式=' . $this->loginVia . ' · 令牌=' . substr((string)$this->token, 0, 16)
+ . '… · Cookie=[' . implode(', ', array_keys($this->cookies)) . ']',
+ ];
+ } catch (Throwable $e) {
+ $steps[] = ['step' => '登录', 'ok' => false, 'detail' => $e->getMessage()];
+ return ['steps' => $steps];
+ }
+
+ foreach (['/project/list', '/answer/list'] as $p) {
+ try {
+ $q = $p === '/answer/list' ? ['projectId' => '', 'current' => 1, 'pageSize' => 1] : ['current' => 1, 'pageSize' => 1];
+ $r = $this->request($p, $q);
+ $cnt = is_array($r['data']) ? count($r['data']['list'] ?? []) : 0;
+ $steps[] = ['step' => 'GET ' . $p, 'ok' => true, 'detail' => 'HTTP ' . $r['status'] . ' · 本页 ' . $cnt . ' 条 · ' . mb_substr($r['raw'], 0, 150)];
+ } catch (Throwable $e) {
+ $steps[] = ['step' => 'GET ' . $p, 'ok' => false, 'detail' => $e->getMessage()];
+ }
+ }
+ return ['steps' => $steps];
+ }
+
+ /** RSA 公钥加密(无 openssl 时退回明文,兼容老版本) */
+ private function encryptPassword(string $password, string $publicKey): string
+ {
+ if ($publicKey === '' || !function_exists('openssl_public_encrypt')) {
+ return $password;
+ }
+ $pem = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(preg_replace('/\s+/', '', $publicKey), 64, "\n") . "-----END PUBLIC KEY-----\n";
+ $key = openssl_pkey_get_public($pem);
+ if ($key === false) {
+ return $password;
+ }
+ $crypted = '';
+ if (@openssl_public_encrypt($password, $crypted, $key, OPENSSL_PKCS1_PADDING)) {
+ return base64_encode($crypted);
+ }
+ return $password;
+ }
+
+ // ---------------- 业务接口 ----------------
+ /** 问卷 / 考试列表 */
+ public function listProjects(): array
+ {
+ $size = max(20, (int)($this->cfg['page_size'] ?? 200));
+ $all = [];
+ for ($page = 1; $page <= 50; $page++) {
+ $r = $this->request('/project/list', ['current' => $page, 'pageSize' => $size]);
+ $data = $r['data'];
+ $list = is_array($data) ? ($data['list'] ?? $data) : [];
+ if (!is_array($list) || $list === []) {
+ break;
+ }
+ foreach ($list as $item) {
+ if (is_array($item)) {
+ $all[] = $item;
+ }
+ }
+ $total = is_array($data) ? (int)($data['total'] ?? 0) : 0;
+ if ($total > 0 && count($all) >= $total) {
+ break;
+ }
+ if (count($list) < $size) {
+ break;
+ }
+ }
+ return $all;
+ }
+
+ /** 问卷 / 考试详情(含题目结构) */
+ public function getProject(string $id): array
+ {
+ $r = $this->request('/project', ['id' => $id]);
+ return is_array($r['data']) ? $r['data'] : [];
+ }
+
+ /** 答卷列表(自动翻页取全量) */
+ public function listAnswers(string $projectId): array
+ {
+ $size = max(20, (int)($this->cfg['page_size'] ?? 200));
+ $all = [];
+ for ($page = 1; $page <= 100; $page++) {
+ $r = $this->request('/answer/list', [
+ 'projectId' => $projectId,
+ 'current' => $page,
+ 'pageSize' => $size,
+ ]);
+ $data = $r['data'];
+ $list = is_array($data) ? ($data['list'] ?? $data) : [];
+ if (!is_array($list) || $list === []) {
+ break;
+ }
+ foreach ($list as $item) {
+ if (is_array($item)) {
+ $all[] = $item;
+ }
+ }
+ $total = is_array($data) ? (int)($data['total'] ?? 0) : 0;
+ if ($total > 0 && count($all) >= $total) {
+ break;
+ }
+ if (count($list) < $size) {
+ break;
+ }
+ }
+ return $all;
+ }
+
+ /** 对外:测试连接 */
+ public function ping(): array
+ {
+ $t0 = microtime(true);
+ $this->getToken(true);
+ $projects = $this->listProjects();
+ return [
+ 'ok' => true,
+ 'projects' => count($projects),
+ 'elapsed' => round((microtime(true) - $t0) * 1000),
+ ];
+ }
+}
diff --git a/lib/XlsxReader.php b/lib/XlsxReader.php
new file mode 100644
index 0000000..bfa2eee
--- /dev/null
+++ b/lib/XlsxReader.php
@@ -0,0 +1,223 @@
+> 二维数组(行 => 列)
+ */
+ public static function read(string $file): array
+ {
+ if (!is_file($file)) {
+ throw new RuntimeException('文件不存在:' . $file);
+ }
+ $entries = self::readZip($file);
+
+ $shared = [];
+ if (isset($entries['xl/sharedStrings.xml'])) {
+ $shared = self::parseSharedStrings($entries['xl/sharedStrings.xml']);
+ }
+
+ $sheet = self::firstSheetContent($entries);
+ if ($sheet === null) {
+ throw new RuntimeException('未找到工作表内容');
+ }
+ return self::parseSheet($sheet, $shared);
+ }
+
+ // ---------------- ZIP ----------------
+ private static function readZip(string $file): array
+ {
+ if (class_exists('ZipArchive')) {
+ $out = [];
+ $za = new ZipArchive();
+ if ($za->open($file) === true) {
+ for ($i = 0; $i < $za->numFiles; $i++) {
+ $name = $za->getNameIndex($i);
+ if ($name === false) {
+ continue;
+ }
+ $content = $za->getFromIndex($i);
+ $out[$name] = $content === false ? '' : $content;
+ }
+ $za->close();
+ return $out;
+ }
+ }
+ return self::parseZipRaw($file);
+ }
+
+ private static function parseZipRaw(string $file): array
+ {
+ $data = (string)@file_get_contents($file);
+ $len = strlen($data);
+ if ($len < 22) {
+ throw new RuntimeException('不是有效的 xlsx 文件');
+ }
+ $eocd = -1;
+ for ($i = $len - 22; $i >= 0; $i--) {
+ if (substr($data, $i, 4) === "\x50\x4b\x05\x06") {
+ $eocd = $i;
+ break;
+ }
+ }
+ if ($eocd < 0) {
+ throw new RuntimeException('不是有效的 xlsx/zip 文件(未找到目录结束标记)');
+ }
+ $count = self::u16($data, $eocd + 10);
+ $offset = self::u32($data, $eocd + 16);
+
+ $out = [];
+ $p = $offset;
+ for ($i = 0; $i < $count; $i++) {
+ if (substr($data, $p, 4) !== "\x50\x4b\x01\x02") {
+ break;
+ }
+ $method = self::u16($data, $p + 10);
+ $compSize = self::u32($data, $p + 20);
+ $nameLen = self::u16($data, $p + 28);
+ $extraLen = self::u16($data, $p + 30);
+ $commentLen = self::u16($data, $p + 32);
+ $localOff = self::u32($data, $p + 42);
+ $name = substr($data, $p + 46, $nameLen);
+
+ $lNameLen = self::u16($data, $localOff + 26);
+ $lExtraLen = self::u16($data, $localOff + 28);
+ $start = $localOff + 30 + $lNameLen + $lExtraLen;
+ $raw = substr($data, $start, $compSize);
+
+ if ($method === 0) {
+ $content = $raw;
+ } else {
+ $content = @gzinflate($raw);
+ if ($content === false) {
+ $content = @gzinflate(substr($raw, 2));
+ }
+ }
+
+ $out[str_replace('\\', '/', $name)] = $content === false ? '' : $content;
+ $p += 46 + $nameLen + $extraLen + $commentLen;
+ }
+ return $out;
+ }
+
+ private static function u16(string $d, int $off): int
+ {
+ $s = substr($d, $off, 2);
+ return $s === '' || strlen($s) < 2 ? 0 : unpack('v', $s)[1];
+ }
+
+ private static function u32(string $d, int $off): int
+ {
+ $s = substr($d, $off, 4);
+ return $s === '' || strlen($s) < 4 ? 0 : unpack('V', $s)[1];
+ }
+
+ // ---------------- XML ----------------
+ private static function firstSheetContent(array $entries): ?string
+ {
+ if (isset($entries['xl/worksheets/sheet1.xml'])) {
+ return $entries['xl/worksheets/sheet1.xml'];
+ }
+ $found = [];
+ foreach (array_keys($entries) as $k) {
+ if (preg_match('#^xl/worksheets/sheet\d+\.xml$#', $k)) {
+ $found[] = $k;
+ }
+ }
+ if ($found === []) {
+ return null;
+ }
+ sort($found, SORT_NATURAL);
+ return $entries[$found[0]];
+ }
+
+ private static function parseSharedStrings(string $xml): array
+ {
+ $out = [];
+ if (preg_match_all('#(.*?)#s', $xml, $m) > 0) {
+ foreach ($m[1] as $si) {
+ if (preg_match_all('#]*>(.*?)#s', $si, $tm) > 0) {
+ $out[] = implode('', array_map(
+ static fn($x) => html_entity_decode($x, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
+ $tm[1]
+ ));
+ } else {
+ $out[] = '';
+ }
+ }
+ }
+ return $out;
+ }
+
+ private static function parseSheet(string $xml, array $shared): array
+ {
+ $rows = [];
+ if (preg_match_all('#]*>(.*?)
#s', $xml, $rm) === 0) {
+ return $rows;
+ }
+ foreach ($rm[1] as $rowXml) {
+ $cells = [];
+ if (preg_match_all('#]*?))?(?:/>|>(.*?))#s', $rowXml, $cm, PREG_SET_ORDER) > 0) {
+ $autoIdx = 0;
+ foreach ($cm as $c) {
+ $attrs = $c[1] ?? '';
+ $inner = $c[2] ?? '';
+
+ $idx = $autoIdx;
+ if (preg_match('#\br="([A-Z]+)\d*"#', $attrs, $r)) {
+ $idx = self::colToIndex($r[1]);
+ }
+ $autoIdx = $idx + 1;
+
+ $type = preg_match('#\bt="([^"]+)"#', $attrs, $tm) ? $tm[1] : '';
+
+ if ($type === 'inlineStr') {
+ $val = '';
+ if (preg_match_all('#]*>(.*?)#s', $inner, $im) > 0) {
+ $val = implode('', array_map(
+ static fn($x) => html_entity_decode($x, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
+ $im[1]
+ ));
+ }
+ } else {
+ $val = preg_match('#(.*?)#s', $inner, $vm)
+ ? html_entity_decode($vm[1], ENT_QUOTES | ENT_HTML5, 'UTF-8')
+ : '';
+ if ($type === 's') {
+ $i = (int)$val;
+ $val = $shared[$i] ?? '';
+ } elseif ($type === 'b') {
+ $val = ((int)$val) === 1 ? 'TRUE' : 'FALSE';
+ }
+ }
+ $cells[$idx] = $val;
+ }
+ }
+ if ($cells === []) {
+ continue;
+ }
+ $max = max(array_keys($cells));
+ $line = [];
+ for ($i = 0; $i <= $max; $i++) {
+ $line[] = $cells[$i] ?? '';
+ }
+ $rows[] = $line;
+ }
+ return $rows;
+ }
+
+ private static function colToIndex(string $letters): int
+ {
+ $n = 0;
+ $len = strlen($letters);
+ for ($i = 0; $i < $len; $i++) {
+ $n = $n * 26 + (ord($letters[$i]) - 64);
+ }
+ return $n - 1;
+ }
+}
diff --git a/tools/lint.js b/tools/lint.js
new file mode 100644
index 0000000..a6014ea
--- /dev/null
+++ b/tools/lint.js
@@ -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);
diff --git a/tools/mock-server.js b/tools/mock-server.js
new file mode 100644
index 0000000..7c97da7
--- /dev/null
+++ b/tools/mock-server.js
@@ -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: '姓名
' },
+ { 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);
+});
diff --git a/tools/test-xlsx.js b/tools/test-xlsx.js
new file mode 100644
index 0000000..09a58e3
--- /dev/null
+++ b/tools/test-xlsx.js
@@ -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 = /([\s\S]*?)<\/si>/g;
+ let m;
+ while ((m = re.exec(xml))) {
+ const si = m[1];
+ const tre = /]*>([\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 = /]*>([\s\S]*?)<\/row>/g;
+ let rm;
+ while ((rm = rowRe.exec(xml))) {
+ const rowXml = rm[1];
+ const cells = {};
+ const cRe = /]*?))?(?:\/>|>([\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 = /]*>([\s\S]*?)<\/t>/g;
+ let t, parts = [];
+ while ((t = tre.exec(inner))) parts.push(decodeEntities(t[1]));
+ val = parts.join('');
+ } else {
+ const vm = /([\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));
diff --git a/启动平台.bat b/启动平台.bat
new file mode 100644
index 0000000..e0f4951
--- /dev/null
+++ b/启动平台.bat
@@ -0,0 +1,24 @@
+@echo off
+chcp 65001 >nul
+title NY问卷考试系统数据平台
+cd /d "%~dp0"
+
+where php >nul 2>nul
+if errorlevel 1 (
+ echo.
+ echo [错误] 未检测到 PHP,请先安装 PHP 8 并加入环境变量 PATH,
+ echo 或把 php.exe 放到本目录后再运行。
+ echo 下载地址: https://windows.php.net/download
+ echo.
+ pause
+ exit /b 1
+)
+
+echo.
+echo NY问卷考试系统数据平台 启动中...
+echo 浏览器访问: http://localhost:8080
+echo 停止服务请按 Ctrl + C
+echo.
+start "" http://localhost:8080
+php -S 0.0.0.0:8080
+pause