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/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();