feat: 文件夹双策略识别 + 布局选项 + 移除提交率卡片 + localStorage缓存层
- 文件夹识别:mode 含 'folder' 优先,缺省时用 parentId 引用反推; 即使卷王版本无 mode 字段也能正确分组,避免'直接没有文件夹选项' - 布局选项:工具栏新增 #layoutSel(默认 / 成绩明细与缺考名单同行), 用 CSS Grid 双模式渲染,窄屏回落单列;偏好写 localStorage - 移除统计卡片中的提交率卡片(卡片数 9 → 8) - localStorage 缓存层:问卷列表 / 每个项目数据 / 看板数据 / 班级组别 筛选 / 布局 / 上次项目 id 全部持久化;进入页面先渲染缓存, 后台再异步拉新数据并写回缓存;网络失败时仍能用本地缓存查看 - dashboard.php 同步:文件夹识别 / 数据缓存一致更新
This commit is contained in:
+197
-78
@@ -24,6 +24,26 @@
|
||||
lastSig: ''
|
||||
};
|
||||
|
||||
/* ---------------- 本地存储 ---------------- */
|
||||
// 浏览器关闭后仍然保留,账号/密码等敏感配置除外;进入页面时优先读缓存,再后台异步刷新
|
||||
const LS = {
|
||||
get(k, def) {
|
||||
try { const v = localStorage.getItem(k); return v == null ? def : JSON.parse(v); }
|
||||
catch (e) { return def; }
|
||||
},
|
||||
set(k, v) {
|
||||
try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { /* 隐私模式 */ }
|
||||
},
|
||||
del(k) { try { localStorage.removeItem(k); } catch (e) {} }
|
||||
};
|
||||
const LS_PROJECTS = 'ny_projects';
|
||||
const LS_LASTPID = 'ny_last_pid';
|
||||
const LS_LASTQID = 'ny_last_qid';
|
||||
const LS_FILTERS = 'ny_filters'; // {cls, grp} -> 跟随 projectId
|
||||
const LS_LAYOUT = 'ny_layout';
|
||||
const LS_ROSTER = 'ny_roster_cache'; // 名单 cache(用于首屏补齐班级/组别下拉)
|
||||
const dataKey = pid => 'ny_data_' + pid;
|
||||
|
||||
/* ---------------- 通用 ---------------- */
|
||||
async function api(action, opts) {
|
||||
opts = opts || {};
|
||||
@@ -73,97 +93,155 @@
|
||||
}
|
||||
|
||||
/* ---------------- 问卷列表 ---------------- */
|
||||
// 把数组渲染到 #projectSel(抽出便于缓存复用)
|
||||
function renderProjects() {
|
||||
const sel = $('#projectSel');
|
||||
const keep = sel.value;
|
||||
sel.innerHTML = '';
|
||||
if (!state.projects.length) {
|
||||
sel.innerHTML = '<option value="">(没有获取到问卷,请检查设置)</option>';
|
||||
} else {
|
||||
const ph = document.createElement('option');
|
||||
ph.value = ''; ph.textContent = '请选择问卷 / 考试';
|
||||
sel.appendChild(ph);
|
||||
|
||||
// 双策略识别文件夹:
|
||||
// 1) mode 字段含 'folder'(卷王默认)
|
||||
// 2) 被其他项目作为 parent_id 引用(部分版本无 mode 字段,回退推断)
|
||||
const isFolder = p => (p.mode || '').toLowerCase().includes('folder');
|
||||
const parentIds = new Set();
|
||||
state.projects.forEach(p => {
|
||||
const pid = (p.parent_id || '0');
|
||||
if (pid && pid !== '0' && pid !== '') parentIds.add(pid);
|
||||
});
|
||||
const folderSet = new Set();
|
||||
state.projects.forEach(p => {
|
||||
if (isFolder(p) || parentIds.has(p.id)) folderSet.add(p.id);
|
||||
});
|
||||
const childOf = fid => state.projects.filter(p =>
|
||||
!folderSet.has(p.id) && (p.parent_id || '0') === fid);
|
||||
|
||||
const mkOpt = p => {
|
||||
const o = document.createElement('option');
|
||||
o.value = p.id;
|
||||
o.textContent = (p.mode ? '[' + p.mode + '] ' : '') + p.name + '(' + (p.total || 0) + '份)';
|
||||
return o;
|
||||
};
|
||||
|
||||
// 根项(不在任何文件夹里的)
|
||||
state.projects.filter(p => !folderSet.has(p.id))
|
||||
.forEach(p => sel.appendChild(mkOpt(p)));
|
||||
// 文件夹及子项(空文件夹不显示,避免点击报错)
|
||||
state.projects.filter(p => folderSet.has(p.id)).forEach(f => {
|
||||
const kids = childOf(f.id);
|
||||
if (!kids.length) return;
|
||||
const og = document.createElement('optgroup');
|
||||
og.label = '📁 ' + f.name;
|
||||
kids.forEach(p => og.appendChild(mkOpt(p)));
|
||||
sel.appendChild(og);
|
||||
});
|
||||
}
|
||||
if (keep && state.projects.some(p => p.id === keep)) sel.value = keep;
|
||||
else if (state.deeplinkProject && state.projects.some(p => p.id === state.deeplinkProject)) sel.value = state.deeplinkProject;
|
||||
else {
|
||||
const last = LS.get(LS_LASTPID, '');
|
||||
if (last && state.projects.some(p => p.id === last)) sel.value = last;
|
||||
}
|
||||
const surveyCount = state.projects.filter(p => !((p.mode || '').toLowerCase().includes('folder'))).length;
|
||||
setConn('conn-on', (state._fromCache ? '缓存 · ' : '') + '已连接 · ' + surveyCount + ' 个问卷');
|
||||
showAlert('');
|
||||
if (sel.value) refresh();
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
setConn('conn-load', '连接中…');
|
||||
|
||||
// 1) 先读本地缓存 → 立刻渲染,避免空白
|
||||
const cached = LS.get(LS_PROJECTS, []);
|
||||
if (Array.isArray(cached) && cached.length) {
|
||||
state.projects = cached;
|
||||
state._fromCache = true;
|
||||
renderProjects();
|
||||
}
|
||||
|
||||
// 2) 再异步刷新
|
||||
try {
|
||||
const list = await api('projects');
|
||||
state.projects = list || [];
|
||||
const sel = $('#projectSel');
|
||||
const keep = sel.value;
|
||||
sel.innerHTML = '';
|
||||
if (!state.projects.length) {
|
||||
sel.innerHTML = '<option value="">(没有获取到问卷,请检查设置)</option>';
|
||||
} else {
|
||||
const ph = document.createElement('option');
|
||||
ph.value = ''; ph.textContent = '请选择问卷 / 考试';
|
||||
sel.appendChild(ph);
|
||||
|
||||
// 文件夹(mode 含 folder)以 optgroup 分组形式呈现,其子项以 parentId 关联
|
||||
const isFolder = p => (p.mode || '').toLowerCase().includes('folder');
|
||||
const folders = state.projects.filter(isFolder);
|
||||
const folderIds = new Set(folders.map(f => f.id));
|
||||
const childOfFolder = fid => state.projects.filter(p => !isFolder(p) && (p.parent_id || '0') === fid);
|
||||
|
||||
const mkOpt = p => {
|
||||
const o = document.createElement('option');
|
||||
o.value = p.id;
|
||||
o.textContent = (p.mode ? '[' + p.mode + '] ' : '') + p.name + '(' + (p.total || 0) + '份)';
|
||||
return o;
|
||||
};
|
||||
|
||||
// 根项(不含文件夹、且 parent_id 不指向任何文件夹)
|
||||
state.projects.filter(p => !isFolder(p) && !(p.parent_id && p.parent_id !== '0' && folderIds.has(p.parent_id)))
|
||||
.forEach(p => sel.appendChild(mkOpt(p)));
|
||||
// 文件夹及子项
|
||||
folders.forEach(f => {
|
||||
const kids = childOfFolder(f.id);
|
||||
if (!kids.length) return; // 空文件夹不显示
|
||||
const og = document.createElement('optgroup');
|
||||
og.label = '📁 ' + f.name;
|
||||
kids.forEach(p => og.appendChild(mkOpt(p)));
|
||||
sel.appendChild(og);
|
||||
});
|
||||
}
|
||||
if (keep && state.projects.some(p => p.id === keep)) sel.value = keep;
|
||||
else if (state.deeplinkProject && state.projects.some(p => p.id === state.deeplinkProject)) sel.value = state.deeplinkProject;
|
||||
const surveyCount = state.projects.filter(p => !(p.mode || '').toLowerCase().includes('folder')).length;
|
||||
setConn('conn-on', '已连接 · ' + surveyCount + ' 个问卷');
|
||||
showAlert('');
|
||||
if (sel.value) refresh();
|
||||
state._fromCache = false;
|
||||
LS.set(LS_PROJECTS, state.projects);
|
||||
renderProjects();
|
||||
} catch (e) {
|
||||
setConn('conn-err', '连接失败');
|
||||
showAlert('无法连接卷王系统:' + e.message + '\n请点击右上角【设置】检查系统地址、接口前缀与账号密码。');
|
||||
if (!cached || !cached.length) {
|
||||
setConn('conn-err', '连接失败');
|
||||
showAlert('无法连接卷王系统:' + e.message + '\n请点击右上角【设置】检查系统地址、接口前缀与账号密码。');
|
||||
} else {
|
||||
showAlert('⚠️ 后台刷新失败,已显示本地缓存:' + e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 数据刷新 ---------------- */
|
||||
function applyData(d, opts) {
|
||||
opts = opts || {};
|
||||
state.questions = d.questions || [];
|
||||
state.rows = d.rows || [];
|
||||
state.absent = d.absent || [];
|
||||
state.unmatched = d.unmatched || [];
|
||||
state.roster = d.roster || [];
|
||||
state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100;
|
||||
|
||||
const cur = new Set(state.rows.map(r => r.sid).filter(Boolean));
|
||||
if (state.hasPrev) state.newSids = new Set(Array.from(cur).filter(s => !state.prevSids.has(s)));
|
||||
else state.newSids = new Set();
|
||||
state.hasPrev = true;
|
||||
|
||||
renderNameFields(d);
|
||||
renderStats();
|
||||
renderTable();
|
||||
state.prevSids = cur;
|
||||
populateClasses();
|
||||
populateGroups();
|
||||
renderAbsent();
|
||||
renderUnmatched();
|
||||
renderHints();
|
||||
|
||||
$('#lastUpdate').textContent = d.updated_at || '—';
|
||||
setConn('conn-on', (opts.fromCache ? '缓存 · ' : '') + '实时 · ' + (d.project && d.project.name || ''));
|
||||
if (!opts.fromCache) showAlert('');
|
||||
}
|
||||
|
||||
async function refresh(silent) {
|
||||
const pid = $('#projectSel').value;
|
||||
if (!pid) return;
|
||||
if (state.loading) return;
|
||||
if (state.loading && !silent) return;
|
||||
state.loading = true;
|
||||
if (!silent) setConn('conn-load', '拉取数据…');
|
||||
|
||||
// 1) 先读本地缓存 → 立即渲染,刷新期间保持视觉稳定
|
||||
const cached = LS.get(dataKey(pid), null);
|
||||
if (cached && cached.updated_at) {
|
||||
try {
|
||||
applyData(cached, { fromCache: true });
|
||||
} catch (e) { /* 旧格式缓存忽略 */ }
|
||||
}
|
||||
|
||||
// 2) 后台异步拉真实数据
|
||||
try {
|
||||
const d = await api('data', { params: { projectId: pid, nameQid: $('#nameFieldSel').value } });
|
||||
state.questions = d.questions || [];
|
||||
state.rows = d.rows || [];
|
||||
state.absent = d.absent || [];
|
||||
state.unmatched = d.unmatched || [];
|
||||
state.roster = d.roster || [];
|
||||
state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100;
|
||||
|
||||
// 计算本次刷新新增的 sid(用于绿色底纹);首次加载不标记
|
||||
const cur = new Set(state.rows.map(r => r.sid).filter(Boolean));
|
||||
if (state.hasPrev) state.newSids = new Set(Array.from(cur).filter(s => !state.prevSids.has(s)));
|
||||
else state.newSids = new Set();
|
||||
state.hasPrev = true;
|
||||
|
||||
renderNameFields(d);
|
||||
renderStats();
|
||||
renderTable();
|
||||
state.prevSids = cur;
|
||||
populateClasses();
|
||||
populateGroups();
|
||||
renderAbsent();
|
||||
renderUnmatched();
|
||||
renderHints();
|
||||
|
||||
$('#lastUpdate').textContent = d.updated_at;
|
||||
setConn('conn-on', '实时 · ' + d.project.name);
|
||||
showAlert('');
|
||||
applyData(d);
|
||||
LS.set(dataKey(pid), d);
|
||||
LS.set(LS_LASTPID, pid);
|
||||
LS.set(LS_LASTQID, $('#nameFieldSel').value || '');
|
||||
// 名单缓存一份(便于刷新前/失败时仍能渲染班级/组别下拉)
|
||||
LS.set(LS_ROSTER, { ts: Date.now(), students: d.roster || [] });
|
||||
} catch (e) {
|
||||
if (!silent) setConn('conn-err', '数据获取失败');
|
||||
showAlert(e.message);
|
||||
if (!cached) {
|
||||
if (!silent) setConn('conn-err', '数据获取失败');
|
||||
showAlert(e.message);
|
||||
} else {
|
||||
showAlert('⚠️ 后台刷新失败,已显示本地缓存:' + e.message);
|
||||
}
|
||||
} finally {
|
||||
state.loading = false;
|
||||
$('#liveDot').classList.add('on');
|
||||
@@ -189,8 +267,10 @@
|
||||
o.textContent = (q.title || q.id) + '(' + (q.type || '') + ')';
|
||||
sel.appendChild(o);
|
||||
});
|
||||
const lastQid = LS.get(LS_LASTQID, '');
|
||||
if (keep) sel.value = keep;
|
||||
else if (state.deeplinkQid) sel.value = state.deeplinkQid;
|
||||
else if (lastQid && (cands.some(c => c.id === lastQid) || (d.questions || []).some(q => q.id === lastQid))) sel.value = lastQid;
|
||||
else if (d.name_qid) sel.value = d.name_qid;
|
||||
}
|
||||
|
||||
@@ -202,7 +282,11 @@
|
||||
const vals = Array.from(set).sort((a, b) => a.localeCompare(b, 'zh', { numeric: true }));
|
||||
sel.innerHTML = '<option value="">全部班级</option>' +
|
||||
vals.map(v => '<option value="' + esc(v) + '">' + esc(v) + '</option>').join('');
|
||||
// 优先保留用户当前已选项;否则按本地缓存恢复(跟随 projectId)
|
||||
const pid = $('#projectSel').value;
|
||||
const cache = pid ? (LS.get(LS_FILTERS, {})[pid] || {}) : {};
|
||||
if (vals.indexOf(cur) >= 0) sel.value = cur;
|
||||
else if (cache.cls && vals.indexOf(cache.cls) >= 0) sel.value = cache.cls;
|
||||
else sel.value = '';
|
||||
}
|
||||
|
||||
@@ -220,10 +304,21 @@
|
||||
const vals = Array.from(set).sort();
|
||||
grpSel.innerHTML = '<option value="">全部组别</option>' +
|
||||
vals.map(v => '<option value="' + esc(v) + '">' + esc(v) + '</option>').join('');
|
||||
const pid = $('#projectSel').value;
|
||||
const cache = pid ? (LS.get(LS_FILTERS, {})[pid] || {}) : {};
|
||||
if (vals.indexOf(cur) >= 0) grpSel.value = cur;
|
||||
else if (cache.grp && vals.indexOf(cache.grp) >= 0) grpSel.value = cache.grp;
|
||||
else grpSel.value = '';
|
||||
}
|
||||
|
||||
function rememberFilters() {
|
||||
const pid = $('#projectSel').value;
|
||||
if (!pid) return;
|
||||
const all = LS.get(LS_FILTERS, {});
|
||||
all[pid] = { cls: $('#clsSel').value, grp: $('#grpSel').value, ts: Date.now() };
|
||||
LS.set(LS_FILTERS, all);
|
||||
}
|
||||
|
||||
// 按当前所选班级 + 组别(或无)客户端重算统计卡片
|
||||
function computeDisplayStats(cls, grp) {
|
||||
const allStudents = state.roster || [];
|
||||
@@ -268,11 +363,10 @@
|
||||
const cls = $('#clsSel').value;
|
||||
const grp = $('#grpSel').value;
|
||||
const st = computeDisplayStats(cls, grp);
|
||||
const set = (id, v) => { $(id).textContent = v; };
|
||||
const set = (id, v) => { const el = $(id); if (el) el.textContent = v; };
|
||||
set('#sTotal', st.total);
|
||||
set('#sSubmit', st.submitted);
|
||||
set('#sAbsent', st.absent);
|
||||
set('#sRate', st.rate + '%');
|
||||
set('#sAvg', st.avg == null ? '-' : st.avg);
|
||||
set('#sMax', st.maxScore == null ? '-' : st.maxScore);
|
||||
set('#sMin', st.minScore == null ? '-' : st.minScore);
|
||||
@@ -423,6 +517,16 @@
|
||||
if (btn) btn.textContent = on ? '⛶ 退出全屏' : '⛶ 全屏';
|
||||
}
|
||||
|
||||
/* ---------------- 布局切换 ---------------- */
|
||||
function applyLayout(mode) {
|
||||
const m = (mode === 'side') ? 'side' : 'default';
|
||||
const lay = document.getElementById('dataLayout');
|
||||
if (lay) lay.setAttribute('data-layout', m);
|
||||
const sel = $('#layoutSel');
|
||||
if (sel && sel.value !== m) sel.value = m;
|
||||
LS.set(LS_LAYOUT, m);
|
||||
}
|
||||
|
||||
/* ---------------- 设置 ---------------- */
|
||||
async function openSettings() {
|
||||
try {
|
||||
@@ -546,8 +650,6 @@
|
||||
/* ---------------- 事件绑定 ---------------- */
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
$('#projectSel').addEventListener('change', () => { state.prevMap = {}; refresh(); });
|
||||
$('#nameFieldSel').addEventListener('change', () => { state.prevMap = {}; refresh(); });
|
||||
$('#btnRefresh').addEventListener('click', () => refresh(false));
|
||||
$('#btnSettings').addEventListener('click', openSettings);
|
||||
$('#btnRoster').addEventListener('click', openRoster);
|
||||
@@ -559,17 +661,31 @@
|
||||
$('#kw').addEventListener('input', renderTable);
|
||||
$('#clsSel').addEventListener('change', () => {
|
||||
populateGroups();
|
||||
rememberFilters();
|
||||
renderStats();
|
||||
renderTable();
|
||||
renderAbsent();
|
||||
renderHints();
|
||||
});
|
||||
$('#grpSel').addEventListener('change', () => {
|
||||
rememberFilters();
|
||||
renderStats();
|
||||
renderTable();
|
||||
renderAbsent();
|
||||
renderHints();
|
||||
});
|
||||
$('#layoutSel').addEventListener('change', e => applyLayout(e.target.value));
|
||||
$('#nameFieldSel').addEventListener('change', () => {
|
||||
state.prevMap = {}; state.prevSids = new Set(); state.hasPrev = false;
|
||||
LS.set(LS_LASTQID, $('#nameFieldSel').value || '');
|
||||
refresh();
|
||||
});
|
||||
$('#projectSel').addEventListener('change', () => {
|
||||
// 切换项目后清掉前一项目的 prevMap 与高亮缓存
|
||||
state.prevMap = {}; state.prevSids = new Set(); state.hasPrev = false;
|
||||
// 用缓存数据先快速渲染,再后台拉新数据
|
||||
refresh();
|
||||
});
|
||||
$('#intervalSel').addEventListener('change', schedule);
|
||||
$('#autoRefresh').addEventListener('change', e => {
|
||||
if (e.target.checked) { schedule(); refresh(true); }
|
||||
@@ -751,7 +867,10 @@
|
||||
});
|
||||
|
||||
/* 启动 */
|
||||
// 支持 ?projectId=xxx&nameQid=yyy 直接打开某份问卷
|
||||
// 1) 恢复布局偏好(仅样式,不涉及数据)
|
||||
applyLayout(LS.get(LS_LAYOUT, 'default'));
|
||||
|
||||
// 2) 支持 ?projectId=xxx&nameQid=yyy 直接打开某份问卷
|
||||
const qs = new URLSearchParams(location.search);
|
||||
if (qs.get('interval')) $('#intervalSel').value = qs.get('interval');
|
||||
state.deeplinkProject = qs.get('projectId') || '';
|
||||
|
||||
Reference in New Issue
Block a user