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 = '
clsCardHtml() 渲染,
+ 数据取自 mock 卷王。真实站点上点卡片会整页跳转到 dashboard.php?pid=…&class=…,
+ 进入该班独立大屏(那里不再有班级选择、返回主系统与退出,浏览器后退键也被锁住)。合格线 110 分,达 1 次标绿
+| 学号 | 姓名 | 第几次 | 第一次月考·数学 | 第一次月考·英语 | ||
| 成绩 | 成绩 | |||||
| 356001 | 张三 | 1 | 112 | 112 | ||
| 100 | 99 | |||||
| 136 | ||||||
| 127 | ||||||
| 合格次数 | 3 | 1 | ||||
| 356002 | 李四 | 1 | 130 | |||
| 129 | ||||||
| 133 | ||||||
| 合格次数 | 0 | 3 | ||||
| 356003 | 王五 | 1 | 95 | 未考 | ||
| 合格次数 | 0 | |||||
标记说明:最高分 标红、红字;合格次数 始终显示(含 0 次),达 1 次标绿、绿底;未达标显示次数、不标绿。 +
教师签字:______________ 教研组长签字:______________ 日期:____________
+第一次月考·数学 · 合格线 110 分,达 1 次标绿
+| 学号 | 姓名 | 第1次 | 第2次 | 第3次 | 第4次 | 合格次数 |
| 356001 | 张三 | 112 | 100 | 136 | 127 | 3 |
| 356002 | 李四 | 0 | ||||
| 356003 | 王五 | 95 | 0 |
标记说明:最高分 标红、红字;合格次数 始终显示(含 0 次),达 1 次标绿、绿底;未达标显示次数、不标绿。 +
教师签字:______________ 教研组长签字:______________ 日期:____________
+全部记录会把每位学生的每次提交各打一行,完整留存历史;按最新成绩则每人仅显示当前代表成绩。
+「每次考试一行」会把每位学生的每次提交各打一行,完整留存历史。
+与「导出 → 成绩明细」的「每人成绩」一致,默认最新成绩。
打印内容不受页面当前筛选外的影响,始终按当前班级/组别筛选结果输出;表头会在每页自动重复,末尾附签字栏。
+要打印多场考试对比或「每次考试」表(最高分标红、合格次数达标绿底)?用工具栏的 导出 弹窗,选「多考试对比」或「每次考试」后点「打印」。
勾选多场考试后导出一张横向对比表:第一行是考试名称,前两列是学号 / 姓名,默认按学号排序;每场考试的成绩可按「最新」或「最高」显示。
-' + sub + '
', + table, + '标记说明:最高分 标红、' + + '红字;合格次数 始终显示(含 0 次),' + + '达 ' + meta.pass_times + ' 次标绿、绿底;未达标显示次数、不标绿。', + '
教师签字:______________ 教研组长签字:______________ 日期:____________
', + '