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 = '