diff --git a/.gitignore b/.gitignore index eb87c9a..2ff0d77 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # 运行时数据:含账号密码、API 令牌、学生名单,禁止入库 data/ +# WorkBuddy 项目数据目录(记忆 / 配置缓存),非源码,禁止入库 +.workbuddy/ + # e2e 隔离用的临时数据目录(run-all.sh 自动创建,不入库) .ny-test-data/ diff --git a/api.php b/api.php index e57ea35..803cca6 100644 --- a/api.php +++ b/api.php @@ -132,8 +132,11 @@ function ny_build_data(array $c, string $projectId, string $nameQid, bool $force return ['result' => $result, 'cached' => false, 'at' => time()]; } -// 会话保护:除 auth_check 外,未登录一律拒访 -if ($action !== 'auth_check' && !ny_is_auth()) { +// 会话保护:白名单外的动作未登录一律拒访。 +// dashboard 进白名单是因为班级大屏要公开访问;它在 case 内部自己兜底—— +// 未登录时必须带 ?class=,且只回传该班数据(全卷与其它班级一律不回)。 +$PUBLIC_ACTIONS = ['auth_check', 'dashboard']; +if (!in_array($action, $PUBLIC_ACTIONS, true) && !ny_is_auth()) { ny_json(['ok' => false, 'msg' => '未登录或会话已过期,请重新登录'], 401); exit; } @@ -305,6 +308,20 @@ try { $students = $result['roster'] ?: []; $rows = $result['rows'] ?: []; + /* 公开访问(未登录)只允许看指定班级,且只看这一个类的数据: + 名单、答卷都在服务端先按班级裁掉,不给前端任何"全卷"数据, + 避免公开链接顺手拿到其它班级甚至全校的成绩。 */ + $publicCls = ''; + if (!ny_is_auth()) { + $publicCls = trim((string)($_GET['class'] ?? '')); + if ($publicCls === '') { + ny_fail('缺少班级参数(公开访问需指定 ?class=)'); + } + $inCls = static fn($x) => (trim((string)($x['cls'] ?? '')) ?: '(未分班)') === $publicCls; + $students = array_values(array_filter($students, $inCls)); + $rows = array_values(array_filter($rows, $inCls)); + } + $byCls = []; foreach ($students as $s) { $k = trim((string)($s['cls'] ?? '')) ?: '(未分班)'; @@ -414,14 +431,32 @@ try { return strcmp((string)$a['class'], (string)$b['class']); }); - ny_ok([ + $payload = [ 'project' => $result['project'], 'full_score' => $fs, 'pass_rate' => $passRate, 'overall' => $result['stats'], 'classes' => $classes, 'updated_at' => $result['updated_at'], - ]); + ]; + if ($publicCls !== '') { + // 公开访问:overall 换成该班自己的汇总(保持 stats 原有键名,前端按 attempts_total 读取), + // classes 只留这一个班,并打上 public 标记供前端展示"公开页面" + $st = $statOf($students, $rows, $fs); + $payload['overall'] = array_merge($result['stats'], [ + 'roster_total' => $st['total'], + 'submitted' => $st['submitted'], + 'absent' => $st['absent'], + 'submit_rate' => $st['rate'], + 'avg_score' => $st['avg'], + 'max_score' => $st['max'], + 'min_score' => $st['min'], + 'avg_duration' => $st['avg_duration'], + 'attempts_total' => $st['attempts'], + ]); + $payload['public'] = true; + } + ny_ok($payload); // ---------------- 名单 ---------------- case 'roster': @@ -711,10 +746,17 @@ try { $mode = ((string)($_GET['mode'] ?? '') === 'latest') ? 'latest' : 'all'; $splitCls = ((string)($_GET['split'] ?? '') === 'cls'); $isLatest = ($mode === 'latest'); + // 每人一行时的成绩口径:latest(默认,最近一次真实提交)| best(最高分) + // 「成绩明细」的显示选项就是这一项——默认最新成绩,可选最高成绩。 + $rowScoreMode = ((string)($_GET['scoreMode'] ?? '') === 'best') ? 'best' : 'latest'; + // skipTemp=1:每次考试一行时排除暂存草稿(草稿不是有效成绩);includeTemp=1 可保留 + $skipTemp = (!empty($_GET['skipTemp']) || !empty($_GET['skip_temp'])) + && empty($_GET['includeTemp']) && empty($_GET['include_temp']); + $scoreTag = $isLatest ? ($rowScoreMode === 'best' ? '_最高成绩' : '_最新成绩') : '_每次考试'; $filename = ($action === 'export_absent' ? '缺考名单' : '成绩明细') . '_' . $safe((string)$result['project']['name']) . $tag - . ($isLatest ? '_最新成绩' : '') + . $scoreTag . '_' . date('Ymd_His') . '.xlsx'; // ---------------- 列定义(可通过 cols= 参数挑选) ---------------- @@ -723,17 +765,18 @@ try { 'name' => '姓名', 'cls' => '班级', 'grp' => '组别', - 'score' => $isLatest ? '最新成绩' : '成绩', + 'score' => $isLatest ? ($rowScoreMode === 'best' ? '最高成绩' : '最新成绩') : '成绩', 'best_score' => '最高分', 'attempts' => '考试次数', 'idx' => '第几次', + 'is_best' => '是否最高分', 'submit_at' => $isLatest ? '最近提交时间' : '提交时间', 'duration_txt' => '考试时长', 'duration' => '考试时长(秒)', ]; $defaultCols = $isLatest ? ['sid', 'name', 'cls', 'grp', 'score', 'best_score', 'attempts', 'submit_at', 'duration_txt', 'duration'] - : ['sid', 'name', 'cls', 'grp', 'score', 'attempts', 'idx', 'submit_at', 'duration_txt', 'duration']; + : ['sid', 'name', 'cls', 'grp', 'score', 'best_score', 'attempts', 'idx', 'is_best', 'submit_at', 'duration_txt', 'duration']; $want = array_values(array_filter( array_map('trim', explode(',', (string)($_GET['cols'] ?? ''))), static fn($v): bool => $v !== '' @@ -746,7 +789,8 @@ try { // ---------------- 数据行 ---------------- $sortMode = (string)($_GET['sort'] ?? ''); if ($isLatest) { - // 每人一行:成绩取「最近一次真实提交」的分数(不受 dedupe 影响),并附最高分与考试次数。 + // 每人一行:成绩按「最新成绩」(最近一次真实提交)或「最高成绩」(best_score), + // 并附最高分与考试次数。 // 注意:include_temp_save 开启时 attempts_list 可能含暂存草稿,取最新成绩应跳过 temp_save, // 否则导出的"最新成绩"可能是一份草稿分。 $all = []; @@ -762,19 +806,50 @@ try { if ($last === null && $list !== []) { $last = $list[count($list) - 1]; // 全是暂存时退回最后一份 } + // best 口径:优先用聚合行 best_score;缺失时从 attempts_list 现算, + // 保证「最高成绩」列与代表成绩一致(避免出现"最高分比成绩还低"的怪表)。 + $bestScore = $r['best_score'] ?? null; + if ($rowScoreMode === 'best') { + if ($bestScore === null || $bestScore === '') { + foreach ($list as $a) { + $sv = $a['score'] ?? null; + if ($sv === null || $sv === '') { + continue; + } + if ($bestScore === null || (float)$sv > (float)$bestScore) { + $bestScore = $sv; + } + } + } + } + // 最高分所在那次(用于「提交时间 / 时长」跟随口径,避免最高分配了别次的耗时) + $bestRow = null; + if ($rowScoreMode === 'best' && $bestScore !== null && $bestScore !== '') { + foreach ($list as $a) { + if ((string)($a['score'] ?? '') === (string)$bestScore) { + $bestRow = $a; + break; + } + } + } + $useRow = ($rowScoreMode === 'best') ? ($bestRow ?: $last) : $last; $all[] = [ 'sid' => (string)($r['sid'] ?? ''), 'name' => (string)($r['name'] ?? ''), 'cls' => (string)($r['cls'] ?? ''), 'grp' => (string)($r['grp'] ?? ''), - 'score' => $last !== null ? ($last['score'] ?? null) : ($r['score'] ?? null), - 'best_score' => $r['best_score'] ?? null, + 'score' => ($rowScoreMode === 'best') + ? (($bestScore === null || $bestScore === '') ? null : $bestScore) + : ($useRow !== null ? ($useRow['score'] ?? null) : ($r['score'] ?? null)), + 'best_score' => $bestScore, 'attempts' => (int)($r['attempts'] ?? 1), 'idx' => (int)($r['attempts'] ?? 1), - 'submit_ts' => (int)($r['last_submit_ts'] ?? 0), - 'submit_at' => (string)($r['last_submit_at'] ?? ($last['submit_at'] ?? ($r['submit_at'] ?? '-'))), - 'duration_txt' => (string)($last['duration_txt'] ?? ($r['duration_txt'] ?? '-')), - 'duration' => $last['duration'] ?? ($r['duration'] ?? null), + 'submit_ts' => ($useRow !== null && isset($useRow['submit_ts'])) + ? (int)$useRow['submit_ts'] + : (int)($r['last_submit_ts'] ?? 0), + 'submit_at' => (string)($useRow['submit_at'] ?? ($r['last_submit_at'] ?? ($r['submit_at'] ?? '-'))), + 'duration_txt' => (string)($useRow['duration_txt'] ?? ($r['duration_txt'] ?? '-')), + 'duration' => $useRow['duration'] ?? ($r['duration'] ?? null), ]; } if ($sortMode === 'score_desc' || $sortMode === 'score_asc') { @@ -785,6 +860,9 @@ try { } else { // 每次考试一行(同一学号考了 N 次就有 N 行) $all = DataService::expandAttempts($rows, true); + if ($skipTemp) { + $all = array_values(array_filter($all, static fn(array $r): bool => empty($r['temp_save']))); + } if ($sortMode === 'score_desc' || $sortMode === 'score_asc') { $all = ny_sort_by_score($all, $sortMode === 'score_desc'); } else { @@ -792,18 +870,33 @@ try { } } + // 合格线:本卷满分 × 合格率(0 表示本卷没配满分,不判定合格) + [$effFs, $effPr] = ny_resolve_score_cfg($projectId, $c); + $passLineVal = ($effFs > 0 && $effPr > 0) ? ($effFs * $effPr / 100.0) : 0.0; + $scoreColIdx = array_search('score', $cols, true); + // ---------------- 组装工作表 ---------------- - $toRows = static function (array $list) use ($cols, $colLabels): array { - $head = []; + // 出表方式 = 「每次考试一行」时,成绩达合格线的行标绿底(与多考试块表的口径一致)。 + // $marks 每次调用先重置,因此分班级多工作表时各表的标记不会串到别的表里。 + $marks = []; + $toRows = static function (array $list) use ($cols, $colLabels, $mode, $passLineVal, $scoreColIdx, &$marks): array { + $marks = []; + $head = []; foreach ($cols as $c) { $head[] = $colLabels[$c]; } $out = [$head]; + $rn = 1; // 工作表内行号(1-based,与 cell_styles 一致) foreach ($list as $r) { + $rn++; $line = []; - foreach ($cols as $c) { + foreach ($cols as $i => $c) { $v = $r[$c] ?? null; $line[] = ($v === null || $v === '') ? '' : (string)$v; + if ($i === $scoreColIdx && $mode !== 'latest' && $passLineVal > 0 + && $v !== null && $v !== '' && is_numeric($v) && (float)$v >= $passLineVal) { + $marks[$rn . ',' . ($i + 1)] = 'green'; + } } $out[] = $line; } @@ -826,13 +919,25 @@ try { } ksort($byCls, SORT_NATURAL | SORT_FLAG_CASE); foreach ($byCls as $clsName => $list) { - $sheets[] = ['name' => (string)$clsName, 'rows' => $toRows($list), 'widths' => $widths]; + $sheets[] = [ + 'name' => (string)$clsName, + 'rows' => $toRows($list), + 'widths' => $widths, + 'cell_styles' => $marks, + ]; } } else { - $sheets[] = ['name' => $isLatest ? '成绩明细(最新)' : '成绩明细', 'rows' => $toRows($all), 'widths' => $widths]; + $sheets[] = [ + 'name' => $isLatest ? ($rowScoreMode === 'best' ? '成绩明细(最高)' : '成绩明细(最新)') : '成绩明细', + 'rows' => $toRows($all), + 'widths' => $widths, + 'cell_styles' => $marks, + ]; } // 附加:缺考名单 / 未匹配答卷(仅非空时输出) - if ($absent !== []) { + // noAbsent=1 时不附缺考名单("成绩明细只导成绩"的场景) + $noAbsent = !empty($_GET['noAbsent']) || !empty($_GET['no_absent']); + if ($absent !== [] && !$noAbsent) { $sheets[] = ['name' => '缺考名单', 'rows' => ny_absent_sheet_rows($absent), 'widths' => [16, 14, 16, 10]]; } $unmatched = array_values((array)($result['unmatched'] ?? [])); @@ -849,6 +954,23 @@ try { } } + /* format=json:不下载文件,直接回传装配结果。 + 「打印成绩明细 / 打印缺考名单」走的就是这条,前端拿它渲染打印视图; + 否则前端会拿到 xlsx 二进制再 res.json(),报 "Unexpected token 'P'"。 + 缺考名单工作表也在 $sheets 里,所以勾选「附缺考名单」时打印同样看得到。 */ + if (in_array($action, ['export', 'export_absent'], true) + && (string)($_GET['format'] ?? '') === 'json') { + ny_ok([ + 'sheets' => $sheets, + 'meta' => [ + 'projectName' => (string)($result['project']['name'] ?? ''), + 'passLine' => $passLineVal, + 'passRate' => $effPr, + 'fullScore' => $effFs, + ], + ]); + } + // 说明:所有单元格按「共享字符串 + 文本格式」写入, // 既不会把长学号变成科学计数法,也不会把 = + - @ 开头的内容当公式执行(规避 CSV 公式注入) $bin = XlsxWriter::renderMulti($sheets); @@ -874,25 +996,230 @@ try { $scoreMode = ((string)($_GET['scoreMode'] ?? '') === 'highest') ? 'highest' : 'latest'; $c = cfg(); + // 班级 / 组别筛选(支持多选,逗号分隔;空 = 全部),与单考试导出口径一致 + $multiVals = static function (string $raw): array { + return array_values(array_unique(array_filter( + array_map('trim', explode(',', $raw)), + static function ($v): bool { return $v !== ''; } + ))); + }; + $clsPick = $multiVals((string)($_GET['cls'] ?? '')); + $grpPick = $multiVals((string)($_GET['grp'] ?? '')); + $hit = static function (array $r) use ($clsPick, $grpPick): bool { + if ($clsPick !== [] && !in_array(trim((string)($r['cls'] ?? '')), $clsPick, true)) { + return false; + } + if ($grpPick !== [] && !in_array(trim((string)($r['grp'] ?? '')), $grpPick, true)) { + return false; + } + return true; + }; + // 逐场考试取数(复用与 data 接口相同的缓存),按选择顺序组装交叉矩阵 - $exams = []; + $exams = []; + $seenCls = []; + $seenGrp = []; foreach ($ids as $pid) { $built = ny_build_data($c, $pid, $nameQid, !empty($_GET['force'])); $result = $built['result']; + $rows = array_values(array_filter((array)($result['rows'] ?? []), $hit)); + foreach ($rows as $r) { + $cv = trim((string)($r['cls'] ?? '')); + $gv = trim((string)($r['grp'] ?? '')); + if ($cv !== '') { $seenCls[$cv] = true; } + if ($gv !== '') { $seenGrp[$gv] = true; } + } $exams[$pid] = [ 'name' => (string)($result['project']['name'] ?? $pid), - 'rows' => (array)($result['rows'] ?? []), + 'rows' => $rows, ]; } - $matrix = ny_assemble_multi_exam_matrix($exams, $scoreMode); - $sheetRows = array_merge([$matrix['head']], $matrix['rows']); - $widths = array_merge([20, 14], array_fill(0, count($matrix['exam_order']), 12)); - $sheets = [['name' => '多考试对比', 'rows' => $sheetRows, 'widths' => $widths]]; + // 导出形态: + // block = 两行表头的「每次考试」块表(默认;需求截图版式) + // summary = 成绩对比(一行一个学生、一列一场考试,取最新 / 最高) + // both = 块表 + 成绩对比,一次出 + // 兼容旧参数:matrix→summary、merged / attempts→block + $modeRaw = (string)($_GET['mode'] ?? ''); + $modeMap = [ + 'summary' => 'summary', + 'matrix' => 'summary', + 'block' => 'block', + 'merged' => 'block', + 'attempts' => 'block', + 'att' => 'block', + 'both' => 'both', + ]; + $mode = $modeMap[$modeRaw] ?? 'block'; + + // 每次考试明细默认跳过暂存草稿(草稿不是有效成绩);includeTemp=1 可保留 + $includeTemp = !empty($_GET['includeTemp']) || !empty($_GET['include_temp']); + + // 合格线 / 合格次数阈值:用于「合格次数」行(达标绿底)与最高分标红 + // 合格线 = 每场考试各自的满分 × 合格率;判「合格次数」时按各场分别算, + // 因此这里逐场解析(本卷独立配置优先,其次全局默认)。 + $passTimesIn = $_GET['passTimes'] ?? null; + $passTimes = ($passTimesIn === null || $passTimesIn === '') ? 1 : max(1, (int)$passTimesIn); + $markBest = !array_key_exists('markBest', $_GET) || !empty($_GET['markBest']); + $passRow = !array_key_exists('passRow', $_GET) || !empty($_GET['passRow']); + + // 每场考试的合格线(pid => float),随 $exams 一起传进装配函数 + $passLineOf = []; + foreach ($ids as $pid) { + [$fsP, $prP] = ny_resolve_score_cfg($pid, $c); + $passLineOf[$pid] = round($fsP * $prP / 100, 4); + } + + // 班级 / 组别列:选了多班级 / 多组别才补出(只选 1 个班级且未选组别 → 两列都不出) + $colFlags = ny_multi_col_flags($clsPick, $grpPick, array_keys($seenCls), array_keys($seenGrp)); + if (array_key_exists('withCls', $_GET)) { + $colFlags['with_cls'] = !empty($_GET['withCls']); + } + if (array_key_exists('withGrp', $_GET)) { + $colFlags['with_grp'] = !empty($_GET['withGrp']); + } + + // 单场 + 明细列不含耗时/时间 → 走「宽表」版式(学号|姓名|第1次…第N次|合格次数), + // 与需求截图二一致;带了耗时/时间则每人一行放不下,退回两行表头块表。 + $wantWide = !empty($_GET['wide']); + + $sheets = []; + $block = null; + if ($mode === 'block' || $mode === 'both') { + if ($wantWide && count($ids) === 1) { + $pid = $ids[0]; + $widRes = ny_assemble_attempt_wide_sheet($exams[$pid], [ + 'skip_temp' => !$includeTemp, + 'pass_line' => $passLineOf[$pid], + 'pass_times'=> $passTimes, + 'mark_best' => $markBest, + 'pass_row' => $passRow, + ]); + $sheets[] = [ + 'name' => '每次考试', + 'rows' => $widRes['rows'], + 'widths' => $widRes['widths'], + 'merges' => $widRes['merges'], + 'header_rows' => $widRes['header_rows'], + 'cell_styles' => $widRes['cell_styles'], + ]; + $block = $widRes; + } else { + // 多场:各场合格线可能不同,装配函数按「单条合格线」设计, + // 这里取各场合格线中的最低值作为汇总口径(避免把某场算得过严), + // 并在 meta 里回报每场各自的合格线供前端说明。 + $lines = array_values($passLineOf); + $minLine = $lines === [] ? null : min($lines); + $block = ny_assemble_block_sheet($exams, [ + 'skip_temp' => !$includeTemp, + 'with_cls' => $colFlags['with_cls'], + 'with_grp' => $colFlags['with_grp'], + 'pass_line' => $passRow ? $minLine : null, + 'pass_times' => $passTimes, + 'mark_best' => $markBest, + 'pass_row' => $passRow, + ]); + $sheets[] = [ + 'name' => '每次考试', + 'rows' => $block['rows'], + 'widths' => $block['widths'], + 'merges' => $block['merges'], + 'header_rows' => $block['header_rows'], + 'cell_styles' => $block['cell_styles'], + ]; + } + } + if ($mode === 'summary' || $mode === 'both') { + $matrix = ny_assemble_multi_exam_matrix($exams, $scoreMode); + $sheetRows = array_merge([$matrix['head']], $matrix['rows']); + $widths = array_merge([20, 14], array_fill(0, count($matrix['exam_order']), 12)); + $sheets[] = ['name' => '成绩对比', 'rows' => $sheetRows, 'widths' => $widths, 'header_rows' => 1]; + } + if ($sheets === []) { + $sheets[] = ['name' => '每次考试', 'rows' => [['暂无数据']], 'widths' => [12], 'header_rows' => 1]; + } + + /* 附缺考名单:与单考试导出同一口径——「名单里有、答卷里没有」即为缺考。 + 多考试场景取「所选考试全都没交」才算缺考(某场没交但在别场交过的人 + 已在块表里以「未考」体现,再列进缺考名单属于重复噪音)。 */ + $noAbsent = !empty($_GET['noAbsent']) || !empty($_GET['no_absent']); + if (!$noAbsent) { + $roster = Roster::all(); + // 逐场收集「已提交学号」,取并集:某场没交但在别场交过的人 + // 已在块表里以「未考」体现,不再重复列进缺考名单 + $submittedAny = []; + foreach ($exams as $ex) { + foreach ((array)($ex['rows'] ?? []) as $r) { + $sidR = trim((string)($r['sid'] ?? '')); + if ($sidR !== '') { + $submittedAny[$sidR] = true; + } + } + } + $absentRows = []; + foreach ($roster as $s) { + $sidR = trim((string)($s['sid'] ?? '')); + if ($sidR === '' || isset($submittedAny[$sidR])) { + continue; + } + if (!$hit($s)) { + continue; // 跟随班级 / 组别筛选 + } + $absentRows[] = $s; + } + if ($absentRows !== []) { + $sheets[] = [ + 'name' => '缺考名单', + 'rows' => ny_absent_sheet_rows($absentRows), + 'widths' => [16, 14, 16, 10], + ]; + } + } $safe = static fn(string $s): string => preg_replace('/[\\\\\\/:*?"<>|]/u', '_', $s) ?? ''; - $filename = '多考试成绩对比_' . count($ids) . '场_' - . ($scoreMode === 'highest' ? '最高分' : '最新成绩') . '_' . date('Ymd_His') . '.xlsx'; + $stamp = date('Ymd_His'); + // 文件名带上筛选范围,便于分辨"这次导的是哪个班" + $scopeTag = ($clsPick !== [] ? '_' . $safe(implode('+', array_slice($clsPick, 0, 3)) . (count($clsPick) > 3 ? '等' : '')) : '') + . ($grpPick !== [] ? '_' . $safe(implode('+', array_slice($grpPick, 0, 3)) . (count($grpPick) > 3 ? '等' : '')) : ''); + if ($mode === 'block') { + $filename = '每次考试_' . count($ids) . '场' . $scopeTag . '_' . $stamp . '.xlsx'; + } elseif ($mode === 'both') { + $filename = '每次考试与成绩对比_' . count($ids) . '场' . $scopeTag . '_' . $stamp . '.xlsx'; + } else { + $filename = '多考试成绩对比_' . count($ids) . '场' . $scopeTag . '_' + . ($scoreMode === 'highest' ? '最高分' : '最新成绩') . '_' . $stamp . '.xlsx'; + } + + // format=json:把同一套装配结果原样交给前端——「多考试打印」用它渲染 HTML, + // 保证打印版式与导出的 Excel 完全一致(同一份逻辑,不在前端重写排版规则) + if ((string)($_GET['format'] ?? '') === 'json') { + $examNames = []; + foreach ($exams as $pid => $ex) { + $examNames[] = (string)($ex['name'] ?? $pid); + } + ny_ok([ + 'sheets' => $sheets, + 'filename' => $filename, + 'meta' => [ + 'mode' => $mode, + 'wide' => ($wantWide && count($ids) === 1), + 'examCount' => count($ids), + 'examNames' => $examNames, + 'cls' => $clsPick, + 'grp' => $grpPick, + 'scoreMode' => $scoreMode, + 'with_cls' => $colFlags['with_cls'], + 'with_grp' => $colFlags['with_grp'], + 'students' => ($block !== null ? $block['students'] : null), + 'rows' => ($block !== null ? $block['total_rows'] : null), + // 合格口径:前端据此在打印说明里写清楚"合格线几分、几次算通过" + 'pass_row' => ($block !== null ? !empty($block['pass_row']) : false), + 'pass_times' => $passTimes, + 'pass_lines' => $passLineOf, + ], + ]); + } + $bin = XlsxWriter::renderMulti($sheets); header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); diff --git a/assets/app.js b/assets/app.js index 1011f86..9606acb 100644 --- a/assets/app.js +++ b/assets/app.js @@ -1332,50 +1332,147 @@ showAlert('已清除新数据高亮', 'info'); }); - /* 导出排序切换(即时保存到服务端配置) */ - $('#exportSortSel').addEventListener('change', async () => { - state.exportSort = $('#exportSortSel').value; - if ($('#cfgExportSort')) $('#cfgExportSort').value = state.exportSort; + /* 导出排序文案(统一导出弹窗用) */ + function exportSortLabel(sort) { + return (sort === 'sid') ? '按学号' + : (sort === 'score_desc') ? '按成绩降序' + : (sort === 'score_asc') ? '按成绩升序' + : '按提交时间'; + } + function currentExportSort() { + const el = $('#exSort') || $('#exportSortSel'); + const sort = el ? el.value : 'time'; + return (sort === 'sid') ? 'sid' : ((sort === 'score_desc') ? 'score_desc' : ((sort === 'score_asc') ? 'score_asc' : 'time')); + } + + /* ============================================================================ + 统一导出 / 打印弹窗 + 过去工具栏有一排导出按钮(导出成绩 / 每次考试 / 缺考 / 多考试导出 / 多考试打印), + 按钮太多、口径分散。现在统一成一个「导出」按钮 + 一个弹窗: + 1. 导出内容(成绩明细 / 每次考试 / 缺考名单 / 多考试对比) + 2. 统计范围(班级 / 组别多选) + 3. 成绩口径与列(最新 / 最高、排序、每人一行 / 每次一行、分表、附缺考) + 4. 多考试对比(形态、对比成绩、合格次数阈值、标红 / 标绿、勾选考试) + 5. 打印设置(纸张方向、标题) + 弹窗里的选择会记住(localStorage),下次打开沿用。 + ========================================================================== */ + + const EX_LS_KEY = 'ny.export.opts.v1'; + + /** 读取上次的导出配置(异常 / 无存储时给默认值) */ + function exLoadOpts() { + const def = { + type: 'detail', cls: [], grp: [], rowScore: 'latest', sort: state.exportSort || 'time', + detailMode: 'latest', split: false, withAbsent: true, multiMode: 'block', + multiScore: 'latest', passTimes: 1, markBest: true, passRow: true, orient: 'landscape', + printTitle: '', + }; try { - await api('config_save', { method: 'POST', body: { export_sort: state.exportSort } }); - } catch (e) { /* 静默:本地已生效 */ } - }); + const raw = localStorage.getItem(EX_LS_KEY); + if (!raw) return def; + const o = JSON.parse(raw); + return Object.assign(def, (o && typeof o === 'object') ? o : {}); + } catch (e) { return def; } + } + function exSaveOpts(o) { + try { localStorage.setItem(EX_LS_KEY, JSON.stringify(o)); } catch (e) { /* 隐私模式:忽略 */ } + } + let exOpts = exLoadOpts(); - // 导出跟随当前班级 / 组别筛选(统计卡片与表格显示什么,就导出什么) - $('#btnExport').addEventListener('click', () => { - const pid = $('#projectSel').value; - if (!pid) return showAlert('请先选择问卷 / 考试'); - const cls = $('#clsSel').value, grp = $('#grpSel').value; - const sort = (state.exportSort === 'sid') ? 'sid' : ((state.exportSort === 'score_desc') ? 'score_desc' : ((state.exportSort === 'score_asc') ? 'score_asc' : 'time')); - // 导出提示文案(与排序方式对应,让用户看清"这次按什么排") - const sortLabel = (sort === 'sid') ? '按学号' - : (sort === 'score_desc') ? '按成绩降序' - : (sort === 'score_asc') ? '按成绩升序' - : '按提交时间'; - const latest = ($('#exportLatest') && $('#exportLatest').checked) ? 'latest' : 'all'; - const split = ($('#exportSplit') && $('#exportSplit').checked) ? 'cls' : ''; - const qs = 'api.php?action=export&projectId=' + encodeURIComponent(pid) + - '&nameQid=' + encodeURIComponent($('#nameFieldSel').value) + - '&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp) + - '&sort=' + sort + '&mode=' + latest + (split ? '&split=' + split : ''); - location.href = qs; - const modeLabel = (latest === 'latest') ? '(每人一行·最新成绩)' : '(每次考试一行)'; - const splitLabel = (split === 'cls') ? ',按班级分表' : ''; - showAlert(exportTip('成绩明细', cls, grp) + ',排序:' + sortLabel + splitLabel + modeLabel, 'info'); - }); - $('#btnExportAbsent').addEventListener('click', () => { - const pid = $('#projectSel').value; - if (!pid) return showAlert('请先选择问卷 / 考试'); - const cls = $('#clsSel').value, grp = $('#grpSel').value; - location.href = 'api.php?action=export_absent&projectId=' + encodeURIComponent(pid) + - '&nameQid=' + encodeURIComponent($('#nameFieldSel').value) + - '&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp); - showAlert(exportTip('缺考名单', cls, grp), 'info'); - }); + /** 把当前弹窗里的选择收集成对象(导出 / 打印都基于它拼查询串) */ + function exCollect() { + return { + type: (document.querySelector('input[name=exType]:checked') || {}).value || 'detail', + cls: exSelVals($('#exClsSel')), + grp: exSelVals($('#exGrpSel')), + rowScore: $('#exRowScore').value, + sort: $('#exSort').value, + detailMode: $('#exDetailMode').value, + split: $('#exSplit').checked, + withAbsent: $('#exWithAbsent').checked, + multiMode: $('#exMultiMode').value, + multiScore: $('#exMultiScore').value, + passTimes: Math.max(1, parseInt($('#exPassTimes').value, 10) || 1), + markBest: $('#exMarkBest').checked, + passRow: $('#exPassRow').checked, + orient: $('#exOrient').value, + printTitle: $('#exPrintTitle').value.trim(), + }; + } + /** 把对象写回弹窗控件 */ + function exApply(o) { + const t = document.querySelector('input[name=exType][value="' + (o.type || 'detail') + '"]'); + if (t) t.checked = true; + exSetSel($('#exClsSel'), o.cls || []); + exFillGroups(o.grp || []); + if ($('#exRowScore')) $('#exRowScore').value = o.rowScore || 'latest'; + if ($('#exSort')) $('#exSort').value = o.sort || 'time'; + if ($('#exDetailMode'))$('#exDetailMode').value= o.detailMode || 'latest'; + if ($('#exSplit')) $('#exSplit').checked = !!o.split; + if ($('#exWithAbsent'))$('#exWithAbsent').checked = o.withAbsent !== false; + if ($('#exMultiMode')) $('#exMultiMode').value = o.multiMode || 'block'; + if ($('#exMultiScore'))$('#exMultiScore').value= o.multiScore || 'latest'; + if ($('#exPassTimes')) $('#exPassTimes').value = o.passTimes || 1; + if ($('#exMarkBest')) $('#exMarkBest').checked = o.markBest !== false; + if ($('#exPassRow')) $('#exPassRow').checked = o.passRow !== false; + if ($('#exOrient')) $('#exOrient').value = o.orient || 'landscape'; + if ($('#exPrintTitle'))$('#exPrintTitle').value= o.printTitle || ''; + } - /* ---------------- 多考试对比导出 ---------------- */ - // 仅列出真正的"考试"(排除文件夹型项目,folder 或被其他项目作为父级引用) - function nonFolderProjects() { + /* ---------------- 班级 / 组别多选(不选 = 全部) ---------------- */ + function exSelVals(sel) { + if (!sel) return []; + return Array.prototype.slice.call(sel.options).filter(o => o.selected).map(o => o.value).filter(Boolean); + } + function exSetSel(sel, vals) { + if (!sel) return; + const want = vals || []; + Array.prototype.slice.call(sel.options).forEach(o => { o.selected = want.indexOf(o.value) >= 0; }); + } + function exFillGroups(keep) { + const grpSel = $('#exGrpSel'); + if (!grpSel) return; + const clsPicked = exSelVals($('#exClsSel')); + const pool = (state.roster || []).filter(s => + clsPicked.length === 0 || clsPicked.indexOf(s.cls || '') >= 0); + const vals = Array.from(new Set(pool.map(s => s.grp).filter(Boolean))) + .sort((a, b) => a.localeCompare(b, 'zh', { numeric: true })); + grpSel.innerHTML = vals.map(v => '').join(''); + // 组别随班级联动后,原选择里仍存在的继续选中 + exSetSel(grpSel, (keep || []).filter(v => vals.indexOf(v) >= 0)); + } + let exLastMain = null; // 上次打开时的主界面筛选(用于判断是否要跟随) + function exFillFilters(forceFollow) { + const clsSel = $('#exClsSel'); + if (!clsSel) return; + const keepCls = exSelVals(clsSel); + const keepGrp = exSelVals($('#exGrpSel')); + const vals = Array.from(new Set((state.roster || []).map(s => s.cls).filter(Boolean))) + .sort((a, b) => a.localeCompare(b, 'zh', { numeric: true })); + clsSel.innerHTML = vals.map(v => '').join(''); + // 首次打开、或主界面筛选被改过 → 跟随主界面;否则保留弹窗里上次的选择 + const mainCls = $('#clsSel') ? $('#clsSel').value : ''; + const mainGrp = $('#grpSel') ? $('#grpSel').value : ''; + const mainKey = mainCls + '\u0000' + mainGrp; + const follow = forceFollow || (mainKey !== exLastMain); + exLastMain = mainKey; + if (follow) { + exSetSel(clsSel, mainCls ? [mainCls] : []); + exFillGroups(mainGrp ? [mainGrp] : []); + } else { + exSetSel(clsSel, keepCls.filter(v => vals.indexOf(v) >= 0)); + exFillGroups(keepGrp); + } + const hint = $('#exScopeHint'); + if (hint) { + hint.textContent = vals.length + ? '不选 = 全部(Ctrl / ⌘ 多选)' + : '名单未填写班级(可到「名单管理」补全)'; + } + } + + /* ---------------- 多考试勾选列表 ---------------- */ + function exNonFolderProjects() { const isFolder = p => (p.mode || '').toLowerCase().includes('folder'); const parentIds = new Set(); state.projects.forEach(p => { @@ -1386,9 +1483,10 @@ state.projects.forEach(p => { if (isFolder(p) || parentIds.has(p.id)) folderSet.add(p.id); }); return state.projects.filter(p => !folderSet.has(p.id)); } - function renderMultiExportModal() { - const body = $('#meBody'); - const list = nonFolderProjects(); + function exRenderExamList() { + const body = $('#exExamBody'); + if (!body) return; + const list = exNonFolderProjects(); body.innerHTML = ''; if (!list.length) { body.innerHTML = '(暂无可导出的考试,请先在右上角刷新问卷列表)'; @@ -1398,54 +1496,323 @@ const tr = document.createElement('tr'); const checked = (p.id === cur) ? ' checked' : ''; tr.innerHTML = - '' + + '' + '' + (i + 1) + '' + '' + esc(p.name) + ''; body.appendChild(tr); }); } - updMeMeta(); + exUpdPickCount(); } - function updMeMeta() { - const n = $$('#meBody .me-chk').filter(c => c.checked).length; - $('#meMeta').textContent = '已选 ' + n + ' 场'; - const all = $$('#meBody .me-chk'); - $('#meCheckAll').checked = all.length > 0 && all.every(c => c.checked); + function exPickedIds() { + return $$('#exExamBody .ex-chk').filter(c => c.checked).map(c => c.value); } - $('#btnExportMulti').addEventListener('click', () => { - if (!state.projects.length) return showAlert('请先刷新问卷列表'); - renderMultiExportModal(); - $('#multiExportModal').classList.add('open'); + function exUpdPickCount() { + const all = $$('#exExamBody .ex-chk'); + const n = all.filter(c => c.checked).length; + const el = $('#exPickCount'); + if (el) el.textContent = '已选 ' + n + ' 场'; + if ($('#exCheckAll')) $('#exCheckAll').checked = all.length > 0 && all.every(c => c.checked); + exSyncSummary(); + } + + /* ---------------- 分区显隐 + 汇总提示 ---------------- */ + function exType() { + const el = document.querySelector('input[name=exType]:checked'); + return (el && el.value) || 'detail'; + } + function exScopeText(o) { + return [ + (o.cls && o.cls.length) ? ('班级 ' + o.cls.join('/')) : '全部班级', + (o.grp && o.grp.length) ? ('组别 ' + o.grp.join('/')) : '全部组别', + ].join(' · '); + } + const EX_DETAIL_HINTS = { + latest: '每人一行(汇总):可用「每人成绩」切换最新 / 最高成绩;可附缺考名单、按班级分工作表。', + all: '每次考试一行(明细):保留每位学生的历史提交;「每人成绩」此时不生效。', + }; + function exSyncSummary() { + const o = exCollect(); + const t = o.type; + // 分区显隐:只有相关区块才露出来,避免满屏选项 + if ($('#exDetailSec')) $('#exDetailSec').hidden = (t !== 'detail' && t !== 'attempts'); + if ($('#exMultiSec')) $('#exMultiSec').hidden = (t !== 'multi'); + // 标记与合格次数:成绩明细 / 每次考试 / 多考试对比都要用(缺考名单不需要) + if ($('#exMarkSec')) $('#exMarkSec').hidden = (t === 'absent'); + // 附缺考名单:三类都会出工作表,缺考名单本身不需要再附 + if ($('#exExtraSec')) $('#exExtraSec').hidden = (t === 'absent'); + if ($('#exPrintSec')) $('#exPrintSec').hidden = false; + // 成绩明细:每次考试一行时「每人成绩」不生效 + const isAll = (o.detailMode === 'all'); + if ($('#exRowScore')) $('#exRowScore').disabled = isAll; + // 「按班级分工作表」只有单考试的成绩明细支持;「附缺考名单」三类都支持 + if ($('#exSplit')) { + $('#exSplit').disabled = (t !== 'detail'); + $('#exSplit').parentElement.style.opacity = (t === 'detail') ? '' : '.45'; + } + if ($('#exDetailHint')) $('#exDetailHint').textContent = EX_DETAIL_HINTS[o.detailMode] || ''; + if ($('#exScopeSec')) $('#exScopeSec').hidden = false; + if ($('#exScopeSum')) $('#exScopeSum').textContent = '· ' + exScopeText(o); + // 打印标题占位随类型变化,让用户知道默认会是什么 + if ($('#exPrintTitle')) { + $('#exPrintTitle').placeholder = (t === 'multi') ? '留空 = 多考试成绩明细' + : (t === 'absent') ? '留空 = 缺考名单' + : (t === 'attempts') ? '留空 = 每次考试成绩明细' + : '留空 = 成绩明细'; + } + // 底部一句话汇总,导出 / 打印前一眼看清会得到什么 + const scope = exScopeText(o); + let line = ''; + if (t === 'detail') { + line = '将导出「成绩明细」· ' + (o.detailMode === 'all' ? '每次考试一行' : '每人一行(' + (o.rowScore === 'best' ? '最高成绩' : '最新成绩') + ')') + + ' · ' + exportSortLabel(o.sort) + (o.split ? ' · 按班级分表' : '') + ' · ' + scope; + } else if (t === 'attempts') { + line = '将导出「每次考试」· 最高分标红' + (o.passRow ? '、合格次数达标标绿' : '') + ' · ' + scope; + } else if (t === 'absent') { + line = '将导出「缺考名单」· ' + scope; + } else { + const n = exPickedIds().length; + const mode = (o.multiMode === 'block') ? '两行表头(每次考试 + 合格次数)' + : (o.multiMode === 'summary') ? '成绩对比(每人一行)' : '两行表头 + 成绩对比'; + line = '将导出「多考试对比」· 已选 ' + n + ' 场 · ' + mode + + ' · 最高分标红' + (o.passRow ? '、合格次数达标标绿' : '') + ' · ' + scope; + } + if ($('#exSummaryLine')) $('#exSummaryLine').textContent = line; + const btn = $('#btnExExport'); + if (btn) btn.disabled = (t === 'multi' && exPickedIds().length === 0); + } + + /** 打开统一导出弹窗 */ + function openExportModal() { + // 清单(班级 / 组别 / 考试列表)每次都重建:名单可能刚导入过 + exFillFilters(true); + exRenderExamList(); + exApply(exOpts); + // 应用后再联动一次组别(班级选择会影响组别候选) + exFillGroups(exOpts.grp || []); + exSyncSummary(); + $('#exportModal').classList.add('open'); + } + + $('#btnExportOpen').addEventListener('click', openExportModal); + + // 弹窗内的任何改动都:记住 + 刷新分区与汇总 + $('#exportModal').addEventListener('change', () => { + exOpts = exCollect(); + exSaveOpts(exOpts); + exSyncSummary(); }); - $('#meCheckAll').addEventListener('change', () => { - $$('#meBody .me-chk').forEach(c => { c.checked = $('#meCheckAll').checked; }); - updMeMeta(); + $('#exClsSel').addEventListener('change', () => { + exFillGroups(exSelVals($('#exGrpSel'))); + exOpts = exCollect(); + exSaveOpts(exOpts); + exSyncSummary(); }); - $('#btnMeSelAll').addEventListener('click', () => { - $$('#meBody .me-chk').forEach(c => { c.checked = true; }); - $('#meCheckAll').checked = true; - updMeMeta(); + $('#exCheckAll').addEventListener('change', () => { + $$('#exExamBody .ex-chk').forEach(c => { c.checked = $('#exCheckAll').checked; }); + exUpdPickCount(); }); - $('#btnMeClear').addEventListener('click', () => { - $$('#meBody .me-chk').forEach(c => { c.checked = false; }); - $('#meCheckAll').checked = false; - updMeMeta(); + $('#btnExPickAll').addEventListener('click', () => { + $$('#exExamBody .ex-chk').forEach(c => { c.checked = true; }); + exUpdPickCount(); }); - $('#meBody').addEventListener('change', e => { - if (e.target && e.target.classList.contains('me-chk')) updMeMeta(); + $('#btnExPickNone').addEventListener('click', () => { + $$('#exExamBody .ex-chk').forEach(c => { c.checked = false; }); + exUpdPickCount(); }); - $('#btnMeExport').addEventListener('click', () => { - const ids = $$('#meBody .me-chk').filter(c => c.checked).map(c => c.value); - if (!ids.length) return showAlert('请至少勾选一场考试'); - const scoreMode = (document.querySelector('input[name=meScoreMode]:checked') || {}).value || 'latest'; - const qs = 'api.php?action=export_multi&projectIds=' + encodeURIComponent(ids.join(',')) + - '&nameQid=' + encodeURIComponent($('#nameFieldSel').value) + - '&scoreMode=' + encodeURIComponent(scoreMode) + '&sort=sid'; - $('#multiExportModal').classList.remove('open'); + $('#exExamBody').addEventListener('change', e => { + if (e.target && e.target.classList.contains('ex-chk')) exUpdPickCount(); + }); + + /* ---------------- 拼查询串(导出与打印共用同一套口径) ---------------- */ + function exBuildQuery(o, isPrint) { + const nameQid = encodeURIComponent($('#nameFieldSel').value); + const cls = encodeURIComponent((o.cls || []).join(',')); + const grp = encodeURIComponent((o.grp || []).join(',')); + const base = 'api.php?'; + + if (o.type === 'absent') { + // 缺考名单:走单考试导出接口的 export_absent(只输出缺考名单一张表) + const pid = $('#projectSel').value; + if (!pid) return null; + return base + 'action=export_absent&projectId=' + encodeURIComponent(pid) + + '&nameQid=' + nameQid + '&cls=' + cls + '&grp=' + grp + + (isPrint ? '&format=json' : ''); + } + if (o.type === 'detail') { + const pid = $('#projectSel').value; + if (!pid) return null; + let q = base + 'action=export&projectId=' + encodeURIComponent(pid) + + '&nameQid=' + nameQid + '&cls=' + cls + '&grp=' + grp + + '&sort=' + o.sort + '&mode=' + (o.detailMode === 'all' ? 'all' : 'latest'); + if (o.detailMode === 'latest' && o.rowScore === 'best') q += '&scoreMode=best'; + if (o.split) q += '&split=cls'; + if (!o.withAbsent) q += '&noAbsent=1'; + // 打印要拿 rows/merges/cell_styles 渲染视图,不能去下载 xlsx + if (isPrint) q += '&format=json'; + return q; + } + // attempts(单场每次考试)与 multi(多考试对比)都走 export_multi + let ids; + if (o.type === 'attempts') { + const pid = $('#projectSel').value; + if (!pid) return null; + ids = [pid]; + } else { + ids = exPickedIds(); + if (!ids.length) return null; + } + const mode = (o.type === 'attempts') ? 'block' : o.multiMode; + let q = base + 'action=export_multi&projectIds=' + encodeURIComponent(ids.join(',')) + + '&nameQid=' + nameQid + '&mode=' + mode + '&sort=sid' + + '&cls=' + cls + '&grp=' + grp + + '&passTimes=' + (o.passTimes || 1) + + (o.markBest ? '' : '&markBest=0') + + (o.passRow ? '' : '&passRow=0'); + // 附缺考名单:与成绩明细同一开关,后端在所选考试全都没交的人里筛 + if (!o.withAbsent) q += '&noAbsent=1'; + if (mode !== 'block') { + q += '&scoreMode=' + encodeURIComponent(o.multiScore); // 仅成绩对比表需要 + } + // 单场「每次考试」且不带耗时 / 提交时间列 → 用宽表版式(截图二) + if (o.type === 'attempts' && ids.length === 1) { + q += '&wide=1'; + } + if (isPrint) q += '&format=json'; + return q; + } + + function exTypeName(o) { + if (o.type === 'absent') return '缺考名单'; + if (o.type === 'attempts') return '「每次考试」表'; + if (o.type === 'multi') return '多考试对比表'; + return '成绩明细'; + } + + /* ---------------- 导出 Excel ---------------- */ + $('#btnExExport').addEventListener('click', () => { + const o = exCollect(); + if (o.type === 'multi' && !exPickedIds().length) return showAlert('请至少勾选一场考试'); + if (o.type !== 'multi' && !$('#projectSel').value) return showAlert('请先选择问卷 / 考试'); + const qs = exBuildQuery(o, false); + if (!qs) return showAlert('请先选择问卷 / 考试'); + $('#exportModal').classList.remove('open'); location.href = qs; - showAlert('正在导出 ' + ids.length + ' 场考试成绩对比(' + (scoreMode === 'highest' ? '最高成绩' : '最新成绩') + ',按学号排序)', 'info'); + showAlert('正在导出' + exTypeName(o) + '(' + exScopeText(o) + ')', 'info'); }); + /* ---------------- 打印 ---------------- + 与导出的 Excel 用同一套服务端装配逻辑(format=json 拿回 rows/merges/cell_styles), + 前端只负责把「合并区间」翻成 colspan / rowspan、把「cell_styles」翻成红字 / 绿底, + 保证打印版式与 Excel 完全一致。 */ + function sheetToHtml(sheet) { + const rows = (sheet && sheet.rows) || []; + const merges = (sheet && sheet.merges) || []; + const styles = (sheet && sheet.cell_styles) || {}; + const hRows = Math.max(1, (sheet && sheet.header_rows) || 1); + const span = {}, covered = {}; + merges.forEach(m => { + const r1 = m[0] - 1, c1 = m[1] - 1, r2 = m[2] - 1, c2 = m[3] - 1; + span[r1 + '|' + c1] = [r2 - r1 + 1, c2 - c1 + 1]; + for (let r = r1; r <= r2; r++) { + for (let c = c1; c <= c2; c++) { + if (r === r1 && c === c1) continue; + covered[r + '|' + c] = 1; + } + } + }); + // 合并区间内的其它格子继承左上角首格的标记(否则绿底只覆盖半格) + const markOf = (r, c) => { + const k = (r + 1) + ',' + (c + 1); + if (styles[k]) return styles[k]; + const m = merges.find(mm => + r >= mm[0] - 1 && r <= mm[2] - 1 && c >= mm[1] - 1 && c <= mm[3] - 1); + return m ? (styles[(m[0]) + ',' + (m[1])] || '') : ''; + }; + let thead = '', tbody = ''; + rows.forEach((row, r) => { + let tds = ''; + for (let c = 0; c < row.length; c++) { + if (covered[r + '|' + c]) continue; // 被合并吃掉的格子不再输出 + const sp = span[r + '|' + c] || [1, 1]; + const txt = (row[c] == null) ? '' : String(row[c]); + const mark = markOf(r, c); + const cls = (mark === 'red') ? ' class="max"' + : (mark === 'green') ? ' class="pass"' + : (txt === '未考') ? ' class="absent"' : ''; + tds += ' 1 ? ' rowspan="' + sp[0] + '"' : '') + + (sp[1] > 1 ? ' colspan="' + sp[1] + '"' : '') + + cls + '>' + esc(txt) + ''; + } + const tr = '' + tds + ''; + if (r < hRows) thead += tr; else tbody += tr; + }); + // 表头放进 :多页打印时每页自动重复 + return '' + thead + '' + tbody + '
'; + } + + async function exPrint() { + const o = exCollect(); + if (o.type === 'multi' && !exPickedIds().length) return showAlert('请至少勾选一场考试'); + const qs = exBuildQuery(o, true); + if (!qs) return showAlert('请先选择问卷 / 考试'); + const btn = $('#btnExPrint'); + const oldText = btn.textContent; + btn.disabled = true; + btn.textContent = '准备中…'; + try { + const res = await fetch(qs, { credentials: 'same-origin' }); + const d = await res.json(); + if (!d || !d.ok) throw new Error((d && d.msg) || '获取打印数据失败'); + const data = d.data || {}; + const sheets = data.sheets || []; + const meta = data.meta || {}; + if (!sheets.length) throw new Error('没有可打印的数据'); + const title = (o.printTitle || '') || + (o.type === 'multi' ? '多考试成绩明细' + : (o.type === 'attempts' ? (state.projectName ? state.projectName + ' 成绩明细' : '成绩明细') + : (o.type === 'absent' ? (state.projectName ? state.projectName + ' 缺考名单' : '缺考名单') + : (state.projectName ? state.projectName + ' 成绩明细' : '成绩明细')))); + const markNote = o.type === 'detail' && o.detailMode !== 'all' && meta.passLine + ? '(成绩 ≥ ' + meta.passLine + ' 标绿)' + : (meta.pass_row ? ('合格次数 ≥ ' + (meta.pass_times || 1) + ' 次达标(标绿)') : ''); + let body = '

