Compare commits

..
5 Commits
Author SHA1 Message Date
nxxy335 0fd880624f feat: 完善/优化/新增大批次 + 安全加固 + 性能优化
- 修复再次提交不置顶(排序键改用 last_submit_ts)
- 后端结果缓存 + 并发锁(DataCache / ny_build_data / Roster 指纹失效)
- 导出 xlsx 多表:按最新成绩 / 按班级分表 / 可选列
- 名单管理:新增改弹窗、删除多选+二次确认、登陆账号列
- 安全:CSRF token + 登录失败限流(5次锁10分钟)
- 未匹配答卷一键加入名单(unmatched_map)
- 缺考名单增强:复制学号/姓名、按班统计、打印
- 成绩快照 + 跨卷趋势(Snapshot)
- 异常检测线索(短时长高分/高频重考/雷同成绩)
- 前端性能:虚拟滚动、搜索防抖、后台暂停、失败退避
- 测试沉淀:test-new-features.php + run-all.sh
2026-09-10 17:59:45 +08:00
nxxy335 87750b20c4 fix: 修复拉取大量答卷时内存耗尽(128M)导致后台刷新失败
- SurveyKingClient::listAnswers/listProjects 单页上限夹在 20~200,
  无论配置 page_size 多大,单次 json_decode 体量都被限制,避免超大响应撑爆 128M
- raw() 不再随返回值常驻完整 HTTP 正文(仅留前 2000 字符用于诊断),
  降低 json_decode 之外的额外内存占用;每页处理后及时释放引用
- bootstrap.php 在未被 php_admin_value 硬锁时临时将 memory_limit 放宽到 512M
  作为安全兜底(触顶场景:/api.php?action=data 拉取卷王全量答卷)

根因:error.log 中 PHP Fatal error Allowed memory size of 134217728 bytes exhausted
位于 SurveyKingClient.php:180 json_decode(),由上传名单后答卷量增大、
单页响应整体解码超出内存上限引发。
2026-09-10 15:45:39 +08:00
nxxy335 a65ec682ce fix: 修复点击设置报 Cannot set properties of null (setting 'textContent')
根因:#fullScoresHint 是 #fullScoresBox 的子元素,而 renderFullScores()
用 box.innerHTML 渲染试卷列表时会把它销毁,紧接着的
$('#fullScoresHint').textContent = ... 即变成 null.textContent 抛错。

只要 state.projects 非空(连上卷王、有可答卷)点设置必触发;
projects 为空时走 early return 反而不报错,故此前长期潜伏未被发现
(自 8686eb9 设置重构引入)。

修复:
- index.php:把 #fullScoresHint 移出 #fullScoresBox,改为兄弟元素,
  并加注释说明为何不能放回去
- assets/app.js:新增 setFullScoresHint() 带 null 守卫,三个分支统一走它;
  即使将来 DOM 结构再变动也不会让整个设置弹窗打不开
2026-09-10 12:50:39 +08:00
nxxy335 6def49b969 fix: 全量提示改为顶部 toast 飘窗 + 修复卷王 token 失效跳转死循环 + 调整行点击防御
- 所有 showAlert 改为 showToast 顶部飘窗(首屏 / 抽取页统一,含空消息守卫)
- 导出排序新增 成绩升序 / 成绩降序 两种(前后端 + 设置弹窗都支持)
- 设置保存:仅连接信息真变化才 reload projects,避免非连接改动导致持续刷新失败
- 强化历史成绩弹窗排序与兜底(缺 submit_ts 时按 idx 倒序)
- 行点击事件加防御:要求父节点是 #tbody,避免被无关 tr 误触发
- 卷王 token 失效时:先 fetch logout 清会话再跳 login.php,登录页也加 token 探测兜底,根治"跳不过去"死循环
- 移除 index.php / draw.php 中已无用的内联 #alert DOM
- 兼容 config_get 缺 export_sort 时回退 time 的 4 模式归一化
2026-09-10 12:30:00 +08:00
nxxy335 137e2cb665 fix: 更新 dedupe 提示文字(修复后考试次数始终完整展示)
- 设置弹窗「只保留最新一份」的说明文字过时:仍写'考试次数恒为 1、
  历史只有一条、需取消勾选',但 aggregateByStudent 已修复为无论 dedupe
  取值都保留真实次数与全部历史。更新为准确描述当前行为。
- 全面审查未发现其它代码级 bug:表头/表体列序一致(学号/姓名/班级/
  组别/成绩/考试次数/提交时间/时长)、attempts 列确实在成绩后面;
  dashboard 看板成员榜的'刷N次'标签在名字旁是紧凑三列布局的刻意设计。
