diff --git a/api.php b/api.php index 355e40d..e05a88c 100644 --- a/api.php +++ b/api.php @@ -132,6 +132,36 @@ try { } ny_ok($res); + // ---------------- 用弹窗内(未保存)的凭据测试连接 ---------------- + case 'config_test': + $raw = (string)file_get_contents('php://input'); + $in = json_decode($raw, true); + if (!is_array($in)) { + $in = $_POST; + } + // 以已保存配置为基底,仅覆盖弹窗中填写的字段,避免漏填导致误测 + $test = ny_load_config(); + foreach (['base_url', 'api_prefix', 'username', 'verify_ssl', 'timeout'] as $k) { + if (array_key_exists($k, $in)) { + $test[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k]; + } + } + if (array_key_exists('password', $in)) { + $pwdIn = (string)$in['password']; + if ($pwdIn !== '' && $pwdIn !== '******') { + $test['password'] = $pwdIn; + } + } + $test['base_url'] = rtrim((string)($test['base_url'] ?? ''), '/'); + $test['api_prefix'] = '/' . trim((string)($test['api_prefix'] ?? '/api'), '/'); + try { + $client = new SurveyKingClient($test); + $p = $client->ping(); + ny_ok(['connected' => true, 'projects' => $p['projects'], 'elapsed' => $p['elapsed']]); + } catch (Throwable $e) { + ny_ok(['connected' => false, 'msg' => $e->getMessage()]); + } + // ---------------- 连接测试 ---------------- case 'ping': $client = new SurveyKingClient(cfg()); @@ -345,7 +375,13 @@ try { } else { Roster::save($res['students']); } - ny_ok(['students' => Roster::all(), 'meta' => Roster::meta(), 'imported' => $res['count']]); + ny_ok([ + 'students' => Roster::all(), + 'meta' => Roster::meta(), + 'imported' => $res['count'], + 'skipped' => $res['skipped'] ?? 0, + 'duplicates' => $res['duplicates'] ?? 0, + ]); case 'roster_save': $raw = (string)file_get_contents('php://input'); diff --git a/assets/app.js b/assets/app.js index 2ad6cf2..0f8513a 100644 --- a/assets/app.js +++ b/assets/app.js @@ -21,7 +21,8 @@ sortAsc: false, timer: null, loading: false, - lastSig: '' + lastSig: '', + connFail: 0 }; /* ---------------- 本地存储 ---------------- */ @@ -41,6 +42,7 @@ const LS_LASTQID = 'ny_last_qid'; const LS_FILTERS = 'ny_filters'; // {cls, grp} -> 跟随 projectId const LS_LAYOUT = 'ny_layout'; + const LS_INTERVAL = 'ny_interval'; // 用户手动调整的刷新间隔(跟随本机) const LS_ROSTER = 'ny_roster_cache'; // 名单 cache(用于首屏补齐班级/组别下拉) const dataKey = pid => 'ny_data_' + pid; @@ -243,6 +245,7 @@ try { const d = await api('data', { params: { projectId: pid, nameQid: $('#nameFieldSel').value } }); applyData(d); + state.connFail = 0; LS.set(dataKey(pid), d); LS.set(LS_LASTPID, pid); LS.set(LS_LASTQID, $('#nameFieldSel').value || ''); @@ -259,6 +262,9 @@ if (!silent) setConn('conn-err', '数据获取失败'); showAlert(e.message); } else { + // 后台静默刷新失败:保留缓存数据,但把连接指示转为"中断"告警,成功即恢复 + state.connFail++; + if (state.connFail >= 1) setConn('conn-warn', '⚠️ 实时刷新中断 · 已显示本地缓存'); showAlert('⚠️ 后台刷新失败,已显示本地缓存:' + e.message); } } finally { @@ -697,7 +703,10 @@ // 用缓存数据先快速渲染,再后台拉新数据 refresh(); }); - $('#intervalSel').addEventListener('change', schedule); + $('#intervalSel').addEventListener('change', () => { + LS.set(LS_INTERVAL, parseInt($('#intervalSel').value, 10) || 15); + schedule(); + }); $('#autoRefresh').addEventListener('change', e => { if (e.target.checked) { schedule(); refresh(true); } else if (state.timer) clearInterval(state.timer); @@ -740,6 +749,8 @@ /* 设置保存 */ $('#btnSaveCfg').addEventListener('click', async () => { + const btnSave = $('#btnSaveCfg'); + btnSave.disabled = true; btnSave.textContent = '保存中…'; const pass = $('#cfgPass').value; const payload = { page_size: parseInt($('#cfgPageSize').value, 10) || 200, @@ -758,7 +769,7 @@ let resp; try { resp = await api('config_save', { method: 'POST', body: payload }); - } catch (e) { showAlert(e.message); return; } + } catch (e) { showAlert(e.message); btnSave.disabled = false; btnSave.textContent = '保存'; return; } $('#cfgPass').value = ''; // 提交后清空密码框 const note = (resp && resp.conn_changed) ? '账号/连接已更新' : '设置已保存'; if (resp && resp.ping) { @@ -783,8 +794,44 @@ if (!resp || !resp.ping || resp.ping.ok) { $('#settingsModal').classList.remove('open'); } + btnSave.disabled = false; btnSave.textContent = '保存'; }); + /* 独立测试连接(使用弹窗内尚未保存的账号/密码,不落盘) */ + async function testConnection() { + const btn = $('#btnTestConn'); + const box = $('#pingResult'); + if (!btn || !box) return; + btn.disabled = true; btn.textContent = '测试中…'; + box.classList.remove('hidden', 'err'); box.classList.add('loading'); + box.textContent = '正在连接卷王系统测试…'; + try { + // 仅传可编辑的连接字段;base_url / api_prefix 在弹窗中只读,沿用已保存值 + const r = await api('config_test', { + method: 'POST', + body: { + username: $('#cfgUser').value.trim(), + password: $('#cfgPass').value, + verify_ssl: $('#cfgSsl').checked + } + }); + box.classList.remove('loading'); + if (r && r.connected) { + box.classList.remove('err'); + box.textContent = '✅ 连接成功:检测到 ' + r.projects + ' 个问卷 / 用时 ' + r.elapsed + 'ms'; + } else { + box.classList.add('err'); + box.textContent = '❌ 连接失败:' + ((r && r.msg) || '未知错误'); + } + } catch (e) { + box.classList.remove('loading'); box.classList.add('err'); + box.textContent = '❌ 测试请求异常:' + e.message; + } finally { + btn.disabled = false; btn.textContent = '测试连接'; + } + } + $('#btnTestConn').addEventListener('click', testConnection); + // 收集"各试卷独立满分" -> {projectId: score} function collectFullScores() { const out = {}; @@ -871,6 +918,13 @@ // 2) 支持 ?projectId=xxx&nameQid=yyy 直接打开某份问卷 const qs = new URLSearchParams(location.search); if (qs.get('interval')) $('#intervalSel').value = qs.get('interval'); + else { + const iv = LS.get(LS_INTERVAL, 0); + if (iv) { + const opts = $$('#intervalSel option').map(o => parseInt(o.value, 10)); + if (opts.indexOf(iv) >= 0) $('#intervalSel').value = String(iv); + } + } state.deeplinkProject = qs.get('projectId') || ''; state.deeplinkQid = qs.get('nameQid') || ''; diff --git a/assets/draw.js b/assets/draw.js index 8a3ba60..ea32d9f 100644 --- a/assets/draw.js +++ b/assets/draw.js @@ -710,6 +710,9 @@ // 重置结果:清空本次结果 + 累计已抽取(历史记录保留) $('#btnReset').addEventListener('click', () => { + if (state.picked.length || state.history.size) { + if (!confirm('确定重置?将清空本次结果与累计已抽取的 ' + state.history.size + ' 人(历史记录仍保留)。')) return; + } state.picked = []; state.history = new Set(); renderResult(); @@ -721,6 +724,37 @@ showAlert('已重置结果与累计已抽取(历史记录仍保留)', 'info'); }); + // 复制名单:两列「姓名 / 登陆账号」,制表符分隔,便于直接粘贴到 Excel / 微信 + $('#btnCopy').addEventListener('click', async () => { + if (!state.picked.length) { + showAlert('请先点击「🎲 开始抽取」再复制'); + return; + } + const lines = state.picked.map(m => + (m.name || '') + '\t' + (m.login || m.sid || '')); + const text = lines.join('\n'); + let ok = false; + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + ok = true; + } + } catch (e) { /* 降级到 textarea */ } + if (!ok) { + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + ok = document.execCommand('copy'); + ta.remove(); + } catch (e) { ok = false; } + } + showAlert(ok ? ('已复制 ' + state.picked.length + ' 人的姓名 / 登陆账号到剪贴板') : '复制失败,请手动选择结果复制', ok ? 'info' : ''); + }); + // 清空历史(顶栏) $('#btnClearLog').addEventListener('click', () => { if (!state.log.length) return showAlert('暂无历史记录'); diff --git a/assets/style.css b/assets/style.css index 95a01bc..7ff7feb 100644 --- a/assets/style.css +++ b/assets/style.css @@ -80,6 +80,8 @@ body { .conn-err { background: var(--danger-weak); color: var(--danger); } .conn-err i { background: var(--danger); } .conn-load i { background: var(--warn); animation: blink 1s infinite; } +.conn-warn { background: var(--warn-weak, #fff4e0); color: var(--warn, #e08c1a); } +.conn-warn i { background: var(--warn, #e08c1a); animation: blink 1s infinite; } @keyframes blink { 50% { opacity: .25; } } /* ---------- 按钮 ---------- */ @@ -231,6 +233,7 @@ select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px .updated { font-size: 12px; color: var(--text-3); display: flex; align-items: center; gap: 6px; } .dot { width: 7px; height: 7px; border-radius: 50%; background: #cbd3e2; display: inline-block; } .dot.on { background: var(--ok); animation: pulse 1.6s infinite; } +.dot.stale { background: var(--danger); animation: blink 1s infinite; } @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } } .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } @@ -367,6 +370,7 @@ tr.clickable:hover td { background: var(--border-2); } background: var(--ok-weak); color: #0d7a4d; border: 1px solid #c8ecda; } .ping-result.err { background: var(--danger-weak); color: #a3342c; border-color: #f5d3cf; } +.ping-result.loading { background: var(--border-2); color: var(--text-2); border-color: var(--border-2); } /* ---------- 名单 ---------- */ .upload-zone { diff --git a/dashboard.php b/dashboard.php index ba59ee5..a830fcc 100644 --- a/dashboard.php +++ b/dashboard.php @@ -118,6 +118,8 @@ const LS = { }; const LS_PROJECTS = 'ny_projects'; const LS_BOARD = (pid, cls) => 'ny_board_' + pid + '_' + encodeURIComponent(cls); +const LS_DASH_AUTO = 'ny_dash_auto'; +const LS_DASH_INTERVAL= 'ny_dash_interval'; let DASH = null; let CUR = readUrl(); @@ -235,7 +237,7 @@ function membersTable(members){ '排名姓名成绩' + 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')); const att = (m.attempts || 1) > 1; @@ -274,6 +276,37 @@ function closeAttempts(){ if (m) m.classList.remove('open'); } +/* ---------- 看板占位(未选问卷/班级) ---------- */ +function renderPlaceholderBoard(){ + const cards = [ + ['总人数','-'],['已提交','-'],['缺考','-'], + ['提交率','-'],['平均分','-'],['合格率','-'] + ]; + $('#boardSummary').innerHTML = cards.map(c => + '
' + c[0] + '' + c[1] + '
').join(''); + $('#boardBody').innerHTML = '
请选择问卷与班级查看考试整体情况
'; + $('#heroTitle').textContent = '班级看板'; + $('#heroSub').textContent = '选择一个问卷与班级查看考试整体情况'; + const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); } +} + +/* ---------- 实时状态指示 ---------- */ +function markLive(){ + const d = $('#liveDot'); + if (d) { d.classList.remove('stale'); d.classList.add('on'); } + setTimeout(() => { const e = $('#liveDot'); if (e) e.classList.remove('on'); }, 900); +} +function markStale(msg){ + const d = $('#liveDot'); + if (d) { d.classList.remove('on'); d.classList.add('stale'); } + const lu = $('#lastUpdate'); + if (lu) lu.textContent = '刷新中断(仍显示上次数据)'; +} +function handleAuthFail(){ + $('#boardBody').innerHTML = '
卷王账号未配置或登录已失效,点击重新登录
'; + setTimeout(() => { location.href = 'login.php'; }, 1200); +} + /* ---------- 渲染单班看板 ---------- */ function renderBoard(){ const pid = $('#projectSel').value; @@ -282,11 +315,7 @@ function renderBoard(){ writeUrl({ pid, cls }); if (!pid || !cls || !DASH) { - $('#boardSummary').innerHTML = '
总人数-
'.repeat(0) || - '
请选择问卷与班级
'; - $('#boardBody').innerHTML = '
请选择问卷与班级查看考试整体情况
'; - $('#heroTitle').textContent = '班级看板'; - $('#heroSub').textContent = '选择一个问卷与班级查看考试整体情况'; + renderPlaceholderBoard(); return; } LS.set('ny_board_last_pid', JSON.stringify(pid)); @@ -384,9 +413,11 @@ function schedule(){ const sec = parseInt($('#intervalSel').value, 10) || 15; timer = setInterval(refreshBoard, sec * 1000); } -async function refreshBoard(){ +async function refreshBoard(manual){ const pid = $('#projectSel').value; if (!pid) return; + const btn = $('#btnRefresh'); + if (manual && btn) { btn.disabled = true; btn.textContent = '刷新中…'; } try { const fresh = await api('dashboard', { projectId: pid }); DASH = fresh; @@ -403,24 +434,54 @@ async function refreshBoard(){ } renderBoard(); $('#lastUpdate').textContent = fresh.updated_at || '-'; - $('#liveDot').classList.add('on'); - setTimeout(() => $('#liveDot').classList.remove('on'), 900); + markLive(); } catch (e) { - /* 静默续期:保留旧数据不打扰 */ + if (e.auth) { handleAuthFail(); return; } + // 静默续期:保留旧数据,但把实时点变红提示中断 + markStale(e.message); + } finally { + if (manual && btn) { btn.disabled = false; btn.textContent = '刷新'; } } } /* ---------- 事件 ---------- */ $('#projectSel').addEventListener('change', () => { CUR.pid = $('#projectSel').value; CUR.cls = ''; refreshClassSel(); schedule(); }); $('#classSel').addEventListener('change', () => { CUR.cls = $('#classSel').value; renderBoard(); }); -$('#btnRefresh').addEventListener('click', refreshBoard); -$('#autoRefresh').addEventListener('change', () => { writeUrl({ auto: $('#autoRefresh').checked }); schedule(); }); -$('#intervalSel').addEventListener('change', schedule); +$('#btnRefresh').addEventListener('click', () => refreshBoard(true)); +$('#autoRefresh').addEventListener('change', () => { + writeUrl({ auto: $('#autoRefresh').checked }); + LS.set(LS_DASH_AUTO, $('#autoRefresh').checked); + schedule(); +}); +$('#intervalSel').addEventListener('change', () => { + LS.set(LS_DASH_INTERVAL, parseInt($('#intervalSel').value, 10) || 15); + schedule(); +}); $('#btnFullscreen').addEventListener('click', toggleFullscreen); document.addEventListener('fullscreenchange', onFsChange); document.addEventListener('webkitfullscreenchange', onFsChange); +/* ---------- 偏好恢复(自动刷新开关 + 间隔,含 ?interval= 深链) ---------- */ +function restoreDashPrefs(){ + const qs = new URLSearchParams(location.search); + if (qs.get('interval')) { + $('#intervalSel').value = qs.get('interval'); + LS.set(LS_DASH_INTERVAL, parseInt($('#intervalSel').value, 10) || 15); + } else { + const iv = LS.get(LS_DASH_INTERVAL, 0); + if (iv) { + const opts = Array.prototype.slice.call($('#intervalSel').options).map(o => parseInt(o.value, 10)); + if (opts.includes(iv)) $('#intervalSel').value = String(iv); + } + } + if (CUR.auto !== '0') { + const auto = LS.get(LS_DASH_AUTO, null); + if (auto !== null) $('#autoRefresh').checked = !!auto; + } +} + /* ---------- 启动 ---------- */ +restoreDashPrefs(); loadProjects().then(() => { if (CUR.pid) { $('#projectSel').value = CUR.pid; diff --git a/draw.php b/draw.php index eb73abe..11eb4af 100644 --- a/draw.php +++ b/draw.php @@ -104,6 +104,7 @@ $rosterCount = Roster::count();