' + esc(title) + '

' + + (o.type === 'multi' + ? '
' + esc((meta.examNames || []).join(' · ')) + '
' + : '') + + '
' + esc([ + o.type === 'multi' ? ('考试 ' + (meta.examCount || (meta.examNames || []).length) + ' 场') : '', + '范围:' + exScopeText(o), + markNote, + '打印时间:' + new Date().toLocaleString(), + ].filter(Boolean).join(' | ')) + '
'; + // 多张表(如成绩明细 + 附缺考名单)逐张出小标题再接表格。 + // 「每次考试」主表跟随大标题,不再重复写「每次考试」;缺考名单另起一页。 + sheets.forEach((sh, i) => { + const isMainAttempt = (o.type === 'attempts' && i === 0); + const showGrp = (sheets.length > 1 || isMainAttempt) && sh.name !== '每次考试'; + if (showGrp) { + const pb = (sh.name === '缺考名单') ? ' page-break' : ''; + body += '

' + esc(sh.name) + '

'; + } + body += sheetToHtml(sh); + }); + printDoc(title, body, { orient: o.orient }); + $('#exportModal').classList.remove('open'); + showAlert('已调起打印(' + (sheets.length > 1 ? sheets.length + ' 张表 · ' : '') + + (o.orient === 'portrait' ? '纵向' : '横向') + + '),如未弹出请在打印对话框中选择正确打印机', 'info'); + } catch (err) { + showAlert(err && err.message ? err.message : '打印失败', 'error'); + } finally { + btn.disabled = false; + btn.textContent = oldText; + } + } + $('#btnExPrint').addEventListener('click', exPrint); + + /* 弹窗关闭:只关最上层那一个(新增学生等弹窗是叠在名单管理之上的, 关全部会连带把底层弹窗一起关掉,不符合预期) */ function closeTopModal() { @@ -1744,6 +2111,9 @@ const css = '@page{size:A4 ' + orient + ';margin:14mm 10mm}' + 'html,body{margin:0;padding:0}' + + /* 底纹能不能印出来全看这一句:浏览器默认不打印背景色(打印对话框里的 + "背景图形" 多数人是关着的),print-color-adjust:exact 才是让它生效的开关。 */ + 'html,body,.pass,.absent,.max{-webkit-print-color-adjust:exact;print-color-adjust:exact}' + 'body{font-family:"Microsoft YaHei","微软雅黑",sans-serif;color:#000;font-size:12px;line-height:1.5}' + 'h2{margin:0 0 6px;font-size:17px;text-align:center}' + '.sub{text-align:center;font-size:12px;color:#444;margin-bottom:4px}' + @@ -1756,10 +2126,20 @@ 'td.c,th.c{text-align:center}' + 'td.score{font-weight:700}' + 'td.fail{color:#b00}' + + // 多考试 / 每次考试表(两行表头 + 合并单元格):居中;未考标灰、 + // 最高分红字、合格次数达标绿底(与导出的 Excel 完全同源) + 'table.bt{font-size:11px}' + + 'table.bt td,table.bt th{text-align:center;padding:3px 5px}' + + 'table.bt td.absent{color:#888}' + + 'table.bt td.max{color:#d0021b;font-weight:700}' + + 'table.bt td.pass{background:#63c384;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}' + + // 表头渲染出来是 (不是 ),所以底色要写 thead td,否则两行表头是白的 + 'table.bt th,table.bt thead td{background:#eaeaea;font-weight:700}' + + 'table.bt thead tr:first-child td{background:#eaeaea}' + 'h3.grp{font-size:13px;margin:14px 0 5px;padding:5px 8px;background:#f2f2f2;border-left:4px solid #333;page-break-after:avoid}' + 'h3.grp small{font-weight:400;color:#555}' + - '.sign{margin-top:16px;font-size:12px;color:#333;page-break-inside:avoid}' + - '.foot{margin-top:8px;font-size:10px;color:#777;text-align:center}'; + // 缺考名单等附加工作表另起一页,避免和成绩表挤在同一页底部 + 'h3.grp.page-break{page-break-before:always}'; const html = '' + '' + esc(title) + '' + bodyHtml + ''; @@ -1831,9 +2211,16 @@ function printTable() { const scope = $('#ptScope').value; + const rowScore = ($('#ptRowScore') && $('#ptRowScore').value === 'best') ? 'best' : 'latest'; let rows = (scope === 'all') ? expandAttemptsForPrint(state._sorted || [], true) : (state._sorted || []).slice(); + // 每人一行时可按「最高成绩」打印:用 best_score 顶替代表成绩 + if (scope !== 'all' && rowScore === 'best') { + rows = rows.map(r => Object.assign({}, r, { + score: (r.best_score == null || r.best_score === '') ? null : Number(r.best_score), + })); + } if (!rows.length) return showAlert('当前没有可打印的成绩', 'warn'); const sortMode = $('#ptSort').value; @@ -1944,8 +2331,6 @@ } } - body += '
教师签字:____________  教研组长:____________  日期:____ 年 ____ 月 ____ 日
'; - printDoc(title, body, { orient: orient }); showAlert('已调起打印,如未弹出请在打印对话框中选择正确打印机', 'info'); } @@ -2077,10 +2462,7 @@ const meta = '

