feat: 支持单套试卷同一提交人多次刷卷并展示

- DataService::build 新增 aggregateByStudent 按学号聚合:
  dedupe=true 仅保留最新一份;false 则每学号一行(代表成绩取最高分)
  附 attempts(次数) 与 attempts_list(每次提交明细),修正提交人数被多次提交inflate的问题
- api.php dashboard 透传 attempts/attempts_list;statOf 增加 attempts 求和;export 增加刷题次数列
- dashboard.php 成员榜加"刷N次"徽标,点击下钻弹窗看每次提交(时间/成绩/时长/暂存);hero 显示总提交次数
- 主统计页成绩明细同样加"刷N次"徽标与下钻弹窗
- stats 新增 attempts_total
This commit is contained in:
2026-09-09 15:55:06 +08:00
parent 3952c77afe
commit d2f0908385
6 changed files with 188 additions and 27 deletions
+73 -13
View File
@@ -128,9 +128,8 @@ class DataService
$includeTemp = !empty($cfg['include_temp_save']);
$dedupe = !empty($cfg['dedupe']);
$rows = [];
$rawRows = []; // 已匹配学号的原始 row(每条 = 一次提交)
$unmatched = [];
$seenSid = [];
foreach ($answers as $a) {
$tempSave = isset($a['tempSave']) ? (int)$a['tempSave'] : 1;
@@ -226,19 +225,15 @@ class DataService
continue;
}
if ($dedupe && isset($seenSid[$sid])) {
$idx = $seenSid[$sid];
// 保留提交时间最新的一份
if ($ts >= ($rows[$idx]['submit_ts'] ?? 0)) {
$rows[$idx] = $row;
$seenSid[$sid] = $idx;
}
continue;
}
$seenSid[$sid] = count($rows);
$rows[] = $row;
$rawRows[] = $row;
}
// 按学号聚合:dedupe=true 仅保留最新一份;false 则每名一行(代表=最高分)
// 并附带 attempts(次数) / attempts_list(每次提交明细),供看板展示"多次刷题"
$agg = self::aggregateByStudent($rawRows, $dedupe);
$rows = $agg['rows'];
$attemptsTotal = $agg['attempts_total'];
// 成绩明细默认按提交时间降序(最新在前),学号作为次级排序
usort($rows, static function (array $x, array $y): int {
$d = ($y['submit_ts'] ?? 0) <=> ($x['submit_ts'] ?? 0);
@@ -292,6 +287,7 @@ class DataService
'min_score' => $scores !== [] ? min($scores) : null,
'avg_duration' => $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null,
'answer_total' => count($answers),
'attempts_total'=> $attemptsTotal,
];
return [
@@ -312,4 +308,68 @@ class DataService
'updated_at' => date('Y-m-d H:i:s'),
];
}
/**
* 按学号聚合一次提交的答卷记录,解决"同人多次刷卷"在看板中被当成多个人的问题。
* - dedupe=true:每学号仅保留提交时间最新的一份(原行为)
* - dedupe=false:每学号一行,代表成绩取最高分,附 attempts(次数) / attempts_list(每次提交明细)
*
* @param array $rows 已匹配学号的原始 row(每条 = 一次提交)
* @return array{rows:array, attempts_total:int}
*/
public static function aggregateByStudent(array $rows, bool $dedupe): array
{
$bySid = [];
foreach ($rows as $r) {
$bySid[(string)($r['sid'] ?? '')][] = $r;
}
$agg = [];
$attemptsTotal = 0;
foreach ($bySid as $sid => $list) {
$attemptsTotal += count($list);
// 升序:旧 -> 新
$sorted = $list;
usort($sorted, static function (array $x, array $y): int {
return ($x['submit_ts'] ?? 0) <=> ($y['submit_ts'] ?? 0);
});
$attemptsList = array_map(static function (array $a): array {
return [
'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);
if ($dedupe) {
// 仅保留提交时间最新的一份(明细列表也只留这一份,与"只保留最新"语义一致)
$row = $sorted[count($sorted) - 1];
$row['attempts'] = 1;
$row['best_score'] = $row['score'];
$row['attempts_list'] = [$attemptsList[count($attemptsList) - 1]];
} else {
// 代表成绩取最高分(null 成绩不参与比较,全部为 null 时回退为最近一份)
$best = null;
$bestScore = null;
foreach ($sorted as $a) {
if ($a['score'] !== null && ($bestScore === null || $a['score'] > $bestScore)) {
$bestScore = $a['score'];
$best = $a;
}
}
if ($best === null) {
$best = $sorted[count($sorted) - 1];
}
$row = $best;
$row['attempts'] = count($list);
$row['best_score'] = $bestScore;
$row['attempts_list'] = $attemptsList;
}
$agg[] = $row;
}
return ['rows' => $agg, 'attempts_total' => $attemptsTotal];
}
}