feat: 登录门认证 + 每套试卷独立满分等增强
- 登录门:进入页面先输入系统网址/API路由/账号/密码,测试连接通过后才显示登录按钮,登录后才进入主界面;密码每次需输入,防止依赖一次配置直接查看数据 - 每套试卷独立满分配置(设置中按当前试卷单独设置,合格率按 ≥60% 满分计算) - 班级联动统计与面板计数(成绩明细/缺考名单标题计数随班级过滤) - 移除最高/最低分获得者显示 - 平均用时不换行(避免“秒”字换行) - 卷王地址默认 https://exam.nxxy335.top,默认勾选校验 HTTPS 证书 - 统计卡片含合格率(≥60% 满分) - .gitignore 排除 data/(账号密码/令牌/学生名单,禁止入库)
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# 运行时数据:含账号密码、API 令牌、学生名单,禁止入库
|
||||
data/
|
||||
|
||||
# 依赖与系统文件
|
||||
node_modules/
|
||||
vendor/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
/**
|
||||
* NY 问卷考试系统数据平台 - 后端接口
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
|
||||
$action = (string)($_GET['action'] ?? $_POST['action'] ?? '');
|
||||
|
||||
function cfg(): array
|
||||
{
|
||||
return ny_load_config();
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
// ---------------- 配置 ----------------
|
||||
case 'config_get':
|
||||
$c = cfg();
|
||||
$c['has_password'] = $c['password'] !== '';
|
||||
ny_ok($c);
|
||||
|
||||
case 'config_save':
|
||||
$raw = (string)file_get_contents('php://input');
|
||||
$in = json_decode($raw, true);
|
||||
if (!is_array($in)) {
|
||||
$in = $_POST;
|
||||
}
|
||||
$old = cfg();
|
||||
$allowed = ['base_url', 'api_prefix', 'username', 'password', 'timeout', 'refresh_interval', 'page_size', 'name_keywords'];
|
||||
$new = $old;
|
||||
foreach ($allowed as $k) {
|
||||
if (array_key_exists($k, $in)) {
|
||||
$new[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k];
|
||||
}
|
||||
}
|
||||
foreach (['verify_ssl', 'include_temp_save', 'dedupe'] as $k) {
|
||||
if (array_key_exists($k, $in)) {
|
||||
$new[$k] = filter_var($in[$k], FILTER_VALIDATE_BOOL);
|
||||
}
|
||||
}
|
||||
$pwdIn = $in['password'] ?? null;
|
||||
if ($pwdIn === null || $pwdIn === '' || $pwdIn === '******') {
|
||||
$new['password'] = $old['password'];
|
||||
}
|
||||
$new['base_url'] = rtrim($new['base_url'], '/');
|
||||
$new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/');
|
||||
$new['timeout'] = (int)$new['timeout'];
|
||||
$new['refresh_interval'] = (int)$new['refresh_interval'];
|
||||
if ($new['refresh_interval'] < 3) {
|
||||
$new['refresh_interval'] = 3;
|
||||
}
|
||||
// 每套试卷独立满分:选中试卷时写入 full_scores[projectId],否则更新全局默认
|
||||
$fs = (int)($in['full_score'] ?? $new['full_score'] ?? 100);
|
||||
if ($fs < 1) {
|
||||
$fs = 100;
|
||||
}
|
||||
$pid = trim((string)($in['project_id'] ?? ''));
|
||||
if ($pid !== '') {
|
||||
$scores = is_array($new['full_scores'] ?? null) ? $new['full_scores'] : [];
|
||||
$scores[$pid] = $fs;
|
||||
$new['full_scores'] = $scores;
|
||||
} else {
|
||||
$new['full_score'] = $fs;
|
||||
}
|
||||
ny_save_config($new);
|
||||
@unlink(NY_DATA . '/cache/token.json'); // 清掉旧令牌
|
||||
ny_ok(['saved' => 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,
|
||||
]);
|
||||
}
|
||||
+692
@@ -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 = '<i></i><span>' + esc(text) + '</span>';
|
||||
}
|
||||
|
||||
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 = '<option value="">(没有获取到问卷,请检查设置)</option>';
|
||||
} 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 = '<option value="">自动识别</option>';
|
||||
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 = '<option value="">全部班级</option>' +
|
||||
vals.map(v => '<option value="' + esc(v) + '">' + esc(v) + '</option>').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 = '<tr><td colspan="6" class="empty">' +
|
||||
(state.rows.length ? '没有匹配的记录' : '暂无提交记录') + '</td></tr>';
|
||||
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 '<tr class="' + clsName.trim() + '">' +
|
||||
'<td class="sid">' + esc(r.sid) + '</td>' +
|
||||
'<td class="name">' + esc(r.name) + '</td>' +
|
||||
'<td>' + esc(r.cls || '') + '</td>' +
|
||||
'<td class="score num ' + scoreClass(r.score) + '">' + (r.score == null ? '-' : r.score) + '</td>' +
|
||||
'<td class="num">' + esc(r.submit_at) + '</td>' +
|
||||
'<td class="num">' + esc(r.duration_txt) + '</td>' +
|
||||
'</tr>';
|
||||
}).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 ? ' <span class="arrow">' + (state.sortAsc ? '↑' : '↓') + '</span>' : '');
|
||||
});
|
||||
}
|
||||
|
||||
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 = '<tr><td colspan="3" class="empty">' + (clsFilter ? '本班全部已提交 🎉' : '全部已提交 🎉') + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
tb.innerHTML = list.map(a =>
|
||||
'<tr><td class="sid">' + esc(a.sid) + '</td><td>' + esc(a.name) + '</td><td>' + esc(a.cls || '') + '</td></tr>').join('');
|
||||
}
|
||||
|
||||
function renderUnmatched() {
|
||||
const tb = $('#unmatchedBody');
|
||||
if (!state.unmatched.length) {
|
||||
tb.innerHTML = '<tr><td colspan="3" class="empty">无</td></tr>';
|
||||
return;
|
||||
}
|
||||
tb.innerHTML = state.unmatched.map(u =>
|
||||
'<tr><td>' + esc(u.name) + '</td><td class="num">' + (u.score == null ? '-' : u.score) +
|
||||
'</td><td class="num">' + esc(u.submit_at) + '</td></tr>').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 = '<tr><td colspan="4" class="empty">暂无名单,请上传文件</td></tr>';
|
||||
return;
|
||||
}
|
||||
tb.innerHTML = rows.map((r, i) => {
|
||||
const gi = rosterDraft.indexOf(r);
|
||||
return '<tr data-i="' + gi + '">' +
|
||||
'<td><input value="' + esc(r.sid) + '" data-f="sid"></td>' +
|
||||
'<td><input value="' + esc(r.name) + '" data-f="name"></td>' +
|
||||
'<td><input value="' + esc(r.cls || '') + '" data-f="cls" placeholder="可选"></td>' +
|
||||
'<td><span class="link-del" data-del="' + gi + '">删除</span></td></tr>';
|
||||
}).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();
|
||||
});
|
||||
})();
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
/**
|
||||
* NY 问卷考试系统数据平台 - 引导 / 公共函数
|
||||
* PHP >= 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 '<h2>环境不满足</h2><p>本平台需要 <b>PHP 8.0 及以上</b>,当前版本:' . PHP_VERSION . '</p>';
|
||||
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 '<h2>缺少 PHP 扩展</h2><p>请在 php.ini 中启用:<b>' . implode('、', $missing) . '</b></p>'
|
||||
. '<p>Windows:去掉 <code>extension=' . $missing[0] . '</code> 前面的分号并重启服务。'
|
||||
. '(<code>zip</code> 扩展可选,未启用时会用内置解析器读取 xlsx)</p>';
|
||||
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];
|
||||
}
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
|
||||
$cfg = ny_load_config();
|
||||
$rosterCount = Roster::count();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= htmlspecialchars(NY_APP_NAME) ?></title>
|
||||
<link rel="stylesheet" href="assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="logo">NY</span>
|
||||
<div class="brand-text">
|
||||
<strong>NY问卷考试系统数据平台</strong>
|
||||
<small>卷王问卷系统 · 实时数据统计</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<div id="conn" class="conn conn-off"><i></i><span>未连接</span></div>
|
||||
<button class="btn" id="btnFullscreen" title="切换全屏">⛶ 全屏</button>
|
||||
<button class="btn" id="btnRoster">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></button>
|
||||
<button class="btn btn-primary" id="btnSettings">设置</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ================= 登录门 ================= -->
|
||||
<div class="login-gate" id="loginGate">
|
||||
<div class="login-box">
|
||||
<div class="login-brand">
|
||||
<span class="logo">NY</span>
|
||||
<div class="brand-text">
|
||||
<strong>NY问卷考试系统数据平台</strong>
|
||||
<small>卷王问卷系统 · 实时数据统计</small>
|
||||
</div>
|
||||
</div>
|
||||
<p class="login-tip">请输入卷王系统连接信息,完成连接测试后登录。密码需每次输入,避免依赖一次配置直接查看数据。</p>
|
||||
<div class="fi"><label>系统网址</label><input type="text" id="lgBase" placeholder="https://exam.nxxy335.top"></div>
|
||||
<div class="fi"><label>API 路由</label><input type="text" id="lgPrefix" placeholder="/api"></div>
|
||||
<div class="fi"><label>账号</label><input type="text" id="lgUser" placeholder="admin"></div>
|
||||
<div class="fi"><label>密码</label><input type="password" id="lgPwd" placeholder="请输入密码" autocomplete="current-password"></div>
|
||||
<div class="login-actions">
|
||||
<button class="btn" id="lgTest">测试连接</button>
|
||||
<button class="btn btn-primary" id="lgLogin" disabled>登录</button>
|
||||
</div>
|
||||
<div class="login-msg" id="lgMsg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="wrap">
|
||||
|
||||
<section class="toolbar">
|
||||
<div class="field grow">
|
||||
<label>问卷 / 考试</label>
|
||||
<select id="projectSel"><option value="">加载中…</option></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>姓名字段</label>
|
||||
<select id="nameFieldSel"><option value="">自动识别</option></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>班级</label>
|
||||
<select id="clsSel"><option value="">全部班级</option></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>自动刷新</label>
|
||||
<div class="inline">
|
||||
<label class="switch"><input type="checkbox" id="autoRefresh" checked><span></span></label>
|
||||
<select id="intervalSel">
|
||||
<option value="5">5 秒</option>
|
||||
<option value="10">10 秒</option>
|
||||
<option value="15" selected>15 秒</option>
|
||||
<option value="30">30 秒</option>
|
||||
<option value="60">60 秒</option>
|
||||
<option value="120">2 分钟</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field btns">
|
||||
<button class="btn btn-primary" id="btnRefresh">立即刷新</button>
|
||||
</div>
|
||||
<div class="field grow-2">
|
||||
<label>搜索</label>
|
||||
<input type="text" id="kw" placeholder="输入学号或姓名筛选">
|
||||
</div>
|
||||
<div class="field btns">
|
||||
<button class="btn" id="btnExport">导出成绩</button>
|
||||
<button class="btn" id="btnExportAbsent">导出缺考</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="alert" class="alert hidden"></div>
|
||||
|
||||
<section class="cards" id="statCards">
|
||||
<div class="card"><span>名单总人数</span><b id="sTotal">-</b></div>
|
||||
<div class="card ok"><span>已提交</span><b id="sSubmit">-</b></div>
|
||||
<div class="card warn"><span>缺考</span><b id="sAbsent">-</b></div>
|
||||
<div class="card"><span>提交率</span><b id="sRate">-</b></div>
|
||||
<div class="card"><span>平均分</span><b id="sAvg">-</b></div>
|
||||
<div class="card"><span>最高分</span><b id="sMax">-</b></div>
|
||||
<div class="card"><span>最低分</span><b id="sMin">-</b></div>
|
||||
<div class="card"><span>合格率(≥60%满分)</span><b id="sPass">-</b></div>
|
||||
<div class="card"><span>平均用时</span><b id="sDur">-</b></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>成绩明细 <small id="tableHint"></small></h2>
|
||||
<div class="updated">最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i></div>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="grid" id="dataTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" data-k="sid">学号</th>
|
||||
<th class="sortable" data-k="name">姓名</th>
|
||||
<th class="sortable" data-k="cls">班级</th>
|
||||
<th class="sortable" data-k="score">成绩</th>
|
||||
<th class="sortable" data-k="submit_ts">提交时间</th>
|
||||
<th class="sortable" data-k="duration">考试时长</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"><tr><td colspan="6" class="empty">请选择问卷 / 考试</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid-2">
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h2>缺考名单 <small id="absentHint"></small></h2></div>
|
||||
<div class="table-scroll short">
|
||||
<table class="grid compact">
|
||||
<thead><tr><th>学号</th><th>姓名</th><th>班级</th></tr></thead>
|
||||
<tbody id="absentBody"><tr><td colspan="3" class="empty">—</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h2>未匹配答卷 <small>姓名不在名单中</small></h2></div>
|
||||
<div class="table-scroll short">
|
||||
<table class="grid compact">
|
||||
<thead><tr><th>姓名</th><th>成绩</th><th>提交时间</th></tr></thead>
|
||||
<tbody id="unmatchedBody"><tr><td colspan="3" class="empty">—</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ================= 设置 ================= -->
|
||||
<div class="modal" id="settingsModal">
|
||||
<div class="modal-box">
|
||||
<div class="modal-head"><h3>系统设置</h3><button class="x" data-close>×</button></div>
|
||||
<div class="modal-body">
|
||||
<div class="form-grid">
|
||||
<div class="fi full">
|
||||
<label>卷王系统地址</label>
|
||||
<input type="text" id="cfgBase" placeholder="http://127.0.0.1:1991">
|
||||
<p class="hint">例如 http://192.168.1.10:1991,结尾不要带 /</p>
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>接口前缀</label>
|
||||
<input type="text" id="cfgPrefix" placeholder="/api">
|
||||
<p class="hint">社区版默认 /api(企业版可填 /admin-api)</p>
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>每页拉取条数</label>
|
||||
<input type="number" id="cfgPageSize" min="20" max="1000">
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>登录账号</label>
|
||||
<input type="text" id="cfgUser">
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>登录密码</label>
|
||||
<input type="password" id="cfgPwd" placeholder="留空表示不修改">
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>默认刷新间隔(秒)</label>
|
||||
<input type="number" id="cfgInterval" min="3" max="600">
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>请求超时(秒)</label>
|
||||
<input type="number" id="cfgTimeout" min="3" max="120">
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>满分(用于合格率)</label>
|
||||
<input type="number" id="cfgFull" min="1" max="1000">
|
||||
<p class="hint" id="cfgFullHint">未选择试卷时,作为所有试卷的默认值</p>
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>姓名题关键词</label>
|
||||
<input type="text" id="cfgKeywords">
|
||||
<p class="hint">英文逗号分隔,用于自动识别"姓名"题目</p>
|
||||
</div>
|
||||
<div class="fi full checks">
|
||||
<label><input type="checkbox" id="cfgDedupe"> 同一学号只保留最新一份答卷</label>
|
||||
<label><input type="checkbox" id="cfgTemp"> 包含"暂存"未提交的答卷</label>
|
||||
<label><input type="checkbox" id="cfgSsl"> 校验 HTTPS 证书</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pingResult" class="ping-result hidden"></div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn" id="btnPing">测试连接</button>
|
||||
<button class="btn" id="btnDiag">详细诊断</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn" data-close>取消</button>
|
||||
<button class="btn btn-primary" id="btnSaveCfg">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= 名单管理 ================= -->
|
||||
<div class="modal" id="rosterModal">
|
||||
<div class="modal-box wide">
|
||||
<div class="modal-head"><h3>学号名单管理</h3><button class="x" data-close>×</button></div>
|
||||
<div class="modal-body">
|
||||
<div class="upload-zone" id="dropZone">
|
||||
<input type="file" id="rosterFile" accept=".xlsx,.xlsm,.csv,.txt" hidden>
|
||||
<div class="uz-inner">
|
||||
<strong>点击或拖拽文件到此上传</strong>
|
||||
<span>支持 .xlsx / .csv,需包含「学号」「姓名」两列(「班级」可选)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="roster-bar">
|
||||
<div class="inline">
|
||||
<label class="chk"><input type="radio" name="impMode" value="replace" checked> 覆盖</label>
|
||||
<label class="chk"><input type="radio" name="impMode" value="append"> 追加</label>
|
||||
</div>
|
||||
<input type="text" id="rosterSearch" placeholder="搜索学号 / 姓名">
|
||||
<span class="spacer"></span>
|
||||
<button class="btn sm" id="btnAddRow">+ 新增</button>
|
||||
<button class="btn sm danger" id="btnClearRoster">清空</button>
|
||||
<button class="btn sm btn-primary" id="btnSaveRoster">保存名单</button>
|
||||
</div>
|
||||
<div class="table-scroll mid">
|
||||
<table class="grid compact" id="rosterTable">
|
||||
<thead><tr><th style="width:32%">学号</th><th style="width:26%">姓名</th><th>班级</th><th style="width:70px">操作</th></tr></thead>
|
||||
<tbody id="rosterBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint" id="rosterMeta">共 0 人</p>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<span class="spacer"></span>
|
||||
<button class="btn" data-close>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.NY = {
|
||||
appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>,
|
||||
version: <?= json_encode(NY_VERSION, JSON_UNESCAPED_UNICODE) ?>,
|
||||
interval: <?= (int)$cfg['refresh_interval'] ?>
|
||||
};
|
||||
</script>
|
||||
<script src="assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
/**
|
||||
* 数据组装:卷王答卷 -> 学号 / 姓名 / 成绩 / 提交时间 / 考试时长
|
||||
*/
|
||||
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<int,array{id:string,title:string,type:string,path:string}>
|
||||
*/
|
||||
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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
/**
|
||||
* 学号名单管理
|
||||
* 存储:data/roster.json -> {"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<int,array{sid:string,name:string,cls:string}> */
|
||||
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<int,array{sid:string,name:string,cls:string}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
<?php
|
||||
/**
|
||||
* 卷王问卷系统(SurveyKing)社区版 开放 API 客户端
|
||||
*
|
||||
* 社区版 v1.13 认证流程:
|
||||
* 1) GET {base}{prefix}/system -> 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<int,string> */
|
||||
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<string,string>,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<int,array{step:string,ok:bool,detail:string}>}
|
||||
*/
|
||||
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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
/**
|
||||
* 极简 XLSX 读取器(无第三方依赖)
|
||||
* 优先使用 ZipArchive,缺失时回退到纯 PHP 的 ZIP 解析(支持 stored / deflate)。
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
class XlsxReader
|
||||
{
|
||||
/**
|
||||
* @return array<int, array<int, string>> 二维数组(行 => 列)
|
||||
*/
|
||||
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('#<si>(.*?)</si>#s', $xml, $m) > 0) {
|
||||
foreach ($m[1] as $si) {
|
||||
if (preg_match_all('#<t[^>]*>(.*?)</t>#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('#<row\b[^>]*>(.*?)</row>#s', $xml, $rm) === 0) {
|
||||
return $rows;
|
||||
}
|
||||
foreach ($rm[1] as $rowXml) {
|
||||
$cells = [];
|
||||
if (preg_match_all('#<c(?:\s([^>]*?))?(?:/>|>(.*?)</c>)#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('#<t[^>]*>(.*?)</t>#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('#<v>(.*?)</v>#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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const engine = require('php-parser');
|
||||
|
||||
const parser = new engine({
|
||||
parser: { suppressErrors: true, version: 803 },
|
||||
ast: { withPositions: true },
|
||||
});
|
||||
|
||||
const files = process.argv.slice(2);
|
||||
let bad = 0;
|
||||
for (const f of files) {
|
||||
const code = fs.readFileSync(f, 'utf8');
|
||||
try {
|
||||
const ast = parser.parseCode(code, f);
|
||||
const errs = (ast.errors || []).filter(e => e && e.message);
|
||||
if (errs.length) {
|
||||
bad++;
|
||||
console.log('[SYNTAX] ' + f);
|
||||
errs.slice(0, 5).forEach(e => console.log(' line ' + (e.line || '?') + ': ' + e.message));
|
||||
} else {
|
||||
console.log('[OK] ' + path.basename(f));
|
||||
}
|
||||
} catch (e) {
|
||||
bad++;
|
||||
console.log('[FAIL] ' + f + ' :: ' + e.message);
|
||||
}
|
||||
}
|
||||
process.exit(bad ? 1 : 0);
|
||||
@@ -0,0 +1,138 @@
|
||||
// 模拟卷王(SurveyKing)社区版 API,用于端到端测试
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 1024,
|
||||
publicKeyEncoding: { type: 'spki', format: 'der' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'der' },
|
||||
});
|
||||
const PUB_B64 = Buffer.from(publicKey).toString('base64');
|
||||
|
||||
const PASSWORD = 'surveyking';
|
||||
let TOKEN = '';
|
||||
|
||||
const roster = JSON.parse(fs.readFileSync(require('path').join(__dirname, '..', 'data', 'roster.json'), 'utf8')).students;
|
||||
|
||||
// 构造答卷:前 30 人提交,其中 2 人姓名有空格/错别字
|
||||
const answers = roster.slice(0, 30).map((s, i) => {
|
||||
let name = s.name;
|
||||
if (i === 3) name = ' ' + s.name + ' '; // 带空格
|
||||
if (i === 7) name = s.name; // 正常
|
||||
const start = Date.parse('2026-09-08T09:00:00+08:00') + i * 1000;
|
||||
const end = start + (600 + i * 37) * 1000;
|
||||
return {
|
||||
id: 'a' + (i + 1),
|
||||
projectId: 'p1',
|
||||
answer: { q1: name, q2: '高一(1)班', q3: 'B' },
|
||||
examScore: [100, 92, 88, 76, 61, 59, 95, 83, 72, 68][i % 10] + (i % 3),
|
||||
tempSave: i === 5 ? 0 : 1,
|
||||
metaInfo: { answerInfo: { startTime: start, endTime: end }, clientInfo: { browser: 'Chrome' } },
|
||||
createAt: new Date(end).toISOString().replace('T', ' ').slice(0, 19),
|
||||
createByName: '',
|
||||
};
|
||||
});
|
||||
// 一个不在名单里的答卷
|
||||
answers.push({
|
||||
id: 'ax', projectId: 'p1', answer: { q1: '路过的同学', q2: '其他', q3: 'A' },
|
||||
examScore: 55, tempSave: 1,
|
||||
metaInfo: { answerInfo: { startTime: Date.parse('2026-09-08T09:30:00+08:00'), endTime: Date.parse('2026-09-08T09:41:20+08:00') } },
|
||||
createAt: '2026-09-08 09:41:20', createByName: '',
|
||||
});
|
||||
|
||||
const SURVEY = {
|
||||
id: 'root', title: '高一数学月考', type: 'survey',
|
||||
children: [
|
||||
{ id: 'pg1', type: 'pagination', title: '基本信息', children: [
|
||||
{ id: 'q1', type: 'text', title: '<p>姓名</p>' },
|
||||
{ id: 'q2', type: 'text', title: '班级' },
|
||||
]},
|
||||
{ id: 'pg2', type: 'pagination', title: '试题', children: [
|
||||
{ id: 'q3', type: 'radio', title: '1+1=?', children: [{ id: 'o1', title: 'A' }, { id: 'o2', title: 'B' }] },
|
||||
]},
|
||||
],
|
||||
};
|
||||
|
||||
const PROJECTS = [
|
||||
{ id: 'p1', name: '高一数学月考', mode: 'exam', status: 1, total: answers.length },
|
||||
{ id: 'p2', name: '课后满意度调查', mode: 'survey', status: 1, total: 5 },
|
||||
];
|
||||
|
||||
function send(res, code, obj, headers) {
|
||||
res.writeHead(code, Object.assign({ 'Content-Type': 'application/json; charset=utf-8' }, headers || {}));
|
||||
res.end(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const u = new URL(req.url, 'http://x');
|
||||
const p = u.pathname;
|
||||
let body = '';
|
||||
req.on('data', c => body += c);
|
||||
req.on('end', () => {
|
||||
// 鉴权:模拟社区版 v1.13 —— 只读 sk-token Cookie,不读 Authorization 头
|
||||
if (p.startsWith('/api/') && !p.startsWith('/api/public/') && p !== '/api/system') {
|
||||
const jar = {};
|
||||
(req.headers.cookie || '').split(';').forEach(kv => {
|
||||
const i = kv.indexOf('=');
|
||||
if (i > 0) jar[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
|
||||
});
|
||||
if (!jar['sk-token'] || jar['sk-token'] !== TOKEN) {
|
||||
return send(res, 200, {
|
||||
code: 401, data: null,
|
||||
msg: 'Full authentication is required to access this resource'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (p === '/api/system') {
|
||||
return send(res, 200, { code: 0, data: { name: '卷王', version: '1.13.0', publicKey: PUB_B64 } });
|
||||
}
|
||||
|
||||
if (p === '/api/public/login') {
|
||||
let j = {};
|
||||
try { j = JSON.parse(body); } catch (e) {}
|
||||
let pwd = j.password;
|
||||
let plain = pwd;
|
||||
try {
|
||||
plain = crypto.privateDecrypt(
|
||||
{ key: privateKey, format: 'der', type: 'pkcs8', padding: crypto.constants.RSA_PKCS1_PADDING },
|
||||
Buffer.from(pwd, 'base64')
|
||||
).toString('utf8');
|
||||
} catch (e) { plain = '<<解密失败>>'; }
|
||||
if (j.username !== 'admin' || plain !== PASSWORD) {
|
||||
return send(res, 200, { code: 500, data: null, msg: '账号或密码错误(收到明文=' + plain + ')' });
|
||||
}
|
||||
TOKEN = 'mock.jwt.token.' + Date.now();
|
||||
// 真实 v1.13:Authorization 头和 Set-Cookie 都下发,但服务端鉴权只读 Cookie
|
||||
return send(res, 200, { code: 0, data: { userId: '1' } }, {
|
||||
'Set-Cookie': 'sk-token=' + TOKEN + '; Path=/; HttpOnly; SameSite=Lax',
|
||||
Authorization: TOKEN,
|
||||
});
|
||||
}
|
||||
|
||||
if (p === '/api/project/list') {
|
||||
return send(res, 200, { code: 0, data: { total: PROJECTS.length, list: PROJECTS } });
|
||||
}
|
||||
if (p === '/api/project') {
|
||||
const id = u.searchParams.get('id');
|
||||
const proj = PROJECTS.find(x => x.id === id);
|
||||
if (!proj) return send(res, 200, { code: 404, data: null, msg: 'not found' });
|
||||
return send(res, 200, { code: 0, data: Object.assign({}, proj, { survey: id === 'p1' ? SURVEY : null }) });
|
||||
}
|
||||
if (p === '/api/answer/list') {
|
||||
const pid = u.searchParams.get('projectId');
|
||||
const cur = parseInt(u.searchParams.get('current') || '1', 10);
|
||||
const size = parseInt(u.searchParams.get('pageSize') || '200', 10);
|
||||
const all = pid === 'p1' ? answers : [];
|
||||
const slice = all.slice((cur - 1) * size, cur * size);
|
||||
return send(res, 200, { code: 0, data: { total: all.length, list: slice } });
|
||||
}
|
||||
|
||||
return send(res, 200, { code: 404, data: null, msg: 'no route ' + p });
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(19911, '127.0.0.1', () => {
|
||||
console.log('mock 卷王已启动 http://127.0.0.1:19911 密码=' + PASSWORD);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// 用 JS 复现 XlsxReader 的 ZIP/XML 解析逻辑,验证对真实 导入模板.xlsx 的处理
|
||||
const fs = require('fs');
|
||||
|
||||
function u16(d, o) { const s = d.subarray(o, o + 2); return s.length < 2 ? 0 : s.readUInt16LE(0); }
|
||||
function u32(d, o) { const s = d.subarray(o, o + 4); return s.length < 4 ? 0 : s.readUInt32LE(0); }
|
||||
|
||||
function parseZipRaw(data) {
|
||||
let eocd = -1;
|
||||
for (let i = data.length - 22; i >= 0; i--) {
|
||||
if (data[i] === 0x50 && data[i + 1] === 0x4b && data[i + 2] === 0x05 && data[i + 3] === 0x06) { eocd = i; break; }
|
||||
}
|
||||
if (eocd < 0) throw new Error('no EOCD');
|
||||
const count = u16(data, eocd + 10);
|
||||
const offset = u32(data, eocd + 16);
|
||||
const out = {};
|
||||
let p = offset;
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (!(data[p] === 0x50 && data[p + 1] === 0x4b && data[p + 2] === 0x01 && data[p + 3] === 0x02)) break;
|
||||
const method = u16(data, p + 10);
|
||||
const compSize = u32(data, p + 20);
|
||||
const nameLen = u16(data, p + 28);
|
||||
const extraLen = u16(data, p + 30);
|
||||
const commentLen = u16(data, p + 32);
|
||||
const localOff = u32(data, p + 42);
|
||||
const name = data.subarray(p + 46, p + 46 + nameLen).toString('binary');
|
||||
const lNameLen = u16(data, localOff + 26);
|
||||
const lExtraLen = u16(data, localOff + 28);
|
||||
const start = localOff + 30 + lNameLen + lExtraLen;
|
||||
const raw = data.subarray(start, start + compSize);
|
||||
let content;
|
||||
if (method === 0) content = raw;
|
||||
else content = require('zlib').inflateRawSync(raw);
|
||||
out[name.replace(/\\/g, '/')] = content.toString('utf8');
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function decodeEntities(s) {
|
||||
return s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
.replace(/'/g, "'").replace(/'/g, "'").replace(/&/g, '&');
|
||||
}
|
||||
|
||||
function parseSharedStrings(xml) {
|
||||
const out = [];
|
||||
const re = /<si>([\s\S]*?)<\/si>/g;
|
||||
let m;
|
||||
while ((m = re.exec(xml))) {
|
||||
const si = m[1];
|
||||
const tre = /<t[^>]*>([\s\S]*?)<\/t>/g;
|
||||
let t, parts = [];
|
||||
while ((t = tre.exec(si))) parts.push(decodeEntities(t[1]));
|
||||
out.push(parts.join(''));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function colToIndex(letters) {
|
||||
let n = 0;
|
||||
for (let i = 0; i < letters.length; i++) n = n * 26 + (letters.charCodeAt(i) - 64);
|
||||
return n - 1;
|
||||
}
|
||||
|
||||
function parseSheet(xml, shared) {
|
||||
const rows = [];
|
||||
const rowRe = /<row\b[^>]*>([\s\S]*?)<\/row>/g;
|
||||
let rm;
|
||||
while ((rm = rowRe.exec(xml))) {
|
||||
const rowXml = rm[1];
|
||||
const cells = {};
|
||||
const cRe = /<c(?:\s([^>]*?))?(?:\/>|>([\s\S]*?)<\/c>)/g;
|
||||
let cm, autoIdx = 0;
|
||||
while ((cm = cRe.exec(rowXml))) {
|
||||
const attrs = cm[1] || '';
|
||||
const inner = cm[2] || '';
|
||||
let idx = autoIdx;
|
||||
const r = /\br="([A-Z]+)\d*"/.exec(attrs);
|
||||
if (r) idx = colToIndex(r[1]);
|
||||
autoIdx = idx + 1;
|
||||
const tm = /\bt="([^"]+)"/.exec(attrs);
|
||||
const type = tm ? tm[1] : '';
|
||||
let val = '';
|
||||
if (type === 'inlineStr') {
|
||||
const tre = /<t[^>]*>([\s\S]*?)<\/t>/g;
|
||||
let t, parts = [];
|
||||
while ((t = tre.exec(inner))) parts.push(decodeEntities(t[1]));
|
||||
val = parts.join('');
|
||||
} else {
|
||||
const vm = /<v>([\s\S]*?)<\/v>/.exec(inner);
|
||||
val = vm ? decodeEntities(vm[1]) : '';
|
||||
if (type === 's') val = shared[parseInt(val, 10)] || '';
|
||||
else if (type === 'b') val = parseInt(val, 10) === 1 ? 'TRUE' : 'FALSE';
|
||||
}
|
||||
cells[idx] = val;
|
||||
}
|
||||
const keys = Object.keys(cells).map(Number);
|
||||
if (!keys.length) continue;
|
||||
const max = Math.max(...keys);
|
||||
const line = [];
|
||||
for (let i = 0; i <= max; i++) line.push(cells[i] || '');
|
||||
rows.push(line);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const file = process.argv[2];
|
||||
const entries = parseZipRaw(fs.readFileSync(file));
|
||||
console.log('entries:', Object.keys(entries).join(', '));
|
||||
const shared = entries['xl/sharedStrings.xml'] ? parseSharedStrings(entries['xl/sharedStrings.xml']) : [];
|
||||
const sheet = entries['xl/worksheets/sheet1.xml'] || entries[Object.keys(entries).find(k => /^xl\/worksheets\/sheet\d+\.xml$/.test(k))];
|
||||
const rows = parseSheet(sheet, shared);
|
||||
console.log('rows:', rows.length);
|
||||
console.log('first 3:', JSON.stringify(rows.slice(0, 3), null, 0));
|
||||
console.log('last 2:', JSON.stringify(rows.slice(-2), null, 0));
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user