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('');
+
+ // 2) 高频重考
+ sec.push('🔄 高频重考(' + a.frequent.length + ')
');
+ sec.push('');
+
+ // 3) 雷同成绩
+ sec.push('⚠️ 多人成绩雷同(' + a.identical.length + ' 组)
');
+ sec.push('');
+
+ 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() + '
' +
+ '' +
+ '