成绩明细历史记录:按提交年月日分类,每个分类标记最高分

新增 DataService::dayKeyOf(),日期键一律用服务端本地时区计算(避免 UTC 导致东八区零点前后错一天);aggregateByStudent 为 attempts_list 每项附加 day/day_best/day_count,expandAttempts 透传。

弹窗按天分组渲染分组行(日期 + 共N次 + 最高X分),组内新到旧;当日最高标签仅在当天有多次提交时显示,避免单次日每行都挂标签。

修复:submit_ts=0(时间未知)的记录原本被误标为第1次(升序排序 idx=1 但展示在末尾),现显示 -;未知时间统一归入「未知时间」组。

aggregate 单测 17 -> 33(跨天归属/日内次数/每日最高/未知时间组/时区边界 00:30 与 23:30);全量 148 项全绿。
This commit is contained in:
2026-09-14 07:28:02 +08:00
parent eb5d06d392
commit 555f500dc7
4 changed files with 186 additions and 31 deletions
+29 -4
View File
@@ -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,18 +935,42 @@
if (ta !== tb) return tb - ta;
return (b.idx || 0) - (a.idx || 0);
});
const rows = desc.map((a, i) => {
// 按「提交时间年月日」分组:新的一天在前。
// 日期键由后端按服务端本地时区算好(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 = '<tr class="att-day"><td colspan="4">' + esc(g.key || '未知时间') +
'<span class="att-day-sub">共 ' + g.items.length + ' 次' + maxTxt + '</span></td></tr>';
html += g.items.map(a => {
const sc = (a.score == null) ? '<span class="muted">未判分</span>' : esc(a.score);
const best = (a.is_best && attempts > 1) ? ' <span class="tag tag-best">最佳</span>' : '';
// 当日最高:仅当天有多次提交时才标,避免"一天只考一次"时每行都挂标签、反而看不出重点
const dayBest = (a.day_best && (a.day_count || 0) >= 2) ? ' <span class="tag tag-day">当日最高</span>' : '';
const tmp = a.temp_save ? ' <span class="tag tag-gray">暂存</span>' : '';
const no = attempts > 1 ? (a.idx || (desc.length - i)) : 1;
// 时间未知(submit_ts=0)时无从判断先后,次序显示 '-',而不是误报"第 1 次"
const noTxt = (attempts > 1 && a.submit_ts) ? ('第 ' + (a.idx || 1) + ' 次') : '-';
return '<tr>' +
'<td class="num">' + (attempts > 1 ? '第 ' + no + ' 次' : '-') + '</td>' +
'<td class="num">' + noTxt + '</td>' +
'<td class="num">' + esc(a.submit_at || '-') + '</td>' +
'<td class="num">' + sc + best + tmp + '</td>' +
'<td class="num">' + sc + best + dayBest + tmp + '</td>' +
'<td class="num">' + esc(a.duration_txt || '-') + '</td>' +
'</tr>';
}).join('');
return html;
}).join('');
const bestScore = (r.best_score == null) ? r.score : r.best_score;
$('#attTitle').textContent = (r.name || '') + '(' + r.sid + ') · 共 ' + attempts + ' 次考试' +
(bestScore == null ? '' : ' · 最高 ' + bestScore + ' 分');
+5
View File
@@ -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; }
+58 -2
View File
@@ -465,6 +465,9 @@ class DataService
$one = $r;
$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,15 +617,37 @@ 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 {
$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,
@@ -608,8 +658,14 @@ class DataService
'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);
},
$sorted
);
// 代表成绩选择(仅影响主明细表那一行的成绩,不影响考试次数 / 历史明细)
if ($dedupe) {
+69
View File
@@ -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);