feat: 文件夹双策略识别 + 布局选项 + 移除提交率卡片 + localStorage缓存层

- 文件夹识别:mode 含 'folder' 优先,缺省时用 parentId 引用反推;
  即使卷王版本无 mode 字段也能正确分组,避免'直接没有文件夹选项'
- 布局选项:工具栏新增 #layoutSel(默认 / 成绩明细与缺考名单同行),
  用 CSS Grid 双模式渲染,窄屏回落单列;偏好写 localStorage
- 移除统计卡片中的提交率卡片(卡片数 9 → 8)
- localStorage 缓存层:问卷列表 / 每个项目数据 / 看板数据 / 班级组别
  筛选 / 布局 / 上次项目 id 全部持久化;进入页面先渲染缓存,
  后台再异步拉新数据并写回缓存;网络失败时仍能用本地缓存查看
- dashboard.php 同步:文件夹识别 / 数据缓存一致更新
This commit is contained in:
2026-09-09 00:08:08 +08:00
parent 7345f9c29b
commit 64d0116c0e
4 changed files with 345 additions and 113 deletions
+81 -9
View File
@@ -87,6 +87,15 @@ function rateColor(r){
return 'var(--danger)';
}
/* 本地存储(缓存问卷列表/看板数据,重开即恢复) */
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_DASH = pid => 'ny_dashboard_' + pid;
let DASH = null; // 最近一次看板数据
let CUR = null; // 当前查看的班级名
@@ -102,16 +111,60 @@ async function api(action, opts){
throw new Error((j && (j.msg || j.error)) || '请求失败');
}
/* ---------- 问卷列表:双策略识别文件夹 + 缓存优先 ---------- */
function renderProjects(list){
const sel = $('#projectSel');
const keep = sel.value;
sel.innerHTML = '<option value="">请选择问卷 / 考试</option>';
// 双策略识别文件夹:mode 含 folder,或被其他项目作为 parent_id 引用
const isFolder = p => (p.mode || '').toLowerCase().includes('folder');
const parentIds = new Set();
list.forEach(p => { const pid = (p.parent_id || '0'); if (pid && pid !== '0' && pid !== '') parentIds.add(pid); });
const folderSet = new Set();
list.forEach(p => { if (isFolder(p) || parentIds.has(p.id)) folderSet.add(p.id); });
const childOf = fid => list.filter(p => !folderSet.has(p.id) && (p.parent_id || '0') === fid);
// 根项
list.filter(p => !folderSet.has(p.id)).forEach(p => {
const o = document.createElement('option');
o.value = p.id; o.textContent = (p.mode ? '[' + p.mode + '] ' : '') + p.name + '(' + (p.total || 0) + '份)';
sel.appendChild(o);
});
// 文件夹
list.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 => {
const o = document.createElement('option');
o.value = p.id; o.textContent = (p.mode ? '[' + p.mode + '] ' : '') + p.name + '(' + (p.total || 0) + '份)';
og.appendChild(o);
});
sel.appendChild(og);
});
if (keep && list.some(p => p.id === keep)) sel.value = keep;
else {
const last = LS.get('ny_dash_last_pid', '');
if (last && list.some(p => p.id === last)) sel.value = last;
}
}
async function loadProjects(){
// 1) 先读本地缓存
const cached = LS.get(LS_PROJECTS, []);
if (Array.isArray(cached) && cached.length) {
renderProjects(cached);
}
// 2) 后台异步刷新
try {
const list = await api('projects');
const sel = $('#projectSel');
const keep = sel.value;
sel.innerHTML = '<option value="">请选择问卷 / 考试</option>' +
list.map(p => '<option value="' + esc(p.id) + '">' + esc(p.name) + '(' + esc(p.mode || '') + ')</option>').join('');
if (keep) sel.value = keep;
LS.set(LS_PROJECTS, list || []);
renderProjects(list || []);
} catch (e) {
$('#classGrid').innerHTML = '<div class="empty">加载问卷列表失败:' + esc(e.message) + '</div>';
if (!cached || !cached.length) {
$('#classGrid').innerHTML = '<div class="empty">加载问卷列表失败:' + esc(e.message) + '</div>';
}
}
}
@@ -241,20 +294,39 @@ async function loadDashboard(){
$('#classGrid').innerHTML = '<div class="empty">请选择问卷 / 考试以查看各班数据</div>';
return;
}
// 1) 缓存优先:先渲染上次保存的看板,避免空白
const cached = LS.get(LS_DASH(pid), null);
if (cached && cached.classes) {
DASH = cached;
renderSummary(DASH);
renderClasses(DASH);
$('#lastUpdate').textContent = (cached.updated_at || '—') + '(缓存)';
if (CUR) {
const still = (DASH.classes || []).some(x => x.class === CUR);
if (still) showClassDetail(CUR); else backToOverview();
}
}
// 2) 后台拉真实数据
try {
DASH = await api('dashboard', { params: { projectId: pid } });
const fresh = await api('dashboard', { params: { projectId: pid } });
DASH = fresh;
LS.set(LS_DASH(pid), fresh);
LS.set('ny_dash_last_pid', pid);
renderSummary(DASH);
renderClasses(DASH);
$('#lastUpdate').textContent = DASH.updated_at || '-';
$('#liveDot').classList.add('on');
setTimeout(() => $('#liveDot').classList.remove('on'), 900);
// 若正在看某班详情,刷新后同步更新
if (CUR) {
const still = (DASH.classes || []).some(x => x.class === CUR);
if (still) showClassDetail(CUR); else backToOverview();
}
} catch (e) {
$('#classGrid').innerHTML = '<div class="empty">数据加载失败:' + esc(e.message) + '</div>';
if (!cached) {
$('#classGrid').innerHTML = '<div class="empty">数据加载失败:' + esc(e.message) + '</div>';
}
}
}