feat(export/dashboard): 导出增强与大屏公开化

导出-每次考试/多考试对比:
- 合格次数阈值等标记项抽为常显区块,三类成绩表共用
- 附缺考名单扩展到多考试导出(取所选考试全都没交的人)
- 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列
- 列宽按实际内容估算,合并区空位只给最小宽度
- 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底
- 打印标题改为「成绩明细」,缺考名单分页单独成页
- 打印样式加 print-color-adjust:exact,绿底/红字可正常印出

数据大屏:
- ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传
- 单班大屏移除退出按钮,仅首页保留
- 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到)
  改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿

测试/样例/文档同步更新,全量回归通过。
This commit is contained in:
2026-09-27 22:04:17 +08:00
parent 0ba9c7c33c
commit d05fd4a9b5
34 changed files with 4290 additions and 305 deletions
+3
View File
@@ -1,6 +1,9 @@
# 运行时数据:含账号密码、API 令牌、学生名单,禁止入库
data/
# WorkBuddy 项目数据目录(记忆 / 配置缓存),非源码,禁止入库
.workbuddy/
# e2e 隔离用的临时数据目录(run-all.sh 自动创建,不入库)
.ny-test-data/
+355 -28
View File
@@ -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');
+467 -85
View File
@@ -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 => '<option value="' + esc(v) + '">' + esc(v) + '</option>').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 => '<option value="' + esc(v) + '">' + esc(v) + '</option>').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 = '<tr><td colspan="3" class="empty">(暂无可导出的考试,请先在右上角刷新问卷列表)</td></tr>';
@@ -1398,54 +1496,323 @@
const tr = document.createElement('tr');
const checked = (p.id === cur) ? ' checked' : '';
tr.innerHTML =
'<td><input type="checkbox" class="me-chk" value="' + esc(p.id) + '"' + checked + '></td>' +
'<td><input type="checkbox" class="ex-chk" value="' + esc(p.id) + '"' + checked + '></td>' +
'<td>' + (i + 1) + '</td>' +
'<td>' + esc(p.name) + '</td>';
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 += '<td' +
(sp[0] > 1 ? ' rowspan="' + sp[0] + '"' : '') +
(sp[1] > 1 ? ' colspan="' + sp[1] + '"' : '') +
cls + '>' + esc(txt) + '</td>';
}
const tr = '<tr>' + tds + '</tr>';
if (r < hRows) thead += tr; else tbody += tr;
});
// 表头放进 <thead>:多页打印时每页自动重复
return '<table class="bt"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table>';
}
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 = '<h2>' + esc(title) + '</h2>' +
(o.type === 'multi'
? '<div class="sub">' + esc((meta.examNames || []).join(' · ')) + '</div>'
: '') +
'<div class="meta">' + esc([
o.type === 'multi' ? ('考试 ' + (meta.examCount || (meta.examNames || []).length) + ' 场') : '',
'范围:' + exScopeText(o),
markNote,
'打印时间:' + new Date().toLocaleString(),
].filter(Boolean).join(' | ')) + '</div>';
// 多张表(如成绩明细 + 附缺考名单)逐张出小标题再接表格。
// 「每次考试」主表跟随大标题,不再重复写「每次考试」;缺考名单另起一页。
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 += '<h3 class="grp' + pb + '">' + esc(sh.name) + '</h3>';
}
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}' +
// 表头渲染出来是 <td>(不是 <th>),所以底色要写 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 = '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">' +
'<title>' + esc(title) + '</title><style>' + css + '</style></head><body>' + bodyHtml + '</body></html>';
@@ -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 += '<div class="sign">教师签字:____________  教研组长:____________  日期:____ 年 ____ 月 ____ 日</div>';
printDoc(title, body, { orient: orient });
showAlert('已调起打印,如未弹出请在打印对话框中选择正确打印机', 'info');
}
@@ -2077,10 +2462,7 @@
const meta = '<h2>' + esc(title) + '</h2>' +
(proj ? '<div class="sub">' + esc(proj) + '</div>' : '') +
'<div class="meta">' + esc(printMetaLine(['共 ' + list.length + ' 人'])) + '</div>';
const sign = '<div class="sign">教师签字:____________  日期:____ 年 ____ 月 ____ 日</div>' +
'<div class="foot">' + esc(NY.appName || '') + ' · 缺考名单,请核对后签字确认</div>';
printDoc(title, meta + body + sign, { orient: 'portrait' });
printDoc(title, meta + body, { orient: 'portrait' });
showAlert('已调起打印,如未弹出请在打印对话框中选择正确打印机', 'info');
});
+118 -1
View File
@@ -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; }
/* ================= 随机抽取页 ================= */
+199 -32
View File
@@ -1,35 +1,51 @@
<?php
/**
* 班级数据看板(独立页面):每个班级一张专属看板,无概览、无返回按钮。
* URL 支持 ?pid=<projectId>&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 . ' · 班级考试大屏') : '数据大屏 · 选择班级';
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>班级看板 · <?= htmlspecialchars(NY_APP_NAME) ?></title>
<title><?= htmlspecialchars($pageTitle) ?> · <?= htmlspecialchars(NY_APP_NAME) ?></title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body class="dash">
<!-- dash-class = 单班大屏(隐藏班级选择/返回/退出 + 锁后退);dash-home = 首页班级入口 -->
<body class="dash <?= $isClass ? 'dash-class' : 'dash-home' ?>">
<div class="wrap">
<div class="dash-hero">
<div class="brand">
<h1 class="brand-title">NY问卷考试系统数据平台</h1>
<?php if ($isClass): ?>
<span class="brand-cls" id="heroClass"><?= htmlspecialchars($clsParam) ?></span>
<?php endif; ?>
</div>
<div class="dash-controls">
<select id="projectSel" style="min-width:160px"><option value="">请选择问卷 / 考试</option></select>
<select id="classSel" style="min-width:140px" disabled><option value="">请先选问卷</option></select>
<span id="classSelWrap"><select id="classSel" style="min-width:140px" disabled><option value="">请先选问卷</option></select></span>
<label class="switch"><input type="checkbox" id="autoRefresh" checked><span></span></label>
<select id="intervalSel" style="width:auto">
<option value="5">5 秒</option>
@@ -40,20 +56,23 @@ if (!ny_is_auth()) {
</select>
<button class="btn" id="btnRefresh">刷新</button>
<button class="btn" id="btnFullscreen" title="切换全屏">⛶ 全屏</button>
<a class="btn back" href="index.php" title="返回主系统(成绩明细 / 名单 / 抽取)">← 主系统</a>
<a class="btn back" href="logout.php" title="退出登录">退出</a>
<a class="btn back" id="linkIndex" href="index.php" title="返回主系统(成绩明细 / 名单 / 抽取)">← 主系统</a>
<?php if (!$publicView): ?>
<a class="btn back" id="linkLogout" href="logout.php" title="退出登录">退出</a>
<?php endif; ?>
</div>
</div>
<div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
<span id="heroSub">选择一个问卷与班级查看考试整体情况</span>
<?php if ($publicView): ?><span class="pub-tag" title="本页面为公开页面,无需登录即可查看">公开 · 无需登录</span><?php endif; ?>
<span class="spacer" style="flex:1"></span>
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
</div>
<!-- ================= 班级看板(单班,无概览) ================= -->
<!-- ================= 看板区:首页=班级入口卡片,单班页=该班看板 ================= -->
<div id="board">
<!-- 6 个汇总卡片 -->
<!-- 5 个汇总卡片(仅单班大屏使用) -->
<section class="cards" id="boardSummary">
<div class="card accent-blue"><span>总人数</span><b id="bTotal">-</b></div>
<div class="card ok accent-green"><span>已提交</span><b id="bSubmit">-</b></div>
@@ -62,7 +81,7 @@ if (!ny_is_auth()) {
<div class="card accent-indigo"><span>合格率<span id="bPassLabel">(≥60%满分)</span></span><b id="bPass">-</b></div>
</section>
<div class="sec-title">分组对比 / 考试成员</div>
<div class="sec-title" id="boardTitle">分组对比 / 考试成员</div>
<div id="boardBody">
<div class="empty">请选择问卷与班级查看考试整体情况</div>
</div>
@@ -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 => '<option value="' + esc(c) + '">' + esc(c) + '</option>').join('')
: '<option value="">暂无班级</option>';
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 ? ' <span class="tag tag-att" title="查看 ' + m.attempts + ' 次提交明细">刷' + m.attempts + '次</span>' : '';
// 注意:不要把 sid 直接拼进 onclick="...",JSON.stringify 产生的双引号会提前闭合
@@ -323,7 +372,7 @@ function membersTable(members){
return '<tr' + (att ? ' class="clickable"' : '') + ' data-sid="' + esc(m.sid) + '"' + (att ? ' title="查看提交明细"' : '') + '>' +
'<td class="num"><span class="' + badge + '">' + rk + '</span></td>' +
'<td class="name">' + esc(m.name) + tag + '</td>' +
'<td class="score num ' + cls + '">' + (sc == null ? '-' : sc) + '</td>' +
'<td class="score num ' + cls + '"' + tip + '>' + (sc == null ? '-' : sc) + '</td>' +
'</tr>';
}).join('') +
'</tbody></table></div>';
@@ -335,11 +384,13 @@ function openAttempts(sid){
if (!info) return;
const list = info.list || [];
const rows = list.map(a => {
const sc = a.score == null ? '<span class="muted">未判分</span>' : esc(a.score);
// 下钻弹窗里的成绩用同一套红绿口径,避免"列表里是绿的、点开却是白的"
const g = scoreGrade(a.score);
const tmp = a.temp_save ? ' <span class="tag tag-gray">暂存</span>' : '';
const sc = a.score == null ? '<span class="muted">未判分</span>' : esc(a.score);
return '<tr>' +
'<td class="num">' + esc(a.submit_at) + '</td>' +
'<td class="num">' + sc + tmp + '</td>' +
'<td class="num ' + g.cls + '">' + sc + tmp + '</td>' +
'<td class="num">' + esc(a.duration_txt || '-') + '</td>' +
'</tr>';
}).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) =>
'<div class="cls-kpi ' + (kc || '') + '"><span>' + label + '</span><b>' + val + '</b></div>';
return '<div class="cls-card" data-cls="' + esc(c.class) + '" role="button" tabindex="0" ' +
'title="点击进入「' + esc(c.class) + '」的独立大屏">' +
'<div class="cls-head"><span class="cls-name">' + esc(c.class) + '</span>' +
'<span class="cls-go">进入 ›</span></div>' +
'<div class="cls-kpis">' +
kpi('总人数', total) +
kpi('已提交', sub, 'k-ok') +
kpi('缺考', abs, abs > 0 ? 'k-warn' : '') +
kpi('平均分', dv(c.avg)) +
kpi('合格率', pv(pr), 'k-ok') +
'</div>' +
'<div class="cls-bar"><i style="width:' + rate + '%;background:' + rateColor(rate) + '"></i></div>' +
'<div class="cls-sub"><span>提交率 ' + rate + '%</span>' +
(c.attempts > c.submitted ? '<span>共 ' + dv(c.attempts, 0) + ' 次刷题</span>' : '') +
'</div>' +
'</div>';
}
/** 首页渲染:把该卷的班级列成卡片,点击进入该班独立大屏(带 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 = '班级入口 <span class="tag-gray">点击卡片进入该班独立大屏</span>';
const box = $('#boardBody');
if (!list.length) {
box.innerHTML = '<div class="empty">该问卷没有解析出班级,请检查名单的「班级」列</div>';
return;
}
box.innerHTML = '<div class="cls-grid">' + list.map(clsCardHtml).join('') + '</div>';
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-* 决定左侧色条),避免空态蓝条与真实卡片不一致
'<div class="card ' + PLACEHOLDER_ACCENT[c[0]] + '"><span>' + c[0] + '</span><b>' + c[1] + '</b></div>').join('');
$('#boardBody').innerHTML = '<div class="empty">请选择问卷与班级查看考试整体情况</div>';
$('#boardBody').innerHTML = '<div class="empty">' + esc(m) + '</div>';
const bt = document.getElementById('boardTitle');
if (bt) bt.innerHTML = IS_CLASS ? '分组对比 / 考试成员'
: '班级入口 <span class="tag-gray">选好试卷后显示可进入的班级</span>';
// 注意: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;
</script>
+40 -3
View File
@@ -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
+1 -1
View File
@@ -12,7 +12,7 @@
```
ny-exam-platform/
├─ index.php 主系统页面(服务端渲染骨架 + 前端挂载)
├─ dashboard.php 数据看板页面
├─ dashboard.php 数据大屏:首页(班级入口)+ ?class= 单班大屏(锁后退)
├─ draw.php 随机抽取页面
├─ login.php / logout.php 登录与登出
├─ api.php 所有后端接口(单一入口,?action=xxx)
+50 -4
View File
@@ -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 <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`,不需要在对话框里勾「背景图形」)
- [ ] 导出弹窗「附加内容 · 附缺考名单」→ 每次考试 / 多考试对比也出「缺考名单」工作表
- [ ] 在每卷配置里改满分成数 / 合格率 → 数据大屏成员榜的红绿跟着变
- [ ] 数据大屏首页 → 选试卷后列出班级卡片,点击进入单班大屏
- [ ] 单班大屏 → 顶栏只有试卷 / 自动刷新 / 全屏,没有班级选择与退出;按浏览器后退键**退不回去**
- [ ] 复制单班大屏链接在**无痕窗口**打开 → 直接看到该班数据(免登录),顶栏有「公开 · 无需登录」角标;
看不到其它班级的成绩;大屏首页仍要求登录
- [ ] 数据大屏全屏进入 / 退出都正常
- [ ] 随机抽取 → 名额守恒
## 相关
+65 -15
View File
@@ -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 分」。
## 班级 / 组别下钻
(即单班大屏的主体部分)
看板主体按**班级**分块,每块内再按**组别**对比,指标与统计口径完全相同:
总人数、已提交、缺考、提交率、平均分、合格率、最高分、最低分、平均用时。
+150 -44
View File
@@ -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` 强制不出)。
### 其它形态
| 表格形态 | 产出 |
| --- | --- |
| 两行表头(每次考试 + 合格次数) | 上面那套块表(**默认**) |
| 成绩对比(每人一行) | 一行一个学生、一列一场考试,成绩取「最新」或「最高」 |
| 两者都出 | 第一张块表 + 第二张成绩对比表 |
### 文件名
```
成绩明细_<试卷名>[_<班级>][_<组别>][_最新成绩]_<YYYYMMDD_HHMMSS>.xlsx
成绩明细_<试卷名>[_<班级>][_<组别>][_最新成绩|_最高成绩]_<YYYYMMDD_HHMMSS>.xlsx
每次考试_<试卷名>[_<班级>][_<组别>]_<YYYYMMDD_HHMMSS>.xlsx
缺考名单_<试卷名>[_<班级>][_<组别>]_<YYYYMMDD_HHMMSS>.xlsx
多考试成绩对比_<N>场[_<班级…>][_<组别…>]_<最新成绩|最高分>_<YYYYMMDD_HHMMSS>.xlsx
每次考试与成绩对比_<N>场[_<班级…>][_<组别…>]_<YYYYMMDD_HHMMSS>.xlsx # 两者都出
```
### 跟随筛选
导出**完全跟随页面当前的班级 / 组别筛选**,缺考口径也与页面一致
(筛选后名单 − 筛选后已提交,按学号匹配),不会出现"页面上 30 人、导出 50 人"的情况。
(筛选后名单 − 筛选后已提交,按学号匹配),不会出现「页面上 30 人、导出 50 人」的情况。
## 导出缺考名单
工具栏 **导出缺考**,列为 `学号 / 姓名 / 班级 / 组别`,按学号升序。
## 打印成绩明细
成绩明细面板右上角 **打印**,弹窗里可设置:
## 打印
| 选项 | 说明 |
| --- | --- |
| 打印标题 | 默认「试卷名 + 成绩明细」,可改成学校/考试正式名称 |
| 排序方式 | 成绩降序(默认)/ 升序 / 学号 / 提交时间 |
| 分组对比 | 按班级或组别分段,段首给出小计(人数/平均/最高/最低/合格人数与合格率) |
| 纸张方向 | A4 纵向(列少清晰)或横向(列多不折行) |
| 显示名次 | 全表连续编号,不分组也不会因为分段重新从 1 开始 |
| 显示「是否合格」列 | 低于合格线的成绩与「不合格」标红,一眼定位 |
| 显示考试次数 | 便于发现反复重考的人 |
| 附缺考名单 | 成绩表后**另起一页**附缺考名单,带空白备注列供手写 |
| 打印标题 | 默认按考试名自动生成,可改成学校 / 考试正式名称 |
| 纸张方向 | 多考试打印默认 **横向**(列多不折行),可改纵向 |
| 成绩明细的分组对比 | 按班级或组别分段,段首给出小计(人数 / 平均 / 最高 / 最低 / 合格人数与合格率) |
| 成绩明细的显示选项 | 显示名次、显示「是否合格」列、显示考试次数、附缺考名单 |
其它细节:
- 表头在**每一页自动重复**,行不会被切断到两页;两行表头都放进 `<thead>`;
- 页眉含范围(班级 / 组别)、打印时间、人数、平均分、最高 / 最低、合格人数与合格线;
- 页脚含**教师 / 教研组长签字栏与日期**,可直接签字归档。
- 表头在**每一页自动重复**,行不会被切断到两页;
- 页眉含范围(班级/组别)、打印时间、人数、平均分、最高/最低、合格人数与合格线;
- 页脚含**教师 / 教研组长签字栏与日期**,可直接签字归档;
- 打印走隐藏 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**;
前端据此加红字 / 绿底,合并区间内的非首格继承左上角首格的标记。
## 相关
+3 -2
View File
@@ -31,9 +31,10 @@ NY 问卷考试系统数据平台是一个**零第三方运行依赖**的 PHP 8
| 试卷设置 | 为当前选中的试卷单独设置满分 / 合格率 / 免考名单,试卷间互相隔离 |
| 导出 / 打印 | 成绩明细、缺考名单 `.xlsx`;成绩明细按成绩降序打印,支持分组小计、合格标记、附缺考名单、签字栏 |
### 数据看板 `dashboard.php`
### 数据大屏 `dashboard.php`
大屏看板,适合投屏。支持全屏切换与自动刷新。
首页先选试卷,再用卡片列出该卷的班级;点卡片进入该班专属大屏。
大屏页支持全屏切换与自动刷新,**没有返回按钮,浏览器后退键也被屏蔽**(只能靠地址栏或关标签页离开)。
### 随机抽取 `draw.php`
+1 -1
View File
@@ -50,7 +50,7 @@ features:
| 页面 | 入口 | 用途 |
| --- | --- | --- |
| 主系统 | `index.php` | 成绩明细、统计卡片、名单管理、试卷设置、导出与打印 |
| 数据看板 | `dashboard.php` | 大屏展示,适合投屏实时监控提交进度 |
| 数据大屏 | `dashboard.php` | 首页列出班级入口,点进班级专属大屏(可全屏、自动刷新),适合投屏 |
| 随机抽取 | `draw.php` | 从名单或某试卷已提交人员中随机抽取,支持白名单导出 |
![主系统界面](/images/scores.png)
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<title>数据大屏首页(预览)</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="../../assets/style.css">
<style>
/* 预览页专属:补上 hero 里样式类所需的小件(原页面由 dashboard.php 内联给出) */
.brand-text{display:flex;flex-direction:column;gap:2px}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--border-2);vertical-align:1px}
.dot.on{background:var(--ok)}
.dot.stale{background:var(--danger)}
.spacer{flex:1}
.preview-tip{margin:12px 0 0;padding:8px 12px;border:1px dashed var(--border);border-radius:var(--radius);
font-size:12px;color:var(--text-3);background:var(--panel-weak,#f7f8fc)}
</style></head>
<body class="dash dash-home">
<div class="wrap">
<div class="dash-hero">
<div class="brand"><h1 class="brand-title">NY问卷考试系统数据平台</h1></div>
<div class="dash-controls">
<select id="projectSel"><option value="p1">高一数学月考(31 份)</option></select>
<span id="classSelWrap"><select id="classSel" disabled><option value="">请先选问卷</option></select></span>
<label class="switch"><input type="checkbox" id="autoRefresh" checked><span></span></label>
<select id="intervalSel"><option value="15" selected>15 秒</option></select>
<button class="btn" id="btnRefresh">刷新</button>
<button class="btn" id="btnFullscreen">⛶ 全屏</button>
<a class="btn back" id="linkIndex" href="index.php">← 主系统</a>
<a class="btn back" id="linkLogout" href="logout.php">退出</a>
</div>
</div>
<div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
<span id="heroSub">共 4 个班级 · 点击卡片进入该班大屏</span>
<span class="spacer"></span>
最后更新:<span id="lastUpdate">2026-09-27 20:57:25</span><i id="liveDot" class="dot on"></i>
</div>
<div class="preview-tip">静态预览:这张页面由生产环境 dashboard.php 的 <code>clsCardHtml()</code> 渲染,
数据取自 mock 卷王。真实站点上点卡片会整页跳转到 <code>dashboard.php?pid=…&amp;class=…</code>,
进入该班独立大屏(那里不再有班级选择、返回主系统与退出,浏览器后退键也被锁住)。</div>
<div id="board">
<section class="cards" id="boardSummary">
<div class="card accent-blue"><span>总人数</span><b>0</b></div>
<div class="card ok accent-green"><span>已提交</span><b>0</b></div>
<div class="card warn accent-orange"><span>缺考</span><b>0</b></div>
<div class="card accent-teal"><span>平均分</span><b>-</b></div>
<div class="card accent-indigo"><span>合格率</span><b>-</b></div>
</section>
<div class="sec-title" id="boardTitle">班级入口 <span class="tag-gray">点击卡片进入该班独立大屏</span></div>
<div id="boardBody"><div class="cls-grid"><div class="cls-card" data-cls="高一(1)班" role="button" tabindex="0" title="点击进入「高一(1)班」的独立大屏"><div class="cls-head"><span class="cls-name">高一(1)班</span><span class="cls-go">进入 ›</span></div><div class="cls-kpis"><div class="cls-kpi "><span>总人数</span><b>2</b></div><div class="cls-kpi k-ok"><span>已提交</span><b>2</b></div><div class="cls-kpi "><span>缺考</span><b>0</b></div><div class="cls-kpi "><span>平均分</span><b>81</b></div><div class="cls-kpi k-ok"><span>合格率</span><b>100%</b></div></div><div class="cls-bar"><i style="width:100%;background:var(--ok)"></i></div><div class="cls-sub"><span>提交率 100%</span></div></div><div class="cls-card" data-cls="高一(2)班" role="button" tabindex="0" title="点击进入「高一(2)班」的独立大屏"><div class="cls-head"><span class="cls-name">高一(2)班</span><span class="cls-go">进入 ›</span></div><div class="cls-kpis"><div class="cls-kpi "><span>总人数</span><b>1</b></div><div class="cls-kpi k-ok"><span>已提交</span><b>1</b></div><div class="cls-kpi "><span>缺考</span><b>0</b></div><div class="cls-kpi "><span>平均分</span><b>93</b></div><div class="cls-kpi k-ok"><span>合格率</span><b>100%</b></div></div><div class="cls-bar"><i style="width:100%;background:var(--ok)"></i></div><div class="cls-sub"><span>提交率 100%</span></div></div><div class="cls-card" data-cls="高一(3)班" role="button" tabindex="0" title="点击进入「高一(3)班」的独立大屏"><div class="cls-head"><span class="cls-name">高一(3)班</span><span class="cls-go">进入 ›</span></div><div class="cls-kpis"><div class="cls-kpi "><span>总人数</span><b>1</b></div><div class="cls-kpi k-ok"><span>已提交</span><b>1</b></div><div class="cls-kpi "><span>缺考</span><b>0</b></div><div class="cls-kpi "><span>平均分</span><b>90</b></div><div class="cls-kpi k-ok"><span>合格率</span><b>100%</b></div></div><div class="cls-bar"><i style="width:100%;background:var(--ok)"></i></div><div class="cls-sub"><span>提交率 100%</span></div></div><div class="cls-card" data-cls="高一(4)班" role="button" tabindex="0" title="点击进入「高一(4)班」的独立大屏"><div class="cls-head"><span class="cls-name">高一(4)班</span><span class="cls-go">进入 ›</span></div><div class="cls-kpis"><div class="cls-kpi "><span>总人数</span><b>1</b></div><div class="cls-kpi k-ok"><span>已提交</span><b>1</b></div><div class="cls-kpi "><span>缺考</span><b>0</b></div><div class="cls-kpi "><span>平均分</span><b>76</b></div><div class="cls-kpi k-ok"><span>合格率</span><b>100%</b></div></div><div class="cls-bar"><i style="width:100%;background:var(--ok)"></i></div><div class="cls-sub"><span>提交率 100%</span></div></div></div></div>
</div>
</div>
<script>/* 预览页:把"全屏/刷新"按钮做成 plainly 不可点,仅示意版式 */
document.addEventListener('DOMContentLoaded', function () {
["#btnFullscreen", "#btnRefresh", "#projectSel", "#classSelWrap"].forEach(function (s) {
var el = document.querySelector(s); if (el) el.setAttribute("disabled", "disabled");
});
});</script>
</body></html>
Binary file not shown.
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<title>多考试导出 · 每次考试(两行表头)(预览)</title><style>body{font-family:"Microsoft YaHei","PingFang SC",sans-serif;color:#222;background:#f7f7f7;margin:0;padding:24px}.page{background:#fff;border:1px solid #e3e3e3;border-radius:6px;padding:20px 22px;margin:0 auto 20px;max-width:1100px}.page h2{font-size:16px;margin:0 0 4px}.page .sub{font-size:12px;color:#777;margin:0 0 14px}table.bt{border-collapse:collapse;border:1px solid #999;width:100%;background:#fff;font-size:11px}table.bt td,table.bt th{border:1px solid #999;text-align:center;padding:3px 5px}table.bt th,table.bt thead td{background:#eaeaea;font-weight:700}table.bt thead tr:first-child td{background:#eaeaea}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}html,body,.pass,.absent,.max{-webkit-print-color-adjust:exact;print-color-adjust:exact}.legend{margin:10px 0 0;font-size:12px;color:#555}.legend i{display:inline-block;width:11px;height:11px;vertical-align:-1px;margin:0 4px 0 12px}.legend .r{color:#d0021b;font-weight:700}.legend .g{background:#63c384;font-weight:700}.sign{margin-top:16px;font-size:12px}</style></head><body>
<div class="page"><h2>多考试导出 · 每次考试(两行表头)</h2><p class="sub">合格线 110 分,达 1 次标绿</p>
<table class="bt"><thead><tr><td rowspan="2">学号</td><td rowspan="2">姓名</td><td rowspan="2">第几次</td><td colspan="2">第一次月考·数学</td><td colspan="2">第一次月考·英语</td></tr><tr><td>成绩</td><td></td><td>成绩</td><td></td></tr></thead><tbody><tr><td rowspan="5">356001</td><td rowspan="5">张三</td><td rowspan="4">1</td><td>112</td><td></td><td class="max">112</td><td></td></tr><tr><td>100</td><td></td><td>99</td><td></td></tr><tr><td class="max">136</td><td></td><td></td><td></td></tr><tr><td>127</td><td></td><td></td><td></td></tr><tr><td>合格次数</td><td class="pass">3</td><td></td><td class="pass">1</td><td></td></tr><tr><td rowspan="4">356002</td><td rowspan="4">李四</td><td rowspan="3">1</td><td></td><td></td><td>130</td><td></td></tr><tr><td></td><td></td><td>129</td><td></td></tr><tr><td></td><td></td><td class="max">133</td><td></td></tr><tr><td>合格次数</td><td>0</td><td></td><td class="pass">3</td><td></td></tr><tr><td rowspan="2">356003</td><td rowspan="2">王五</td><td>1</td><td class="max">95</td><td></td><td colspan="2" class="absent">未考</td></tr><tr><td>合格次数</td><td>0</td><td></td><td></td><td></td></tr></tbody></table>
<p class="legend">标记说明:<span class="r">最高分</span> 标红、<i class="r">红字</i>;<span class="g">合格次数</span> 始终显示(含 0 次),达 1 次标绿、<i class="g">绿底</i>;未达标显示次数、不标绿。
<p class="sign">教师签字:______________  教研组长签字:______________  日期:____________</p>
</div></body></html>
+7
View File
@@ -0,0 +1,7 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<title>单场导出 · 每次考试(每人一行的宽表)(预览)</title><style>body{font-family:"Microsoft YaHei","PingFang SC",sans-serif;color:#222;background:#f7f7f7;margin:0;padding:24px}.page{background:#fff;border:1px solid #e3e3e3;border-radius:6px;padding:20px 22px;margin:0 auto 20px;max-width:1100px}.page h2{font-size:16px;margin:0 0 4px}.page .sub{font-size:12px;color:#777;margin:0 0 14px}table.bt{border-collapse:collapse;border:1px solid #999;width:100%;background:#fff;font-size:11px}table.bt td,table.bt th{border:1px solid #999;text-align:center;padding:3px 5px}table.bt th,table.bt thead td{background:#eaeaea;font-weight:700}table.bt thead tr:first-child td{background:#eaeaea}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}html,body,.pass,.absent,.max{-webkit-print-color-adjust:exact;print-color-adjust:exact}.legend{margin:10px 0 0;font-size:12px;color:#555}.legend i{display:inline-block;width:11px;height:11px;vertical-align:-1px;margin:0 4px 0 12px}.legend .r{color:#d0021b;font-weight:700}.legend .g{background:#63c384;font-weight:700}.sign{margin-top:16px;font-size:12px}</style></head><body>
<div class="page"><h2>单场导出 · 每次考试(每人一行的宽表)</h2><p class="sub">第一次月考·数学 · 合格线 110 分,达 1 次标绿</p>
<table class="bt"><thead><tr><td>学号</td><td>姓名</td><td>第1次</td><td>第2次</td><td>第3次</td><td>第4次</td><td>合格次数</td></tr></thead><tbody><tr><td>356001</td><td>张三</td><td>112</td><td>100</td><td class="max">136</td><td>127</td><td class="pass">3</td></tr><tr><td>356002</td><td>李四</td><td></td><td></td><td></td><td></td><td>0</td></tr><tr><td>356003</td><td>王五</td><td class="max">95</td><td></td><td></td><td></td><td>0</td></tr></tbody></table>
<p class="legend">标记说明:<span class="r">最高分</span> 标红、<i class="r">红字</i>;<span class="g">合格次数</span> 始终显示(含 0 次),达 1 次标绿、<i class="g">绿底</i>;未达标显示次数、不标绿。
<p class="sign">教师签字:______________  教研组长签字:______________  日期:____________</p>
</div></body></html>
+287
View File
@@ -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": "第一次月考·数学"
}
}
}
+182 -37
View File
@@ -80,24 +80,9 @@ $rosterCount = Roster::count();
<section class="subbar">
<span class="subbar-label">导出</span>
<label class="subbar-item">排序
<select id="exportSortSel">
<option value="time">按提交时间</option>
<option value="sid">按学号</option>
<option value="score_desc">按成绩降序</option>
<option value="score_asc">按成绩升序</option>
</select>
</label>
<label class="subbar-item" title="每人只导一行:最近一次成绩 + 最高分 + 考试次数">
<input type="checkbox" id="exportLatest">按最新成绩
</label>
<label class="subbar-item" title="按班级拆分多个工作表(xlsx 多表)">
<input type="checkbox" id="exportSplit">按班级分表
</label>
<span class="hint" id="exportBarHint">成绩明细 / 每次考试 / 缺考名单 / 多考试对比,都在「导出」里配置</span>
<span class="spacer"></span>
<button class="btn" id="btnExport">导出成绩</button>
<button class="btn" id="btnExportAbsent">导出缺考</button>
<button class="btn" id="btnExportMulti" title="勾选多场考试,横向对比同一批学生的成绩">多考试对比导出</button>
<button class="btn btn-primary" id="btnExportOpen" title="导出与打印:选好导出内容、范围与标记方式,再导出 Excel 或直接打印">导出</button>
</section>
<section class="cards" id="statCards">
@@ -425,10 +410,18 @@ $rosterCount = Roster::count();
<div class="fi">
<label>打印范围</label>
<select id="ptScope">
<option value="latest" selected>按最新成绩打印(每人一行)</option>
<option value="all">打印全部记录(含每位学生历史提交)</option>
<option value="latest" selected>每人一行(汇总)</option>
<option value="all">每次考试一行(含历史提交)</option>
</select>
<p class="hint">全部记录会把每位学生的每次提交各打一行,完整留存历史;按最新成绩则每人仅显示当前代表成绩。</p>
<p class="hint">「每次考试一行」会把每位学生的每次提交各打一行,完整留存历史。</p>
</div>
<div class="fi">
<label>每人成绩口径</label>
<select id="ptRowScore" title="每人一行时的代表成绩;选「每次考试一行」时本项不生效">
<option value="latest" selected>最新成绩(最近一次提交)</option>
<option value="best">最高成绩(该生最高分)</option>
</select>
<p class="hint">与「导出 → 成绩明细」的「每人成绩」一致,默认最新成绩。</p>
</div>
<div class="fi checks">
<label><input type="checkbox" id="ptRank" checked> 显示名次(全表连续编号)</label>
@@ -437,6 +430,7 @@ $rosterCount = Roster::count();
<label><input type="checkbox" id="ptAbsent" title="成绩表后另起一页附缺考名单,含备注列供手写"> 附缺考名单(另起一页)</label>
</div>
<p class="hint">打印内容<b>不受页面当前筛选外的影响</b>,始终按当前班级/组别筛选结果输出;表头会在每页自动重复,末尾附签字栏。</p>
<p class="hint">要打印<b>多场考试对比</b>或<b>「每次考试」表</b>(最高分标红、合格次数达标绿底)?用工具栏的 <b>导出</b> 弹窗,选「多考试对比」或「每次考试」后点「打印」。</p>
<p class="hint" id="ptHint"></p>
</div>
<div class="modal-foot">
@@ -478,35 +472,186 @@ $rosterCount = Roster::count();
</div>
</div>
<!-- 多考试对比导出 -->
<div class="modal" id="multiExportModal">
<!-- ================= 统一导出 / 打印 =================
所有导出(成绩明细 / 每次考试 / 缺考名单 / 多考试对比)与打印都在这一个弹窗里配置:
先选「导出什么」,再按需展开「范围」「列与标记」「打印」三个区块。 -->
<div class="modal" id="exportModal">
<div class="modal-box wide">
<div class="modal-head"><h3>多考试成绩对比导出</h3><button class="x" data-close>&times;</button></div>
<div class="modal-head"><h3>导出 / 打印</h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<p class="hint" style="margin:0 0 12px">勾选多场考试后导出一张横向对比表:第一行是考试名称,前两列是学号 / 姓名,默认按学号排序;每场考试的成绩可按「最新」或「最高」显示。</p>
<div class="me-bar">
<label class="chk"><input type="radio" name="meScoreMode" value="latest" checked> 最新成绩</label>
<label class="chk"><input type="radio" name="meScoreMode" value="highest"> 最高成绩</label>
<span class="spacer"></span>
<button class="btn sm" id="btnMeSelAll">全选</button>
<button class="btn sm" id="btnMeClear">清空</button>
<!-- 1. 导出内容 -->
<div class="ex-sec">
<div class="ex-sec-title">导出内容</div>
<div class="ex-types">
<label class="ex-type" data-for="detail">
<input type="radio" name="exType" value="detail" checked>
<span class="ex-type-t">
<b>成绩明细</b>
<small>每人一行:学号 / 姓名 / 班级 / 组别 / 成绩 / 次数 / 时间 / 时长</small>
</span>
</label>
<label class="ex-type" data-for="attempts">
<input type="radio" name="exType" value="attempts">
<span class="ex-type-t">
<b>每次考试</b>
<small>按截图版式:一行一次考试,最高分标红、合格次数标绿</small>
</span>
</label>
<label class="ex-type" data-for="absent">
<input type="radio" name="exType" value="absent">
<span class="ex-type-t">
<b>缺考名单</b>
<small>学号 / 姓名 / 班级 / 组别(跟随下方筛选范围)</small>
</span>
</label>
<label class="ex-type" data-for="multi">
<input type="radio" name="exType" value="multi">
<span class="ex-type-t">
<b>多考试对比</b>
<small>勾选多场考试,横向对比:每场一列,最高分标红、合格次数标绿</small>
</span>
</label>
</div>
</div>
<div class="table-scroll mid">
<table class="grid compact" id="meTable">
<thead><tr><th style="width:6%"><input type="checkbox" id="meCheckAll" title="全选"></th><th style="width:8%">顺序</th><th>考试名称</th></tr></thead>
<tbody id="meBody"><tr><td colspan="3" class="empty">(暂无可导出的考试,请先在右上角刷新问卷列表)</td></tr></tbody>
</table>
<!-- 2. 统计范围(班级 / 组别) -->
<div class="ex-sec" id="exScopeSec">
<div class="ex-sec-title">统计范围 <small id="exScopeSum"></small></div>
<div class="ex-row">
<label class="ex-field">班级
<select id="exClsSel" multiple size="4" title="按住 Ctrl / ⌘ 可多选;不选 = 全部班级"></select>
</label>
<label class="ex-field">组别
<select id="exGrpSel" multiple size="4" title="按住 Ctrl / ⌘ 可多选;不选 = 全部组别"></select>
</label>
<div class="ex-field ex-field-tip">
<span class="hint" id="exScopeHint">不选 = 全部(Ctrl / ⌘ 多选)</span>
<span class="hint">选多个班级 / 组别时,导出会自动补出对应列并按相同值合并单元格。</span>
</div>
</div>
</div>
<p class="hint" id="meMeta">已选 0 场</p>
<!-- 3. 明细列与标记(成绩明细 / 每次考试 用) -->
<div class="ex-sec" id="exDetailSec">
<div class="ex-sec-title">成绩口径与列</div>
<div class="ex-row">
<label class="ex-field">每人成绩
<select id="exRowScore" title="每人一行时的代表成绩:最新 = 最近一次真实提交;最高 = 该生最高分">
<option value="latest" selected>最新成绩</option>
<option value="best">最高成绩</option>
</select>
</label>
<label class="ex-field">排序
<select id="exSort">
<option value="time">按提交时间</option>
<option value="sid">按学号</option>
<option value="score_desc">按成绩降序</option>
<option value="score_asc">按成绩升序</option>
</select>
</label>
<label class="ex-field">出表方式
<select id="exDetailMode">
<option value="latest" selected>每人一行(汇总)</option>
<option value="all">每次考试一行(明细)</option>
</select>
</label>
</div>
<div class="ex-checks" id="exDetailChecks">
<label><input type="checkbox" id="exSplit"> 按班级分工作表</label>
</div>
<p class="hint" id="exDetailHint">每人一行时可用「每人成绩」切换最新 / 最高;「每次考试一行」会保留每位学生的历史提交。</p>
</div>
<!-- 3.5 附加工作表(成绩明细 / 每次考试 / 多考试对比 共用) -->
<div class="ex-sec" id="exExtraSec">
<div class="ex-sec-title">附加内容</div>
<div class="ex-checks">
<label><input type="checkbox" id="exWithAbsent" checked> 附缺考名单(名单里有、所选考试全都没交的人)</label>
</div>
</div>
<!-- 4. 标记与合格次数(成绩明细 / 每次考试 / 多考试对比 共用) -->
<div class="ex-sec" id="exMarkSec">
<div class="ex-sec-title">标记与合格次数</div>
<div class="ex-row">
<label class="ex-field">合格次数阈值
<input type="number" id="exPassTimes" min="1" max="20" step="1" value="1"
title="同一场考试里「达到合格线」的次数 ≥ 此值才标绿(默认 1 次)">
</label>
<div class="ex-field ex-field-tip">
<span class="hint">合格线 = 该场考试满分 × 合格率(在「试卷设置」里按卷配置,缺省用全局)</span>
<span class="hint">例:阈值填 2,则该场考到合格线 ≥ 2 次才把「合格次数」标绿。</span>
</div>
</div>
<div class="ex-checks">
<label><input type="checkbox" id="exMarkBest" checked> 最高分标红</label>
<label><input type="checkbox" id="exPassRow" checked> 输出「合格次数」行(达标绿底)</label>
</div>
</div>
<!-- 5. 多考试内容 -->
<div class="ex-sec" id="exMultiSec" hidden>
<div class="ex-sec-title">多考试对比</div>
<div class="ex-row">
<label class="ex-field">表格形态
<select id="exMultiMode">
<option value="block" selected>两行表头(每次考试 + 合格次数)</option>
<option value="summary">成绩对比(每人一行)</option>
<option value="both">两者都出</option>
</select>
</label>
<label class="ex-field">对比成绩
<select id="exMultiScore">
<option value="latest" selected>最新成绩</option>
<option value="highest">最高成绩</option>
</select>
</label>
</div>
<div class="ex-pick">
<div class="ex-pick-head">
<span>勾选要对比的考试 <small id="exPickCount"></small></span>
<span class="spacer"></span>
<button class="btn sm" id="btnExPickAll" type="button">全选</button>
<button class="btn sm" id="btnExPickNone" type="button">清空</button>
</div>
<div class="table-scroll mid">
<table class="grid compact" id="exExamTable">
<thead><tr><th style="width:8%"><input type="checkbox" id="exCheckAll" title="全选"></th><th style="width:8%">顺序</th><th>考试名称</th></tr></thead>
<tbody id="exExamBody"><tr><td colspan="3" class="empty">(暂无可导出的考试,请先在右上角刷新问卷列表)</td></tr></tbody>
</table>
</div>
</div>
</div>
<!-- 6. 打印设置 -->
<div class="ex-sec" id="exPrintSec" hidden>
<div class="ex-sec-title">打印设置</div>
<div class="ex-row">
<label class="ex-field">纸张方向
<select id="exOrient" title="多考试打印默认横向(列多,横向不易折行)">
<option value="landscape" selected>横向(A4)</option>
<option value="portrait">纵向(A4)</option>
</select>
</label>
<label class="ex-field ex-field-grow">打印标题
<input type="text" id="exPrintTitle" placeholder="留空 = 用考试名自动生成">
</label>
</div>
</div>
<p class="hint" id="exSummaryLine" style="margin-top:4px"></p>
</div>
<div class="modal-foot">
<span class="spacer"></span>
<button class="btn" data-close>取消</button>
<button class="btn btn-primary" id="btnMeExport">导出对比表</button>
<button class="btn" id="btnExPrint">打印</button>
<button class="btn btn-primary" id="btnExExport">导出 Excel</button>
</div>
</div>
</div>
<script>
window.NY = {
appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>,
+187 -23
View File
@@ -5,7 +5,10 @@
* 仅用 PHP 内置函数生成最小可用的 .xlsx 文件:
* - 有 zip 扩展时走 ZipArchive;无 zip 扩展时回退到"手写 ZIP(stored)"实现,保证任何环境都能导出
* - 单元格统一按「共享字符串 + 文本格式(numFmtId=49)」写入,学号/账号等长数字不会被转成科学计数法
* - 第一行固定为表头(加粗 + 居中)
* - 表头行(默认第 1 行,可用 header_rows 指定多行)加粗 + 居中 + 边框
* - 支持合并单元格(merges),被合并的非首格写「空值 + 同样的样式」,保证合并区域四周边框完整
* - 支持单元格级样式(cell_styles):按 [行,列] 指定「红字 / 绿底」等标记,
* 用于"每场最高分标红、合格次数达标标绿"
*/
declare(strict_types=1);
@@ -17,13 +20,26 @@ final class XlsxWriter
private array $strings = [];
/** @var array<int,int> 索引 => 出现次数(sst 的 count 用,区别于 uniqueCount) */
private array $strCount = [];
/** @var array<int,array{name:string,rows:array,widths:array}> 工作表(按输出顺序) */
/** @var array<int,array{name:string,rows:array,widths:array,merges:array}> 工作表(按输出顺序) */
private array $sheets = [];
// 样式索引(见 stylesXml)
private const S_DEFAULT = 0; // 常规
private const S_HEADER = 1; // 表头:加粗 + 居中
private const S_TEXT = 2; // 文本格式(numFmtId=49)
private const S_DEFAULT = 0; // 常规(无边框)
private const S_HEADER = 1; // 表头:加粗 + 居中 + 细边框
private const S_TEXT = 2; // 数据:文本格式(numFmtId=49)+ 细边框
private const S_RED = 3; // 数据 + 红字(最高分标记)
private const S_GREEN = 4; // 数据 + 绿底(合格次数达标标记)
private const S_HEADER_GREEN = 5; // 表头样式 + 绿底(合格次数行的表头格)
/**
* 语义化标记 => 样式索引。调用方只管语义('red' / 'green' / 'header_green'),
* 具体配色编号由本类统一维护,避免外部硬编码样式下标。
*/
private const MARK_STYLES = [
'red' => self::S_RED,
'green' => self::S_GREEN,
'header_green' => self::S_HEADER_GREEN,
];
/**
* 生成单表 xlsx 二进制内容(兼容旧调用)
@@ -48,7 +64,11 @@ final class XlsxWriter
* 因此既不会出现长学号被转成科学计数法,也不会把以 = + - @ 开头的内容
* 当成公式执行(天然规避 CSV 公式注入问题)。
*
* @param array $sheets 每个元素:['name'=>工作表名, 'rows'=>二维表, 'widths'=>[列宽...]]
* @param array $sheets 每个元素:['name'=>工作表名, 'rows'=>二维表, 'widths'=>[列宽...], 'merges'=>[[r1,c1,r2,c2],...], 'header_rows'=>int,
* 'cell_styles'=>['r,c'=>'red'|'green'|'header_green', ...]]
* merges 为 1-based 闭区间(含表头行),如 [[2,1,4,1]] = A2:A4 纵向合并
* header_rows 表头行数(默认 1;两行表头的交叉表用 2)
* cell_styles 的键为 1-based 的 "行,列",值为语义标记名(未知标记静默忽略)
* @return string xlsx 文件二进制
*/
public static function renderMulti(array $sheets): string
@@ -63,18 +83,93 @@ final class XlsxWriter
continue; // 空表不输出,避免 Excel 提示修复
}
$w->sheets[] = [
'name' => self::safeSheetName((string)($sh['name'] ?? ''), count($w->sheets) + 1, $w->sheets),
'rows' => $rows,
'widths' => array_values((array)($sh['widths'] ?? [])),
'name' => self::safeSheetName((string)($sh['name'] ?? ''), count($w->sheets) + 1, $w->sheets),
'rows' => $rows,
'widths' => array_values((array)($sh['widths'] ?? [])),
'merges' => self::normalizeMerges((array)($sh['merges'] ?? []), count($rows), self::maxCols($rows)),
'header_rows' => max(1, (int)($sh['header_rows'] ?? 1)),
'cell_styles' => self::normalizeCellStyles((array)($sh['cell_styles'] ?? [])),
];
}
if ($w->sheets === []) {
// 保底:至少输出一张空表
$w->sheets[] = ['name' => 'Sheet1', 'rows' => [['']], 'widths' => []];
$w->sheets[] = ['name' => 'Sheet1', 'rows' => [['']], 'widths' => [], 'merges' => [], 'header_rows' => 1, 'cell_styles' => []];
}
return $w->build();
}
/**
* 归一化单元格样式表:键 "行,列"(1-based)=> 样式索引;未知标记直接丢弃。
*
* @param array $raw ['3,5' => 'red', [4,5] => 'green', ...](两种写法都接受)
* @return array<string,int>
*/
private static function normalizeCellStyles(array $raw): array
{
$out = [];
foreach ($raw as $k => $mark) {
if (is_array($k) && count($k) >= 2) {
$key = ((int)$k[0]) . ',' . ((int)$k[1]);
} else {
$key = (string)$k;
if (!preg_match('/^\s*(\d+)\s*,\s*(\d+)\s*$/', $key, $m)) {
continue;
}
$key = ((int)$m[1]) . ',' . ((int)$m[2]);
}
$idx = self::MARK_STYLES[(string)$mark] ?? null;
if ($idx === null) {
continue; // 未知标记:不写样式,不报错
}
[$r, $c] = array_map('intval', explode(',', $key));
if ($r < 1 || $c < 1) {
continue;
}
$out[$key] = $idx;
}
return $out;
}
/** 二维表最大列数(至少 1) */
private static function maxCols(array $rows): int
{
$max = 1;
foreach ($rows as $r) {
$max = max($max, count((array)$r));
}
return $max;
}
/**
* 归一化合并区间:过滤非法 / 退化(单格)区间,并按行列排序,保证 Excel 不报修复提示。
*
* @param array $raw [[r1,c1,r2,c2], ...] 1-based
* @return array<int,array{0:int,1:int,2:int,3:int}>
*/
private static function normalizeMerges(array $raw, int $maxRow, int $maxCol): array
{
$out = [];
foreach ($raw as $m) {
if (!is_array($m) || count($m) < 4) {
continue;
}
$r1 = (int)$m[0]; $c1 = (int)$m[1]; $r2 = (int)$m[2]; $c2 = (int)$m[3];
if ($r1 > $r2) { [$r1, $r2] = [$r2, $r1]; }
if ($c1 > $c2) { [$c1, $c2] = [$c2, $c1]; }
if ($r1 < 1 || $c1 < 1 || $r2 > $maxRow || $c2 > $maxCol) {
continue; // 越界丢弃
}
if ($r1 === $r2 && $c1 === $c2) {
continue; // 单格无需合并
}
$out[] = [$r1, $c1, $r2, $c2];
}
usort($out, static function (array $a, array $b): int {
return [$a[0], $a[1], $a[2], $a[3]] <=> [$b[0], $b[1], $b[2], $b[3]];
});
return $out;
}
/* ---------------- 内部:构建 ---------------- */
private function build(): string
@@ -103,7 +198,15 @@ final class XlsxWriter
$cells[$r][$c] = $this->intern((string)($val ?? ''));
}
}
$sheetXmls['xl/worksheets/sheet' . $n . '.xml'] = $this->sheetXml($cells, $maxRow, $maxCol, $sh['widths']);
$sheetXmls['xl/worksheets/sheet' . $n . '.xml'] = $this->sheetXml(
$cells,
$maxRow,
$maxCol,
$sh['widths'],
$sh['merges'] ?? [],
(int)($sh['header_rows'] ?? 1),
(array)($sh['cell_styles'] ?? [])
);
$names[] = $sh['name'];
}
@@ -165,7 +268,7 @@ final class XlsxWriter
return $i;
}
private function sheetXml(array $cells, int $maxRow, int $maxCol, array $widths = []): string
private function sheetXml(array $cells, int $maxRow, int $maxCol, array $widths = [], array $merges = [], int $headerRows = 1, array $cellStyles = []): string
{
$dim = 'A1:' . self::colName($maxCol) . $maxRow;
@@ -175,18 +278,49 @@ final class XlsxWriter
$cols .= '<col min="' . $c . '" max="' . $c . '" width="' . $w . '" customWidth="1"/>';
}
// 合并区域的非首格不能带值(否则 Excel 打开会提示"修复"),
// 但仍要写成「空值 + 带样式的格子」,否则合并区域的边框会在 Excel 里缺边。
$swallowed = [];
foreach ($merges as $m) {
for ($r = $m[0]; $r <= $m[2]; $r++) {
for ($c = $m[1]; $c <= $m[3]; $c++) {
if ($r === $m[0] && $c === $m[1]) {
continue;
}
$swallowed[$r][$c] = true;
}
}
}
$data = '';
for ($r = 1; $r <= $maxRow; $r++) {
$baseStyle = $r <= $headerRows ? self::S_HEADER : self::S_TEXT;
$line = '';
$has = false;
for ($c = 1; $c <= $maxCol; $c++) {
if (!isset($cells[$r][$c])) {
$sw = isset($swallowed[$r][$c]);
if (!isset($cells[$r][$c]) && !$sw) {
continue;
}
$has = true;
$s = $r === 1 ? self::S_HEADER : self::S_TEXT;
$line .= '<c r="' . self::colName($c) . $r . '" s="' . $s . '" t="s"><v>' .
$cells[$r][$c] . '</v></c>';
$ref = self::colName($c) . $r;
// 单元格级标记(红字 / 绿底)优先;合并区间跟随左上角首格的样式,
// 否则"合并成一格但只有左边半截有底色"会很难看。
$mark = $cellStyles[$r . ',' . $c] ?? null;
if ($mark === null && $sw) {
foreach ($merges as $m) {
if ($r >= $m[0] && $r <= $m[2] && $c >= $m[1] && $c <= $m[3]) {
$mark = $cellStyles[$m[0] . ',' . $m[1]] ?? null;
break;
}
}
}
$style = ($mark !== null) ? (int)$mark : $baseStyle;
if ($sw) {
$line .= '<c r="' . $ref . '" s="' . $style . '"/>';
} else {
$line .= '<c r="' . $ref . '" s="' . $style . '" t="s"><v>' . $cells[$r][$c] . '</v></c>';
}
}
if (!$has) {
continue;
@@ -194,6 +328,15 @@ final class XlsxWriter
$data .= '<row r="' . $r . '" spans="1:' . $maxCol . '">' . $line . '</row>';
}
$mergeXml = '';
if ($merges !== []) {
$mergeXml = '<mergeCells count="' . count($merges) . '">';
foreach ($merges as $m) {
$mergeXml .= '<mergeCell ref="' . self::colName($m[1]) . $m[0] . ':' . self::colName($m[3]) . $m[2] . '"/>';
}
$mergeXml .= '</mergeCells>';
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
'<dimension ref="' . $dim . '"/>' .
@@ -201,6 +344,7 @@ final class XlsxWriter
'<sheetFormatPr defaultRowHeight="13.5"/>' .
($cols !== '' ? '<cols>' . $cols . '</cols>' : '') .
'<sheetData>' . $data . '</sheetData>' .
$mergeXml .
'</worksheet>';
}
@@ -273,18 +417,38 @@ final class XlsxWriter
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
// numFmts: 49 = @(文本)
'<numFmts count="1"><numFmt numFmtId="49" formatCode="@"/></numFmts>' .
'<fonts count="2">' .
// 字体:0 常规 / 1 加粗(表头)/ 2 红色(最高分)/ 3 加粗绿字
'<fonts count="3">' .
'<font><sz val="11"/><color theme="1"/><name val="宋体"/><charset val="134"/></font>' .
'<font><b/><sz val="11"/><color theme="1"/><name val="宋体"/><charset val="134"/></font>' .
'<font><sz val="11"/><color rgb="FFD0021B"/><name val="宋体"/><charset val="134"/></font>' .
'</fonts>' .
'<fills count="2"><fill><patternFill patternType="none"/></fill>' .
'<fill><patternFill patternType="gray125"/></fill></fills>' .
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' .
// 填充:0 none / 1 gray125(Excel 约定占位)/ 2 合格绿底 / 3 表头绿底
// 绿色沿用导出截图里的浅绿 (#63C384 系):既醒目又不至于盖住黑色文字
'<fills count="4">' .
'<fill><patternFill patternType="none"/></fill>' .
'<fill><patternFill patternType="gray125"/></fill>' .
'<fill><patternFill patternType="solid"><fgColor rgb="FF63C384"/><bgColor indexed="64"/></patternFill></fill>' .
'<fill><patternFill patternType="solid"><fgColor rgb="FF63C384"/><bgColor indexed="64"/></patternFill></fill>' .
'</fills>' .
// 0 = 无边框,1 = 细灰边框(表格统一描边,打印/截图都清晰)
'<borders count="2">' .
'<border><left/><right/><top/><bottom/><diagonal/></border>' .
'<border>' .
'<left style="thin"><color rgb="FF808080"/></left>' .
'<right style="thin"><color rgb="FF808080"/></right>' .
'<top style="thin"><color rgb="FF808080"/></top>' .
'<bottom style="thin"><color rgb="FF808080"/></bottom>' .
'<diagonal/></border>' .
'</borders>' .
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' .
'<cellXfs count="3">' .
'<cellXfs count="6">' .
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>' . // 0 常规
'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"><alignment horizontal="center" vertical="center"/></xf>' . // 1 表头
'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"><alignment vertical="center"/></xf>' . // 2 文本
'<xf numFmtId="0" fontId="1" fillId="0" borderId="1" xfId="0" applyFont="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 1 表头
'<xf numFmtId="49" fontId="0" fillId="0" borderId="1" xfId="0" applyNumberFormat="1" applyBorder="1"><alignment vertical="center"/></xf>' . // 2 文本
'<xf numFmtId="49" fontId="2" fillId="0" borderId="1" xfId="0" applyNumberFormat="1" applyFont="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 3 红字(最高分)
'<xf numFmtId="49" fontId="0" fillId="2" borderId="1" xfId="0" applyNumberFormat="1" applyFill="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 4 绿底(合格次数达标)
'<xf numFmtId="0" fontId="1" fillId="3" borderId="1" xfId="0" applyFont="1" applyFill="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 5 表头绿底
'</cellXfs>' .
'<cellStyles count="1"><cellStyle name="常规" xfId="0" builtinId="0"/></cellStyles>' .
'</styleSheet>';
+757 -13
View File
@@ -1,11 +1,14 @@
<?php
/**
* 多考试对比导出:成绩交叉矩阵的纯逻辑(不依赖卷王客户端,便于单测)
* 多考试导出:成绩对比矩阵 + 「每次考试」块表的纯逻辑(不依赖卷王客户端,便于单测)
*
* 矩阵约定(满足需求):
* - 第一行 = 表头,列 A=学号、列 B=姓名、列 C 起每个选中的考试占一列,列标题为考试名称
* - 数据行:列 A=学号、列 B=姓名、列 C 起为该生在该场考试里的最新/最高成绩(未考则为空)
* - 默认按学号升序(数值优先,否则自然序)
* 两种版式:
* 1) 成绩对比(ny_assemble_multi_exam_matrix):一行一个学生、一列一场考试,只取最新 / 最高分
* 2) 每次考试块表(ny_assemble_block_sheet):两行表头,一行一次考试,学号 / 姓名 / 班级 / 组别
* 纵向合并单元格,没考的考试合并成「未考」一格(见需求截图版式)
*
* 说明:本文件刻意只用 PHP 7.0+ 语法(不用箭头函数 / 数字分隔符),
* 便于在只有 PHP 7.x 的机器上直接跑回归测试。
*/
declare(strict_types=1);
@@ -47,7 +50,7 @@ function ny_exam_cell_score(array $r, string $mode): ?float
}
/**
* 把多场考试已构建好的结果,组装成"成绩交叉对比"二维矩阵。
* 把多场考试已构建好的结果,组装成"成绩对比"二维矩阵。
*
* @param array $exams 关联数组 pid => ['name'=>string, 'rows'=>array](保持插入顺序即考试展示顺序)
* @param string $scoreMode 'latest' | 'highest'
@@ -87,17 +90,16 @@ function ny_assemble_multi_exam_matrix(array $exams, string $scoreMode): array
$sids = array_keys($sidSet);
// 默认按学号排序(数值优先,否则自然序)
usort($sids, static function ($a, $b): int {
$a = (string)$a;
$b = (string)$b;
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
return ny_sid_cmp((string)$a, (string)$b);
});
$head = array_merge(['学号', '姓名'], array_map(static fn($pid) => $examNames[$pid], $examOrder));
$head = ['学号', '姓名'];
foreach ($examOrder as $pid) {
$head[] = $examNames[$pid];
}
$rows = [];
foreach ($sids as $sid) {
$sid = (string)$sid;
$line = [$sid, $nameBySid[$sid] ?? ''];
foreach ($examOrder as $pid) {
$sc = $scoreByExam[$pid][$sid] ?? null;
@@ -107,3 +109,745 @@ function ny_assemble_multi_exam_matrix(array $exams, string $scoreMode): array
}
return ['head' => $head, 'rows' => $rows, 'exam_order' => $examOrder, 'exam_names' => $examNames];
}
/* ==================== 排序 / 列显示规则(纯逻辑,可单测) ==================== */
/** 学号比较:纯数字按数值比,否则自然序 */
function ny_sid_cmp(string $a, string $b): int
{
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
}
/** 文本比较:空值恒排末尾(班级 / 组别排序用,避免"未分班"插在中间) */
function ny_text_cmp(string $a, string $b): int
{
if ($a === $b) {
return 0;
}
if ($a === '') {
return 1;
}
if ($b === '') {
return -1;
}
return strnatcmp($a, $b);
}
/**
* 多考试导出「是否补出班级 / 组别列」的判定(按需求)。
*
* 规则:
* - 选了 ≥2 个班级 → 出「班级」列;只选 1 个班级 → 不出(全班一样,纯噪音)
* - 选了 ≥2 个组别 → 出「组别」列;只选 1 个组别 → 不出
* - 未选班级(= 全部)时按实际数据判断:数据确实跨多个班级才出列
* - 「只选了 1 个班级且未选组别」→ 班级 / 组别两列都不出
*
* @param array $clsPick 已选班级([] = 全部)
* @param array $grpPick 已选组别([] = 全部)
* @param array $seenCls 实际数据里出现的班级
* @param array $seenGrp 实际数据里出现的组别
* @return array{with_cls:bool,with_grp:bool}
*/
function ny_multi_col_flags(array $clsPick, array $grpPick, array $seenCls, array $seenGrp): array
{
$nCls = count($clsPick);
$nGrp = count($grpPick);
$withCls = ($nCls > 1) || ($nCls === 0 && count($seenCls) > 1);
$withGrp = ($nGrp > 1) || ($nGrp === 0 && $nCls !== 1 && count($seenGrp) > 1);
return ['with_cls' => $withCls, 'with_grp' => $withGrp];
}
/* ==================== 「每次考试」块表(两行表头 + 合并单元格) ==================== */
/**
* 取某个学生在某场考试里的「每次考试」列表(按提交时间升序 = 第1次、第2次…)。
*
* - skipTemp 时草稿不计入、也不占"第几次"编号(导出后 1..N 连续)
* - `is_best` 在**被导出的那些次**里比较(跳过草稿时,草稿分不再抢走「是否最佳」标记,
* 否则会出现整场没有一次被标「是」的怪表);同分只标最早达成的那次
* - 保留草稿时草稿条目的 `draft` 为 true,调用方可据此标注「(草稿)」
*
* @return array<int,array{idx:int,score:string,ts:int,is_best:bool,draft:bool}>
*/
function ny_exam_attempt_list(array $r, bool $skipTemp = false): array
{
$list = array_values((array)($r['attempts_list'] ?? []));
if ($list === []) {
// 无明细:代表行本身算一次考试
$list = [[
'idx' => 1,
'score' => $r['score'] ?? null,
'submit_ts' => (int)($r['submit_ts'] ?? 0),
'temp_save' => false,
]];
}
// 统一按提交时间升序(旧 -> 新)
usort($list, static function (array $x, array $y): int {
return ((int)($x['submit_ts'] ?? 0)) <=> ((int)($y['submit_ts'] ?? 0));
});
// 先过滤,再在被导出的那些次里找最高分
$kept = [];
foreach ($list as $a) {
if ($skipTemp && !empty($a['temp_save'])) {
continue;
}
$kept[] = $a;
}
$bestVal = null;
$bestAtIdx = -1;
foreach ($kept as $i => $a) {
$v = $a['score'] ?? null;
if ($v === null || $v === '') {
continue;
}
if ($bestVal === null || (float)$v > $bestVal) {
$bestVal = (float)$v;
$bestAtIdx = $i;
}
}
$out = [];
foreach ($kept as $i => $a) {
$score = $a['score'] ?? null;
$out[] = [
'idx' => $i + 1,
'score' => ($score === null || $score === '') ? '' : (string)$score,
'ts' => (int)($a['submit_ts'] ?? 0),
'is_best' => ($i === $bestAtIdx),
'draft' => !empty($a['temp_save']),
];
}
return $out;
}
/**
* 组装「每次考试」块表(两行表头 / 合并单元格 / 未考占位)——与需求截图一致。
*
* 版式(对应需求截图):
* ┌──────┬──────┬────────┬────────────────┬────────────────┐
* │ 学号 │ 姓名 │ 第几次 │ 考试名称1 │ 考试名称2 │ ← 第 1 行:考试名横向合并 2 列
* ├──────┼──────┼────────┼────────┬───────┼────────┬───────┤
* │ │ │ │ 成绩 │ 成绩 │ 成绩 │ 成绩 │ ← 第 2 行(第几次纵向贯穿两行)
* ├──────┼──────┼────────┼────────┼───────┼────────┼───────┤
* │24030…│ 张三 │ 1 │ 127 │ 127 │ 134 │ 134 │
* │(合并)│(合并)│ 2 │ 132 │ 132 │ 136 │ 136 │ ← 最高分用红字
* │ │ │ … │ … │ … │ │ │
* │ │ │合格次数│ 2 │ 1 │ 2 │ 2 │ ← 达标绿底、未达标空白
* └──────┴──────┴────────┴────────┴───────┴────────┴───────┘
*
* 约定:
* - 一名学生占 max(各场有效次数) + 1 行(+1 是末尾的「合格次数」汇总行)
* - 学号 / 姓名 / 第几次 三列按学生块纵向合并(第几次与学号同高,贯穿表头以下全部行)
* - 每场考试占 2 列:考试名称(第 1 行横向合并)+ 成绩(仅第 2 行有「成绩」小标题)
* - 只在该生某场考试完全没考时写「未考」并合并该场全部列;考了但次数少 → 留空
* - 「合格次数」= 该场成绩 ≥ 合格线的次数;始终显示(含 0 次),只有 ≥ 阈值才标绿底
* - 每场成绩等于该场最高分的那次用红字标出(同分只标最早达成的一次)
*
* @param array $exams 关联数组 pid => ['name'=>string, 'rows'=>array](保持插入顺序 = 考试列顺序)
* @param array $opts [
* 'skip_temp' => bool, 'with_cls' => bool, 'with_grp' => bool, 'absent_label' => string,
* 'pass_line' => float|null, 合格线分数(null = 不判定合格,不出现合格次数行)
* 'pass_times' => int, 至少合格几次才算通过(默认 1)
* 'mark_best' => bool, 是否把每场最高分标红(默认 true)
* 'pass_row' => bool, 是否输出「合格次数」汇总行(默认 true,且需 pass_line 有效)
* ]
* @return array{rows:array,merges:array,widths:array,header_rows:int,exam_order:array,exam_names:array,exam_col:array,cell_styles:array,students:int,total_rows:int,with_cls:bool,with_grp:bool,pass_row:bool,cols:array}
*/
function ny_assemble_block_sheet(array $exams, array $opts = []): array
{
// 默认跳过暂存草稿(草稿不是有效成绩);显式传 skip_temp=false 可保留
$skipTemp = array_key_exists('skip_temp', $opts) ? !empty($opts['skip_temp']) : true;
$withCls = !empty($opts['with_cls']);
$withGrp = !empty($opts['with_grp']);
$absent = (string)($opts['absent_label'] ?? '未考');
$markBest = array_key_exists('mark_best', $opts) ? !empty($opts['mark_best']) : true;
// 合格线:null / 非正数 => 不判定合格(不输出合格次数行)
$passLine = $opts['pass_line'] ?? null;
$passLine = ($passLine === null || $passLine === '') ? null : (float)$passLine;
if ($passLine !== null && $passLine <= 0) {
$passLine = null;
}
$passTimes = (int)($opts['pass_times'] ?? 1);
if ($passTimes < 1) {
$passTimes = 1;
}
$wantPassRow = array_key_exists('pass_row', $opts) ? !empty($opts['pass_row']) : true;
$passRow = ($wantPassRow && $passLine !== null);
$examOrder = [];
$examNames = [];
$stu = []; // sid => ['name','cls','grp','exams' => [pid => 每次考试列表]]
foreach ($exams as $pid => $ex) {
$examOrder[] = $pid;
$examNames[$pid] = trim((string)($ex['name'] ?? '')) !== '' ? (string)$ex['name'] : (string)$pid;
foreach ((array)($ex['rows'] ?? []) as $r) {
$sid = trim((string)($r['sid'] ?? ''));
if ($sid === '') {
continue;
}
if (!isset($stu[$sid])) {
$stu[$sid] = ['name' => '', 'cls' => '', 'grp' => '', 'exams' => []];
}
// 同一学生在多场考试里字段一致,取首个非空即可
if ($stu[$sid]['name'] === '' && trim((string)($r['name'] ?? '')) !== '') {
$stu[$sid]['name'] = (string)$r['name'];
}
if ($stu[$sid]['cls'] === '' && trim((string)($r['cls'] ?? '')) !== '') {
$stu[$sid]['cls'] = (string)$r['cls'];
}
if ($stu[$sid]['grp'] === '' && trim((string)($r['grp'] ?? '')) !== '') {
$stu[$sid]['grp'] = (string)$r['grp'];
}
$stu[$sid]['exams'][$pid] = ny_exam_attempt_list($r, $skipTemp);
}
}
// 学生顺序:有班级 / 组别列时按 班级 → 组别 → 学号(同班同组自然相邻,合并单元格才不会断开)
$sids = array_keys($stu);
usort($sids, static function ($a, $b) use ($stu, $withCls, $withGrp): int {
$a = (string)$a; // PHP 会把纯数字学号键转成 int,这里统一回字符串
$b = (string)$b;
if ($withCls) {
$d = ny_text_cmp($stu[$a]['cls'], $stu[$b]['cls']);
if ($d !== 0) {
return $d;
}
}
if ($withGrp) {
$d = ny_text_cmp($stu[$a]['grp'], $stu[$b]['grp']);
if ($d !== 0) {
return $d;
}
}
return ny_sid_cmp($a, $b);
});
/* ---------------- 列布局 ----------------
前导列:班级? / 组别? / 学号 / 姓名 / 第几次(各考试共用一列,纵向合并到学生块)
考试区:每场 2 列(考试名称跨度由第 1 行横向合并,第 2 行只有「成绩」小标题) */
$colCls = $withCls ? 1 : 0;
$colGrp = $withGrp ? ($withCls ? 2 : 1) : 0;
$colSid = 1 + ($withCls ? 1 : 0) + ($withGrp ? 1 : 0);
$colName = $colSid + 1;
$colIdx = $colName + 1; // 「第几次」(所有考试共用)
$colExam0 = $colIdx + 1;
$h1 = [];
$h2 = [];
if ($withCls) { $h1[] = '班级'; $h2[] = ''; }
if ($withGrp) { $h1[] = '组别'; $h2[] = ''; }
$h1[] = '学号';
$h2[] = '';
$h1[] = '姓名';
$h2[] = '';
$h1[] = '第几次';
$h2[] = '';
$merges = [];
// 表头两行的纵向合并:班级 / 组别 / 学号 / 姓名 / 第几次
foreach ([$colCls, $colGrp, $colSid, $colName, $colIdx] as $c) {
if ($c > 0) {
$merges[] = [1, $c, 2, $c];
}
}
// 每场考试占 2 列:考试名称(第 1 行横向合并)+ 成绩(第 2 行小标题)
$examCol = [];
$c = $colExam0;
foreach ($examOrder as $pid) {
$examCol[$pid] = $c;
$h1[] = $examNames[$pid];
$h1[] = '';
$h2[] = '成绩';
$h2[] = '';
$merges[] = [1, $c, 1, $c + 1];
$c += 2;
}
$colPass = $colExam0 + 2 * count($examOrder); // 「合格次数」所在列(若启用)
$rows = [$h1, $h2];
$rn = 2; // 当前行号(两行表头已占 1~2)
$total = 0;
$blocks = []; // 学生块:['start','end','cls','grp']
// 单元格标记:'行,列' => 'red' | 'green' | 'header_green'
$marks = [];
foreach ($sids as $sid) {
$sid = (string)$sid;
$s = $stu[$sid];
$lists = [];
$maxN = 0;
foreach ($examOrder as $pid) {
$l = $s['exams'][$pid] ?? [];
$lists[$pid] = $l;
if (count($l) > $maxN) {
$maxN = count($l);
}
}
if ($maxN < 1) {
continue; // 该生所有考试都没有有效提交(例如只有草稿被跳过)
}
// 每场最高分(用于标红;只在被导出的那些次里比较,与列表口径一致)
$bestScore = [];
foreach ($examOrder as $pid) {
$bv = null;
foreach ($lists[$pid] as $a) {
$sv = $a['score'];
if ($sv === '' || $sv === null) {
continue;
}
if ($bv === null || (float)$sv > $bv) {
$bv = (float)$sv;
}
}
$bestScore[$pid] = $bv;
}
$start = $rn + 1;
for ($i = 0; $i < $maxN; $i++) {
$rn++;
$line = [];
if ($withCls) {
$line[] = ($i === 0) ? $s['cls'] : '';
}
if ($withGrp) {
$line[] = ($i === 0) ? $s['grp'] : '';
}
$line[] = ($i === 0) ? $sid : '';
$line[] = ($i === 0) ? $s['name'] : '';
$line[] = (string)($i + 1); // 第几次:所有考试共用一列
foreach ($examOrder as $pid) {
$a = $lists[$pid][$i] ?? null;
if ($a === null) {
$line[] = '';
$line[] = '';
continue;
}
$line[] = $a['score'];
$line[] = ''; // 每场固定 2 列:第 1 列成绩、第 2 列是合并区间内的空位。
// 漏掉这个空串会让后续考试整体左移一列——数据串列、
// 表头合并区间与实际列对不上(截图里的错位就是它)。
// 最高分标红:只标一处(同分只标最早达成的那一行)
if ($markBest && $a['score'] !== '' && $bestScore[$pid] !== null
&& (float)$a['score'] === $bestScore[$pid] && $a['is_best']) {
$marks[$rn . ',' . $examCol[$pid]] = 'red';
}
}
$rows[] = $line;
$total++;
}
$end = $rn;
if ($end > $start) {
$merges[] = [$start, $colSid, $end, $colSid];
$merges[] = [$start, $colName, $end, $colName];
$merges[] = [$start, $colIdx, $end, $colIdx];
}
// 完全没考的考试:该场全部列 × 学生块高 → 合并成「未考」
foreach ($examOrder as $pid) {
if ($lists[$pid] !== []) {
continue;
}
$cc = $examCol[$pid];
$rows[$start - 1][$cc - 1] = $absent;
$merges[] = [$start, $cc, $end, $cc + 1];
}
$blocks[] = ['start' => $start, 'end' => $end, 'cls' => $s['cls'], 'grp' => $s['grp']];
}
/* ---------------- 末尾「合格次数」汇总行 ----------------
每个学生块末尾追加一行「合格次数」:每场考试列出该生达到合格线的次数;
次数始终显示(含 0 次),达到阈值(默认 1 次)才标绿底。
追加行会改变所有行号,因此这里不走"插入",而是带着合格次数行整体重建一遍
(学生块高 = maxN + 1),保证合并区间 / 标红位置的坐标一次算对。 */
if ($passRow) {
$rows = [$h1, $h2];
$merges = [];
$marks = [];
$rn = 2;
$total = 0;
$newBlocks = [];
foreach ([$colCls, $colGrp, $colSid, $colName, $colIdx] as $cc) {
if ($cc > 0) {
$merges[] = [1, $cc, 2, $cc];
}
}
$cc = $colExam0;
foreach ($examOrder as $pid) {
$merges[] = [1, $cc, 1, $cc + 1];
$cc += 2;
}
foreach ($sids as $sid) {
$sid = (string)$sid;
$s = $stu[$sid];
$lists = [];
$maxN = 0;
foreach ($examOrder as $pid) {
$l = $s['exams'][$pid] ?? [];
$lists[$pid] = $l;
if (count($l) > $maxN) {
$maxN = count($l);
}
}
if ($maxN < 1) {
continue;
}
$bestScore = [];
foreach ($examOrder as $pid) {
$bv = null;
foreach ($lists[$pid] as $a) {
$sv = $a['score'];
if ($sv === '' || $sv === null) {
continue;
}
if ($bv === null || (float)$sv > $bv) {
$bv = (float)$sv;
}
}
$bestScore[$pid] = $bv;
}
$start = $rn + 1;
for ($i = 0; $i < $maxN; $i++) {
$rn++;
$line = [];
if ($withCls) { $line[] = ($i === 0) ? $s['cls'] : ''; }
if ($withGrp) { $line[] = ($i === 0) ? $s['grp'] : ''; }
$line[] = ($i === 0) ? $sid : '';
$line[] = ($i === 0) ? $s['name'] : '';
$line[] = (string)($i + 1); // 第几次:所有考试共用一列
foreach ($examOrder as $pid) {
$a = $lists[$pid][$i] ?? null;
if ($a === null) {
$line[] = '';
$line[] = '';
continue;
}
$line[] = $a['score'];
$line[] = ''; // 同上:每场固定 2 列,缺了这个空串会整体左移
if ($markBest && $a['score'] !== '' && $bestScore[$pid] !== null
&& (float)$a['score'] === $bestScore[$pid] && $a['is_best']) {
$marks[$rn . ',' . $examCol[$pid]] = 'red';
}
}
$rows[] = $line;
$total++;
}
// 完全没考的考试:该场全部列 × 学生块高 → 合并成「未考」
foreach ($examOrder as $pid) {
if ($lists[$pid] !== []) {
continue;
}
$cx = $examCol[$pid];
$rows[$start - 1][$cx - 1] = $absent;
$merges[] = [$start, $cx, $rn, $cx + 1];
}
// 合格次数行(每个学生块一行)
$rn++;
$passLineRow = [];
if ($withCls) { $passLineRow[] = ''; }
if ($withGrp) { $passLineRow[] = ''; }
$passLineRow[] = '';
$passLineRow[] = '';
$passLineRow[] = '合格次数'; // 落在「第几次」列,与截图一致
foreach ($examOrder as $pid) {
$list = $lists[$pid];
if ($list === []) {
// 整场未考:与「未考」合并格保持一致,不再单独判断合格次数
$passLineRow[] = '';
$passLineRow[] = '';
continue;
}
$cnt = 0;
foreach ($list as $a) {
if ($a['score'] !== '' && $a['score'] !== null && (float)$a['score'] >= $passLine) {
$cnt++;
}
}
/* 合格次数始终显示(含 0 次);只有达到阈值才标绿底。
之前写 '' 会把「考了但没达标」的人整格隐藏,用户要看全部人次。 */
$passLineRow[] = (string)$cnt;
$passLineRow[] = '';
if ($cnt >= $passTimes) {
$marks[$rn . ',' . $examCol[$pid]] = 'green'; // 达标:绿底
}
}
$rows[] = $passLineRow;
$total++;
/* 学号 / 姓名 纵向合并到「合格次数」行(这两列该行本就是空的,合并正确);
「第几次」只合并到最后一次考试行,末行留给「合格次数」标签——
若连合格次数行一起合并,该格会被当作"被合并的非首格"而丢掉值。 */
$merges[] = [$start, $colSid, $rn, $colSid];
$merges[] = [$start, $colName, $rn, $colName];
if ($rn > $start) {
$merges[] = [$start, $colIdx, $rn - 1, $colIdx];
}
$newBlocks[] = ['start' => $start, 'end' => $rn, 'cls' => $s['cls'], 'grp' => $s['grp']];
}
$blocks = $newBlocks;
}
// 班级 / 组别:连续相同值的相邻学生块合并成一格
foreach ([['cls', $colCls], ['grp', $colGrp]] as $spec) {
$key = $spec[0];
$col = (int)$spec[1];
if ($col < 1) {
continue;
}
$n = count($blocks);
$i = 0;
while ($i < $n) {
$val = $blocks[$i][$key];
$j = $i;
while ($j + 1 < $n && $blocks[$j + 1][$key] === $val) {
$j++;
}
if ($val !== '' && $j > $i) {
$merges[] = [$blocks[$i]['start'], $col, $blocks[$j]['end'], $col];
}
$i = $j + 1;
}
}
// 合并区间去重(重复区间会让 Excel 打开时提示"修复")
$seen = [];
$uniq = [];
foreach ($merges as $m) {
$k = $m[0] . ',' . $m[1] . ',' . $m[2] . ',' . $m[3];
if (isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$uniq[] = $m;
}
$merges = $uniq;
/* 列宽:够用就好,宽了会把人挤到屏幕外(截图里学号列两侧大片空白就是这么来的)。
- 学号按实际内容估宽(10 位数字约 11,超过 12 位才放宽),不再一律 20
- 姓名同样按最长姓名估宽
- 每场考试占 2 列,但第 2 列恒为合并区间内的空位 → 给最小宽度 2.5,
把宽度让给真正承载成绩的第 1 列(否则一屏只看得到两场考试) */
$maxSidLen = 2;
$maxNameLen = 2;
foreach ($stu as $s) {
// 名单里可能缺 sid / name 的条目(脏数据):估宽只能兜住,不能因此报 Undefined key
$sidLen = mb_strlen((string)($s['sid'] ?? ''), 'UTF-8');
$nameLen = mb_strlen((string)($s['name'] ?? ''), 'UTF-8');
if ($sidLen > $maxSidLen) {
$maxSidLen = $sidLen;
}
if ($nameLen > $maxNameLen) {
$maxNameLen = $nameLen;
}
}
// Excel 列宽单位≈半角字符数;中文按 2 个半角宽算,再留 2 的余量
$sidW = min(18, max(10, $maxSidLen + 2));
$nameW = min(20, max(9, $maxNameLen * 2 + 2));
$widths = [];
if ($withCls) {
$widths[] = 14; // 班级名通常较长(如「计算机2401」)
}
if ($withGrp) {
$widths[] = 9;
}
$widths[] = $sidW; // 学号
$widths[] = $nameW; // 姓名
$widths[] = 9; // 第几次
foreach ($examOrder as $pid) {
$widths[] = 10; // 成绩列(每场第 1 列)
$widths[] = 2.5; // 合并区内的空列,给最小宽度即可
}
return [
'rows' => $rows,
'merges' => $merges,
'widths' => $widths,
'header_rows' => 2,
'exam_order' => $examOrder,
'exam_names' => $examNames,
'exam_col' => $examCol,
'cell_styles' => $marks,
'students' => count($blocks),
'total_rows' => $total,
'with_cls' => $withCls,
'with_grp' => $withGrp,
'pass_row' => $passRow,
'pass_line' => $passLine,
'pass_times' => $passTimes,
'cols' => [
'cls' => $colCls, 'grp' => $colGrp, 'sid' => $colSid,
'name' => $colName, 'idx' => $colIdx, 'exam0' => $colExam0,
],
];
}
/**
* 组装「单场考试的每次考试」宽表——对应需求截图二。
*
* 版式:
* ┌──────┬──────┬──────┬──────┬─────┬────────┐
* │ 学号 │ 姓名 │ 第1次│ 第2次│ ... │合格次数│
* ├──────┼──────┼──────┼──────┼─────┼────────┤
* │24030…│ 张三 │ 127 │ 132 │ 143 │ 2 │ ← 最高分红字、合格次数达标绿底
* └──────┴──────┴──────┴──────┴─────┴────────┘
*
* 每人一行;列数 = 2 + 该场最多考试次数 + 1(合格次数)。
* 某生次数少于最大值时留空。仅在"不带考试耗时 / 提交时间"时使用本版式,
* 带了耗时 / 时间就会失去宽表的意义(每人一行放不下每次的耗时),此时退回明细表。
*
* @param array $exam 单场考试 ['name'=>string, 'rows'=>array]
* @param array $opts ['skip_temp'=>bool, 'pass_line'=>float|null, 'pass_times'=>int, 'mark_best'=>bool, 'pass_row'=>bool]
* @return array{rows:array,merges:array,widths:array,header_rows:int,cell_styles:array,students:int,total_rows:int,attempt_cols:int,pass_row:bool}
*/
function ny_assemble_attempt_wide_sheet(array $exam, array $opts = []): array
{
$skipTemp = array_key_exists('skip_temp', $opts) ? !empty($opts['skip_temp']) : true;
$markBest = array_key_exists('mark_best', $opts) ? !empty($opts['mark_best']) : true;
$passLine = $opts['pass_line'] ?? null;
$passLine = ($passLine === null || $passLine === '') ? null : (float)$passLine;
if ($passLine !== null && $passLine <= 0) {
$passLine = null;
}
$passTimes = max(1, (int)($opts['pass_times'] ?? 1));
$wantPassRow = array_key_exists('pass_row', $opts) ? !empty($opts['pass_row']) : true;
$passRow = ($wantPassRow && $passLine !== null);
$stu = [];
foreach ((array)($exam['rows'] ?? []) as $r) {
$sid = trim((string)($r['sid'] ?? ''));
if ($sid === '') {
continue;
}
if (!isset($stu[$sid])) {
$stu[$sid] = [
'sid' => $sid,
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
'grp' => (string)($r['grp'] ?? ''),
'list' => ny_exam_attempt_list($r, $skipTemp),
];
}
}
$sids = array_keys($stu);
usort($sids, static function ($a, $b) use ($stu): int {
$a = (string)$a;
$b = (string)$b;
$d = ny_text_cmp($stu[$a]['cls'], $stu[$b]['cls']);
if ($d !== 0) {
return $d;
}
$d = ny_text_cmp($stu[$a]['grp'], $stu[$b]['grp']);
if ($d !== 0) {
return $d;
}
return ny_sid_cmp($a, $b);
});
$maxN = 0;
foreach ($sids as $sid) {
$n = count($stu[$sid]['list']);
if ($n > $maxN) {
$maxN = $n;
}
}
if ($maxN < 1) {
$maxN = 1;
}
$head = ['学号', '姓名'];
for ($i = 1; $i <= $maxN; $i++) {
$head[] = '第' . $i . '次';
}
if ($passRow) {
$head[] = '合格次数';
}
$rows = [$head];
$marks = [];
$rn = 1;
$colPass = 2 + $maxN + 1; // 合格次数列(1-based)
foreach ($sids as $sid) {
$sid = (string)$sid;
$s = $stu[$sid];
$list = $s['list'];
$rn++;
$line = [$sid, $s['name']];
$best = null;
foreach ($list as $a) {
if ($a['score'] === '' || $a['score'] === null) {
continue;
}
$v = (float)$a['score'];
if ($best === null || $v > $best) {
$best = $v;
}
}
for ($i = 0; $i < $maxN; $i++) {
$a = $list[$i] ?? null;
if ($a === null) {
$line[] = '';
continue;
}
$line[] = $a['score'];
if ($markBest && $a['score'] !== '' && $best !== null
&& (float)$a['score'] === $best && $a['is_best']) {
$marks[$rn . ',' . (3 + $i)] = 'red'; // 第1次在第 3 列
}
}
if ($passRow) {
$cnt = 0;
foreach ($list as $a) {
if ($a['score'] !== '' && $a['score'] !== null && (float)$a['score'] >= $passLine) {
$cnt++;
}
}
// 合格次数始终显示(含 0 次);只有达到阈值才标绿底
$line[] = (string)$cnt;
if ($cnt >= $passTimes) {
$marks[$rn . ',' . $colPass] = 'green';
}
}
$rows[] = $line;
}
$widths = [20, 14];
for ($i = 0; $i < $maxN; $i++) {
$widths[] = 9;
}
if ($passRow) {
$widths[] = 11;
}
return [
'rows' => $rows,
'merges' => [],
'widths' => $widths,
'header_rows' => 1,
'cell_styles' => $marks,
'students' => count($sids),
'total_rows' => count($sids),
'attempt_cols' => $maxN,
'pass_row' => $passRow,
'pass_line' => $passLine,
'pass_times' => $passTimes,
];
}
+20 -5
View File
@@ -11,9 +11,12 @@ pick() {
if [ -n "$cand" ] && [ -x "$cand" ]; then echo "$cand"; return; fi
command -v "$fallback" 2>/dev/null || echo "$fallback"
}
PHP="$(pick "${PHP_BIN:-C:/Users/Zhang/.workbuddy/binaries/php/php.exe}" php)"
# 本项目用 PHP 7.4+ 语法(箭头函数 / 类型化属性),必须是 PHP 8;
# 工作区里已备好便携版 8.2.34(其 php.ini 已开启 pdo_sqlite / zip / mbstring),
# 想换别的解释器就设 PHP_BIN=... 覆盖
PHP="$(pick "${PHP_BIN:-C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe}" php)"
NODE="$(pick "${NODE_BIN:-}" node)"
INI="-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite"
INI="${PHP_INI:-}"
echo "PHP = $PHP"
echo "NODE = $NODE"
@@ -32,15 +35,27 @@ NY_DATA_TMP="$(pwd)/.ny-test-data"
mkdir -p "$NY_DATA_TMP"
# 说明:test-login-loop.js 会清空数据目录里的账密来复现"未配置账号"现场,
# 即使不传 NY_DATA 它也会自己建临时目录,绝不回退到项目 data/。
for f in tools/test-config-save.js tools/test-upload-guard.js tools/test-whitelist.js tools/test-login-loop.js; do
# 这几段依赖 mock-server(dashboard-nav 自带名单,另 4 段需本机 data/roster.json)。
# 逐段容错,避免一个坏掉的环境把后面的测试整段带走;失败照常记账,末尾统一汇总退出码。
e2e_fail=0
for f in tools/test-config-save.js tools/test-upload-guard.js tools/test-whitelist.js tools/test-login-loop.js tools/test-dashboard-nav.js; do
echo ""
echo ">>> $f (NY_DATA=$NY_DATA_TMP)"
NY_DATA="$NY_DATA_TMP" "$NODE" "$f"
if ! NY_DATA="$NY_DATA_TMP" "$NODE" "$f"; then e2e_fail=$((e2e_fail + 1)); fi
done
# test-draw-alloc.js 不读写连接配置,沿用项目 data/ 即可
echo ""
echo ">>> tools/test-draw-alloc.js"
"$NODE" "tools/test-draw-alloc.js"
"$NODE" "tools/test-draw-alloc.js" || e2e_fail=$((e2e_fail + 1))
# 多考试打印渲染(纯前端函数,不需要服务器):从 app.js 抽取 sheetToHtml 校验合并单元格换算
echo ""
echo ">>> tools/test-block-print.js"
"$NODE" "tools/test-block-print.js" || e2e_fail=$((e2e_fail + 1))
echo ""
if [ "$e2e_fail" -gt 0 ]; then
echo "全部测试跑完,但有 $e2e_fail 个 Node 段失败(多为 mock-server 缺 data/roster.json 导致的环境问题)"
exit 1
fi
echo "全部测试完成。"
+286 -1
View File
@@ -34,7 +34,7 @@ function check(string $name, bool $cond, string $detail = ''): void
function stuRow(string $sid, string $name, array $scoresAsc, array $tempMarks = [], ?float $best = null): array
{
$list = [];
$t = 1_000_000;
$t = 1000000; // 不用数字分隔符,保证 PHP 7.3+ 都能跑回归测试
foreach ($scoresAsc as $i => $sc) {
$t += 600;
$list[] = [
@@ -132,5 +132,290 @@ $r3 = ['sid' => 'X', 'name' => 'Y', 'score' => 77.0, 'best_score' => 77.0]; //
check('无明细 latest 退回代表行=77', ny_exam_cell_score($r3, 'latest') === 77.0);
check('无明细 highest=77', ny_exam_cell_score($r3, 'highest') === 77.0);
/* ---------------- 「每次考试」块表(两行表头 / 合并单元格 / 未考) ---------------- */
/**
* 行内容比较:数据行是「参差不齐」的(某场次数少就没有尾巴上的格子),
* 短的一侧按空串补齐到等长再逐格比,避免因为"少几个空格子"误判。
*/
function rowEq(array $a, array $b): bool
{
$n = max(count($a), count($b));
for ($i = 0; $i < $n; $i++) {
$x = isset($a[$i]) ? (string)$a[$i] : '';
$y = isset($b[$i]) ? (string)$b[$i] : '';
if ($x !== $y) {
return false;
}
}
return true;
}
/** merges 转成 "A2:A4" 形式,便于与期望值比较(不依赖生成顺序) */function mergeRefs(array $merges): array
{
$col = static function (int $n): string {
$s = '';
while ($n > 0) {
$m = ($n - 1) % 26;
$s = chr(65 + $m) . $s;
$n = (int)(($n - $m) / 26);
}
return $s;
};
$out = [];
foreach ($merges as $m) {
$out[] = $col($m[1]) . $m[0] . ':' . $col($m[3]) . $m[2];
}
sort($out);
return $out;
}
/** 构造带班级 / 组别的学生行 */
function stuRowCG(string $sid, string $name, string $cls, string $grp, array $scoresAsc, array $tempMarks = [], ?float $best = null): array
{
$r = stuRow($sid, $name, $scoresAsc, $tempMarks, $best);
$r['cls'] = $cls;
$r['grp'] = $grp;
return $r;
}
// 版式场景完全对齐需求截图:张三 4 次数学 / 2 次英语;李四只有英语(数学「未考」)
$shotMath = ['name' => '数学', 'rows' => [
stuRow('356001', '张三', [112.0, 100.0, 136.0, 127.0], [], 136.0),
]];
$shotEng = ['name' => '英语', 'rows' => [
stuRow('356001', '张三', [112.0, 100.0], [], 112.0),
stuRow('356002', '李四', [130.0, 129.0, 133.0], [], 133.0),
]];
echo "== 每次考试块表:两行表头 / 共享第几次 / 未考(截图一版式) ==\n";
$bs = ny_assemble_block_sheet(['m' => $shotMath, 'e' => $shotEng]);
$rows = $bs['rows'];
check('表头第 1 行:学号 / 姓名 / 第几次 / 考试名(各占 1 格 + 1 空格)',
$rows[0] === ['学号', '姓名', '第几次', '数学', '', '英语', ''],
json_encode($rows[0], JSON_UNESCAPED_UNICODE));
check('表头第 2 行:只有「成绩」小标题',
$rows[1] === ['', '', '', '成绩', '', '成绩', ''],
json_encode($rows[1], JSON_UNESCAPED_UNICODE));
check('header_rows = 2', $bs['header_rows'] === 2);
/* 每场考试固定占 2 列:第 1 列成绩,第 2 列是「成绩列与相邻列合并」落下的空位。
当初漏推这个空串,整行会左移一格,表现就是成绩串列、表头合并区间对不上。 */
check('张三块首行:第几次=1,两场成绩(每场 2 列)',
rowEq($rows[2], ['356001', '张三', '1', '112', '', '112', '']),
json_encode($rows[2], JSON_UNESCAPED_UNICODE));
check('每场考试固定 2 列(成绩 + 落位空列),行宽齐整',
count($rows) === 9 && !array_filter(array_map('count', $rows), static fn($n) => $n !== 7),
'列数序列 = ' . json_encode(array_map('count', $rows)));
check('张三第 3 行:第几次=3,数学 136 / 英语空',
rowEq($rows[4], ['', '', '3', '136', '']),
json_encode($rows[4], JSON_UNESCAPED_UNICODE));
check('张三第 4 行:第几次=4,英语留空',
rowEq($rows[5], ['', '', '4', '127', '']),
json_encode($rows[5], JSON_UNESCAPED_UNICODE));
check('李四块首行:数学未考 + 英语 130',
rowEq($rows[6], ['356002', '李四', '1', '未考', '', '130']),
json_encode($rows[6], JSON_UNESCAPED_UNICODE));
check('总行数 = 9(2 行表头 + 张三 4 + 李四 3)', count($rows) === 9, (string)count($rows));
check('total_rows / students 统计正确', $bs['total_rows'] === 7 && $bs['students'] === 2,
$bs['total_rows'] . '/' . $bs['students']);
check('未启用合格行时 pass_row=false', $bs['pass_row'] === false, var_export($bs['pass_row'], true));
$refs = mergeRefs($bs['merges']);
check('合并区间:表头 C 列纵向 + 考试名横向 + 学号姓名第几次纵向 + 未考跨 2 列',
$refs === ['A1:A2', 'A3:A6', 'A7:A9', 'B1:B2', 'B3:B6', 'B7:B9', 'C1:C2', 'C3:C6', 'C7:C9', 'D1:E1', 'D7:E9', 'F1:G1'],
json_encode($refs));
/* 列宽够用就好:空落位列给 2.5,宽度让给真正显示成绩的列,
否则一屏只放得下两场考试。 */
check('列宽 = 学号/姓名/第几次 + 成绩 10 + 空落位 2.5',
$bs['widths'] === [10, 9, 9, 10, 2.5, 10, 2.5], json_encode($bs['widths']));
echo "== 每次考试块表:最高分标红 / 合格次数标绿 ==\n";
// 合格线 110:
// 张三数学 [112,100,136,127] → 112/136/127 合格 = 3
// 张三英语 [112,100] → 112 合格 = 1
// 李四英语 [130,129,133] → 130/129/133 合格 = 3
$bp = ny_assemble_block_sheet(['m' => $shotMath, 'e' => $shotEng], ['pass_line' => 110]);
$pp = $bp['rows'];
check('启用合格行后 header_rows 仍为 2', $bp['header_rows'] === 2);
check('pass_row=true 且 pass_line 回传', $bp['pass_row'] === true && $bp['pass_line'] === 110.0,
var_export($bp['pass_line'], true));
// 张三块:4 行成绩 + 1 行合格次数(第 7 行)
check('张三合格次数行:数学 3 次 / 英语 1 次',
rowEq($pp[6], ['', '', '合格次数', '3', '', '1']),
json_encode($pp[6], JSON_UNESCAPED_UNICODE));
// 李四块:3 行成绩 + 1 行合格次数(第 11 行)
check('李四合格次数行:数学未考留空 / 英语 3 次',
rowEq($pp[10], ['', '', '合格次数', '', '', '3']),
json_encode($pp[10], JSON_UNESCAPED_UNICODE));
check('总行数 = 11(2 + (4+1) + (3+1))', count($pp) === 11, (string)count($pp));
check('total_rows 含合格次数行 = 9', $bp['total_rows'] === 9, (string)$bp['total_rows']);
$pm = $bp['cell_styles'];
// 张三数学最高分 136 在第 5 行第 4 列 → 红字
check('张三数学最高分 136 标红', ($pm['5,4'] ?? '') === 'red', json_encode($pm));
// 张三英语最高分 112 在第 3 行第 6 列 → 红字
check('张三英语最高分 112 标红', ($pm['3,6'] ?? '') === 'red', json_encode($pm));
// 张三合格次数(第 7 行):数学 3 达标绿底、英语 1 达标绿底
check('张三数学合格次数达标绿底', ($pm['7,4'] ?? '') === 'green', json_encode($pm));
check('张三英语合格次数达标绿底', ($pm['7,6'] ?? '') === 'green', json_encode($pm));
// 李四:数学未考 → 不标绿;英语 3 次达标绿底
check('李四数学未考不标绿', !isset($pm['11,4']), json_encode($pm));
check('李四英语合格次数达标绿底', ($pm['11,6'] ?? '') === 'green', json_encode($pm));
// 未达标(阈值 4 次)→ 仍显示实际次数(张三数学 3 / 英语 1),但不标绿
$bh = ny_assemble_block_sheet(['m' => $shotMath, 'e' => $shotEng], ['pass_line' => 110, 'pass_times' => 4]);
$hp = $bh['rows'];
check('阈值 4 次时张三仍显示实际合格次数(不隐藏)',
rowEq($hp[6], ['', '', '合格次数', '3', '', '1']),
json_encode($hp[6], JSON_UNESCAPED_UNICODE));
check('阈值 4 次时不达标不标绿',
!isset($bh['cell_styles']['7,4']) && !isset($bh['cell_styles']['7,6']),
json_encode($bh['cell_styles']));
echo "== 每次考试块表:草稿 / 多班级组别列 / 空考试 ==\n";
// 赵六:数学第 2 次是草稿 → 默认跳过(不占编号)
$draftMath = ['name' => '数学', 'rows' => [
stuRow('D1', '赵六', [60.0, 99.0], [false, true], 99.0),
]];
$ds = ny_assemble_block_sheet(['m' => $draftMath]);
check('默认跳过草稿:只剩 1 次',
rowEq($ds['rows'][2], ['D1', '赵六', '1', '60']),
json_encode($ds['rows'][2], JSON_UNESCAPED_UNICODE));
check('跳过草稿后无多余行', count($ds['rows']) === 3, (string)count($ds['rows']));
$di = ny_assemble_block_sheet(['m' => $draftMath], ['skip_temp' => false]);
check('保留草稿:2 次且草稿分参与',
rowEq($di['rows'][3], ['', '', '2', '99']),
json_encode($di['rows'][3], JSON_UNESCAPED_UNICODE));
// 只有草稿的学生:跳过草稿后整场无有效成绩 → 不出现在表里
$onlyDraft = ['name' => '数学', 'rows' => [
stuRow('X1', '只交草稿', [88.0], [true], 88.0),
stuRow('X2', '正常', [70.0], [], 70.0),
]];
$od = ny_assemble_block_sheet(['m' => $onlyDraft]);
check('全草稿学生被剔除(只剩正常那位)', $od['students'] === 1, (string)$od['students']);
check('全草稿学生未写「未考」占位', rowEq($od['rows'][2], ['X2', '正常', '1', '70']),
json_encode($od['rows'][2], JSON_UNESCAPED_UNICODE));
// 多班级 / 多组别:按 班级 → 组别 → 学号 排序,连续相同班级 / 组别合并
$multi = ['name' => '数学', 'rows' => [
stuRowCG('S3', '王五', '1班', 'B', [60.0]),
stuRowCG('S4', '赵六', '2班', 'B', [50.0]),
stuRowCG('S1', '张三', '1班', 'A', [90.0, 80.0]),
stuRowCG('S2', '李四', '1班', 'A', [70.0]),
]];
$mc = ny_assemble_block_sheet(['m' => $multi], ['with_cls' => true, 'with_grp' => true]);
check('多班级表头 = 班级/组别/学号/姓名/第几次/考试名',
$mc['rows'][0] === ['班级', '组别', '学号', '姓名', '第几次', '数学', ''],
json_encode($mc['rows'][0], JSON_UNESCAPED_UNICODE));
check('按 班级 → 组别 → 学号 排序',
[$mc['rows'][2][2], $mc['rows'][4][2], $mc['rows'][5][2], $mc['rows'][6][2]]
=== ['S1', 'S2', 'S3', 'S4'],
json_encode([$mc['rows'][2][2], $mc['rows'][4][2], $mc['rows'][5][2], $mc['rows'][6][2]]));
check('班级 / 组别只写在学生块首行',
rowEq($mc['rows'][2], ['1班', 'A', 'S1', '张三', '1', '90']),
json_encode($mc['rows'][2], JSON_UNESCAPED_UNICODE));
$mcRefs = mergeRefs($mc['merges']);
check('相同班级 / 相同组别的单元格合并(1班 S1~S3 归一格,A 组 S1~S2 归一格)',
in_array('A3:A6', $mcRefs, true) && in_array('B3:B5', $mcRefs, true),
json_encode($mcRefs));
check('班级列位移后学号 / 姓名合并列正确(C / D 列)',
in_array('C3:C4', $mcRefs, true) && in_array('D3:D4', $mcRefs, true),
json_encode($mcRefs));
// 空考试:只出两行表头
$bEmpty = ny_assemble_block_sheet(['none' => ['name' => '无人考试', 'rows' => []]]);
check('空考试只有两行表头', count($bEmpty['rows']) === 2 && $bEmpty['students'] === 0);
check('空考试仅剩表头合并',
mergeRefs($bEmpty['merges']) === ['A1:A2', 'B1:B2', 'C1:C2', 'D1:E1'],
json_encode(mergeRefs($bEmpty['merges'])));
// 考试列顺序跟随传入顺序
$bOrder = ny_assemble_block_sheet(['e' => $shotEng, 'm' => $shotMath]);
check('考试列顺序跟随传入顺序(英语在前)',
$bOrder['rows'][0][3] === '英语' && $bOrder['rows'][0][5] === '数学',
json_encode($bOrder['rows'][0], JSON_UNESCAPED_UNICODE));
echo "== 单场「每次考试」宽表(截图二版式) ==\n";
$w = ny_assemble_attempt_wide_sheet($shotEng, ['pass_line' => 110]);
check('表头 = 学号/姓名/第1次…第N次/合格次数',
$w['rows'][0] === ['学号', '姓名', '第1次', '第2次', '第3次', '合格次数'],
json_encode($w['rows'][0], JSON_UNESCAPED_UNICODE));
check('每人一行,共 2 人', count($w['rows']) === 3 && $w['students'] === 2, (string)count($w['rows']));
// 张三英语 [112,100]:只有 2 次,第 3 列留空;≥110 → 112 合格 = 1
check('张三行:112 / 100 / 空,合格 1 次',
$w['rows'][1] === ['356001', '张三', '112', '100', '', '1'],
json_encode($w['rows'][1], JSON_UNESCAPED_UNICODE));
// 李四英语 [130,129,133]:全 ≥110 = 3 次合格
check('李四行:130 / 129 / 133,合格 3 次',
$w['rows'][2] === ['356002', '李四', '130', '129', '133', '3'],
json_encode($w['rows'][2], JSON_UNESCAPED_UNICODE));
check('attempt_cols=3', $w['attempt_cols'] === 3, (string)$w['attempt_cols']);
check('最高分 112(张三第 3 列)标红', ($w['cell_styles']['2,3'] ?? '') === 'red', json_encode($w['cell_styles']));
check('最高分 133(李四第 5 列)标红', ($w['cell_styles']['3,5'] ?? '') === 'red', json_encode($w['cell_styles']));
check('张三合格次数 1 次达标绿底', ($w['cell_styles']['2,6'] ?? '') === 'green', json_encode($w['cell_styles']));
check('李四合格次数 3 次达标绿底', ($w['cell_styles']['3,6'] ?? '') === 'green', json_encode($w['cell_styles']));
// 阈值 4 次 → 都不到阈值,仍显示实际次数(1 / 3),但不标绿
$w4 = ny_assemble_attempt_wide_sheet($shotEng, ['pass_line' => 110, 'pass_times' => 4]);
check('阈值 4 次时仍显示实际合格次数(不隐藏)',
$w4['rows'][1][5] === '1' && $w4['rows'][2][5] === '3',
json_encode([$w4['rows'][1][5], $w4['rows'][2][5]], JSON_UNESCAPED_UNICODE));
check('阈值 4 次时不标绿',
!isset($w4['cell_styles']['2,6']) && !isset($w4['cell_styles']['3,6']),
json_encode($w4['cell_styles']));
// 关闭标红 / 关掉合格行
$wOff = ny_assemble_attempt_wide_sheet($shotEng, ['pass_line' => 110, 'mark_best' => false, 'pass_row' => false]);
check('mark_best=false 不标红', $wOff['cell_styles'] === [], json_encode($wOff['cell_styles']));
check('pass_row=false 表头无合格次数', !in_array('合格次数', $wOff['rows'][0], true),
json_encode($wOff['rows'][0], JSON_UNESCAPED_UNICODE));
/** 检查合并区间两两不重叠(区间重叠会让 Excel 打开时报"需要修复") */
function overlapsOf(array $merges): array
{
$bad = [];
$n = count($merges);
for ($i = 0; $i < $n; $i++) {
for ($j = $i + 1; $j < $n; $j++) {
$a = $merges[$i];
$b = $merges[$j];
if ($a[0] <= $b[2] && $b[0] <= $a[2] && $a[1] <= $b[3] && $b[1] <= $a[3]) {
$bad[] = json_encode($a) . '×' . json_encode($b);
}
}
}
return $bad;
}
echo "== 班级 / 组别列的显示规则 ==\n";
$f1 = ny_multi_col_flags(['1班'], [], ['1班'], ['A', 'B']);
check('只选 1 个班级且未选组别 → 班级 / 组别列都不出',
$f1['with_cls'] === false && $f1['with_grp'] === false, json_encode($f1));
$f2 = ny_multi_col_flags(['1班', '2班'], ['A', 'B'], ['1班', '2班'], ['A', 'B']);
check('选了多班级 + 多组别 → 两列都出',
$f2['with_cls'] === true && $f2['with_grp'] === true, json_encode($f2));
$f3 = ny_multi_col_flags(['1班'], ['A', 'B'], ['1班'], ['A', 'B']);
check('只选 1 个班级但选了多组别 → 只出组别列',
$f3['with_cls'] === false && $f3['with_grp'] === true, json_encode($f3));
$f4 = ny_multi_col_flags([], [], ['1班'], ['A']);
check('未筛选且数据只有 1 个班 / 1 个组 → 两列都不出',
$f4['with_cls'] === false && $f4['with_grp'] === false, json_encode($f4));
$f5 = ny_multi_col_flags([], [], ['1班', '2班', '3班'], ['A', 'B']);
check('未筛选且数据跨 3 个班 → 按数据补出班级 / 组别列',
$f5['with_cls'] === true && $f5['with_grp'] === true, json_encode($f5));
$f6 = ny_multi_col_flags(['1班', '2班'], ['A'], ['1班', '2班'], ['A']);
check('多班级 + 只选 1 个组别 → 只出班级列',
$f6['with_cls'] === true && $f6['with_grp'] === false, json_encode($f6));
echo "== 合并区间不重叠(防 Excel 报修复) ==\n";
check('截图版式:无重叠', overlapsOf($bs['merges']) === [], json_encode(overlapsOf($bs['merges'])));
check('多班级多组别:无重叠', overlapsOf($mc['merges']) === [], json_encode(overlapsOf($mc['merges'])));
check('单场导出(带班级组别列):无重叠',
overlapsOf(ny_assemble_block_sheet(['m' => $multi], ['with_cls' => true])['merges']) === []);
check('草稿模式:无重叠',
overlapsOf(ny_assemble_block_sheet(['m' => $draftMath], ['skip_temp' => false])['merges']) === []);
echo "\n通过 {$pass} 项,失败 {$fail} 项\n";
exit($fail === 0 ? 0 : 1);
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env node
/**
* 生成数据大屏首页的可打开预览:docs/samples/dash-home-preview.html
*
* 关键点与 make-samples.js 一致——卡片**直接调用 dashboard.php 里生产环境的 clsCardHtml()**,
* 不是另写一份渲染逻辑,所以预览长什么样,线上首页就长什么样,不会两边漂移。
* 数据取自本机 mock-server(tools/mock-server.js)+ PHP 内置服务器,名单与答卷都是真跑出来的。
*
* 用法:node tools/make-dash-sample.js
*/
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const http = require('http');
const { spawn } = require('child_process');
const ROOT = path.join(__dirname, '..');
const PORT = 18095;
const INI = process.env.PHP_INI || '';
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
/* ---------- 临时数据目录:绝不动项目 data/ ---------- */
let p = process.env.NY_DATA || path.join(os.tmpdir(), 'ny-dash-sample-' + process.pid);
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2);
const DATA = path.resolve(p);
process.env.NY_DATA = DATA;
fs.mkdirSync(DATA, { recursive: true });
fs.writeFileSync(path.join(DATA, 'config.json'), JSON.stringify({
base_url: 'http://127.0.0.1:19911',
api_prefix: '/api',
username: '', password: '',
verify_ssl: false, timeout: 10,
refresh_interval: 15, page_size: 50,
dedupe: true, include_temp_save: false,
name_keywords: '姓名,名字',
full_score: 100, layout: 'default',
}, null, 2));
/* 名单:mock 的「卷内参与者」只放行 90001-90005,应考名单会被收敛到这几人,
所以把 5 人分散到 4 个班,其余人做无答卷的填充(让卡片同时能看到缺考数)。 */
const STUDENTS = [];
[['90001', '高一(1)班'], ['90002', '高一(2)班'], ['90003', '高一(3)班'],
['90004', '高一(4)班'], ['90005', '高一(1)班']].forEach(([sid, cls]) =>
STUDENTS.push({ sid, name: sid, cls, grp: 'A组', login: '' }));
['高一(1)班', '高一(2)班', '高一(3)班', '高一(4)班'].forEach((cls, ci) => {
for (let i = 1; i <= 11; i++) {
const sid = String(9200 + ci * 100 + i);
STUDENTS.push({ sid, name: sid, cls, grp: i % 2 ? 'A组' : 'B组', login: '' });
}
});
fs.writeFileSync(path.join(DATA, 'roster.json'), JSON.stringify({ students: STUDENTS, updated_at: '2026-01-01 00:00:00' }));
let mock = null, srv = null, cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
try { srv && srv.kill('SIGKILL'); } catch (e) {}
try { mock && mock.kill('SIGKILL'); } catch (e) {}
try { fs.rmSync(DATA, { recursive: true, force: true }); } catch (e) {}
}
process.on('exit', cleanup);
process.on('uncaughtException', e => { console.error('FATAL', e); cleanup(); process.exit(2); });
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'ignore' });
srv = spawn(PHP, INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]), { cwd: ROOT, stdio: 'ignore' });
/* ---------- 极简 HTTP ---------- */
const jar = [];
function collect(res) {
for (const sc of (res.headers['set-cookie'] || [])) {
const pair = sc.split(';')[0];
const i = jar.findIndex(x => x.startsWith(pair.split('=')[0] + '='));
if (i >= 0) jar[i] = pair; else jar.push(pair);
}
}
const cookie = () => (jar.length ? jar.join('; ') : '');
function req(method, p) {
return new Promise((resolve, reject) => {
const h = { 'Accept': 'application/json' };
if (cookie()) h['Cookie'] = cookie();
const r = http.request({ host: '127.0.0.1', port: PORT, method, path: p, headers: h }, res => {
const bufs = [];
res.on('data', c => bufs.push(c));
res.on('end', () => { collect(res); resolve({ raw: Buffer.concat(bufs).toString('utf8') }); });
});
r.on('error', reject);
r.end();
});
}
// 登录必须走表单编码(login.php 读 $_POST),只发一个空 POST 是登不上的
function formPost(p, fields) {
return new Promise((resolve, reject) => {
const body = new URLSearchParams(fields).toString();
const h = { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body) };
if (cookie()) h['Cookie'] = cookie();
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path: p, headers: h }, res => {
const bufs = [];
res.on('data', c => bufs.push(c));
res.on('end', () => { collect(res); resolve({ raw: Buffer.concat(bufs).toString('utf8') }); });
});
r.on('error', reject);
r.write(body); r.end();
});
}
const delay = ms => new Promise(r => setTimeout(r, ms));
/* ---------- 抽出生产环境的卡片渲染函数 ---------- */
const dashSrc = fs.readFileSync(path.join(ROOT, 'dashboard.php'), 'utf8');
const m = dashSrc.match(/function clsCardHtml\(c\)\{[\s\S]*?\n\}/);
if (!m) { console.error('未能从 dashboard.php 抽取 clsCardHtml'); cleanup(); process.exit(1); }
const esc = s => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const dv = (v, d) => (v === undefined || v === null || v === '' ? (d === undefined ? '-' : d) : v);
const pv = v => dv(v, '-') + (v === undefined || v === null || v === '' ? '' : '%');
const rateColor = r => (r >= 90 ? 'var(--ok)' : (r >= 60 ? 'var(--warn)' : 'var(--danger)'));
const clsCardHtml = new Function('esc', 'dv', 'pv', 'rateColor', m[0] + ' return clsCardHtml;')(esc, dv, pv, rateColor);
(async () => {
await delay(2200);
await formPost('/login.php', { username: 'mock', password: 'surveyking' }); // 顺带把账密写进临时配置
const pr = await req('GET', '/api.php?action=projects');
const projects = ((JSON.parse(pr.raw).data) || []).filter(x => x.id && x.id !== 'p2');
if (!projects.length) { console.error('mock 没返回可用问卷,原始响应:\n' + pr.raw.slice(0, 800)); cleanup(); process.exit(1); }
const project = projects[0];
const dr = await req('GET', '/api.php?action=dashboard&projectId=' + encodeURIComponent(project.id));
const data = JSON.parse(dr.raw).data || {};
const classes = data.classes || [];
if (!classes.length) { console.error('dashboard 没返回班级,名单是否为空?'); cleanup(); process.exit(1); }
const cards = classes.map(clsCardHtml).join('');
const projectName = project.name || project.id;
const html = [
'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">',
'<title>数据大屏首页(预览)</title>',
'<meta name="viewport" content="width=device-width, initial-scale=1">',
'<link rel="stylesheet" href="../../assets/style.css">',
'<style>',
'/* 预览页专属:补上 hero 里样式类所需的小件(原页面由 dashboard.php 内联给出) */',
'.brand-text{display:flex;flex-direction:column;gap:2px}',
'.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--border-2);vertical-align:1px}',
'.dot.on{background:var(--ok)}',
'.dot.stale{background:var(--danger)}',
'.spacer{flex:1}',
'.preview-tip{margin:12px 0 0;padding:8px 12px;border:1px dashed var(--border);border-radius:var(--radius);',
' font-size:12px;color:var(--text-3);background:var(--panel-weak,#f7f8fc)}',
'</style></head>',
'<body class="dash dash-home">',
'<div class="wrap">',
' <div class="dash-hero">',
' <div class="brand"><h1 class="brand-title">NY问卷考试系统数据平台</h1></div>',
' <div class="dash-controls">',
' <select id="projectSel"><option value="' + esc(project.id) + '">' + esc(project.name) +
'(' + project.total + ' 份)</option></select>',
' <span id="classSelWrap"><select id="classSel" disabled><option value="">请先选问卷</option></select></span>',
' <label class="switch"><input type="checkbox" id="autoRefresh" checked><span></span></label>',
' <select id="intervalSel"><option value="15" selected>15 秒</option></select>',
' <button class="btn" id="btnRefresh">刷新</button>',
' <button class="btn" id="btnFullscreen">⛶ 全屏</button>',
' <a class="btn back" id="linkIndex" href="index.php">← 主系统</a>',
' <a class="btn back" id="linkLogout" href="logout.php">退出</a>',
' </div>',
' </div>',
' <div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;flex-wrap:wrap;">',
' <span id="heroSub">共 ' + classes.length + ' 个班级 · 点击卡片进入该班大屏</span>',
' <span class="spacer"></span>',
' 最后更新:<span id="lastUpdate">' + esc(data.updated_at || '-') + '</span><i id="liveDot" class="dot on"></i>',
' </div>',
' <div class="preview-tip">静态预览:这张页面由生产环境 dashboard.php 的 <code>clsCardHtml()</code> 渲染,',
' 数据取自 mock 卷王。真实站点上点卡片会整页跳转到 <code>dashboard.php?pid=…&amp;class=…</code>,',
' 进入该班独立大屏(那里不再有班级选择、返回主系统与退出,浏览器后退键也被锁住)。</div>',
' <div id="board">',
' <section class="cards" id="boardSummary">',
' <div class="card accent-blue"><span>总人数</span><b>0</b></div>',
' <div class="card ok accent-green"><span>已提交</span><b>0</b></div>',
' <div class="card warn accent-orange"><span>缺考</span><b>0</b></div>',
' <div class="card accent-teal"><span>平均分</span><b>-</b></div>',
' <div class="card accent-indigo"><span>合格率</span><b>-</b></div>',
' </section>',
' <div class="sec-title" id="boardTitle">班级入口 <span class="tag-gray">点击卡片进入该班独立大屏</span></div>',
' <div id="boardBody"><div class="cls-grid">' + cards + '</div></div>',
' </div>',
'</div>',
'<script>/* 预览页:把"全屏/刷新"按钮做成 plainly 不可点,仅示意版式 */',
'document.addEventListener(\'DOMContentLoaded\', function () {',
' ["#btnFullscreen", "#btnRefresh", "#projectSel", "#classSelWrap"].forEach(function (s) {',
' var el = document.querySelector(s); if (el) el.setAttribute("disabled", "disabled");',
' });',
'});</script>',
'</body></html>',
].join('\n');
const out = path.join(ROOT, 'docs', 'samples', 'dash-home-preview.html');
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, html, 'utf8');
console.log('已生成 ' + path.relative(ROOT, out));
console.log(' 问卷:' + projectName + ' 班级:' + classes.map(c => c.class).join('、'));
cleanup();
process.exit(0);
})();
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env node
/**
* 把 tools/make-samples.php 产出的数据渲染成可打开的样例页面。
*
* 关键点:页面里的表格**直接调用 assets/app.js 里生产环境的 sheetToHtml()**——
* 不是另写一份渲染逻辑,所以预览长什么样,打印就长什么样,不会两边漂移。
* 配色 / 字号沿用 printDoc() 里打印用的那套样式。
*
* 用法:node tools/make-samples.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const data = JSON.parse(fs.readFileSync(path.join(ROOT, 'docs', 'samples', 'sheet-data.json'), 'utf8'));
/* ---- 抽出生产环境的 sheetToHtml ---- */
const appSrc = fs.readFileSync(path.join(ROOT, 'assets', 'app.js'), 'utf8');
const m = appSrc.match(/function sheetToHtml\(sheet\)\s*\{[\s\S]*?\n \}/);
if (!m) {
console.error('未能从 assets/app.js 抽取 sheetToHtml');
process.exit(1);
}
const esc = s => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// eslint-disable-next-line no-new-func
const sheetToHtml = new Function('esc', 'return ' + m[0])(esc);
/* ---- 沿用 printDoc() 里的打印配色 ---- */
const PRINT_CSS = [
'body{font-family:"Microsoft YaHei","PingFang SC",sans-serif;color:#222;background:#f7f7f7;margin:0;padding:24px}',
'.page{background:#fff;border:1px solid #e3e3e3;border-radius:6px;padding:20px 22px;margin:0 auto 20px;max-width:1100px}',
'.page h2{font-size:16px;margin:0 0 4px}',
'.page .sub{font-size:12px;color:#777;margin:0 0 14px}',
// 与 printDoc() 里的打印样式保持一致(表头渲染成 <td>,所以底色写 thead td)
'table.bt{border-collapse:collapse;border:1px solid #999;width:100%;background:#fff;font-size:11px}',
'table.bt td,table.bt th{border:1px solid #999;text-align:center;padding:3px 5px}',
'table.bt th,table.bt thead td{background:#eaeaea;font-weight:700}',
'table.bt thead tr:first-child td{background:#eaeaea}',
'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}',
'html,body,.pass,.absent,.max{-webkit-print-color-adjust:exact;print-color-adjust:exact}',
'.legend{margin:10px 0 0;font-size:12px;color:#555}',
'.legend i{display:inline-block;width:11px;height:11px;vertical-align:-1px;margin:0 4px 0 12px}',
'.legend .r{color:#d0021b;font-weight:700}',
'.legend .g{background:#63c384;font-weight:700}',
'.sign{margin-top:16px;font-size:12px}',
].join('');
function render(item) {
const s = item.sheet;
const table = sheetToHtml(s);
const meta = item.meta || {};
const title = esc(item.title);
const sub = meta.exam_name
? esc(meta.exam_name) + ' · 合格线 ' + meta.pass_line + ' 分,达 ' + meta.pass_times + ' 次标绿'
: '合格线 ' + meta.pass_line + ' 分,达 ' + meta.pass_times + ' 次标绿';
return [
'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">',
'<title>' + title + '(预览)</title><style>' + PRINT_CSS + '</style></head><body>',
'<div class="page"><h2>' + title + '</h2><p class="sub">' + sub + '</p>',
table,
'<p class="legend">标记说明:<span class="r">最高分</span> 标红、'
+ '<i class="r">红字</i>;<span class="g">合格次数</span> 始终显示(含 0 次),'
+ '达 ' + meta.pass_times + ' 次标绿、<i class="g">绿底</i>;未达标显示次数、不标绿。',
'<p class="sign">教师签字:______________  教研组长签字:______________  日期:____________</p>',
'</div></body></html>',
].join('\n');
}
const outDir = path.join(ROOT, 'docs', 'samples');
fs.writeFileSync(path.join(outDir, 'preview-multi-block.html'), render(data.block));
fs.writeFileSync(path.join(outDir, 'preview-single-wide.html'), render(data.wide));
console.log('已生成:');
console.log(' docs/samples/preview-multi-block.html (多考试块表)');
console.log(' docs/samples/preview-single-wide.html (单场宽表)');
+126
View File
@@ -0,0 +1,126 @@
<?php
/**
* 生成「导出 / 打印」新版式的可打开样例:
* 1) docs/samples/export-multi-block.xlsx —— 多考试块表(两行表头 + 合格次数 + 红绿标记)
* 2) docs/samples/export-single-wide.xlsx —— 单场宽表(每人一行、第几次做列名)
* 3) docs/samples/sheet-data.json —— 供 make-samples.js 渲染预览 HTML
*
* 数据是一次性写死的示例数据(与 tests/test-multi-export.php 的截图场景同一套口径),
* 不是真实导出结果,仅用于看版式。
*
* 运行:php tools/make-samples.php
*/
declare(strict_types=1);
require __DIR__ . '/../bootstrap.php';
require __DIR__ . '/../lib/multi_export.php';
/** 构造一名学生的若干次考试记录(成绩按时间升序) */
function sample_stu(string $sid, string $name, float $best, array $scoresAsc, array $tempMarks = []): array
{
$list = [];
$t = 1000000;
foreach ($scoresAsc as $i => $sc) {
$t += 600;
$list[] = [
'idx' => $i + 1,
'submit_ts' => $t,
'score' => $sc,
'temp_save' => !empty($tempMarks[$i]),
'duration' => 120,
];
}
return [
'sid' => $sid,
'name' => $name,
'cls' => '1班',
'grp' => 'A',
'score' => end($scoresAsc),
'best_score' => $best,
'attempts' => count($scoresAsc),
'attempts_list' => $list,
];
}
/* ---- 示例数据:与需求截图同口径(合格线 110,阈值 1 次) ---- */
$exams = [
'm' => ['name' => '第一次月考·数学', 'rows' => [
sample_stu('356001', '张三', 136.0, [112.0, 100.0, 136.0, 127.0]),
sample_stu('356002', '李四', 0.0, []), // 整场未考
sample_stu('356003', '王五', 95.0, [95.0]), // 未达合格线 → 不标绿
]],
'e' => ['name' => '第一次月考·英语', 'rows' => [
sample_stu('356001', '张三', 112.0, [112.0, 99.0]),
sample_stu('356002', '李四', 133.0, [130.0, 129.0, 133.0]),
]],
];
$opts = [
'pass_line' => 110, // 本卷满分 130 × 合格率 85%
'pass_times' => 1, // 达标阈值
'mark_best' => true, // 最高分标红
'pass_row' => true, // 输出「合格次数」行
];
/* ---- 1) 多考试块表 ---- */
$block = ny_assemble_block_sheet($exams, $opts);
$blockBin = XlsxWriter::renderMulti([[
'name' => '每次考试',
'rows' => $block['rows'],
'widths' => $block['widths'] ?? [],
'merges' => $block['merges'],
'header_rows' => $block['header_rows'],
'cell_styles' => $block['cell_styles'] ?? [],
]]);
$blockPath = __DIR__ . '/../docs/samples/export-multi-block.xlsx';
file_put_contents($blockPath, $blockBin);
/* ---- 2) 单场宽表(只导出一场考试时的版式) ---- */
$wideRes = ny_assemble_attempt_wide_sheet($exams['m'], $opts);
$wideBin = XlsxWriter::renderMulti([[
'name' => '每次考试',
'rows' => $wideRes['rows'],
'widths' => $wideRes['widths'] ?? [],
'merges' => $wideRes['merges'],
'header_rows' => $wideRes['header_rows'],
'cell_styles' => $wideRes['cell_styles'] ?? [],
]]);
$widePath = __DIR__ . '/../docs/samples/export-single-wide.xlsx';
file_put_contents($widePath, $wideBin);
/* ---- 3) 交给 Node 渲染预览 HTML(用 app.js 里的 sheetToHtml,避免两份实现漂移) ---- */
$data = [
'block' => [
'title' => '多考试导出 · 每次考试(两行表头)',
'sheet' => [
'rows' => $block['rows'],
'merges' => $block['merges'],
'header_rows' => $block['header_rows'],
'cell_styles' => $block['cell_styles'] ?? [],
],
'meta' => [
'pass_line' => $opts['pass_line'],
'pass_times' => $opts['pass_times'],
],
],
'wide' => [
'title' => '单场导出 · 每次考试(每人一行的宽表)',
'sheet' => [
'rows' => $wideRes['rows'],
'merges' => $wideRes['merges'],
'header_rows' => $wideRes['header_rows'],
'cell_styles' => $wideRes['cell_styles'] ?? [],
],
'meta' => [
'pass_line' => $opts['pass_line'],
'pass_times' => $opts['pass_times'],
'exam_name' => $exams['m']['name'],
],
],
];
file_put_contents(__DIR__ . '/../docs/samples/sheet-data.json', json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
echo '块表:' . count($block['rows']) . ' 行 / ' . count($block['merges']) . ' 处合并 / '
. count($block['cell_styles'] ?? []) . " 处标记 → " . realpath($blockPath) . PHP_EOL;
echo '宽表:' . count($wideRes['rows']) . ' 行 / ' . count($wideRes['merges']) . ' 处合并 / '
. count($wideRes['cell_styles'] ?? []) . " 处标记 → " . realpath($widePath) . PHP_EOL;
echo '预览数据:' . realpath(__DIR__ . '/../docs/samples/sheet-data.json') . PHP_EOL;
+26 -2
View File
@@ -13,7 +13,30 @@ const PUB_B64 = Buffer.from(publicKey).toString('base64');
const PASSWORD = 'surveyking';
let TOKEN = '';
const roster = JSON.parse(fs.readFileSync(require('path').join(__dirname, '..', 'data', 'roster.json'), 'utf8')).students;
/* 名单:优先用 data/roster.json(真实名单,答卷也按它生成)。
没有该文件时用内置样例名单兜底,免得整个 e2e 因为缺文件连不上——
mock 只关心名单的条数与姓名,真实字段由 demoRoster() 补齐。 */
function demoRoster() {
const out = [];
const classes = ['高一(1)班', '高一(2)班'];
const groups = ['A组', 'B组'];
for (let i = 1; i <= 40; i++) {
// 名字必须与下方 PARTNERS 的 userName 一致,否则答卷匹配不到名单
const id = String(90000 + i);
out.push({ sid: id, name: id, cls: classes[i % 2], grp: groups[i % 2] });
}
return out;
}
function loadRoster() {
const p = require('path').join(__dirname, '..', 'data', 'roster.json');
try {
const list = JSON.parse(fs.readFileSync(p, 'utf8')).students;
if (Array.isArray(list) && list.length) return { list, from: 'data/roster.json' };
} catch (e) { /* 没有名单文件,走内置兜底 */ }
return { list: demoRoster(), from: '内置样例名单(data/roster.json 不存在)' };
}
const ROSTER = loadRoster();
const roster = ROSTER.list;
// 构造答卷:前 30 人提交,其中 2 人姓名有空格/错别字
const answers = roster.slice(0, 30).map((s, i) => {
@@ -153,5 +176,6 @@ const server = http.createServer((req, res) => {
});
server.listen(19911, '127.0.0.1', () => {
console.log('mock 卷王已启动 http://127.0.0.1:19911 密码=' + PASSWORD);
console.log('mock 卷王已启动 http://127.0.0.1:19911 密码=' + PASSWORD
+ '\n名单来源:' + ROSTER.from + '(' + roster.length + ' 人)');
});
+150
View File
@@ -0,0 +1,150 @@
/* 多考试打印渲染回归测试
*
* 「多考试打印」的版式由 assets/app.js 的 sheetToHtml() 负责:把服务端装配好的
* 二维表 + merges(1-based 闭区间)+ cell_styles(red=最高分红字 / green=合格绿底)
* 翻成带 colspan / rowspan 的 HTML。
* 本测试直接从 app.js 抽取该函数源码执行(不复制一份实现,避免两边漂移),
* 用与 tests/test-multi-export.php 同一套版式校验渲染结果。
*
* 运行:node tools/test-block-print.js
*/
const fs = require('fs');
const path = require('path');
let pass = 0, fail = 0;
function chk(name, ok, extra) {
if (ok) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
}
/* ---------- 1) 从生产代码里抽取 sheetToHtml ---------- */
const appPath = path.join(__dirname, '..', 'assets', 'app.js');
const appSrc = fs.readFileSync(appPath, 'utf8');
const m = appSrc.match(/function sheetToHtml\(sheet\)\s*\{[\s\S]*?\n \}/);
chk('能从 assets/app.js 抽到 sheetToHtml', !!m);
if (!m) {
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
process.exit(1);
}
const PRINT_CSS = (appSrc.match(/function printDoc\(title, bodyHtml, opts\)\s*\{[\s\S]*?\n \}/) || [''])[0];
const esc = s => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// eslint-disable-next-line no-new-func
const sheetToHtml = new Function('esc', 'return ' + m[0])(esc);
/* ---------- 2) 真实装配结果(截图版式:共享「第几次」列 + 合格次数行) ----------
列:1 学号 | 2 姓名 | 3 第几次 | 4-5 数学 | 6-7 英语
合格线 110,阈值 1 次。 */
const FIXTURE = {
header_rows: 2,
rows: [
['学号', '姓名', '第几次', '数学', '', '英语', ''],
['', '', '', '成绩', '', '成绩', ''],
// 张三:数学 3 次(112/100/136),英语 1 次(112)
['356001', '张三', '1', '112', '', '112', ''],
['', '', '2', '100', '', '', ''],
['', '', '3', '136', '', '', ''],
['', '', '合格次数', '3', '', '1', ''],
// 李四:数学整场未考,英语 3 次(130/129/133)
['356002', '李四', '1', '未考', '', '130', ''],
['', '', '2', '', '', '129', ''],
['', '', '3', '', '', '133', ''],
['', '', '合格次数', '', '', '3', ''],
// 王五:数学 1 次(88,未达合格线),英语整场未考
['356003', '王五', '1', '88', '', '未考', ''],
['', '', '合格次数', '0', '', '', ''],
],
merges: [
[1, 1, 2, 1], [1, 2, 2, 2], [1, 3, 2, 3], [1, 4, 1, 5], [1, 6, 1, 7], // 表头
[3, 1, 6, 1], [3, 2, 6, 2], [3, 3, 5, 3], // 张三:学号/姓名并入末行,「第几次」只到末次考试
[7, 1, 10, 1], [7, 2, 10, 2], [7, 4, 10, 5], // 李四:学号/姓名并入末行;数学整场「未考」跨 2 列 × 4 行
[11, 6, 11, 7], // 王五:英语整场「未考」跨 2 列
],
cell_styles: {
'5,4': 'red', // 张三 数学最高分 136(1-based 行 5) → 红字
'9,6': 'red', // 李四 英语最高分 133(1-based 行 9) → 红字
'6,4': 'green', // 张三 数学合格次数 3 → 绿底
'6,6': 'green', // 张三 英语合格次数 1 → 绿底
'10,6': 'green', // 李四 英语合格次数 3 → 绿底
},
};
const html = sheetToHtml(FIXTURE);
const thead = (html.match(/<thead>([\s\S]*?)<\/thead>/) || [])[1] || '';
const tbody = (html.match(/<tbody>([\s\S]*?)<\/tbody>/) || [])[1] || '';
const count = (s, needle) => s.split(needle).length - 1;
/* ---------- 3) 断言 ---------- */
chk('表头 2 行进 <thead>(多页打印自动重复)', count(thead, '<tr>') === 2 && count(tbody, '<tr>') === 10,
'thead=' + count(thead, '<tr>') + ' tbody=' + count(tbody, '<tr>'));
chk('学号表头纵向合并 2 行', thead.indexOf('<td rowspan="2">学号</td>') >= 0);
chk('姓名表头纵向合并 2 行', thead.indexOf('<td rowspan="2">姓名</td>') >= 0);
chk('第几次表头纵向合并 2 行', thead.indexOf('<td rowspan="2">第几次</td>') >= 0);
chk('考试名称横跨 2 列',
thead.indexOf('<td colspan="2">数学</td>') >= 0 && thead.indexOf('<td colspan="2">英语</td>') >= 0);
chk('第 2 行表头是「成绩」小标题', thead.indexOf('<td>成绩</td>') >= 0);
chk('张三学号纵向合并 4 行(3 次考试 + 合格次数行)', tbody.indexOf('<td rowspan="4">356001</td>') >= 0);
chk('李四学号纵向合并 4 行', tbody.indexOf('<td rowspan="4">356002</td>') >= 0);
chk('王五学号不合并(只有 1 次考试)', tbody.indexOf('<td>356003</td>') >= 0);
chk('「合格次数」标签落在「第几次」列且 3 处都在', count(tbody, '<td>合格次数</td>') === 3,
'实际 ' + count(tbody, '<td>合格次数</td>'));
chk('未考合并 2 列 × 4 行(李四·数学)',
tbody.indexOf('<td rowspan="4" colspan="2" class="absent">未考</td>') >= 0);
chk('未考合并 2 列 × 1 行(王五·英语)',
tbody.indexOf('<td colspan="2" class="absent">未考</td>') >= 0);
chk('最高分红字 2 处(张三 136 / 李四 133)', count(tbody, 'class="max">') === 2,
'实际 ' + count(tbody, 'class="max">'));
chk('合格次数绿底 3 处(张三 2 场 + 李四 1 场)', count(tbody, 'class="pass">') === 3,
'实际 ' + count(tbody, 'class="pass">'));
chk('王五数学 0 次未达标 → 不标绿', tbody.indexOf('>0</td>') >= 0 && tbody.indexOf('class="pass">0') < 0);
/* td 数守恒:总格位数 − 被合并吞掉的格位。合并一旦算错(重叠 / 漏格)这里就会炸 */
const covered = new Set();
FIXTURE.merges.forEach(mg => {
for (let r = mg[0] - 1; r <= mg[2] - 1; r++) {
for (let c = mg[1] - 1; c <= mg[3] - 1; c++) {
if (r === mg[0] - 1 && c === mg[1] - 1) continue;
covered.add(r + '|' + c);
}
}
});
const capacity = FIXTURE.rows.reduce((s, row) => s + row.length, 0);
const eaten = FIXTURE.rows.reduce((s, row, r) =>
s + row.filter((_, c) => covered.has(r + '|' + c)).length, 0);
chk('td 数守恒(' + capacity + ' − ' + eaten + ' = ' + (capacity - eaten) + ')',
count(html, '<td') === capacity - eaten, '实际 ' + count(html, '<td'));
/* 标记只落在 cell_styles 指定的格子上:合并首格带上 class 且 rowspan/colspan 完整,
同行未参与合并的格子不受牵连,越界键被忽略 */
const markHtml = sheetToHtml({
rows: [['a', 'b', 'c'], ['1', '2', '3']],
merges: [[1, 1, 2, 2]],
cell_styles: { '1,1': 'green', '99,99': 'red' },
header_rows: 1,
});
chk('合并首格带标记:class 正确且 rowspan/colspan 完整',
markHtml.indexOf('<td rowspan="2" colspan="2" class="pass">a</td>') >= 0, markHtml);
chk('同行未合并格不受相邻合并影响', markHtml.indexOf('<td>c</td>') >= 0);
chk('越界标记键被忽略', markHtml.indexOf('class="max"') < 0);
chk('打印 CSS 含块表样式(居中 / 未考灰 / 最高分红 / 合格绿)',
PRINT_CSS.indexOf('table.bt') >= 0 && PRINT_CSS.indexOf('td.absent') >= 0
&& PRINT_CSS.indexOf('td.max') >= 0 && PRINT_CSS.indexOf('td.pass') >= 0);
/* 绿底印不印得出来,取决于 print-color-adjust 有没有写上——浏览器默认关闭"背景图形" */
chk('打印 CSS 声明了 print-color-adjust:exact(否则底纹不会被打印)',
/print-color-adjust:\s*exact/.test(PRINT_CSS) && PRINT_CSS.indexOf('-webkit-print-color-adjust') >= 0);
chk('绿底单元格自己也声明了 print-color-adjust',
/td\.pass\{[^}]*print-color-adjust:\s*exact/.test(PRINT_CSS));
/* 缺考名单等附加工作表需要另起一页,否则会挤在成绩表底部 */
chk('打印 CSS 让缺考名单(h3.grp.page-break)另起一页',
/h3\.grp\.page-break\{[^}]*page-break-before:\s*always/.test(PRINT_CSS));
/* 表头渲染成 <td>,底色必须写 thead td,写成 th 是死规则、两行表头会变白 */
chk('表头底色命中实际渲染的 <td>(thead td,不是死规则 th)',
/table\.bt thead td\{background:/.test(PRINT_CSS));
chk('HTML 已转义',
sheetToHtml({ rows: [['<b>x</b>']], merges: [], header_rows: 1 }).indexOf('&lt;b&gt;') >= 0);
console.log('');
console.log('结果:' + pass + ' 通过,' + fail + ' 失败');
module.exports = { sheetToHtml: sheetToHtml, PRINT_CSS: PRINT_CSS };
process.exit(fail === 0 ? 0 : 1);
+2 -2
View File
@@ -7,8 +7,8 @@ const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const PORT = 18081;
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
// 1) 备份 / 准备文件存储模式
// 数据目录默认用项目下的 data/,但优先读 NY_DATA 环境变量(bootstrap.php 同样支持):
+296
View File
@@ -0,0 +1,296 @@
// e2e: 数据大屏两种形态 —— 首页班级入口 + 单班独立大屏(含后退锁定 / 顶栏控件收敛)
//
// 覆盖点:
// 1) 无 ?class= → dash-home:仍显示班级下拉,顶栏出现"选择考试 → 点卡片进入"文案
// 2) ?class=X → dash-class:顶栏隐藏班级选择 / ← 主系统,页面内不出现回首页的链接
// 3) 后退锁定:lockHistory() 只在 IS_CLASS 时挂,首页不受影响
// 4) 卡片渲染:直接把 dashboard.php 里的 clsCardHtml 抽出来跑,断言卡片结构与跳转参数
// 5) 公开访问:带 ?class= 的班级大屏免登录,且接口只回该班数据(其它班级不泄漏)
//
// 运行:node tools/test-dashboard-nav.js
const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const PORT = 18091;
const INI = process.env.PHP_INI || '';
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
const MOCK = { user: 'mock', pass: 'surveyking' };
// NY_DATA 指向临时目录:名单/配置/缓存一律写临时目录,绝不碰项目 data/
let p = process.env.NY_DATA || path.join(os.tmpdir(), 'ny-dash-nav-' + process.pid);
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2);
const DATA = path.resolve(p);
process.env.NY_DATA = DATA;
fs.mkdirSync(DATA, { recursive: true });
const cfgFile = path.join(DATA, 'config.json');
// 名单:5 人有答卷(与 mock 的 PARTNERS 90001-90005 对齐),再补 30 名无答卷者凑出 3 个班
const ROSTER = { students: [], updated_at: '2026-01-01 00:00:00' };
const back = [
['90001', '高一(1)班', 'A组'], ['90002', '高一(1)班', 'A组'], ['90003', '高一(1)班', 'B组'],
['90004', '高一(2)班', 'A组'], ['90005', '高一(2)班', 'B组'],
];
back.forEach(([sid, cls, grp]) => ROSTER.students.push({ sid, name: sid, cls, grp, login: '' }));
['高一(1)班', '高一(2)班', '高一(3)班'].forEach((cls, ci) => {
for (let i = 1; i <= 10; i++) {
const sid = String(9100 + ci * 100 + i);
ROSTER.students.push({ sid, name: sid, cls, grp: i % 2 ? 'A组' : 'B组', login: '' });
}
});
try { fs.unlinkSync(cfgFile); } catch (e) {}
// 起始配置:连的是本机 mock,账号留空(登录时再写入,与 test-login-loop 同口径)
fs.writeFileSync(cfgFile, JSON.stringify({
base_url: 'http://127.0.0.1:19911',
api_prefix: '/api',
username: '',
password: '',
verify_ssl: false,
timeout: 10,
refresh_interval: 15,
page_size: 50,
dedupe: true,
include_temp_save: false,
name_keywords: '姓名,名字',
full_score: 100,
layout: 'default',
}, null, 2));
fs.writeFileSync(path.join(DATA, 'roster.json'), JSON.stringify(ROSTER));
let cleanedUp = false, mock = null, srv = null;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
try { srv && srv.kill('SIGKILL'); } catch (e) {}
try { mock && mock.kill('SIGKILL'); } catch (e) {}
try { fs.rmSync(DATA, { recursive: true, force: true }); } catch (e) {}
}
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(1); });
process.on('uncaughtException', e => { console.error('FATAL', e); cleanup(); process.exit(2); });
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'ignore' });
srv = spawn(PHP, INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]), { cwd: ROOT, stdio: 'ignore' });
let pass = 0, fail = 0;
function chk(name, cond, extra) {
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
}
const cookieJar = [];
function collect(res) {
for (const sc of (res.headers['set-cookie'] || [])) {
const pair = sc.split(';')[0];
const name = pair.split('=')[0];
const i = cookieJar.findIndex(x => x.startsWith(name + '='));
if (i >= 0) cookieJar[i] = pair; else cookieJar.push(pair);
}
}
const jarHeader = () => (cookieJar.length ? cookieJar.map(c => c.replace(/;.*$/, '')).join('; ') : '');
// jar 省略 → 用当前登录会话;传 [] → 完全不带 Cookie 的匿名请求(验公开访问)
function get(p, jar) {
return new Promise((resolve, reject) => {
const h = { 'Accept': 'text/html,application/json' };
const ck = jar === undefined ? jarHeader()
: (jar || []).map(c => c.replace(/;.*$/, '')).join('; ');
if (ck) h['Cookie'] = ck;
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'GET', path: p, headers: h }, res => {
const bufs = [];
res.on('data', c => bufs.push(c));
res.on('end', () => {
// 匿名请求不回灌 Cookie:否则会用它自己的新会话覆盖掉登录会话
if (jar === undefined) collect(res);
resolve({ status: res.statusCode, raw: Buffer.concat(bufs).toString('utf8'), loc: res.headers.location });
});
});
r.on('error', reject);
r.end();
});
}
function post(p, fields) {
return new Promise((resolve, reject) => {
const data = new URLSearchParams(fields).toString();
const h = { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(data) };
if (jarHeader()) h['Cookie'] = jarHeader();
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path: p, headers: h }, res => {
const bufs = [];
res.on('data', c => bufs.push(c));
res.on('end', () => { collect(res); resolve({ status: res.statusCode, raw: Buffer.concat(bufs).toString('utf8') }); });
});
r.on('error', reject);
r.write(data); r.end();
});
}
const delay = ms => new Promise(r => setTimeout(r, ms));
const dashSrc = fs.readFileSync(path.join(ROOT, 'dashboard.php'), 'utf8');
const cssSrc = fs.readFileSync(path.join(ROOT, 'assets', 'style.css'), 'utf8');
(async () => {
await delay(2200);
console.log('\n--- 0) 登录 ---');
await post('/login.php', { username: MOCK.user, password: MOCK.pass });
const ac = await get('/api.php?action=auth_check');
chk('已登录', /"auth"\s*:\s*true/.test(ac.raw), ac.raw.slice(0, 120));
console.log('\n--- 1) 首页形态(无 ?class=)---');
const home = await get('/dashboard.php');
chk('首页返回 200', home.status === 200, 'status=' + home.status);
chk('body 带 dash-home、不带 dash-class',
/<body class="dash dash-home">/.test(home.raw) && !/dash dash-class/.test(home.raw),
(home.raw.match(/<body class="[^"]*"/) || [''])[0]);
chk('首页仍显示班级下拉(机构形态不变)', /id="classSelWrap"/.test(home.raw));
chk('首页保留「← 主系统 / 退出」入口(仅单班页隐藏)',
/id="linkIndex" href="index\.php"/.test(home.raw) && /id="linkLogout" href="logout\.php"/.test(home.raw));
chk('首页首屏提示为"先选考试再点班级"', /先选择一份问卷 \/ 考试,再点击班级卡片进入该班大屏/.test(home.raw));
chk('首页把 renderBoard 分流到 renderClassCards',
/if \(!IS_CLASS\) \{ renderClassCards\(\); return; \}/.test(dashSrc));
chk('首页自动刷新照旧可用(autoRefresh/intervalSel 都在)',
/id="autoRefresh"/.test(home.raw) && /id="intervalSel"/.test(home.raw) && /id="btnFullscreen"/.test(home.raw));
console.log('\n--- 2) 单班大屏形态(?pid=p1&class=高一(1)班)---');
const one = await get('/dashboard.php?pid=p1&class=' + encodeURIComponent('高一(1)班'));
chk('单班页返回 200', one.status === 200, 'status=' + one.status);
chk('body 带 dash-class、不带 dash-home',
/<body class="dash dash-class">/.test(one.raw) && !/dash dash-home/.test(one.raw),
(one.raw.match(/<body class="[^"]*"/) || [''])[0]);
chk('顶栏显示当前班级徽标', /id="heroClass"[^>]*>高一\(1\)班/.test(one.raw),
(one.raw.match(/id="heroClass"[^>]*/) || [''])[0]);
chk('页面内没有任何指向大屏首页的链接', !/href="dashboard\.php"/.test(one.raw));
chk('lockHistory() 在启动时被调起', /lockHistory\(\);/.test(dashSrc));
chk('后退锁定只在单班形态生效', /if \(!IS_CLASS \|\| historyLocked\) return;/.test(dashSrc));
chk('lockHistory 用 pushState 把本页重新推回栈顶',
/history\.pushState\(\{ nyLock: 1 \}, '', location\.href\)/.test(dashSrc) &&
/popstate/.test(dashSrc));
chk('首页形态不锁后退(IS_CLASS 为假时不 push)', !/if \(IS_CLASS(?![\s\S]{0,40}return;\})[\s\S]{0,40}pushState/.test(
dashSrc.slice(dashSrc.indexOf('function lockHistory'), dashSrc.indexOf('function lockHistory') + 400)));
console.log('\n--- 3) 顶栏控件收敛(CSS 隐藏规则)---');
chk('隐藏班级选择', /body\.dash-class #classSelWrap,\s*\n?\s*body\.dash-class #linkIndex \{ display: none !important; \}/.test(cssSrc));
chk('隐藏「← 主系统」', /body\.dash-class #linkIndex/.test(cssSrc));
chk('已不再靠 CSS 藏退出按钮(该形态下根本不渲染)', !/#linkLogout/.test(cssSrc));
chk('首页隐藏 5 张汇总卡', /body\.dash-home #boardSummary \{ display: none; \}/.test(cssSrc));
chk('单班页仍保留试卷/自动刷新/全屏三个控件',
/id="projectSel"/.test(one.raw) && /id="autoRefresh"/.test(one.raw) && /id="btnFullscreen"/.test(one.raw));
chk('单班页的「← 主系统」靠 CSS 隐藏',
/id="linkIndex"/.test(one.raw) && /display: none !important/.test(
cssSrc.slice(cssSrc.indexOf('body.dash-class #classSelWrap'), cssSrc.indexOf('body.dash-home #boardSummary'))));
chk('单班页(已登录)仍渲染退出按钮,由 CSS 隐藏', /id="linkLogout"/.test(one.raw));
console.log('\n--- 4) 班级卡片渲染(把 dashboard.php 里的 clsCardHtml 抽出来实跑)---');
const m = dashSrc.match(/function clsCardHtml\(c\)\{[\s\S]*?\n\}/);
chk('能抽出 clsCardHtml 函数体', !!m);
if (m) {
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const dv = (v, d) => (v === undefined || v === null || v === '' ? (d === undefined ? '-' : d) : v);
const pv = v => dv(v, '-') + (v === undefined || v === null || v === '' ? '' : '%');
const rateColor = r => (r >= 90 ? '#12a86a' : (r >= 60 ? '#e08c1a' : '#e0483d'));
const fn = new Function('esc', 'dv', 'pv', 'rateColor', m[0] + ' return clsCardHtml;')(esc, dv, pv, rateColor);
const html = fn({
class: '高一(1)班', total: 20, submitted: 3, absent: 17, rate: 15,
attempts: 5, avg: 78.5, pass_rate: 66.7,
});
chk('卡片带上班级名与进入入口', /data-cls="高一\(1\)班"/.test(html) && /cls-go/.test(html));
chk('卡片是按钮语义(role/tabindex)', /role="button"/.test(html) && /tabindex="0"/.test(html));
chk('5 项指标齐全', (html.match(/cls-kpi /g) || []).length === 5, '实际 ' + (html.match(/cls-kpi /g) || []).length + ' 项');
chk('提交率进度条按 rate 取值', /cls-bar"><i style="width:15%/.test(html), (html.match(/width:\d+%/) || [''])[0]);
chk('多次刷题徽标透传', /共 5 次刷题/.test(html));
chk('无成绩时的缺考数照常显示', /<span>缺考<\/span><b>17<\/b>/.test(html));
}
console.log('\n--- 5) 进入班级的跳转 ---');
const e = dashSrc.match(/function enterClass\(cls\)\{[\s\S]*?\n\}/);
chk('能抽出 enterClass 函数体', !!e);
if (e) {
chk('跳转时带上 pid + class + auto',
/searchParams\.set\('pid'/.test(e[0]) && /searchParams\.set\('class'/.test(e[0]) && /searchParams\.set\('auto'/.test(e[0]));
chk('是整页跳转(location.href,不是前端路由)', /location\.href = u\.pathname \+ u\.search;/.test(e[0]));
chk('卡片点击/回车都走事件委托(网格重绘不掉监听)',
/box\.addEventListener\('click'/.test(dashSrc) && /box\.addEventListener\('keydown'/.test(dashSrc) &&
/closest\('\.cls-card'\)/.test(dashSrc));
}
chk('首页卡片容器类名与样式一致(cls-grid)',
/<div class="cls-grid">/.test(dashSrc) && /\.cls-grid \{/.test(cssSrc));
console.log('\n--- 5.5) 成绩红绿口径(必须跟随后台配置的满分成数 / 合格率)---');
const sgSrc = dashSrc.match(/function scoreGrade\(sc\)\{[\s\S]*?\n\}/);
chk('能从 dashboard.php 抽出 scoreGrade', !!sgSrc);
if (sgSrc) {
// scoreGrade 里读的是脚本作用域的 DASH(let 声明,不在 window 上),
// 这里把 DASH 作为形参传进去,还原同一条作用域链
// eslint-disable-next-line no-new-func
const mk = new Function('DASH', sgSrc[0] + '; return scoreGrade;');
const g100 = mk({ full_score: 100, pass_rate: 60 });
const g150 = mk({ full_score: 150, pass_rate: 80 });
const gCur = mk({ full_score: 100, pass_rate: 95 });
chk('默认口径:95 绿 / 70 黄 / 50 红',
g100(95).cls === 'score-high' && g100(70).cls === 'score-mid' && g100(50).cls === 'score-low',
[95, 70, 50].map(v => v + '→' + g100(v).cls).join(' '));
// 后台把合格率改成 80、满分改成 150:100 分只占 66.7%,必须判红,不能再按 100 分制当成优秀
chk('后台改「满分 150 / 合格率 80%」后,100 分判红(不再按 100 分制当优秀)',
g150(100).cls === 'score-low', '100→' + g150(100).cls);
chk('同一配置下 130 分(86.7%)判黄、145 分(96.7%)判绿',
g150(130).cls === 'score-mid' && g150(145).cls === 'score-high',
[130, 145].map(v => v + '→' + g150(v).cls).join(' '));
// 合格率 85%:88 分合格但没到 90 分优秀线 → 黄
chk('合格率 85% 时 88 分判黄(合格但未达优秀线,绿线仍是 90)',
mk({ full_score: 100, pass_rate: 85 })(88).cls === 'score-mid',
'88→' + mk({ full_score: 100, pass_rate: 85 })(88).cls);
// 合格率 95%:合格线本身高过 90 分,绿线跟着抬到 95 —— 92/94 都不及格判红,96 判绿
chk('合格线高过 90% 时绿线跟着抬(合格率 95% → 92/94 判红、96 判绿)',
gCur(92).cls === 'score-low' && gCur(94).cls === 'score-low' && gCur(96).cls === 'score-high',
[92, 94, 96].map(v => v + '→' + gCur(v).cls).join(' '));
chk('缺成绩/越界分数不套样式',
g100(null).cls === '' && g100(undefined).cls === '' && g100('abc').cls === '');
chk('合格线按满分折算后回传(150×80% = 120)', g150(100).line === 120, 'line=' + g150(100).line);
chk('scoreGrade 不再通过 window.DASH 取值(let 声明取不到 window,只能读自由变量 DASH)',
!/window\.DASH/.test(sgSrc[0]));
chk('membersTable 也不再走 window.DASH',
!/window\.DASH/.test(dashSrc.slice(dashSrc.indexOf('function membersTable'), dashSrc.indexOf('function membersTable') + 1600)));
}
console.log('\n--- 6) 公开访问:班级大屏免登录 ---');
const CLS2 = encodeURIComponent('高一(2)班');
const anonPage = await get('/dashboard.php?pid=p1&class=' + CLS2, []);
chk('未登录访问班级大屏返回 200(不再被弹到登录页)',
anonPage.status === 200 && !anonPage.loc, 'status=' + anonPage.status + ' loc=' + (anonPage.loc || ''));
chk('匿名页同样是 dash-class 形态', /<body class="dash dash-class">/.test(anonPage.raw));
chk('公开页不再渲染退出按钮', !/id="linkLogout"/.test(anonPage.raw));
chk('公开页渲染「公开 · 无需登录」角标', /class="pub-tag"/.test(anonPage.raw));
chk('取数带 class(公开访问后端要按班级裁剪,两处调用都不能漏)',
(dashSrc.match(/api\('dashboard',\s*dashParams\(/g) || []).length === 2,
'实际 ' + (dashSrc.match(/api\('dashboard',\s*dashParams\(/g) || []).length + ' 处');
chk('dashParams 在单班形态下补充 class 参数',
/if \(IS_CLASS && CUR\.cls\) p\.class = CUR\.cls;/.test(dashSrc));
const anonApi = await get('/api.php?action=dashboard&projectId=p1&class=' + CLS2, []);
const aj = JSON.parse(anonApi.raw);
chk('未登录调用 dashboard 接口成功', aj.ok === true, anonApi.raw.slice(0, 120));
chk('classes 只回指定班级',
Array.isArray(aj.data.classes) && aj.data.classes.length === 1 && aj.data.classes[0].class === '高一(2)班',
'返回 ' + (aj.data.classes || []).map(x => x.class).join('/'));
chk('别班数据没有泄漏', !JSON.stringify(aj.data.classes).includes('高一(1)班'));
chk('overall 已换成该班汇总且键名兼容',
aj.data.overall && typeof aj.data.overall.attempts_total === 'number',
'attempts_total=' + (aj.data.overall || {}).attempts_total);
chk('overall 不含全卷名单总数', aj.data.overall && aj.data.overall.roster_total === aj.data.classes[0].total,
'roster_total=' + (aj.data.overall || {}).roster_total + ' class.total=' + (aj.data.classes[0] || {}).total);
chk('响应带 public 标记', aj.data.public === true);
const anonNoCls = await get('/api.php?action=dashboard&projectId=p1', []);
chk('未登录且不指定 class → 拒绝(不能拿全卷数据)',
JSON.parse(anonNoCls.raw).ok === false, anonNoCls.raw.slice(0, 120));
const homeAnon = await get('/dashboard.php', []);
chk('未登录访问大屏首页仍然跳转登录页',
/login\.php/.test(homeAnon.loc || ''), 'loc=' + (homeAnon.loc || ''));
console.log('\n通过 ' + pass + ' 项,失败 ' + fail + ' 项');
cleanup();
process.exit(fail ? 1 : 0);
})();
+2 -2
View File
@@ -17,8 +17,8 @@ const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const PORT = 18087;
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
const MOCK = { user: 'mock', pass: 'surveyking' };
// NY_DATA 指向临时目录:Git Bash 风格 /c/... 需还原成 C:\...,并写回 env 给 PHP 子进程。
+2 -2
View File
@@ -12,8 +12,8 @@ const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const PHP_PORT = 18081;
const MOCK_PORT = 19911;
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
const DEBUG = !!process.env.NY_DEBUG;
// 数据目录优先读 NY_DATA 环境变量(与 bootstrap.php 一致),让测试整体指向临时目录,
+2 -2
View File
@@ -17,8 +17,8 @@ const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const PHP_PORT = 18082;
const MOCK_PORT = 19911;
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
const DEBUG = !!process.env.NY_DEBUG;
function resolveDataDir() {
+162
View File
@@ -99,6 +99,168 @@ if ($parts !== []) {
@unlink($manualOut);
}
// 4) 合并单元格(「每次考试」导出用):写入 mergeCells,且被合并的非首格必须留空
$mergedRows = [
['学号', '姓名', '考试名称', '第几次', '成绩', '最高分'],
['2021001', '张三', '数学期中', '第1次', '90', '是'],
['', '', '', '第2次', '80', ''], // 学号/姓名/考试名称被合并占用
['', '', '英语期中', '第1次', '85', '是'],
];
$mOut = __DIR__ . '/_xlsxwriter_merge.xlsx';
$mBin = XlsxWriter::renderMulti([[
'name' => '每次考试',
'rows' => $mergedRows,
'widths' => [20, 14, 26, 10, 10, 8],
// A2:A3、B2:B3 纵向合并;C2:C2 为退化区间(应被丢弃);越界区间也应被丢弃
'merges' => [[2, 1, 3, 1], [2, 2, 3, 2], [2, 3, 2, 3], [2, 6, 99, 6]],
]]);
file_put_contents($mOut, $mBin);
chk('合并表 ZIP 魔数 PK', substr($mBin, 0, 2) === 'PK');
// 取 sheet XML:优先 ZipArchive;没有 zip 扩展时借 XlsxReader 的纯 PHP 解包(stored/deflate 都能读)
$mParts = [];
if (class_exists('ZipArchive')) {
$mz = new ZipArchive();
if ($mz->open($mOut) === true) {
for ($i = 0; $i < $mz->numFiles; $i++) {
$n = $mz->getNameIndex($i);
$mParts[$n] = $mz->getFromIndex($i);
}
$mz->close();
}
}
if ($mParts === []) {
try {
$rm = new ReflectionMethod('XlsxReader', 'readZip');
$rm->setAccessible(true);
$mParts = (array)$rm->invoke(null, $mOut);
} catch (Throwable $e) {
$mParts = [];
}
}
$sheetXml = (string)($mParts['xl/worksheets/sheet1.xml'] ?? '');
if ($sheetXml !== '') {
chk('写入 mergeCells 且计数为 2(退化/越界区间被丢弃)',
strpos($sheetXml, '<mergeCells count="2">') !== false, '未找到 mergeCells count=2');
chk('合并区间 A2:A3 / B2:B3 正确',
strpos($sheetXml, '<mergeCell ref="A2:A3"/>') !== false
&& strpos($sheetXml, '<mergeCell ref="B2:B3"/>') !== false);
chk('mergeCells 位于 sheetData 之后',
strpos($sheetXml, '</sheetData><mergeCells') !== false);
chk('被合并的非首格只带样式、不带值(A3)',
strpos($sheetXml, '<c r="A3" s="2"/>') !== false, '未找到 <c r="A3" s="2"/>');
chk('区块首格仍然保留(A2 / B2 在)',
strpos($sheetXml, 'r="A2"') !== false && strpos($sheetXml, 'r="B2"') !== false);
chk('英语块第 4 行考试名称未被吞(C4 在)', strpos($sheetXml, 'r="C4"') !== false);
chk('样式表已写入细边框', strpos((string)($mParts['xl/styles.xml'] ?? ''), '<left style="thin">') !== false);
} else {
chk('(跳过)无 ZipArchive,合并单元格 XML 未校验', true);
}
$mBack = XlsxReader::read($mOut);
chk('含合并表可被 XlsxReader 解析', count($mBack) === count($mergedRows), '行数=' . count($mBack));
chk('合并表首行内容正确', ($mBack[1][1] ?? '') === '张三' && ($mBack[3][2] ?? '') === '英语期中',
json_encode($mBack[1] ?? [], JSON_UNESCAPED_UNICODE));
@unlink($mOut);
/* 5) 两行表头 + 交叉合并(「多考试导出」块表用):header_rows=2 时两行都按表头样式输出,
并用 cell_styles 标记「最高分红字 / 合格次数绿底」 */
$blockRows = [
['学号', '姓名', '第几次', '数学', '', '英语', ''],
['', '', '', '成绩', '', '成绩', ''],
['356001', '张三', '1', '112', '112'],
['', '', '2', '100', '100'],
['', '', '3', '136', '133'],
['', '', '合格次数', '3', '', '2', ''],
['356002', '李四', '1', '未考', '', '130'],
];
$bOut = __DIR__ . '/_xlsxwriter_block.xlsx';
$bBin = XlsxWriter::renderMulti([[
'name' => '每次考试',
'rows' => $blockRows,
'widths' => [20, 14, 9, 10, 11, 10, 11],
'merges' => [
[1, 1, 2, 1], [1, 2, 2, 2], [1, 3, 2, 3], [1, 4, 1, 5], [1, 6, 1, 7], // 表头
[3, 1, 6, 1], [3, 2, 6, 2], [3, 3, 5, 3], // 张三 学号/姓名/第几次
// 「第几次」不并入合格次数行,
// 否则 C6 的「合格次数」标签会被吞掉
[7, 4, 7, 5], // 李四 数学「未考」跨 2 列
],
'header_rows' => 2,
'cell_styles' => [
'5,4' => 'red', // 张三数学最高分 136 → 红字
'6,4' => 'green', // 张三数学合格次数 → 绿底
'6,6' => 'green', // 张三英语合格次数 → 绿底
'8,2' => 'nope', // 未知标记应被静默忽略
],
]]);
file_put_contents($bOut, $bBin);
chk('两行表头表 ZIP 魔数 PK', substr($bBin, 0, 2) === 'PK');
$bParts = [];
if (class_exists('ZipArchive')) {
$bz = new ZipArchive();
if ($bz->open($bOut) === true) {
for ($i = 0; $i < $bz->numFiles; $i++) {
$bn = $bz->getNameIndex($i);
$bParts[$bn] = $bz->getFromIndex($i);
}
$bz->close();
}
}
$bXml = (string)($bParts['xl/worksheets/sheet1.xml'] ?? '');
$bStyles = (string)($bParts['xl/styles.xml'] ?? '');
if ($bXml !== '') {
chk('第 2 行表头也用表头样式(s="1")',
strpos($bXml, '<c r="A2" s="1"/>') !== false && strpos($bXml, 'r="D2" s="1"') !== false);
chk('数据行用文本样式(s="2")', strpos($bXml, 'r="A3" s="2"') !== false);
chk('表头合并区间齐备',
strpos($bXml, '<mergeCell ref="A1:A2"/>') !== false
&& strpos($bXml, '<mergeCell ref="D1:E1"/>') !== false
&& strpos($bXml, '<mergeCell ref="F1:G1"/>') !== false
&& strpos($bXml, '<mergeCell ref="D7:E7"/>') !== false);
chk('mergeCells 计数 = 9', strpos($bXml, '<mergeCells count="9">') !== false);
chk('「第几次」不并入合格次数行(C6 仍带值)',
strpos($bXml, '<c r="C6" s="2" t="s"><v>16</v></c>') !== false);
// ---- 单元格级样式:最高分红字(s="3")/ 合格次数绿底(s="4") ----
chk('最高分标红用红字样式(D5 s="3")', strpos($bXml, '<c r="D5" s="3"') !== false);
chk('合格次数达标用绿底样式(D6 s="4")', strpos($bXml, '<c r="D6" s="4"') !== false);
chk('合格次数达标用绿底样式(F6 s="4")', strpos($bXml, '<c r="F6" s="4"') !== false);
chk('未标记格仍用普通文本样式(D3 s="2")', strpos($bXml, '<c r="D3" s="2"') !== false);
// 样式表里必须真的存在红字字体与绿色填充,否则 Excel 打开是一片黑白
chk('styles 含红色字体(FFD0021B)', strpos($bStyles, 'FFD0021B') !== false);
chk('styles 含绿色填充(FF63C384)', strpos($bStyles, 'FF63C384') !== false);
chk('cellXfs 数量 = 6', strpos($bStyles, '<cellXfs count="6">') !== false);
// XML 必须合法:结构错一个字符 Excel 就会提示"修复",用 DOM 解析一遍最省事
if (class_exists('DOMDocument')) {
$okXml = true;
foreach (['sheet1.xml' => $bXml, 'styles.xml' => $bStyles] as $part => $xml) {
$doc = new DOMDocument();
if ($xml === '' || !@$doc->loadXML($xml)) {
$okXml = false;
chk('XML 可解析:' . $part, false, '解析失败');
}
}
if ($okXml) {
chk('XML 可解析(sheet / styles)', true);
}
}
} else {
chk('(跳过)无 ZipArchive,两行表头 XML 未校验', true);
}
$bBack = XlsxReader::read($bOut);
chk('两行表头表可被读回', count($bBack) === count($blockRows), '行数=' . count($bBack));
chk('两行表头表内容正确',
($bBack[2][0] ?? '') === '356001'
&& ($bBack[5][2] ?? '') === '合格次数' // 合格次数标签不被合并吞掉
&& ($bBack[6][3] ?? '') === '未考'
&& ($bBack[4][3] ?? '') === '136' && ($bBack[4][4] ?? '') === '133',
json_encode($bBack[6] ?? [], JSON_UNESCAPED_UNICODE));
@unlink($bOut);
@unlink($out);
echo PHP_EOL . "结果:$pass 通过,$fail 失败" . PHP_EOL;
exit($fail === 0 ? 0 : 1);