2026-09-10 11:21:32 +08:00
17 changed files with 1979 additions and 221 deletions
+351 -46
View File
@@ -13,12 +13,128 @@ function cfg(): array
return ny_load_config(); return ny_load_config();
} }
/**
* 按成绩排序(null 成绩恒排末尾,同分按提交时间降序)
* @param bool $desc true=降序
*/
function ny_sort_by_score(array $rows, bool $desc): array
{
$dir = $desc ? -1 : 1;
usort($rows, static function (array $x, array $y) use ($dir): int {
$xn = ($x['score'] ?? null) === null;
$yn = ($y['score'] ?? null) === null;
if ($xn && $yn) {
return 0;
}
if ($xn) {
return 1;
}
if ($yn) {
return -1;
}
$d = ((float)$x['score'] <=> (float)$y['score']) * $dir;
if ($d !== 0) {
return $d;
}
return ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0));
});
return $rows;
}
/** 缺考名单工作表行(按学号升序) */
function ny_absent_sheet_rows(array $absent): array
{
usort($absent, static function ($x, $y): int {
$a = trim((string)($x['sid'] ?? ''));
$b = trim((string)($y['sid'] ?? ''));
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
});
$out = [['学号', '姓名', '班级', '组别']];
foreach ($absent as $r) {
$out[] = [
(string)($r['sid'] ?? ''),
(string)($r['name'] ?? ''),
(string)($r['cls'] ?? ''),
(string)($r['grp'] ?? ''),
];
}
return $out;
}
/**
* 带短期缓存的数据构建(含并发锁,避免多人同时刷新时重复打卷王造成惊群)
*
* 缓存 key 由「项目 + 姓名字段 + 连接/去重等配置 + 名单指纹」组成:
* 名单一变指纹就变,因此不会出现"改了名单还显示旧数据"。
* force=true 时跳过缓存(前端"立即刷新"按钮使用)。
*
* @return array{result:array,cached:bool,at:int}
*/
function ny_build_data(array $c, string $projectId, string $nameQid, bool $force = false): array
{
$ttl = (int)($c['cache_ttl'] ?? 15);
if ($ttl < 0) {
$ttl = 0;
}
$key = DataCache::key([
$projectId, $nameQid,
(string)$c['base_url'], (string)$c['api_prefix'], (string)$c['username'],
!empty($c['dedupe']) ? 1 : 0,
!empty($c['include_temp_save']) ? 1 : 0,
(string)($c['name_keywords'] ?? ''),
(int)($c['page_size'] ?? 100),
Roster::fingerprint(),
]);
if ($ttl > 0 && !$force) {
$hit = DataCache::read($key, $ttl);
if ($hit !== null) {
return ['result' => $hit['result'], 'cached' => true, 'at' => $hit['at']];
}
}
// 并发锁:同一 key 只允许一个请求真正去拉卷王
$lock = DataCache::acquireLock($key);
if ($lock === null) {
// 已有别的请求在构建:短暂等待其落盘后直接复用(允许略旧,总比同时压垮卷王好)
for ($i = 0; $i < 15; $i++) {
usleep(200000); // 200ms
$hit = DataCache::read($key, max($ttl, 60));
if ($hit !== null) {
return ['result' => $hit['result'], 'cached' => true, 'at' => $hit['at']];
}
}
// 等不到(构建很慢或锁不被支持)就自己构建,保证可用性
}
$result = DataService::build($c, $projectId, $nameQid);
if ($ttl > 0) {
DataCache::write($key, $result);
}
DataCache::releaseLock($lock);
return ['result' => $result, 'cached' => false, 'at' => time()];
}
// 会话保护:除 auth_check 外,未登录一律拒访 // 会话保护:除 auth_check 外,未登录一律拒访
if ($action !== 'auth_check' && !ny_is_auth()) { if ($action !== 'auth_check' && !ny_is_auth()) {
ny_json(['ok' => false, 'msg' => '未登录或会话已过期,请重新登录'], 401); ny_json(['ok' => false, 'msg' => '未登录或会话已过期,请重新登录'], 401);
exit; exit;
} }
// CSRF 防护:所有写操作必须携带会话绑定的 token(GET 读取类接口不受影响)
$WRITE_ACTIONS = [
'config_save', 'roster_save', 'roster_import', 'roster_clear',
'snapshot_save', 'snapshot_delete', 'unmatched_map',
];
if (in_array($action, $WRITE_ACTIONS, true) && !ny_csrf_ok()) {
ny_json(['ok' => false, 'msg' => '安全校验失败(CSRF token 缺失或不匹配),请刷新页面后重试'], 403);
exit;
}
try { try {
switch ($action) { switch ($action) {
// 登录状态检查 // 登录状态检查
@@ -30,6 +146,7 @@ try {
$c = cfg(); $c = cfg();
$c['has_password'] = ((string)$c['password']) !== ''; $c['has_password'] = ((string)$c['password']) !== '';
unset($c['password']); unset($c['password']);
$c['csrf_token'] = ny_csrf_token();
// 诊断信息:配置存在哪里、数据库通不通(登录卷王失败时优先看这里) // 诊断信息:配置存在哪里、数据库通不通(登录卷王失败时优先看这里)
$c['storage'] = ny_config_source(); $c['storage'] = ny_config_source();
$c['db_driver'] = class_exists('Db') ? Db::driver() : ''; $c['db_driver'] = class_exists('Db') ? Db::driver() : '';
@@ -72,9 +189,10 @@ try {
if (array_key_exists('history_display', $in)) { if (array_key_exists('history_display', $in)) {
$new['history_display'] = ($in['history_display'] === 'inline') ? 'inline' : 'click'; $new['history_display'] = ($in['history_display'] === 'inline') ? 'inline' : 'click';
} }
// 成绩导出排序:time(按提交时间降序,默认) / sid(按学号) // 成绩导出排序:time(按提交时间降序,默认)/ sid(按学号)/ score_desc(成绩降序)/ score_asc(成绩升序)
if (array_key_exists('export_sort', $in)) { if (array_key_exists('export_sort', $in)) {
$new['export_sort'] = ($in['export_sort'] === 'sid') ? 'sid' : 'time'; $v = (string)$in['export_sort'];
$new['export_sort'] = in_array($v, ['time','sid','score_desc','score_asc'], true) ? $v : 'time';
} }
// 各试卷独立满分(覆盖整个 full_scores 映射) // 各试卷独立满分(覆盖整个 full_scores 映射)
if (array_key_exists('full_scores', $in) && is_array($in['full_scores'])) { if (array_key_exists('full_scores', $in) && is_array($in['full_scores'])) {
@@ -209,7 +327,8 @@ try {
} }
$nameQid = (string)($_GET['nameQid'] ?? ''); $nameQid = (string)($_GET['nameQid'] ?? '');
$c = cfg(); $c = cfg();
$result = DataService::build($c, $projectId, $nameQid); $built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
$result = $built['result'];
$fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : []; $fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : [];
$fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100); $fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100);
if ($fs < 1) { if ($fs < 1) {
@@ -220,6 +339,8 @@ try {
'base_url' => $c['base_url'], 'base_url' => $c['base_url'],
'full_score' => $fs, 'full_score' => $fs,
]; ];
// 缓存来源标记:便于前端/排查时知道这次是实时拉取还是命中短期缓存
$result['_cache'] = ['cached' => $built['cached'], 'at' => $built['at']];
ny_ok($result); ny_ok($result);
// ---------------- 数据看板(按班级聚合,面向班主任/学生整体情况) ---------------- // ---------------- 数据看板(按班级聚合,面向班主任/学生整体情况) ----------------
@@ -230,7 +351,8 @@ try {
} }
$nameQid = (string)($_GET['nameQid'] ?? ''); $nameQid = (string)($_GET['nameQid'] ?? '');
$c = cfg(); $c = cfg();
$result = DataService::build($c, $projectId, $nameQid); $built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
$result = $built['result'];
$fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : []; $fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : [];
$fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100); $fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100);
if ($fs < 1) { if ($fs < 1) {
@@ -417,6 +539,99 @@ try {
Roster::clear(); Roster::clear();
ny_ok(['students' => [], 'meta' => Roster::meta()]); ny_ok(['students' => [], 'meta' => Roster::meta()]);
// ---------------- 未匹配答卷 → 加入名单 ----------------
case 'unmatched_map':
$raw = (string)file_get_contents('php://input');
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
}
$name = trim((string)($in['name'] ?? ''));
$sid = trim((string)($in['sid'] ?? ''));
if ($name === '' || $sid === '') {
ny_fail('姓名与学号必填');
}
$cls = trim((string)($in['cls'] ?? ''));
$grp = trim((string)($in['grp'] ?? ''));
$login = trim((string)($in['login'] ?? ''));
$roster = Roster::all();
// 若学号已存在则更新其姓名为该答卷姓名(映射到已有学生);否则新增一行
$found = false;
foreach ($roster as &$s) {
if (trim((string)($s['sid'] ?? '')) === $sid) {
$s['name'] = $name;
if ($cls !== '') { $s['cls'] = $cls; }
if ($grp !== '') { $s['grp'] = $grp; }
if ($login !== '') { $s['login'] = $login; }
$found = true;
break;
}
}
unset($s);
if (!$found) {
$roster[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login];
}
Roster::save($roster);
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta(), 'mapped' => $name]);
// ---------------- 成绩快照 / 趋势 ----------------
case 'snapshot_save':
$projectId = (string)($_GET['projectId'] ?? $_POST['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
$raw = (string)file_get_contents('php://input');
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
}
$label = trim((string)($in['label'] ?? ''));
// 复用缓存构建(不强制,快照本就是"当前看到的成绩")
$c = cfg();
$nameQid = (string)($_GET['nameQid'] ?? '');
$built = ny_build_data($c, $projectId, $nameQid);
$snap = Snapshot::summarize($built['result'], $label);
$file = Snapshot::save($projectId, $snap);
ny_ok(['file' => $file, 'label' => $snap['label'], 'at_txt' => $snap['at_txt'], 'count' => count($snap['students'])]);
case 'snapshot_list':
$projectId = (string)($_GET['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
ny_ok(['snapshots' => Snapshot::list($projectId)]);
case 'snapshot_delete':
$projectId = (string)($_GET['projectId'] ?? $_POST['projectId'] ?? '');
$raw = (string)file_get_contents('php://input');
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
}
$file = (string)($in['file'] ?? '');
if ($projectId === '' || $file === '') {
ny_fail('参数错误');
}
ny_ok(['deleted' => Snapshot::delete($projectId, $file)]);
case 'snapshot_trend':
$projectId = (string)($_GET['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
// 支持跨项目对比:projectId 可用逗号分隔多个项目,取各自快照混合趋势
$pids = array_values(array_filter(array_map('trim', explode(',', $projectId))));
$snaps = [];
foreach ($pids as $pid) {
foreach (Snapshot::list($pid) as $s) {
$full = Snapshot::get($pid, $s['file']);
if ($full !== null) {
$snaps[] = $full;
}
}
}
ny_ok(Snapshot::trend($snaps));
// ---------------- 导出(支持按班级 / 组别筛选,与页面当前筛选保持一致) ---------------- // ---------------- 导出(支持按班级 / 组别筛选,与页面当前筛选保持一致) ----------------
case 'export': case 'export':
case 'export_absent': case 'export_absent':
@@ -440,7 +655,9 @@ try {
}; };
$c = cfg(); $c = cfg();
$result = DataService::build($c, $projectId, $nameQid); // 复用与 data 接口相同的结果缓存:导出不必再全量拉一次卷王
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
$result = $built['result'];
$rows = array_values(array_filter((array)($result['rows'] ?? []), $hit)); $rows = array_values(array_filter((array)($result['rows'] ?? []), $hit));
@@ -472,57 +689,145 @@ try {
$tag = ($clsLabel !== '' ? '_' . $safe($clsLabel) : '') $tag = ($clsLabel !== '' ? '_' . $safe($clsLabel) : '')
. ($grpLabel !== '' ? '_' . $safe($grpLabel) : ''); . ($grpLabel !== '' ? '_' . $safe($grpLabel) : '');
$mode = ((string)($_GET['mode'] ?? '') === 'latest') ? 'latest' : 'all';
$splitCls = ((string)($_GET['split'] ?? '') === 'cls');
$isLatest = ($mode === 'latest');
$filename = ($action === 'export_absent' ? '缺考名单' : '成绩明细') $filename = ($action === 'export_absent' ? '缺考名单' : '成绩明细')
. '_' . $safe((string)$result['project']['name']) . '_' . $safe((string)$result['project']['name'])
. $tag . $tag
. '_' . date('Ymd_His') . '.csv'; . ($isLatest ? '_最新成绩' : '')
. '_' . date('Ymd_His') . '.xlsx';
header('Content-Type: text/csv; charset=utf-8'); // ---------------- 列定义(可通过 cols= 参数挑选) ----------------
header('Content-Disposition: attachment; filename="' . $filename . '"; filename*=UTF-8\'\'' . rawurlencode($filename)); $colLabels = [
header('Cache-Control: no-store'); 'sid' => '学号',
echo "\xEF\xBB\xBF"; 'name' => '姓名',
$out = fopen('php://output', 'w'); 'cls' => '班级',
'grp' => '组别',
'score' => $isLatest ? '最新成绩' : '成绩',
'best_score' => '最高分',
'attempts' => '考试次数',
'idx' => '第几次',
'submit_at' => $isLatest ? '最近提交时间' : '提交时间',
'duration_txt' => '考试时长',
'duration' => '考试时长(秒)',
];
$defaultCols = $isLatest
? ['sid', 'name', 'cls', 'grp', 'score', 'best_score', 'attempts', 'submit_at', 'duration_txt', 'duration']
: ['sid', 'name', 'cls', 'grp', 'score', 'attempts', 'idx', 'submit_at', 'duration_txt', 'duration'];
$want = array_values(array_filter(
array_map('trim', explode(',', (string)($_GET['cols'] ?? ''))),
static fn($v): bool => $v !== ''
));
$cols = $want !== [] ? array_values(array_intersect($defaultCols, $want)) : $defaultCols;
if ($cols === []) {
$cols = $defaultCols;
}
if ($action === 'export_absent') { // ---------------- 数据行 ----------------
// 与页面缺考名单一致:按学号升序(纯数字按数值,否则自然排序) $sortMode = (string)($_GET['sort'] ?? '');
usort($absent, static function ($x, $y): int { if ($isLatest) {
$a = trim((string)($x['sid'] ?? '')); // 每人一行:成绩取「最近一次提交」的分数(不受 dedupe 影响),并附最高分与考试次数
$b = trim((string)($y['sid'] ?? '')); $all = [];
if (is_numeric($a) && is_numeric($b)) { foreach ($rows as $r) {
return $a <=> $b; $list = array_values((array)($r['attempts_list'] ?? []));
} $last = $list !== [] ? $list[count($list) - 1] : null;
return strnatcmp($a, $b); $all[] = [
}); 'sid' => (string)($r['sid'] ?? ''),
fputcsv($out, ['学号', '姓名', '班级', '组别']); 'name' => (string)($r['name'] ?? ''),
foreach ($absent as $r) { 'cls' => (string)($r['cls'] ?? ''),
fputcsv($out, [$r['sid'] ?? '', $r['name'] ?? '', $r['cls'] ?? '', $r['grp'] ?? '']); 'grp' => (string)($r['grp'] ?? ''),
'score' => $last !== null ? ($last['score'] ?? null) : ($r['score'] ?? null),
'best_score' => $r['best_score'] ?? null,
'attempts' => (int)($r['attempts'] ?? 1),
'idx' => (int)($r['attempts'] ?? 1),
'submit_ts' => (int)($r['last_submit_ts'] ?? 0),
'submit_at' => (string)($r['last_submit_at'] ?? ($last['submit_at'] ?? ($r['submit_at'] ?? '-'))),
'duration_txt' => (string)($last['duration_txt'] ?? ($r['duration_txt'] ?? '-')),
'duration' => $last['duration'] ?? ($r['duration'] ?? null),
];
}
if ($sortMode === 'score_desc' || $sortMode === 'score_asc') {
$all = ny_sort_by_score($all, $sortMode === 'score_desc');
} else {
$all = DataService::sortForExport($all, ($sortMode === 'sid') ? 'sid' : 'time');
} }
} else { } else {
// 导出全部考试记录(同一学号考了 N 次就有 N 行),并保留"考试次数" // 每次考试一行(同一学号考了 N 次就有 N 行)
$sortMode = (string)($_GET['sort'] ?? '');
$sortMode = ($sortMode === 'sid') ? 'sid' : 'time';
$all = DataService::expandAttempts($rows, true); $all = DataService::expandAttempts($rows, true);
if ($sortMode === 'score_desc' || $sortMode === 'score_asc') {
// time=按提交时间降序;sid=按学号(同学号内按提交时间降序) $all = ny_sort_by_score($all, $sortMode === 'score_desc');
$all = DataService::sortForExport($all, $sortMode); } else {
$all = DataService::sortForExport($all, ($sortMode === 'sid') ? 'sid' : 'time');
fputcsv($out, ['学号', '姓名', '班级', '组别', '成绩', '考试次数', '第几次', '提交时间', '考试时长', '考试时长(秒)']);
foreach ($all as $r) {
fputcsv($out, [
$r['sid'] ?? '',
$r['name'] ?? '',
$r['cls'] ?? '',
$r['grp'] ?? '',
$r['score'] === null ? '' : $r['score'],
(int)($r['attempts'] ?? 1),
(int)($r['idx'] ?? 1),
$r['submit_at'] ?? '',
$r['duration_txt'] ?? '',
$r['duration'] === null ? '' : $r['duration'],
]);
} }
} }
fclose($out);
// ---------------- 组装工作表 ----------------
$toRows = static function (array $list) use ($cols, $colLabels): array {
$head = [];
foreach ($cols as $c) {
$head[] = $colLabels[$c];
}
$out = [$head];
foreach ($list as $r) {
$line = [];
foreach ($cols as $c) {
$v = $r[$c] ?? null;
$line[] = ($v === null || $v === '') ? '' : (string)$v;
}
$out[] = $line;
}
return $out;
};
$widths = [];
foreach ($cols as $c) {
$widths[] = in_array($c, ['sid', 'submit_at'], true) ? 20 : ($c === 'name' ? 14 : 11);
}
$sheets = [];
if ($action === 'export_absent') {
$sheets[] = ['name' => '缺考名单', 'rows' => ny_absent_sheet_rows($absent), 'widths' => [16, 14, 16, 10]];
} else {
if ($splitCls) {
// 按班级分表(班级名即工作表名)
$byCls = [];
foreach ($all as $r) {
$byCls[trim((string)($r['cls'] ?? '')) ?: '(未分班)'][] = $r;
}
ksort($byCls, SORT_NATURAL | SORT_FLAG_CASE);
foreach ($byCls as $clsName => $list) {
$sheets[] = ['name' => (string)$clsName, 'rows' => $toRows($list), 'widths' => $widths];
}
} else {
$sheets[] = ['name' => $isLatest ? '成绩明细(最新)' : '成绩明细', 'rows' => $toRows($all), 'widths' => $widths];
}
// 附加:缺考名单 / 未匹配答卷(仅非空时输出)
if ($absent !== []) {
$sheets[] = ['name' => '缺考名单', 'rows' => ny_absent_sheet_rows($absent), 'widths' => [16, 14, 16, 10]];
}
$unmatched = array_values((array)($result['unmatched'] ?? []));
if ($unmatched !== []) {
$um = [['姓名', '成绩', '提交时间']];
foreach ($unmatched as $u) {
$um[] = [
(string)($u['name'] ?? ''),
($u['score'] ?? null) === null ? '' : (string)$u['score'],
(string)($u['submit_at'] ?? ''),
];
}
$sheets[] = ['name' => '未匹配答卷', 'rows' => $um, 'widths' => [16, 10, 20]];
}
}
// 说明:所有单元格按「共享字符串 + 文本格式」写入,
// 既不会把长学号变成科学计数法,也不会把 = + - @ 开头的内容当公式执行(规避 CSV 公式注入)
$bin = XlsxWriter::renderMulti($sheets);
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
header('Content-Disposition: attachment; filename="export.xlsx"; filename*=UTF-8\'\'' . rawurlencode($filename));
header('Content-Length: ' . strlen($bin));
header('Cache-Control: no-store');
echo $bin;
exit; exit;
// ---------------- 随机抽取:导出白名单(xlsx,列:姓名 / 登陆账号) ---------------- // ---------------- 随机抽取:导出白名单(xlsx,列:姓名 / 登陆账号) ----------------
+548 -77
View File
@@ -28,6 +28,9 @@
highlight: new Set(), // 出现过新提交/新数据的学号,绿色底纹 highlight: new Set(), // 出现过新提交/新数据的学号,绿色底纹
hlTimer: null, // 自动清除高亮的定时器 hlTimer: null, // 自动清除高亮的定时器
hlAutoClearSec: 30, // 新数据高亮每隔多少秒自动清除一次 hlAutoClearSec: 30, // 新数据高亮每隔多少秒自动清除一次
_sorted: null, // 虚拟滚动:最近一次排序后的完整行数组(缓存,避免重复排序)
_kwDebounce: null, // 搜索框防抖定时器
csrf: '', // CSRF token(来自 config_get),写请求自动附带
}; };
/* ---------------- 本地存储 ---------------- */ /* ---------------- 本地存储 ---------------- */
@@ -57,12 +60,19 @@
const init = { method: opts.method || 'GET' }; const init = { method: opts.method || 'GET' };
if (opts.body) { if (opts.body) {
init.headers = { 'Content-Type': 'application/json' }; init.headers = { 'Content-Type': 'application/json' };
// 写操作附带 CSRF token(在 body 内,后端 ny_csrf_ok 会从 JSON 里读取 _csrf)
if (state.csrf) opts.body._csrf = state.csrf;
init.body = JSON.stringify(opts.body); init.body = JSON.stringify(opts.body);
} }
if (opts.form) { if (opts.form) {
init.method = 'POST'; init.method = 'POST';
if (state.csrf) opts.form.append('_csrf', state.csrf);
init.body = opts.form; init.body = opts.form;
} }
if (opts.method === 'POST' && state.csrf) {
init.headers = init.headers || {};
init.headers['X-CSRF-Token'] = state.csrf;
}
let url = 'api.php?action=' + encodeURIComponent(action); let url = 'api.php?action=' + encodeURIComponent(action);
if (opts.params) { if (opts.params) {
for (const k in opts.params) { for (const k in opts.params) {
@@ -91,19 +101,51 @@
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
} }
/* ---------------- Toast 顶部飘窗通知(替代旧 inline alert) ---------------- */
let toastTimer = null;
const TOAST_DURATION = 3500; // 默认显示 3.5 秒
const TOAST_LIMIT = 5; // 最多同时展示 5 条(旧消息优先被挤掉)
function showToast(msg, type) {
if (msg === '' || msg == null) return; // 空消息不弹(showAlert('') 兼容旧调用 → 静默返回)
// type: ''=默认(蓝) | 'info'=绿 | 'error'=红 | 'warn'=橙
const el = document.getElementById('toastContainer') || (function () {
const c = document.createElement('div');
c.id = 'toastContainer';
c.style.cssText = 'position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none;max-width:calc(100vw - 32px);width:560px';
document.body.appendChild(c);
return c;
})();
// 限流:超过上限移除最旧
while (el.children.length >= TOAST_LIMIT) {
el.firstChild.remove();
}
const t = document.createElement('div');
const cls = type === 'error' ? 'toast-error' : (type === 'warn' ? 'toast-warn' : (type === 'info' ? 'toast-ok' : 'toast'));
t.className = 'toast ' + cls;
t.innerHTML = '<span>' + esc(msg).replace(/\n/g, '<br>') + '</span>';
t.style.cssText = 'pointer-events:auto;padding:10px 16px;border-radius:10px;font-size:13px;line-height:1.55;box-shadow:0 8px 30px rgba(15,23,42,.18);animation:toastIn .25s ease;white-space:pre-wrap;word-break:break-word';
el.appendChild(t);
// 点击关闭
t.addEventListener('click', () => { t.style.animation='toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); });
// 自动消失
setTimeout(() => {
if (t.parentNode) { t.style.animation='toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); }
}, TOAST_DURATION);
}
/** 兼容旧调用:showAlert 现在走 toast(type 映射) */
function showAlert(msg, type) {
if (msg === '' || msg == null) return; // 旧版 showAlert('') 用于"清空内联提示",toast 无需此操作
const t = type === 'info' ? 'ok' : (type === 'error' ? 'error' : '');
showToast(msg, t);
}
function setConn(cls, text) { function setConn(cls, text) {
const el = $('#conn'); const el = $('#conn');
el.className = 'conn ' + cls; el.className = 'conn ' + cls;
el.innerHTML = '<i></i><span>' + esc(text) + '</span>'; 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' : '');
}
// 导出提示:说明本次导出的筛选范围 // 导出提示:说明本次导出的筛选范围
function exportTip(what, cls, grp) { function exportTip(what, cls, grp) {
const parts = []; const parts = [];
@@ -217,6 +259,7 @@
state.absent = d.absent || []; state.absent = d.absent || [];
state.unmatched = d.unmatched || []; state.unmatched = d.unmatched || [];
state.roster = d.roster || []; state.roster = d.roster || [];
state.anomalies = d.anomalies || { fast_high: [], frequent: [], identical: [] };
state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100; state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100;
// 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据" // 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据"
@@ -256,16 +299,17 @@
renderHints(); renderHints();
$('#lastUpdate').textContent = d.updated_at || '—'; $('#lastUpdate').textContent = d.updated_at || '—';
state.projectName = (d.project && d.project.name) || '';
setConn('conn-on', (opts.fromCache ? '缓存 · ' : '') + '实时 · ' + (d.project && d.project.name || '')); setConn('conn-on', (opts.fromCache ? '缓存 · ' : '') + '实时 · ' + (d.project && d.project.name || ''));
if (!opts.fromCache) showAlert(''); if (!opts.fromCache) showAlert('');
} }
async function refresh(silent) { async function refresh(silent, force) {
const pid = $('#projectSel').value; const pid = $('#projectSel').value;
if (!pid) return; if (!pid) return;
if (state.loading && !silent) return; if (state.loading && !silent) return;
state.loading = true; state.loading = true;
if (!silent) setConn('conn-load', '拉取数据…'); if (!silent) setConn('conn-load', force ? '重新拉取数据…' : '拉取数据…');
// 1) 先读本地缓存 → 立即渲染,刷新期间保持视觉稳定 // 1) 先读本地缓存 → 立即渲染,刷新期间保持视觉稳定
const cached = LS.get(dataKey(pid), null); const cached = LS.get(dataKey(pid), null);
@@ -277,7 +321,9 @@
// 2) 后台异步拉真实数据 // 2) 后台异步拉真实数据
try { try {
const d = await api('data', { params: { projectId: pid, nameQid: $('#nameFieldSel').value } }); const params = { projectId: pid, nameQid: $('#nameFieldSel').value };
if (force) params.force = 1;
const d = await api('data', { params });
applyData(d); applyData(d);
state.connFail = 0; state.connFail = 0;
LS.set(dataKey(pid), d); LS.set(dataKey(pid), d);
@@ -287,9 +333,21 @@
LS.set(LS_ROSTER, { ts: Date.now(), students: d.roster || [] }); LS.set(LS_ROSTER, { ts: Date.now(), students: d.roster || [] });
} catch (e) { } catch (e) {
if (e.auth) { if (e.auth) {
// 卷王账号未配置或登录已失效:引导重新登录,避免卡在"已显示本地缓存"的死状态 // 卷王账号未配置或登录已失效:引导重新登录(强制清掉本地会话 + 跳登录页)
showAlert('卷王账号未配置或登录已失效,正在跳转登录页…', 'error'); // 关键:必须先调 logout.php 把服务端会话清空,否则 login.php 发现会话还在会把人踢回 index.php,形成"跳不过去"的死循环
setTimeout(() => { location.href = 'login.php'; }, 900); if (!state._authRedirecting) {
state._authRedirecting = true;
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
if (state.hlTimer) clearInterval(state.hlTimer);
showAlert('卷王账号未配置或登录已失效,正在跳转登录页…', 'error');
// 异步清会话,忽略失败;登录页端也有探测兜底(token 过期时主动销毁会话)
fetch('logout.php', { method: 'POST', credentials: 'same-origin' })
.catch(() => {})
.finally(() => {
// 200ms 足够让 toast 入场,又不至于让用户干等
setTimeout(() => { window.location.href = 'login.php'; }, 200);
});
}
return; return;
} }
if (!cached) { if (!cached) {
@@ -305,6 +363,8 @@
state.loading = false; state.loading = false;
$('#liveDot').classList.add('on'); $('#liveDot').classList.add('on');
setTimeout(() => $('#liveDot').classList.remove('on'), 900); setTimeout(() => $('#liveDot').classList.remove('on'), 900);
// 自动刷新是 setTimeout 链:每轮结束重新排下一轮(成功复位、失败按退避延长)
schedule();
} }
} }
@@ -444,7 +504,7 @@
const rosterForGrp = grp ? rosterForCls.filter(s => (s.grp || '') === grp) : rosterForCls; const rosterForGrp = grp ? rosterForCls.filter(s => (s.grp || '') === grp) : rosterForCls;
const absentForGrp = Math.max(0, rosterForGrp.length - rowsForGrp.length); const absentForGrp = Math.max(0, rosterForGrp.length - rowsForGrp.length);
$('#tableHint').textContent = '共 ' + rowsForGrp.length + ' 人已提交 · 名单 ' + rosterForGrp.length + ' 人'; $('#tableHint').textContent = '共 ' + rowsForGrp.length + ' 人已提交 · 名单 ' + rosterForGrp.length + ' 人';
$('#absentHint').textContent = absentForGrp ? '共 ' + absentForGrp + ' 人' : ''; // absentHint 由 renderAbsent 统一维护(含按班统计),此处不再重复设置
} }
function fmtDur(sec) { function fmtDur(sec) {
@@ -582,42 +642,15 @@
const rows = buildRows(); const rows = buildRows();
const k = state.sortKey, dir = state.sortAsc ? 1 : -1; const k = state.sortKey, dir = state.sortAsc ? 1 : -1;
rows.sort((a, b) => cmpBy(k, dir, a, b)); rows.sort((a, b) => cmpBy(k, dir, a, b));
state._sorted = rows;
const tb = $('#tbody'); const tb = $('#tbody');
if (!rows.length) { if (!rows.length) {
tb.innerHTML = '<tr><td colspan="' + COL_COUNT + '" class="empty">' + tb.innerHTML = '<tr><td colspan="' + COL_COUNT + '" class="empty">' +
(state.rows.length ? '没有匹配的记录' : '暂无提交记录') + '</td></tr>'; (state.rows.length ? '没有匹配的记录' : '暂无提交记录') + '</td></tr>';
return; } else {
renderVirtualRows(tb, rows);
} }
tb.innerHTML = rows.map(r => {
const sid = String(r.sid || '');
const isNew = sid !== '' && state.newSids.has(sid);
const hl = sid !== '' && state.highlight.has(sid);
const attempts = (r.attempts || 1);
const clsName = [hl ? 'new' : '', 'clickable'].filter(Boolean).join(' ');
// 姓名后缀标记
const tags = [];
if (isNew) tags.push('<span class="tag tag-new">新</span>');
if (r._att && r.is_best && attempts > 1) tags.push('<span class="tag tag-best">最佳</span>');
if (r._att && r.temp_save) tags.push('<span class="tag tag-gray">暂存</span>');
// 考试次数:全部展示模式下额外标出"第几次"
const attCell = r._att && attempts > 1
? attempts + ' <small style="color:var(--text-3)">第' + r.idx + '次</small>'
: String(attempts);
return '<tr class="' + clsName + '" data-sid="' + esc(sid) + '" title="点击查看历史成绩">' +
'<td class="sid">' + esc(r.sid) + '</td>' +
'<td class="name">' + esc(r.name) + (tags.length ? ' ' + tags.join(' ') : '') + '</td>' +
'<td>' + esc(r.cls || '') + '</td>' +
'<td>' + esc(r.grp || '') + '</td>' +
'<td class="score num ' + scoreClass(r.score) + '">' + (r.score == null ? '-' : r.score) + '</td>' +
'<td class="num att">' + attCell + '</td>' +
'<td class="num">' + esc(r.submit_at) + '</td>' +
'<td class="num">' + esc(r.duration_txt) + '</td>' +
'</tr>';
}).join('');
$$('#dataTable th.sortable').forEach(th => { $$('#dataTable th.sortable').forEach(th => {
const isCur = th.dataset.k === state.sortKey; const isCur = th.dataset.k === state.sortKey;
@@ -627,6 +660,78 @@
renderHlBadge(); renderHlBadge();
} }
/** 单行 HTML(与虚拟滚动共用,避免逻辑重复) */
function rowHtml(r) {
const sid = String(r.sid || '');
const isNew = sid !== '' && state.newSids.has(sid);
const hl = sid !== '' && state.highlight.has(sid);
const attempts = (r.attempts || 1);
const clsName = [hl ? 'new' : '', 'clickable'].filter(Boolean).join(' ');
const tags = [];
if (isNew) tags.push('<span class="tag tag-new">新</span>');
if (r._att && r.is_best && attempts > 1) tags.push('<span class="tag tag-best">最佳</span>');
if (r._att && r.temp_save) tags.push('<span class="tag tag-gray">暂存</span>');
const attCell = r._att && attempts > 1
? attempts + ' <small style="color:var(--text-3)">第' + r.idx + '次</small>'
: String(attempts);
return '<tr class="' + clsName + '" data-sid="' + esc(sid) + '" title="点击查看历史成绩">' +
'<td class="sid">' + esc(r.sid) + '</td>' +
'<td class="name">' + esc(r.name) + (tags.length ? ' ' + tags.join(' ') : '') + '</td>' +
'<td>' + esc(r.cls || '') + '</td>' +
'<td>' + esc(r.grp || '') + '</td>' +
'<td class="score num ' + scoreClass(r.score) + '">' + (r.score == null ? '-' : r.score) + '</td>' +
'<td class="num att">' + attCell + '</td>' +
'<td class="num">' + esc(r.submit_at) + '</td>' +
'<td class="num">' + esc(r.duration_txt) + '</td>' +
'</tr>';
}
/**
* 虚拟滚动:只渲染可视窗口内的行,用顶部/底部 spacer 撑起完整滚动高度。
* 行数很多(尤其「全部展开」历史模式下)时,避免一次性插入成千上万 DOM 节点导致卡顿。
*/
const ROW_H = 41; // 估算单行高度(含 padding/border),用于窗口定位
const VS_OVERSCAN = 12; // 视口上下各多渲染的行数,减少快速滚动时的白屏
function renderVirtualRows(tb, rows) {
const scroller = tb.closest('.table-scroll');
if (!scroller) {
// 兜底:找不到滚动容器时退回全量渲染
tb.innerHTML = rows.map(rowHtml).join('');
return;
}
const scrollTop = scroller.scrollTop;
const viewH = scroller.clientHeight;
const total = rows.length;
let start = Math.floor(scrollTop / ROW_H) - VS_OVERSCAN;
let end = Math.ceil((scrollTop + viewH) / ROW_H) + VS_OVERSCAN;
if (start < 0) start = 0;
if (end > total) end = total;
if (start >= end) start = Math.max(0, end - 1);
const frag = [];
if (start > 0) {
frag.push('<tr class="vspacer" style="height:' + (start * ROW_H) + 'px;pointer-events:none"><td colspan="' + COL_COUNT + '"></td></tr>');
}
for (let i = start; i < end; i++) {
frag.push(rowHtml(rows[i]));
}
if (end < total) {
frag.push('<tr class="vspacer" style="height:' + ((total - end) * ROW_H) + 'px;pointer-events:none"><td colspan="' + COL_COUNT + '"></td></tr>');
}
tb.innerHTML = frag.join('');
}
/** 供滚动监听调用:滚动时重算可视窗口(不重新排序,直接复用 state._sorted) */
function onTableScroll() {
const tb = $('#tbody');
const rows = state._sorted;
if (!tb || !rows || !rows.length) return;
renderVirtualRows(tb, rows);
}
function renderAbsent() { function renderAbsent() {
const clsFilter = $('#clsSel').value; const clsFilter = $('#clsSel').value;
const grpFilter = $('#grpSel').value; const grpFilter = $('#grpSel').value;
@@ -637,24 +742,40 @@
const rosterForCls = clsFilter ? (state.roster || []).filter(s => (s.cls || '') === clsFilter) : (state.roster || []); const rosterForCls = clsFilter ? (state.roster || []).filter(s => (s.cls || '') === clsFilter) : (state.roster || []);
const rosterForGrp = grpFilter ? rosterForCls.filter(s => (s.grp || '') === grpFilter) : rosterForCls; const rosterForGrp = grpFilter ? rosterForCls.filter(s => (s.grp || '') === grpFilter) : rosterForCls;
const list = rosterForGrp.filter(s => !submittedSids.has(s.sid)); const list = rosterForGrp.filter(s => !submittedSids.has(s.sid));
state._absentList = list; // 供复制/打印按钮使用
const tb = $('#absentBody'); const tb = $('#absentBody');
if (!list.length) { if (!list.length) {
tb.innerHTML = '<tr><td colspan="4" class="empty">' + (clsFilter ? '本班全部已提交 🎉' : '全部已提交 🎉') + '</td></tr>'; tb.innerHTML = '<tr><td colspan="4" class="empty">' + (clsFilter ? '本班全部已提交 🎉' : '全部已提交 🎉') + '</td></tr>';
return; } else {
tb.innerHTML = list.map(a =>
'<tr><td class="sid">' + esc(a.sid) + '</td><td>' + esc(a.name) + '</td><td>' + esc(a.cls || '') + '</td><td>' + esc(a.grp || '') + '</td></tr>').join('');
}
// 按班统计缺考人数(未按班级筛选时显示概览)
const hint = $('#absentHint');
if (hint) {
if (!clsFilter && list.length) {
const byCls = {};
list.forEach(a => { const c = (a.cls || '未分班'); byCls[c] = (byCls[c] || 0) + 1; });
const parts = Object.keys(byCls).sort().map(c => c + ' ' + byCls[c] + ' 人');
hint.textContent = '共 ' + list.length + ' 人 · ' + parts.join(' ');
} else {
hint.textContent = list.length ? '共 ' + list.length + ' 人' : '';
}
} }
tb.innerHTML = list.map(a =>
'<tr><td class="sid">' + esc(a.sid) + '</td><td>' + esc(a.name) + '</td><td>' + esc(a.cls || '') + '</td><td>' + esc(a.grp || '') + '</td></tr>').join('');
} }
function renderUnmatched() { function renderUnmatched() {
const tb = $('#unmatchedBody'); const tb = $('#unmatchedBody');
if (!state.unmatched.length) { if (!state.unmatched.length) {
tb.innerHTML = '<tr><td colspan="3" class="empty">无</td></tr>'; tb.innerHTML = '<tr><td colspan="4" class="empty">无</td></tr>';
return; return;
} }
tb.innerHTML = state.unmatched.map(u => tb.innerHTML = state.unmatched.map(u =>
'<tr><td>' + esc(u.name) + '</td><td class="num">' + (u.score == null ? '-' : u.score) + '<tr>' +
'</td><td class="num">' + esc(u.submit_at) + '</td></tr>').join(''); '<td>' + esc(u.name) + '</td>' +
'<td class="num">' + (u.score == null ? '-' : u.score) + '</td>' +
'<td class="num">' + esc(u.submit_at) + '</td>' +
'<td><button class="btn xs" data-map="' + esc(u.name) + '">加入名单</button></td></tr>').join('');
} }
/* 历史成绩弹窗(主统计页成绩明细):任意成员都可点开,含只考过 1 次的 */ /* 历史成绩弹窗(主统计页成绩明细):任意成员都可点开,含只考过 1 次的 */
@@ -667,11 +788,16 @@
// 后端未返回明细时,用代表行兜底成一条 // 后端未返回明细时,用代表行兜底成一条
list = [{ list = [{
idx: 1, is_best: true, score: r.score, submit_at: r.submit_at, idx: 1, is_best: true, score: r.score, submit_at: r.submit_at,
submit_ts: r.submit_ts || r.last_submit_ts || 0,
duration_txt: r.duration_txt, temp_save: !!r.temp_save, duration_txt: r.duration_txt, temp_save: !!r.temp_save,
}]; }];
} }
// 新 -> 旧 展示 // 新 -> 旧 展示(缺 submit_ts 时按 idx 倒序兜底,保证"次序"看起来合理)
const desc = list.slice().sort((a, b) => (b.submit_ts || 0) - (a.submit_ts || 0)); const desc = list.slice().sort((a, b) => {
const ta = a.submit_ts || 0, tb = b.submit_ts || 0;
if (ta !== tb) return tb - ta;
return (b.idx || 0) - (a.idx || 0);
});
const rows = desc.map((a, i) => { const rows = desc.map((a, i) => {
const sc = (a.score == null) ? '<span class="muted">未判分</span>' : esc(a.score); const sc = (a.score == null) ? '<span class="muted">未判分</span>' : esc(a.score);
const best = (a.is_best && attempts > 1) ? ' <span class="tag tag-best">最佳</span>' : ''; const best = (a.is_best && attempts > 1) ? ' <span class="tag tag-best">最佳</span>' : '';
@@ -698,11 +824,15 @@
} }
/* ---------------- 自动刷新 ---------------- */ /* ---------------- 自动刷新 ---------------- */
// 失败退避:连续失败时延长下次刷新间隔(1x → 2x → 4x,封顶 8x),成功后复位。
// 用 setTimeout 链而非 setInterval,便于动态调整间隔;后台标签页暂停刷新,回到前台立即补一次。
function schedule() { function schedule() {
if (state.timer) clearInterval(state.timer); if (state.timer) { clearTimeout(state.timer); state.timer = null; }
if (!$('#autoRefresh').checked) return; if (!$('#autoRefresh').checked) return;
const sec = parseInt($('#intervalSel').value, 10) || 15; const base = (parseInt($('#intervalSel').value, 10) || 15) * 1000;
state.timer = setInterval(() => refresh(true), sec * 1000); const backoff = Math.min(8, Math.pow(2, state.connFail)) * base;
const delay = (document.hidden ? Math.max(backoff, 5000) : backoff);
state.timer = setTimeout(() => { refresh(true); }, delay);
} }
/* ---------------- 新数据高亮自动清除 ---------------- */ /* ---------------- 新数据高亮自动清除 ---------------- */
@@ -749,6 +879,7 @@
async function openSettings() { async function openSettings() {
try { try {
const c = await api('config_get'); const c = await api('config_get');
if (c.csrf_token) state.csrf = c.csrf_token;
$('#cfgPageSize').value = c.page_size || 200; $('#cfgPageSize').value = c.page_size || 200;
$('#cfgInterval').value = c.refresh_interval || 15; $('#cfgInterval').value = c.refresh_interval || 15;
$('#cfgTimeout').value = c.timeout || 20; $('#cfgTimeout').value = c.timeout || 20;
@@ -764,7 +895,7 @@
renderFullScores((c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {}); renderFullScores((c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {});
// 历史成绩展示方式 + 导出排序 // 历史成绩展示方式 + 导出排序
state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click'; state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click';
state.exportSort = (c.export_sort === 'sid') ? 'sid' : 'time'; state.exportSort = (c.export_sort === 'sid') ? 'sid' : ((c.export_sort === 'score_desc') ? 'score_desc' : ((c.export_sort === 'score_asc') ? 'score_asc' : 'time'));
$('#cfgHistoryDisplay').value = state.historyDisplay; $('#cfgHistoryDisplay').value = state.historyDisplay;
$('#cfgExportSort').value = state.exportSort; $('#cfgExportSort').value = state.exportSort;
if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort; if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort;
@@ -784,17 +915,26 @@
} }
// 渲染"各试卷独立满分"列表 // 渲染"各试卷独立满分"列表
// 注意:#fullScoresHint 必须位于 #fullScoresBox 之外(见 index.php),
// 否则 box.innerHTML 覆盖会销毁它,下面的 textContent 赋值会抛 "Cannot set properties of null"。
// 这里仍保留 null 守卫,避免任何 DOM 结构变动导致整个设置弹窗打不开。
function setFullScoresHint(text) {
const hint = $('#fullScoresHint');
if (hint) hint.textContent = text;
}
function renderFullScores(scores) { function renderFullScores(scores) {
const box = $('#fullScoresBox'); const box = $('#fullScoresBox');
if (!box) return; if (!box) return;
if (!state.projects.length) { if (!state.projects.length) {
box.innerHTML = '<span class="hint">暂无试卷,请先连接卷王系统并选择问卷</span>'; box.innerHTML = '<span class="hint">暂无试卷,请先连接卷王系统并选择问卷</span>';
setFullScoresHint('给某卷填入数字即可单独覆盖默认满分;留空=用默认');
return; return;
} }
// 排除文件夹项:模式为文件夹的无可答卷 // 排除文件夹项:模式为文件夹的无可答卷
const surveys = state.projects.filter(p => !((p.mode || '').toLowerCase().includes('folder'))); const surveys = state.projects.filter(p => !((p.mode || '').toLowerCase().includes('folder')));
if (!surveys.length) { if (!surveys.length) {
box.innerHTML = '<span class="hint">尚未获取到可答卷</span>'; box.innerHTML = '<span class="hint">尚未获取到可答卷</span>';
setFullScoresHint('给某卷填入数字即可单独覆盖默认满分;留空=用默认');
return; return;
} }
box.innerHTML = surveys.map(p => { box.innerHTML = surveys.map(p => {
@@ -804,11 +944,20 @@
'<input type="number" min="1" max="1000" data-pid="' + esc(p.id) + '" value="' + esc(val) + '" placeholder="默认">' + '<input type="number" min="1" max="1000" data-pid="' + esc(p.id) + '" value="' + esc(val) + '" placeholder="默认">' +
'</div>'; '</div>';
}).join(''); }).join('');
$('#fullScoresHint').textContent = '默认分值为空时使用上方"默认满分";填写即单独覆盖该卷'; setFullScoresHint('默认分值为空时使用上方"默认满分";填写即单独覆盖该卷');
} }
/* ---------------- 名单 ---------------- */ /* ---------------- 名单 ---------------- */
let rosterDraft = []; let rosterDraft = [];
const rosterSel = new Set(); // 勾选待删除的行下标(对应 rosterDraft 索引)
// 更新「删除选中」按钮的可见性与计数
function updateDelSelBtn() {
const btn = $('#btnDelSel');
if (!btn) return;
btn.hidden = (rosterSel.size === 0);
btn.textContent = '删除选中' + (rosterSel.size > 0 ? '(' + rosterSel.size + ')' : '');
}
async function openRoster() { async function openRoster() {
try { try {
@@ -827,24 +976,28 @@
String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw)); String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw));
const tb = $('#rosterBody'); const tb = $('#rosterBody');
if (!rows.length) { if (!rows.length) {
tb.innerHTML = '<tr><td colspan="4" class="empty">暂无名单,请上传文件</td></tr>'; tb.innerHTML = '<tr><td colspan="6" class="empty">暂无名单,请上传文件</td></tr>';
$('#btnDelSel').hidden = true;
return; return;
} }
tb.innerHTML = rows.map((r, i) => { tb.innerHTML = rows.map((r, i) => {
const gi = rosterDraft.indexOf(r); const gi = rosterDraft.indexOf(r);
const checked = rosterSel.has(gi) ? ' checked' : '';
return '<tr data-i="' + gi + '">' + return '<tr data-i="' + gi + '">' +
'<td><input type="checkbox" class="roster-cb" data-i="' + gi + '"' + checked + '></td>' +
'<td><input value="' + esc(r.sid) + '" data-f="sid"></td>' + '<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.name) + '" data-f="name"></td>' +
'<td><input value="' + esc(r.cls || '') + '" data-f="cls" placeholder="可选"></td>' + '<td><input value="' + esc(r.cls || '') + '" data-f="cls" placeholder="可选"></td>' +
'<td><input value="' + esc(r.grp || '') + '" data-f="grp" placeholder="A/B"></td>' + '<td><input value="' + esc(r.grp || '') + '" data-f="grp" placeholder="A/B"></td>' +
'<td><span class="link-del" data-del="' + gi + '">删除</span></td></tr>'; '<td><input value="' + esc(r.login || '') + '" data-f="login" placeholder="可选"></td></tr>';
}).join(''); }).join('');
updateDelSelBtn();
} }
/* ---------------- 事件绑定 ---------------- */ /* ---------------- 事件绑定 ---------------- */
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
$('#btnRefresh').addEventListener('click', () => refresh(false)); $('#btnRefresh').addEventListener('click', () => refresh(false, true));
$('#btnSettings').addEventListener('click', openSettings); $('#btnSettings').addEventListener('click', openSettings);
$('#btnRoster').addEventListener('click', openRoster); $('#btnRoster').addEventListener('click', openRoster);
$('#btnFullscreen').addEventListener('click', toggleFullscreen); $('#btnFullscreen').addEventListener('click', toggleFullscreen);
@@ -852,7 +1005,11 @@
if (document.webkitFullscreenEnabled === undefined && !document.documentElement.requestFullscreen && !document.documentElement.webkitRequestFullscreen) { if (document.webkitFullscreenEnabled === undefined && !document.documentElement.requestFullscreen && !document.documentElement.webkitRequestFullscreen) {
$('#btnFullscreen').style.display = 'none'; $('#btnFullscreen').style.display = 'none';
} }
$('#kw').addEventListener('input', renderTable); // 搜索框防抖:连续输入时只渲染最后一次,避免每个字符都全量重排
$('#kw').addEventListener('input', () => {
if (state._kwDebounce) clearTimeout(state._kwDebounce);
state._kwDebounce = setTimeout(() => { renderTable(); }, 200);
});
$('#clsSel').addEventListener('change', () => { $('#clsSel').addEventListener('change', () => {
populateGroups(); populateGroups();
rememberFilters(); rememberFilters();
@@ -887,7 +1044,16 @@
}); });
$('#autoRefresh').addEventListener('change', e => { $('#autoRefresh').addEventListener('change', e => {
if (e.target.checked) { schedule(); refresh(true); } if (e.target.checked) { schedule(); refresh(true); }
else if (state.timer) clearInterval(state.timer); else if (state.timer) { clearTimeout(state.timer); state.timer = null; }
});
// 后台标签页暂停自动刷新(省流量/CPU);回到前台立即补一次并重置倒计时
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
} else {
if ($('#autoRefresh').checked) { refresh(true); schedule(); }
}
}); });
$$('#dataTable th.sortable').forEach(th => { $$('#dataTable th.sortable').forEach(th => {
@@ -898,10 +1064,31 @@
}); });
}); });
/* 成绩明细行点击 → 历史成绩弹窗(事件委托,避免内联 onclick 找不到 IIFE 内函数) */ // 虚拟滚动:滚动成绩表时按需渲染可视窗口(throttle 到 rAF 避免高频抖动)
(function () {
const scroller = $('#tbody') && $('#tbody').closest('.table-scroll');
if (!scroller) return;
let ticking = false;
scroller.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => { onTableScroll(); ticking = false; });
});
// 窗口尺寸变化(缩放/布局切换)时重算可视行
if (window.ResizeObserver) {
new ResizeObserver(() => { if (!ticking) { ticking = true; requestAnimationFrame(() => { onTableScroll(); ticking = false; }); } })
.observe(scroller);
}
})();
/* 成绩明细行点击 → 历史成绩弹窗(事件委托,避免内联 onclick 找不到 IIFE 内函数)
注意:先绑 tbody 的 click,再绑 document 的 modal-close click;事件冒泡到 tbody 时就处理掉,
让 modal-close 只对弹窗自身 / [data-close] 元素生效,避免误关。 */
$('#tbody').addEventListener('click', e => { $('#tbody').addEventListener('click', e => {
const tr = e.target.closest('tr[data-sid]'); const tr = e.target.closest('tr[data-sid]');
if (!tr) return; if (!tr) return;
// 仅响应"打开成员历史"动作;表头排序 sortable 在 #dataTable 上另行处理
if (tr.parentNode && tr.parentNode.id !== 'tbody') return;
const sid = tr.getAttribute('data-sid'); const sid = tr.getAttribute('data-sid');
if (sid) openAttemptsModal(sid); if (sid) openAttemptsModal(sid);
}); });
@@ -928,12 +1115,22 @@
const pid = $('#projectSel').value; const pid = $('#projectSel').value;
if (!pid) return showAlert('请先选择问卷 / 考试'); if (!pid) return showAlert('请先选择问卷 / 考试');
const cls = $('#clsSel').value, grp = $('#grpSel').value; const cls = $('#clsSel').value, grp = $('#grpSel').value;
const sort = (state.exportSort === 'sid') ? 'sid' : 'time'; const sort = (state.exportSort === 'sid') ? 'sid' : ((state.exportSort === 'score_desc') ? 'score_desc' : ((state.exportSort === 'score_asc') ? 'score_asc' : 'time'));
location.href = 'api.php?action=export&projectId=' + encodeURIComponent(pid) + // 导出提示文案(与排序方式对应,让用户看清"这次按什么排")
const sortLabel = (sort === 'sid') ? '按学号'
: (sort === 'score_desc') ? '按成绩降序'
: (sort === 'score_asc') ? '按成绩升序'
: '按提交时间';
const latest = ($('#exportLatest') && $('#exportLatest').checked) ? 'latest' : 'all';
const split = ($('#exportSplit') && $('#exportSplit').checked) ? 'cls' : '';
const qs = 'api.php?action=export&projectId=' + encodeURIComponent(pid) +
'&nameQid=' + encodeURIComponent($('#nameFieldSel').value) + '&nameQid=' + encodeURIComponent($('#nameFieldSel').value) +
'&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp) + '&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp) +
'&sort=' + sort; '&sort=' + sort + '&mode=' + latest + (split ? '&split=' + split : '');
showAlert(exportTip('成绩明细', cls, grp) + ',排序:' + (sort === 'sid' ? '按学号' : '按提交时间'), 'info'); location.href = qs;
const modeLabel = (latest === 'latest') ? '(每人一行·最新成绩)' : '(每次考试一行)';
const splitLabel = (split === 'cls') ? ',按班级分表' : '';
showAlert(exportTip('成绩明细', cls, grp) + ',排序:' + sortLabel + splitLabel + modeLabel, 'info');
}); });
$('#btnExportAbsent').addEventListener('click', () => { $('#btnExportAbsent').addEventListener('click', () => {
const pid = $('#projectSel').value; const pid = $('#projectSel').value;
@@ -996,7 +1193,10 @@
opts.forEach(v => { if (Math.abs(v - iv) < Math.abs(best - iv)) best = v; }); opts.forEach(v => { if (Math.abs(v - iv) < Math.abs(best - iv)) best = v; });
$('#intervalSel').value = String(best); $('#intervalSel').value = String(best);
schedule(); schedule();
loadProjects(); // 仅连接信息变化时才重新加载项目列表(避免非连接设置保存触发破坏性刷新)
if (resp && resp.conn_changed) {
loadProjects();
}
// 展示方式可能变化 → 立即重渲染表格(不重新拉数据) // 展示方式可能变化 → 立即重渲染表格(不重新拉数据)
state.historyDisplay = payload.history_display; state.historyDisplay = payload.history_display;
state.exportSort = payload.export_sort; state.exportSort = payload.export_sort;
@@ -1083,21 +1283,70 @@
} }
$('#rosterSearch').addEventListener('input', renderRoster); $('#rosterSearch').addEventListener('input', renderRoster);
// 新增学生:弹出表单,填写后追加(不再直接在表格底部插入空行)
$('#btnAddRow').addEventListener('click', () => { $('#btnAddRow').addEventListener('click', () => {
rosterDraft.push({ sid: '', name: '', cls: '' }); $('#addSid').value = ''; $('#addName').value = '';
renderRoster(); $('#addCls').value = ''; $('#addGrp').value = ''; $('#addLogin').value = '';
$('#addStudentHint').textContent = '';
$('#addStudentModal').classList.add('open');
$('#addSid').focus();
}); });
$('#btnAddStudentOk').addEventListener('click', () => {
const sid = $('#addSid').value.trim();
const name = $('#addName').value.trim();
if (!sid || !name) {
$('#addStudentHint').textContent = '学号和姓名必填';
return;
}
// 学号去重提示(不强制拦截,仅提醒)
if (rosterDraft.some(s => String(s.sid).trim() === sid)) {
$('#addStudentHint').textContent = '⚠️ 该学号已存在,仍会添加(请确认)';
}
rosterDraft.push({
sid, name,
cls: $('#addCls').value.trim(),
grp: $('#addGrp').value.trim(),
login: $('#addLogin').value.trim(),
});
renderRoster();
$('#addStudentModal').classList.remove('open');
showAlert('已添加学生:' + sid + ' ' + name, 'info');
});
$('#btnClearRoster').addEventListener('click', async () => { $('#btnClearRoster').addEventListener('click', async () => {
if (!confirm('确定清空全部名单?')) return; if (!confirm('确定清空全部名单?此操作不可撤销。')) return;
try { try {
const d = await api('roster_clear', { method: 'POST' }); const d = await api('roster_clear', { method: 'POST' });
rosterDraft = []; rosterDraft = [];
rosterSel.clear();
renderRoster(); renderRoster();
$('#rosterBadge').textContent = '0'; $('#rosterBadge').textContent = '0';
$('#rosterMeta').textContent = '共 0 人'; $('#rosterMeta').textContent = '共 0 人';
} catch (e) { showAlert(e.message); } } catch (e) { showAlert(e.message); }
}); });
// 删除选中(多选 + 二次确认)
$('#btnDelSel').addEventListener('click', () => {
const n = rosterSel.size;
if (!n) return;
if (!confirm('确定删除选中的 ' + n + ' 名学生?此操作需点击「保存名单」后才会落盘。')) return;
const idx = Array.from(rosterSel).sort((a, b) => b - a); // 从大到小删,避免下标错位
idx.forEach(i => rosterDraft.splice(i, 1));
rosterSel.clear();
renderRoster();
showAlert('已删除 ' + n + ' 名学生(记得保存名单)', 'info');
});
// 全选 / 取消全选
$('#rosterCheckAll').addEventListener('change', e => {
rosterSel.clear();
if (e.target.checked) {
rosterDraft.forEach((_, i) => rosterSel.add(i));
}
renderRoster();
});
$('#rosterBody').addEventListener('input', e => { $('#rosterBody').addEventListener('input', e => {
const tr = e.target.closest('tr'); const tr = e.target.closest('tr');
if (!tr) return; if (!tr) return;
@@ -1105,11 +1354,15 @@
if (isNaN(i)) return; if (isNaN(i)) return;
rosterDraft[i][e.target.dataset.f] = e.target.value; rosterDraft[i][e.target.dataset.f] = e.target.value;
}); });
$('#rosterBody').addEventListener('click', e => {
const id = e.target.getAttribute && e.target.getAttribute('data-del'); // 勾选 / 取消勾选(事件委托;input 事件会冒泡到这里时已被上面的 input 处理,这里只处理 checkbox)
if (id == null) return; $('#rosterBody').addEventListener('change', e => {
rosterDraft.splice(parseInt(id, 10), 1); if (!e.target.classList || !e.target.classList.contains('roster-cb')) return;
renderRoster(); const i = parseInt(e.target.dataset.i, 10);
if (isNaN(i)) return;
if (e.target.checked) rosterSel.add(i);
else rosterSel.delete(i);
updateDelSelBtn();
}); });
$('#btnSaveRoster').addEventListener('click', async () => { $('#btnSaveRoster').addEventListener('click', async () => {
@@ -1123,6 +1376,223 @@
} catch (e) { showAlert(e.message); } } catch (e) { showAlert(e.message); }
}); });
/* 未匹配答卷 → 加入名单(映射到学号) */
let mapUmName = '';
$('#unmatchedBody').addEventListener('click', e => {
const name = e.target.getAttribute && e.target.getAttribute('data-map');
if (name == null) return;
mapUmName = name;
$('#mapUmName').textContent = name;
$('#mapUmSid').value = ''; $('#mapUmCls').value = ''; $('#mapUmGrp').value = '';
$('#mapUmHint').textContent = '';
$('#mapUnmatchedModal').classList.add('open');
$('#mapUmSid').focus();
});
$('#btnMapUmOk').addEventListener('click', async () => {
const sid = $('#mapUmSid').value.trim();
if (!sid) { $('#mapUmHint').textContent = '请输入学号'; return; }
try {
const d = await api('unmatched_map', { method: 'POST', body: {
name: mapUmName, sid,
cls: $('#mapUmCls').value.trim(),
grp: $('#mapUmGrp').value.trim(),
}});
$('#mapUnmatchedModal').classList.remove('open');
showAlert('已将「' + mapUmName + '」映射到学号 ' + sid + ' 并加入名单', 'info');
if ($('#rosterBadge')) $('#rosterBadge').textContent = (d.students || []).length;
// 刷新数据,让该答卷从「未匹配」归位到成绩明细
if ($('#projectSel').value) refresh(false, true);
} catch (err) { showAlert(err.message); }
});
/* 成绩快照 / 趋势 */
function curPid() { return $('#projectSel').value; }
async function loadSnapshots() {
const pid = curPid();
if (!pid) return;
try {
const d = await api('snapshot_list', { params: { projectId: pid } });
const list = d.snapshots || [];
const tb = $('#snapBody');
if (!list.length) {
tb.innerHTML = '<tr><td colspan="5" class="empty">暂无快照,点击「保存当前快照」创建第一份</td></tr>';
return;
}
tb.innerHTML = list.map(s =>
'<tr>' +
'<td>' + esc(s.label) + '</td>' +
'<td class="num">' + esc(s.at_txt) + '</td>' +
'<td class="num">' + s.count + '</td>' +
'<td>' + esc((s.project && s.project.name) || '') + '</td>' +
'<td><span class="link-del" data-snapdel="' + esc(s.file) + '">删除</span></td></tr>').join('');
} catch (e) { showAlert(e.message); }
}
$('#btnSnapshot').addEventListener('click', () => {
if (!curPid()) return showAlert('请先选择问卷 / 考试');
$('#snapLabel').value = '';
$('#snapTitle').textContent = '成绩快照 · ' + (state.projectName || '');
loadSnapshots();
$('#snapshotModal').classList.add('open');
});
$('#btnSnapSave').addEventListener('click', async () => {
const pid = curPid();
if (!pid) return showAlert('请先选择问卷 / 考试');
try {
const d = await api('snapshot_save', { method: 'POST', params: { projectId: pid, nameQid: $('#nameFieldSel').value }, body: { label: $('#snapLabel').value.trim() } });
showAlert('快照已保存:' + d.label + '(' + d.count + ' 人)', 'info');
$('#snapLabel').value = '';
loadSnapshots();
} catch (e) { showAlert(e.message); }
});
$('#btnSnapRefresh').addEventListener('click', loadSnapshots);
$('#snapBody').addEventListener('click', async e => {
const f = e.target.getAttribute && e.target.getAttribute('data-snapdel');
if (f == null) return;
if (!confirm('确定删除该快照?此操作不可撤销。')) return;
try {
await api('snapshot_delete', { method: 'POST', params: { projectId: curPid() }, body: { file: f } });
showAlert('快照已删除', 'info');
loadSnapshots();
} catch (err) { showAlert(err.message); }
});
async function renderTrend() {
const pid = curPid();
if (!pid) return showAlert('请先选择问卷 / 考试');
const body = $('#trendBody'), head = $('#trendHead');
body.innerHTML = '<tr><td colspan="10" class="empty">加载中…</td></tr>';
try {
const d = await api('snapshot_trend', { params: { projectId: pid } });
const cols = d.columns || [], rows = d.rows || [];
$('#trendSub').textContent = state.projectName || '';
if (!cols.length) {
head.innerHTML = ''; body.innerHTML = '<tr><td colspan="10" class="empty">还没有快照,请先保存快照</td></tr>';
return;
}
head.innerHTML = '<tr><th>学号</th><th>姓名</th><th>班级</th>' +
cols.map(c => '<th title="' + esc(c.at_txt) + '">' + esc(c.label) + '</th>').join('') + '</tr>';
const colSpan = cols.length + 3;
if (!rows.length) {
body.innerHTML = '<tr><td colspan="' + colSpan + '" class="empty">无数据</td></tr>';
return;
}
body.innerHTML = rows.map(r => {
let cells = '<td class="sid">' + esc(r.sid) + '</td><td>' + esc(r.name) + '</td><td>' + esc(r.cls || '') + '</td>';
cols.forEach(c => {
const v = r.scores[c.key];
cells += '<td class="num">' + (v == null ? '—' : v) + '</td>';
});
return '<tr>' + cells + '</tr>';
}).join('');
} catch (e) { showAlert(e.message); }
}
$('#btnSnapTrend').addEventListener('click', () => {
$('#snapshotModal').classList.remove('open');
renderTrend();
$('#trendModal').classList.add('open');
});
$('#btnTrend').addEventListener('click', () => {
if (!curPid()) return showAlert('请先选择问卷 / 考试');
renderTrend();
$('#trendModal').classList.add('open');
});
/* 异常检测线索 */
function renderAnomaly() {
const a = state.anomalies || { fast_high: [], frequent: [], identical: [] };
const box = $('#anomalyBody');
const sec = [];
const empty = '<span class="muted">无</span>';
// 1) 短时长高得分
sec.push('<h4 style="margin:14px 0 6px">⚡ 用时异常短却得分高(' + a.fast_high.length + ')</h4>');
sec.push('<div class="table-scroll short"><table class="grid compact">' +
'<thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>成绩</th><th>用时</th></tr></thead><tbody>');
if (!a.fast_high.length) sec.push('<tr><td colspan="5" class="empty">' + empty + '</td></tr>');
else a.fast_high.forEach(x => {
sec.push('<tr><td class="sid">' + esc(x.sid) + '</td><td>' + esc(x.name) + '</td><td>' + esc(x.cls || '') +
'</td><td class="num">' + x.score + '</td><td class="num">' + esc(x.duration_txt || (x.duration + 's')) + '</td></tr>');
});
sec.push('</tbody></table></div>');
// 2) 高频重考
sec.push('<h4 style="margin:14px 0 6px">🔄 高频重考(' + a.frequent.length + ')</h4>');
sec.push('<div class="table-scroll short"><table class="grid compact">' +
'<thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>考试次数</th><th>最高分</th></tr></thead><tbody>');
if (!a.frequent.length) sec.push('<tr><td colspan="5" class="empty">' + empty + '</td></tr>');
else a.frequent.forEach(x => {
sec.push('<tr><td class="sid">' + esc(x.sid) + '</td><td>' + esc(x.name) + '</td><td>' + esc(x.cls || '') +
'</td><td class="num">' + x.attempts + '</td><td class="num">' + (x.best_score == null ? '-' : x.best_score) + '</td></tr>');
});
sec.push('</tbody></table></div>');
// 3) 雷同成绩
sec.push('<h4 style="margin:14px 0 6px">⚠️ 多人成绩雷同(' + a.identical.length + ' 组)</h4>');
sec.push('<div class="table-scroll short"><table class="grid compact">' +
'<thead><tr><th>成绩</th><th>人数</th><th>学生</th></tr></thead><tbody>');
if (!a.identical.length) sec.push('<tr><td colspan="3" class="empty">' + empty + '</td></tr>');
else a.identical.forEach(g => {
const names = g.members.map(m => m.name + '(' + m.sid + ')').join('、');
sec.push('<tr><td class="num">' + g.score + '</td><td class="num">' + g.count + '</td><td>' + esc(names) + '</td></tr>');
});
sec.push('</tbody></table></div>');
box.innerHTML = sec.join('');
}
$('#btnAnomaly').addEventListener('click', () => {
if (!curPid()) return showAlert('请先选择问卷 / 考试');
renderAnomaly();
$('#anomalyModal').classList.add('open');
});
/* 缺考名单增强:复制学号 / 复制姓名 / 打印 */
function copyText(text, what) {
const done = () => showAlert(what + '已复制到剪贴板', 'info');
const fail = () => showAlert('复制失败,请手动复制', 'error');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, fail);
} else {
// 兜底:临时 textarea + execCommand
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); done(); } catch (e) { fail(); }
document.body.removeChild(ta);
}
}
$('#btnCopySids').addEventListener('click', () => {
const list = state._absentList || [];
if (!list.length) return showAlert('当前没有缺考学生', 'warn');
copyText(list.map(a => a.sid).join(','), '学号');
});
$('#btnCopyNames').addEventListener('click', () => {
const list = state._absentList || [];
if (!list.length) return showAlert('当前没有缺考学生', 'warn');
copyText(list.map(a => a.name).join('、'), '姓名');
});
$('#btnPrintAbsent').addEventListener('click', () => {
const list = state._absentList || [];
if (!list.length) return showAlert('当前没有缺考学生', 'warn');
const rows = list.map(a =>
'<tr><td>' + esc(a.sid) + '</td><td>' + esc(a.name) + '</td><td>' + esc(a.cls || '') + '</td><td>' + esc(a.grp || '') + '</td></tr>').join('');
const project = state.projectName || '';
const title = (project ? project + ' · ' : '') + '缺考名单(' + list.length + ' 人)';
const w = window.open('', '_blank');
if (!w) return showAlert('浏览器拦截了弹窗,请允许后重试', 'error');
w.document.write('<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>' + esc(title) + '</title>' +
'<style>body{font-family:sans-serif;margin:24px}h2{margin:0 0 8px}table{border-collapse:collapse;width:100%}td,th{border:1px solid #333;padding:6px 10px;text-align:left;font-size:13px}th{background:#eee}</style></head><body>' +
'<h2>' + esc(title) + '</h2><p>打印时间:' + new Date().toLocaleString() + '</p>' +
'<table><thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>组别</th></tr></thead><tbody>' + rows + '</tbody></table>' +
'<script>window.onload=function(){setTimeout(function(){window.print()},300)}<\/script></body></html>');
w.document.close();
});
/* 启动 */ /* 启动 */
// 1) 恢复布局偏好(仅样式,不涉及数据) // 1) 恢复布局偏好(仅样式,不涉及数据)
applyLayout(LS.get(LS_LAYOUT, 'default')); applyLayout(LS.get(LS_LAYOUT, 'default'));
@@ -1158,8 +1628,9 @@
(async () => { (async () => {
try { try {
const c = await api('config_get'); const c = await api('config_get');
if (c.csrf_token) state.csrf = c.csrf_token; // 提前拿到 CSRF token,写请求才能通过校验
state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click'; state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click';
state.exportSort = (c.export_sort === 'sid') ? 'sid' : 'time'; state.exportSort = (c.export_sort === 'sid') ? 'sid' : ((c.export_sort === 'score_desc') ? 'score_desc' : ((c.export_sort === 'score_asc') ? 'score_asc' : 'time'));
if ($('#cfgHistoryDisplay')) $('#cfgHistoryDisplay').value = state.historyDisplay; if ($('#cfgHistoryDisplay')) $('#cfgHistoryDisplay').value = state.historyDisplay;
if ($('#cfgExportSort')) $('#cfgExportSort').value = state.exportSort; if ($('#cfgExportSort')) $('#cfgExportSort').value = state.exportSort;
if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort; if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort;
+26 -5
View File
@@ -57,12 +57,33 @@
return j.data; return j.data;
} }
/* ---------------- Toast 顶部飘窗通知(与 app.js 一致) ---------------- */
const TOAST_DURATION = 3500;
const TOAST_LIMIT = 5;
function showToast(msg, type) {
if (msg === '' || msg == null) return;
const el = document.getElementById('toastContainer') || (function () {
const c = document.createElement('div');
c.id = 'toastContainer';
c.style.cssText = 'position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none;max-width:calc(100vw - 32px);width:560px';
document.body.appendChild(c);
return c;
})();
while (el.children.length >= TOAST_LIMIT) el.firstChild.remove();
const t = document.createElement('div');
const cls = type === 'error' ? 'toast-error' : (type === 'warn' ? 'toast-warn' : (type === 'info' ? 'toast-ok' : 'toast'));
t.className = 'toast ' + cls;
t.innerHTML = '<span>' + esc(msg).replace(/\n/g, '<br>') + '</span>';
t.style.cssText = 'pointer-events:auto;padding:10px 16px;border-radius:10px;font-size:13px;line-height:1.55;box-shadow:0 8px 30px rgba(15,23,42,.18);animation:toastIn .25s ease;white-space:pre-wrap;word-break:break-word';
el.appendChild(t);
t.addEventListener('click', () => { t.style.animation = 'toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); });
setTimeout(() => { if (t.parentNode) { t.style.animation = 'toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); } }, TOAST_DURATION);
}
/** 兼容旧 showAlert 调用 → 走 toast(draw.js 内部已有 esc,此处直接复用) */
function showAlert(msg, type) { function showAlert(msg, type) {
const el = $('#alert'); if (msg === '' || msg == null) return;
if (!el) return; const t = type === 'info' ? 'ok' : (type === 'error' ? 'error' : '');
if (!msg) { el.classList.add('hidden'); return; } showToast(msg, t);
el.textContent = msg;
el.className = 'alert' + (type === 'info' ? ' info' : '');
} }
/* ================= 抽取核心(纯函数,供测试) ================= */ /* ================= 抽取核心(纯函数,供测试) ================= */
+22
View File
@@ -117,6 +117,7 @@ body {
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; } .btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn-primary:hover { background: #2559db; border-color: #2559db; } .btn-primary:hover { background: #2559db; border-color: #2559db; }
.btn.sm { padding: 5px 10px; font-size: 12px; } .btn.sm { padding: 5px 10px; font-size: 12px; }
.btn.xs { padding: 3px 8px; font-size: 11px; }
.btn.danger { color: var(--danger); border-color: #f3cfcb; } .btn.danger { color: var(--danger); border-color: #f3cfcb; }
.btn.danger:hover { background: var(--danger-weak); } .btn.danger:hover { background: var(--danger-weak); }
.btn b { .btn b {
@@ -289,6 +290,9 @@ table.grid th.sortable { cursor: pointer; user-select: none; }
table.grid th.sortable:hover { color: var(--primary); } table.grid th.sortable:hover { color: var(--primary); }
table.grid th .arrow { color: var(--primary); margin-left: 3px; } table.grid th .arrow { color: var(--primary); margin-left: 3px; }
table.grid td { padding: 10px 14px; border-bottom: 1px solid var(--border-2); } table.grid td { padding: 10px 14px; border-bottom: 1px solid var(--border-2); }
/* 虚拟滚动占位行:不占视觉空间、不响应 hover/条纹、不显示边框 */
table.grid tbody tr.vspacer td { padding: 0; border: 0; background: transparent !important; }
table.grid tbody tr.vspacer:hover td { background: transparent !important; }
table.grid tbody tr:nth-child(even) td { background: #fbfcff; } table.grid tbody tr:nth-child(even) td { background: #fbfcff; }
table.grid tbody tr:hover td { background: var(--primary-weak); } table.grid tbody tr:hover td { background: var(--primary-weak); }
table.grid.compact td, table.grid.compact th { padding: 7px 12px; } table.grid.compact td, table.grid.compact th { padding: 7px 12px; }
@@ -303,6 +307,23 @@ td.score { font-weight: 700; font-variant-numeric: tabular-nums; }
.diff { animation: flash 1.6s ease-out; } .diff { animation: flash 1.6s ease-out; }
@keyframes flash { from { background: #fff6d9; } to { background: transparent; } } @keyframes flash { from { background: #fff6d9; } to { background: transparent; } }
/* ---------- Toast 顶部飘窗通知 ---------- */
.toast {
background: linear-gradient(135deg, #2f6bff, #5b8bff);
color: #fff;
}
.toast-ok { background: linear-gradient(135deg, #12a86a, #3ec99a); color: #fff; }
.toast-error{ background: linear-gradient(135deg, #e0483d, #ef6c64); color: #fff; }
.toast-warn { background: linear-gradient(135deg, #e08c1a, #f0a54a); color: #fff; }
@keyframes toastIn {
from { opacity: 0; transform: translateY(-12px) scale(.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes toastOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(-8px) scale(.96); }
}
/* 新数据绿色底纹:常亮(不再闪烁),由"清除高亮"或切换项目/姓名题清除 */ /* 新数据绿色底纹:常亮(不再闪烁),由"清除高亮"或切换项目/姓名题清除 */
table.grid tbody tr.new td { background: #e7f7ee; } table.grid tbody tr.new td { background: #e7f7ee; }
table.grid tbody tr.new:nth-child(even) td { background: #e7f7ee; } table.grid tbody tr.new:nth-child(even) td { background: #e7f7ee; }
@@ -364,6 +385,7 @@ table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
.fi { display: flex; flex-direction: column; gap: 6px; } .fi { display: flex; flex-direction: column; gap: 6px; }
.fi.full { grid-column: 1 / -1; } .fi.full { grid-column: 1 / -1; }
.fi label { font-size: 12px; font-weight: 600; color: var(--text-2); } .fi label { font-size: 12px; font-weight: 600; color: var(--text-2); }
.fi label .req { color: var(--danger, #e5484d); margin-left: 2px; }
.fi-inline { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; } .fi-inline { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; }
.fi-inline .chk { font-weight: 500; font-size: 13px; color: var(--text); } .fi-inline .chk { font-weight: 500; font-size: 13px; color: var(--text); }
.fullscores { display: flex; flex-direction: column; gap: 6px; } .fullscores { display: flex; flex-direction: column; gap: 6px; }
+115
View File
@@ -36,6 +36,30 @@ if ($missing !== []) {
date_default_timezone_set('Asia/Shanghai'); date_default_timezone_set('Asia/Shanghai');
mb_internal_encoding('UTF-8'); mb_internal_encoding('UTF-8');
// ---------------- 内存上限(数据接口可能处理大量答卷)----------------
// 拉取卷王全量答卷做 json_decode 时极易触顶 128M;在未被禁用 / 未被 php_admin_value 硬锁的前提下
// 临时放宽到 512M。若主机用 php_admin_value 锁定了 memory_limit,下面的 ini_set 会静默失效,
// 需要改主机 php.ini(或 fpm 池配置)才能生效。
(function (): void {
$cur = (string) ini_get('memory_limit');
if ($cur === '' || $cur === '-1') {
return; // 未设置 / 已无限制,无需处理
}
$curBytes = (int) $cur;
$unit = strtoupper(substr($cur, -1));
if ($unit === 'G') {
$curBytes *= 1024 * 1024 * 1024;
} elseif ($unit === 'M') {
$curBytes *= 1024 * 1024;
} elseif ($unit === 'K') {
$curBytes *= 1024;
}
$want = 512 * 1024 * 1024;
if ($curBytes > 0 && $curBytes < $want) {
@ini_set('memory_limit', '512M');
}
})();
// ---------------- 会话 ---------------- // ---------------- 会话 ----------------
function ny_session_start(): void function ny_session_start(): void
{ {
@@ -80,6 +104,91 @@ function ny_logout(): void
session_destroy(); session_destroy();
} }
// ---------------- CSRF 防护 ----------------
/**
* 会话绑定的 CSRF token:写操作(POST)必须携带,防止跨站请求伪造。
* 前端从 config_get / auth_check 响应里拿到 csrf_token,后续写请求附在
* X-CSRF-Token 请求头或 body 的 _csrf 字段。
*/
function ny_csrf_token(): string
{
ny_session_start();
if (empty($_SESSION['ny_csrf']) || !is_string($_SESSION['ny_csrf'])) {
$_SESSION['ny_csrf'] = bin2hex(random_bytes(16));
}
return $_SESSION['ny_csrf'];
}
/** 校验 CSRF token(写操作调用)。失败返回 false 并应拒绝请求。 */
function ny_csrf_ok(): bool
{
$given = '';
if (!empty($_SERVER['HTTP_X_CSRF_TOKEN'])) {
$given = (string)$_SERVER['HTTP_X_CSRF_TOKEN'];
} elseif (!empty($_POST['_csrf'])) {
$given = (string)$_POST['_csrf'];
} else {
// JSON body 里的 _csrf(api.php 写接口多走 JSON)
$raw = (string)file_get_contents('php://input');
if ($raw !== '') {
$j = json_decode($raw, true);
if (is_array($j) && isset($j['_csrf'])) {
$given = (string)$j['_csrf'];
}
}
}
$token = ny_csrf_token();
return $given !== '' && hash_equals($token, $given);
}
// ---------------- 登录失败限流 ----------------
/**
* 登录失败限流:按 IP 记录连续失败次数与时间戳(写入 data/login_fail.json)。
* 5 次失败后锁 10 分钟。仅防御暴力破解,不做超强语义(单机小工具足够)。
*/
function ny_login_throttle_check(): array
{
$path = NY_DATA . '/login_fail.json';
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown');
$rec = ['count' => 0, 'until' => 0];
if (is_file($path)) {
$j = json_decode((string)file_get_contents($path), true);
if (is_array($j) && isset($j[$ip])) {
$rec = array_merge($rec, $j[$ip]);
}
}
return $rec;
}
function ny_login_throttle_hit(): void
{
$path = NY_DATA . '/login_fail.json';
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown');
$all = is_file($path) ? (json_decode((string)file_get_contents($path), true) ?: []) : [];
$rec = isset($all[$ip]) ? $all[$ip] : ['count' => 0, 'until' => 0];
$rec['count'] = ((int)($rec['count'] ?? 0)) + 1;
if ($rec['count'] >= 5) {
$rec['count'] = 0; // 重新计数,进入冷却
$rec['until'] = time() + 600; // 锁 10 分钟
}
$all[$ip] = $rec;
@file_put_contents($path, json_encode($all, JSON_UNESCAPED_UNICODE), LOCK_EX);
}
function ny_login_throttle_reset(): void
{
$path = NY_DATA . '/login_fail.json';
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown');
if (!is_file($path)) {
return;
}
$all = json_decode((string)file_get_contents($path), true) ?: [];
if (isset($all[$ip])) {
unset($all[$ip]);
@file_put_contents($path, json_encode($all, JSON_UNESCAPED_UNICODE), LOCK_EX);
}
}
/** 判断是否已安装(只看文件 config:base_url 非空即视为已具备连接配置) */ /** 判断是否已安装(只看文件 config:base_url 非空即视为已具备连接配置) */
function ny_is_installed(): bool function ny_is_installed(): bool
{ {
@@ -124,6 +233,10 @@ function ny_default_config(): array
'history_display' => 'click', 'history_display' => 'click',
// 成绩导出排序:time=按提交时间降序(默认);sid=按学号(同学号内按提交时间降序) // 成绩导出排序:time=按提交时间降序(默认);sid=按学号(同学号内按提交时间降序)
'export_sort' => 'time', 'export_sort' => 'time',
// 数据结果缓存秒数:0=每次实时拉取;建议 10~30。
// 拉取卷王全量答卷是重操作(多次 HTTP + 大量解码),缓存可避免多人同时刷新时重复打卷王。
// 名单一变指纹就变会自动失效;点"立即刷新"也会 force=1 跳过缓存。
'cache_ttl' => 15,
]; ];
} }
@@ -306,6 +419,8 @@ spl_autoload_register(function (string $class): void {
'SurveyKingClient' => NY_ROOT . '/lib/SurveyKingClient.php', 'SurveyKingClient' => NY_ROOT . '/lib/SurveyKingClient.php',
'Roster' => NY_ROOT . '/lib/Roster.php', 'Roster' => NY_ROOT . '/lib/Roster.php',
'DataService' => NY_ROOT . '/lib/DataService.php', 'DataService' => NY_ROOT . '/lib/DataService.php',
'DataCache' => NY_ROOT . '/lib/DataCache.php',
'Snapshot' => NY_ROOT . '/lib/Snapshot.php',
'Db' => NY_ROOT . '/lib/Db.php', 'Db' => NY_ROOT . '/lib/Db.php',
]; ];
if (isset($map[$class]) && is_file($map[$class])) { if (isset($map[$class]) && is_file($map[$class])) {
-2
View File
@@ -89,8 +89,6 @@ $rosterCount = Roster::count();
<div class="field full hidden" id="ratioBox" style="grid-column:1/-1"></div> <div class="field full hidden" id="ratioBox" style="grid-column:1/-1"></div>
</section> </section>
<div id="alert" class="alert hidden"></div>
<section class="cards" id="statCards"> <section class="cards" id="statCards">
<div class="card accent-blue"><span>候选人数</span><b id="dPool">-</b></div> <div class="card accent-blue"><span>候选人数</span><b id="dPool">-</b></div>
<div class="card accent-green"><span>本次抽取</span><b id="dPicked">-</b></div> <div class="card accent-green"><span>本次抽取</span><b id="dPicked">-</b></div>
+161 -14
View File
@@ -79,20 +79,29 @@ $rosterCount = Roster::count();
<label>搜索</label> <label>搜索</label>
<input type="text" id="kw" placeholder="输入学号或姓名筛选"> <input type="text" id="kw" placeholder="输入学号或姓名筛选">
</div> </div>
<div class="field btns"> <div class="field btns" style="flex-wrap:wrap;gap:8px">
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-2)">导出排序 <label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-2)">导出排序
<select id="exportSortSel" style="height:30px;border-radius:8px;border:1px solid var(--border);padding:0 6px;background:#fff"> <select id="exportSortSel" style="height:30px;border-radius:8px;border:1px solid var(--border);padding:0 6px;background:#fff">
<option value="time">按提交时间</option> <option value="time">按提交时间</option>
<option value="sid">按学号</option> <option value="sid">按学号</option>
<option value="score_desc">按成绩降序</option>
<option value="score_asc">按成绩升序</option>
</select> </select>
</label> </label>
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-2);cursor:pointer" title="每人只导一行:最近一次成绩 + 最高分 + 考试次数">
<input type="checkbox" id="exportLatest" style="width:auto;height:auto">按最新成绩
</label>
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-2);cursor:pointer" title="按班级拆分多个工作表(xlsx 多表)">
<input type="checkbox" id="exportSplit" style="width:auto;height:auto">按班级分表
</label>
<button class="btn" id="btnExport">导出成绩</button> <button class="btn" id="btnExport">导出成绩</button>
<button class="btn" id="btnExportAbsent">导出缺考</button> <button class="btn" id="btnExportAbsent">导出缺考</button>
<button class="btn" id="btnSnapshot" title="保存当前成绩为一份带标签的时间点快照">存快照</button>
<button class="btn" id="btnTrend" title="查看本卷各快照间每名学生的成绩趋势">趋势</button>
<button class="btn" id="btnAnomaly" title="异常检测线索:短时长高分 / 高频重考 / 雷同成绩">异常</button>
</div> </div>
</section> </section>
<div id="alert" class="alert hidden"></div>
<section class="cards" id="statCards"> <section class="cards" id="statCards">
<div class="card accent-blue"><span>名单总人数</span><b id="sTotal">-</b></div> <div class="card accent-blue"><span>名单总人数</span><b id="sTotal">-</b></div>
<div class="card ok accent-green"><span>已提交</span><b id="sSubmit">-</b></div> <div class="card ok accent-green"><span>已提交</span><b id="sSubmit">-</b></div>
@@ -133,9 +142,15 @@ $rosterCount = Roster::count();
</section> </section>
<section class="panel" id="absentPanel"> <section class="panel" id="absentPanel">
<div class="panel-head"><h2>缺考名单 <small id="absentHint"></small></h2></div> <div class="panel-head">
<h2>缺考名单 <small id="absentHint"></small></h2>
<span class="spacer"></span>
<button class="btn sm" id="btnCopySids" title="复制全部缺考学号(逗号分隔)">复制学号</button>
<button class="btn sm" id="btnCopyNames" title="复制全部缺考姓名">复制姓名</button>
<button class="btn sm" id="btnPrintAbsent" title="打印缺考名单">打印</button>
</div>
<div class="table-scroll short"> <div class="table-scroll short">
<table class="grid compact"> <table class="grid compact" id="absentTable">
<thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>组别</th></tr></thead> <thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>组别</th></tr></thead>
<tbody id="absentBody"><tr><td colspan="4" class="empty">—</td></tr></tbody> <tbody id="absentBody"><tr><td colspan="4" class="empty">—</td></tr></tbody>
</table> </table>
@@ -146,8 +161,8 @@ $rosterCount = Roster::count();
<div class="panel-head"><h2>未匹配答卷 <small>姓名不在名单中</small></h2></div> <div class="panel-head"><h2>未匹配答卷 <small>姓名不在名单中</small></h2></div>
<div class="table-scroll short"> <div class="table-scroll short">
<table class="grid compact"> <table class="grid compact">
<thead><tr><th>姓名</th><th>成绩</th><th>提交时间</th></tr></thead> <thead><tr><th>姓名</th><th>成绩</th><th>提交时间</th><th style="width:90px">操作</th></tr></thead>
<tbody id="unmatchedBody"><tr><td colspan="3" class="empty">—</td></tr></tbody> <tbody id="unmatchedBody"><tr><td colspan="4" class="empty">—</td></tr></tbody>
</table> </table>
</div> </div>
</section> </section>
@@ -178,7 +193,8 @@ $rosterCount = Roster::count();
</div> </div>
<div class="fi"> <div class="fi">
<label>每页拉取条数</label> <label>每页拉取条数</label>
<input type="number" id="cfgPageSize" min="20" max="1000"> <input type="number" id="cfgPageSize" min="20" max="200">
<p class="hint">实际生效上限 200,超出会自动夹到 200(防止单页内存耗尽)</p>
</div> </div>
<div class="fi"> <div class="fi">
<label>默认满分(用于合格率)</label> <label>默认满分(用于合格率)</label>
@@ -198,6 +214,8 @@ $rosterCount = Roster::count();
<select id="cfgExportSort"> <select id="cfgExportSort">
<option value="time">按提交时间(默认)</option> <option value="time">按提交时间(默认)</option>
<option value="sid">按学号(同学号按提交时间)</option> <option value="sid">按学号(同学号按提交时间)</option>
<option value="score_desc">按成绩降序</option>
<option value="score_asc">按成绩升序</option>
</select> </select>
<p class="hint">导出包含每次考试记录,始终带「考试次数」列</p> <p class="hint">导出包含每次考试记录,始终带「考试次数」列</p>
</div> </div>
@@ -209,9 +227,11 @@ $rosterCount = Roster::count();
<div class="fi full"> <div class="fi full">
<label>各试卷独立满分</label> <label>各试卷独立满分</label>
<div id="fullScoresBox" class="fullscores"> <div id="fullScoresBox" class="fullscores">
<span class="hint" id="fullScoresHint">未选中试卷时,此处为空;选中某套试卷后保存即在设置中为该卷单独设定满分</span> <span class="hint">未选中试卷时,此处为空;选中某套试卷后保存即在设置中为该卷单独设定满分</span>
</div> </div>
<p class="hint">给某卷填入数字即可单独覆盖默认满分;留空=用默认</p> <!-- 注意:#fullScoresHint 必须放在 #fullScoresBox 之外,
否则 renderFullScores() 的 box.innerHTML 覆盖会把它销毁,导致 textContent 赋值报 null -->
<p class="hint" id="fullScoresHint">给某卷填入数字即可单独覆盖默认满分;留空=用默认</p>
</div> </div>
<div class="fi full checks"> <div class="fi full checks">
<label><input type="checkbox" id="cfgDedupe"> 同一学号只保留最新一份答卷</label> <label><input type="checkbox" id="cfgDedupe"> 同一学号只保留最新一份答卷</label>
@@ -219,9 +239,8 @@ $rosterCount = Roster::count();
<label><input type="checkbox" id="cfgSsl"> 校验 HTTPS 证书</label> <label><input type="checkbox" id="cfgSsl"> 校验 HTTPS 证书</label>
</div> </div>
<p class="hint" style="margin-top:-4px"> <p class="hint" style="margin-top:-4px">
<b>「只保留最新一份」务必注意</b>:勾选后每人只算最后一次提交, <b>「只保留最新一份」说明</b>:勾选后主明细表的「成绩」列取该生最新一次提交的分数,
「考试次数」恒为 1、历史成绩只有一条。<b>想看考试次数与多次考试记录,请取消勾选</b> 取消勾选则取最高分。<b>无论是否勾选,「考试次数」与「历史成绩」始终展示全部提交记录</b>。
(取消后每人一行,成绩取最高分,历史里可看到每一次)。
</p> </p>
<div class="fi full" style="margin-top:6px"> <div class="fi full" style="margin-top:6px">
@@ -277,12 +296,13 @@ $rosterCount = Roster::count();
<input type="text" id="rosterSearch" placeholder="搜索学号 / 姓名"> <input type="text" id="rosterSearch" placeholder="搜索学号 / 姓名">
<span class="spacer"></span> <span class="spacer"></span>
<button class="btn sm" id="btnAddRow">+ 新增</button> <button class="btn sm" id="btnAddRow">+ 新增</button>
<button class="btn sm danger" id="btnDelSel" hidden>删除选中</button>
<button class="btn sm danger" id="btnClearRoster">清空</button> <button class="btn sm danger" id="btnClearRoster">清空</button>
<button class="btn sm btn-primary" id="btnSaveRoster">保存名单</button> <button class="btn sm btn-primary" id="btnSaveRoster">保存名单</button>
</div> </div>
<div class="table-scroll mid"> <div class="table-scroll mid">
<table class="grid compact" id="rosterTable"> <table class="grid compact" id="rosterTable">
<thead><tr><th style="width:28%">学号</th><th style="width:22%">姓名</th><th>班级</th><th>组别</th><th style="width:70px">操作</th></tr></thead> <thead><tr><th style="width:34px"><input type="checkbox" id="rosterCheckAll" title="全选"></th><th style="width:24%">学号</th><th style="width:20%">姓名</th><th>班级</th><th>组别</th><th>登陆账号</th></tr></thead>
<tbody id="rosterBody"></tbody> <tbody id="rosterBody"></tbody>
</table> </table>
</div> </div>
@@ -303,6 +323,133 @@ $rosterCount = Roster::count();
</div> </div>
</div> </div>
<!-- 新增学生弹窗 -->
<div class="modal" id="addStudentModal">
<div class="modal-box">
<div class="modal-head"><h3>新增学生</h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<div class="fi">
<label>学号 <b class="req">*</b></label>
<input type="text" id="addSid" placeholder="必填">
</div>
<div class="fi">
<label>姓名 <b class="req">*</b></label>
<input type="text" id="addName" placeholder="必填">
</div>
<div class="fi">
<label>班级</label>
<input type="text" id="addCls" placeholder="可选">
</div>
<div class="fi">
<label>组别</label>
<input type="text" id="addGrp" placeholder="如 A / B,可选">
</div>
<div class="fi">
<label>登陆账号</label>
<input type="text" id="addLogin" placeholder="可选,用于抽取页登录">
</div>
<p class="hint" id="addStudentHint"></p>
</div>
<div class="modal-foot">
<span class="spacer"></span>
<button class="btn" data-close>取消</button>
<button class="btn btn-primary" id="btnAddStudentOk">添加</button>
</div>
</div>
</div>
<!-- 未匹配答卷映射到名单 -->
<div class="modal" id="mapUnmatchedModal">
<div class="modal-box">
<div class="modal-head"><h3>加入名单</h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<p class="hint" style="margin:0 0 12px">答卷姓名:<b id="mapUmName"></b>(不在名单中)</p>
<div class="fi">
<label>学号 <b class="req">*</b></label>
<input type="text" id="mapUmSid" placeholder="输入该生的学号">
</div>
<div class="fi">
<label>班级</label>
<input type="text" id="mapUmCls" placeholder="可选">
</div>
<div class="fi">
<label>组别</label>
<input type="text" id="mapUmGrp" placeholder="可选">
</div>
<p class="hint" id="mapUmHint"></p>
</div>
<div class="modal-foot">
<span class="spacer"></span>
<button class="btn" data-close>取消</button>
<button class="btn btn-primary" id="btnMapUmOk">加入名单</button>
</div>
</div>
</div>
<!-- 成绩快照 / 趋势 -->
<div class="modal" id="snapshotModal">
<div class="modal-box wide">
<div class="modal-head"><h3 id="snapTitle">成绩快照</h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<div class="fi" style="margin-bottom:10px">
<label>快照标签(可选)</label>
<input type="text" id="snapLabel" placeholder="如:第一次考试、补考后…">
</div>
<div class="snap-toolbar" style="display:flex;gap:8px;margin-bottom:12px">
<button class="btn sm btn-primary" id="btnSnapSave">保存当前快照</button>
<span class="spacer"></span>
<button class="btn sm" id="btnSnapRefresh">刷新列表</button>
<button class="btn sm" id="btnSnapTrend">查看趋势</button>
</div>
<div class="table-scroll mid">
<table class="grid compact" id="snapTable">
<thead><tr><th>标签</th><th>保存时间</th><th>人数</th><th>项目</th><th style="width:70px">操作</th></tr></thead>
<tbody id="snapBody"><tr><td colspan="5" class="empty">暂无快照</td></tr></tbody>
</table>
</div>
</div>
<div class="modal-foot">
<span class="spacer"></span>
<button class="btn" data-close>关闭</button>
</div>
</div>
</div>
<!-- 跨快照趋势 -->
<div class="modal" id="trendModal">
<div class="modal-box wide">
<div class="modal-head"><h3>成绩趋势 <small id="trendSub"></small></h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<div class="table-scroll" style="max-height:60vh">
<table class="grid compact" id="trendTable">
<thead id="trendHead"></thead>
<tbody id="trendBody"><tr><td colspan="10" class="empty">加载中…</td></tr></tbody>
</table>
</div>
<p class="hint" id="trendHint">趋势值为每名学生的「最高分」,未出现该快照的记「—」</p>
</div>
<div class="modal-foot">
<span class="spacer"></span>
<button class="btn" data-close>关闭</button>
</div>
</div>
</div>
<!-- 异常检测线索 -->
<div class="modal" id="anomalyModal">
<div class="modal-box wide">
<div class="modal-head"><h3>异常检测线索</h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<p class="hint" style="margin:0 0 12px">以下为自动统计的「防作弊线索」,仅供人工复核判断,不代表结论。</p>
<div id="anomalyBody"></div>
</div>
<div class="modal-foot">
<span class="spacer"></span>
<button class="btn" data-close>关闭</button>
</div>
</div>
</div>
<script> <script>
window.NY = { window.NY = {
appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>, appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>,
+117
View File
@@ -0,0 +1,117 @@
<?php
/**
* 数据结果缓存(短期 TTL)
*
* 目的:拉取卷王全量答卷是重操作(多次 HTTP + 大量 json_decode),
* 多个老师同时查看同一套卷、或页面按 5~15 秒自动刷新时,会重复打卷王。
* 这里把 DataService::build() 的结果按「项目 + 配置 + 名单指纹」缓存一小段时间。
*
* 失效保证:名单变化(Roster::fingerprint)、连接/去重等配置变化都会改变 key。
* 另外可显式 force=1 跳过缓存(前端"立即刷新"按钮使用)。
*/
declare(strict_types=1);
final class DataCache
{
private static function dir(): string
{
return NY_DATA . '/cache';
}
/** 由若干要素拼成缓存 key */
public static function key(array $parts): string
{
return md5(implode("\x1f", array_map(static fn($v) => (string)$v, $parts)));
}
private static function path(string $key): string
{
return self::dir() . '/data_' . $key . '.json';
}
private static function lockPath(string $key): string
{
return self::dir() . '/data_' . $key . '.lock';
}
/**
* 读取缓存;不存在 / 过期 / 损坏都返回 null
* @return array{at:int,result:array}|null
*/
public static function read(string $key, int $ttl): ?array
{
$f = self::path($key);
if (!is_file($f)) {
return null;
}
$raw = @file_get_contents($f);
if ($raw === false || $raw === '') {
return null;
}
$d = json_decode($raw, true);
if (!is_array($d) || !isset($d['at']) || !is_array($d['result'])) {
return null;
}
if ($ttl > 0 && (time() - (int)$d['at']) > $ttl) {
return null;
}
return ['at' => (int)$d['at'], 'result' => $d['result']];
}
public static function write(string $key, array $result): bool
{
if (!is_dir(self::dir())) {
@mkdir(self::dir(), 0775, true);
}
$tmp = self::path($key) . '.tmp';
$ok = @file_put_contents($tmp, json_encode(
['at' => time(), 'result' => $result],
JSON_UNESCAPED_UNICODE
), LOCK_EX);
if ($ok === false) {
return false;
}
return @rename($tmp, self::path($key));
}
/**
* 获取构建锁(防止并发惊群:同一 key 只有一个请求真正去拉卷王)。
*
* @return resource|null 拿到锁返回句柄(需配合 releaseLock 释放);拿不到返回 null
*/
public static function acquireLock(string $key)
{
if (!is_dir(self::dir())) {
@mkdir(self::dir(), 0775, true);
}
$fh = @fopen(self::lockPath($key), 'c');
if ($fh === false) {
return null;
}
if (!flock($fh, LOCK_EX | LOCK_NB)) {
fclose($fh);
return null; // 已有其它请求在构建
}
return $fh;
}
public static function releaseLock($fh): void
{
if (is_resource($fh)) {
@flock($fh, LOCK_UN);
@fclose($fh);
}
}
/** 清空所有数据缓存(名单变更 / 手动清缓存时使用) */
public static function clear(): int
{
$n = 0;
foreach (glob(self::dir() . '/data_*.json') ?: [] as $f) {
if (@unlink($f)) {
$n++;
}
}
return $n;
}
}
+93 -2
View File
@@ -235,8 +235,12 @@ class DataService
$attemptsTotal = $agg['attempts_total']; $attemptsTotal = $agg['attempts_total'];
// 成绩明细默认按提交时间降序(最新在前),学号作为次级排序 // 成绩明细默认按提交时间降序(最新在前),学号作为次级排序
usort($rows, static function (array $x, array $y): int { // 重要:排序键必须用「最近一次提交时间 last_submit_ts」,而不是代表行的 submit_ts。
$d = ($y['submit_ts'] ?? 0) <=> ($x['submit_ts'] ?? 0); // 否则当 dedupe=false(代表成绩=最高分)时,学生重考且分数更低,代表行仍是最高分那次,
// submit_ts 不变 → 该行会停在原来的位置只高亮,不会置顶,教师登记时看不到新提交。
$sortTs = static fn(array $r): int => (int)($r['last_submit_ts'] ?? 0) ?: (int)($r['submit_ts'] ?? 0);
usort($rows, static function (array $x, array $y) use ($sortTs): int {
$d = $sortTs($y) <=> $sortTs($x);
if ($d !== 0) { if ($d !== 0) {
return $d; return $d;
} }
@@ -290,6 +294,9 @@ class DataService
'attempts_total'=> $attemptsTotal, 'attempts_total'=> $attemptsTotal,
]; ];
// 异常检测(防作弊线索,仅供教师人工判断,不做自动判定)
$anomalies = self::detectAnomalies($rows, $stats);
return [ return [
'project' => [ 'project' => [
'id' => ny_str($project['id'] ?? $projectId), 'id' => ny_str($project['id'] ?? $projectId),
@@ -303,12 +310,96 @@ class DataService
'absent' => $absent, 'absent' => $absent,
'unmatched' => $unmatched, 'unmatched' => $unmatched,
'stats' => $stats, 'stats' => $stats,
'anomalies' => $anomalies,
'roster' => $students, 'roster' => $students,
'roster_count' => count($students), 'roster_count' => count($students),
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
]; ];
} }
/**
* 异常检测(防作弊线索):基于聚合后的学生行,输出三类线索供教师人工复核。
* 1) 短时长高得分:用时远低于平均(<平均 40% 且 <180s)却接近满分;
* 2) 高频重考:考试次数明显多于多数人(>1 且 >= 平均次数+3);
* 3) 雷同成绩:多人分数完全相同(疑似互抄,需满足该分数下人数 >=3)。
*
* 返回值:['fast_high'=>[], 'frequent'=>[], 'identical'=>[['score'=>x,'count'=>n,'names'=>[...]]]]
*/
public static function detectAnomalies(array $rows, array $stats): array
{
$avgDur = (int)($stats['avg_duration'] ?? 0);
$fullHint = max(1, (float)($stats['max_score'] ?? 100));
$fastHigh = [];
$frequent = [];
$attemptsAll = [];
foreach ($rows as $r) {
$dur = $r['duration'] ?? null;
$score = $r['score'] ?? null;
$attempts = (int)($r['attempts'] ?? 1);
$attemptsAll[] = $attempts;
// 短时长高得分:有时长记录、明显快于平均、分数 >= 满分 85%
if ($dur !== null && $avgDur > 0 && $dur < max(60, (int)($avgDur * 0.4))
&& $score !== null && $score >= $fullHint * 0.85) {
$fastHigh[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
'score' => $score,
'duration' => $dur,
'duration_txt' => (string)($r['duration_txt'] ?? ''),
];
}
// 高频重考:考试次数明显偏多
if ($attempts > 1) {
$frequent[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
'attempts' => $attempts,
'best_score' => $r['best_score'] ?? null,
];
}
}
// 高频重考阈值:平均次数 + 3(且至少 4 次才够"高频")
$avgAttempts = $attemptsAll !== [] ? array_sum($attemptsAll) / count($attemptsAll) : 0;
$freqThreshold = max(4, (int)ceil($avgAttempts + 3));
$frequent = array_values(array_filter($frequent, static fn($f) => $f['attempts'] >= $freqThreshold));
usort($frequent, static fn($a, $b) => $b['attempts'] <=> $a['attempts']);
// 雷同成绩:按分数分组,出现 >=3 人同分(且分数不为 null)视为疑似
$scoreGroups = [];
foreach ($rows as $r) {
$score = $r['score'] ?? null;
if ($score === null) {
continue;
}
$key = (string)$score;
$scoreGroups[$key][] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
];
}
$identical = [];
foreach ($scoreGroups as $score => $members) {
if (count($members) >= 3) {
$identical[] = ['score' => (float)$score, 'count' => count($members), 'members' => $members];
}
}
usort($identical, static fn($a, $b) => $b['count'] <=> $a['count']);
return [
'fast_high' => $fastHigh,
'frequent' => $frequent,
'identical' => $identical,
];
}
/** /**
* 把聚合后的学生行展开为"每次考试一行",用于导出全部考试记录 / 明细内全部展示。 * 把聚合后的学生行展开为"每次考试一行",用于导出全部考试记录 / 明细内全部展示。
* 每行携带该次考试的成绩、提交时间、时长,以及该学生的总考试次数 attempts 与第几次 idx。 * 每行携带该次考试的成绩、提交时间、时长,以及该学生的总考试次数 attempts 与第几次 idx。
+17
View File
@@ -98,6 +98,23 @@ class Roster
return count(self::all()); return count(self::all());
} }
/**
* 名单指纹:名单内容一变就变,用于数据缓存 key。
* 只取「人数 + 文件修改时间 + 内容的轻量哈希」,避免每次全量序列化。
*/
public static function fingerprint(): string
{
if (self::useDb()) {
return 'db:' . self::count();
}
$f = self::file();
$mt = is_file($f) ? (string)filemtime($f) : '0';
$sz = is_file($f) ? (string)filesize($f) : '0';
$d = ny_read_json($f, null);
$hash = is_array($d) ? md5(json_encode($d['students'] ?? [], JSON_UNESCAPED_UNICODE)) : '0';
return $mt . ':' . $sz . ':' . $hash;
}
/** /**
* 首次使用时,若名单为空且存在 导入模板.xlsx,自动导入 * 首次使用时,若名单为空且存在 导入模板.xlsx,自动导入
*/ */
+164
View File
@@ -0,0 +1,164 @@
<?php
/**
* 成绩快照:把某次「拉取到的成绩」落盘成一份带标签的时间点存档,
* 用于跨时间 / 跨试卷的趋势对比(例如某学生几次考试的分数变化)。
*
* 存储位置:data/snapshots/<projectId>/<unix秒>_<md5(标签)>.json
* 每份快照内容为 { label, at, project:{id,name}, stats, students:[{sid,name,cls,grp,score,best_score,attempts,last_submit_at}] }
*/
declare(strict_types=1);
final class Snapshot
{
private static function dir(string $projectId): string
{
$d = NY_DATA . '/snapshots/' . preg_replace('/[^A-Za-z0-9_-]/', '_', $projectId);
if (!is_dir($d)) {
@mkdir($d, 0777, true);
}
return $d;
}
/** 从聚合结果里抽取轻量学生摘要(只保留趋势对比需要的字段) */
public static function summarize(array $result, string $label): array
{
$students = [];
foreach ((array)($result['rows'] ?? []) as $r) {
$students[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
'grp' => (string)($r['grp'] ?? ''),
'score' => ($r['score'] ?? null) === null ? null : (float)$r['score'],
'best_score' => ($r['best_score'] ?? null) === null ? null : (float)$r['best_score'],
'attempts' => (int)($r['attempts'] ?? 1),
'last_submit_at' => (string)($r['last_submit_at'] ?? ($r['submit_at'] ?? '-')),
];
}
// 按学号排序,保证趋势表行序稳定
usort($students, static function ($a, $b): int {
$sa = $a['sid']; $sb = $b['sid'];
if (is_numeric($sa) && is_numeric($sb)) {
return $sa <=> $sb;
}
return strnatcmp((string)$sa, (string)$sb);
});
return [
'label' => trim($label) === '' ? '快照 ' . date('m-d H:i') : trim($label),
'at' => time(),
'at_txt' => date('Y-m-d H:i:s'),
'project' => [
'id' => (string)($result['project']['id'] ?? ''),
'name' => (string)($result['project']['name'] ?? ''),
],
'stats' => $result['stats'] ?? [],
'students' => $students,
];
}
/** 保存一份快照,返回文件名 */
public static function save(string $projectId, array $snap): string
{
$dir = self::dir($projectId);
$name = time() . '_' . substr(md5($snap['label'] . $snap['at']), 0, 8) . '.json';
@file_put_contents($dir . '/' . $name, json_encode($snap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
return $name;
}
/** 列出某项目的全部快照(按时间倒序) */
public static function list(string $projectId): array
{
$dir = self::dir($projectId);
$out = [];
foreach (glob($dir . '/*.json') ?: [] as $f) {
$raw = @file_get_contents($f);
if ($raw === false) {
continue;
}
$j = json_decode($raw, true);
if (!is_array($j)) {
continue;
}
$out[] = [
'file' => basename($f),
'label' => (string)($j['label'] ?? basename($f)),
'at' => (int)($j['at'] ?? 0),
'at_txt' => (string)($j['at_txt'] ?? ''),
'project' => $j['project'] ?? [],
'count' => count((array)($j['students'] ?? [])),
];
}
usort($out, static fn($a, $b) => ($b['at'] <=> $a['at']));
return $out;
}
/** 读取单份快照完整内容 */
public static function get(string $projectId, string $file): ?array
{
$file = basename($file); // 防路径穿越
$path = self::dir($projectId) . '/' . $file;
if (!is_file($path)) {
return null;
}
$j = json_decode((string)@file_get_contents($path), true);
return is_array($j) ? $j : null;
}
/** 删除单份快照 */
public static function delete(string $projectId, string $file): bool
{
$file = basename($file);
$path = self::dir($projectId) . '/' . $file;
if (is_file($path)) {
return @unlink($path);
}
return false;
}
/**
* 跨快照趋势:以学号为行、各快照为列,输出每个学生在每份快照里的成绩。
* 用 best_score(真实最高分)作为趋势值,未出现记 null。
*/
public static function trend(array $snapshots): array
{
// 按时间升序排,最早的列在最前
$snaps = $snapshots;
usort($snaps, static fn($a, $b) => ($a['at'] <=> $b['at']));
$cols = [];
$bySid = [];
foreach ($snaps as $sn) {
$colKey = (string)$sn['at'] . ':' . (string)$sn['label'];
$cols[] = ['key' => $colKey, 'label' => (string)$sn['label'], 'at_txt' => (string)($sn['at_txt'] ?? '')];
foreach ((array)($sn['students'] ?? []) as $st) {
$sid = (string)($st['sid'] ?? '');
if ($sid === '') {
continue;
}
if (!isset($bySid[$sid])) {
$bySid[$sid] = [
'sid' => $sid,
'name' => (string)($st['name'] ?? ''),
'cls' => (string)($st['cls'] ?? ''),
'scores' => [],
];
}
$bySid[$sid]['scores'][$colKey] = ($st['best_score'] ?? null) === null
? null
: (float)$st['best_score'];
}
}
$rows = array_values($bySid);
// 按学号排序
usort($rows, static function ($a, $b): int {
if (is_numeric($a['sid']) && is_numeric($b['sid'])) {
return $a['sid'] <=> $b['sid'];
}
return strnatcmp((string)$a['sid'], (string)$b['sid']);
});
return ['columns' => $cols, 'rows' => $rows];
}
}
+13 -4
View File
@@ -187,7 +187,10 @@ class SurveyKingClient
$msg = (string)($decoded['msg'] ?? $decoded['message'] ?? ''); $msg = (string)($decoded['msg'] ?? $decoded['message'] ?? '');
} }
return ['status' => $status, 'headers' => $h, 'data' => $data, 'raw' => $rawBody, 'code' => $code, 'msg' => $msg]; // 仅保留前若干字符用于错误诊断;完整正文不再随返回值常驻内存,
// 避免大答卷响应在 json_decode 之外再额外占用一倍内存。
$rawDiag = mb_strcut($rawBody, 0, 2000);
return ['status' => $status, 'headers' => $h, 'data' => $data, 'raw' => $rawDiag, 'code' => $code, 'msg' => $msg];
} }
/** /**
@@ -448,7 +451,7 @@ class SurveyKingClient
/** 问卷 / 考试列表 */ /** 问卷 / 考试列表 */
public function listProjects(): array public function listProjects(): array
{ {
$size = max(20, (int)($this->cfg['page_size'] ?? 200)); $size = min(200, max(20, (int)($this->cfg['page_size'] ?? 100)));
$all = []; $all = [];
for ($page = 1; $page <= 50; $page++) { for ($page = 1; $page <= 50; $page++) {
$r = $this->request('/project/list', ['current' => $page, 'pageSize' => $size]); $r = $this->request('/project/list', ['current' => $page, 'pageSize' => $size]);
@@ -483,7 +486,9 @@ class SurveyKingClient
/** 答卷列表(自动翻页取全量) */ /** 答卷列表(自动翻页取全量) */
public function listAnswers(string $projectId): array public function listAnswers(string $projectId): array
{ {
$size = max(20, (int)($this->cfg['page_size'] ?? 200)); // 单页上限夹在 20~200:无论配置 page_size 多大,单次 json_decode 的体量都被限制住,
// 避免一份超大响应直接撑爆 128M。100 页 × 200 = 最多 2 万份,足以覆盖学校级考试。
$size = min(200, max(20, (int)($this->cfg['page_size'] ?? 100)));
$all = []; $all = [];
for ($page = 1; $page <= 100; $page++) { for ($page = 1; $page <= 100; $page++) {
$r = $this->request('/answer/list', [ $r = $this->request('/answer/list', [
@@ -502,10 +507,14 @@ class SurveyKingClient
} }
} }
$total = is_array($data) ? (int)($data['total'] ?? 0) : 0; $total = is_array($data) ? (int)($data['total'] ?? 0) : 0;
// 及时释放本页引用,降低峰值内存占用
$r = null;
$data = null;
$list = null;
if ($total > 0 && count($all) >= $total) { if ($total > 0 && count($all) >= $total) {
break; break;
} }
if (count($list) < $size) { if (count($all) < $size) {
break; break;
} }
} }
+138 -46
View File
@@ -15,10 +15,8 @@ final class XlsxWriter
private array $strIndex = []; private array $strIndex = [];
/** @var array<int,string> 索引 => 共享字符串 */ /** @var array<int,string> 索引 => 共享字符串 */
private array $strings = []; private array $strings = [];
/** @var array<int,array<int,string>> 行数据 */ /** @var array<int,array{name:string,rows:array,widths:array}> 工作表(按输出顺序) */
private array $rows = []; private array $sheets = [];
/** @var array<int,float> 列宽(1 基) */
private array $widths = [];
// 样式索引(见 stylesXml) // 样式索引(见 stylesXml)
private const S_DEFAULT = 0; // 常规 private const S_DEFAULT = 0; // 常规
@@ -26,57 +24,132 @@ final class XlsxWriter
private const S_TEXT = 2; // 文本格式(numFmtId=49) private const S_TEXT = 2; // 文本格式(numFmtId=49)
/** /**
* 生成 xlsx 二进制内容 * 生成单表 xlsx 二进制内容(兼容旧调用)
* *
* @param array $rows 二维表,第一行视为表头;每个元素会被强制转成字符串 * @param array $rows 二维表,第一行视为表头;每个元素会被强制转成字符串
* @param array $opts ['widths' => [列宽,...], 'title' => 工作表名] * @param array $opts ['widths' => [列宽,...], 'title' => 工作表名]
* @return string xlsx 文件二进制 * @return string xlsx 文件二进制
*/ */
public static function render(array $rows, array $opts = []): string public static function render(array $rows, array $opts = []): string
{
return self::renderMulti([[
'name' => (string)($opts['title'] ?? 'Sheet1'),
'rows' => $rows,
'widths' => array_values((array)($opts['widths'] ?? [])),
]]);
}
/**
* 生成多工作表 xlsx
*
* 说明:所有单元格都写成「共享字符串 + 文本格式(numFmtId=49)」,
* 因此既不会出现长学号被转成科学计数法,也不会把以 = + - @ 开头的内容
* 当成公式执行(天然规避 CSV 公式注入问题)。
*
* @param array $sheets 每个元素:['name'=>工作表名, 'rows'=>二维表, 'widths'=>[列宽...]]
* @return string xlsx 文件二进制
*/
public static function renderMulti(array $sheets): string
{ {
$w = new self(); $w = new self();
$w->rows = array_values($rows); foreach ($sheets as $sh) {
$w->widths = array_values((array)($opts['widths'] ?? [])); if (!is_array($sh)) {
return $w->build((string)($opts['title'] ?? 'Sheet1')); continue;
}
$rows = array_values((array)($sh['rows'] ?? []));
if ($rows === []) {
continue; // 空表不输出,避免 Excel 提示修复
}
$w->sheets[] = [
'name' => self::safeSheetName((string)($sh['name'] ?? ''), count($w->sheets) + 1, $w->sheets),
'rows' => $rows,
'widths' => array_values((array)($sh['widths'] ?? [])),
];
}
if ($w->sheets === []) {
// 保底:至少输出一张空表
$w->sheets[] = ['name' => 'Sheet1', 'rows' => [['']], 'widths' => []];
}
return $w->build();
} }
/* ---------------- 内部:构建 ---------------- */ /* ---------------- 内部:构建 ---------------- */
private function build(string $title): string private function build(): string
{ {
$maxCol = 1;
foreach ($this->rows as $r) {
$maxCol = max($maxCol, count((array)$r));
}
$maxRow = max(1, count($this->rows));
// 先登记所有字符串,生成 sharedStrings 索引
$cells = [];
foreach ($this->rows as $ri => $row) {
$r = (int)$ri + 1;
foreach (array_values((array)$row) as $ci => $val) {
$c = (int)$ci + 1;
$s = $this->intern((string)($val ?? ''));
$cells[$r][$c] = $s;
}
}
$sheet = $this->sheetXml($cells, $maxRow, $maxCol);
$files = [ $files = [
'[Content_Types].xml' => $this->contentTypesXml(), '[Content_Types].xml' => '', // 占位,下面按表数量生成
'_rels/.rels' => $this->rootRelsXml(),
'docProps/app.xml' => $this->appXml(),
'docProps/core.xml' => $this->coreXml($title),
'xl/workbook.xml' => $this->workbookXml($title),
'xl/_rels/workbook.xml.rels' => $this->workbookRelsXml(),
'xl/styles.xml' => $this->stylesXml(),
'xl/sharedStrings.xml' => $this->sharedStringsXml(),
'xl/worksheets/sheet1.xml' => $sheet,
]; ];
$sheetXmls = [];
$names = [];
foreach ($this->sheets as $i => $sh) {
$n = $i + 1;
$maxCol = 1;
foreach ($sh['rows'] as $r) {
$maxCol = max($maxCol, count((array)$r));
}
$maxRow = max(1, count($sh['rows']));
// 登记共享字符串并生成单元格索引
$cells = [];
foreach (array_values($sh['rows']) as $ri => $row) {
$r = (int)$ri + 1;
foreach (array_values((array)$row) as $ci => $val) {
$c = (int)$ci + 1;
$cells[$r][$c] = $this->intern((string)($val ?? ''));
}
}
$sheetXmls['xl/worksheets/sheet' . $n . '.xml'] = $this->sheetXml($cells, $maxRow, $maxCol, $sh['widths']);
$names[] = $sh['name'];
}
$count = count($this->sheets);
$files = [
'[Content_Types].xml' => $this->contentTypesXml($count),
'_rels/.rels' => $this->rootRelsXml(),
'docProps/app.xml' => $this->appXml(),
'docProps/core.xml' => $this->coreXml($names[0] ?? 'Sheet1'),
'xl/workbook.xml' => $this->workbookXml($names),
'xl/_rels/workbook.xml.rels' => $this->workbookRelsXml($count),
'xl/styles.xml' => $this->stylesXml(),
'xl/sharedStrings.xml' => $this->sharedStringsXml(),
] + $sheetXmls;
return $this->zip($files); return $this->zip($files);
} }
/**
* 工作表名安全化:Excel 限制 31 字符、禁止 []:*?/\、不可重复、不可为空
*/
private static function safeSheetName(string $name, int $seq, array $existing): string
{
$bad = ['\\', '/', '?', '*', '[', ']', ':'];
$n = trim(str_replace($bad, '_', $name));
if ($n === '') {
$n = 'Sheet' . $seq;
}
if (function_exists('mb_substr')) {
$n = mb_substr($n, 0, 31, 'UTF-8');
} else {
$n = substr($n, 0, 31);
}
// 去重
$base = $n;
$i = 2;
$taken = [];
foreach ($existing as $e) {
$taken[strtolower($e['name'])] = true;
}
while (isset($taken[strtolower($n)])) {
$suffix = '(' . $i . ')';
$n = (function_exists('mb_substr') ? mb_substr($base, 0, 31 - strlen($suffix), 'UTF-8') : substr($base, 0, 31 - strlen($suffix))) . $suffix;
$i++;
}
return $n;
}
private function intern(string $s): int private function intern(string $s): int
{ {
if (!array_key_exists($s, $this->strIndex)) { if (!array_key_exists($s, $this->strIndex)) {
@@ -87,13 +160,13 @@ final class XlsxWriter
return $this->strIndex[$s]; return $this->strIndex[$s];
} }
private function sheetXml(array $cells, int $maxRow, int $maxCol): string private function sheetXml(array $cells, int $maxRow, int $maxCol, array $widths = []): string
{ {
$dim = 'A1:' . self::colName($maxCol) . $maxRow; $dim = 'A1:' . self::colName($maxCol) . $maxRow;
$cols = ''; $cols = '';
for ($c = 1; $c <= $maxCol; $c++) { for ($c = 1; $c <= $maxCol; $c++) {
$w = $this->widths[$c - 1] ?? 16; $w = $widths[$c - 1] ?? 16;
$cols .= '<col min="' . $c . '" max="' . $c . '" width="' . $w . '" customWidth="1"/>'; $cols .= '<col min="' . $c . '" max="' . $c . '" width="' . $w . '" customWidth="1"/>';
} }
@@ -126,14 +199,19 @@ final class XlsxWriter
'</worksheet>'; '</worksheet>';
} }
private function contentTypesXml(): string private function contentTypesXml(int $sheetCount): string
{ {
$overrides = '';
for ($i = 1; $i <= max(1, $sheetCount); $i++) {
$overrides .= '<Override PartName="/xl/worksheets/sheet' . $i . '.xml" ' .
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" . return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' . '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' .
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' . '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' .
'<Default Extension="xml" ContentType="application/xml"/>' . '<Default Extension="xml" ContentType="application/xml"/>' .
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' . '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' .
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' . $overrides .
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' . '<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' .
'<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>' . '<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>' .
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' . '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' .
@@ -151,22 +229,36 @@ final class XlsxWriter
'</Relationships>'; '</Relationships>';
} }
private function workbookXml(string $title): string /** @param array<int,string> $names 工作表名(已安全化) */
private function workbookXml(array $names): string
{ {
$sheets = '';
foreach (array_values($names) as $i => $nm) {
$n = (int)$i + 1;
$sheets .= '<sheet name="' . self::xml((string)$nm) . '" sheetId="' . $n . '" r:id="rId' . $n . '"/>';
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" . return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' . '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' .
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' . 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' .
'<sheets><sheet name="' . self::xml($title) . '" sheetId="1" r:id="rId1"/></sheets>' . '<sheets>' . $sheets . '</sheets>' .
'</workbook>'; '</workbook>';
} }
private function workbookRelsXml(): string private function workbookRelsXml(int $sheetCount): string
{ {
$rels = '';
for ($i = 1; $i <= max(1, $sheetCount); $i++) {
$rels .= '<Relationship Id="rId' . $i . '" ' .
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" ' .
'Target="worksheets/sheet' . $i . '.xml"/>';
}
$stylesId = max(1, $sheetCount) + 1;
$sstId = max(1, $sheetCount) + 2;
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" . return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' . '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' .
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' . $rels .
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' . '<Relationship Id="rId' . $stylesId . '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' .
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>' . '<Relationship Id="rId' . $sstId . '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>' .
'</Relationships>'; '</Relationships>';
} }
+43 -23
View File
@@ -8,8 +8,19 @@ declare(strict_types=1);
require __DIR__ . '/bootstrap.php'; require __DIR__ . '/bootstrap.php';
if (ny_is_auth()) { if (ny_is_auth()) {
header('Location: index.php'); // 已登录但卷王 token 可能已过期:用当前 token 探一次卷王 /system,能 200 即视为有效,否则清掉本地会话强制重新登录
exit; try {
$client = new SurveyKingClient($cfg);
$client->ping();
} catch (Throwable $e) {
// 卷王 token 已失效或配置错误 → 清会话 + 强制显示登录页(避免 login.php 把人踢回 index.php 形成死循环)
$_SESSION = [];
@session_destroy();
}
if (ny_is_auth()) {
header('Location: index.php');
exit;
}
} }
$cfg = ny_load_config(); $cfg = ny_load_config();
@@ -17,28 +28,37 @@ $err = '';
$ok = false; $ok = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$user = trim((string)($_POST['username'] ?? $cfg['username'])); // 登录失败限流:5 次失败锁 10 分钟,防止暴力破解
$pwd = (string)($_POST['password'] ?? ''); $throttle = ny_login_throttle_check();
// 允许用户在本页调整账号;密码必须输入 if ($throttle['until'] > time()) {
if ($user === '' || $pwd === '') { $wait = (int)(($throttle['until'] - time()) / 60) + 1;
$err = '请输入账号与密码。'; $err = '尝试次数过多,请 ' . $wait . ' 分钟后再试。';
} else { } else {
// 用当前配置的连接信息 + 本页输入的账密做一次卷王登录 $user = trim((string)($_POST['username'] ?? $cfg['username']));
$test = $cfg; $pwd = (string)($_POST['password'] ?? '');
$test['username'] = $user; // 允许用户在本页调整账号;密码必须输入
$test['password'] = $pwd; if ($user === '' || $pwd === '') {
try { $err = '请输入账号与密码。';
$client = new SurveyKingClient($test); } else {
$client->getToken(true); // 用当前配置的连接信息 + 本页输入的账密做一次卷王登录
// 登录成功:把账号与密码写回配置(本地单用户工具,持久化密码以便 token $test = $cfg;
// 失效时平台能自动重新登录卷王,避免后台刷新陷入"未配置账号"的死状态) $test['username'] = $user;
$cfg['username'] = $user; $test['password'] = $pwd;
$cfg['password'] = $pwd; try {
ny_save_config($cfg); $client = new SurveyKingClient($test);
ny_login(); $client->getToken(true);
$ok = true; // 登录成功:把账号与密码写回配置(本地单用户工具,持久化密码以便 token
} catch (Throwable $e) { // 失效时平台能自动重新登录卷王,避免后台刷新陷入"未配置账号"的死状态)
$err = '登录失败:' . $e->getMessage(); $cfg['username'] = $user;
$cfg['password'] = $pwd;
ny_save_config($cfg);
ny_login();
ny_login_throttle_reset();
$ok = true;
} catch (Throwable $e) {
ny_login_throttle_hit();
$err = '登录失败:' . $e->getMessage();
}
} }
} }
} }
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# 一键跑完所有回归测试(PHP 单元自测 + Node e2e)。
# 用法:bash tests/run-all.sh
set -e
cd "$(dirname "$0")/.."
PHP="${PHP_BIN:-C:/Users/Zhang/.workbuddy/binaries/php/php.exe}"
NODE="${NODE_BIN:-C:/Users/Zhang/.workbuddy/binaries/node/versions/22.22.2-2/node.exe}"
INI="-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite"
echo "================ PHP 自测 ================"
for f in tests/test-aggregate.php tests/test-new-features.php tools/test-xlsxwriter.php; do
echo ""
echo ">>> $f"
"$PHP" $INI "$f"
done
echo ""
echo "================ Node e2e(需 mock-server + PHP 内置服务器) ================"
for f in tools/test-config-save.js tools/test-draw-alloc.js; do
echo ""
echo ">>> $f"
"$NODE" "$f"
done
echo ""
echo "全部测试完成。"
+137
View File
@@ -0,0 +1,137 @@
<?php
/**
* 新功能回归测试:
* 1) 异常检测 detectAnomalies(短时长高分 / 高频重考 / 雷同成绩)
* 2) 成绩快照 Snapshot::summarize / trend
* 3) 成绩明细排序:再次提交应置顶(last_submit_ts 排序)
* 4) XlsxWriter 多工作表 renderMulti(分表 + 名字去重/截断)
*/
declare(strict_types=1);
require __DIR__ . '/../bootstrap.php';
$pass = 0;
$fail = 0;
function check(string $name, bool $cond, string $detail = ''): void
{
global $pass, $fail;
if ($cond) { $pass++; echo " ✅ {$name}\n"; }
else { $fail++; echo " ❌ {$name} {$detail}\n"; }
}
/* ============ 1) 异常检测 ============ */
echo "\n== 异常检测 ==\n";
// 构造:3 人同分(雷同)、1 人短时长高分、1 人高频重考
function mkRow(string $sid, string $name, ?float $score, ?int $duration, int $attempts): array
{
return [
'sid' => $sid, 'name' => $name, 'cls' => '1班', 'grp' => 'A',
'score' => $score, 'duration' => $duration, 'duration_txt' => $duration === null ? '-' : ($duration . '秒'),
'attempts' => $attempts, 'best_score' => $score,
];
}
$rows = [
mkRow('S01', '甲', 90.0, 600, 1),
mkRow('S02', '乙', 90.0, 610, 1), // 与甲同分
mkRow('S03', '丙', 90.0, 620, 1), // 三人同分 → 雷同
mkRow('S04', '丁', 95.0, 30, 1), // 短时长(30s)+ 高分
mkRow('S05', '戊', 70.0, 500, 9), // 高频重考 9 次
];
$stats = ['avg_duration' => 600, 'max_score' => 100.0];
$anom = DataService::detectAnomalies($rows, $stats);
check('雷同成绩:捕获 90 分 3 人组', count($anom['identical']) >= 1 && (float)$anom['identical'][0]['score'] === 90.0,
json_encode($anom['identical'], JSON_UNESCAPED_UNICODE));
check('雷同成绩:人数 = 3', (int)($anom['identical'][0]['count'] ?? 0) === 3);
check('短时长高分:捕获丁', count($anom['fast_high']) >= 1 && ($anom['fast_high'][0]['sid'] ?? '') === 'S04',
json_encode($anom['fast_high'], JSON_UNESCAPED_UNICODE));
check('高频重考:捕获戊(9 次)', count($anom['frequent']) >= 1 && ($anom['frequent'][0]['sid'] ?? '') === 'S05',
json_encode($anom['frequent'], JSON_UNESCAPED_UNICODE));
check('正常学生(甲 1 次)不被误报高频', !in_array('S01', array_column($anom['frequent'], 'sid'), true));
/* ============ 2) 快照 summarize / trend ============ */
echo "\n== 成绩快照 ==\n";
$result1 = [
'project' => ['id' => 'P1', 'name' => '数学测验'],
'stats' => ['avg_score' => 85],
'rows' => [
['sid' => 'S01', 'name' => '甲', 'cls' => '1班', 'grp' => 'A', 'score' => 90, 'best_score' => 95, 'attempts' => 2, 'last_submit_at' => '2026-09-10 10:00:00'],
['sid' => 'S02', 'name' => '乙', 'cls' => '1班', 'grp' => 'A', 'score' => 80, 'best_score' => 80, 'attempts' => 1, 'last_submit_at' => '2026-09-10 10:05:00'],
],
];
$result2 = [
'project' => ['id' => 'P1', 'name' => '数学测验'],
'stats' => ['avg_score' => 88],
'rows' => [
['sid' => 'S01', 'name' => '甲', 'cls' => '1班', 'grp' => 'A', 'score' => 92, 'best_score' => 96, 'attempts' => 3, 'last_submit_at' => '2026-09-10 12:00:00'],
['sid' => 'S03', 'name' => '丙', 'cls' => '2班', 'grp' => 'B', 'score' => 88, 'best_score' => 88, 'attempts' => 1, 'last_submit_at' => '2026-09-10 12:10:00'],
],
];
$snap1 = Snapshot::summarize($result1, '第一次');
$snap2 = Snapshot::summarize($result2, '第二次');
$snap1['at'] = 100; $snap2['at'] = 200; // 固定时间戳便于排序
check('summarize 抽取学生数 = 2', count($snap1['students']) === 2);
check('summarize 保留 best_score', (float)$snap1['students'][0]['best_score'] === 95.0);
check('summarize 按学号排序', $snap1['students'][0]['sid'] === 'S01' && $snap1['students'][1]['sid'] === 'S02');
$trend = Snapshot::trend([$snap1, $snap2]);
check('trend 列数 = 2(两份快照)', count($trend['columns']) === 2, 'got ' . count($trend['columns']));
check('trend 列按时间升序(先第一次后第二次)', $trend['columns'][0]['label'] === '第一次' && $trend['columns'][1]['label'] === '第二次');
check('trend 行数 = 3(S01/S02/S03 并集)', count($trend['rows']) === 3, 'got ' . count($trend['rows']));
$s01 = null; foreach ($trend['rows'] as $r) { if ($r['sid'] === 'S01') $s01 = $r; }
check('S01 两次快照成绩 = 95 / 96', $s01 !== null && $s01['scores'][$trend['columns'][0]['key']] == 95 && $s01['scores'][$trend['columns'][1]['key']] == 96);
$s02 = null; foreach ($trend['rows'] as $r) { if ($r['sid'] === 'S02') $s02 = $r; }
check('S02 第二次快照缺席记 null', $s02 !== null && !array_key_exists($trend['columns'][1]['key'], $s02['scores']));
/* ============ 3) 排序:再次提交置顶 ============ */
echo "\n== 排序(last_submit_ts 置顶)==\n";
// 甲:代表成绩=最高分(98,早提交);最近一次提交=重考 70(晚提交)→ last_submit_ts 更晚,应排在乙前
$aggRows = [
['sid' => 'S01', 'name' => '甲', 'score' => 98.0, 'submit_ts' => 1000, 'last_submit_ts' => 5000, 'last_submit_at' => '2026-09-10 12:00:00'],
['sid' => 'S02', 'name' => '乙', 'score' => 90.0, 'submit_ts' => 2000, 'last_submit_ts' => 2000, 'last_submit_at' => '2026-09-10 10:00:00'],
];
$sortTs = static fn(array $r): int => (int)($r['last_submit_ts'] ?? 0) ?: (int)($r['submit_ts'] ?? 0);
usort($aggRows, static function (array $x, array $y) use ($sortTs): int {
$d = $sortTs($y) <=> $sortTs($x);
return $d !== 0 ? $d : strcmp((string)($x['sid'] ?? ''), (string)($y['sid'] ?? ''));
});
check('重考后的甲(last_submit_ts=5000)排在乙前', $aggRows[0]['sid'] === 'S01', json_encode($aggRows, JSON_UNESCAPED_UNICODE));
/* ============ 4) XlsxWriter 多工作表 ============ */
echo "\n== XlsxWriter 多工作表 ==\n";
$sheets = [
['name' => '1班', 'rows' => [['学号', '姓名'], ['S01', '甲']], 'widths' => [16, 14]],
['name' => '2班', 'rows' => [['学号', '姓名'], ['S02', '乙']], 'widths' => [16, 14]],
['name' => '成绩明细', 'rows' => [['学号', '成绩'], ['S03', 90]], 'widths' => [16, 11]],
['name' => '成绩明细', 'rows' => [['学号', '成绩'], ['S04', 80]], 'widths' => [16, 11]], // 重名 → 应去重
['name' => str_repeat('长', 40), 'rows' => [['X'], [1]], 'widths' => [10]], // 超长名 → 截断
];
$bin = XlsxWriter::renderMulti($sheets);
check('renderMulti 产出 PK ZIP', substr($bin, 0, 2) === 'PK');
$names = [];
if (class_exists('ZipArchive')) {
$tmp = __DIR__ . '/_multi.xlsx';
file_put_contents($tmp, $bin);
$z = new ZipArchive();
if ($z->open($tmp) === true) {
for ($i = 0; $i < $z->numFiles; $i++) {
$n = $z->getNameIndex($i);
if (strpos($n, 'xl/worksheets/sheet') === 0) {
$names[] = $n;
}
}
$z->close();
}
@unlink($tmp);
}
check('产出 5 个工作表文件', count($names) === 5, 'got ' . count($names) . ': ' . json_encode($names));
check('sheet 文件名连续 sheet1..sheet5', in_array('xl/worksheets/sheet5.xml', $names, true));
echo "\n========================================\n";
echo "通过 {$pass} · 失败 {$fail}\n";
exit($fail === 0 ? 0 : 1);
+7 -2
View File
@@ -77,6 +77,8 @@ function chk(name, cond, extra) {
function req(method, path, body, headers) { function req(method, path, body, headers) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// 写操作自动附带 CSRF token(后端对所有写 action 强制校验)
if (body && CSRF && !body._csrf) body = Object.assign({}, body, { _csrf: CSRF });
const data = body ? JSON.stringify(body) : ''; const data = body ? JSON.stringify(body) : '';
const h = Object.assign({ const h = Object.assign({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -114,6 +116,7 @@ function req(method, path, body, headers) {
}); });
} }
const cookieJar = []; const cookieJar = [];
let CSRF = ''; // 从 config_get 取到的会话 CSRF token,POST 写请求自动附带
function formPost(path, fields) { function formPost(path, fields) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -204,8 +207,7 @@ async function login() {
process.exit(1); process.exit(1);
} }
// 之后用 ping 触发 SurveyKing 登录 // 之后用 ping 触发 SurveyKing 登录
const p1 = await req('GET', '/api.php?action=ping&_t=' + Date.now()); const p1 = await req('GET', '/api.php?action=ping&_t=' + Date.now()); console.log(' [debug] ping raw:', p1.status, 'loc=' + p1.loc, (p1.raw||'').slice(0, 200));
console.log(' [debug] ping raw:', p1.status, 'loc=' + p1.loc, (p1.raw||'').slice(0, 200));
chk('ping 200', p1.status === 200, 'status=' + p1.status + ' body=' + (p1.raw||'').slice(0, 200)); chk('ping 200', p1.status === 200, 'status=' + p1.status + ' body=' + (p1.raw||'').slice(0, 200));
chk('ping.ok', p1.body && p1.body.ok === true, JSON.stringify(p1.body)); chk('ping.ok', p1.body && p1.body.ok === true, JSON.stringify(p1.body));
await delay(100); await delay(100);
@@ -218,6 +220,9 @@ async function login() {
'storage=' + (cg1.body && cg1.body.data && cg1.body.data.storage)); 'storage=' + (cg1.body && cg1.body.data && cg1.body.data.storage));
chk('不返回明文密码', cg1.body && cg1.body.data && cg1.body.data.password === undefined); chk('不返回明文密码', cg1.body && cg1.body.data && cg1.body.data.password === undefined);
chk('has_password=true', cg1.body && cg1.body.data && cg1.body.data.has_password === true); chk('has_password=true', cg1.body && cg1.body.data && cg1.body.data.has_password === true);
// 取 CSRF token,后续写请求自动附带
CSRF = (cg1.body && cg1.body.data && cg1.body.data.csrf_token) || '';
chk('config_get 返回 csrf_token', CSRF !== '', 'csrf=' + CSRF);
console.log('\n--- 3) config_save 不改连接信息:不应清 token / 不应触发 ping ---'); console.log('\n--- 3) config_save 不改连接信息:不应清 token / 不应触发 ping ---');
const before = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : ''; const before = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';