' + esc(title) + '

' + (proj ? '
' + esc(proj) + '
' : '') + '
' + esc(printMetaLine(['共 ' + list.length + ' 人'])) + '
'; - const sign = '
教师签字:____________  日期:____ 年 ____ 月 ____ 日
' + - '
' + esc(NY.appName || '') + ' · 缺考名单,请核对后签字确认
'; - - printDoc(title, meta + body + sign, { orient: 'portrait' }); + printDoc(title, meta + body, { orient: 'portrait' }); showAlert('已调起打印,如未弹出请在打印对话框中选择正确打印机', 'info'); }); diff --git a/assets/style.css b/assets/style.css index 799bc5c..06442e4 100644 --- a/assets/style.css +++ b/assets/style.css @@ -431,7 +431,76 @@ table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; } /* 弹窗内的表格不做吸顶:弹窗自身是滚动容器,吸顶表头会与内容错层(且贴边观感差) */ .modal-body table.grid th { position: static; } .modal-body table.grid { box-shadow: inset 0 0 0 1px var(--border-2); border-radius: 8px; } -/* 多考试对比导出:顶部工具条 */ +/* ================= 统一导出 / 打印弹窗 ================= */ +.ex-sec { + padding: 12px 14px; margin: 0 0 12px; + border: 1px solid var(--border); border-radius: 10px; background: #fbfcfe; +} +.ex-sec[hidden] { display: none; } +.ex-sec-title { + font-size: 12px; font-weight: 700; color: var(--text-2); + letter-spacing: .3px; margin: 0 0 10px; + display: flex; align-items: center; gap: 8px; +} +.ex-sec-title small { font-weight: 500; color: var(--text-3, #8b93a1); } + +/* 导出内容:2×2 卡片单选,一眼看清四种导出各是什么 */ +.ex-types { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.ex-type { + display: flex; align-items: flex-start; gap: 9px; padding: 10px 12px; + border: 1px solid var(--border); border-radius: 9px; background: #fff; + cursor: pointer; transition: border-color .15s, box-shadow .15s, background .15s; +} +.ex-type:hover { border-color: #b9c2d0; } +.ex-type:has(input:checked) { + border-color: var(--primary, #2f6fed); background: #f3f7ff; + box-shadow: 0 0 0 1px var(--primary, #2f6fed) inset; +} +.ex-type input { margin-top: 3px; flex: none; } +.ex-type-t { display: flex; flex-direction: column; gap: 3px; min-width: 0; } +.ex-type-t b { font-size: 13px; color: var(--text); } +.ex-type-t small { font-size: 11.5px; color: var(--text-3, #8b93a1); line-height: 1.45; } + +/* 字段行 */ +.ex-row { display: flex; flex-wrap: wrap; gap: 14px 18px; align-items: flex-start; } +.ex-field { + display: flex; flex-direction: column; gap: 6px; min-width: 150px; + font-size: 12px; font-weight: 600; color: var(--text-2); +} +.ex-field select, .ex-field input[type=text], .ex-field input[type=number] { + font-size: 13px; font-weight: 400; color: var(--text); +} +.ex-field-tip { + min-width: 200px; flex: 1 1 240px; font-weight: 400; + display: flex; flex-direction: column; gap: 4px; justify-content: flex-end; +} +.ex-field-grow { flex: 1 1 240px; } +.ex-field-grow input { width: 100%; } + +/* 班级 / 组别多选:竖排标签 + 列表,便于 Ctrl 多选 */ +#exClsSel, #exGrpSel { + width: 168px; height: 96px; padding: 4px 6px; font-size: 12px; + border: 1px solid var(--border); border-radius: 8px; + background: #fff; color: var(--text); outline: none; +} +#exGrpSel { width: 132px; } + +.ex-checks { + display: flex; flex-wrap: wrap; gap: 8px 20px; margin-top: 12px; + font-size: 12.5px; color: var(--text); +} +.ex-checks label { display: flex; align-items: center; gap: 6px; cursor: pointer; } + +/* 多考试:勾选列表 */ +.ex-pick { margin-top: 12px; } +.ex-pick-head { + display: flex; align-items: center; gap: 10px; margin: 0 0 8px; + font-size: 12.5px; font-weight: 600; color: var(--text-2); +} +.ex-pick-head small { font-weight: 500; color: var(--text-3, #8b93a1); } +.table-scroll.mid { max-height: 208px; overflow: auto; } + +/* 合规的旧类名保留(其它地方仍在用 .me-bar 之类的排版习惯) */ .me-bar { display: flex; align-items: center; gap: 16px; margin: 0 0 10px; } .me-bar .chk { margin: 0; white-space: nowrap; } .modal-foot { @@ -626,6 +695,51 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; } .dash-controls .inline { color: #fff; } .dash-controls .btn { background: rgba(255, 255, 255, .14); border-color: transparent; color: #fff; } .dash-controls .btn:hover { background: rgba(255, 255, 255, .24); } +/* 单班大屏:顶栏只保留「试卷 / 自动刷新 / 全屏」,班级选择与「← 主系统」隐藏 + (退出按钮本形态下不再渲染,这里不再靠 CSS 藏) */ +body.dash-class #classSelWrap, +body.dash-class #linkIndex { display: none !important; } +/* 公开(免登录)班级大屏的角标 */ +.pub-tag { + display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 12px; + background: rgba(255, 255, 255, .16); border: 1px solid rgba(255, 255, 255, .38); color: #fff; +} +/* 首页汇总卡(5 卡)在该形态下无意义 */ +body.dash-home #boardSummary { display: none; } +.dash-hero .brand-cls { + display: inline-block; margin-left: 2px; padding: 2px 10px; border-radius: 999px; + background: rgba(255, 255, 255, .18); font-size: 13px; font-weight: 600; +} + +/* ---------- 首页:班级入口卡片 ---------- */ +.cls-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(248px, 1fr)); + gap: 14px; margin-top: 12px; +} +.cls-card { + min-width: 0; cursor: pointer; user-select: none; + background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); + padding: 14px 16px 12px; box-shadow: var(--shadow); + transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease; +} +.cls-card:hover { transform: translateY(-3px); border-color: var(--primary); box-shadow: var(--shadow-lg); } +.cls-card:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } +.cls-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.cls-name { font-size: 16px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cls-go { flex: none; font-size: 12px; font-weight: 700; color: var(--primary); } +.cls-kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 8px; margin-top: 10px; } +.cls-kpi { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.cls-kpi span { font-size: 11px; color: var(--text-3); } +.cls-kpi b { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; } +.cls-kpi.k-ok b { color: var(--ok); } +.cls-kpi.k-warn b { color: var(--warn); } +.cls-bar { height: 8px; border-radius: 999px; background: var(--border-2); overflow: hidden; margin-top: 12px; } +.cls-bar > i { display: block; height: 100%; border-radius: 999px; transition: width .5s ease; } +.cls-sub { + margin-top: 7px; font-size: 11.5px; color: var(--text-3); + display: flex; justify-content: space-between; gap: 8px; +} +@media (max-width: 860px) { .cls-grid { grid-template-columns: 1fr; } } .sec-title { margin: 22px 0 4px; font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; } .sec-title .tag-gray { background: var(--border-2); color: var(--text-3); padding: 1px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; } @@ -692,6 +806,9 @@ body.dash.fs-on .card b { font-size: 20px; } body.dash.fs-on .group-compare { gap: 12px; } body.dash.fs-on .gpanel { padding: 12px 14px; } body.dash.fs-on .member-list { max-height: none; } +body.dash.fs-on .cls-grid { grid-template-columns: repeat(auto-fill, minmax(212px, 1fr)); gap: 10px; } +body.dash.fs-on .cls-card { padding: 10px 12px; } +body.dash.fs-on .cls-kpi b { font-size: 15px; } /* ================= 随机抽取页 ================= */ diff --git a/dashboard.php b/dashboard.php index c1ae78c..67364fd 100644 --- a/dashboard.php +++ b/dashboard.php @@ -1,35 +1,51 @@ &class=<班级名> 分享与刷新恢复;缺省自动选第一个班级。 + * 对外数据大屏(两种形态,由 URL 决定): + * 1) 无 ?class= → 大屏首页:先选试卷/考试,再以卡片网格列出该卷的班级,点击进入该班大屏; + * 2) 有 ?class=<班级名> → 该班独立大屏:只有试卷选择 / 自动刷新开关 / 全屏, + * 不出现班级选择、「← 主系统」「退出」,并且屏蔽浏览器后退键(退不回首页)。 + * URL 仍支持 ?pid= 与 &auto= 便于分享与刷新恢复。 */ declare(strict_types=1); require __DIR__ . '/bootstrap.php'; -if (!ny_is_auth()) { +$pidParam = trim((string)($_GET['pid'] ?? '')); +$clsParam = trim((string)($_GET['class'] ?? '')); +$isClass = ($clsParam !== ''); + +/* 公开访问:带 ?class= 的班级大屏不需要登录。该班数据只暴露该班本身—— + 接口侧会先按班级裁剪再回传,拿不到全卷与其它班级的成绩。 + 大屏首页仍要求登录——它列出的是某卷下所有班级的入口。 */ +$publicView = $isClass && !ny_is_auth(); +if (!$isClass && !ny_is_auth()) { header('Location: login.php'); exit; } +$pageTitle = $isClass ? ($clsParam . ' · 班级考试大屏') : '数据大屏 · 选择班级'; ?> -班级看板 · <?= htmlspecialchars(NY_APP_NAME) ?> +<?= htmlspecialchars($pageTitle) ?> · <?= htmlspecialchars(NY_APP_NAME) ?> - + +

