From 0fd880624f5bcc2412e90cdaf68e39dc13b2ef75 Mon Sep 17 00:00:00 2001 From: nxxy335 Date: Thu, 10 Sep 2026 17:59:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84/=E4=BC=98=E5=8C=96/?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=A4=A7=E6=89=B9=E6=AC=A1=20+=20=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E5=8A=A0=E5=9B=BA=20+=20=E6=80=A7=E8=83=BD=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复再次提交不置顶(排序键改用 last_submit_ts) - 后端结果缓存 + 并发锁(DataCache / ny_build_data / Roster 指纹失效) - 导出 xlsx 多表:按最新成绩 / 按班级分表 / 可选列 - 名单管理:新增改弹窗、删除多选+二次确认、登陆账号列 - 安全:CSRF token + 登录失败限流(5次锁10分钟) - 未匹配答卷一键加入名单(unmatched_map) - 缺考名单增强:复制学号/姓名、按班统计、打印 - 成绩快照 + 跨卷趋势(Snapshot) - 异常检测线索(短时长高分/高频重考/雷同成绩) - 前端性能:虚拟滚动、搜索防抖、后台暂停、失败退避 - 测试沉淀:test-new-features.php + run-all.sh --- api.php | 404 ++++++++++++++++++++++++---- assets/app.js | 521 +++++++++++++++++++++++++++++++----- assets/style.css | 5 + bootstrap.php | 91 +++++++ index.php | 158 ++++++++++- lib/DataCache.php | 117 ++++++++ lib/DataService.php | 95 ++++++- lib/Roster.php | 17 ++ lib/Snapshot.php | 164 ++++++++++++ lib/XlsxWriter.php | 184 +++++++++---- login.php | 51 ++-- tests/run-all.sh | 27 ++ tests/test-new-features.php | 137 ++++++++++ tools/test-config-save.js | 9 +- 14 files changed, 1784 insertions(+), 196 deletions(-) create mode 100644 lib/DataCache.php create mode 100644 lib/Snapshot.php create mode 100644 tests/run-all.sh create mode 100644 tests/test-new-features.php diff --git a/api.php b/api.php index 2613905..81a0a4b 100644 --- a/api.php +++ b/api.php @@ -13,12 +13,128 @@ function cfg(): array 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 外,未登录一律拒访 if ($action !== 'auth_check' && !ny_is_auth()) { ny_json(['ok' => false, 'msg' => '未登录或会话已过期,请重新登录'], 401); 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 { switch ($action) { // 登录状态检查 @@ -30,6 +146,7 @@ try { $c = cfg(); $c['has_password'] = ((string)$c['password']) !== ''; unset($c['password']); + $c['csrf_token'] = ny_csrf_token(); // 诊断信息:配置存在哪里、数据库通不通(登录卷王失败时优先看这里) $c['storage'] = ny_config_source(); $c['db_driver'] = class_exists('Db') ? Db::driver() : ''; @@ -210,7 +327,8 @@ try { } $nameQid = (string)($_GET['nameQid'] ?? ''); $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'] : []; $fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100); if ($fs < 1) { @@ -221,6 +339,8 @@ try { 'base_url' => $c['base_url'], 'full_score' => $fs, ]; + // 缓存来源标记:便于前端/排查时知道这次是实时拉取还是命中短期缓存 + $result['_cache'] = ['cached' => $built['cached'], 'at' => $built['at']]; ny_ok($result); // ---------------- 数据看板(按班级聚合,面向班主任/学生整体情况) ---------------- @@ -231,7 +351,8 @@ try { } $nameQid = (string)($_GET['nameQid'] ?? ''); $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'] : []; $fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100); if ($fs < 1) { @@ -418,6 +539,99 @@ try { Roster::clear(); 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_absent': @@ -441,7 +655,9 @@ try { }; $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)); @@ -473,73 +689,145 @@ try { $tag = ($clsLabel !== '' ? '_' . $safe($clsLabel) : '') . ($grpLabel !== '' ? '_' . $safe($grpLabel) : ''); + $mode = ((string)($_GET['mode'] ?? '') === 'latest') ? 'latest' : 'all'; + $splitCls = ((string)($_GET['split'] ?? '') === 'cls'); + $isLatest = ($mode === 'latest'); $filename = ($action === 'export_absent' ? '缺考名单' : '成绩明细') . '_' . $safe((string)$result['project']['name']) . $tag - . '_' . date('Ymd_His') . '.csv'; + . ($isLatest ? '_最新成绩' : '') + . '_' . date('Ymd_His') . '.xlsx'; - header('Content-Type: text/csv; charset=utf-8'); - header('Content-Disposition: attachment; filename="' . $filename . '"; filename*=UTF-8\'\'' . rawurlencode($filename)); - header('Cache-Control: no-store'); - echo "\xEF\xBB\xBF"; - $out = fopen('php://output', 'w'); + // ---------------- 列定义(可通过 cols= 参数挑选) ---------------- + $colLabels = [ + 'sid' => '学号', + 'name' => '姓名', + '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') { - // 与页面缺考名单一致:按学号升序(纯数字按数值,否则自然排序) - 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); - }); - fputcsv($out, ['学号', '姓名', '班级', '组别']); - foreach ($absent as $r) { - fputcsv($out, [$r['sid'] ?? '', $r['name'] ?? '', $r['cls'] ?? '', $r['grp'] ?? '']); + // ---------------- 数据行 ---------------- + $sortMode = (string)($_GET['sort'] ?? ''); + if ($isLatest) { + // 每人一行:成绩取「最近一次提交」的分数(不受 dedupe 影响),并附最高分与考试次数 + $all = []; + foreach ($rows as $r) { + $list = array_values((array)($r['attempts_list'] ?? [])); + $last = $list !== [] ? $list[count($list) - 1] : null; + $all[] = [ + 'sid' => (string)($r['sid'] ?? ''), + 'name' => (string)($r['name'] ?? ''), + 'cls' => (string)($r['cls'] ?? ''), + '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), + ]; } - } else { - // 导出全部考试记录(同一学号考了 N 次就有 N 行),并保留"考试次数" - $sortMode = (string)($_GET['sort'] ?? ''); - $all = DataService::expandAttempts($rows, true); - - // time=按提交时间降序;sid=按学号(同学号内按提交时间降序) - // score_desc=按成绩降序(同分按提交时间);score_asc=按成绩升序 if ($sortMode === 'score_desc' || $sortMode === 'score_asc') { - $dir = ($sortMode === 'score_desc') ? -1 : 1; - usort($all, static function (array $x, array $y) use ($dir): int { - // null 成绩排末尾 - $xn = ($x['score'] === null); - $yn = ($y['score'] === null); - if ($xn && $yn) return 0; - if ($xn) return 1; - if ($yn) return -1; - $d = ($x['score'] <=> $y['score']) * $dir; - if ($d !== 0) return $d; - // 同分按提交时间降序 - return ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0)); - }); + $all = ny_sort_by_score($all, $sortMode === 'score_desc'); } 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'], - ]); + } else { + // 每次考试一行(同一学号考了 N 次就有 N 行) + $all = DataService::expandAttempts($rows, true); + 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'); } } - 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; // ---------------- 随机抽取:导出白名单(xlsx,列:姓名 / 登陆账号) ---------------- diff --git a/assets/app.js b/assets/app.js index b9022a6..5e5fdbc 100644 --- a/assets/app.js +++ b/assets/app.js @@ -28,6 +28,9 @@ highlight: new Set(), // 出现过新提交/新数据的学号,绿色底纹 hlTimer: null, // 自动清除高亮的定时器 hlAutoClearSec: 30, // 新数据高亮每隔多少秒自动清除一次 + _sorted: null, // 虚拟滚动:最近一次排序后的完整行数组(缓存,避免重复排序) + _kwDebounce: null, // 搜索框防抖定时器 + csrf: '', // CSRF token(来自 config_get),写请求自动附带 }; /* ---------------- 本地存储 ---------------- */ @@ -57,12 +60,19 @@ const init = { method: opts.method || 'GET' }; if (opts.body) { 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); } if (opts.form) { init.method = 'POST'; + if (state.csrf) opts.form.append('_csrf', state.csrf); 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); if (opts.params) { for (const k in opts.params) { @@ -249,6 +259,7 @@ state.absent = d.absent || []; state.unmatched = d.unmatched || []; 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; // 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据" @@ -288,16 +299,17 @@ renderHints(); $('#lastUpdate').textContent = d.updated_at || '—'; + state.projectName = (d.project && d.project.name) || ''; setConn('conn-on', (opts.fromCache ? '缓存 · ' : '') + '实时 · ' + (d.project && d.project.name || '')); if (!opts.fromCache) showAlert(''); } - async function refresh(silent) { + async function refresh(silent, force) { const pid = $('#projectSel').value; if (!pid) return; if (state.loading && !silent) return; state.loading = true; - if (!silent) setConn('conn-load', '拉取数据…'); + if (!silent) setConn('conn-load', force ? '重新拉取数据…' : '拉取数据…'); // 1) 先读本地缓存 → 立即渲染,刷新期间保持视觉稳定 const cached = LS.get(dataKey(pid), null); @@ -309,7 +321,9 @@ // 2) 后台异步拉真实数据 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); state.connFail = 0; LS.set(dataKey(pid), d); @@ -323,7 +337,7 @@ // 关键:必须先调 logout.php 把服务端会话清空,否则 login.php 发现会话还在会把人踢回 index.php,形成"跳不过去"的死循环 if (!state._authRedirecting) { state._authRedirecting = true; - if (state.timer) clearInterval(state.timer); + if (state.timer) { clearTimeout(state.timer); state.timer = null; } if (state.hlTimer) clearInterval(state.hlTimer); showAlert('卷王账号未配置或登录已失效,正在跳转登录页…', 'error'); // 异步清会话,忽略失败;登录页端也有探测兜底(token 过期时主动销毁会话) @@ -349,6 +363,8 @@ state.loading = false; $('#liveDot').classList.add('on'); setTimeout(() => $('#liveDot').classList.remove('on'), 900); + // 自动刷新是 setTimeout 链:每轮结束重新排下一轮(成功复位、失败按退避延长) + schedule(); } } @@ -488,7 +504,7 @@ const rosterForGrp = grp ? rosterForCls.filter(s => (s.grp || '') === grp) : rosterForCls; const absentForGrp = Math.max(0, rosterForGrp.length - rowsForGrp.length); $('#tableHint').textContent = '共 ' + rowsForGrp.length + ' 人已提交 · 名单 ' + rosterForGrp.length + ' 人'; - $('#absentHint').textContent = absentForGrp ? '共 ' + absentForGrp + ' 人' : ''; + // absentHint 由 renderAbsent 统一维护(含按班统计),此处不再重复设置 } function fmtDur(sec) { @@ -626,42 +642,15 @@ const rows = buildRows(); const k = state.sortKey, dir = state.sortAsc ? 1 : -1; rows.sort((a, b) => cmpBy(k, dir, a, b)); + state._sorted = rows; const tb = $('#tbody'); if (!rows.length) { tb.innerHTML = '' + (state.rows.length ? '没有匹配的记录' : '暂无提交记录') + ''; - 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('新'); - if (r._att && r.is_best && attempts > 1) tags.push('最佳'); - if (r._att && r.temp_save) tags.push('暂存'); - - // 考试次数:全部展示模式下额外标出"第几次" - const attCell = r._att && attempts > 1 - ? attempts + ' 第' + r.idx + '次' - : String(attempts); - - return '' + - '' + esc(r.sid) + '' + - '' + esc(r.name) + (tags.length ? ' ' + tags.join(' ') : '') + '' + - '' + esc(r.cls || '') + '' + - '' + esc(r.grp || '') + '' + - '' + (r.score == null ? '-' : r.score) + '' + - '' + attCell + '' + - '' + esc(r.submit_at) + '' + - '' + esc(r.duration_txt) + '' + - ''; - }).join(''); $$('#dataTable th.sortable').forEach(th => { const isCur = th.dataset.k === state.sortKey; @@ -671,6 +660,78 @@ 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('新'); + if (r._att && r.is_best && attempts > 1) tags.push('最佳'); + if (r._att && r.temp_save) tags.push('暂存'); + + const attCell = r._att && attempts > 1 + ? attempts + ' 第' + r.idx + '次' + : String(attempts); + + return '' + + '' + esc(r.sid) + '' + + '' + esc(r.name) + (tags.length ? ' ' + tags.join(' ') : '') + '' + + '' + esc(r.cls || '') + '' + + '' + esc(r.grp || '') + '' + + '' + (r.score == null ? '-' : r.score) + '' + + '' + attCell + '' + + '' + esc(r.submit_at) + '' + + '' + esc(r.duration_txt) + '' + + ''; + } + + /** + * 虚拟滚动:只渲染可视窗口内的行,用顶部/底部 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(''); + } + for (let i = start; i < end; i++) { + frag.push(rowHtml(rows[i])); + } + if (end < total) { + frag.push(''); + } + 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() { const clsFilter = $('#clsSel').value; const grpFilter = $('#grpSel').value; @@ -681,24 +742,40 @@ const rosterForCls = clsFilter ? (state.roster || []).filter(s => (s.cls || '') === clsFilter) : (state.roster || []); const rosterForGrp = grpFilter ? rosterForCls.filter(s => (s.grp || '') === grpFilter) : rosterForCls; const list = rosterForGrp.filter(s => !submittedSids.has(s.sid)); + state._absentList = list; // 供复制/打印按钮使用 const tb = $('#absentBody'); if (!list.length) { tb.innerHTML = '' + (clsFilter ? '本班全部已提交 🎉' : '全部已提交 🎉') + ''; - return; + } else { + tb.innerHTML = list.map(a => + '' + esc(a.sid) + '' + esc(a.name) + '' + esc(a.cls || '') + '' + esc(a.grp || '') + '').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 => - '' + esc(a.sid) + '' + esc(a.name) + '' + esc(a.cls || '') + '' + esc(a.grp || '') + '').join(''); } function renderUnmatched() { const tb = $('#unmatchedBody'); if (!state.unmatched.length) { - tb.innerHTML = '无'; + tb.innerHTML = '无'; return; } tb.innerHTML = state.unmatched.map(u => - '' + esc(u.name) + '' + (u.score == null ? '-' : u.score) + - '' + esc(u.submit_at) + '').join(''); + '' + + '' + esc(u.name) + '' + + '' + (u.score == null ? '-' : u.score) + '' + + '' + esc(u.submit_at) + '' + + '').join(''); } /* 历史成绩弹窗(主统计页成绩明细):任意成员都可点开,含只考过 1 次的 */ @@ -747,11 +824,15 @@ } /* ---------------- 自动刷新 ---------------- */ + // 失败退避:连续失败时延长下次刷新间隔(1x → 2x → 4x,封顶 8x),成功后复位。 + // 用 setTimeout 链而非 setInterval,便于动态调整间隔;后台标签页暂停刷新,回到前台立即补一次。 function schedule() { - if (state.timer) clearInterval(state.timer); + if (state.timer) { clearTimeout(state.timer); state.timer = null; } if (!$('#autoRefresh').checked) return; - const sec = parseInt($('#intervalSel').value, 10) || 15; - state.timer = setInterval(() => refresh(true), sec * 1000); + const base = (parseInt($('#intervalSel').value, 10) || 15) * 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); } /* ---------------- 新数据高亮自动清除 ---------------- */ @@ -798,6 +879,7 @@ async function openSettings() { try { const c = await api('config_get'); + if (c.csrf_token) state.csrf = c.csrf_token; $('#cfgPageSize').value = c.page_size || 200; $('#cfgInterval').value = c.refresh_interval || 15; $('#cfgTimeout').value = c.timeout || 20; @@ -867,6 +949,15 @@ /* ---------------- 名单 ---------------- */ 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() { try { @@ -885,24 +976,28 @@ String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw)); const tb = $('#rosterBody'); if (!rows.length) { - tb.innerHTML = '暂无名单,请上传文件'; + tb.innerHTML = '暂无名单,请上传文件'; + $('#btnDelSel').hidden = true; return; } tb.innerHTML = rows.map((r, i) => { const gi = rosterDraft.indexOf(r); + const checked = rosterSel.has(gi) ? ' checked' : ''; return '' + + '' + '' + '' + '' + '' + - '删除'; + ''; }).join(''); + updateDelSelBtn(); } /* ---------------- 事件绑定 ---------------- */ document.addEventListener('DOMContentLoaded', function () { - $('#btnRefresh').addEventListener('click', () => refresh(false)); + $('#btnRefresh').addEventListener('click', () => refresh(false, true)); $('#btnSettings').addEventListener('click', openSettings); $('#btnRoster').addEventListener('click', openRoster); $('#btnFullscreen').addEventListener('click', toggleFullscreen); @@ -910,7 +1005,11 @@ if (document.webkitFullscreenEnabled === undefined && !document.documentElement.requestFullscreen && !document.documentElement.webkitRequestFullscreen) { $('#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', () => { populateGroups(); rememberFilters(); @@ -945,7 +1044,16 @@ }); $('#autoRefresh').addEventListener('change', e => { 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 => { @@ -956,6 +1064,23 @@ }); }); + // 虚拟滚动:滚动成绩表时按需渲染可视窗口(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] 元素生效,避免误关。 */ @@ -996,11 +1121,16 @@ : (sort === 'score_desc') ? '按成绩降序' : (sort === 'score_asc') ? '按成绩升序' : '按提交时间'; - location.href = 'api.php?action=export&projectId=' + encodeURIComponent(pid) + + 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) + '&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp) + - '&sort=' + sort; - showAlert(exportTip('成绩明细', cls, grp) + ',排序:' + sortLabel, 'info'); + '&sort=' + sort + '&mode=' + latest + (split ? '&split=' + split : ''); + location.href = qs; + const modeLabel = (latest === 'latest') ? '(每人一行·最新成绩)' : '(每次考试一行)'; + const splitLabel = (split === 'cls') ? ',按班级分表' : ''; + showAlert(exportTip('成绩明细', cls, grp) + ',排序:' + sortLabel + splitLabel + modeLabel, 'info'); }); $('#btnExportAbsent').addEventListener('click', () => { const pid = $('#projectSel').value; @@ -1153,21 +1283,70 @@ } $('#rosterSearch').addEventListener('input', renderRoster); + + // 新增学生:弹出表单,填写后追加(不再直接在表格底部插入空行) $('#btnAddRow').addEventListener('click', () => { - rosterDraft.push({ sid: '', name: '', cls: '' }); - renderRoster(); + $('#addSid').value = ''; $('#addName').value = ''; + $('#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 () => { - if (!confirm('确定清空全部名单?')) return; + if (!confirm('确定清空全部名单?此操作不可撤销。')) return; try { const d = await api('roster_clear', { method: 'POST' }); rosterDraft = []; + rosterSel.clear(); renderRoster(); $('#rosterBadge').textContent = '0'; $('#rosterMeta').textContent = '共 0 人'; } 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 => { const tr = e.target.closest('tr'); if (!tr) return; @@ -1175,11 +1354,15 @@ if (isNaN(i)) return; rosterDraft[i][e.target.dataset.f] = e.target.value; }); - $('#rosterBody').addEventListener('click', e => { - const id = e.target.getAttribute && e.target.getAttribute('data-del'); - if (id == null) return; - rosterDraft.splice(parseInt(id, 10), 1); - renderRoster(); + + // 勾选 / 取消勾选(事件委托;input 事件会冒泡到这里时已被上面的 input 处理,这里只处理 checkbox) + $('#rosterBody').addEventListener('change', e => { + if (!e.target.classList || !e.target.classList.contains('roster-cb')) return; + 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 () => { @@ -1193,6 +1376,223 @@ } 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 = '暂无快照,点击「保存当前快照」创建第一份'; + return; + } + tb.innerHTML = list.map(s => + '' + + '' + esc(s.label) + '' + + '' + esc(s.at_txt) + '' + + '' + s.count + '' + + '' + esc((s.project && s.project.name) || '') + '' + + '删除').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 = '加载中…'; + 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 = '还没有快照,请先保存快照'; + return; + } + head.innerHTML = '学号姓名班级' + + cols.map(c => '' + esc(c.label) + '').join('') + ''; + const colSpan = cols.length + 3; + if (!rows.length) { + body.innerHTML = '无数据'; + return; + } + body.innerHTML = rows.map(r => { + let cells = '' + esc(r.sid) + '' + esc(r.name) + '' + esc(r.cls || '') + ''; + cols.forEach(c => { + const v = r.scores[c.key]; + cells += '' + (v == null ? '—' : v) + ''; + }); + return '' + cells + ''; + }).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 = '无'; + + // 1) 短时长高得分 + sec.push('

⚡ 用时异常短却得分高(' + a.fast_high.length + ')

'); + sec.push('
' + + ''); + if (!a.fast_high.length) sec.push(''); + else a.fast_high.forEach(x => { + sec.push(''); + }); + sec.push('
学号姓名班级成绩用时
' + empty + '
' + esc(x.sid) + '' + esc(x.name) + '' + esc(x.cls || '') + + '' + x.score + '' + esc(x.duration_txt || (x.duration + 's')) + '
'); + + // 2) 高频重考 + sec.push('

🔄 高频重考(' + a.frequent.length + ')

'); + sec.push('
' + + ''); + if (!a.frequent.length) sec.push(''); + else a.frequent.forEach(x => { + sec.push(''); + }); + sec.push('
学号姓名班级考试次数最高分
' + empty + '
' + esc(x.sid) + '' + esc(x.name) + '' + esc(x.cls || '') + + '' + x.attempts + '' + (x.best_score == null ? '-' : x.best_score) + '
'); + + // 3) 雷同成绩 + sec.push('

⚠️ 多人成绩雷同(' + a.identical.length + ' 组)

'); + sec.push('
' + + ''); + if (!a.identical.length) sec.push(''); + else a.identical.forEach(g => { + const names = g.members.map(m => m.name + '(' + m.sid + ')').join('、'); + sec.push(''); + }); + sec.push('
成绩人数学生
' + empty + '
' + g.score + '' + g.count + '' + esc(names) + '
'); + + 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 => + '' + esc(a.sid) + '' + esc(a.name) + '' + esc(a.cls || '') + '' + esc(a.grp || '') + '').join(''); + const project = state.projectName || ''; + const title = (project ? project + ' · ' : '') + '缺考名单(' + list.length + ' 人)'; + const w = window.open('', '_blank'); + if (!w) return showAlert('浏览器拦截了弹窗,请允许后重试', 'error'); + w.document.write('' + esc(title) + '' + + '' + + '

' + esc(title) + '

打印时间:' + new Date().toLocaleString() + '

' + + '' + rows + '
学号姓名班级组别
' + + '