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问卷考试系统数据平台 + 卷王问卷系统 · 实时数据统计 +
+
+
+
未连接
+ + + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ + +
+
+ + +
+
+ + + +
+
名单总人数-
+
已提交-
+
缺考-
+
提交率-
+
平均分-
+
最高分-
+
最低分-
+
合格率(≥60%满分)-
+
平均用时-
+
+ +
+
+

成绩明细

+
最后更新:—
+
+
+ + + + + + + + + + + + +
学号姓名班级成绩提交时间考试时长
请选择问卷 / 考试
+
+
+ +
+
+

缺考名单

+
+ + + +
学号姓名班级
—
+
+
+
+

未匹配答卷 姓名不在名单中

+
+ + + +
姓名成绩提交时间
—
+
+
+
+
+ + + + + + + + + + + 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