feat: 全面打磨平台交互
- 名单导入容错:rowsToStudents 加去重/跳过统计、表头正则扩充、.0 尾巴清理;importFile 返回精确 count
- 看板:修复 .repeat(0) 空状态坏代码;新增实时点 stale 指示(手动/自动刷新失败变红 + 提示刷新中断);
手动刷新按钮加刷新中…反馈;记住自动刷新开关与间隔(localStorage + ?interval= 深链)
- 主统计页:后台静默刷新连续失败时连接指示转实时刷新中断告警;记住用户手动调整的刷新间隔
- 设置页:新增 config_test 端点 + 独立测试连接按钮(用弹窗内未保存凭据测连接,不落盘);
保存按钮加保存中…防重提交;登录失败不清旧 token
- 抽取页:重置结果加二次确认;新增复制名单按钮(两列制表符分隔,可粘到微信/Excel)
- 修复看板预先存在 JS 语法错误(少一个右括号导致整页 JS 失效)
This commit is contained in:
@@ -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
@@ -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') || '';
|
||||||
|
|
||||||
|
|||||||
@@ -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('暂无历史记录');
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+39
-14
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user