diff --git a/api.php b/api.php index 81a0a4b..49482f0 100644 --- a/api.php +++ b/api.php @@ -128,7 +128,7 @@ if ($action !== 'auth_check' && !ny_is_auth()) { // CSRF 防护:所有写操作必须携带会话绑定的 token(GET 读取类接口不受影响) $WRITE_ACTIONS = [ 'config_save', 'roster_save', 'roster_import', 'roster_clear', - 'snapshot_save', 'snapshot_delete', 'unmatched_map', + 'snapshot_save', 'snapshot_delete', 'unmatched_map', 'project_config_save', ]; if (in_array($action, $WRITE_ACTIONS, true) && !ny_csrf_ok()) { ny_json(['ok' => false, 'msg' => '安全校验失败(CSRF token 缺失或不匹配),请刷新页面后重试'], 403); @@ -194,9 +194,11 @@ try { $v = (string)$in['export_sort']; $new['export_sort'] = in_array($v, ['time','sid','score_desc','score_asc'], true) ? $v : 'time'; } - // 各试卷独立满分(覆盖整个 full_scores 映射) - if (array_key_exists('full_scores', $in) && is_array($in['full_scores'])) { - $new['full_scores'] = $in['full_scores']; + // 说明:「各试卷独立满分」已移出设置,改为在主页面针对当前试卷配置 + // (project_config / project_config_save),设置里只保留「全局默认满分」。 + if (array_key_exists('pass_rate', $in)) { + $pr = (float)$in['pass_rate']; + $new['pass_rate'] = ($pr >= 1 && $pr <= 100) ? $pr : 60; } $new['base_url'] = rtrim((string)$new['base_url'], '/'); $new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/'); @@ -211,18 +213,6 @@ try { $fs = 100; } $new['full_score'] = $fs; - // 各试卷独立满分:仅当显式指定 apply_pid 时才写入/清除该卷(修复 bug:之前会误用默认满分覆盖当前卷) - $applyPid = trim((string)($in['apply_pid'] ?? '')); - if ($applyPid !== '') { - $scores = is_array($new['full_scores'] ?? null) ? $new['full_scores'] : []; - $applyScore = (int)($in['apply_score'] ?? 0); - if ($applyScore > 0) { - $scores[$applyPid] = $applyScore; - } else { - unset($scores[$applyPid]); - } - $new['full_scores'] = $scores; - } // 仅当连接信息真的变了才清 token + 重连;保存满分/布局等不应触发登录卷王 $connKeys = ['base_url', 'api_prefix', 'username', 'password', 'verify_ssl']; $connChanged = false; @@ -329,15 +319,13 @@ try { $c = cfg(); $built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force'])); $result = $built['result']; - $fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : []; - $fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100); - if ($fs < 1) { - $fs = 100; - } + // 满分:本卷独立优先,其次全局默认 + [$fs, $passRate] = ny_resolve_score_cfg($projectId, $c); $result['config'] = [ 'refresh_interval' => (int)$c['refresh_interval'], 'base_url' => $c['base_url'], 'full_score' => $fs, + 'pass_rate' => $passRate, ]; // 缓存来源标记:便于前端/排查时知道这次是实时拉取还是命中短期缓存 $result['_cache'] = ['cached' => $built['cached'], 'at' => $built['at']]; @@ -353,11 +341,7 @@ try { $c = cfg(); $built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force'])); $result = $built['result']; - $fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : []; - $fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100); - if ($fs < 1) { - $fs = 100; - } + [$fs, $passRate] = ny_resolve_score_cfg($projectId, $c); $students = $result['roster'] ?: []; $rows = $result['rows'] ?: []; @@ -374,13 +358,14 @@ try { } // 通用统计器:给定学生集合 + 答卷集合,算一组指标 - $statOf = static function (array $stu, array $rs, int $fs): array { + // 合格率阈值可配(全局 pass_rate 或本卷独立),不再写死 60% + $statOf = static function (array $stu, array $rs, int $fs) use ($passRate): array { $total = count($stu); $submitted = count($rs); $attempts = array_sum(array_map(static fn($r) => (int)($r['attempts'] ?? 1), $rs)); $scores = array_values(array_filter(array_map(static fn($r) => $r['score'] ?? null, $rs), static fn($v) => $v !== null)); $avg = $scores !== [] ? round(array_sum($scores) / count($scores), 2) : null; - $pass = count(array_filter($rs, static fn($r) => ($r['score'] ?? null) !== null && ($r['score'] >= 0.6 * $fs))); + $pass = count(array_filter($rs, static fn($r) => ($r['score'] ?? null) !== null && ($r['score'] >= ($passRate / 100) * $fs))); $passRate = $submitted > 0 ? round($pass / $submitted * 100, 1) : 0; $durs = array_values(array_filter(array_map(static fn($r) => $r['duration'] ?? null, $rs), static fn($v) => $v !== null)); $avgDur = $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null; @@ -473,6 +458,7 @@ try { ny_ok([ 'project' => $result['project'], 'full_score' => $fs, + 'pass_rate' => $passRate, 'overall' => $result['stats'], 'classes' => $classes, 'updated_at' => $result['updated_at'], @@ -632,6 +618,74 @@ try { } ny_ok(Snapshot::trend($snaps)); + // ---------------- 试卷独立配置(满分 / 合格率 / 免考名单)——仅对当前试卷生效 ---------------- + case 'project_config': + $projectId = (string)($_GET['projectId'] ?? ''); + if ($projectId === '') { + ny_fail('请先选择问卷/考试'); + } + $c = cfg(); + [$fs, $pr] = ny_resolve_score_cfg($projectId, $c); + $pc = DataService::projectConfig($projectId); + ny_ok([ + 'project_id' => $projectId, + 'cfg' => $pc, + // 解析后的生效值(前端回显用;null 表示"跟随全局") + 'effective' => ['full_score' => $fs, 'pass_rate' => $pr], + 'global' => [ + 'full_score' => (int)($c['full_score'] ?? 100), + 'pass_rate' => (float)($c['pass_rate'] ?? 60), + ], + ]); + + case 'project_config_save': + $projectId = (string)($_GET['projectId'] ?? $_POST['projectId'] ?? ''); + if ($projectId === '') { + ny_fail('请先选择问卷/考试'); + } + $raw = (string)file_get_contents('php://input'); + $in = json_decode($raw, true); + if (!is_array($in)) { + $in = $_POST; + } + $old = DataService::projectConfig($projectId); + // 满分:留空/0 = 清除(跟随全局) + $fsIn = $in['full_score'] ?? null; + $fs = ($fsIn === null || $fsIn === '' || (int)$fsIn < 1) ? null : (int)$fsIn; + // 合格率:留空 = 清除(跟随全局);否则夹到 1~100 + $prIn = $in['pass_rate'] ?? null; + $pr = ($prIn === null || $prIn === '') ? null : max(1, min(100, (float)$prIn)); + // 免考名单:支持数组或逗号/换行分隔的字符串 + $exIn = $in['exempt'] ?? null; + $exempt = []; + if (is_array($exIn)) { + foreach ($exIn as $v) { + $v = trim((string)$v); + if ($v !== '') { + $exempt[] = $v; + } + } + } elseif (is_string($exIn) && trim($exIn) !== '') { + foreach (preg_split('/[,,\s]+/u', trim($exIn)) ?: [] as $v) { + $v = trim($v); + if ($v !== '') { + $exempt[] = $v; + } + } + } + $new = [ + 'full_score' => $fs, + 'pass_rate' => $pr, + 'exempt' => array_values(array_unique($exempt)), + ]; + DataService::projectConfigSave($projectId, $new); + $c2 = cfg(); + [$effFs, $effPr] = ny_resolve_score_cfg($projectId, $c2); + ny_ok([ + 'cfg' => $new, + 'effective' => ['full_score' => $effFs, 'pass_rate' => $effPr], + ]); + // ---------------- 导出(支持按班级 / 组别筛选,与页面当前筛选保持一致) ---------------- case 'export': case 'export_absent': diff --git a/assets/app.js b/assets/app.js index 5e5fdbc..8887525 100644 --- a/assets/app.js +++ b/assets/app.js @@ -261,6 +261,7 @@ state.roster = d.roster || []; state.anomalies = d.anomalies || { fast_high: [], frequent: [], identical: [] }; state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100; + state.passRate = (d.config && d.config.pass_rate != null) ? (parseFloat(d.config.pass_rate) || 60) : 60; // 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据" // (纳入 attempts 与 last_submit_ts 才能发现"重考但分数更低"这种提交) @@ -456,7 +457,8 @@ const avg = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 100) / 100 : null; const full = state.fullScore || 100; - const passCount = rows.filter(r => r.score !== null && r.score !== undefined && r.score >= 0.6 * full).length; + const pr = (state.passRate == null ? 60 : state.passRate); // 合格率阈值可配 + const passCount = rows.filter(r => r.score !== null && r.score !== undefined && r.score >= (pr / 100) * full).length; const passRate = submitted > 0 ? Math.round(passCount / submitted * 1000) / 10 : 0; let maxScore = null, minScore = null; @@ -490,6 +492,9 @@ set('#sMax', st.maxScore == null ? '-' : st.maxScore); set('#sMin', st.minScore == null ? '-' : st.minScore); set('#sPass', st.passRate + '%'); + // 合格率阈值可配(全局或本卷独立),卡片标题实时反映当前生效值 + const lbl = $('#sPassLabel'); + if (lbl) lbl.textContent = '(≥' + (state.passRate == null ? 60 : state.passRate) + '%满分)'; set('#sDur', st.avgDur == null ? '-' : fmtDur(st.avgDur)); } @@ -854,10 +859,20 @@ const el = document.documentElement; if (!document.fullscreenElement && !document.webkitFullscreenElement) { const req = el.requestFullscreen || el.webkitRequestFullscreen; - if (req) req.call(el); + if (req) { + const p = req.call(el); + // 部分浏览器返回 Promise,被拒绝时静默吞掉会让用户以为"点了没反应" + if (p && typeof p.catch === 'function') { + p.catch(() => { showAlert('当前浏览器/环境不允许进入全屏,请按 F11 手动切换', 'warn'); }); + } + } } else { + // exitFullscreen 挂在 document 上(documentElement.exitFullscreen 恒为 undefined) const exit = document.exitFullscreen || document.webkitExitFullscreen; - if (exit) exit.call(document); + if (exit) { + const p = exit.call(document); + if (p && typeof p.catch === 'function') p.catch(() => {}); + } } } function onFsChange() { @@ -890,9 +905,9 @@ // 布局 const layout = c.layout || LS.get(LS_LAYOUT, 'default'); (document.querySelector('input[name=cfgLayout][value="' + layout + '"]') || {}).checked = true; - // 满分:默认 + 各试卷独立 + // 全局默认满分 / 合格率(每卷的独立配置移到主页面「试卷设置」) $('#cfgFull').value = c.full_score || 100; - renderFullScores((c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {}); + if ($('#cfgPassRate')) $('#cfgPassRate').value = (c.pass_rate == null ? 60 : c.pass_rate); // 历史成绩展示方式 + 导出排序 state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click'; state.exportSort = (c.export_sort === 'sid') ? 'sid' : ((c.export_sort === 'score_desc') ? 'score_desc' : ((c.export_sort === 'score_asc') ? 'score_asc' : 'time')); @@ -914,39 +929,6 @@ $('#settingsModal').classList.add('open'); } - // 渲染"各试卷独立满分"列表 - // 注意:#fullScoresHint 必须位于 #fullScoresBox 之外(见 index.php), - // 否则 box.innerHTML 覆盖会销毁它,下面的 textContent 赋值会抛 "Cannot set properties of null"。 - // 这里仍保留 null 守卫,避免任何 DOM 结构变动导致整个设置弹窗打不开。 - function setFullScoresHint(text) { - const hint = $('#fullScoresHint'); - if (hint) hint.textContent = text; - } - function renderFullScores(scores) { - const box = $('#fullScoresBox'); - if (!box) return; - if (!state.projects.length) { - box.innerHTML = '暂无试卷,请先连接卷王系统并选择问卷'; - setFullScoresHint('给某卷填入数字即可单独覆盖默认满分;留空=用默认'); - return; - } - // 排除文件夹项:模式为文件夹的无可答卷 - const surveys = state.projects.filter(p => !((p.mode || '').toLowerCase().includes('folder'))); - if (!surveys.length) { - box.innerHTML = '尚未获取到可答卷'; - setFullScoresHint('给某卷填入数字即可单独覆盖默认满分;留空=用默认'); - return; - } - box.innerHTML = surveys.map(p => { - const val = scores[p.id] != null ? scores[p.id] : ''; - return '
' + - '' + - '' + - '
'; - }).join(''); - setFullScoresHint('默认分值为空时使用上方"默认满分";填写即单独覆盖该卷'); - } - /* ---------------- 名单 ---------------- */ let rosterDraft = []; const rosterSel = new Set(); // 勾选待删除的行下标(对应 rosterDraft 索引) @@ -1163,8 +1145,11 @@ include_temp_save: $('#cfgTemp').checked, verify_ssl: $('#cfgSsl').checked, full_score: parseInt($('#cfgFull').value, 10) || 100, + pass_rate: (function () { + const v = $('#cfgPassRate') ? parseFloat($('#cfgPassRate').value) : NaN; + return (isNaN(v) || v < 1 || v > 100) ? 60 : v; + })(), layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default', - full_scores: collectFullScores(), username: $('#cfgUser').value.trim(), history_display: $('#cfgHistoryDisplay').value, export_sort: $('#cfgExportSort').value, @@ -1245,15 +1230,6 @@ $('#btnTestConn').addEventListener('click', testConnection); // 收集"各试卷独立满分" -> {projectId: score} - function collectFullScores() { - const out = {}; - $$('#fullScoresBox input[data-pid]').forEach(inp => { - const v = parseInt(inp.value, 10); - if (inp.dataset.pid && !isNaN(v) && v > 0) out[inp.dataset.pid] = v; - }); - return out; - } - /* 名单上传 */ const zone = $('#dropZone'), fileInput = $('#rosterFile'); zone.addEventListener('click', () => fileInput.click()); @@ -1503,6 +1479,167 @@ $('#trendModal').classList.add('open'); }); + /* 成绩明细打印:默认按成绩降序,可选按班级/组别分组对比 */ + function printTable() { + const rows = (state._sorted || []).slice(); + if (!rows.length) return showAlert('当前没有可打印的成绩', 'warn'); + + const sortMode = $('#ptSort').value; + const groupBy = $('#ptGroup').value; // none | cls | grp + const showRank = $('#ptRank').checked; + const showAtt = $('#ptAttempts').checked; + const full = state.fullScore || 100; + const passRate = (state.passRate == null ? 60 : state.passRate); + + // 排序(打印独立于页面排序,默认成绩降序;null 成绩恒排末尾) + const cmp = (a, b) => { + const an = (a.score == null), bn = (b.score == null); + if (an && bn) return 0; + if (an) return 1; + if (bn) return -1; + if (sortMode === 'score_desc') return (b.score - a.score) || ((b.submit_ts || 0) - (a.submit_ts || 0)); + if (sortMode === 'score_asc') return (a.score - b.score) || ((b.submit_ts || 0) - (a.submit_ts || 0)); + if (sortMode === 'sid') return String(a.sid).localeCompare(String(b.sid), 'zh', { numeric: true }); + return ((b.submit_ts || 0) - (a.submit_ts || 0)); // time + }; + rows.sort(cmp); + + const passOf = r => (r.score != null && r.score >= (passRate / 100) * full); + const statOf = list => { + const sc = list.map(r => r.score).filter(v => v != null); + const avg = sc.length ? (sc.reduce((x, y) => x + y, 0) / sc.length) : null; + const pass = list.filter(passOf).length; + return { + n: list.length, + avg: avg == null ? '—' : avg.toFixed(1), + passRate: list.length ? Math.round(pass / list.length * 1000) / 10 : 0, + }; + }; + + const head = '' + (showRank ? '名次' : '') + '学号姓名' + + (groupBy === 'none' ? '班级组别' : '') + + '成绩' + (showAtt ? '考试次数' : '') + '提交时间'; + + let html = ''; + let rank = 0; + const renderRows = (list, offsetRank) => list.map((r, i) => { + const rk = (r.score == null) ? '' : (offsetRank + i + 1); + return '' + (showRank ? '' + rk + '' : '') + + '' + esc(r.sid || '') + '' + + '' + esc(r.name || '') + '' + + (groupBy === 'none' ? '' + esc(r.cls || '') + '' + esc(r.grp || '') + '' : '') + + '' + (r.score == null ? '—' : r.score) + '' + + (showAtt ? '' + (r.attempts || 1) + '' : '') + + '' + esc(r.submit_at || '') + ''; + }).join(''); + + if (groupBy === 'none') { + html = '' + head + '' + renderRows(rows, 0) + '
'; + } else { + const keyOf = (groupBy === 'cls') ? (r => (r.cls || '未分班')) : (r => (r.grp || '未分组')); + const groups = {}; + rows.forEach(r => { const k = keyOf(r); (groups[k] = groups[k] || []).push(r); }); + Object.keys(groups).sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true })) + .forEach(k => { + const list = groups[k]; + const st = statOf(list); + html += '

' + esc(k) + + ' (' + st.n + ' 人 · 平均 ' + st.avg + ' · 合格率 ' + st.passRate + '%)

'; + html += '' + head + '' + renderRows(list, 0) + '
'; + }); + } + + const all = statOf(rows); + const title = (state.projectName ? state.projectName + ' · ' : '') + '成绩明细'; + const cls = $('#clsSel').value, grp = $('#grpSel').value; + const scope = (cls ? cls + '班 ' : '') + (grp ? grp + '组' : ''); + + const w = window.open('', '_blank'); + if (!w) return showAlert('浏览器拦截了弹窗,请允许后重试', 'error'); + w.document.write('' + esc(title) + '' + + '' + + '

' + esc(title) + '

' + + '
' + esc(scope) + ' 共 ' + all.n + ' 人 · 平均 ' + all.avg + + ' · 合格率 ' + all.passRate + '%(合格线:满分的 ' + passRate + '%,即 ' + + (Math.round(passRate / 100 * full * 10) / 10) + ' 分) · 打印时间:' + new Date().toLocaleString() + '
' + + html + + 'window.onload=function(){setTimeout(function(){window.print()},300)}' + + ''); + w.document.close(); + } + + $('#btnPrintTable').addEventListener('click', () => { + if (!curPid()) return showAlert('请先选择问卷 / 考试'); + const n = (state._sorted || []).length; + $('#ptHint').textContent = n ? ('当前将打印 ' + n + ' 条记录') : '暂无数据'; + $('#printTableModal').classList.add('open'); + }); + $('#btnPtPrint').addEventListener('click', () => { + $('#printTableModal').classList.remove('open'); + printTable(); + }); + + /* 试卷独立设置(满分 / 合格率 / 免考名单)——仅对当前选中试卷生效 */ + async function openProjCfg() { + const pid = curPid(); + if (!pid) return showAlert('请先选择问卷 / 考试'); + const pname = (($('#projectSel').selectedOptions && $('#projectSel').selectedOptions[0]) || {}).textContent || ''; + $('#projCfgSub').textContent = pname ? ('· ' + pname) : ''; + try { + const d = await api('project_config', { params: { projectId: pid } }); + const cfg = d.cfg || {}, eff = d.effective || {}, g = d.global || {}; + $('#pcFull').value = (cfg.full_score == null ? '' : cfg.full_score); + $('#pcPassRate').value = (cfg.pass_rate == null ? '' : cfg.pass_rate); + $('#pcExempt').value = (cfg.exempt || []).join('\n'); + $('#pcFullHint').textContent = '当前生效:' + (eff.full_score || '-') + + '(全局默认 ' + (g.full_score || '-') + ')'; + $('#pcPassHint').textContent = '当前生效:' + (eff.pass_rate || '-') + + '%(全局默认 ' + (g.pass_rate || '-') + '%)'; + } catch (e) { showAlert(e.message); return; } + $('#projCfgModal').classList.add('open'); + } + $('#btnProjCfg').addEventListener('click', openProjCfg); + + $('#btnPcSave').addEventListener('click', async () => { + const pid = curPid(); + if (!pid) return showAlert('请先选择问卷 / 考试'); + try { + await api('project_config_save', { method: 'POST', params: { projectId: pid }, body: { + full_score: $('#pcFull').value.trim(), + pass_rate: $('#pcPassRate').value.trim(), + exempt: $('#pcExempt').value, + }}); + $('#projCfgModal').classList.remove('open'); + showAlert('本卷设置已保存(仅对当前试卷生效)', 'info'); + refresh(false, true); // 免考会改变名单/缺考口径,需强制重拉 + } catch (e) { showAlert(e.message); } + }); + + $('#btnPcReset').addEventListener('click', async () => { + const pid = curPid(); + if (!pid) return; + if (!confirm('确定清除本卷的全部独立设置(恢复跟随全局默认)?')) return; + try { + await api('project_config_save', { method: 'POST', params: { projectId: pid }, body: { + full_score: '', pass_rate: '', exempt: '', + }}); + $('#projCfgModal').classList.remove('open'); + showAlert('本卷设置已清除', 'info'); + refresh(false, true); + } catch (e) { showAlert(e.message); } + }); + /* 异常检测线索 */ function renderAnomaly() { const a = state.anomalies || { fast_high: [], frequent: [], identical: [] }; diff --git a/assets/style.css b/assets/style.css index 6bed70f..bffb853 100644 --- a/assets/style.css +++ b/assets/style.css @@ -53,7 +53,12 @@ body { border-bottom: 1px solid var(--border); box-shadow: 0 2px 12px rgba(20, 30, 60, .05); } -.brand { display: flex; align-items: center; gap: 12px; } +.brand { display: flex; align-items: center; gap: 12px; min-width: 0; } +/* 导航栏主标题(已移除 Logo 与副标题,只保留单一主标题) */ +.brand-title { + margin: 0; font-size: 16px; font-weight: 700; letter-spacing: .3px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} .logo { width: 38px; height: 38px; border-radius: 10px; @@ -469,32 +474,47 @@ body.fs-on .card b { font-size: 28px; } body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; } /* ---------- 三端自适应(桌面 / 平板 / 手机) ---------- */ + +/* 大屏笔记本:略收边距,统计卡片保持一行 */ @media (max-width: 1180px) { .wrap { padding: 16px 16px 40px; } .topbar { padding: 10px 16px; } } +/* 平板横屏(769~1024):卡片改 4 列、工具栏字段占满整行 */ @media (max-width: 1024px) { .grid-2 { grid-template-columns: 1fr; } .field.grow, .field.grow-2 { flex: 1 1 100%; min-width: 0; } + .cards { grid-template-columns: repeat(4, 1fr); } + .toolbar { gap: 14px; } } +/* 平板竖屏 / 大手机(<=768):整体收紧,工具栏与按钮自动换行 */ @media (max-width: 768px) { body { font-size: 13px; } .topbar { flex-wrap: wrap; gap: 10px; } - .brand-text small { display: none; } + .brand { flex: 1 1 100%; } + .brand-title { font-size: 15px; } .top-right { flex-wrap: wrap; gap: 8px; } .top-right .btn { padding: 6px 10px; font-size: 12px; } .btn b { font-size: 10px; } .wrap { padding: 12px 12px 36px; } - .toolbar { padding: 12px; gap: 10px; } + .toolbar { padding: 12px; gap: 10px 12px; } .field { flex: 1 1 100%; min-width: 0; } - .field.btns { flex: 1 1 100%; } + .field.btns { flex: 1 1 100%; flex-wrap: wrap; row-gap: 8px; } .field.btns select { width: 100%; } + /* 导出区的复选标签在小屏独占一行,避免和按钮挤成一团 */ + .field.btns > label[style*="cursor:pointer"] { flex: 1 1 auto; } .cards { grid-template-columns: repeat(3, 1fr); gap: 8px; } .card { padding: 10px 12px; } .card b { font-size: 20px; } - .panel-head { flex-direction: column; align-items: flex-start; gap: 6px; } + .card span { font-size: 11px; } + /* 面板头部(含按钮)竖排,按钮自动换行 */ + .panel-head { flex-direction: column; align-items: flex-start; gap: 8px; } + /* 竖排时 .spacer(flex:1)会把内容上下撑开,必须隐藏 */ + .panel-head > .spacer { display: none; } + .panel-head .updated { flex-wrap: wrap; width: 100%; } + .panel-head .btn, .panel-head .updated .btn { flex: 0 0 auto; } .table-scroll { max-height: 56vh; } table.grid { font-size: 12px; } table.grid th { padding: 8px 10px; white-space: nowrap; } @@ -503,19 +523,36 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; } .modal-box, .modal-box.wide { width: 100%; max-height: 92vh; } .form-grid { grid-template-columns: 1fr; } .modal-body { padding: 14px; } + /* 弹窗底栏在小屏换行,按钮可点满宽 */ + .modal-foot { flex-wrap: wrap; padding: 10px 14px; } + .modal-foot > .spacer { display: none; } + .modal-foot .btn { flex: 1 1 auto; } + .roster-bar { flex-wrap: wrap; } .roster-bar .chk { font-size: 11px; } + /* 名单表在窄屏可横向滚动,避免 6 列挤爆 */ + #rosterTable { min-width: 560px; } } +/* 手机(<=480):卡片两列,进一步压缩字号与内边距 */ @media (max-width: 480px) { - .cards { grid-template-columns: repeat(2, 1fr); } - .brand-text strong { font-size: 14px; } - .topbar .logo { width: 32px; height: 32px; font-size: 13px; } + .cards { grid-template-columns: repeat(2, 1fr); gap: 8px; } + .brand-title { font-size: 14px; } .btn { padding: 6px 9px; font-size: 12px; } .field label { font-size: 11px; } .roster-bar { gap: 8px; } .roster-bar input[type=text] { width: 120px; } .panel-head h2 { font-size: 14px; } #rosterBody input { font-size: 12px; } + .modal-box { max-height: 94vh; } + .modal-body { padding: 12px; } + /* 工具条按钮在小屏铺满,便于点按 */ + .field.btns .btn { flex: 1 1 auto; } +} + +/* 触摸设备:增大点按热区,去掉 hover 残留高亮 */ +@media (hover: none) and (pointer: coarse) { + .btn { min-height: 34px; } + table.grid tbody tr:hover td { background: inherit; } } /* ---------- 数据看板 ---------- */ @@ -525,8 +562,7 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; } display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; box-shadow: var(--shadow); } -.dash-hero .brand strong { color: #fff; font-size: 17px; } -.dash-hero .brand small { color: rgba(255, 255, 255, .72); } +.dash-hero .brand-title { color: #fff; font-size: 17px; } .dash-hero a.back { color: #fff; opacity: .9; font-size: 13px; text-decoration: none; font-weight: 600; } .dash-hero a.back:hover { opacity: 1; text-decoration: underline; } .dash-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } diff --git a/bootstrap.php b/bootstrap.php index f887d30..dc91032 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -227,7 +227,9 @@ function ny_default_config(): array 'dedupe' => true, 'name_keywords' => '姓名,名字,学生姓名,考生姓名,您的姓名,姓名:,姓名:', 'full_score' => 100, - 'full_scores' => [], + // 全局默认合格率(百分比,如 60 表示"≥满分的 60%"算合格)。 + // 每份试卷可在主页面单独覆盖(project_config),试卷之间互相隔离。 + 'pass_rate' => 60, 'layout' => 'default', // 历史成绩展示方式:click=点击成员弹窗查看(默认);inline=成绩明细内直接展开每次考试 'history_display' => 'click', @@ -258,16 +260,45 @@ function ny_load_config(): array { $GLOBALS['__ny_cfg_source'] = 'default'; - // 默认配置 + 文件覆盖(DB 后端本批次已下线,统一走文件存储) - $cfg = ny_default_config(); - $saved = ny_read_json(ny_config_file(), null); + $cfg = ny_default_config(); + $file = ny_config_file(); + $saved = null; + + // 1) 优先读 SQLite(data/ny.db 的 ny_settings.config) + if (class_exists('Db') && Db::pdo() !== null) { + $raw = Db::settingsGet('config', ''); + if ($raw !== '') { + $j = json_decode($raw, true); + if (is_array($j)) { + $saved = $j; + $GLOBALS['__ny_cfg_source'] = 'db'; + } + } + // DB 可用但尚无记录 → 把已有 config.json 迁进来(老数据不丢,只迁一次) + if ($saved === null) { + $fromFile = ny_read_json($file, null); + if (is_array($fromFile)) { + Db::settingsSet('config', json_encode($fromFile, JSON_UNESCAPED_UNICODE)); + $saved = $fromFile; + $GLOBALS['__ny_cfg_source'] = 'db'; + } + } + } + + // 2) DB 不可用时退回文件 + if ($saved === null) { + $saved = ny_read_json($file, null); + if (is_array($saved)) { + $GLOBALS['__ny_cfg_source'] = 'file'; + } + } + if (is_array($saved)) { foreach ($cfg as $k => $v) { if (array_key_exists($k, $saved)) { $cfg[$k] = is_bool($v) ? (bool)$saved[$k] : $saved[$k]; } } - $GLOBALS['__ny_cfg_source'] = 'file'; } $cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/'); $cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/'); @@ -276,8 +307,52 @@ function ny_load_config(): array function ny_save_config(array $cfg): bool { - $GLOBALS['__ny_cfg_source'] = 'file'; - return ny_write_json(ny_config_file(), $cfg); + // 双写:SQLite 为主(成功即算成功),文件为备份(DB 挂掉时还能读回来) + $ok = false; + if (class_exists('Db') && Db::pdo() !== null) { + try { + Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE)); + $GLOBALS['__ny_cfg_source'] = 'db'; + $ok = true; + } catch (Throwable $e) { + $ok = false; + } + } + $fileOk = ny_write_json(ny_config_file(), $cfg); + if (!$ok) { + $GLOBALS['__ny_cfg_source'] = 'file'; + } + return $ok || $fileOk; +} + +/** + * 解析「本卷满分 + 合格率」: + * 优先取该试卷的独立配置(试卷之间互相隔离),没有则用全局配置。 + * + * @return array{0:int,1:float} [满分, 合格率百分比(如 60 表示 60%)] + */ +function ny_resolve_score_cfg(string $projectId, array $c): array +{ + $pCfg = class_exists('DataService') ? DataService::projectConfig($projectId) : []; + + $fs = (int)($pCfg['full_score'] ?? 0); + if ($fs < 1) { + $fs = (int)($c['full_score'] ?? 100); + } + if ($fs < 1) { + $fs = 100; + } + + $pr = $pCfg['pass_rate'] ?? null; + if ($pr === null || $pr === '') { + $pr = $c['pass_rate'] ?? 60; + } + $pr = (float)$pr; + if ($pr <= 0 || $pr > 100) { + $pr = 60; + } + + return [$fs, $pr]; } /** 安全的字符串化(避免数组/对象转字符串报错) */ diff --git a/dashboard.php b/dashboard.php index a830fcc..39078ed 100644 --- a/dashboard.php +++ b/dashboard.php @@ -25,11 +25,7 @@ if (!ny_is_auth()) {
- -
- 班级看板 - 选择一个问卷与班级查看考试整体情况 -
+

NY问卷考试系统数据平台

@@ -47,7 +43,9 @@ if (!ny_is_auth()) {
-
+
+ 选择一个问卷与班级查看考试整体情况 + 最后更新:—
@@ -60,7 +58,7 @@ if (!ny_is_auth()) {
缺考-
提交率-
平均分-
-
合格率(≥60%满分)-
+
合格率(≥60%满分)-
分组对比 / 考试成员
@@ -200,6 +198,9 @@ async function refreshClassSel(){ try { const data = await api('dashboard', { projectId: pid }); DASH = data; + // 合格率阈值可配(全局或本卷独立),卡片标题实时反映 + const bl = document.getElementById('bPassLabel'); + if (bl) bl.textContent = '(≥' + (data.pass_rate == null ? 60 : data.pass_rate) + '%满分)'; const classes = (data.classes || []).map(c => c.class); sel.innerHTML = classes.length ? classes.map(c => '').join('') @@ -285,8 +286,9 @@ function renderPlaceholderBoard(){ $('#boardSummary').innerHTML = cards.map(c => '
' + c[0] + '' + c[1] + '
').join(''); $('#boardBody').innerHTML = '
请选择问卷与班级查看考试整体情况
'; - $('#heroTitle').textContent = '班级看板'; - $('#heroSub').textContent = '选择一个问卷与班级查看考试整体情况'; + // 注意:heroTitle 已随导航栏精简移除,只保留 heroSub(承载当前问卷/班级提示) + const _hs = document.getElementById('heroSub'); + if (_hs) _hs.textContent = '选择一个问卷与班级查看考试整体情况'; const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); } } @@ -327,9 +329,8 @@ function renderBoard(){ return; } - // 顶部品牌区:动态标题 + // 顶部提示:当前班级 / 问卷(heroTitle 已移除,只更新 heroSub) const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || ''; - $('#heroTitle').textContent = cls + ' · 班级看板'; // 构建"学号 -> 多次刷题明细"映射(班级级 members 已含全班所有人的 attempts_list) const MAP = {}; @@ -342,7 +343,8 @@ function renderBoard(){ const overall = DASH.overall || {}; const totalAttempts = (overall.attempts_total || 0); - $('#heroSub').textContent = '问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' + + const _hs2 = document.getElementById('heroSub'); + if (_hs2) _hs2.textContent = cls + ' · 问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' + '总 ' + c.total + ' / 已交 ' + c.submitted + ' / 缺考 ' + c.absent + (totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : ''); @@ -354,7 +356,7 @@ function renderBoard(){ '
缺考' + c.absent + '
' + '
提交率' + c.rate + '%
' + '
平均分' + (c.avg != null ? c.avg : '-') + '
' + - '
合格率' + c.pass_rate + '%
'; + '
合格率(≥' + (DASH && DASH.pass_rate != null ? DASH.pass_rate : 60) + '%满分)' + c.pass_rate + '%
'; const groups = (c.groups || []).filter(g => g.group !== '(未分组)'); const box = $('#boardBody'); @@ -392,10 +394,21 @@ function toggleFullscreen(){ const el = document.documentElement; if (!document.fullscreenElement && !document.webkitFullscreenElement) { const req = el.requestFullscreen || el.webkitRequestFullscreen; - if (req) req.call(el); + if (req) { + const p = req.call(el); + // 部分浏览器(尤其 Safari)会返回 Promise,被拒绝时静默吞掉会让人以为"点了没反应" + if (p && typeof p.catch === 'function') { + p.catch(() => { alert('当前浏览器/环境不允许进入全屏,请按 F11 手动切换'); }); + } + } } else { - const ex = el.exitFullscreen || el.webkitExitFullscreen; - if (ex) ex.call(document); + // 注意:exitFullscreen 挂在 document 上,不是 documentElement 上 + // (写成 el.exitFullscreen 恒为 undefined → 退出全屏点了没反应) + const ex = document.exitFullscreen || document.webkitExitFullscreen; + if (ex) { + const p = ex.call(document); + if (p && typeof p.catch === 'function') p.catch(() => {}); + } } } function onFsChange(){ diff --git a/draw.php b/draw.php index b500af8..d7645f7 100644 --- a/draw.php +++ b/draw.php @@ -26,11 +26,7 @@ $rosterCount = Roster::count();
- -
- 随机抽取 - 名单 / 已提交人员 → 卷王白名单 -
+

NY问卷考试系统数据平台

名单管理 diff --git a/index.php b/index.php index 9759476..c861a4d 100644 --- a/index.php +++ b/index.php @@ -22,11 +22,7 @@ $rosterCount = Roster::count();
- -
- NY问卷考试系统数据平台 - 卷王问卷系统 · 实时数据统计 -
+

NY问卷考试系统数据平台

未连接
@@ -99,6 +95,7 @@ $rosterCount = Roster::count(); +
@@ -109,7 +106,7 @@ $rosterCount = Roster::count();
平均分-
最高分-
最低分-
-
合格率(≥60%满分)-
+
合格率(≥60%满分)-
平均用时-
@@ -119,6 +116,7 @@ $rosterCount = Roster::count();

成绩明细

+ 最后更新:—
@@ -197,9 +195,14 @@ $rosterCount = Roster::count();

实际生效上限 200,超出会自动夹到 200(防止单页内存耗尽)

- + -

未选中试卷时的默认值

+

全局默认;每份试卷可在主页面「试卷设置」中单独覆盖

+
+
+ + +

达到满分的该百分比即算合格;每份试卷可单独覆盖

@@ -224,15 +227,6 @@ $rosterCount = Roster::count();

英文逗号分隔,用于自动识别"姓名"题目

-
- -
- 未选中试卷时,此处为空;选中某套试卷后保存即在设置中为该卷单独设定满分 -
- -

给某卷填入数字即可单独覆盖默认满分;留空=用默认

-
@@ -435,6 +429,74 @@ $rosterCount = Roster::count();
+ + + + + +