diff --git a/assets/app.js b/assets/app.js index db0078d..54dcf25 100644 --- a/assets/app.js +++ b/assets/app.js @@ -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 = ''; + } 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 = ''; - } 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 = '' + vals.map(v => '').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 = '' + vals.map(v => '').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') || ''; diff --git a/assets/style.css b/assets/style.css index 9e7b96d..b0ae4ee 100644 --- a/assets/style.css +++ b/assets/style.css @@ -212,6 +212,8 @@ select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; } +/* 在 .layout 容器内由 gap 控制间距,去掉 panel 自身 margin-top */ +.layout > .panel { margin-top: 0; } .panel-head { display: flex; align-items: center; justify-content: space-between; padding: 13px 18px; border-bottom: 1px solid var(--border-2); @@ -234,6 +236,38 @@ select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } @media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } } +/* ---------- 三 panel 布局容器(默认 / 成绩明细与缺考名单同行) ---------- */ +.layout { + display: grid; gap: 18px; + grid-template-columns: 1fr; + margin-top: 18px; +} +.layout > #tablePanel { grid-column: 1; grid-row: 1; } +.layout > #absentPanel { grid-column: 1; grid-row: 2; } +.layout > #unmatchedPanel { grid-column: 1; grid-row: 3; } + +/* side:成绩明细 与 缺考名单 同行,未匹配答卷放下面整宽 */ +.layout[data-layout="side"] { + grid-template-columns: 1fr 1fr; +} +.layout[data-layout="side"] > #tablePanel { grid-column: 1; grid-row: 1; } +.layout[data-layout="side"] > #absentPanel { grid-column: 2; grid-row: 1; } +.layout[data-layout="side"] > #unmatchedPanel { grid-column: 1 / -1; grid-row: 2; } + +/* 窄屏一律回落单列(grid-row 也要还原) */ +@media (max-width: 900px) { + .layout, .layout[data-layout="side"] { grid-template-columns: 1fr; } + .layout > #tablePanel, .layout > #absentPanel, .layout > #unmatchedPanel, + .layout[data-layout="side"] > #tablePanel, + .layout[data-layout="side"] > #absentPanel, + .layout[data-layout="side"] > #unmatchedPanel { + grid-column: 1; + } + .layout > #tablePanel, .layout[data-layout="side"] > #tablePanel { grid-row: 1; } + .layout > #absentPanel, .layout[data-layout="side"] > #absentPanel { grid-row: 2; } + .layout > #unmatchedPanel, .layout[data-layout="side"] > #unmatchedPanel { grid-row: 3; } +} + /* ---------- 表格 ---------- */ .table-scroll { overflow: auto; max-height: 62vh; } .table-scroll.short { max-height: 320px; } diff --git a/dashboard.php b/dashboard.php index 0cfb7a9..72760de 100644 --- a/dashboard.php +++ b/dashboard.php @@ -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 = ''; + // 双策略识别文件夹: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 = '' + - list.map(p => '').join(''); - if (keep) sel.value = keep; + LS.set(LS_PROJECTS, list || []); + renderProjects(list || []); } catch (e) { - $('#classGrid').innerHTML = '
加载问卷列表失败:' + esc(e.message) + '
'; + if (!cached || !cached.length) { + $('#classGrid').innerHTML = '
加载问卷列表失败:' + esc(e.message) + '
'; + } } } @@ -241,20 +294,39 @@ async function loadDashboard(){ $('#classGrid').innerHTML = '
请选择问卷 / 考试以查看各班数据
'; 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 = '
数据加载失败:' + esc(e.message) + '
'; + if (!cached) { + $('#classGrid').innerHTML = '
数据加载失败:' + esc(e.message) + '
'; + } } } diff --git a/index.php b/index.php index 1ee3fbc..b364158 100644 --- a/index.php +++ b/index.php @@ -75,6 +75,13 @@ $rosterCount = Roster::count(); +
+ + +
@@ -108,7 +115,6 @@ $rosterCount = Roster::count();
名单总人数-
已提交-
缺考-
-
提交率-
平均分-
最高分-
最低分-
@@ -116,31 +122,31 @@ $rosterCount = Roster::count();
平均用时-
-
-
-

成绩明细

-
最后更新:—
-
-
- - - - - - - - - - - - - -
学号姓名班级组别成绩提交时间考试时长
请选择问卷 / 考试
-
-
+
+
+
+

成绩明细

+
最后更新:—
+
+
+ + + + + + + + + + + + + +
学号姓名班级组别成绩提交时间考试时长
请选择问卷 / 考试
+
+
-
-
+

缺考名单

@@ -149,7 +155,8 @@ $rosterCount = Roster::count();
-
+ +

未匹配答卷 姓名不在名单中