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
+57 -3
View File
@@ -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') || '';