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:
@@ -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) {
|
||||
|
||||
+79
-18
@@ -91,19 +91,51 @@
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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) {
|
||||
// 卷王账号未配置或登录已失效:引导重新登录,避免卡在"已显示本地缓存"的死状态
|
||||
showAlert('卷王账号未配置或登录已失效,正在跳转登录页…', 'error');
|
||||
setTimeout(() => { location.href = 'login.php'; }, 900);
|
||||
// 卷王账号未配置或登录已失效:引导重新登录(强制清掉本地会话 + 跳登录页)
|
||||
// 关键:必须先调 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');
|
||||
// 异步清会话,忽略失败;登录页端也有探测兜底(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();
|
||||
loadProjects();
|
||||
// 仅连接信息变化时才重新加载项目列表(避免非连接设置保存触发破坏性刷新)
|
||||
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
@@ -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);
|
||||
}
|
||||
|
||||
/* ================= 抽取核心(纯函数,供测试) ================= */
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,8 +8,19 @@ declare(strict_types=1);
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
|
||||
if (ny_is_auth()) {
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
// 已登录但卷王 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();
|
||||
|
||||
Reference in New Issue
Block a user