diff --git a/api.php b/api.php index 1f7b13f..3010add 100644 --- a/api.php +++ b/api.php @@ -89,11 +89,12 @@ try { continue; } $out[] = [ - 'id' => ny_str($p['id'] ?? ''), - 'name' => ny_str($p['name'] ?? '') ?: '未命名', - 'mode' => ny_str($p['mode'] ?? ''), - 'total' => (int)($p['total'] ?? 0), - 'status'=> (int)($p['status'] ?? 0), + 'id' => ny_str($p['id'] ?? ''), + 'name' => ny_str($p['name'] ?? '') ?: '未命名', + 'mode' => ny_str($p['mode'] ?? ''), + 'total' => (int)($p['total'] ?? 0), + 'status' => (int)($p['status'] ?? 0), + 'parent_id' => (string)($p['parentId'] ?? '0'), ]; } ny_ok($out); diff --git a/assets/app.js b/assets/app.js index d194a15..db0078d 100644 --- a/assets/app.js +++ b/assets/app.js @@ -87,16 +87,37 @@ const ph = document.createElement('option'); ph.value = ''; ph.textContent = '请选择问卷 / 考试'; sel.appendChild(ph); - state.projects.forEach(p => { + + // 文件夹(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) + '份)'; - sel.appendChild(o); + 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; - setConn('conn-on', '已连接 · ' + state.projects.length + ' 个问卷'); + const surveyCount = state.projects.filter(p => !(p.mode || '').toLowerCase().includes('folder')).length; + setConn('conn-on', '已连接 · ' + surveyCount + ' 个问卷'); showAlert(''); if (sel.value) refresh(); } catch (e) { @@ -132,6 +153,7 @@ renderTable(); state.prevSids = cur; populateClasses(); + populateGroups(); renderAbsent(); renderUnmatched(); renderHints(); @@ -184,11 +206,33 @@ else sel.value = ''; } - // 按当前所选班级(或无)客户端重算统计卡片 - function computeDisplayStats(cls) { + // 组别下拉:仅显示当前所选班级内出现的组别("全部班级" 时显示所有组别) + function populateGroups() { + const cls = $('#clsSel').value; + const grpSel = $('#grpSel'); + if (!grpSel) return; + const cur = grpSel.value; + const set = new Set(); + (state.roster || []).forEach(s => { + if (cls && (s.cls || '') !== cls) return; + if (s.grp) set.add(s.grp); + }); + const vals = Array.from(set).sort(); + grpSel.innerHTML = '' + + vals.map(v => '').join(''); + if (vals.indexOf(cur) >= 0) grpSel.value = cur; + else grpSel.value = ''; + } + + // 按当前所选班级 + 组别(或无)客户端重算统计卡片 + function computeDisplayStats(cls, grp) { const allStudents = state.roster || []; - const rows = cls ? state.rows.filter(r => (r.cls || '') === cls) : state.rows; - const students = cls ? allStudents.filter(s => (s.cls || '') === cls) : allStudents; + let students = cls ? allStudents.filter(s => (s.cls || '') === cls) : allStudents; + let rows = cls ? state.rows.filter(r => (r.cls || '') === cls) : state.rows; + if (grp) { + students = students.filter(s => (s.grp || '') === grp); + rows = rows.filter(r => (r.grp || '') === grp); + } const total = students.length; const submitted = rows.length; const absent = Math.max(0, total - submitted); @@ -222,7 +266,8 @@ function renderStats() { const cls = $('#clsSel').value; - const st = computeDisplayStats(cls); + const grp = $('#grpSel').value; + const st = computeDisplayStats(cls, grp); const set = (id, v) => { $(id).textContent = v; }; set('#sTotal', st.total); set('#sSubmit', st.submitted); @@ -235,15 +280,18 @@ set('#sDur', st.avgDur == null ? '-' : fmtDur(st.avgDur)); } - // 各面板计数文字按所选班级过滤(成绩明细 / 缺考名单) + // 各面板计数文字按所选班级 + 组别过滤(成绩明细 / 缺考名单) // 缺考数与统计卡片保持一致:过滤后名单 − 过滤后提交(而非二次过滤后端 absent 数组) function renderHints() { const cls = $('#clsSel').value; + const grp = $('#grpSel').value; const rowsForCls = cls ? state.rows.filter(r => (r.cls || '') === cls) : state.rows; const rosterForCls = cls ? (state.roster || []).filter(s => (s.cls || '') === cls) : (state.roster || []); - const absentForCls = Math.max(0, rosterForCls.length - rowsForCls.length); - $('#tableHint').textContent = '共 ' + rowsForCls.length + ' 人已提交 · 名单 ' + rosterForCls.length + ' 人'; - $('#absentHint').textContent = absentForCls ? '共 ' + absentForCls + ' 人' : ''; + const rowsForGrp = grp ? rowsForCls.filter(r => (r.grp || '') === grp) : rowsForCls; + const rosterForGrp = grp ? rosterForCls.filter(s => (s.grp || '') === grp) : rosterForCls; + const absentForGrp = Math.max(0, rosterForGrp.length - rowsForGrp.length); + $('#tableHint').textContent = '共 ' + rowsForGrp.length + ' 人已提交 · 名单 ' + rosterForGrp.length + ' 人'; + $('#absentHint').textContent = absentForGrp ? '共 ' + absentForGrp + ' 人' : ''; } function fmtDur(sec) { @@ -265,9 +313,11 @@ function renderTable() { const kw = $('#kw').value.trim().toLowerCase(); const clsFilter = $('#clsSel').value; + const grpFilter = $('#grpSel').value; let rows = state.rows.slice(); if (clsFilter) rows = rows.filter(r => (r.cls || '') === clsFilter); + if (grpFilter) rows = rows.filter(r => (r.grp || '') === grpFilter); if (kw) rows = rows.filter(r => String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw)); @@ -319,11 +369,14 @@ function renderAbsent() { const clsFilter = $('#clsSel').value; + const grpFilter = $('#grpSel').value; // 与统计卡片保持一致:缺考 = 过滤后名单中未出现在提交记录里的学生(按 sid 匹配) const rowsForCls = clsFilter ? state.rows.filter(r => (r.cls || '') === clsFilter) : state.rows; - const submittedSids = new Set(rowsForCls.map(r => r.sid).filter(Boolean)); + const rowsForGrp = grpFilter ? rowsForCls.filter(r => (r.grp || '') === grpFilter) : rowsForCls; + const submittedSids = new Set(rowsForGrp.map(r => r.sid).filter(Boolean)); const rosterForCls = clsFilter ? (state.roster || []).filter(s => (s.cls || '') === clsFilter) : (state.roster || []); - const list = rosterForCls.filter(s => !submittedSids.has(s.sid)); + const rosterForGrp = grpFilter ? rosterForCls.filter(s => (s.grp || '') === grpFilter) : rosterForCls; + const list = rosterForGrp.filter(s => !submittedSids.has(s.sid)); const tb = $('#absentBody'); if (!list.length) { tb.innerHTML = '' + (clsFilter ? '本班全部已提交 🎉' : '全部已提交 🎉') + ''; @@ -505,6 +558,13 @@ } $('#kw').addEventListener('input', renderTable); $('#clsSel').addEventListener('change', () => { + populateGroups(); + renderStats(); + renderTable(); + renderAbsent(); + renderHints(); + }); + $('#grpSel').addEventListener('change', () => { renderStats(); renderTable(); renderAbsent(); diff --git a/assets/style.css b/assets/style.css index 65d8cf9..9e7b96d 100644 --- a/assets/style.css +++ b/assets/style.css @@ -526,10 +526,21 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; } .gpanel, .class-card { overflow: hidden; } .member-list td.name { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -/* 看板全屏适配 */ -body.fs-on .wrap { max-width: none; padding-top: 14px; } -body.fs-on .member-list { max-height: 46vh; } -body.fs-on .class-grid { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); } -body.fs-on .group-compare { grid-template-columns: 1fr 1fr; } -body.fs-on .dash-hero { border-radius: var(--radius); } +/* 看板全屏:整页不溢出屏幕(外层锁 100vh + overflow:hidden,内部区域自行滚动) */ +body.dash.fs-on .wrap { + height: 100vh; display: flex; flex-direction: column; overflow: hidden; + max-width: none; padding: 12px 20px 14px; +} +body.dash.fs-on .dash-hero { padding: 12px 18px; flex: 0 0 auto; border-radius: var(--radius); } +body.dash.fs-on .sec-title { margin: 12px 0 4px; font-size: 14px; } +body.dash.fs-on .cards { gap: 10px; margin-top: 10px; } +body.dash.fs-on .card { padding: 10px 12px; } +body.dash.fs-on .card b { font-size: 20px; } +body.dash.fs-on #overview, body.dash.fs-on #detail { + flex: 1 1 auto; min-height: 0; overflow: auto; +} +body.dash.fs-on .class-grid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; } +body.dash.fs-on .group-compare { gap: 12px; } +body.dash.fs-on .gpanel { padding: 12px 14px; } +body.dash.fs-on .member-list { max-height: 30vh; } diff --git a/dashboard.php b/dashboard.php index c56f0c4..0cfb7a9 100644 --- a/dashboard.php +++ b/dashboard.php @@ -10,7 +10,7 @@ require __DIR__ . '/bootstrap.php'; 数据看板 · <?= htmlspecialchars(NY_APP_NAME) ?> - +
diff --git a/index.php b/index.php index 7290a87..1ee3fbc 100644 --- a/index.php +++ b/index.php @@ -71,6 +71,10 @@ $rosterCount = Roster::count();
+
+ + +