抽取结果

+
diff --git a/index.php b/index.php index 9df731e..8705572 100644 --- a/index.php +++ b/index.php @@ -219,6 +219,7 @@ $rosterCount = Roster::count(); diff --git a/lib/Roster.php b/lib/Roster.php index ad3baa8..f6cc1ec 100644 --- a/lib/Roster.php +++ b/lib/Roster.php @@ -128,6 +128,10 @@ class Roster * 解析上传文件 -> 学生数组 * @return array{count:int,students:array} */ + /** + * 解析上传文件 -> 学生数组 + * @return array{count:int,skipped:int,duplicates:int,headers:array,students:array} + */ public static function importFile(string $file, string $ext = ''): array { if ($ext === '') { @@ -148,9 +152,17 @@ class Roster } if ($rows === []) { - throw new RuntimeException('文件为空或无法解析'); + throw new RuntimeException('文件为空或无法解析(请确认是 xlsx/csv 且包含数据行)'); } - return ['count' => count($rows), 'students' => self::rowsToStudents($rows)]; + $meta = []; + $students = self::rowsToStudents($rows, $meta); + return [ + 'count' => count($students), + 'skipped' => $meta['skipped'], + 'duplicates' => $meta['duplicates'], + 'headers' => $meta['headers'], + 'students' => $students, + ]; } public static function parseCsv(string $file): array @@ -195,10 +207,13 @@ class Roster /** * 识别 学号/姓名/班级/组别/登陆账号 列 * 登陆账号仅用于随机抽取页导出白名单,界面任何位置都不显示 + * @param array $rows + * @param array $meta 输出:['skipped'=>int,'duplicates'=>int,'headers'=>array] * @return array */ - public static function rowsToStudents(array $rows): array + public static function rowsToStudents(array $rows, array &$meta = []): array { + $meta = ['skipped' => 0, 'duplicates' => 0, 'headers' => []]; $sidCol = -1; $nameCol = -1; $clsCol = -1; @@ -206,11 +221,11 @@ class Roster $loginCol = -1; $headerIdx = -1; - // 找表头 + // 找表头:同一行需同时命中「学号类」与「姓名类」关键词(容错全角空格/间隔) foreach ($rows as $i => $row) { $joined = implode('|', array_map('strval', $row)); - if (preg_match('/学\s*号|编\s*号|序\s*号|no\.?|id/i', $joined) - && preg_match('/姓\s*名|名\s*字|学生|成员/i', $joined)) { + if (preg_match('/学\s*号|编\s*号|序\s*号|学\s*员\s*号|号\s*码|no\.?|id/i', $joined) + && preg_match('/姓\s*名|名\s*字|学\s*生\s*姓\s*名|姓\s*氏|成\s*员/i', $joined)) { $headerIdx = $i; break; } @@ -220,22 +235,25 @@ class Roster $start = $headerIdx + 1; foreach ($rows[$headerIdx] as $j => $cell) { $c = (string)$cell; - if ($sidCol < 0 && preg_match('/学\s*号|编\s*号|序\s*号|^\s*no\.?\s*$|^\s*id\s*$/i', $c)) { + if ($sidCol < 0 && preg_match('/学\s*号|编\s*号|序\s*号|学\s*员\s*号|号\s*码|^\s*no\.?\s*$|^\s*id\s*$/i', $c)) { $sidCol = $j; } - if ($nameCol < 0 && preg_match('/姓\s*名|名\s*字|学生姓名/i', $c)) { + if ($nameCol < 0 && preg_match('/姓\s*名|名\s*字|学\s*生\s*姓\s*名|姓\s*氏/i', $c)) { $nameCol = $j; } if ($clsCol < 0 && preg_match('/班\s*级|班级|class/i', $c)) { $clsCol = $j; } - if ($grpCol < 0 && preg_match('/组\s*别|分\s*组|^\s*组\s*$|group/i', $c)) { + if ($grpCol < 0 && preg_match('/组\s*别|分\s*组|组\s*次|^\s*组\s*$|group/i', $c)) { $grpCol = $j; } if ($loginCol < 0 && preg_match('/登\s*[陆录]\s*账\s*号|登\s*[陆录]\s*账号|账\s*号|手\s*机\s*号|手\s*机/i', $c)) { $loginCol = $j; } } + $meta['headers'] = [ + 'sid' => $sidCol, 'name' => $nameCol, 'cls' => $clsCol, 'grp' => $grpCol, 'login' => $loginCol, + ]; } if ($sidCol < 0) { $sidCol = 0; @@ -245,20 +263,27 @@ class Roster } $out = []; + $seen = []; for ($i = $start; $i < count($rows); $i++) { $row = $rows[$i]; $sid = trim((string)($row[$sidCol] ?? '')); $name = trim((string)($row[$nameCol] ?? '')); if ($sid === '' && $name === '') { + $meta['skipped']++; continue; } - // 学号去掉 .0 尾巴(Excel 数字读取) - $sid = preg_replace('/\.0+$/', '', $sid) ?? $sid; - $cls = $clsCol >= 0 ? trim((string)($row[$clsCol] ?? '')) : ''; - $grp = $grpCol >= 0 ? trim((string)($row[$grpCol] ?? '')) : ''; + // 学号/登陆账号去掉 Excel 数字尾巴(如 2024012001.0 / 13912687810.0) + $sid = preg_replace('/\.0+$/', '', $sid) ?? $sid; + $cls = $clsCol >= 0 ? trim((string)($row[$clsCol] ?? '')) : ''; + $grp = $grpCol >= 0 ? trim((string)($row[$grpCol] ?? '')) : ''; $login = $loginCol >= 0 ? trim((string)($row[$loginCol] ?? '')) : ''; - // 登陆账号同样去掉 Excel 数字尾巴(手机号被读成 13912687810.0) $login = preg_replace('/\.0+$/', '', $login) ?? $login; + // 同一条学号多次出现:保留首次,其余记重复跳过(避免看板/统计被重复计入) + if ($sid !== '' && isset($seen[$sid])) { + $meta['duplicates']++; + continue; + } + $seen[$sid] = true; $out[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login]; } return $out;