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 {
|
||||
$total = count($stu);
|
||||
$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));
|
||||
$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)));
|
||||
@@ -226,6 +227,7 @@ try {
|
||||
return [
|
||||
'total' => $total,
|
||||
'submitted' => $submitted,
|
||||
'attempts' => $attempts,
|
||||
'absent' => max(0, $total - $submitted),
|
||||
'rate' => $total > 0 ? round($submitted / $total * 100, 1) : 0,
|
||||
'avg' => $avg,
|
||||
@@ -237,13 +239,16 @@ try {
|
||||
};
|
||||
|
||||
// 已提交成员榜:按成绩从高到低,缺成绩者排最后,并给出排名
|
||||
// 透传 attempts / attempts_list,供前端展示"同人多刷卷"
|
||||
$membersOf = static function (array $rs): array {
|
||||
$ms = [];
|
||||
foreach ($rs as $r) {
|
||||
$ms[] = [
|
||||
'sid' => (string)($r['sid'] ?? ''),
|
||||
'name' => (string)($r['name'] ?? ''),
|
||||
'score' => ($r['score'] ?? null),
|
||||
'sid' => (string)($r['sid'] ?? ''),
|
||||
'name' => (string)($r['name'] ?? ''),
|
||||
'score' => ($r['score'] ?? null),
|
||||
'attempts' => (int)($r['attempts'] ?? 1),
|
||||
'attempts_list' => $r['attempts_list'] ?? [],
|
||||
];
|
||||
}
|
||||
usort($ms, static function (array $a, array $b): int {
|
||||
@@ -440,7 +445,7 @@ try {
|
||||
fputcsv($out, [$r['sid'] ?? '', $r['name'] ?? '', $r['cls'] ?? '', $r['grp'] ?? '']);
|
||||
}
|
||||
} else {
|
||||
fputcsv($out, ['学号', '姓名', '班级', '组别', '成绩', '提交时间', '考试时长', '考试时长(秒)']);
|
||||
fputcsv($out, ['学号', '姓名', '班级', '组别', '成绩', '提交时间', '考试时长', '考试时长(秒)', '刷题次数']);
|
||||
foreach ($rows as $r) {
|
||||
fputcsv($out, [
|
||||
$r['sid'] ?? '',
|
||||
@@ -451,6 +456,7 @@ try {
|
||||
$r['submit_at'] ?? '',
|
||||
$r['duration_txt'] ?? '',
|
||||
$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 changed = state.prevMap[r.sid] && state.prevMap[r.sid] !== sig;
|
||||
const isNew = r.sid && state.newSids.has(r.sid);
|
||||
const clsName = (changed ? 'diff ' : '') + (isNew ? 'new' : '');
|
||||
return '<tr class="' + clsName.trim() + '">' +
|
||||
const canDrill = (r.attempts || 1) > 1;
|
||||
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="name">' + esc(r.name) + '</td>' +
|
||||
'<td class="name">' + esc(r.name) + att + '</td>' +
|
||||
'<td>' + esc(r.cls || '') + '</td>' +
|
||||
'<td>' + esc(r.grp || '') + '</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('');
|
||||
}
|
||||
|
||||
/* 多次刷题下钻弹窗(主统计页成绩明细) */
|
||||
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() {
|
||||
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-warn { background: var(--warn-weak); color: var(--warn); }
|
||||
.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 {
|
||||
|
||||
+63
-7
@@ -71,7 +71,21 @@ if (!ny_is_auth()) {
|
||||
|
||||
</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>
|
||||
// 弹窗:点击遮罩或带 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);
|
||||
function esc(s){ return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||
function rateColor(r){
|
||||
@@ -204,7 +218,7 @@ async function refreshClassSel(){
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 成员榜 ---------- */
|
||||
/* ---------- 成员榜(含多次刷题徽标与下钻) ---------- */
|
||||
function membersTable(members){
|
||||
if (!members || !members.length) {
|
||||
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>' +
|
||||
members.map(m => {
|
||||
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 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="name">' + esc(m.name) + '</td>' +
|
||||
'<td class="name">' + esc(m.name) + tag + '</td>' +
|
||||
'<td class="score num ' + cls + '">' + (sc == null ? '-' : sc) + '</td>' +
|
||||
'</tr>';
|
||||
}).join('') +
|
||||
'</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(){
|
||||
const pid = $('#projectSel').value;
|
||||
@@ -252,8 +293,21 @@ function renderBoard(){
|
||||
// 顶部品牌区:动态标题
|
||||
const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || '';
|
||||
$('#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$/, '') + ' · ' +
|
||||
'总 ' + c.total + ' / 已交 ' + c.submitted + ' / 缺考 ' + c.absent;
|
||||
'总 ' + c.total + ' / 已交 ' + c.submitted + ' / 缺考 ' + c.absent +
|
||||
(totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : '');
|
||||
|
||||
// 6 卡汇总
|
||||
const color = rateColor(c.rate || 0);
|
||||
@@ -271,7 +325,8 @@ function renderBoard(){
|
||||
box.innerHTML = '<div class="gpanel">' +
|
||||
'<div class="ghead"><strong>本班考试成员</strong>' +
|
||||
'<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>';
|
||||
} else {
|
||||
box.innerHTML = '<div class="group-compare">' + groups.map(g => {
|
||||
@@ -279,7 +334,8 @@ function renderBoard(){
|
||||
return '<div class="gpanel">' +
|
||||
'<div class="ghead"><strong>' + esc(g.group) + ' 组</strong>' +
|
||||
'<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="class-metrics">' +
|
||||
'<div class="m"><span>平均分</span><b>' + (g.avg != null ? g.avg : '-') + '</b></div>' +
|
||||
|
||||
@@ -263,6 +263,14 @@ $rosterCount = Roster::count();
|
||||
</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>
|
||||
window.NY = {
|
||||
appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>,
|
||||
|
||||
+73
-13
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user