文档站(#88) - base 默认改为 /ny-exam-platform/,可用 DOCS_BASE=/ 覆盖本地预览 - 新增 DOCS_OUT 环境变量,避免构建时清空输出目录失败导致产物半截 - 新增 tools/docs-preview.js:按子路径重写路由的本地预览服务 - 删除 docs/guide/snapshot.md,同步清理全部相关文档 移除存快照 / 趋势 / 异常(#89) - 后端:删除 api.php 的 snapshot_save/list/delete/trend 四个 action 与 $WRITE_ACTIONS 中的同名条目;删除 lib/Snapshot.php 与自动加载注册; DataService 移除 detectAnomalies() 及其调用 - 前端:移除三个按钮与三个弹窗、相关 JS 处理与 state 字段 - 测试:移除对应用例,补充 5 条名单导入列识别断言(17+9+18+32+29+11 全过) 打印完善(#90) - 改用隐藏 iframe 输出,规避弹窗拦截;@page A4 支持纵向/横向 - 表头每页重复、行内不跨页断开、全表连续名次、可选「是否合格」列 - 分组小计(人数/平均/最高/最低/合格人数与合格率)、可附缺考名单另起一页 - 末尾签字栏;缺考名单打印改为同一套输出 UI / 交互 / Bug 修复(#91) - dashboard.php:补「← 主系统 / 退出」入口(此前全屏后无法离开); 修复成员下钻 onclick 里 JSON.stringify 双引号提前闭合属性导致点击报错, 改为 data-sid 事件委托;补 Esc 关弹窗;后台标签页暂停轮询、切回自动补拉; 刷新中断原因写入 title;数值字段统一兜底,避免 NaN / undefined% - draw.php / draw.js:结果表新增「登陆账号」列(导出前可核对,缺失显示 学号 ⚠); 弹窗只关最上层且支持 Esc;「全部」改为跟随候选池,手工改人数即解除; 候选池不足时给出明确提示;自定义比例框隐藏时清空,避免旧权重被误读 - app.js / dashboard.php:成绩着色改为按「占本卷满分的百分比」判断, 修复满分不是 100 时整列误标红 - style.css:数值列右对齐;新增 subbar、draw-stage-sub; 看板顶栏窄屏下拉与按钮换行,消除横向滚动
583 lines
23 KiB
PHP
583 lines
23 KiB
PHP
<?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 在根或子节点
|
||
$self = $node;
|
||
$children = (isset($node['children']) && is_array($node['children'])) ? $node['children'] : null;
|
||
|
||
$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;
|
||
}
|
||
}
|
||
|
||
$students = Roster::all();
|
||
// 免考:从名单中剔除,使其既不计入「总人数」也不计入「缺考」
|
||
if ($exempt !== []) {
|
||
$students = array_values(array_filter(
|
||
$students,
|
||
static fn(array $s): bool => !isset($exempt[trim((string)($s['sid'] ?? ''))])
|
||
));
|
||
}
|
||
// 姓名 -> 学号 / 班级(规范化)
|
||
$studentInfo = [];
|
||
foreach ($students as $s) {
|
||
$k = ny_norm_name((string)$s['name']);
|
||
if ($k !== '') {
|
||
$studentInfo[$k] = [
|
||
'sid' => (string)$s['sid'],
|
||
'cls' => (string)($s['cls'] ?? ''),
|
||
'grp' => (string)($s['grp'] ?? ''),
|
||
];
|
||
}
|
||
}
|
||
|
||
$project = $client->getProject($projectId);
|
||
$survey = $project['survey'] ?? null;
|
||
if (is_string($survey) && $survey !== '') {
|
||
$decoded = json_decode($survey, true);
|
||
if (is_array($decoded)) {
|
||
$survey = $decoded;
|
||
}
|
||
}
|
||
$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($nameMap[ny_norm_name($v)])) {
|
||
$name = $v;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if ($name === '' && $questions === []) {
|
||
foreach ($answerMap as $v) {
|
||
$s = ny_flatten_value($v);
|
||
if ($s !== '' && isset($nameMap[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);
|
||
}
|
||
|
||
// 考试时长
|
||
$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),
|
||
// 本卷独立配置(前端据此回显满分/合格率/免考);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;
|
||
$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']),
|
||
'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;
|
||
}
|
||
|
||
/**
|
||
* 按学号聚合一次提交的答卷记录,解决"同人多次刷卷"在看板中被当成多个人的问题。
|
||
*
|
||
* 关键语义(修复"考试次数=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);
|
||
// 标记是否已给"最佳"打过标(同分时只标最早达到该分数的那次)
|
||
$bestMarked = false;
|
||
$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);
|
||
|
||
// 代表成绩选择(仅影响主明细表那一行的成绩,不影响考试次数 / 历史明细)
|
||
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];
|
||
}
|
||
}
|