diff --git a/api.php b/api.php index ee8f1ee..2613905 100644 --- a/api.php +++ b/api.php @@ -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) { diff --git a/assets/app.js b/assets/app.js index 13da16e..d6074e9 100644 --- a/assets/app.js +++ b/assets/app.js @@ -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 = '' + esc(msg).replace(/\n/g, '
') + '
'; + 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 = '' + esc(text) + ''; } - 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) ? '未判分' : esc(a.score); const best = (a.is_best && attempts > 1) ? ' 最佳' : ''; @@ -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; diff --git a/assets/draw.js b/assets/draw.js index ea32d9f..78177b0 100644 --- a/assets/draw.js +++ b/assets/draw.js @@ -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 = '' + esc(msg).replace(/\n/g, '
') + '
'; + 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); } /* ================= 抽取核心(纯函数,供测试) ================= */ diff --git a/assets/style.css b/assets/style.css index c3d2be9..5fef94b 100644 --- a/assets/style.css +++ b/assets/style.css @@ -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; } diff --git a/draw.php b/draw.php index 11eb4af..b500af8 100644 --- a/draw.php +++ b/draw.php @@ -89,8 +89,6 @@ $rosterCount = Roster::count(); - -
候选人数-
本次抽取-
diff --git a/index.php b/index.php index c6d7cec..4c73aab 100644 --- a/index.php +++ b/index.php @@ -84,6 +84,8 @@ $rosterCount = Roster::count(); @@ -91,8 +93,6 @@ $rosterCount = Roster::count();
- -
名单总人数-
已提交-
@@ -198,6 +198,8 @@ $rosterCount = Roster::count();

导出包含每次考试记录,始终带「考试次数」列

diff --git a/login.php b/login.php index 1654c24..15ce226 100644 --- a/login.php +++ b/login.php @@ -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();