diff --git a/assets/app.js b/assets/app.js index 7dc1719..e05bd99 100644 --- a/assets/app.js +++ b/assets/app.js @@ -926,6 +926,7 @@ 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, + day: '', day_best: false, day_count: 1, }]; } // 新 -> 旧 展示(缺 submit_ts 时按 idx 倒序兜底,保证"次序"看起来合理) @@ -934,17 +935,41 @@ if (ta !== tb) return tb - ta; return (b.idx || 0) - (a.idx || 0); }); - const rows = desc.map((a, i) => { - const sc = (a.score == null) ? '未判分' : esc(a.score); - const best = (a.is_best && attempts > 1) ? ' 最佳' : ''; - const tmp = a.temp_save ? ' 暂存' : ''; - const no = attempts > 1 ? (a.idx || (desc.length - i)) : 1; - return '' + - '' + (attempts > 1 ? '第 ' + no + ' 次' : '-') + '' + - '' + esc(a.submit_at || '-') + '' + - '' + sc + best + tmp + '' + - '' + esc(a.duration_txt || '-') + '' + - ''; + // 按「提交时间年月日」分组:新的一天在前。 + // 日期键由后端按服务端本地时区算好(a.day),这里仅兜底;时间未知的(ts=0)key 为 '', + // 且排序里排最后,天然连续落在末尾 → 归入「未知时间」组。 + const groups = []; + desc.forEach(a => { + const key = a.day || (a.submit_at && a.submit_at !== '-' ? String(a.submit_at).slice(0, 10) : '') || ''; + let g = groups.length ? groups[groups.length - 1] : null; + if (!g || g.key !== key) { g = { key: key, items: [] }; groups.push(g); } + g.items.push(a); + }); + // 每组的最高分(未判分不参与),用于分组行显示「最高 X 分」 + groups.forEach(g => { + g.max = g.items.reduce( + (m, a) => (a.score == null ? m : (m == null || Number(a.score) > m ? Number(a.score) : m)), null); + }); + const rows = groups.map(g => { + const maxTxt = (g.max == null) ? '' : ' · 最高 ' + g.max + ' 分'; + let html = '' + esc(g.key || '未知时间') + + '共 ' + g.items.length + ' 次' + maxTxt + ''; + html += g.items.map(a => { + const sc = (a.score == null) ? '未判分' : esc(a.score); + const best = (a.is_best && attempts > 1) ? ' 最佳' : ''; + // 当日最高:仅当天有多次提交时才标,避免"一天只考一次"时每行都挂标签、反而看不出重点 + const dayBest = (a.day_best && (a.day_count || 0) >= 2) ? ' 当日最高' : ''; + const tmp = a.temp_save ? ' 暂存' : ''; + // 时间未知(submit_ts=0)时无从判断先后,次序显示 '-',而不是误报"第 1 次" + const noTxt = (attempts > 1 && a.submit_ts) ? ('第 ' + (a.idx || 1) + ' 次') : '-'; + return '' + + '' + noTxt + '' + + '' + esc(a.submit_at || '-') + '' + + '' + sc + best + dayBest + tmp + '' + + '' + esc(a.duration_txt || '-') + '' + + ''; + }).join(''); + return html; }).join(''); const bestScore = (r.best_score == null) ? r.score : r.best_score; $('#attTitle').textContent = (r.name || '') + '(' + r.sid + ') · 共 ' + attempts + ' 次考试' + diff --git a/assets/style.css b/assets/style.css index 3df4967..a6e5f28 100644 --- a/assets/style.css +++ b/assets/style.css @@ -393,6 +393,11 @@ td.att { font-weight: 700; color: var(--text-2); } .tag-att { background: #e8eefc; color: #3457c9; cursor: pointer; } .tag-new { background: var(--ok-weak); color: var(--ok); } .tag-best { background: #fff1dc; color: #b26a06; } +/* 当日最高(历史成绩按天分组后,组内最高分) */ +.tag-day { background: var(--primary-weak); color: var(--primary-2); } +/* 历史成绩弹窗:按提交日期分组的分组行 */ +tr.att-day > td { background: var(--border-2); font-weight: 600; color: var(--text-2); } +tr.att-day > td .att-day-sub { margin-left: 8px; font-weight: 400; font-size: 12px; color: var(--text-3); } /* 多次刷题:成员榜可点击行 */ tr.clickable { cursor: pointer; } diff --git a/lib/DataService.php b/lib/DataService.php index f929dd4..910e9f4 100644 --- a/lib/DataService.php +++ b/lib/DataService.php @@ -463,8 +463,11 @@ class DataService if ($list === []) { // 没有明细时退回代表行本身 $one = $r; - $one['idx'] = 1; - $one['is_best'] = true; + $one['idx'] = 1; + $one['is_best'] = true; + $one['day'] = self::dayKeyOf((int)($r['submit_ts'] ?? 0), (string)($r['submit_at'] ?? '')); + $one['day_best'] = true; + $one['day_count'] = 1; $out[] = $one; continue; } @@ -485,6 +488,9 @@ class DataService 'best_score' => $r['best_score'] ?? null, 'idx' => (int)($a['idx'] ?? 1), 'is_best' => !empty($a['is_best']), + 'day' => (string)($a['day'] ?? ''), + 'day_best' => !empty($a['day_best']), + 'day_count' => (int)($a['day_count'] ?? 1), 'last_submit_ts' => (int)($r['last_submit_ts'] ?? 0), 'last_submit_at' => (string)($r['last_submit_at'] ?? '-'), ]; @@ -552,6 +558,28 @@ class DataService return $out; } + /** + * 取某次提交的「年月日」分组键。 + * + * 关键:一律用服务端本地时区 date('Y-m-d', $ts) 计算,**不能用 UTC**(gmdate / JS toISOString), + * 否则零点前后提交的答卷会被算到前一天/后一天(东八区会整体错一天)。 + * 无有效时间戳(createAt 缺失导致 ny_to_timestamp 返回 0)时,退回从显示串取前 10 位; + * 仍取不到则返回空串,上层归为「未知时间」分组并排在最后。 + * + * @param int $ts 提交时间戳(秒),0 表示未知 + * @param string $submitAt 展示用时间串(形如 2026-09-08 09:41:20) + */ + public static function dayKeyOf(int $ts, string $submitAt = ''): string + { + if ($ts > 0) { + return date('Y-m-d', $ts); + } + if ($submitAt !== '' && $submitAt !== '-' && preg_match('/^(\d{4}-\d{2}-\d{2})/', $submitAt, $m) === 1) { + return $m[1]; + } + return ''; + } + /** * 按学号聚合一次提交的答卷记录,解决"同人多次刷卷"在看板中被当成多个人的问题。 * @@ -589,27 +617,55 @@ class DataService } // 最近一次提交(升序排序后的最后一份) $lastSubmitTs = (int)($sorted[count($sorted) - 1]['submit_ts'] ?? 0); + + // 按「提交时间年月日」分组统计:每天的最高分与提交次数。 + // 历史弹窗据此按天分类,并在每个分类内标记当日最高分。 + $dayMax = []; // day => 当天最高分 + $dayCount = []; // day => 当天提交次数(含未判分) + foreach ($sorted as $a) { + $day = self::dayKeyOf((int)($a['submit_ts'] ?? 0), (string)($a['submit_at'] ?? '')); + $dayCount[$day] = ($dayCount[$day] ?? 0) + 1; + if ($a['score'] !== null && (!array_key_exists($day, $dayMax) || $a['score'] > $dayMax[$day])) { + $dayMax[$day] = $a['score']; + } + } + // 标记是否已给"最佳"打过标(同分时只标最早达到该分数的那次) $bestMarked = false; + // 每天是否已标记过"当日最高"(同分时同样只标最早达到的那次,与全局 is_best 一致) + $dayMarked = []; $idx = 0; - $attemptsList = array_map(static function (array $a) use (&$idx, $maxScore, &$bestMarked): array { - $idx++; - $isBest = (!$bestMarked && $a['score'] !== null && $maxScore !== null && (float)$a['score'] === (float)$maxScore); - if ($isBest) { - $bestMarked = true; - } - return [ - 'idx' => $idx, - 'is_best' => $isBest, - 'submit_at' => $a['submit_at'], - 'submit_ts' => $a['submit_ts'], - 'score' => $a['score'], - 'duration' => $a['duration'], - 'duration_txt' => $a['duration_txt'], - 'answer_id' => $a['answer_id'], - 'temp_save' => $a['temp_save'], - ]; - }, $sorted); + $attemptsList = array_map( + static function (array $a) use (&$idx, $maxScore, &$bestMarked, &$dayMarked, $dayMax, $dayCount): array { + $idx++; + $isBest = (!$bestMarked && $a['score'] !== null && $maxScore !== null && (float)$a['score'] === (float)$maxScore); + if ($isBest) { + $bestMarked = true; + } + $day = self::dayKeyOf((int)($a['submit_ts'] ?? 0), (string)($a['submit_at'] ?? '')); + $dayBest = ($a['score'] !== null && array_key_exists($day, $dayMax) + && (float)$a['score'] === (float)$dayMax[$day] && empty($dayMarked[$day])); + if ($dayBest) { + $dayMarked[$day] = true; + } + return [ + 'idx' => $idx, + 'is_best' => $isBest, + 'submit_at' => $a['submit_at'], + 'submit_ts' => $a['submit_ts'], + 'score' => $a['score'], + 'duration' => $a['duration'], + 'duration_txt' => $a['duration_txt'], + 'answer_id' => $a['answer_id'], + 'temp_save' => $a['temp_save'], + // 按天分类用:day 为空串表示时间未知 + 'day' => $day, + 'day_best' => $dayBest, + 'day_count' => (int)($dayCount[$day] ?? 1), + ]; + }, + $sorted + ); // 代表成绩选择(仅影响主明细表那一行的成绩,不影响考试次数 / 历史明细) if ($dedupe) { diff --git a/tests/test-aggregate.php b/tests/test-aggregate.php index 5317dc0..2b82158 100644 --- a/tests/test-aggregate.php +++ b/tests/test-aggregate.php @@ -89,6 +89,75 @@ $r4 = $agg4['rows'][0]; check('单次提交 attempts = 1', ($r4['attempts'] ?? 0) === 1); check('单次提交 attempts_list = 1', count($r4['attempts_list'] ?? []) === 1); +echo "\n== 按提交时间年月日分组:每个分类内标记当日最高 ==\n"; +/** 按 [时间戳, 分数] 造行;ts=0 表示时间未知 */ +function makeRowsAt(string $sid, array $items): array +{ + $rows = []; + foreach ($items as $it) { + [$ts, $sc] = $it; + $rows[] = [ + 'sid' => $sid, + 'name' => '测试生', + 'cls' => '1班', + 'grp' => 'A', + 'score' => $sc, + 'submit_ts' => $ts, + 'submit_at' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : '-', + 'duration' => 120, + 'duration_txt' => '2分0秒', + 'answer_id' => 'ans-' . $ts, + 'matched' => true, + 'temp_save' => false, + ]; + } + return $rows; +} +// 09-08 三次(70/95/88),09-09 两次(60/90) +$rows5 = makeRowsAt('S005', [ + [strtotime('2026-09-08 09:00:00'), 70.0], + [strtotime('2026-09-08 11:00:00'), 95.0], + [strtotime('2026-09-08 15:00:00'), 88.0], + [strtotime('2026-09-09 10:00:00'), 60.0], + [strtotime('2026-09-09 14:00:00'), 90.0], +]); +$agg5 = DataService::aggregateByStudent($rows5, true); +$r5 = $agg5['rows'][0]; +$al5 = $r5['attempts_list']; +check('5 次提交 attempts_list = 5', count($al5) === 5); +check('前 3 条归属 2026-09-08', ($al5[0]['day'] ?? '') === '2026-09-08' && ($al5[2]['day'] ?? '') === '2026-09-08', + 'got ' . ($al5[0]['day'] ?? '?')); +check('后 2 条归属 2026-09-09', ($al5[3]['day'] ?? '') === '2026-09-09' && ($al5[4]['day'] ?? '') === '2026-09-09', + 'got ' . ($al5[3]['day'] ?? '?')); +check('09-08 当天次数 = 3', ($al5[0]['day_count'] ?? 0) === 3, 'got ' . ($al5[0]['day_count'] ?? '?')); +check('09-09 当天次数 = 2', ($al5[3]['day_count'] ?? 0) === 2, 'got ' . ($al5[3]['day_count'] ?? '?')); +$dayBestIdx = array_values(array_filter($al5, fn($a) => !empty($a['day_best']))); +check('每个分类各标记 1 个当日最高(共 2 个)', count($dayBestIdx) === 2, 'got ' . count($dayBestIdx)); +check('09-08 当日最高 = 95(第 2 次)', (int)($dayBestIdx[0]['idx'] ?? 0) === 2 && (float)($dayBestIdx[0]['score'] ?? -1) === 95.0, + 'got idx=' . ($dayBestIdx[0]['idx'] ?? '?') . ' score=' . ($dayBestIdx[0]['score'] ?? '?')); +check('09-09 当日最高 = 90(第 5 次)', (int)($dayBestIdx[1]['idx'] ?? 0) === 5 && (float)($dayBestIdx[1]['score'] ?? -1) === 90.0, + 'got idx=' . ($dayBestIdx[1]['idx'] ?? '?') . ' score=' . ($dayBestIdx[1]['score'] ?? '?')); +check('全局最佳仍是 95(第 2 次)', (int)($al5[1]['is_best'] ?? 0) === 1 || !empty($al5[1]['is_best'])); + +echo "\n== 时间未知(submit_ts=0)归入「未知时间」分组 ==\n"; +$rows6 = makeRowsAt('S006', [[0, 50.0], [0, 80.0]]); +$agg6 = DataService::aggregateByStudent($rows6, true); +$al6 = $agg6['rows'][0]['attempts_list']; +check('时间未知时 day = 空串', ($al6[0]['day'] ?? 'x') === '' && ($al6[1]['day'] ?? 'x') === '', + 'got ' . var_export($al6[0]['day'] ?? null, true)); +check('未知时间组次数 = 2', ($al6[0]['day_count'] ?? 0) === 2, 'got ' . ($al6[0]['day_count'] ?? '?')); +check('未知时间组内仍标记最高(80)', !empty($al6[1]['day_best']) && (float)($al6[1]['score'] ?? -1) === 80.0); + +echo "\n== 日期键时区边界(不能用 UTC,否则零点前后错一天)==\n"; +check('00:30 仍算当天(东八区不会退到前一天)', + DataService::dayKeyOf((int)strtotime('2026-09-08 00:30:00')) === '2026-09-08', + 'got ' . DataService::dayKeyOf((int)strtotime('2026-09-08 00:30:00'))); +check('23:30 仍算当天', + DataService::dayKeyOf((int)strtotime('2026-09-08 23:30:00')) === '2026-09-08', + 'got ' . DataService::dayKeyOf((int)strtotime('2026-09-08 23:30:00'))); +check('无时间戳时从展示串取日期', DataService::dayKeyOf(0, '2026-09-08 09:41:20') === '2026-09-08'); +check('无时间戳且展示串为 - 时返回空串', DataService::dayKeyOf(0, '-') === ''); + echo "\n========================================\n"; echo "通过 {$pass} · 失败 {$fail}\n"; exit($fail === 0 ? 0 : 1);