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){
'排名 姓名 成绩