Files
ny-exam-platform/lib/DataService.php
T
nxxy335 555f500dc7 成绩明细历史记录:按提交年月日分类,每个分类标记最高分
新增 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 项全绿。
2026-09-14 07:28:02 +08:00

705 lines
30 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 数据组装:卷王答卷 -> 学号 / 姓名 / 成绩 / 提交时间 / 考试时长
*/
declare(strict_types=1);
class DataService
{
/** 容器型题目(分页、题组、说明)不参与取值 */
private const CONTAINER_TYPES = [
'pagination', 'page', 'group', 'survey', 'divider', 'tree',
];
/** 题目标题可能是富文本,去掉标签便于展示与匹配 */
private static function cleanTitle(string $t): string
{
$t = strip_tags($t);
$t = html_entity_decode($t, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim(preg_replace('/\s+/u', ' ', $t) ?? '');
}
/**
* 展平题目树
* @return array<int,array{id:string,title:string,type:string,path:string}>
*/
public static function flattenQuestions(mixed $node, string $path = ''): array
{
$out = [];
if (!is_array($node)) {
return $out;
}
// 不同卷王版本题目可能挂在 children / questions / list 下,统一兼容
$self = $node;
$children = null;
foreach (['children', 'questions', 'list', 'items'] as $ck) {
if (isset($node[$ck]) && is_array($node[$ck])) {
$children = $node[$ck];
break;
}
}
$id = isset($self['id']) ? (string)$self['id'] : '';
$title = isset($self['title']) ? self::cleanTitle((string)$self['title']) : '';
$type = isset($self['type']) ? (string)$self['type'] : '';
$isRoot = ($path === '');
if (!$isRoot && $id !== '' && !in_array(strtolower($type), self::CONTAINER_TYPES, true)) {
$out[] = [
'id' => $id,
'title' => $title !== '' ? $title : $id,
'type' => $type,
'path' => $path,
];
}
if ($children !== null) {
foreach ($children as $idx => $child) {
$subPath = $path === '' ? (string)($idx + 1) : $path . '-' . ($idx + 1);
foreach (self::flattenQuestions($child, $subPath) as $q) {
$out[] = $q;
}
}
}
return $out;
}
/** 根据关键词猜测"姓名"题 */
public static function guessNameQuestion(array $questions, string $keywords): array
{
$kws = array_values(array_filter(array_map('trim', preg_split('/[,,]/u', $keywords) ?: [])));
$candidates = [];
foreach ($questions as $q) {
$t = (string)$q['title'];
foreach ($kws as $kw) {
if ($kw !== '' && mb_stripos($t, $kw) !== false) {
$candidates[] = $q;
break;
}
}
}
return $candidates;
}
/**
* 组装结果
*/
/**
* 读取某份试卷的独立配置(满分 / 合格率 / 免考名单)。
* 优先 SQLite(ny_project_config 表);不可用时退回 config.json 里的 project_config 字段,
* 保证没有 pdo_sqlite 的环境也能用(只是隔离粒度仍在,只是落到文件)。
*
* @return array{full_score:?int,pass_rate:?float,exempt:array<int,string>}
*/
public static function projectConfig(string $projectId): array
{
$def = ['full_score' => null, 'pass_rate' => null, 'exempt' => []];
if ($projectId === '') {
return $def;
}
if (class_exists('Db') && Db::pdo() !== null) {
return Db::projectConfigGet($projectId);
}
// 文件回退
$all = [];
$saved = ny_read_json(NY_DATA . '/project_config.json', null);
if (is_array($saved)) {
$all = $saved;
}
$one = $all[$projectId] ?? null;
return is_array($one) ? array_merge($def, $one) : $def;
}
/** 写入某份试卷的独立配置 */
public static function projectConfigSave(string $projectId, array $cfg): void
{
if ($projectId === '') {
return;
}
if (class_exists('Db') && Db::pdo() !== null) {
Db::projectConfigSet($projectId, $cfg);
return;
}
$all = [];
$saved = ny_read_json(NY_DATA . '/project_config.json', null);
if (is_array($saved)) {
$all = $saved;
}
$all[$projectId] = $cfg;
ny_write_json(NY_DATA . '/project_config.json', $all);
}
public static function build(array $cfg, string $projectId, string $nameQid = ''): array
{
$client = new SurveyKingClient($cfg);
// 每卷独立配置(满分 / 合格率 / 免考名单):试卷之间互相隔离
$pCfg = self::projectConfig($projectId);
$exempt = [];
foreach ((array)($pCfg['exempt'] ?? []) as $sid) {
$sid = trim((string)$sid);
if ($sid !== '') {
$exempt[$sid] = true;
}
}
// 基础名单(全名单):用于答卷匹配,不受白名单影响
$rosterAll = Roster::all();
if ($exempt !== []) {
$rosterAll = array_values(array_filter(
$rosterAll,
static fn(array $s): bool => !isset($exempt[trim((string)($s['sid'] ?? ''))])
));
}
// 注:$students(应考名单)与 $studentInfo 在解析出 $survey(含白名单)后统一构建,见下方
$project = $client->getProject($projectId);
$survey = $project['survey'] ?? null;
if (is_string($survey) && $survey !== '') {
$decoded = json_decode($survey, true);
if (is_array($decoded)) {
$survey = $decoded;
}
}
// 卷内「考试白名单 / 通讯录名单」:决定「应考名单」(缺/待考名单来源)。
// 真实来源 = 卷王项目「参与者(受访者)」(t_project_partner, type=RESPONDENT_SYS_USER/IMP_USER),
// 由 SurveyKingClient::getWhitelist -> getRespondentPartners(/api/project/partner/list) 读取;
// 未配置受访者名单时「按现在的方案显示」(使用全名单)。
$paperWhitelist = [];
try {
$paperWhitelist = $client->getWhitelist($projectId, $survey);
} catch (Throwable $e) {
$paperWhitelist = [];
}
$useWhitelist = ($paperWhitelist !== []);
$whitelistTotal = 0;
if ($useWhitelist) {
// 以学号 / 登陆账号 / 姓名(大小写不敏感)把白名单成员匹配回名单,
// 命中的取名单里的姓名/班级/组别;未命中的以白名单值作占位,避免漏统计。
$rosterMap = [];
foreach ($rosterAll as $s) {
$sid = strtolower(trim((string)($s['sid'] ?? '')));
$login = strtolower(trim((string)($s['login'] ?? '')));
$name = strtolower(trim((string)($s['name'] ?? '')));
if ($sid) { $rosterMap['sid:' . $sid] = $s; }
if ($login) { $rosterMap['login:' . $login] = $s; }
if ($name) { $rosterMap['name:' . $name] = $s; }
}
$students = [];
$seen = [];
foreach ($paperWhitelist as $v) {
$key = strtolower(trim((string)$v));
$m = $rosterMap['sid:' . $key] ?? $rosterMap['login:' . $key] ?? $rosterMap['name:' . $key] ?? null;
if ($m !== null) {
$sid = trim((string)($m['sid'] ?? ''));
if ($sid !== '' && isset($seen[$sid])) {
continue;
}
if ($sid !== '') { $seen[$sid] = true; }
$students[] = $m;
} else {
if (isset($seen['wk:' . $key])) {
continue;
}
$seen['wk:' . $key] = true;
$students[] = ['sid' => $v, 'name' => $v, 'cls' => '', 'grp' => '', 'login' => ''];
}
}
$whitelistTotal = count($students);
} else {
$students = $rosterAll;
}
// 姓名 -> 学号 / 班级(规范化):全名单 + 白名单独有成员都纳入,
// 保证所有提交(含白名单外提交)都能匹配到姓名。
$studentInfo = [];
$addInfo = static function (array $s) use (&$studentInfo): void {
$k = ny_norm_name((string)($s['name'] ?? ''));
if ($k !== '') {
$studentInfo[$k] = [
'sid' => (string)$s['sid'],
'cls' => (string)($s['cls'] ?? ''),
'grp' => (string)($s['grp'] ?? ''),
];
}
};
foreach ($rosterAll as $s) { $addInfo($s); }
if ($useWhitelist) {
foreach ($students as $s) { $addInfo($s); }
}
$questions = self::flattenQuestions($survey);
$keywords = (string)($cfg['name_keywords'] ?? '姓名');
$nameCandidates = self::guessNameQuestion($questions, $keywords);
$autoQid = $nameCandidates[0]['id'] ?? '';
// 指定优先
$pickedQid = $nameQid !== '' ? $nameQid : $autoQid;
$candidateIds = [];
if ($pickedQid !== '') {
$candidateIds[] = $pickedQid;
}
foreach ($nameCandidates as $q) {
if (!in_array($q['id'], $candidateIds, true)) {
$candidateIds[] = $q['id'];
}
}
$answers = $client->listAnswers($projectId);
$includeTemp = !empty($cfg['include_temp_save']);
$dedupe = !empty($cfg['dedupe']);
$rawRows = []; // 已匹配学号的原始 row(每条 = 一次提交)
$unmatched = [];
foreach ($answers as $a) {
$tempSave = isset($a['tempSave']) ? (int)$a['tempSave'] : 1;
if (!$includeTemp && $tempSave === 0) {
continue; // 跳过暂存
}
$answerMap = isset($a['answer']) && is_array($a['answer']) ? $a['answer'] : [];
// 姓名:优先关键词命中的题目 -> 全量扫描能匹配名单的答案 -> 登录用户名
$name = '';
foreach ($candidateIds as $qid) {
if (isset($answerMap[$qid])) {
$v = ny_flatten_value($answerMap[$qid]);
if ($v !== '') {
$name = $v;
break;
}
}
}
if ($name === '') {
foreach ($questions as $q) {
$qid = (string)$q['id'];
if (!array_key_exists($qid, $answerMap)) {
continue;
}
$v = ny_flatten_value($answerMap[$qid]);
if ($v !== '' && isset($studentInfo[ny_norm_name($v)])) {
$name = $v;
break;
}
}
}
if ($name === '' && $questions === []) {
foreach ($answerMap as $v) {
$s = ny_flatten_value($v);
if ($s !== '' && isset($studentInfo[ny_norm_name($s)])) {
$name = $s;
break;
}
}
}
if ($name === '') {
$name = ny_str($a['createByName'] ?? '');
}
if ($name === '') {
$name = '(未填写姓名)';
}
// 成绩
$score = null;
if (isset($a['examScore']) && is_numeric($a['examScore'])) {
$score = (float)$a['examScore'];
}
// 提交时间
$ts = ny_to_timestamp($a['createAt'] ?? null);
if ($ts === 0) {
$ts = ny_to_timestamp($a['updateAt'] ?? null);
}
// 考试时长(metaInfo 可能缺失,先整体判空避免每答案两条 Undefined key 警告)
$ai = ($a['metaInfo'] ?? [])['answerInfo'] ?? [];
$start = is_array($ai) ? (int)($ai['startTime'] ?? 0) : 0;
$end = is_array($ai) ? (int)($ai['endTime'] ?? 0) : 0;
$dur = null;
if ($start > 0 && $end > $start) {
$dur = (int)round(($end - $start) / 1000);
}
$key = ny_norm_name($name);
$info = $studentInfo[$key] ?? null;
$sid = $info['sid'] ?? '';
$cls = $info['cls'] ?? '';
$grp = $info['grp'] ?? '';
$row = [
'sid' => $sid,
'name' => $name,
'cls' => $cls,
'grp' => $grp,
'score' => $score,
'submit_ts' => $ts,
'submit_at' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : '-',
'duration' => $dur,
'duration_txt'=> ny_format_duration($dur),
'answer_id' => ny_str($a['id'] ?? ''),
'matched' => $sid !== '',
'temp_save' => $tempSave === 0,
];
if ($sid === '') {
$unmatched[] = $row;
continue;
}
$rawRows[] = $row;
}
// 按学号聚合:dedupe=true 仅保留最新一份;false 则每名一行(代表=最高分)
// 并附带 attempts(次数) / attempts_list(每次提交明细),供看板展示"多次刷题"
$agg = self::aggregateByStudent($rawRows, $dedupe);
$rows = $agg['rows'];
$attemptsTotal = $agg['attempts_total'];
// 成绩明细默认按提交时间降序(最新在前),学号作为次级排序
// 重要:排序键必须用「最近一次提交时间 last_submit_ts」,而不是代表行的 submit_ts。
// 否则当 dedupe=false(代表成绩=最高分)时,学生重考且分数更低,代表行仍是最高分那次,
// submit_ts 不变 → 该行会停在原来的位置只高亮,不会置顶,教师登记时看不到新提交。
$sortTs = static fn(array $r): int => (int)($r['last_submit_ts'] ?? 0) ?: (int)($r['submit_ts'] ?? 0);
usort($rows, static function (array $x, array $y) use ($sortTs): int {
$d = $sortTs($y) <=> $sortTs($x);
if ($d !== 0) {
return $d;
}
$a = (string)$x['sid'];
$b = (string)$y['sid'];
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
});
// 缺考名单
$submittedSids = [];
foreach ($rows as $r) {
$submittedSids[(string)$r['sid']] = true;
}
$absent = [];
foreach ($students as $s) {
if (!isset($submittedSids[(string)$s['sid']])) {
$absent[] = [
'sid' => (string)$s['sid'],
'name' => (string)$s['name'],
'cls' => (string)($s['cls'] ?? ''),
'grp' => (string)($s['grp'] ?? ''),
];
}
}
usort($absent, static function (array $x, array $y): int {
$a = (string)$x['sid'];
$b = (string)$y['sid'];
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
});
// 统计
$scores = array_values(array_filter(array_map(static fn($r) => $r['score'], $rows), static fn($v) => $v !== null));
$durs = array_values(array_filter(array_map(static fn($r) => $r['duration'], $rows), static fn($v) => $v !== null));
$stats = [
'roster_total' => count($students),
'submitted' => count($rows),
'absent' => count($absent),
'unmatched' => count($unmatched),
'submit_rate' => count($students) > 0 ? round(count($rows) / count($students) * 100, 1) : 0,
'avg_score' => $scores !== [] ? round(array_sum($scores) / count($scores), 2) : null,
'max_score' => $scores !== [] ? max($scores) : null,
'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 [
'project' => [
'id' => ny_str($project['id'] ?? $projectId),
'name' => ny_str($project['name'] ?? ''),
'mode' => ny_str($project['mode'] ?? ''),
],
'questions' => $questions,
'name_qid' => $pickedQid,
'name_candidates' => array_map(static fn($q) => ['id' => $q['id'], 'title' => $q['title']], $nameCandidates),
'rows' => $rows,
'absent' => $absent,
'unmatched' => $unmatched,
'stats' => $stats,
'roster' => $students,
'roster_count' => count($students),
'whitelist_driven' => $useWhitelist,
'whitelist_total' => $whitelistTotal,
// 本卷独立配置(前端据此回显满分/合格率/免考);full_score 已按本卷解析
'project_cfg' => [
'full_score' => $pCfg['full_score'] ?? null,
'pass_rate' => $pCfg['pass_rate'] ?? null,
'exempt' => array_values(array_map('strval', array_keys($exempt))),
],
'updated_at' => date('Y-m-d H:i:s'),
];
}
/**
* 把聚合后的学生行展开为"每次考试一行",用于导出全部考试记录 / 明细内全部展示。
* 每行携带该次考试的成绩、提交时间、时长,以及该学生的总考试次数 attempts 与第几次 idx。
*
* @param array $rows 聚合后的行(每行含 attempts_list)
* @param bool $desc 是否按提交时间降序(新 -> 旧)
* @return array
*/
public static function expandAttempts(array $rows, bool $desc = true): array
{
$out = [];
foreach ($rows as $r) {
$list = (array)($r['attempts_list'] ?? []);
if ($list === []) {
// 没有明细时退回代表行本身
$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;
}
foreach ($list as $a) {
$out[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
'grp' => (string)($r['grp'] ?? ''),
'score' => $a['score'] ?? null,
'submit_ts' => (int)($a['submit_ts'] ?? 0),
'submit_at' => (string)($a['submit_at'] ?? '-'),
'duration' => $a['duration'] ?? null,
'duration_txt' => (string)($a['duration_txt'] ?? '-'),
'answer_id' => (string)($a['answer_id'] ?? ''),
'temp_save' => !empty($a['temp_save']),
'attempts' => (int)($r['attempts'] ?? 1),
'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'] ?? '-'),
];
}
}
usort($out, static function (array $x, array $y) use ($desc): int {
// 注意:x<=>y 是升序,降序要用 y<=>x
$d = ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0));
if (!$desc) {
$d = -$d;
}
if ($d !== 0) {
return $d;
}
// 同时间:学号升序
$a = (string)$x['sid'];
$b = (string)$y['sid'];
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
});
return $out;
}
/**
* 导出/展示用的排序:
* - time(默认):按提交时间降序,同时间按学号升序
* - sid:按学号升序,同一学号内多次考试再按提交时间降序
*
* @param array $rows expandAttempts 展开后的行
* @param string $mode 'time' | 'sid'
*/
public static function sortForExport(array $rows, string $mode = 'time'): array
{
$out = $rows;
if ($mode === 'sid') {
usort($out, static function ($x, $y): int {
$a = (string)($x['sid'] ?? '');
$b = (string)($y['sid'] ?? '');
if (is_numeric($a) && is_numeric($b)) {
$d = $a <=> $b;
} else {
$d = strnatcmp($a, $b);
}
if ($d !== 0) {
return $d;
}
return ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0));
});
return $out;
}
usort($out, static function ($x, $y): int {
$d = ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0));
if ($d !== 0) {
return $d;
}
$a = (string)($x['sid'] ?? '');
$b = (string)($y['sid'] ?? '');
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
});
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 '';
}
/**
* 按学号聚合一次提交的答卷记录,解决"同人多次刷卷"在看板中被当成多个人的问题。
*
* 关键语义(修复"考试次数=1 / 历史只有一条"的 bug):
* - attempts(真实考试次数) 与 attempts_list(每次提交明细) **始终等于全部提交**,与 dedupe 无关;
* 这样"考试次数列"与"点击查看历史"永远展示完整数据。
* - dedupe 仅决定主明细表"代表成绩"那一行取哪次考试的成绩:
* dedupe=true → 取提交时间最新的一份(默认,看板更干净)
* dedupe=false → 取最高分(同分时取最早达到该分数的那次)
*
* @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);
});
// 最佳成绩(用于标记每次考试是否为最高分)
$maxScore = null;
foreach ($sorted as $a) {
if ($a['score'] !== null && ($maxScore === null || $a['score'] > $maxScore)) {
$maxScore = $a['score'];
}
}
// 最近一次提交(升序排序后的最后一份)
$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, &$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) {
// 代表成绩 = 提交时间最新的一份(默认,看板更干净)
$row = $sorted[count($sorted) - 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;
}
// 关键修复:考试次数与历史明细始终使用真实全部提交,与去重无关
// (之前 dedupe=true 时把 attempts 写成 1、attempts_list 只留最新一份,导致"考试次数"和
// 历史弹窗都只显示 1 条,多次刷题的数据被吞掉)
$row['attempts'] = count($list);
$row['best_score'] = $maxScore; // 真实最高分(跨全部尝试),用于历史弹窗标题展示
$row['attempts_list'] = $attemptsList;
// 最近一次提交时间(与"代表成绩"可能是不同的一次考试):
// 用于"按提交时间排序"和"新提交高亮",保证重考(哪怕分数更低)也能被发现
$row['last_submit_ts'] = $lastSubmitTs;
$row['last_submit_at'] = $lastSubmitTs > 0 ? date('Y-m-d H:i:s', $lastSubmitTs) : '-';
$agg[] = $row;
}
return ['rows' => $agg, 'attempts_total' => $attemptsTotal];
}
}