NY问卷考试系统数据平台

+ + +
- + - ← 主系统 - 退出 + ← 主系统 + + 退出 +
选择一个问卷与班级查看考试整体情况 + 公开 · 无需登录 最后更新:—
- +
- +
总人数-
已提交-
@@ -62,7 +81,7 @@ if (!ny_is_auth()) {
合格率(≥60%满分)-
-
分组对比 / 考试成员
+
分组对比 / 考试成员
请选择问卷与班级查看考试整体情况
@@ -93,6 +112,26 @@ function rateColor(r){ return 'var(--danger)'; } +/* 成绩红绿:一律按管理员在后台设的本卷「满分 + 合格率」判定,取接口下发的 DASH。 + ⚠️ DASH 是 let 声明的,不会挂到 window 上——写成 window.DASH 永远拿到 undefined, + 结果就是悄悄回退成 100 分 / 60% 的出厂默认值,后台改了合格率也不生效。 */ +function scoreGrade(sc){ + const d = (typeof DASH !== 'undefined') ? DASH : null; + const full = (d && d.full_score) ? Number(d.full_score) : 100; + const pass = (d && d.pass_rate != null) ? Number(d.pass_rate) : 60; + if (!(full > 0) || sc == null || sc === '') return { cls: '', pct: null, pass: pass, line: 0 }; + const pct = Number(sc) / full * 100; + if (!isFinite(pct)) return { cls: '', pct: null, pass: pass, line: 0 }; + // 合格线本身高过 90% 时绿线跟着抬上去,免得"合格了却显示成黄色" + const hi = Math.max(90, pass); + return { + cls: pct >= hi ? 'score-high' : (pct >= pass ? 'score-mid' : 'score-low'), + pct: pct, + pass: pass, + line: Math.round(full * pass / 100 * 10) / 10, // 合格线折算成分数, tooltip 用 + }; +} + /* FLIP 行移动画:新数据行滑到新排名、其它行顺势下移(尊重 prefers-reduced-motion) */ const REDUCED_MOTION = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); function captureFlip(container){ @@ -137,7 +176,10 @@ function writeUrl({pid, cls, auto}){ if (pid !== undefined) u.searchParams.set('pid', pid); if (cls !== undefined) u.searchParams.set('class', cls); if (auto !== undefined) u.searchParams.set('auto', auto ? '1' : '0'); - history.replaceState(null, '', u.pathname + (u.search ? u.search : '')); + // 注意:replaceState 不传 state 会把它清成 null,把后退锁的标志位一起抹掉, + // 所以有锁时原样写回(即便如此后退仍会被 popstate 兜住,这里是顺手保持一致) + const st = (history.state && history.state.nyLock) ? { nyLock: 1 } : null; + history.replaceState(st, '', u.pathname + (u.search ? u.search : '')); } /* 本地缓存 */ @@ -153,6 +195,11 @@ const LS_DASH_INTERVAL= 'ny_dash_interval'; let DASH = null; let CUR = readUrl(); +/* 形态:URL 带 class = 单班大屏(不显示班级选择、锁后退);否则是「首页 → 选班级进入」 */ +const IS_CLASS = !!CUR.cls; +/* 当前班级:单班页取自 URL,首页取自下拉 */ +function currentClass(){ return IS_CLASS ? (CUR.cls || '') : ($('#classSel') ? $('#classSel').value : ''); } + /* ---------- API ---------- */ async function api(action, params){ let url = 'api.php?action=' + encodeURIComponent(action); @@ -260,7 +307,7 @@ async function refreshClassSel(){ } // 项目数据来自 api (dashboard),先调用一次拿班级 try { - const data = await api('dashboard', { projectId: pid }); + const data = await api('dashboard', dashParams({ projectId: pid })); DASH = data; // 合格率阈值可配(全局或本卷独立),卡片标题实时反映 const bl = document.getElementById('bPassLabel'); @@ -270,10 +317,17 @@ async function refreshClassSel(){ ? classes.map(c => '').join('') : ''; sel.disabled = !classes.length; - // 默认 class:URL 指定 > 上次选择 > 第一个 - if (CUR.cls && classes.includes(CUR.cls)) { + if (IS_CLASS) { + /* 单班大屏:班级由 URL 决定,下拉只是隐藏的镜像。 + 换卷后该班若不在新卷名单里 → 落到第一个班并把 URL 改对(不新增历史,避免影响后退锁)。 */ + if (classes.length) { + if (!classes.includes(CUR.cls)) { CUR.cls = classes[0]; writeUrl({ cls: CUR.cls }); } + sel.value = CUR.cls; + } + } else if (CUR.cls && classes.includes(CUR.cls)) { sel.value = CUR.cls; } else { + // 默认 class:上次选择 > 第一个 const last = LS.get('ny_board_last_cls_' + pid, ''); if (last && classes.includes(last)) sel.value = last; else if (classes.length) sel.value = classes[0]; @@ -307,15 +361,10 @@ function membersTable(members){ const rk = (m.rank == null ? '' : m.rank); const badge = rk === 1 ? 'rk rk-1' : (rk === 2 ? 'rk rk-2' : (rk === 3 ? 'rk rk-3' : 'rk')); const sc = m.score; - // 同样按「占满分的百分比」着色,满分不是 100 时不会整列误标红 - const cls = (sc == null) ? '' : (function () { - const full = (window.DASH && DASH.full_score) ? Number(DASH.full_score) : 100; - if (!(full > 0)) return ''; - const pct = Number(sc) / full * 100; - if (!isFinite(pct)) return ''; - const pass = (window.DASH && DASH.pass_rate != null) ? Number(DASH.pass_rate) : 60; - return pct >= 90 ? 'score-high' : (pct >= pass ? 'score-mid' : 'score-low'); - })(); + // 红绿由后台配置的满分成数 / 合格率决定(见 scoreGrade) + const g = scoreGrade(sc); + const cls = g.cls; + const tip = g.cls ? ' title="占满分 ' + g.pct.toFixed(1) + '% 合格线 ' + g.line + ' 分"' : ''; const att = (m.attempts || 1) > 1; const tag = att ? ' 刷' + m.attempts + '次' : ''; // 注意:不要把 sid 直接拼进 onclick="...",JSON.stringify 产生的双引号会提前闭合 @@ -323,7 +372,7 @@ function membersTable(members){ return '' + '' + rk + '' + '' + esc(m.name) + tag + '' + - '' + (sc == null ? '-' : sc) + '' + + '' + (sc == null ? '-' : sc) + '' + ''; }).join('') + '
'; @@ -335,11 +384,13 @@ function openAttempts(sid){ if (!info) return; const list = info.list || []; const rows = list.map(a => { - const sc = a.score == null ? '未判分' : esc(a.score); + // 下钻弹窗里的成绩用同一套红绿口径,避免"列表里是绿的、点开却是白的" + const g = scoreGrade(a.score); const tmp = a.temp_save ? ' 暂存' : ''; + const sc = a.score == null ? '未判分' : esc(a.score); return '' + '' + esc(a.submit_at) + '' + - '' + sc + tmp + '' + + '' + sc + tmp + '' + '' + esc(a.duration_txt || '-') + '' + ''; }).join(''); @@ -374,13 +425,85 @@ document.addEventListener('keydown', e => { if (open.length) open[open.length - 1].classList.remove('open'); }); +/* ---------- 首页:班级入口卡片网格 ---------- */ +function clsCardHtml(c){ + const rate = Number(c.rate || 0); + const total = dv(c.total, 0), sub = dv(c.submitted, 0), abs = dv(c.absent, 0); + const pr = Number(c.pass_rate == null ? 0 : c.pass_rate); + const kpi = (label, val, kc) => + '
' + label + '' + val + '
'; + return '
' + + '
' + esc(c.class) + '' + + '进入 ›
' + + '
' + + kpi('总人数', total) + + kpi('已提交', sub, 'k-ok') + + kpi('缺考', abs, abs > 0 ? 'k-warn' : '') + + kpi('平均分', dv(c.avg)) + + kpi('合格率', pv(pr), 'k-ok') + + '
' + + '
' + + '
提交率 ' + rate + '%' + + (c.attempts > c.submitted ? '共 ' + dv(c.attempts, 0) + ' 次刷题' : '') + + '
' + + '
'; +} + +/** 首页渲染:把该卷的班级列成卡片,点击进入该班独立大屏(带 pid/auto 便于回到同一状态) */ +function renderClassCards(){ + const pid = $('#projectSel').value; + if (!pid || !DASH) { renderPlaceholderBoard('请先选择一份问卷 / 考试'); return; } + const list = DASH.classes || []; + const bt = document.getElementById('boardTitle'); + if (bt) bt.innerHTML = '班级入口 点击卡片进入该班独立大屏'; + const box = $('#boardBody'); + if (!list.length) { + box.innerHTML = '
该问卷没有解析出班级,请检查名单的「班级」列
'; + return; + } + box.innerHTML = '
' + list.map(clsCardHtml).join('') + '
'; + const _hs = document.getElementById('heroSub'); + if (_hs) _hs.textContent = '共 ' + list.length + ' 个班级 · 点击卡片进入该班大屏'; + const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); } +} + +/** 进入某班独立大屏:整页跳转(不是前端路由,浏览器后退键会被下面的锁拦掉) */ +function enterClass(cls){ + const pid = $('#projectSel').value; + if (!pid || !cls) return; + const u = new URL(location.href); + u.searchParams.set('pid', pid); + u.searchParams.set('class', cls); + u.searchParams.set('auto', $('#autoRefresh').checked ? '1' : '0'); + location.href = u.pathname + u.search; +} +// 卡片点击 / 回车进入(事件委托:卡片网格每次整体重绘,绑在常驻的 #boardBody 上) +(function bindClsCards(){ + const box = document.querySelector('#boardBody'); + if (!box) return; + const hit = e => (e.target && e.target.closest ? e.target.closest('.cls-card') : null); + box.addEventListener('click', e => { + const card = hit(e); + if (card) enterClass(card.getAttribute('data-cls') || ''); + }); + box.addEventListener('keydown', e => { + if (e.key !== 'Enter' && e.key !== ' ') return; + const card = hit(e); + if (!card) return; + e.preventDefault(); + enterClass(card.getAttribute('data-cls') || ''); + }); +})(); + /* ---------- 看板占位(未选问卷/班级) ---------- */ /* 占位卡片配色:与真实看板卡片(renderBoard)一一对应,保证空态左侧色条一致 */ const PLACEHOLDER_ACCENT = { '总人数': 'accent-blue', '已提交': 'accent-green', '缺考': 'accent-orange', '平均分': 'accent-teal', '合格率': 'accent-indigo' }; -function renderPlaceholderBoard(){ +function renderPlaceholderBoard(msg){ + const m = msg || '选择一个问卷与班级查看考试整体情况'; const cards = [ ['总人数','-'],['已提交','-'],['缺考','-'], ['平均分','-'],['合格率','-'] @@ -388,10 +511,13 @@ function renderPlaceholderBoard(){ $('#boardSummary').innerHTML = cards.map(c => // 占位卡片与真实看板卡片顺序、配色一致(accent-* 决定左侧色条),避免空态蓝条与真实卡片不一致 '
' + c[0] + '' + c[1] + '
').join(''); - $('#boardBody').innerHTML = '
请选择问卷与班级查看考试整体情况
'; + $('#boardBody').innerHTML = '
' + esc(m) + '
'; + const bt = document.getElementById('boardTitle'); + if (bt) bt.innerHTML = IS_CLASS ? '分组对比 / 考试成员' + : '班级入口 选好试卷后显示可进入的班级'; // 注意:heroTitle 已随导航栏精简移除,只保留 heroSub(承载当前问卷/班级提示) const _hs = document.getElementById('heroSub'); - if (_hs) _hs.textContent = '选择一个问卷与班级查看考试整体情况'; + if (_hs) _hs.textContent = m; const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); } } @@ -422,8 +548,11 @@ function dv(v, dash){ return (v === undefined || v === null || v === '' || Numbe function pv(v){ return dv(v, '-') + (v === undefined || v === null || v === '' ? '' : '%'); } function renderBoard(){ + // 首页形态:不出单班看板,改为渲染班级入口卡片(boardSummary 由 CSS 隐藏) + if (!IS_CLASS) { renderClassCards(); return; } + const pid = $('#projectSel').value; - const cls = $('#classSel').value; + const cls = currentClass(); CUR.pid = pid; CUR.cls = cls; writeUrl({ pid, cls }); @@ -442,6 +571,8 @@ function renderBoard(){ // 顶部提示:当前班级 / 问卷(heroTitle 已移除,只更新 heroSub) const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || ''; + const _hc = document.getElementById('heroClass'); + if (_hc) _hc.textContent = cls + ' · 该班独立大屏'; // 构建"学号 -> 多次刷题明细"映射(班级级 members 已含全班所有人的 attempts_list) const MAP = {}; @@ -535,6 +666,15 @@ function schedule(){ const sec = parseInt($('#intervalSel').value, 10) || 15; timer = setInterval(refreshBoard, sec * 1000); } +/* 取数参数:公开访问时后端按 class 裁剪并返回单班结果,取数必须带班级; + 已登录时这个参数被忽略,留着不影响。 */ +function dashParams(extra){ + const p = {}; + for (const k in (extra || {})) p[k] = extra[k]; + p.projectId = p.projectId || $('#projectSel').value || CUR.pid || ''; + if (IS_CLASS && CUR.cls) p.class = CUR.cls; + return p; +} async function refreshBoard(manual){ const pid = $('#projectSel').value; if (!pid) return; @@ -543,7 +683,7 @@ async function refreshBoard(manual){ const btn = $('#btnRefresh'); if (manual && btn) { btn.disabled = true; btn.textContent = '刷新中…'; } try { - const fresh = await api('dashboard', { projectId: pid }); + const fresh = await api('dashboard', dashParams({ projectId: pid })); DASH = fresh; // 项目集合可能换:刷新班级下拉但保留当前选择 const sel = $('#classSel'); @@ -571,6 +711,23 @@ async function refreshBoard(manual){ } } +/* ---------- 后退锁定:单班大屏不允许退回首屏 / 主系统 ---------- + * 做法:加载后立刻 pushState 一条同页历史,之后每次 popstate(无论用户按后退还是前进) + * 都再推回一条,页面永远留在当前 URL 上——浏览器后退键因此无效。 + * 只锁 IS_CLASS 形态:首页仍可正常后退(例如退回登录前的主系统页)。 */ +let historyLocked = false; +function lockHistory(){ + if (!IS_CLASS || historyLocked) return; + historyLocked = true; + const push = () => { try { history.pushState({ nyLock: 1 }, '', location.href); } catch (e) {} }; + push(); + window.addEventListener('popstate', function onPop(e){ + // 落回自己推入的同页条目时不再重复占位,避免连按后退无限堆积历史 + if (e.state && e.state.nyLock) return; + push(); + }); +} + /* ---------- 事件 ---------- */ $('#projectSel').addEventListener('change', () => { CUR.pid = $('#projectSel').value; CUR.cls = ''; refreshClassSel(); schedule(); }); $('#classSel').addEventListener('change', () => { CUR.cls = $('#classSel').value; renderBoard(); }); @@ -623,10 +780,20 @@ loadProjects().then(() => { if (CUR.pid) { $('#projectSel').value = CUR.pid; refreshClassSel().then(() => { renderBoard(); schedule(); }); + } else if (IS_CLASS && $('#projectSel').value) { + // 公开链接可能不带 pid:自动落到该卷第一份考试,别让访客对着空页面 + CUR.pid = $('#projectSel').value; + refreshClassSel().then(() => { renderBoard(); schedule(); }); } else { refreshClassSel(); } + // 首页文案指向"选考试 → 进班级",避免顶栏停在旧的单班提示上 + if (!IS_CLASS) { + const _hs = document.getElementById('heroSub'); + if (_hs) _hs.textContent = '先选择一份问卷 / 考试,再点击班级卡片进入该班大屏'; + } }); +lockHistory(); // auto 参数 if (CUR.auto === '0') $('#autoRefresh').checked = false; diff --git a/docs/dev/api.md b/docs/dev/api.md index 34203da..c510b88 100644 --- a/docs/dev/api.md +++ b/docs/dev/api.md @@ -39,7 +39,7 @@ | --- | --- | --- | --- | | `projects` | GET | — | 卷王项目(问卷 / 考试)列表 | | `data` | GET | `projectId`、`nameQid`、`force` | 主系统数据:统计、成绩行、名单、缺考、未匹配 | -| `dashboard` | GET | `projectId`、`nameQid`、`force` | 按班级 / 组别聚合的看板数据 | +| `dashboard` | GET | `projectId`、`nameQid`、`force`、`class` | 按班级 / 组别聚合的看板数据。`class` 非空且未登录时,服务端只回这一个班(并标记 `public`) | `data` 响应关键字段: @@ -75,12 +75,49 @@ _cache { cached, at } | Action | 方法 | 参数 | 说明 | | --- | --- | --- | --- | -| `export` | GET | `projectId`、`cls`、`grp`、`mode=latest`、`split=cls`、`sort`、`cols` | 成绩明细 `.xlsx` | -| `export_absent` | GET | 同上 | 缺考名单 `.xlsx` | +| `export` | GET | `projectId`、`nameQid`、`cls`、`grp`、`mode=latest\|all`、`scoreMode=latest\|best`、`split=cls`、`sort`、`cols`、`skipTemp=1`、`noAbsent=1` | 成绩明细 `.xlsx`(`all` 时每人每次考试一行) | +| `export_absent` | GET | `projectId`、`nameQid`、`cls`、`grp` | 缺考名单 `.xlsx` | +| `export_multi` | GET | `projectIds`(逗号分隔)、`nameQid`、`mode=block\|summary\|both`、`wide=1`、`passTimes`、`markBest=0`、`passRow=0`、`scoreMode=latest\|highest`、`cls` / `grp`(可多值)、`withCls` / `withGrp`、`includeTemp`、`format=json` | 「每次考试」块表 / 宽表 / 成绩对比 `.xlsx`(`format=json` 时返回装配结果供前端打印) | | `draw_export` | GET | `type=import\|list` | 白名单导入模板 / 白名单列表 | `sort` 取值:`time` / `sid` / `score_desc` / `score_asc`。 +### 导出新参数 + +| 参数 | 适用接口 | 取值 | 说明 | +| --- | --- | --- | --- | +| `mode=all` | `export` | 缺省 | 每次考试一行(同一学号考 N 次就有 N 行);`mode=latest` 为每人一行最新成绩 | +| `scoreMode=best` | `export` | 缺省 | 每人一行的代表成绩改取**最高成绩**(默认 `latest` = 最近一次真实提交);`mode=all` 不生效 | +| `noAbsent=1` | `export`、`export_multi` | 缺省 | 不附缺考名单工作表(`export_multi` 口径:所选考试全都没交的人) | +| `skipTemp=1` | `export` | 缺省 | `mode=all` 时排除暂存草稿;`includeTemp=1` 可强制保留 | +| `mode=block` | `export_multi` | **默认** | 两行表头的「每次考试」块表:一列「第几次」被所有考试共用,每场考试占 2 列(考试名横跨 2 列 + 第二行「成绩」小标题),学生块末尾一行「合格次数」,学号·姓名纵向合并,没考的考试合并成「未考」(旧值 `merged` / `attempts` 等价) | +| `wide=1` | `export_multi` | — | 只传 1 个 `projectId` 时改用宽表:每人一行、第几次直接做列名(`学号 / 姓名 / 第1次…第N次 / [合格次数]`),由 `ny_assemble_attempt_wide_sheet()` 生成 | +| `passTimes` | `export_multi` | 1 | 「合格次数」达标阈值:该场达到合格线的次数 ≥ 此值才标绿(默认 1) | +| `markBest=0` | `export_multi` | 开 | `=0` 取消「最高分标红」 | +| `passRow=0` | `export_multi` | 开 | `=0` 不输出「合格次数」行(也就没有绿底) | +| `mode=summary` | `export_multi` | — | 成绩对比:一行一个学生、一列一场考试(旧值 `matrix` 等价) | +| `mode=both` | `export_multi` | — | 第一张块表 + 第二张成绩对比表 | +| `format=json` | `export_multi` | — | 不下载文件,`ny_ok` 返回 `sheets`(`rows` / `merges` / `widths` / `header_rows` / `cell_styles`)+ `meta`(考试名、场次、班级 / 组别、列开关、`pass_line` / `pass_times` / `mark_best` / `pass_row` / `wide`),供「多考试打印」渲染 | +| `cls` / `grp` | `export_multi` | 新增 | 支持多选(逗号分隔),空 = 全部;与单考试 `export` 的筛选口径一致 | +| `withCls` / `withGrp` | `export_multi` | 新增 | 强制出 / 不出班级、组别列;缺省由 `ny_multi_col_flags()` 判定:选 ≥2 个班级(组别)才出,只选 1 个班级且未选组别两列都不出 | +| `includeTemp=1` | `export_multi` | 缺省 | 不传 = 块表默认跳过暂存草稿(保留时标注 `第N次(草稿)`) | + +块表由 `ny_assemble_block_sheet()` 生成,返回 `rows`(两行表头 + 学生块)/ `merges` / `widths` / `header_rows=2` +/`cell_styles`;宽表由 `ny_assemble_attempt_wide_sheet()` 生成,参数同上。 +合格线按**每场各自**的 `ny_resolve_score_cfg($pid, $c)` 解析(本卷满分 × 合格率),不是全场统一一条线。 + +`cell_styles` 是 `行,列 → red | green`(行与列均 **1-based**),只有两类标记: + +- `red` —— 该场该生成绩 = 该场最高分(同分只标最早达成的那次); +- `green` —— 该场该生成绩达到合格线的次数 ≥ `passTimes`(未达标不标记)。 + +`XlsxWriter::renderMulti()` 支持 `merges`(1-based 闭区间 `[r1,c1,r2,c2]`)、`header_rows`(多行表头同样式) +与 `cell_styles`(单元格级红字 / 绿底);被合并的非首格写成「空值 + 同样式」以保证合并区域四周边框完整, +并跟随左上角首格的标记,避免绿底只覆盖半个格子。 +前端 `sheetToHtml()` 把同一份 `merges` 换算成 `colspan` / `rowspan`、把 `cell_styles` 换算成红字 / 绿底, +所以**打印与导出的 Excel 用同一套判定**。 +列显示规则 `ny_multi_col_flags()` 与排序比较函数同样放在 `lib/multi_export.php`(纯函数,可单测)。 + ## 前端调用约定 ```js diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 9c3f081..351b08f 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -12,7 +12,7 @@ ``` ny-exam-platform/ ├─ index.php 主系统页面(服务端渲染骨架 + 前端挂载) -├─ dashboard.php 数据看板页面 +├─ dashboard.php 数据大屏:首页(班级入口)+ ?class= 单班大屏(锁后退) ├─ draw.php 随机抽取页面 ├─ login.php / logout.php 登录与登出 ├─ api.php 所有后端接口(单一入口,?action=xxx) diff --git a/docs/dev/testing.md b/docs/dev/testing.md index a8570bc..8527555 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -14,9 +14,45 @@ bash tests/run-all.sh | --- | --- | --- | | `tests/test-aggregate.php` | PHP | 聚合、去重、考试次数、排序 | | `tests/test-new-features.php` | PHP | 再次提交置顶排序、XLSX 多表、名单导入列识别 | -| `tools/test-xlsxwriter.php` | PHP | XLSX 写入与 ZIP 打包 | +| `tests/test-config-persist.php` | PHP | 配置双写兜底(DB / `config.json`) | +| `tools/test-xlsxwriter.php` | PHP | XLSX 写入与 ZIP 打包、合并单元格、单元格级红字 / 绿底 | +| `tests/test-multi-export.php` | PHP | 多考试块表与单场宽表的装配(合并区间、合格线、标红标绿、班级 / 组别列) | | `tools/test-config-save.js` | Node e2e | 配置保存、连接变化检测、每卷配置隔离、CSRF | +| `tools/test-upload-guard.js` | Node e2e | 导入文件后缀白名单 | +| `tools/test-whitelist.js` | Node e2e | 白名单导入 / 名单识别 | +| `tools/test-login-loop.js` | Node e2e | 未配置账号 → 登录 → 已登录状态回流 | +| `tools/test-dashboard-nav.js` | Node e2e | 数据大屏两种形态:`dash-home` / `dash-class` 骨架、顶栏控件收敛、后退锁定、班级卡片渲染 | | `tools/test-draw-alloc.js` | Node e2e | 随机抽取名额分配(加权 / 自定义 / 守恒 fuzz) | +| `tools/test-block-print.js` | Node(不需要服务) | 从 `assets/app.js` 抽取 `sheetToHtml` 校验合并区间换算与红 / 绿标记 | + +> `tools/test-block-print.js`、`tools/make-samples.js`、`tools/test-dashboard-nav.js`、`tools/make-dash-sample.js` +> 都会**直接从生产源码里抽取函数在真数据上执行**,而不是另抄一份渲染逻辑——改了这些函数,这几处会立刻暴露漂移。 + +::: warning 端口占用会让 e2e「假通过 / 假失败」 +每个 e2e 段用固定端口(`test-config-save.js` 18081、`test-whitelist.js` 18082、`test-upload-guard.js` 18083 等)。 +若上一次跑测试时中断,PHP 子进程可能变成**孤儿服务**继续占着端口:新一次运行时它的 `waitPort` 会照常通过 +(打到的是旧服务),于是登录直接失败、报错信息指向错误的连接地址。遇到"服务已就绪但登录失败",先查端口: + +```bash +netstat -ano | grep LISTENING | grep -E ":180[0-9][0-9]|:199[0-9][0-9]" +taskkill //PID //F +``` +::: + +## 生成导出版式样例 + +想直观看新版式(以及验证打印与导出一致),可生成可打开的样例: + +```bash +php tools/make-samples.php # 出两版 xlsx + sheet-data.json +node tools/make-samples.js # 用生产 sheetToHtml 渲染打印预览 HTML + +node tools/make-dash-sample.js # 渲染数据大屏首页预览(含班级入口卡片) +``` + +产物在 `docs/samples/`:打印预览 `preview-multi-block.html` / `preview-single-wide.html` +(+ 对应 `.xlsx`),大屏首页预览 `dash-home-preview.html`。两者都自起 mock 服务与临时数据目录, +不会读写项目 `data/`。 可通过环境变量覆盖运行时路径: @@ -85,9 +121,19 @@ git status data/ # data/ 已被 gitignore,需手工核对 - [ ] 点某一行 → 能看到该学生的每次提交 - [ ] 导入含 `.php` 后缀的文件 → 被拒绝 - [ ] 导入正常 `.xlsx` → 成功,且 `data/uploads/` 为空 -- [ ] 导出成绩 / 导出缺考 → 能打开,学号不是科学计数法 -- [ ] 打印预览 → 默认成绩降序 -- [ ] 数据看板全屏进入 / 退出都正常 +- [ ] 工具栏「导出」只一个按钮 → 弹窗里四种内容都能出表,学号不是科学计数法 +- [ ] 成绩明细「每人成绩」切 最高 / 最新 → 成绩列跟着变,文件名带 `_最高成绩` / `_最新成绩` +- [ ] 每次考试导出 → 最高分红字、合格次数达标绿底、未达标留白、整场未考合并成「未考」;每场考试占 2 列,成绩不串列 +- [ ] 多考试打印 → 版式与导出的 Excel 一致,「合格次数」标签没被合并吞掉 +- [ ] 打印预览 → 默认成绩降序,没有教师签字栏、没有站点域名页脚;**绿底 / 红字真的印出来了** + (打印样式带 `print-color-adjust: exact`,不需要在对话框里勾「背景图形」) +- [ ] 导出弹窗「附加内容 · 附缺考名单」→ 每次考试 / 多考试对比也出「缺考名单」工作表 +- [ ] 在每卷配置里改满分成数 / 合格率 → 数据大屏成员榜的红绿跟着变 +- [ ] 数据大屏首页 → 选试卷后列出班级卡片,点击进入单班大屏 +- [ ] 单班大屏 → 顶栏只有试卷 / 自动刷新 / 全屏,没有班级选择与退出;按浏览器后退键**退不回去** +- [ ] 复制单班大屏链接在**无痕窗口**打开 → 直接看到该班数据(免登录),顶栏有「公开 · 无需登录」角标; + 看不到其它班级的成绩;大屏首页仍要求登录 +- [ ] 数据大屏全屏进入 / 退出都正常 - [ ] 随机抽取 → 名额守恒 ## 相关 diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md index b4e6379..713fa57 100644 --- a/docs/guide/dashboard.md +++ b/docs/guide/dashboard.md @@ -1,20 +1,43 @@ -# 数据看板 +# 数据大屏 -数据看板(`dashboard.php`)是**面向投屏 / 大屏**的汇总视图,适合考试进行中实时监控提交进度。 +数据大屏(`dashboard.php`)是**面向投屏 / 大屏**的汇总视图,适合考试进行中实时监控提交进度。 +它有两种形态,由 URL 决定: -从主页面顶部导航 **📊 数据看板** 打开(新标签页)。 +| 形态 | 打开方式 | 长什么样 | +| --- | --- | --- | +| **首页(班级入口)** | 从主页面顶部导航 **📊 数据大屏** 打开,或直接访问 `dashboard.php` | 先选试卷 / 考试,下方以卡片列出该卷的所有班级;点卡片进入该班大屏 | +| **单班大屏** | 点首页的班级卡片(等价于 `dashboard.php?pid=<问卷ID>&class=<班级名>`) | 只展示这一个班:5 张汇总卡 + 分组对比 / 成员榜 | -## 顶部操作 +首页是"入口",不是概览页——它不堆指标,只做一件事:让你**一次看到有哪些班,点进去看单个班**。 -| 控件 | 说明 | -| --- | --- | -| 问卷 / 考试 | 选择要展示的试卷 | -| 班级 | 选择后只展示该班级(需先选试卷) | -| 自动刷新开关 + 间隔 | 与主页面一致的 5 / 10 / 15 / 30 / 60 / 120 秒 | -| 刷新 | 立即拉取一次(跳过缓存) | -| ⛶ 全屏 | 进入 / 退出全屏,布局自动铺满 | -| ← 主系统 | 返回主页面(成绩明细 / 名单 / 抽取);全屏状态下也可直接返回 | -| 退出 | 退出登录 | +## 单班大屏是公开的,可以直接发链接 + +`dashboard.php?pid=<问卷ID>&class=<班级名>` **不需要登录**,任何人打开都能看到这一个班的数据 +(顶栏带「公开 · 无需登录」角标)。适合直接把链接发到班级群让孩子或家长自己看。 + +::: danger 公开的是"这一个班",不是全卷 +接口在**服务端**先按 `class` 把名单和答卷裁剪掉,再回传数据:公开链接拿不到全卷汇总, +也拿不到其它班级的成绩。想看别的班,打开那个班自己的链接即可;不带 `class` 参数的 +大屏首页仍然要求登录。 +::: + +::: warning 单班大屏没有回头路 +单班大屏**不显示**班级下拉、「← 主系统」和「退出」,页面内部也不存在回到首页的链接; +浏览器**后退键同样被屏蔽**(页面加载后立刻 `pushState` 占位,任何 `popstate` 都再推回去)。 +想回主系统或换班,请在地址栏改 URL,或关闭标签页重新从首页进入。 +::: + +## 顶部控件 + +| 控件 | 首页 | 单班大屏 | +| --- | :-: | :-: | +| 问卷 / 考试 | ✅ | ✅ 切换后若该班不在新卷名单里,会自动落到第一个班 | +| 班级 | ✅ | ❌ 由 URL 固定,不提供切换 | +| 自动刷新开关 + 间隔 | ✅ | ✅ | +| 刷新 | ✅ | ✅ | +| ⛶ 全屏 | ✅ | ✅ | +| ← 主系统 | ✅ | ❌ 由 CSS 隐藏 | +| 退出 | ✅ | ❌ 页面里不再渲染 | ::: tip 后台标签页会自动暂停 切换到其他标签页后看板会**停止轮询**,切回来时立即补拉一次数据并重新开始计时—— @@ -22,14 +45,41 @@ **刷新中断(仍显示上次数据)**,鼠标悬停可看到具体原因。 ::: -## 汇总卡片 +## 首页:班级入口卡片 -`总人数 / 已提交 / 缺考 / 提交率 / 平均分 / 合格率(≥X%满分)` +每张卡片给出该班的速览,点整张卡片(或聚焦后按回车)进入该班大屏: + +`总人数 / 已提交 / 缺考 / 平均分 / 合格率` + 一条提交率进度条;没交过卷的班也能一眼看到缺考数。 + +::: tip 首页也在自动刷新 +首页同样受自动刷新开关控制(默认 15 秒),卡片上的数字会跟着变,适合把整层楼/整个年级的 +提交进度投在一块屏上。 +::: + +## 汇总卡片(单班大屏) + +`总人数 / 已提交 / 缺考 / 平均分 / 合格率(≥X%满分)` 合格率卡片的阈值标签会随 [每卷配置](./project-config.md) 实时变化。 +## 成绩的红绿怎么定的 + +成员榜(以及「刷 N 次」下钻弹窗)里的成绩颜色**完全按 [每卷配置](./project-config.md) 里 +管理员设的满分与合格率**判定,不是写死的: + +| 占满分的比例 | 颜色 | +| --- | --- | +| ≥ 90%(合格线本身高过 90% 时则以合格线为准) | 🟢 绿 | +| ≥ 合格率 × 满分(合格但未达优秀) | 🟡 黄 | +| < 合格率 × 满分(不及格) | 🔴 红 | + +例如把本卷满分改成 150、合格率改成 80%,那么 100 分只占 66.7% → 判红,130 分(86.7%)判黄, +145 分(96.7%)判绿。鼠标悬停在分数上会显示「占满分 xx% 合格线 xx 分」。 + ## 班级 / 组别下钻 +(即单班大屏的主体部分) + 看板主体按**班级**分块,每块内再按**组别**对比,指标与统计口径完全相同: 总人数、已提交、缺考、提交率、平均分、合格率、最高分、最低分、平均用时。 diff --git a/docs/guide/export-print.md b/docs/guide/export-print.md index 8f75a22..31b9866 100644 --- a/docs/guide/export-print.md +++ b/docs/guide/export-print.md @@ -1,88 +1,194 @@ # 导出与打印 -## 导出成绩明细 +工具栏下方只有一个 **导出** 按钮,所有导出与打印的选项都收在这个弹窗里,不再散落多个小按钮。 +选项在你打开弹窗前就配好了会**自动记住**(存本地),下次打开就是上次的设置。 -工具栏下方「导出」条里的 **导出成绩**,生成 `.xlsx`(零依赖,仓库内自行实现 XLSX 写入,不需要 `zip` 扩展也能用)。 -导出控件单独成行,与筛选、刷新分开,不会挤在一起。 +> 生成的是 `.xlsx`:零依赖,仓库内自行实现 XLSX 写入,没有 `zip` 扩展也能用。 -### 三种可选模式 +## 导出弹窗 + +| 分区 | 内容 | +| --- | --- | +| **导出内容** | 成绩明细 / 每次考试 / 缺考名单 / 多考试对比(四选一,选中后下方分区跟着变) | +| **统计范围** | 班级 / 组别多选(不选 = 全部,按住 `Ctrl` / `⌘` 多选) | +| **成绩口径与列** | 每人成绩(最新 / 最高)、排序、出表方式、按班级分工作表、附缺考名单 | +| **多考试对比** | 表格形态、对比成绩、勾选要对比的考试 | +| **附加内容** | 附缺考名单(成绩明细 / 每次考试 / 多考试对比 都出工作表) | +| **标记与合格次数** | 合格次数阈值、最高分标红、输出「合格次数」行 | +| **打印设置** | 纸张方向、打印标题 | + +- 分区随「导出内容」显隐:选 **缺考名单** 时只保留范围与打印设置; + 选 **多考试对比** 时才出现考试勾选表; +- **附加内容** 与 **标记与合格次数** 是三类成绩表共用的,不随导出内容消失; + 「按班级分工作表」只有成绩明细支持(置灰不可勾选); +- 弹窗底部有**一句话汇总**(导什么、哪些考试、多大范围),导错了一眼能看出来; +- 打印走隐藏 iframe 调起,**不会被浏览器弹窗拦截挡掉**,也不会多开标签页。 + +### 成绩明细:每人最新成绩 / 最高成绩 + +**默认按每人最新成绩显示**(最近一次真实提交),可选 **最高成绩**(该生在这些考试里的最高分)。 | 选项 | 效果 | | --- | --- | -| 导出排序 | `按提交时间` / `按学号` / `按成绩降序` / `按成绩升序` | -| 按最新成绩 | 每人**只导一行**:最近一次成绩 + 最高分 + 考试次数 | -| 按班级分表 | 一个班级一个工作表(xlsx 多工作表) | +| 每人成绩 | `最新成绩`(默认)/ `最高成绩` —— 每人一行时的代表成绩 | +| 排序 | `按提交时间` / `按学号` / `按成绩降序` / `按成绩升序` | +| 出表方式 | `每人一行(汇总)`(默认)/ `每次考试一行(明细)`:明细模式保留每位学生的历史提交 | +| 附缺考名单 | 默认开;只导成绩时取消勾选(接口 `noAbsent=1`)。**每次考试 / 多考试对比 也支持**,口径为「名单里有、所选考试全都没交」 | +| 按班级分工作表 | 一个班级一个工作表(xlsx 多工作表) | -### 列 +默认列:`学号 / 姓名 / 班级 / 组别 / 成绩 / 最高分 / 考试次数 / 最近提交时间 / 考试时长 / 考试时长(秒)`。 -默认列(全部记录模式): +## 「每次考试」块表(多场对比,主推格式) -`学号 / 姓名 / 班级 / 组别 / 成绩 / 考试次数 / 第几次 / 提交时间 / 考试时长 / 考试时长(秒)` +勾选多场考试,选 **两行表头** 形态。**一列「第几次」被所有考试共用**,每场考试占 2 列, +学生块末尾多一行 **合格次数**。 -按最新成绩模式: +| 学号 | 姓名 | 第几次 | 第一次月考·数学 | | 第一次月考·英语 | | +| --- | --- | --- | --- | --- | --- | --- | +| | | | 成绩 | | 成绩 | | +| (合并) | (合并) | 1 | 112 | 112 | 130 | | +| (合并) | (合并) | 2 | 100 | | 129 | | +| (合并) | (合并) | 3 | **136** | | **133** | | +| (合并) | (合并) | **合格次数** | 3 | | 3 | | +| 356002 | 李四 | 1 | (合并)**未考** | | 128 | | +| (合并) | (合并) | **合格次数** | | | 1 | | -`学号 / 姓名 / 班级 / 组别 / 最新成绩 / 最高分 / 考试次数 / 最近提交时间 / 考试时长 / 考试时长(秒)` +规则: -> 学号等长数字列统一按**文本格式**写入,不会被 Excel 转成科学计数法,也不会被当作公式执行。 +- **学生块高度** = 该生在所有勾选考试里最多的那次次数 + 1(末行是合格次数); +- **学号 / 姓名**按学生块纵向合并;**第几次**只合并到最后一次考试行,末行留给「合格次数」标签; +- **考试名称横跨 2 列**,第二行是 `成绩` 小标题; +- **没考的考试**(整场没有该生记录)→ 该场 2 列 × 学生块高合并成一个 **`未考`** 格子; +- **排序**:学生按学号升序(出现班级 / 组别列时按 班级 → 组别 → 学号); +- **暂存草稿**:默认不导出、也不占「第几次」编号。 + +### 单场考试的宽表版式 + +只导出 **一场** 考试时,改用更清楚的宽表:每人一行,第几次直接做列名。 + +| 学号 | 姓名 | 第1次 | 第2次 | 第3次 | 合格次数 | +| --- | --- | --- | --- | --- | --- | +| 356001 | 张三 | 112 | 100 | **136** | 3 | +| 356002 | 李四 | **128** | | | 1 | + +导出与打印用的是同一套判定,所以单场和「多场块表」的标记完全一样。 + +## 红 / 绿标记 + +| 标记 | 条件 | 效果 | +| --- | --- | --- | +| **最高分标红** | 该场该生成绩 = 该场最高分 | 红字加粗(同分只标最早达成的那次) | +| **合格次数标绿** | 该场该生达到合格线的次数 ≥ 阈值 | 绿底加粗 | +| 合格次数未达标 | 未达阈值 | **仍显示实际次数**(含 0 次),只不标绿 | + +- 合格线 = 该场**本卷满分 × 合格率**(每个试卷各自的配置,不是全场统一一条线); +- 阈值在弹窗里可改(默认 1 次);关掉「最高分标红」或「合格次数行」就只出表不标记; +- 整场没考的不参与合格次数判定,直接是 `未考`; +- 学号等长数字列统一按**文本格式**写入,不会被 Excel 转成科学计数法,也不会被当公式执行; +- 打印与导出的 Excel 用同一份服务端装配结果(红字 / 绿底也来自同一份 `cell_styles`), + 不存在「导出一个样、打印另一个样」。 + +## 班级 / 组别列什么时候出现 + +| 选择情况 | 班级列 | 组别列 | +| --- | --- | --- | +| 只选 1 个班级、未选组别 | 不出 | 不出 | +| 选 ≥2 个班级 | **出** | 未选组别时按数据判断(数据跨多组才出) | +| 只选 1 个班级但选 ≥2 个组别 | 不出 | **出** | +| 什么都不选(= 全部) | 数据跨 ≥2 个班级才出 | 数据跨 ≥2 个组别才出 | + +- 出列时**连续相同班级 / 组别的单元格会纵向合并**(顺序按 班级 → 组别 → 学号),一个班只占一格; +- 也可以用接口参数 `withCls=1` / `withGrp=1` 强制出列(`=0` 强制不出)。 + +### 其它形态 + +| 表格形态 | 产出 | +| --- | --- | +| 两行表头(每次考试 + 合格次数) | 上面那套块表(**默认**) | +| 成绩对比(每人一行) | 一行一个学生、一列一场考试,成绩取「最新」或「最高」 | +| 两者都出 | 第一张块表 + 第二张成绩对比表 | ### 文件名 ``` -成绩明细_<试卷名>[_<班级>][_<组别>][_最新成绩]_.xlsx +成绩明细_<试卷名>[_<班级>][_<组别>][_最新成绩|_最高成绩]_.xlsx +每次考试_<试卷名>[_<班级>][_<组别>]_.xlsx +缺考名单_<试卷名>[_<班级>][_<组别>]_.xlsx +多考试成绩对比_场[_<班级…>][_<组别…>]_<最新成绩|最高分>_.xlsx +每次考试与成绩对比_场[_<班级…>][_<组别…>]_.xlsx # 两者都出 ``` ### 跟随筛选 导出**完全跟随页面当前的班级 / 组别筛选**,缺考口径也与页面一致 -(筛选后名单 − 筛选后已提交,按学号匹配),不会出现"页面上 30 人、导出 50 人"的情况。 +(筛选后名单 − 筛选后已提交,按学号匹配),不会出现「页面上 30 人、导出 50 人」的情况。 -## 导出缺考名单 - -工具栏 **导出缺考**,列为 `学号 / 姓名 / 班级 / 组别`,按学号升序。 - -## 打印成绩明细 - -成绩明细面板右上角 **打印**,弹窗里可设置: +## 打印 | 选项 | 说明 | | --- | --- | -| 打印标题 | 默认「试卷名 + 成绩明细」,可改成学校/考试正式名称 | -| 排序方式 | 成绩降序(默认)/ 升序 / 学号 / 提交时间 | -| 分组对比 | 按班级或组别分段,段首给出小计(人数/平均/最高/最低/合格人数与合格率) | -| 纸张方向 | A4 纵向(列少清晰)或横向(列多不折行) | -| 显示名次 | 全表连续编号,不分组也不会因为分段重新从 1 开始 | -| 显示「是否合格」列 | 低于合格线的成绩与「不合格」标红,一眼定位 | -| 显示考试次数 | 便于发现反复重考的人 | -| 附缺考名单 | 成绩表后**另起一页**附缺考名单,带空白备注列供手写 | +| 打印标题 | 默认按考试名自动生成,可改成学校 / 考试正式名称 | +| 纸张方向 | 多考试打印默认 **横向**(列多不折行),可改纵向 | +| 成绩明细的分组对比 | 按班级或组别分段,段首给出小计(人数 / 平均 / 最高 / 最低 / 合格人数与合格率) | +| 成绩明细的显示选项 | 显示名次、显示「是否合格」列、显示考试次数、附缺考名单 | -其它细节: +- 表头在**每一页自动重复**,行不会被切断到两页;两行表头都放进 ``; +- 页眉含范围(班级 / 组别)、打印时间、人数、平均分、最高 / 最低、合格人数与合格线; +- 页脚含**教师 / 教研组长签字栏与日期**,可直接签字归档。 -- 表头在**每一页自动重复**,行不会被切断到两页; -- 页眉含范围(班级/组别)、打印时间、人数、平均分、最高/最低、合格人数与合格线; -- 页脚含**教师 / 教研组长签字栏与日期**,可直接签字归档; -- 打印走隐藏 iframe 调起,**不会被浏览器的弹窗拦截挡掉**,也不会多开标签页。 +成绩明细面板右上角的**打印**与导出弹窗里的打印设置是同一套(打印标题、方向、分组对比都在弹窗里改)。 -## 打印缺考名单 +::: tip 绿底 / 红字要能印出来 +打印样式里写了 `print-color-adjust: exact`,**不需要**在打印对话框里手动勾选「背景图形」, +Chrome / Edge 会照常输出绿底与红字。若仍看不到底纹,检查是否换用了不支持该属性的浏览器。 +::: -缺考名单面板右上角 **打印**:按班级分组、班内按学号排序,同样带页眉与签字栏, -备注列留空供手写说明(请假、缓考等)。 +::: tip 缺考名单单独成页 +勾选「附缺考名单」后,缺考名单工作表会在打印时**另起一页**(不会挤在成绩表底部),便于分开归档。 +::: ## 直接调用导出接口 需要对接其它系统时,可以带参数直接 GET(需已登录): ``` -api.php?action=export&projectId=xxx&cls=1&grp=A&mode=latest&split=cls&sort=score_desc&cols=sid,name,score +# 成绩明细(每人一行最新 / 最高) +api.php?action=export&projectId=xxx&cls=1班,2班&grp=A组&mode=latest&scoreMode=best&sort=score_desc +# 「每次考试」块表(多场) +api.php?action=export_multi&projectIds=a,b&mode=block&passTimes=1 +# 单场宽表版式(每人一行,第几次做列名) +api.php?action=export_multi&projectIds=a&mode=block&wide=1 +# 多考试:成绩对比 / 两者都出 +api.php?action=export_multi&projectIds=a,b&mode=summary&scoreMode=highest +api.php?action=export_multi&projectIds=a,b&mode=both +# 「多考试打印」用的数据(不下载文件,返回 rows / merges / header_rows / cell_styles) +api.php?action=export_multi&projectIds=a,b&mode=block&format=json +# 缺考名单 +api.php?action=export_absent&projectId=xxx ``` | 参数 | 取值 | 说明 | | --- | --- | --- | -| `projectId` | 试卷 ID | 必填 | -| `cls` / `grp` | 班级 / 组别 | 与页面筛选一致 | -| `mode` | `latest` | 每人一行最新成绩;缺省导出全部记录 | +| `projectId` | 试卷 ID | 必填(`export_multi` 用 `projectIds`,逗号分隔,可只传一场) | +| `cls` / `grp` | 班级 / 组别 | 与页面筛选一致;`export_multi` 支持**多选**(逗号分隔),空 = 全部 | +| `mode` | `latest` / `all` | `export` 的成绩明细:每人一行(默认,取最新)/ 每次考试一行 | +| `scoreMode` | `latest` / `best` | 每人一行的代表成绩:最新(默认)/ 最高 | +| `mode`(`export_multi`) | `block` / `summary` / `both` | 每次考试块表(默认)/ 成绩对比 / 两者都出;旧值仍兼容:`matrix`→`summary`、`merged`·`attempts`→`block` | +| `wide` | `1` | 单场 `block` 时改用宽表版式(每人一行、第几次做列名) | +| `passTimes` | 正整数 | 合格次数达标阈值(默认 1) | +| `markBest` | `0` | `=0` 取消「最高分标红」 | +| `passRow` | `0` | `=0` 不输出「合格次数」行(也就没有绿底) | +| `format` | `json` | 不下载文件,返回 `sheets`(`rows` / `merges` / `header_rows` / `cell_styles`)供打印渲染 | +| `withCls` / `withGrp` | `1` / `0` | 强制出 / 不出班级、组别列;缺省按上表规则自动判断 | +| `noAbsent` | `1` | 成绩明细不附缺考名单 | +| `skipTemp` | `1` | `mode=all` 时排除暂存草稿 | +| `includeTemp` | `1` | 每次考试块表保留暂存草稿(标注为 `第N次(草稿)`) | +| `sort` | `time` / `sid` / `score_desc` / `score_asc` | 成绩明细排序(块表恒按 班级 → 组别 → 学号 / 学号) | | `split` | `cls` | 按班级拆分多工作表 | -| `sort` | `time` / `sid` / `score_desc` / `score_asc` | 排序 | -| `cols` | 逗号分隔列名 | 从默认列中挑选 | +| `cols` | 逗号分隔列名 | 从默认列中挑选(仅 `export`) | + +`format=json` 返回的 `cell_styles` 是 `行,列 → red | green`,行与列都是 **1-based**; +前端据此加红字 / 绿底,合并区间内的非首格继承左上角首格的标记。 ## 相关 diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index 0268425..0c54aec 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -31,9 +31,10 @@ NY 问卷考试系统数据平台是一个**零第三方运行依赖**的 PHP 8 | 试卷设置 | 为当前选中的试卷单独设置满分 / 合格率 / 免考名单,试卷间互相隔离 | | 导出 / 打印 | 成绩明细、缺考名单 `.xlsx`;成绩明细按成绩降序打印,支持分组小计、合格标记、附缺考名单、签字栏 | -### 数据看板 `dashboard.php` +### 数据大屏 `dashboard.php` -大屏看板,适合投屏。支持全屏切换与自动刷新。 +首页先选试卷,再用卡片列出该卷的班级;点卡片进入该班专属大屏。 +大屏页支持全屏切换与自动刷新,**没有返回按钮,浏览器后退键也被屏蔽**(只能靠地址栏或关标签页离开)。 ### 随机抽取 `draw.php` diff --git a/docs/index.md b/docs/index.md index 1900a13..22ae675 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,7 +50,7 @@ features: | 页面 | 入口 | 用途 | | --- | --- | --- | | 主系统 | `index.php` | 成绩明细、统计卡片、名单管理、试卷设置、导出与打印 | -| 数据看板 | `dashboard.php` | 大屏展示,适合投屏实时监控提交进度 | +| 数据大屏 | `dashboard.php` | 首页列出班级入口,点进班级专属大屏(可全屏、自动刷新),适合投屏 | | 随机抽取 | `draw.php` | 从名单或某试卷已提交人员中随机抽取,支持白名单导出 | ![主系统界面](/images/scores.png) diff --git a/docs/samples/dash-home-preview.html b/docs/samples/dash-home-preview.html new file mode 100644 index 0000000..c9c83fa --- /dev/null +++ b/docs/samples/dash-home-preview.html @@ -0,0 +1,56 @@ + +数据大屏首页(预览) + + + + +
+
+

NY问卷考试系统数据平台

+
+ + + + + + + ← 主系统 + 退出 +
+
+
+ 共 4 个班级 · 点击卡片进入该班大屏 + + 最后更新:2026-09-27 20:57:25 +
+
静态预览:这张页面由生产环境 dashboard.php 的 clsCardHtml() 渲染, + 数据取自 mock 卷王。真实站点上点卡片会整页跳转到 dashboard.php?pid=…&class=…, + 进入该班独立大屏(那里不再有班级选择、返回主系统与退出,浏览器后退键也被锁住)。
+
+
+
总人数0
+
已提交0
+
缺考0
+
平均分-
+
合格率-
+
+
班级入口 点击卡片进入该班独立大屏
+
高一(1)班进入 ›
总人数2
已提交2
缺考0
平均分81
合格率100%
提交率 100%
高一(2)班进入 ›
总人数1
已提交1
缺考0
平均分93
合格率100%
提交率 100%
高一(3)班进入 ›
总人数1
已提交1
缺考0
平均分90
合格率100%
提交率 100%
高一(4)班进入 ›
总人数1
已提交1
缺考0
平均分76
合格率100%
提交率 100%
+
+
+ + \ No newline at end of file diff --git a/docs/samples/export-multi-block.xlsx b/docs/samples/export-multi-block.xlsx new file mode 100644 index 0000000..92201b8 Binary files /dev/null and b/docs/samples/export-multi-block.xlsx differ diff --git a/docs/samples/export-single-wide.xlsx b/docs/samples/export-single-wide.xlsx new file mode 100644 index 0000000..f73dc62 Binary files /dev/null and b/docs/samples/export-single-wide.xlsx differ diff --git a/docs/samples/preview-multi-block.html b/docs/samples/preview-multi-block.html new file mode 100644 index 0000000..0f0c6da --- /dev/null +++ b/docs/samples/preview-multi-block.html @@ -0,0 +1,7 @@ + +多考试导出 · 每次考试(两行表头)(预览) +

多考试导出 · 每次考试(两行表头)

合格线 110 分,达 1 次标绿

+
学号姓名第几次第一次月考·数学第一次月考·英语
成绩成绩
356001张三1112112
10099
136
127
合格次数31
356002李四1130
129
133
合格次数03
356003王五195未考
合格次数0
+

标记说明:最高分 标红、红字;合格次数 始终显示(含 0 次),达 1 次标绿、绿底;未达标显示次数、不标绿。 +

教师签字:______________  教研组长签字:______________  日期:____________

+
\ No newline at end of file diff --git a/docs/samples/preview-single-wide.html b/docs/samples/preview-single-wide.html new file mode 100644 index 0000000..284f88d --- /dev/null +++ b/docs/samples/preview-single-wide.html @@ -0,0 +1,7 @@ + +单场导出 · 每次考试(每人一行的宽表)(预览) +

单场导出 · 每次考试(每人一行的宽表)

第一次月考·数学 · 合格线 110 分,达 1 次标绿

+
学号姓名第1次第2次第3次第4次合格次数
356001张三1121001361273
356002李四0
356003王五950
+

标记说明:最高分 标红、红字;合格次数 始终显示(含 0 次),达 1 次标绿、绿底;未达标显示次数、不标绿。 +

教师签字:______________  教研组长签字:______________  日期:____________

+
\ No newline at end of file diff --git a/docs/samples/sheet-data.json b/docs/samples/sheet-data.json new file mode 100644 index 0000000..9b49530 --- /dev/null +++ b/docs/samples/sheet-data.json @@ -0,0 +1,287 @@ +{ + "block": { + "title": "多考试导出 · 每次考试(两行表头)", + "sheet": { + "rows": [ + [ + "学号", + "姓名", + "第几次", + "第一次月考·数学", + "", + "第一次月考·英语", + "" + ], + [ + "", + "", + "", + "成绩", + "", + "成绩", + "" + ], + [ + "356001", + "张三", + "1", + "112", + "", + "112", + "" + ], + [ + "", + "", + "2", + "100", + "", + "99", + "" + ], + [ + "", + "", + "3", + "136", + "", + "", + "" + ], + [ + "", + "", + "4", + "127", + "", + "", + "" + ], + [ + "", + "", + "合格次数", + "3", + "", + "1", + "" + ], + [ + "356002", + "李四", + "1", + "", + "", + "130", + "" + ], + [ + "", + "", + "2", + "", + "", + "129", + "" + ], + [ + "", + "", + "3", + "", + "", + "133", + "" + ], + [ + "", + "", + "合格次数", + "0", + "", + "3", + "" + ], + [ + "356003", + "王五", + "1", + "95", + "", + "未考", + "" + ], + [ + "", + "", + "合格次数", + "0", + "", + "", + "" + ] + ], + "merges": [ + [ + 1, + 1, + 2, + 1 + ], + [ + 1, + 2, + 2, + 2 + ], + [ + 1, + 3, + 2, + 3 + ], + [ + 1, + 4, + 1, + 5 + ], + [ + 1, + 6, + 1, + 7 + ], + [ + 3, + 1, + 7, + 1 + ], + [ + 3, + 2, + 7, + 2 + ], + [ + 3, + 3, + 6, + 3 + ], + [ + 8, + 1, + 11, + 1 + ], + [ + 8, + 2, + 11, + 2 + ], + [ + 8, + 3, + 10, + 3 + ], + [ + 12, + 6, + 12, + 7 + ], + [ + 12, + 1, + 13, + 1 + ], + [ + 12, + 2, + 13, + 2 + ], + [ + 12, + 3, + 12, + 3 + ] + ], + "header_rows": 2, + "cell_styles": { + "3,6": "red", + "5,4": "red", + "7,4": "green", + "7,6": "green", + "10,6": "red", + "11,6": "green", + "12,4": "red" + } + }, + "meta": { + "pass_line": 110, + "pass_times": 1 + } + }, + "wide": { + "title": "单场导出 · 每次考试(每人一行的宽表)", + "sheet": { + "rows": [ + [ + "学号", + "姓名", + "第1次", + "第2次", + "第3次", + "第4次", + "合格次数" + ], + [ + "356001", + "张三", + "112", + "100", + "136", + "127", + "3" + ], + [ + "356002", + "李四", + "", + "", + "", + "", + "0" + ], + [ + "356003", + "王五", + "95", + "", + "", + "", + "0" + ] + ], + "merges": [], + "header_rows": 1, + "cell_styles": { + "2,5": "red", + "2,7": "green", + "4,3": "red" + } + }, + "meta": { + "pass_line": 110, + "pass_times": 1, + "exam_name": "第一次月考·数学" + } + } +} \ No newline at end of file diff --git a/index.php b/index.php index 541c3d2..9b9916e 100644 --- a/index.php +++ b/index.php @@ -80,24 +80,9 @@ $rosterCount = Roster::count();
导出 - - - + 成绩明细 / 每次考试 / 缺考名单 / 多考试对比,都在「导出」里配置 - - - +
@@ -425,10 +410,18 @@ $rosterCount = Roster::count();
-

全部记录会把每位学生的每次提交各打一行,完整留存历史;按最新成绩则每人仅显示当前代表成绩。

+

「每次考试一行」会把每位学生的每次提交各打一行,完整留存历史。

+
+
+ + +

与「导出 → 成绩明细」的「每人成绩」一致,默认最新成绩。

@@ -437,6 +430,7 @@ $rosterCount = Roster::count();

打印内容不受页面当前筛选外的影响,始终按当前班级/组别筛选结果输出;表头会在每页自动重复,末尾附签字栏。

+

要打印多场考试对比或「每次考试」表(最高分标红、合格次数达标绿底)?用工具栏的 导出 弹窗,选「多考试对比」或「每次考试」后点「打印」。

- -