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);