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:
@@ -217,6 +217,7 @@ try {
|
|||||||
$statOf = static function (array $stu, array $rs, int $fs): array {
|
$statOf = static function (array $stu, array $rs, int $fs): array {
|
||||||
$total = count($stu);
|
$total = count($stu);
|
||||||
$submitted = count($rs);
|
$submitted = count($rs);
|
||||||
|
$attempts = array_sum(array_map(static fn($r) => (int)($r['attempts'] ?? 1), $rs));
|
||||||
$scores = array_values(array_filter(array_map(static fn($r) => $r['score'] ?? null, $rs), static fn($v) => $v !== null));
|
$scores = array_values(array_filter(array_map(static fn($r) => $r['score'] ?? null, $rs), static fn($v) => $v !== null));
|
||||||
$avg = $scores !== [] ? round(array_sum($scores) / count($scores), 2) : null;
|
$avg = $scores !== [] ? round(array_sum($scores) / count($scores), 2) : null;
|
||||||
$pass = count(array_filter($rs, static fn($r) => ($r['score'] ?? null) !== null && ($r['score'] >= 0.6 * $fs)));
|
$pass = count(array_filter($rs, static fn($r) => ($r['score'] ?? null) !== null && ($r['score'] >= 0.6 * $fs)));
|
||||||
@@ -226,6 +227,7 @@ try {
|
|||||||
return [
|
return [
|
||||||
'total' => $total,
|
'total' => $total,
|
||||||
'submitted' => $submitted,
|
'submitted' => $submitted,
|
||||||
|
'attempts' => $attempts,
|
||||||
'absent' => max(0, $total - $submitted),
|
'absent' => max(0, $total - $submitted),
|
||||||
'rate' => $total > 0 ? round($submitted / $total * 100, 1) : 0,
|
'rate' => $total > 0 ? round($submitted / $total * 100, 1) : 0,
|
||||||
'avg' => $avg,
|
'avg' => $avg,
|
||||||
@@ -237,13 +239,16 @@ try {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 已提交成员榜:按成绩从高到低,缺成绩者排最后,并给出排名
|
// 已提交成员榜:按成绩从高到低,缺成绩者排最后,并给出排名
|
||||||
|
// 透传 attempts / attempts_list,供前端展示"同人多刷卷"
|
||||||
$membersOf = static function (array $rs): array {
|
$membersOf = static function (array $rs): array {
|
||||||
$ms = [];
|
$ms = [];
|
||||||
foreach ($rs as $r) {
|
foreach ($rs as $r) {
|
||||||
$ms[] = [
|
$ms[] = [
|
||||||
'sid' => (string)($r['sid'] ?? ''),
|
'sid' => (string)($r['sid'] ?? ''),
|
||||||
'name' => (string)($r['name'] ?? ''),
|
'name' => (string)($r['name'] ?? ''),
|
||||||
'score' => ($r['score'] ?? null),
|
'score' => ($r['score'] ?? null),
|
||||||
|
'attempts' => (int)($r['attempts'] ?? 1),
|
||||||
|
'attempts_list' => $r['attempts_list'] ?? [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
usort($ms, static function (array $a, array $b): int {
|
usort($ms, static function (array $a, array $b): int {
|
||||||
@@ -440,7 +445,7 @@ try {
|
|||||||
fputcsv($out, [$r['sid'] ?? '', $r['name'] ?? '', $r['cls'] ?? '', $r['grp'] ?? '']);
|
fputcsv($out, [$r['sid'] ?? '', $r['name'] ?? '', $r['cls'] ?? '', $r['grp'] ?? '']);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fputcsv($out, ['学号', '姓名', '班级', '组别', '成绩', '提交时间', '考试时长', '考试时长(秒)']);
|
fputcsv($out, ['学号', '姓名', '班级', '组别', '成绩', '提交时间', '考试时长', '考试时长(秒)', '刷题次数']);
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
fputcsv($out, [
|
fputcsv($out, [
|
||||||
$r['sid'] ?? '',
|
$r['sid'] ?? '',
|
||||||
@@ -451,6 +456,7 @@ try {
|
|||||||
$r['submit_at'] ?? '',
|
$r['submit_at'] ?? '',
|
||||||
$r['duration_txt'] ?? '',
|
$r['duration_txt'] ?? '',
|
||||||
$r['duration'] === null ? '' : $r['duration'],
|
$r['duration'] === null ? '' : $r['duration'],
|
||||||
|
(int)($r['attempts'] ?? 1),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-3
@@ -447,10 +447,13 @@
|
|||||||
const sig = r.sid + '|' + r.score + '|' + r.submit_at;
|
const sig = r.sid + '|' + r.score + '|' + r.submit_at;
|
||||||
const changed = state.prevMap[r.sid] && state.prevMap[r.sid] !== sig;
|
const changed = state.prevMap[r.sid] && state.prevMap[r.sid] !== sig;
|
||||||
const isNew = r.sid && state.newSids.has(r.sid);
|
const isNew = r.sid && state.newSids.has(r.sid);
|
||||||
const clsName = (changed ? 'diff ' : '') + (isNew ? 'new' : '');
|
const canDrill = (r.attempts || 1) > 1;
|
||||||
return '<tr class="' + clsName.trim() + '">' +
|
const clsName = (changed ? 'diff ' : '') + (isNew ? 'new ' : '') + (canDrill ? 'clickable' : '');
|
||||||
|
const att = canDrill ? ' <span class="tag tag-att" title="查看 ' + r.attempts + ' 次提交明细">刷' + r.attempts + '次</span>' : '';
|
||||||
|
const onclk = canDrill ? ' onclick="openAttemptsModal(' + JSON.stringify(r.sid) + ')"' : '';
|
||||||
|
return '<tr class="' + clsName.trim() + '"' + onclk + '>' +
|
||||||
'<td class="sid">' + esc(r.sid) + '</td>' +
|
'<td class="sid">' + esc(r.sid) + '</td>' +
|
||||||
'<td class="name">' + esc(r.name) + '</td>' +
|
'<td class="name">' + esc(r.name) + att + '</td>' +
|
||||||
'<td>' + esc(r.cls || '') + '</td>' +
|
'<td>' + esc(r.cls || '') + '</td>' +
|
||||||
'<td>' + esc(r.grp || '') + '</td>' +
|
'<td>' + esc(r.grp || '') + '</td>' +
|
||||||
'<td class="score num ' + scoreClass(r.score) + '">' + (r.score == null ? '-' : r.score) + '</td>' +
|
'<td class="score num ' + scoreClass(r.score) + '">' + (r.score == null ? '-' : r.score) + '</td>' +
|
||||||
@@ -499,6 +502,28 @@
|
|||||||
'</td><td class="num">' + esc(u.submit_at) + '</td></tr>').join('');
|
'</td><td class="num">' + esc(u.submit_at) + '</td></tr>').join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 多次刷题下钻弹窗(主统计页成绩明细) */
|
||||||
|
function openAttemptsModal(sid) {
|
||||||
|
const r = (state.rows || []).find(x => String(x.sid) === String(sid));
|
||||||
|
if (!r || (r.attempts || 1) <= 1) return;
|
||||||
|
const list = r.attempts_list || [];
|
||||||
|
const rows = list.map(a => {
|
||||||
|
const sc = a.score == null ? '<span class="muted">未判分</span>' : esc(a.score);
|
||||||
|
const tmp = a.temp_save ? ' <span class="tag tag-gray">暂存</span>' : '';
|
||||||
|
return '<tr><td class="num">' + esc(a.submit_at) + '</td><td class="num">' + sc + tmp +
|
||||||
|
'</td><td class="num">' + esc(a.duration_txt || '-') + '</td></tr>';
|
||||||
|
}).join('');
|
||||||
|
$('#attTitle').textContent = (r.name || '') + ' · 共 ' + r.attempts + ' 次提交';
|
||||||
|
$('#attBody').innerHTML = list.length
|
||||||
|
? '<table class="grid compact"><thead><tr><th>提交时间</th><th>成绩</th><th>考试时长</th></tr></thead><tbody>' + rows + '</tbody></table>'
|
||||||
|
: '<div class="empty">无提交明细</div>';
|
||||||
|
$('#attemptsModal').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeAttemptsModal() {
|
||||||
|
const m = $('#attemptsModal');
|
||||||
|
if (m) m.classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- 自动刷新 ---------------- */
|
/* ---------------- 自动刷新 ---------------- */
|
||||||
function schedule() {
|
function schedule() {
|
||||||
if (state.timer) clearInterval(state.timer);
|
if (state.timer) clearInterval(state.timer);
|
||||||
|
|||||||
@@ -311,6 +311,12 @@ table.grid tbody tr.new:hover td { background: #d6f2e2; }
|
|||||||
.tag-ok { background: var(--ok-weak); color: var(--ok); }
|
.tag-ok { background: var(--ok-weak); color: var(--ok); }
|
||||||
.tag-warn { background: var(--warn-weak); color: var(--warn); }
|
.tag-warn { background: var(--warn-weak); color: var(--warn); }
|
||||||
.tag-gray { background: var(--border-2); color: var(--text-3); }
|
.tag-gray { background: var(--border-2); color: var(--text-3); }
|
||||||
|
.tag-att { background: #e8eefc; color: #3457c9; cursor: pointer; }
|
||||||
|
|
||||||
|
/* 多次刷题:成员榜可点击行 */
|
||||||
|
tr.clickable { cursor: pointer; }
|
||||||
|
tr.clickable:hover td { background: var(--border-2); }
|
||||||
|
.muted { color: var(--text-3); }
|
||||||
|
|
||||||
/* ---------- 弹窗 ---------- */
|
/* ---------- 弹窗 ---------- */
|
||||||
.modal {
|
.modal {
|
||||||
|
|||||||
+63
-7
@@ -71,7 +71,21 @@ if (!ny_is_auth()) {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 多次刷题明细弹窗 -->
|
||||||
|
<div class="modal" id="attemptsModal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-head"><h3 id="attTitle">提交明细</h3><button class="x" data-close onclick="closeAttempts()">×</button></div>
|
||||||
|
<div class="modal-body" id="attBody"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// 弹窗:点击遮罩或带 data-close 的元素关闭
|
||||||
|
document.querySelectorAll('.modal').forEach(m => {
|
||||||
|
m.addEventListener('click', e => {
|
||||||
|
if (e.target === m || e.target.hasAttribute('data-close')) m.classList.remove('open');
|
||||||
|
});
|
||||||
|
});
|
||||||
const $ = s => document.querySelector(s);
|
const $ = s => document.querySelector(s);
|
||||||
function esc(s){ return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
function esc(s){ return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||||
function rateColor(r){
|
function rateColor(r){
|
||||||
@@ -204,7 +218,7 @@ async function refreshClassSel(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 成员榜 ---------- */
|
/* ---------- 成员榜(含多次刷题徽标与下钻) ---------- */
|
||||||
function membersTable(members){
|
function membersTable(members){
|
||||||
if (!members || !members.length) {
|
if (!members || !members.length) {
|
||||||
return '<div class="empty" style="padding:18px 0">暂无考试成员</div>';
|
return '<div class="empty" style="padding:18px 0">暂无考试成员</div>';
|
||||||
@@ -213,18 +227,45 @@ function membersTable(members){
|
|||||||
'<thead><tr><th style="width:60px">排名</th><th>姓名</th><th style="width:80px">成绩</th></tr></thead><tbody>' +
|
'<thead><tr><th style="width:60px">排名</th><th>姓名</th><th style="width:80px">成绩</th></tr></thead><tbody>' +
|
||||||
members.map(m => {
|
members.map(m => {
|
||||||
const rk = m.rank;
|
const rk = m.rank;
|
||||||
const badge = rk === 1 ? 'rk rk-1' : (rk === 2 ? 'rk rk-2' : (rk === 3 ? 'rk rk-3' : 'rk'));
|
const badge = rk === 1 ? 'rk rk-1' : (rk === 2 ? 'rk rk-2' : (rk === 3 ? 'rk rk-3' : 'rk');
|
||||||
const sc = m.score;
|
const sc = m.score;
|
||||||
const cls = sc == null ? '' : (sc >= 90 ? 'score-high' : (sc >= 60 ? 'score-mid' : 'score-low'));
|
const cls = sc == null ? '' : (sc >= 90 ? 'score-high' : (sc >= 60 ? 'score-mid' : 'score-low'));
|
||||||
return '<tr>' +
|
const att = (m.attempts || 1) > 1;
|
||||||
|
const tag = att ? ' <span class="tag tag-att" title="查看 ' + m.attempts + ' 次提交明细">刷' + m.attempts + '次</span>' : '';
|
||||||
|
return '<tr' + (att ? ' class="clickable" onclick="openAttempts(' + JSON.stringify(m.sid) + ')"' : '') + '>' +
|
||||||
'<td class="num"><span class="' + badge + '">' + rk + '</span></td>' +
|
'<td class="num"><span class="' + badge + '">' + rk + '</span></td>' +
|
||||||
'<td class="name">' + esc(m.name) + '</td>' +
|
'<td class="name">' + esc(m.name) + tag + '</td>' +
|
||||||
'<td class="score num ' + cls + '">' + (sc == null ? '-' : sc) + '</td>' +
|
'<td class="score num ' + cls + '">' + (sc == null ? '-' : sc) + '</td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}).join('') +
|
}).join('') +
|
||||||
'</tbody></table></div>';
|
'</tbody></table></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 多次刷题下钻弹窗 ---------- */
|
||||||
|
function openAttempts(sid){
|
||||||
|
const info = (window.MEMBER_ATT || {})[sid];
|
||||||
|
if (!info) return;
|
||||||
|
const list = info.list || [];
|
||||||
|
const rows = list.map(a => {
|
||||||
|
const sc = a.score == null ? '<span class="muted">未判分</span>' : esc(a.score);
|
||||||
|
const tmp = a.temp_save ? ' <span class="tag tag-gray">暂存</span>' : '';
|
||||||
|
return '<tr>' +
|
||||||
|
'<td class="num">' + esc(a.submit_at) + '</td>' +
|
||||||
|
'<td class="num">' + sc + tmp + '</td>' +
|
||||||
|
'<td class="num">' + esc(a.duration_txt || '-') + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
$('#attTitle').textContent = info.name + ' · 共 ' + (info.attempts || list.length) + ' 次提交';
|
||||||
|
$('#attBody').innerHTML = list.length
|
||||||
|
? '<table class="grid compact"><thead><tr><th>提交时间</th><th>成绩</th><th>考试时长</th></tr></thead><tbody>' + rows + '</tbody></table>'
|
||||||
|
: '<div class="empty">无提交明细</div>';
|
||||||
|
document.querySelector('#attemptsModal').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeAttempts(){
|
||||||
|
const m = document.querySelector('#attemptsModal');
|
||||||
|
if (m) m.classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 渲染单班看板 ---------- */
|
/* ---------- 渲染单班看板 ---------- */
|
||||||
function renderBoard(){
|
function renderBoard(){
|
||||||
const pid = $('#projectSel').value;
|
const pid = $('#projectSel').value;
|
||||||
@@ -252,8 +293,21 @@ function renderBoard(){
|
|||||||
// 顶部品牌区:动态标题
|
// 顶部品牌区:动态标题
|
||||||
const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || '';
|
const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || '';
|
||||||
$('#heroTitle').textContent = cls + ' · 班级看板';
|
$('#heroTitle').textContent = cls + ' · 班级看板';
|
||||||
|
|
||||||
|
// 构建"学号 -> 多次刷题明细"映射(班级级 members 已含全班所有人的 attempts_list)
|
||||||
|
const MAP = {};
|
||||||
|
(c.members || []).forEach(m => {
|
||||||
|
if ((m.attempts || 1) > 1) {
|
||||||
|
MAP[m.sid] = { name: m.name, attempts: m.attempts, list: m.attempts_list || [] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.MEMBER_ATT = MAP;
|
||||||
|
|
||||||
|
const overall = DASH.overall || {};
|
||||||
|
const totalAttempts = (overall.attempts_total || 0);
|
||||||
$('#heroSub').textContent = '问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' +
|
$('#heroSub').textContent = '问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' +
|
||||||
'总 ' + c.total + ' / 已交 ' + c.submitted + ' / 缺考 ' + c.absent;
|
'总 ' + c.total + ' / 已交 ' + c.submitted + ' / 缺考 ' + c.absent +
|
||||||
|
(totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : '');
|
||||||
|
|
||||||
// 6 卡汇总
|
// 6 卡汇总
|
||||||
const color = rateColor(c.rate || 0);
|
const color = rateColor(c.rate || 0);
|
||||||
@@ -271,7 +325,8 @@ function renderBoard(){
|
|||||||
box.innerHTML = '<div class="gpanel">' +
|
box.innerHTML = '<div class="gpanel">' +
|
||||||
'<div class="ghead"><strong>本班考试成员</strong>' +
|
'<div class="ghead"><strong>本班考试成员</strong>' +
|
||||||
'<span class="rate-num">' + c.submitted + ' 人</span></div>' +
|
'<span class="rate-num">' + c.submitted + ' 人</span></div>' +
|
||||||
'<div class="sub">该班级未设置分组(A/B),以下按成绩从高到低排列</div>' +
|
'<div class="sub">该班级未设置分组(A/B),以下按成绩从高到低排列' +
|
||||||
|
(c.attempts > c.submitted ? ' · 共 ' + c.attempts + ' 次刷题' : '') + '</div>' +
|
||||||
membersTable(c.members) + '</div>';
|
membersTable(c.members) + '</div>';
|
||||||
} else {
|
} else {
|
||||||
box.innerHTML = '<div class="group-compare">' + groups.map(g => {
|
box.innerHTML = '<div class="group-compare">' + groups.map(g => {
|
||||||
@@ -279,7 +334,8 @@ function renderBoard(){
|
|||||||
return '<div class="gpanel">' +
|
return '<div class="gpanel">' +
|
||||||
'<div class="ghead"><strong>' + esc(g.group) + ' 组</strong>' +
|
'<div class="ghead"><strong>' + esc(g.group) + ' 组</strong>' +
|
||||||
'<span class="rate-num" style="color:' + gc + '">' + g.rate + '%</span></div>' +
|
'<span class="rate-num" style="color:' + gc + '">' + g.rate + '%</span></div>' +
|
||||||
'<div class="sub">已提交 ' + g.submitted + ' / 总人数 ' + g.total + ' · 缺考 ' + g.absent + '</div>' +
|
'<div class="sub">已提交 ' + g.submitted + ' / 总人数 ' + g.total + ' · 缺考 ' + g.absent +
|
||||||
|
(g.attempts > g.submitted ? ' · 共 ' + g.attempts + ' 次刷题' : '') + '</div>' +
|
||||||
'<div class="bar"><i style="width:' + g.rate + '%;background:' + gc + '"></i></div>' +
|
'<div class="bar"><i style="width:' + g.rate + '%;background:' + gc + '"></i></div>' +
|
||||||
'<div class="class-metrics">' +
|
'<div class="class-metrics">' +
|
||||||
'<div class="m"><span>平均分</span><b>' + (g.avg != null ? g.avg : '-') + '</b></div>' +
|
'<div class="m"><span>平均分</span><b>' + (g.avg != null ? g.avg : '-') + '</b></div>' +
|
||||||
|
|||||||
@@ -263,6 +263,14 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 多次刷题明细弹窗 -->
|
||||||
|
<div class="modal" id="attemptsModal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-head"><h3 id="attTitle">提交明细</h3><button class="x" data-close>×</button></div>
|
||||||
|
<div class="modal-body" id="attBody"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
window.NY = {
|
window.NY = {
|
||||||
appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>,
|
appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>,
|
||||||
|
|||||||
+73
-13
@@ -128,9 +128,8 @@ class DataService
|
|||||||
$includeTemp = !empty($cfg['include_temp_save']);
|
$includeTemp = !empty($cfg['include_temp_save']);
|
||||||
$dedupe = !empty($cfg['dedupe']);
|
$dedupe = !empty($cfg['dedupe']);
|
||||||
|
|
||||||
$rows = [];
|
$rawRows = []; // 已匹配学号的原始 row(每条 = 一次提交)
|
||||||
$unmatched = [];
|
$unmatched = [];
|
||||||
$seenSid = [];
|
|
||||||
|
|
||||||
foreach ($answers as $a) {
|
foreach ($answers as $a) {
|
||||||
$tempSave = isset($a['tempSave']) ? (int)$a['tempSave'] : 1;
|
$tempSave = isset($a['tempSave']) ? (int)$a['tempSave'] : 1;
|
||||||
@@ -226,19 +225,15 @@ class DataService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($dedupe && isset($seenSid[$sid])) {
|
$rawRows[] = $row;
|
||||||
$idx = $seenSid[$sid];
|
|
||||||
// 保留提交时间最新的一份
|
|
||||||
if ($ts >= ($rows[$idx]['submit_ts'] ?? 0)) {
|
|
||||||
$rows[$idx] = $row;
|
|
||||||
$seenSid[$sid] = $idx;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$seenSid[$sid] = count($rows);
|
|
||||||
$rows[] = $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 {
|
usort($rows, static function (array $x, array $y): int {
|
||||||
$d = ($y['submit_ts'] ?? 0) <=> ($x['submit_ts'] ?? 0);
|
$d = ($y['submit_ts'] ?? 0) <=> ($x['submit_ts'] ?? 0);
|
||||||
@@ -292,6 +287,7 @@ class DataService
|
|||||||
'min_score' => $scores !== [] ? min($scores) : null,
|
'min_score' => $scores !== [] ? min($scores) : null,
|
||||||
'avg_duration' => $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null,
|
'avg_duration' => $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null,
|
||||||
'answer_total' => count($answers),
|
'answer_total' => count($answers),
|
||||||
|
'attempts_total'=> $attemptsTotal,
|
||||||
];
|
];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -312,4 +308,68 @@ class DataService
|
|||||||
'updated_at' => date('Y-m-d H:i:s'),
|
'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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user