fix: 全量提示改为顶部 toast 飘窗 + 修复卷王 token 失效跳转死循环 + 调整行点击防御

- 所有 showAlert 改为 showToast 顶部飘窗(首屏 / 抽取页统一,含空消息守卫)
- 导出排序新增 成绩升序 / 成绩降序 两种(前后端 + 设置弹窗都支持)
- 设置保存:仅连接信息真变化才 reload projects,避免非连接改动导致持续刷新失败
- 强化历史成绩弹窗排序与兜底(缺 submit_ts 时按 idx 倒序)
- 行点击事件加防御:要求父节点是 #tbody,避免被无关 tr 误触发
- 卷王 token 失效时:先 fetch logout 清会话再跳 login.php,登录页也加 token 探测兜底,根治"跳不过去"死循环
- 移除 index.php / draw.php 中已无用的内联 #alert DOM
- 兼容 config_get 缺 export_sort 时回退 time 的 4 模式归一化
This commit is contained in:
2026-09-10 12:30:00 +08:00
parent 137e2cb665
commit 6def49b969
7 changed files with 160 additions and 33 deletions
+21 -4
View File
@@ -72,9 +72,10 @@ try {
if (array_key_exists('history_display', $in)) {
$new['history_display'] = ($in['history_display'] === 'inline') ? 'inline' : 'click';
}
// 成绩导出排序:time(按提交时间降序,默认) / sid(按学号)
// 成绩导出排序:time(按提交时间降序,默认)/ sid(按学号)/ score_desc(成绩降序)/ score_asc(成绩升序)
if (array_key_exists('export_sort', $in)) {
$new['export_sort'] = ($in['export_sort'] === 'sid') ? 'sid' : 'time';
$v = (string)$in['export_sort'];
$new['export_sort'] = in_array($v, ['time','sid','score_desc','score_asc'], true) ? $v : 'time';
}
// 各试卷独立满分(覆盖整个 full_scores 映射)
if (array_key_exists('full_scores', $in) && is_array($in['full_scores'])) {
@@ -500,11 +501,27 @@ try {
} else {
// 导出全部考试记录(同一学号考了 N 次就有 N 行),并保留"考试次数"
$sortMode = (string)($_GET['sort'] ?? '');
$sortMode = ($sortMode === 'sid') ? 'sid' : 'time';
$all = DataService::expandAttempts($rows, true);
// time=按提交时间降序;sid=按学号(同学号内按提交时间降序)
$all = DataService::sortForExport($all, $sortMode);
// score_desc=按成绩降序(同分按提交时间);score_asc=按成绩升序
if ($sortMode === 'score_desc' || $sortMode === 'score_asc') {
$dir = ($sortMode === 'score_desc') ? -1 : 1;
usort($all, static function (array $x, array $y) use ($dir): int {
// null 成绩排末尾
$xn = ($x['score'] === null);
$yn = ($y['score'] === null);
if ($xn && $yn) return 0;
if ($xn) return 1;
if ($yn) return -1;
$d = ($x['score'] <=> $y['score']) * $dir;
if ($d !== 0) return $d;
// 同分按提交时间降序
return ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0));
});
} else {
$all = DataService::sortForExport($all, ($sortMode === 'sid') ? 'sid' : 'time');
}
fputcsv($out, ['学号', '姓名', '班级', '组别', '成绩', '考试次数', '第几次', '提交时间', '考试时长', '考试时长(秒)']);
foreach ($all as $r) {
+77 -16
View File
@@ -91,19 +91,51 @@
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
/* ---------------- Toast 顶部飘窗通知(替代旧 inline alert) ---------------- */
let toastTimer = null;
const TOAST_DURATION = 3500; // 默认显示 3.5 秒
const TOAST_LIMIT = 5; // 最多同时展示 5 条(旧消息优先被挤掉)
function showToast(msg, type) {
if (msg === '' || msg == null) return; // 空消息不弹(showAlert('') 兼容旧调用 → 静默返回)
// type: ''=默认(蓝) | 'info'=绿 | 'error'=红 | 'warn'=橙
const el = document.getElementById('toastContainer') || (function () {
const c = document.createElement('div');
c.id = 'toastContainer';
c.style.cssText = 'position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none;max-width:calc(100vw - 32px);width:560px';
document.body.appendChild(c);
return c;
})();
// 限流:超过上限移除最旧
while (el.children.length >= TOAST_LIMIT) {
el.firstChild.remove();
}
const t = document.createElement('div');
const cls = type === 'error' ? 'toast-error' : (type === 'warn' ? 'toast-warn' : (type === 'info' ? 'toast-ok' : 'toast'));
t.className = 'toast ' + cls;
t.innerHTML = '<span>' + esc(msg).replace(/\n/g, '<br>') + '</span>';
t.style.cssText = 'pointer-events:auto;padding:10px 16px;border-radius:10px;font-size:13px;line-height:1.55;box-shadow:0 8px 30px rgba(15,23,42,.18);animation:toastIn .25s ease;white-space:pre-wrap;word-break:break-word';
el.appendChild(t);
// 点击关闭
t.addEventListener('click', () => { t.style.animation='toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); });
// 自动消失
setTimeout(() => {
if (t.parentNode) { t.style.animation='toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); }
}, TOAST_DURATION);
}
/** 兼容旧调用:showAlert 现在走 toast(type 映射) */
function showAlert(msg, type) {
if (msg === '' || msg == null) return; // 旧版 showAlert('') 用于"清空内联提示",toast 无需此操作
const t = type === 'info' ? 'ok' : (type === 'error' ? 'error' : '');
showToast(msg, t);
}
function setConn(cls, text) {
const el = $('#conn');
el.className = 'conn ' + cls;
el.innerHTML = '<i></i><span>' + esc(text) + '</span>';
}
function showAlert(msg, type) {
const el = $('#alert');
if (!msg) { el.classList.add('hidden'); return; }
el.textContent = msg;
el.className = 'alert' + (type === 'info' ? ' info' : '');
}
// 导出提示:说明本次导出的筛选范围
function exportTip(what, cls, grp) {
const parts = [];
@@ -287,9 +319,21 @@
LS.set(LS_ROSTER, { ts: Date.now(), students: d.roster || [] });
} catch (e) {
if (e.auth) {
// 卷王账号未配置或登录已失效:引导重新登录,避免卡在"已显示本地缓存"的死状态
// 卷王账号未配置或登录已失效:引导重新登录(强制清掉本地会话 + 跳登录页)
// 关键:必须先调 logout.php 把服务端会话清空,否则 login.php 发现会话还在会把人踢回 index.php,形成"跳不过去"的死循环
if (!state._authRedirecting) {
state._authRedirecting = true;
if (state.timer) clearInterval(state.timer);
if (state.hlTimer) clearInterval(state.hlTimer);
showAlert('卷王账号未配置或登录已失效,正在跳转登录页…', 'error');
setTimeout(() => { location.href = 'login.php'; }, 900);
// 异步清会话,忽略失败;登录页端也有探测兜底(token 过期时主动销毁会话)
fetch('logout.php', { method: 'POST', credentials: 'same-origin' })
.catch(() => {})
.finally(() => {
// 200ms 足够让 toast 入场,又不至于让用户干等
setTimeout(() => { window.location.href = 'login.php'; }, 200);
});
}
return;
}
if (!cached) {
@@ -667,11 +711,16 @@
// 后端未返回明细时,用代表行兜底成一条
list = [{
idx: 1, is_best: true, score: r.score, submit_at: r.submit_at,
submit_ts: r.submit_ts || r.last_submit_ts || 0,
duration_txt: r.duration_txt, temp_save: !!r.temp_save,
}];
}
// 新 -> 旧 展示
const desc = list.slice().sort((a, b) => (b.submit_ts || 0) - (a.submit_ts || 0));
// 新 -> 旧 展示(缺 submit_ts 时按 idx 倒序兜底,保证"次序"看起来合理)
const desc = list.slice().sort((a, b) => {
const ta = a.submit_ts || 0, tb = b.submit_ts || 0;
if (ta !== tb) return tb - ta;
return (b.idx || 0) - (a.idx || 0);
});
const rows = desc.map((a, i) => {
const sc = (a.score == null) ? '<span class="muted">未判分</span>' : esc(a.score);
const best = (a.is_best && attempts > 1) ? ' <span class="tag tag-best">最佳</span>' : '';
@@ -764,7 +813,7 @@
renderFullScores((c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {});
// 历史成绩展示方式 + 导出排序
state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click';
state.exportSort = (c.export_sort === 'sid') ? 'sid' : 'time';
state.exportSort = (c.export_sort === 'sid') ? 'sid' : ((c.export_sort === 'score_desc') ? 'score_desc' : ((c.export_sort === 'score_asc') ? 'score_asc' : 'time'));
$('#cfgHistoryDisplay').value = state.historyDisplay;
$('#cfgExportSort').value = state.exportSort;
if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort;
@@ -898,10 +947,14 @@
});
});
/* 成绩明细行点击 → 历史成绩弹窗(事件委托,避免内联 onclick 找不到 IIFE 内函数) */
/* 成绩明细行点击 → 历史成绩弹窗(事件委托,避免内联 onclick 找不到 IIFE 内函数)
注意:先绑 tbody 的 click,再绑 document 的 modal-close click;事件冒泡到 tbody 时就处理掉,
让 modal-close 只对弹窗自身 / [data-close] 元素生效,避免误关。 */
$('#tbody').addEventListener('click', e => {
const tr = e.target.closest('tr[data-sid]');
if (!tr) return;
// 仅响应"打开成员历史"动作;表头排序 sortable 在 #dataTable 上另行处理
if (tr.parentNode && tr.parentNode.id !== 'tbody') return;
const sid = tr.getAttribute('data-sid');
if (sid) openAttemptsModal(sid);
});
@@ -928,12 +981,17 @@
const pid = $('#projectSel').value;
if (!pid) return showAlert('请先选择问卷 / 考试');
const cls = $('#clsSel').value, grp = $('#grpSel').value;
const sort = (state.exportSort === 'sid') ? 'sid' : 'time';
const sort = (state.exportSort === 'sid') ? 'sid' : ((state.exportSort === 'score_desc') ? 'score_desc' : ((state.exportSort === 'score_asc') ? 'score_asc' : 'time'));
// 导出提示文案(与排序方式对应,让用户看清"这次按什么排")
const sortLabel = (sort === 'sid') ? '按学号'
: (sort === 'score_desc') ? '按成绩降序'
: (sort === 'score_asc') ? '按成绩升序'
: '按提交时间';
location.href = 'api.php?action=export&projectId=' + encodeURIComponent(pid) +
'&nameQid=' + encodeURIComponent($('#nameFieldSel').value) +
'&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp) +
'&sort=' + sort;
showAlert(exportTip('成绩明细', cls, grp) + ',排序:' + (sort === 'sid' ? '按学号' : '按提交时间'), 'info');
showAlert(exportTip('成绩明细', cls, grp) + ',排序:' + sortLabel, 'info');
});
$('#btnExportAbsent').addEventListener('click', () => {
const pid = $('#projectSel').value;
@@ -996,7 +1054,10 @@
opts.forEach(v => { if (Math.abs(v - iv) < Math.abs(best - iv)) best = v; });
$('#intervalSel').value = String(best);
schedule();
// 仅连接信息变化时才重新加载项目列表(避免非连接设置保存触发破坏性刷新)
if (resp && resp.conn_changed) {
loadProjects();
}
// 展示方式可能变化 → 立即重渲染表格(不重新拉数据)
state.historyDisplay = payload.history_display;
state.exportSort = payload.export_sort;
@@ -1159,7 +1220,7 @@
try {
const c = await api('config_get');
state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click';
state.exportSort = (c.export_sort === 'sid') ? 'sid' : 'time';
state.exportSort = (c.export_sort === 'sid') ? 'sid' : ((c.export_sort === 'score_desc') ? 'score_desc' : ((c.export_sort === 'score_asc') ? 'score_asc' : 'time'));
if ($('#cfgHistoryDisplay')) $('#cfgHistoryDisplay').value = state.historyDisplay;
if ($('#cfgExportSort')) $('#cfgExportSort').value = state.exportSort;
if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort;
+26 -5
View File
@@ -57,12 +57,33 @@
return j.data;
}
/* ---------------- Toast 顶部飘窗通知(与 app.js 一致) ---------------- */
const TOAST_DURATION = 3500;
const TOAST_LIMIT = 5;
function showToast(msg, type) {
if (msg === '' || msg == null) return;
const el = document.getElementById('toastContainer') || (function () {
const c = document.createElement('div');
c.id = 'toastContainer';
c.style.cssText = 'position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none;max-width:calc(100vw - 32px);width:560px';
document.body.appendChild(c);
return c;
})();
while (el.children.length >= TOAST_LIMIT) el.firstChild.remove();
const t = document.createElement('div');
const cls = type === 'error' ? 'toast-error' : (type === 'warn' ? 'toast-warn' : (type === 'info' ? 'toast-ok' : 'toast'));
t.className = 'toast ' + cls;
t.innerHTML = '<span>' + esc(msg).replace(/\n/g, '<br>') + '</span>';
t.style.cssText = 'pointer-events:auto;padding:10px 16px;border-radius:10px;font-size:13px;line-height:1.55;box-shadow:0 8px 30px rgba(15,23,42,.18);animation:toastIn .25s ease;white-space:pre-wrap;word-break:break-word';
el.appendChild(t);
t.addEventListener('click', () => { t.style.animation = 'toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); });
setTimeout(() => { if (t.parentNode) { t.style.animation = 'toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); } }, TOAST_DURATION);
}
/** 兼容旧 showAlert 调用 → 走 toast(draw.js 内部已有 esc,此处直接复用) */
function showAlert(msg, type) {
const el = $('#alert');
if (!el) return;
if (!msg) { el.classList.add('hidden'); return; }
el.textContent = msg;
el.className = 'alert' + (type === 'info' ? ' info' : '');
if (msg === '' || msg == null) return;
const t = type === 'info' ? 'ok' : (type === 'error' ? 'error' : '');
showToast(msg, t);
}
/* ================= 抽取核心(纯函数,供测试) ================= */
+17
View File
@@ -303,6 +303,23 @@ td.score { font-weight: 700; font-variant-numeric: tabular-nums; }
.diff { animation: flash 1.6s ease-out; }
@keyframes flash { from { background: #fff6d9; } to { background: transparent; } }
/* ---------- Toast 顶部飘窗通知 ---------- */
.toast {
background: linear-gradient(135deg, #2f6bff, #5b8bff);
color: #fff;
}
.toast-ok { background: linear-gradient(135deg, #12a86a, #3ec99a); color: #fff; }
.toast-error{ background: linear-gradient(135deg, #e0483d, #ef6c64); color: #fff; }
.toast-warn { background: linear-gradient(135deg, #e08c1a, #f0a54a); color: #fff; }
@keyframes toastIn {
from { opacity: 0; transform: translateY(-12px) scale(.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes toastOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(-8px) scale(.96); }
}
/* 新数据绿色底纹:常亮(不再闪烁),由"清除高亮"或切换项目/姓名题清除 */
table.grid tbody tr.new td { background: #e7f7ee; }
table.grid tbody tr.new:nth-child(even) td { background: #e7f7ee; }
-2
View File
@@ -89,8 +89,6 @@ $rosterCount = Roster::count();
<div class="field full hidden" id="ratioBox" style="grid-column:1/-1"></div>
</section>
<div id="alert" class="alert hidden"></div>
<section class="cards" id="statCards">
<div class="card accent-blue"><span>候选人数</span><b id="dPool">-</b></div>
<div class="card accent-green"><span>本次抽取</span><b id="dPicked">-</b></div>
+4 -2
View File
@@ -84,6 +84,8 @@ $rosterCount = Roster::count();
<select id="exportSortSel" style="height:30px;border-radius:8px;border:1px solid var(--border);padding:0 6px;background:#fff">
<option value="time">按提交时间</option>
<option value="sid">按学号</option>
<option value="score_desc">按成绩降序</option>
<option value="score_asc">按成绩升序</option>
</select>
</label>
<button class="btn" id="btnExport">导出成绩</button>
@@ -91,8 +93,6 @@ $rosterCount = Roster::count();
</div>
</section>
<div id="alert" class="alert hidden"></div>
<section class="cards" id="statCards">
<div class="card accent-blue"><span>名单总人数</span><b id="sTotal">-</b></div>
<div class="card ok accent-green"><span>已提交</span><b id="sSubmit">-</b></div>
@@ -198,6 +198,8 @@ $rosterCount = Roster::count();
<select id="cfgExportSort">
<option value="time">按提交时间(默认)</option>
<option value="sid">按学号(同学号按提交时间)</option>
<option value="score_desc">按成绩降序</option>
<option value="score_asc">按成绩升序</option>
</select>
<p class="hint">导出包含每次考试记录,始终带「考试次数」列</p>
</div>
+11
View File
@@ -7,10 +7,21 @@
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if (ny_is_auth()) {
// 已登录但卷王 token 可能已过期:用当前 token 探一次卷王 /system,能 200 即视为有效,否则清掉本地会话强制重新登录
try {
$client = new SurveyKingClient($cfg);
$client->ping();
} catch (Throwable $e) {
// 卷王 token 已失效或配置错误 → 清会话 + 强制显示登录页(避免 login.php 把人踢回 index.php 形成死循环)
$_SESSION = [];
@session_destroy();
}
if (ny_is_auth()) {
header('Location: index.php');
exit;
}
}
$cfg = ny_load_config();
$err = '';