feat: 全面打磨平台交互

- 名单导入容错:rowsToStudents 加去重/跳过统计、表头正则扩充、.0 尾巴清理;importFile 返回精确 count
- 看板:修复 .repeat(0) 空状态坏代码;新增实时点 stale 指示(手动/自动刷新失败变红 + 提示刷新中断);
        手动刷新按钮加刷新中…反馈;记住自动刷新开关与间隔(localStorage + ?interval= 深链)
- 主统计页:后台静默刷新连续失败时连接指示转实时刷新中断告警;记住用户手动调整的刷新间隔
- 设置页:新增 config_test 端点 + 独立测试连接按钮(用弹窗内未保存凭据测连接,不落盘);
        保存按钮加保存中…防重提交;登录失败不清旧 token
- 抽取页:重置结果加二次确认;新增复制名单按钮(两列制表符分隔,可粘到微信/Excel)
- 修复看板预先存在 JS 语法错误(少一个右括号导致整页 JS 失效)
This commit is contained in:
2026-09-09 18:53:00 +08:00
parent c4c3bf7da1
commit 9ee2aec2f0
8 changed files with 247 additions and 31 deletions
+37 -1
View File
@@ -132,6 +132,36 @@ try {
} }
ny_ok($res); 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': case 'ping':
$client = new SurveyKingClient(cfg()); $client = new SurveyKingClient(cfg());
@@ -345,7 +375,13 @@ try {
} else { } else {
Roster::save($res['students']); 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': case 'roster_save':
$raw = (string)file_get_contents('php://input'); $raw = (string)file_get_contents('php://input');
+57 -3
View File
@@ -21,7 +21,8 @@
sortAsc: false, sortAsc: false,
timer: null, timer: null,
loading: false, loading: false,
lastSig: '' lastSig: '',
connFail: 0
}; };
/* ---------------- 本地存储 ---------------- */ /* ---------------- 本地存储 ---------------- */
@@ -41,6 +42,7 @@
const LS_LASTQID = 'ny_last_qid'; const LS_LASTQID = 'ny_last_qid';
const LS_FILTERS = 'ny_filters'; // {cls, grp} -> 跟随 projectId const LS_FILTERS = 'ny_filters'; // {cls, grp} -> 跟随 projectId
const LS_LAYOUT = 'ny_layout'; const LS_LAYOUT = 'ny_layout';
const LS_INTERVAL = 'ny_interval'; // 用户手动调整的刷新间隔(跟随本机)
const LS_ROSTER = 'ny_roster_cache'; // 名单 cache(用于首屏补齐班级/组别下拉) const LS_ROSTER = 'ny_roster_cache'; // 名单 cache(用于首屏补齐班级/组别下拉)
const dataKey = pid => 'ny_data_' + pid; const dataKey = pid => 'ny_data_' + pid;
@@ -243,6 +245,7 @@
try { try {
const d = await api('data', { params: { projectId: pid, nameQid: $('#nameFieldSel').value } }); const d = await api('data', { params: { projectId: pid, nameQid: $('#nameFieldSel').value } });
applyData(d); applyData(d);
state.connFail = 0;
LS.set(dataKey(pid), d); LS.set(dataKey(pid), d);
LS.set(LS_LASTPID, pid); LS.set(LS_LASTPID, pid);
LS.set(LS_LASTQID, $('#nameFieldSel').value || ''); LS.set(LS_LASTQID, $('#nameFieldSel').value || '');
@@ -259,6 +262,9 @@
if (!silent) setConn('conn-err', '数据获取失败'); if (!silent) setConn('conn-err', '数据获取失败');
showAlert(e.message); showAlert(e.message);
} else { } else {
// 后台静默刷新失败:保留缓存数据,但把连接指示转为"中断"告警,成功即恢复
state.connFail++;
if (state.connFail >= 1) setConn('conn-warn', '⚠️ 实时刷新中断 · 已显示本地缓存');
showAlert('⚠️ 后台刷新失败,已显示本地缓存:' + e.message); showAlert('⚠️ 后台刷新失败,已显示本地缓存:' + e.message);
} }
} finally { } finally {
@@ -697,7 +703,10 @@
// 用缓存数据先快速渲染,再后台拉新数据 // 用缓存数据先快速渲染,再后台拉新数据
refresh(); refresh();
}); });
$('#intervalSel').addEventListener('change', schedule); $('#intervalSel').addEventListener('change', () => {
LS.set(LS_INTERVAL, parseInt($('#intervalSel').value, 10) || 15);
schedule();
});
$('#autoRefresh').addEventListener('change', e => { $('#autoRefresh').addEventListener('change', e => {
if (e.target.checked) { schedule(); refresh(true); } if (e.target.checked) { schedule(); refresh(true); }
else if (state.timer) clearInterval(state.timer); else if (state.timer) clearInterval(state.timer);
@@ -740,6 +749,8 @@
/* 设置保存 */ /* 设置保存 */
$('#btnSaveCfg').addEventListener('click', async () => { $('#btnSaveCfg').addEventListener('click', async () => {
const btnSave = $('#btnSaveCfg');
btnSave.disabled = true; btnSave.textContent = '保存中…';
const pass = $('#cfgPass').value; const pass = $('#cfgPass').value;
const payload = { const payload = {
page_size: parseInt($('#cfgPageSize').value, 10) || 200, page_size: parseInt($('#cfgPageSize').value, 10) || 200,
@@ -758,7 +769,7 @@
let resp; let resp;
try { try {
resp = await api('config_save', { method: 'POST', body: payload }); 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 = ''; // 提交后清空密码框 $('#cfgPass').value = ''; // 提交后清空密码框
const note = (resp && resp.conn_changed) ? '账号/连接已更新' : '设置已保存'; const note = (resp && resp.conn_changed) ? '账号/连接已更新' : '设置已保存';
if (resp && resp.ping) { if (resp && resp.ping) {
@@ -783,8 +794,44 @@
if (!resp || !resp.ping || resp.ping.ok) { if (!resp || !resp.ping || resp.ping.ok) {
$('#settingsModal').classList.remove('open'); $('#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} // 收集"各试卷独立满分" -> {projectId: score}
function collectFullScores() { function collectFullScores() {
const out = {}; const out = {};
@@ -871,6 +918,13 @@
// 2) 支持 ?projectId=xxx&nameQid=yyy 直接打开某份问卷 // 2) 支持 ?projectId=xxx&nameQid=yyy 直接打开某份问卷
const qs = new URLSearchParams(location.search); const qs = new URLSearchParams(location.search);
if (qs.get('interval')) $('#intervalSel').value = qs.get('interval'); 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.deeplinkProject = qs.get('projectId') || '';
state.deeplinkQid = qs.get('nameQid') || ''; state.deeplinkQid = qs.get('nameQid') || '';
+34
View File
@@ -710,6 +710,9 @@
// 重置结果:清空本次结果 + 累计已抽取(历史记录保留) // 重置结果:清空本次结果 + 累计已抽取(历史记录保留)
$('#btnReset').addEventListener('click', () => { $('#btnReset').addEventListener('click', () => {
if (state.picked.length || state.history.size) {
if (!confirm('确定重置?将清空本次结果与累计已抽取的 ' + state.history.size + ' 人(历史记录仍保留)。')) return;
}
state.picked = []; state.picked = [];
state.history = new Set(); state.history = new Set();
renderResult(); renderResult();
@@ -721,6 +724,37 @@
showAlert('已重置结果与累计已抽取(历史记录仍保留)', 'info'); 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', () => { $('#btnClearLog').addEventListener('click', () => {
if (!state.log.length) return showAlert('暂无历史记录'); if (!state.log.length) return showAlert('暂无历史记录');
+4
View File
@@ -80,6 +80,8 @@ body {
.conn-err { background: var(--danger-weak); color: var(--danger); } .conn-err { background: var(--danger-weak); color: var(--danger); }
.conn-err i { background: var(--danger); } .conn-err i { background: var(--danger); }
.conn-load i { background: var(--warn); animation: blink 1s infinite; } .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; } } @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; } .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 { width: 7px; height: 7px; border-radius: 50%; background: #cbd3e2; display: inline-block; }
.dot.on { background: var(--ok); animation: pulse 1.6s infinite; } .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; } } @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .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; background: var(--ok-weak); color: #0d7a4d; border: 1px solid #c8ecda;
} }
.ping-result.err { background: var(--danger-weak); color: #a3342c; border-color: #f5d3cf; } .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 { .upload-zone {
+74 -13
View File
@@ -118,6 +118,8 @@ const LS = {
}; };
const LS_PROJECTS = 'ny_projects'; const LS_PROJECTS = 'ny_projects';
const LS_BOARD = (pid, cls) => 'ny_board_' + pid + '_' + encodeURIComponent(cls); 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 DASH = null;
let CUR = readUrl(); let CUR = readUrl();
@@ -235,7 +237,7 @@ function membersTable(members){
'<thead><tr><th style="width:60px">排名</th><th>姓名</th><th style="width:80px">成绩</th></tr></thead><tbody>' + '<thead><tr><th style="width:60px">排名</th><th>姓名</th><th style="width:80px">成绩</th></tr></thead><tbody>' +
members.map(m => { members.map(m => {
const rk = m.rank; 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 sc = m.score;
const cls = sc == null ? '' : (sc >= 90 ? 'score-high' : (sc >= 60 ? 'score-mid' : 'score-low')); const cls = sc == null ? '' : (sc >= 90 ? 'score-high' : (sc >= 60 ? 'score-mid' : 'score-low'));
const att = (m.attempts || 1) > 1; const att = (m.attempts || 1) > 1;
@@ -274,6 +276,37 @@ function closeAttempts(){
if (m) m.classList.remove('open'); if (m) m.classList.remove('open');
} }
/* ---------- 看板占位(未选问卷/班级) ---------- */
function renderPlaceholderBoard(){
const cards = [
['总人数','-'],['已提交','-'],['缺考','-'],
['提交率','-'],['平均分','-'],['合格率','-']
];
$('#boardSummary').innerHTML = cards.map(c =>
'<div class="card"><span>' + c[0] + '</span><b>' + c[1] + '</b></div>').join('');
$('#boardBody').innerHTML = '<div class="empty">请选择问卷与班级查看考试整体情况</div>';
$('#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 = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
setTimeout(() => { location.href = 'login.php'; }, 1200);
}
/* ---------- 渲染单班看板 ---------- */ /* ---------- 渲染单班看板 ---------- */
function renderBoard(){ function renderBoard(){
const pid = $('#projectSel').value; const pid = $('#projectSel').value;
@@ -282,11 +315,7 @@ function renderBoard(){
writeUrl({ pid, cls }); writeUrl({ pid, cls });
if (!pid || !cls || !DASH) { if (!pid || !cls || !DASH) {
$('#boardSummary').innerHTML = '<div class="card accent-blue"><span>总人数</span><b>-</b></div>'.repeat(0) || renderPlaceholderBoard();
'<div class="empty">请选择问卷与班级</div>';
$('#boardBody').innerHTML = '<div class="empty">请选择问卷与班级查看考试整体情况</div>';
$('#heroTitle').textContent = '班级看板';
$('#heroSub').textContent = '选择一个问卷与班级查看考试整体情况';
return; return;
} }
LS.set('ny_board_last_pid', JSON.stringify(pid)); LS.set('ny_board_last_pid', JSON.stringify(pid));
@@ -384,9 +413,11 @@ function schedule(){
const sec = parseInt($('#intervalSel').value, 10) || 15; const sec = parseInt($('#intervalSel').value, 10) || 15;
timer = setInterval(refreshBoard, sec * 1000); timer = setInterval(refreshBoard, sec * 1000);
} }
async function refreshBoard(){ async function refreshBoard(manual){
const pid = $('#projectSel').value; const pid = $('#projectSel').value;
if (!pid) return; if (!pid) return;
const btn = $('#btnRefresh');
if (manual && btn) { btn.disabled = true; btn.textContent = '刷新中…'; }
try { try {
const fresh = await api('dashboard', { projectId: pid }); const fresh = await api('dashboard', { projectId: pid });
DASH = fresh; DASH = fresh;
@@ -403,24 +434,54 @@ async function refreshBoard(){
} }
renderBoard(); renderBoard();
$('#lastUpdate').textContent = fresh.updated_at || '-'; $('#lastUpdate').textContent = fresh.updated_at || '-';
$('#liveDot').classList.add('on'); markLive();
setTimeout(() => $('#liveDot').classList.remove('on'), 900);
} catch (e) { } 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(); }); $('#projectSel').addEventListener('change', () => { CUR.pid = $('#projectSel').value; CUR.cls = ''; refreshClassSel(); schedule(); });
$('#classSel').addEventListener('change', () => { CUR.cls = $('#classSel').value; renderBoard(); }); $('#classSel').addEventListener('change', () => { CUR.cls = $('#classSel').value; renderBoard(); });
$('#btnRefresh').addEventListener('click', refreshBoard); $('#btnRefresh').addEventListener('click', () => refreshBoard(true));
$('#autoRefresh').addEventListener('change', () => { writeUrl({ auto: $('#autoRefresh').checked }); schedule(); }); $('#autoRefresh').addEventListener('change', () => {
$('#intervalSel').addEventListener('change', schedule); 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); $('#btnFullscreen').addEventListener('click', toggleFullscreen);
document.addEventListener('fullscreenchange', onFsChange); document.addEventListener('fullscreenchange', onFsChange);
document.addEventListener('webkitfullscreenchange', 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(() => { loadProjects().then(() => {
if (CUR.pid) { if (CUR.pid) {
$('#projectSel').value = CUR.pid; $('#projectSel').value = CUR.pid;
+1
View File
@@ -104,6 +104,7 @@ $rosterCount = Roster::count();
<h2>抽取结果 <small id="drawHint"></small></h2> <h2>抽取结果 <small id="drawHint"></small></h2>
<div class="actions"> <div class="actions">
<button class="btn" id="btnLog" title="查看历史抽取批次(重置结果后仍保留)">📜 历史 <b id="logBadge">0</b></button> <button class="btn" id="btnLog" title="查看历史抽取批次(重置结果后仍保留)">📜 历史 <b id="logBadge">0</b></button>
<button class="btn" id="btnCopy" title="复制姓名 / 登陆账号到剪贴板(可直接粘贴到微信 / Excel)">📋 复制名单</button>
<button class="btn" id="btnDlImport" title="导出为「白名单导入模板.xlsx」">⬇ 白名单导入模板</button> <button class="btn" id="btnDlImport" title="导出为「白名单导入模板.xlsx」">⬇ 白名单导入模板</button>
<button class="btn btn-primary" id="btnDlList" title="导出为「白名单列表.xlsx」">⬇ 白名单列表</button> <button class="btn btn-primary" id="btnDlList" title="导出为「白名单列表.xlsx」">⬇ 白名单列表</button>
</div> </div>
+1
View File
@@ -219,6 +219,7 @@ $rosterCount = Roster::count();
<div class="modal-foot"> <div class="modal-foot">
<span class="hint" style="align-self:center">只有修改账号/密码才会触发重连与测连接</span> <span class="hint" style="align-self:center">只有修改账号/密码才会触发重连与测连接</span>
<span class="spacer"></span> <span class="spacer"></span>
<button class="btn" id="btnTestConn">测试连接</button>
<button class="btn" data-close>取消</button> <button class="btn" data-close>取消</button>
<button class="btn btn-primary" id="btnSaveCfg">保存</button> <button class="btn btn-primary" id="btnSaveCfg">保存</button>
</div> </div>
+36 -11
View File
@@ -128,6 +128,10 @@ class Roster
* 解析上传文件 -> 学生数组 * 解析上传文件 -> 学生数组
* @return array{count:int,students:array} * @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 public static function importFile(string $file, string $ext = ''): array
{ {
if ($ext === '') { if ($ext === '') {
@@ -148,9 +152,17 @@ class Roster
} }
if ($rows === []) { 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 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<int,array{sid:string,name:string,cls:string,grp:string,login:string}> * @return array<int,array{sid:string,name:string,cls:string,grp:string,login:string}>
*/ */
public static function rowsToStudents(array $rows): array public static function rowsToStudents(array $rows, array &$meta = []): array
{ {
$meta = ['skipped' => 0, 'duplicates' => 0, 'headers' => []];
$sidCol = -1; $sidCol = -1;
$nameCol = -1; $nameCol = -1;
$clsCol = -1; $clsCol = -1;
@@ -206,11 +221,11 @@ class Roster
$loginCol = -1; $loginCol = -1;
$headerIdx = -1; $headerIdx = -1;
// 找表头 // 找表头:同一行需同时命中「学号类」与「姓名类」关键词(容错全角空格/间隔)
foreach ($rows as $i => $row) { foreach ($rows as $i => $row) {
$joined = implode('|', array_map('strval', $row)); $joined = implode('|', array_map('strval', $row));
if (preg_match('/学\s*号|编\s*号|序\s*号|no\.?|id/i', $joined) if (preg_match('/学\s*号|编\s*号|序\s*号|学\s*员\s*号|号\s*码|no\.?|id/i', $joined)
&& preg_match('/姓\s*名|名\s*字|学生|成员/i', $joined)) { && preg_match('/姓\s*名|名\s*字|学\s*生\s*姓\s*名|姓\s*氏|成\s*员/i', $joined)) {
$headerIdx = $i; $headerIdx = $i;
break; break;
} }
@@ -220,22 +235,25 @@ class Roster
$start = $headerIdx + 1; $start = $headerIdx + 1;
foreach ($rows[$headerIdx] as $j => $cell) { foreach ($rows[$headerIdx] as $j => $cell) {
$c = (string)$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; $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; $nameCol = $j;
} }
if ($clsCol < 0 && preg_match('/班\s*级|班级|class/i', $c)) { if ($clsCol < 0 && preg_match('/班\s*级|班级|class/i', $c)) {
$clsCol = $j; $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; $grpCol = $j;
} }
if ($loginCol < 0 && preg_match('/登\s*[陆录]\s*账\s*号|登\s*[陆录]\s*账号|账\s*号|手\s*机\s*号|手\s*机/i', $c)) { if ($loginCol < 0 && preg_match('/登\s*[陆录]\s*账\s*号|登\s*[陆录]\s*账号|账\s*号|手\s*机\s*号|手\s*机/i', $c)) {
$loginCol = $j; $loginCol = $j;
} }
} }
$meta['headers'] = [
'sid' => $sidCol, 'name' => $nameCol, 'cls' => $clsCol, 'grp' => $grpCol, 'login' => $loginCol,
];
} }
if ($sidCol < 0) { if ($sidCol < 0) {
$sidCol = 0; $sidCol = 0;
@@ -245,20 +263,27 @@ class Roster
} }
$out = []; $out = [];
$seen = [];
for ($i = $start; $i < count($rows); $i++) { for ($i = $start; $i < count($rows); $i++) {
$row = $rows[$i]; $row = $rows[$i];
$sid = trim((string)($row[$sidCol] ?? '')); $sid = trim((string)($row[$sidCol] ?? ''));
$name = trim((string)($row[$nameCol] ?? '')); $name = trim((string)($row[$nameCol] ?? ''));
if ($sid === '' && $name === '') { if ($sid === '' && $name === '') {
$meta['skipped']++;
continue; continue;
} }
// 学号去掉 .0 尾巴(Excel 数字读取) // 学号/登陆账号去掉 Excel 数字尾巴(如 2024012001.0 / 13912687810.0)
$sid = preg_replace('/\.0+$/', '', $sid) ?? $sid; $sid = preg_replace('/\.0+$/', '', $sid) ?? $sid;
$cls = $clsCol >= 0 ? trim((string)($row[$clsCol] ?? '')) : ''; $cls = $clsCol >= 0 ? trim((string)($row[$clsCol] ?? '')) : '';
$grp = $grpCol >= 0 ? trim((string)($row[$grpCol] ?? '')) : ''; $grp = $grpCol >= 0 ? trim((string)($row[$grpCol] ?? '')) : '';
$login = $loginCol >= 0 ? trim((string)($row[$loginCol] ?? '')) : ''; $login = $loginCol >= 0 ? trim((string)($row[$loginCol] ?? '')) : '';
// 登陆账号同样去掉 Excel 数字尾巴(手机号被读成 13912687810.0)
$login = preg_replace('/\.0+$/', '', $login) ?? $login; $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]; $out[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login];
} }
return $out; return $out;