Compare commits
5
Commits
eb5d06d392
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e87986fcc6 | ||
|
|
d05fd4a9b5 | ||
|
|
0ba9c7c33c | ||
|
|
f661862d46 | ||
|
|
555f500dc7 |
@@ -1,6 +1,9 @@
|
|||||||
# 运行时数据:含账号密码、API 令牌、学生名单,禁止入库
|
# 运行时数据:含账号密码、API 令牌、学生名单,禁止入库
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# WorkBuddy 项目数据目录(记忆 / 配置缓存),非源码,禁止入库
|
||||||
|
.workbuddy/
|
||||||
|
|
||||||
# e2e 隔离用的临时数据目录(run-all.sh 自动创建,不入库)
|
# e2e 隔离用的临时数据目录(run-all.sh 自动创建,不入库)
|
||||||
.ny-test-data/
|
.ny-test-data/
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require __DIR__ . '/bootstrap.php';
|
require __DIR__ . '/bootstrap.php';
|
||||||
|
require __DIR__ . '/lib/multi_export.php';
|
||||||
|
|
||||||
$action = (string)($_GET['action'] ?? $_POST['action'] ?? '');
|
$action = (string)($_GET['action'] ?? $_POST['action'] ?? '');
|
||||||
|
|
||||||
@@ -64,6 +65,11 @@ function ny_absent_sheet_rows(array $absent): array
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多考试对比导出(成绩交叉矩阵)的纯逻辑见 lib/multi_export.php
|
||||||
|
* (ny_exam_cell_score / ny_assemble_multi_exam_matrix),便于在 api.php 与单测中复用。
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 带短期缓存的数据构建(含并发锁,避免多人同时刷新时重复打卷王造成惊群)
|
* 带短期缓存的数据构建(含并发锁,避免多人同时刷新时重复打卷王造成惊群)
|
||||||
*
|
*
|
||||||
@@ -126,8 +132,11 @@ function ny_build_data(array $c, string $projectId, string $nameQid, bool $force
|
|||||||
return ['result' => $result, 'cached' => false, 'at' => time()];
|
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);
|
ny_json(['ok' => false, 'msg' => '未登录或会话已过期,请重新登录'], 401);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -171,66 +180,10 @@ try {
|
|||||||
$in = $_POST;
|
$in = $_POST;
|
||||||
}
|
}
|
||||||
$old = cfg();
|
$old = cfg();
|
||||||
$allowed = ['base_url', 'api_prefix', 'username', 'timeout', 'refresh_interval', 'page_size', 'name_keywords', 'history_display', 'export_sort'];
|
// 合并规则统一放在 ny_apply_config_input(含"账号/密码不许被改空"的兜底),便于单测
|
||||||
$new = $old;
|
$applied = ny_apply_config_input($old, $in);
|
||||||
foreach ($allowed as $k) {
|
$new = $applied['config'];
|
||||||
if (array_key_exists($k, $in)) {
|
$connChanged = $applied['conn_changed'];
|
||||||
$new[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 密码:保留首尾空格的原貌,空 / 占位符 = 不修改
|
|
||||||
if (array_key_exists('password', $in)) {
|
|
||||||
$pwdIn = (string)$in['password'];
|
|
||||||
if ($pwdIn !== '' && $pwdIn !== '******') {
|
|
||||||
$new['password'] = $pwdIn;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
foreach (['verify_ssl', 'include_temp_save', 'dedupe'] as $k) {
|
|
||||||
if (array_key_exists($k, $in)) {
|
|
||||||
$new[$k] = filter_var($in[$k], FILTER_VALIDATE_BOOL);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 布局配置
|
|
||||||
if (array_key_exists('layout', $in)) {
|
|
||||||
$new['layout'] = ($in['layout'] === 'side') ? 'side' : 'default';
|
|
||||||
}
|
|
||||||
// 历史成绩展示方式:click(点击成员弹窗,默认) / inline(明细内全部展开)
|
|
||||||
if (array_key_exists('history_display', $in)) {
|
|
||||||
$new['history_display'] = ($in['history_display'] === 'inline') ? 'inline' : 'click';
|
|
||||||
}
|
|
||||||
// 成绩导出排序:time(按提交时间降序,默认)/ sid(按学号)/ score_desc(成绩降序)/ score_asc(成绩升序)
|
|
||||||
if (array_key_exists('export_sort', $in)) {
|
|
||||||
$v = (string)$in['export_sort'];
|
|
||||||
$new['export_sort'] = in_array($v, ['time','sid','score_desc','score_asc'], true) ? $v : 'time';
|
|
||||||
}
|
|
||||||
// 说明:「各试卷独立满分」已移出设置,改为在主页面针对当前试卷配置
|
|
||||||
// (project_config / project_config_save),设置里只保留「全局默认满分」。
|
|
||||||
if (array_key_exists('pass_rate', $in)) {
|
|
||||||
$pr = (float)$in['pass_rate'];
|
|
||||||
$new['pass_rate'] = ($pr >= 1 && $pr <= 100) ? $pr : 60;
|
|
||||||
}
|
|
||||||
$new['base_url'] = rtrim((string)$new['base_url'], '/');
|
|
||||||
$new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/');
|
|
||||||
$new['timeout'] = (int)$new['timeout'];
|
|
||||||
$new['refresh_interval'] = (int)$new['refresh_interval'];
|
|
||||||
if ($new['refresh_interval'] < 3) {
|
|
||||||
$new['refresh_interval'] = 3;
|
|
||||||
}
|
|
||||||
// 默认满分
|
|
||||||
$fs = (int)($in['full_score'] ?? $new['full_score'] ?? 100);
|
|
||||||
if ($fs < 1) {
|
|
||||||
$fs = 100;
|
|
||||||
}
|
|
||||||
$new['full_score'] = $fs;
|
|
||||||
// 仅当连接信息真的变了才清 token + 重连;保存满分/布局等不应触发登录卷王
|
|
||||||
$connKeys = ['base_url', 'api_prefix', 'username', 'password', 'verify_ssl'];
|
|
||||||
$connChanged = false;
|
|
||||||
foreach ($connKeys as $k) {
|
|
||||||
if ((string)($old[$k] ?? '') !== (string)($new[$k] ?? '')) {
|
|
||||||
$connChanged = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ny_save_config($new);
|
ny_save_config($new);
|
||||||
if ($connChanged) {
|
if ($connChanged) {
|
||||||
@unlink(NY_DATA . '/cache/token.json');
|
@unlink(NY_DATA . '/cache/token.json');
|
||||||
@@ -355,6 +308,20 @@ try {
|
|||||||
$students = $result['roster'] ?: [];
|
$students = $result['roster'] ?: [];
|
||||||
$rows = $result['rows'] ?: [];
|
$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 = [];
|
$byCls = [];
|
||||||
foreach ($students as $s) {
|
foreach ($students as $s) {
|
||||||
$k = trim((string)($s['cls'] ?? '')) ?: '(未分班)';
|
$k = trim((string)($s['cls'] ?? '')) ?: '(未分班)';
|
||||||
@@ -464,14 +431,32 @@ try {
|
|||||||
return strcmp((string)$a['class'], (string)$b['class']);
|
return strcmp((string)$a['class'], (string)$b['class']);
|
||||||
});
|
});
|
||||||
|
|
||||||
ny_ok([
|
$payload = [
|
||||||
'project' => $result['project'],
|
'project' => $result['project'],
|
||||||
'full_score' => $fs,
|
'full_score' => $fs,
|
||||||
'pass_rate' => $passRate,
|
'pass_rate' => $passRate,
|
||||||
'overall' => $result['stats'],
|
'overall' => $result['stats'],
|
||||||
'classes' => $classes,
|
'classes' => $classes,
|
||||||
'updated_at' => $result['updated_at'],
|
'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':
|
case 'roster':
|
||||||
@@ -761,10 +746,17 @@ try {
|
|||||||
$mode = ((string)($_GET['mode'] ?? '') === 'latest') ? 'latest' : 'all';
|
$mode = ((string)($_GET['mode'] ?? '') === 'latest') ? 'latest' : 'all';
|
||||||
$splitCls = ((string)($_GET['split'] ?? '') === 'cls');
|
$splitCls = ((string)($_GET['split'] ?? '') === 'cls');
|
||||||
$isLatest = ($mode === 'latest');
|
$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' ? '缺考名单' : '成绩明细')
|
$filename = ($action === 'export_absent' ? '缺考名单' : '成绩明细')
|
||||||
. '_' . $safe((string)$result['project']['name'])
|
. '_' . $safe((string)$result['project']['name'])
|
||||||
. $tag
|
. $tag
|
||||||
. ($isLatest ? '_最新成绩' : '')
|
. $scoreTag
|
||||||
. '_' . date('Ymd_His') . '.xlsx';
|
. '_' . date('Ymd_His') . '.xlsx';
|
||||||
|
|
||||||
// ---------------- 列定义(可通过 cols= 参数挑选) ----------------
|
// ---------------- 列定义(可通过 cols= 参数挑选) ----------------
|
||||||
@@ -773,17 +765,18 @@ try {
|
|||||||
'name' => '姓名',
|
'name' => '姓名',
|
||||||
'cls' => '班级',
|
'cls' => '班级',
|
||||||
'grp' => '组别',
|
'grp' => '组别',
|
||||||
'score' => $isLatest ? '最新成绩' : '成绩',
|
'score' => $isLatest ? ($rowScoreMode === 'best' ? '最高成绩' : '最新成绩') : '成绩',
|
||||||
'best_score' => '最高分',
|
'best_score' => '最高分',
|
||||||
'attempts' => '考试次数',
|
'attempts' => '考试次数',
|
||||||
'idx' => '第几次',
|
'idx' => '第几次',
|
||||||
|
'is_best' => '是否最高分',
|
||||||
'submit_at' => $isLatest ? '最近提交时间' : '提交时间',
|
'submit_at' => $isLatest ? '最近提交时间' : '提交时间',
|
||||||
'duration_txt' => '考试时长',
|
'duration_txt' => '考试时长',
|
||||||
'duration' => '考试时长(秒)',
|
'duration' => '考试时长(秒)',
|
||||||
];
|
];
|
||||||
$defaultCols = $isLatest
|
$defaultCols = $isLatest
|
||||||
? ['sid', 'name', 'cls', 'grp', 'score', 'best_score', 'attempts', 'submit_at', 'duration_txt', 'duration']
|
? ['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(
|
$want = array_values(array_filter(
|
||||||
array_map('trim', explode(',', (string)($_GET['cols'] ?? ''))),
|
array_map('trim', explode(',', (string)($_GET['cols'] ?? ''))),
|
||||||
static fn($v): bool => $v !== ''
|
static fn($v): bool => $v !== ''
|
||||||
@@ -796,7 +789,8 @@ try {
|
|||||||
// ---------------- 数据行 ----------------
|
// ---------------- 数据行 ----------------
|
||||||
$sortMode = (string)($_GET['sort'] ?? '');
|
$sortMode = (string)($_GET['sort'] ?? '');
|
||||||
if ($isLatest) {
|
if ($isLatest) {
|
||||||
// 每人一行:成绩取「最近一次真实提交」的分数(不受 dedupe 影响),并附最高分与考试次数。
|
// 每人一行:成绩按「最新成绩」(最近一次真实提交)或「最高成绩」(best_score),
|
||||||
|
// 并附最高分与考试次数。
|
||||||
// 注意:include_temp_save 开启时 attempts_list 可能含暂存草稿,取最新成绩应跳过 temp_save,
|
// 注意:include_temp_save 开启时 attempts_list 可能含暂存草稿,取最新成绩应跳过 temp_save,
|
||||||
// 否则导出的"最新成绩"可能是一份草稿分。
|
// 否则导出的"最新成绩"可能是一份草稿分。
|
||||||
$all = [];
|
$all = [];
|
||||||
@@ -812,19 +806,50 @@ try {
|
|||||||
if ($last === null && $list !== []) {
|
if ($last === null && $list !== []) {
|
||||||
$last = $list[count($list) - 1]; // 全是暂存时退回最后一份
|
$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[] = [
|
$all[] = [
|
||||||
'sid' => (string)($r['sid'] ?? ''),
|
'sid' => (string)($r['sid'] ?? ''),
|
||||||
'name' => (string)($r['name'] ?? ''),
|
'name' => (string)($r['name'] ?? ''),
|
||||||
'cls' => (string)($r['cls'] ?? ''),
|
'cls' => (string)($r['cls'] ?? ''),
|
||||||
'grp' => (string)($r['grp'] ?? ''),
|
'grp' => (string)($r['grp'] ?? ''),
|
||||||
'score' => $last !== null ? ($last['score'] ?? null) : ($r['score'] ?? null),
|
'score' => ($rowScoreMode === 'best')
|
||||||
'best_score' => $r['best_score'] ?? null,
|
? (($bestScore === null || $bestScore === '') ? null : $bestScore)
|
||||||
|
: ($useRow !== null ? ($useRow['score'] ?? null) : ($r['score'] ?? null)),
|
||||||
|
'best_score' => $bestScore,
|
||||||
'attempts' => (int)($r['attempts'] ?? 1),
|
'attempts' => (int)($r['attempts'] ?? 1),
|
||||||
'idx' => (int)($r['attempts'] ?? 1),
|
'idx' => (int)($r['attempts'] ?? 1),
|
||||||
'submit_ts' => (int)($r['last_submit_ts'] ?? 0),
|
'submit_ts' => ($useRow !== null && isset($useRow['submit_ts']))
|
||||||
'submit_at' => (string)($r['last_submit_at'] ?? ($last['submit_at'] ?? ($r['submit_at'] ?? '-'))),
|
? (int)$useRow['submit_ts']
|
||||||
'duration_txt' => (string)($last['duration_txt'] ?? ($r['duration_txt'] ?? '-')),
|
: (int)($r['last_submit_ts'] ?? 0),
|
||||||
'duration' => $last['duration'] ?? ($r['duration'] ?? null),
|
'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') {
|
if ($sortMode === 'score_desc' || $sortMode === 'score_asc') {
|
||||||
@@ -835,6 +860,9 @@ try {
|
|||||||
} else {
|
} else {
|
||||||
// 每次考试一行(同一学号考了 N 次就有 N 行)
|
// 每次考试一行(同一学号考了 N 次就有 N 行)
|
||||||
$all = DataService::expandAttempts($rows, true);
|
$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') {
|
if ($sortMode === 'score_desc' || $sortMode === 'score_asc') {
|
||||||
$all = ny_sort_by_score($all, $sortMode === 'score_desc');
|
$all = ny_sort_by_score($all, $sortMode === 'score_desc');
|
||||||
} else {
|
} else {
|
||||||
@@ -842,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 {
|
// 出表方式 = 「每次考试一行」时,成绩达合格线的行标绿底(与多考试块表的口径一致)。
|
||||||
|
// $marks 每次调用先重置,因此分班级多工作表时各表的标记不会串到别的表里。
|
||||||
|
$marks = [];
|
||||||
|
$toRows = static function (array $list) use ($cols, $colLabels, $mode, $passLineVal, $scoreColIdx, &$marks): array {
|
||||||
|
$marks = [];
|
||||||
$head = [];
|
$head = [];
|
||||||
foreach ($cols as $c) {
|
foreach ($cols as $c) {
|
||||||
$head[] = $colLabels[$c];
|
$head[] = $colLabels[$c];
|
||||||
}
|
}
|
||||||
$out = [$head];
|
$out = [$head];
|
||||||
|
$rn = 1; // 工作表内行号(1-based,与 cell_styles 一致)
|
||||||
foreach ($list as $r) {
|
foreach ($list as $r) {
|
||||||
|
$rn++;
|
||||||
$line = [];
|
$line = [];
|
||||||
foreach ($cols as $c) {
|
foreach ($cols as $i => $c) {
|
||||||
$v = $r[$c] ?? null;
|
$v = $r[$c] ?? null;
|
||||||
$line[] = ($v === null || $v === '') ? '' : (string)$v;
|
$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;
|
$out[] = $line;
|
||||||
}
|
}
|
||||||
@@ -876,13 +919,25 @@ try {
|
|||||||
}
|
}
|
||||||
ksort($byCls, SORT_NATURAL | SORT_FLAG_CASE);
|
ksort($byCls, SORT_NATURAL | SORT_FLAG_CASE);
|
||||||
foreach ($byCls as $clsName => $list) {
|
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 {
|
} 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]];
|
$sheets[] = ['name' => '缺考名单', 'rows' => ny_absent_sheet_rows($absent), 'widths' => [16, 14, 16, 10]];
|
||||||
}
|
}
|
||||||
$unmatched = array_values((array)($result['unmatched'] ?? []));
|
$unmatched = array_values((array)($result['unmatched'] ?? []));
|
||||||
@@ -899,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 公式注入)
|
// 既不会把长学号变成科学计数法,也不会把 = + - @ 开头的内容当公式执行(规避 CSV 公式注入)
|
||||||
$bin = XlsxWriter::renderMulti($sheets);
|
$bin = XlsxWriter::renderMulti($sheets);
|
||||||
@@ -910,6 +982,253 @@ try {
|
|||||||
echo $bin;
|
echo $bin;
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
|
// ---------------- 多考试对比导出(横向交叉表) ----------------
|
||||||
|
case 'export_multi':
|
||||||
|
$idsRaw = (string)($_GET['projectIds'] ?? '');
|
||||||
|
$ids = array_values(array_unique(array_filter(
|
||||||
|
array_map('trim', explode(',', $idsRaw)),
|
||||||
|
static fn($v): bool => $v !== ''
|
||||||
|
)));
|
||||||
|
if ($ids === []) {
|
||||||
|
ny_fail('请至少选择一个问卷 / 考试');
|
||||||
|
}
|
||||||
|
$nameQid = (string)($_GET['nameQid'] ?? '');
|
||||||
|
$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 = [];
|
||||||
|
$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' => $rows,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出形态:
|
||||||
|
// 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) ?? '';
|
||||||
|
$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');
|
||||||
|
header('Content-Disposition: attachment; filename="export.xlsx"; filename*=UTF-8\'\'' . rawurlencode($filename));
|
||||||
|
header('Content-Length: ' . strlen($bin));
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
echo $bin;
|
||||||
|
exit;
|
||||||
|
|
||||||
// ---------------- 随机抽取:导出白名单(xlsx,列:姓名 / 登陆账号) ----------------
|
// ---------------- 随机抽取:导出白名单(xlsx,列:姓名 / 登陆账号) ----------------
|
||||||
case 'draw_export':
|
case 'draw_export':
|
||||||
$raw = ny_request_body();
|
$raw = ny_request_body();
|
||||||
@@ -954,8 +1273,11 @@ try {
|
|||||||
$isAuth = str_contains($msg, '未配置卷王登录账号')
|
$isAuth = str_contains($msg, '未配置卷王登录账号')
|
||||||
|| str_contains($msg, '登录卷王失败')
|
|| str_contains($msg, '登录卷王失败')
|
||||||
|| str_contains($msg, '登录失败');
|
|| str_contains($msg, '登录失败');
|
||||||
|
// 鉴权失败时回传落盘诊断(账号到底有没有存住、data 目录可不可写),
|
||||||
|
// 否则前端只能给出"账号未配置"这一句空话,用户无法自查,也容易陷入反复跳登录页。
|
||||||
ny_fail($msg, [
|
ny_fail($msg, [
|
||||||
'auth' => $isAuth,
|
'auth' => $isAuth,
|
||||||
|
'diag' => $isAuth ? ny_config_diag() : null,
|
||||||
'trace' => defined('NY_DEBUG') && NY_DEBUG ? $e->getTraceAsString() : null,
|
'trace' => defined('NY_DEBUG') && NY_DEBUG ? $e->getTraceAsString() : null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
+635
-62
@@ -31,6 +31,8 @@
|
|||||||
_sorted: null, // 虚拟滚动:最近一次排序后的完整行数组(缓存,避免重复排序)
|
_sorted: null, // 虚拟滚动:最近一次排序后的完整行数组(缓存,避免重复排序)
|
||||||
_kwDebounce: null, // 搜索框防抖定时器
|
_kwDebounce: null, // 搜索框防抖定时器
|
||||||
csrf: '', // CSRF token(来自 config_get),写请求自动附带
|
csrf: '', // CSRF token(来自 config_get),写请求自动附带
|
||||||
|
_authRedirecting: false, // 鉴权失败已接管(防止并发请求重复跳登录页)
|
||||||
|
_authStopped: false, // 鉴权失败已停自动刷新(等用户处理,不再定时重试)
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------------- 本地存储 ---------------- */
|
/* ---------------- 本地存储 ---------------- */
|
||||||
@@ -89,7 +91,9 @@
|
|||||||
}
|
}
|
||||||
if (!json.ok) {
|
if (!json.ok) {
|
||||||
const err = new Error(json.msg || '请求失败');
|
const err = new Error(json.msg || '请求失败');
|
||||||
err.auth = !!(json.extra && json.extra.auth);
|
// 401 = 本系统会话已失效,与"卷王鉴权失败"同样需要重新登录;
|
||||||
|
// handleAuthError 里的防死循环闸门会保证"刚登录完又失败"时不再反复跳转。
|
||||||
|
err.auth = !!(json.extra && json.extra.auth) || res.status === 401;
|
||||||
err.extra = json.extra || null;
|
err.extra = json.extra || null;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -154,6 +158,106 @@
|
|||||||
return '已导出' + what + (parts.length ? '(' + parts.join(' / ') + ')' : '(全部班级 / 组别)');
|
return '已导出' + what + (parts.length ? '(' + parts.join(' / ') + ')' : '(全部班级 / 组别)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- 鉴权失败处理(含防死循环) ----------------
|
||||||
|
* 死循环场景:登录页登录成功 → 进主页 → 拉数据仍报"未配置账号/登录失败" → 自动跳登录页 → 再登录……
|
||||||
|
* 用户看到的就是"一直让重新登录、根本用不了"。这里加两道闸门:
|
||||||
|
* 1) 刚登录完(120 秒内)又鉴权失败 → 不再自动跳转,直接把服务端真实原因 + 落盘诊断摊开给用户
|
||||||
|
* 2) 同一会话已自动跳转过 2 次 → 同样停在页面报错
|
||||||
|
*/
|
||||||
|
const AUTH_FRESH_MS = 120000;
|
||||||
|
const AUTH_MAX_JUMPS = 2;
|
||||||
|
|
||||||
|
function authJustLoggedIn() {
|
||||||
|
try {
|
||||||
|
const t = parseInt(sessionStorage.getItem('ny_login_at') || '0', 10);
|
||||||
|
return t > 0 && (Date.now() - t) < AUTH_FRESH_MS;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
}
|
||||||
|
function authJumpCount() {
|
||||||
|
try { return parseInt(sessionStorage.getItem('ny_auth_jumps') || '0', 10) || 0; } catch (e) { return 0; }
|
||||||
|
}
|
||||||
|
function stopAutoRefresh() {
|
||||||
|
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
||||||
|
if (state.hlTimer) { clearInterval(state.hlTimer); state.hlTimer = null; }
|
||||||
|
state._authStopped = true;
|
||||||
|
}
|
||||||
|
function forceRelogin() {
|
||||||
|
stopAutoRefresh();
|
||||||
|
// 必须先清服务端会话,否则 login.php 发现会话还在会把人踢回 index.php,跳不过去
|
||||||
|
fetch('logout.php', { method: 'POST', credentials: 'same-origin' })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => { window.location.href = 'login.php'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagLines(diag) {
|
||||||
|
if (!diag) return [];
|
||||||
|
const yn = v => (v ? '是' : '否');
|
||||||
|
return [
|
||||||
|
'配置来源:' + (diag.storage || '-') + '(配置文件可写=' + yn(diag.cfg_writable) + ')',
|
||||||
|
'数据目录:' + (diag.data_dir || '-') + '(可写=' + yn(diag.dir_writable) + ')',
|
||||||
|
'数据库:' + (diag.db_connected ? ('已连接(可写=' + yn(diag.db_writable) + ')') : ('未连接 ' + (diag.db_error || ''))),
|
||||||
|
'已存账号:' + (diag.has_username ? (diag.username || '已设置') : '空 ← 这就是「未配置账号」的原因'),
|
||||||
|
'已存密码:' + (diag.has_password ? '已保存' : '空'),
|
||||||
|
diag.save_error ? ('保存异常:' + diag.save_error) : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAuthFailure(e) {
|
||||||
|
const lines = diagLines(e && e.extra ? e.extra.diag : null);
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.className = 'modal open';
|
||||||
|
box.innerHTML =
|
||||||
|
'<div class="modal-box" style="width:600px">' +
|
||||||
|
'<div class="modal-head"><h3>无法访问卷王</h3><button class="x" data-close>×</button></div>' +
|
||||||
|
'<div class="modal-body">' +
|
||||||
|
'<p style="margin:0 0 10px;line-height:1.7">' + esc((e && e.message) || '鉴权失败') + '</p>' +
|
||||||
|
'<p style="margin:0 0 10px;line-height:1.7;color:var(--text-2)">' +
|
||||||
|
'为避免一直把您踢回登录页,已停止自动跳转。请按下面提示处理后点【重试】。</p>' +
|
||||||
|
(lines.length
|
||||||
|
? '<pre style="white-space:pre-wrap;margin:0 0 10px;padding:10px 12px;border-radius:8px;background:var(--border-2);font-size:12px;line-height:1.9">' + esc(lines.join('\n')) + '</pre>'
|
||||||
|
: '') +
|
||||||
|
'<p style="margin:0;line-height:1.7;color:var(--text-2)">常见原因:① data 目录不可写(账号密码存不住);' +
|
||||||
|
'② 卷王账号/密码已变更;③ 卷王地址不可达。</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="modal-foot">' +
|
||||||
|
'<button class="btn" data-auth-retry>重试</button>' +
|
||||||
|
'<button class="btn" data-auth-settings>打开设置</button>' +
|
||||||
|
'<button class="btn btn-primary" data-auth-relogin>重新登录</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
document.body.appendChild(box);
|
||||||
|
box.querySelector('[data-auth-retry]').onclick = () => {
|
||||||
|
box.remove();
|
||||||
|
state._authRedirecting = false;
|
||||||
|
state._authStopped = false;
|
||||||
|
refresh(false, true); // 非静默 + 强制拉取
|
||||||
|
};
|
||||||
|
box.querySelector('[data-auth-settings]').onclick = () => {
|
||||||
|
box.remove();
|
||||||
|
const b = document.getElementById('btnSettings');
|
||||||
|
if (b) b.click();
|
||||||
|
};
|
||||||
|
box.querySelector('[data-auth-relogin]').onclick = () => { box.remove(); forceRelogin(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一处理鉴权类错误;返回 true 表示已接管(调用方直接 return)。
|
||||||
|
*/
|
||||||
|
function handleAuthError(e) {
|
||||||
|
if (state._authRedirecting) return true;
|
||||||
|
state._authRedirecting = true;
|
||||||
|
const jumps = authJumpCount();
|
||||||
|
if (authJustLoggedIn() || jumps >= AUTH_MAX_JUMPS) {
|
||||||
|
stopAutoRefresh();
|
||||||
|
showAuthFailure(e);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try { sessionStorage.setItem('ny_auth_jumps', String(jumps + 1)); } catch (err) {}
|
||||||
|
showAlert('卷王账号未配置或登录已失效,正在跳转登录页…', 'error');
|
||||||
|
forceRelogin();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- 问卷列表 ---------------- */
|
/* ---------------- 问卷列表 ---------------- */
|
||||||
// 把数组渲染到 #projectSel(抽出便于缓存复用)
|
// 把数组渲染到 #projectSel(抽出便于缓存复用)
|
||||||
function renderProjects() {
|
function renderProjects() {
|
||||||
@@ -234,6 +338,7 @@
|
|||||||
LS.set(LS_PROJECTS, state.projects);
|
LS.set(LS_PROJECTS, state.projects);
|
||||||
renderProjects();
|
renderProjects();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e.auth) { handleAuthError(e); return; }
|
||||||
if (!cached || !cached.length) {
|
if (!cached || !cached.length) {
|
||||||
setConn('conn-err', '连接失败');
|
setConn('conn-err', '连接失败');
|
||||||
showAlert('无法连接卷王系统:' + e.message + '\n请点击右上角【设置】检查系统地址、接口前缀与账号密码。');
|
showAlert('无法连接卷王系统:' + e.message + '\n请点击右上角【设置】检查系统地址、接口前缀与账号密码。');
|
||||||
@@ -374,21 +479,7 @@
|
|||||||
LS.set(LS_ROSTER, { ts: Date.now(), students: d.roster || [] });
|
LS.set(LS_ROSTER, { ts: Date.now(), students: d.roster || [] });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.auth) {
|
if (e.auth) {
|
||||||
// 卷王账号未配置或登录已失效:引导重新登录(强制清掉本地会话 + 跳登录页)
|
handleAuthError(e);
|
||||||
// 关键:必须先调 logout.php 把服务端会话清空,否则 login.php 发现会话还在会把人踢回 index.php,形成"跳不过去"的死循环
|
|
||||||
if (!state._authRedirecting) {
|
|
||||||
state._authRedirecting = true;
|
|
||||||
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
|
||||||
if (state.hlTimer) clearInterval(state.hlTimer);
|
|
||||||
showAlert('卷王账号未配置或登录已失效,正在跳转登录页…', 'error');
|
|
||||||
// 异步清会话,忽略失败;登录页端也有探测兜底(token 过期时主动销毁会话)
|
|
||||||
fetch('logout.php', { method: 'POST', credentials: 'same-origin' })
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => {
|
|
||||||
// 200ms 足够让 toast 入场,又不至于让用户干等
|
|
||||||
setTimeout(() => { window.location.href = 'login.php'; }, 200);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
@@ -926,6 +1017,7 @@
|
|||||||
idx: 1, is_best: true, score: r.score, submit_at: r.submit_at,
|
idx: 1, is_best: true, score: r.score, submit_at: r.submit_at,
|
||||||
submit_ts: r.submit_ts || r.last_submit_ts || 0,
|
submit_ts: r.submit_ts || r.last_submit_ts || 0,
|
||||||
duration_txt: r.duration_txt, temp_save: !!r.temp_save,
|
duration_txt: r.duration_txt, temp_save: !!r.temp_save,
|
||||||
|
day: '', day_best: false, day_count: 1,
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
// 新 -> 旧 展示(缺 submit_ts 时按 idx 倒序兜底,保证"次序"看起来合理)
|
// 新 -> 旧 展示(缺 submit_ts 时按 idx 倒序兜底,保证"次序"看起来合理)
|
||||||
@@ -934,18 +1026,42 @@
|
|||||||
if (ta !== tb) return tb - ta;
|
if (ta !== tb) return tb - ta;
|
||||||
return (b.idx || 0) - (a.idx || 0);
|
return (b.idx || 0) - (a.idx || 0);
|
||||||
});
|
});
|
||||||
const rows = desc.map((a, i) => {
|
// 按「提交时间年月日」分组:新的一天在前。
|
||||||
|
// 日期键由后端按服务端本地时区算好(a.day),这里仅兜底;时间未知的(ts=0)key 为 '',
|
||||||
|
// 且排序里排最后,天然连续落在末尾 → 归入「未知时间」组。
|
||||||
|
const groups = [];
|
||||||
|
desc.forEach(a => {
|
||||||
|
const key = a.day || (a.submit_at && a.submit_at !== '-' ? String(a.submit_at).slice(0, 10) : '') || '';
|
||||||
|
let g = groups.length ? groups[groups.length - 1] : null;
|
||||||
|
if (!g || g.key !== key) { g = { key: key, items: [] }; groups.push(g); }
|
||||||
|
g.items.push(a);
|
||||||
|
});
|
||||||
|
// 每组的最高分(未判分不参与),用于分组行显示「最高 X 分」
|
||||||
|
groups.forEach(g => {
|
||||||
|
g.max = g.items.reduce(
|
||||||
|
(m, a) => (a.score == null ? m : (m == null || Number(a.score) > m ? Number(a.score) : m)), null);
|
||||||
|
});
|
||||||
|
const rows = groups.map(g => {
|
||||||
|
const maxTxt = (g.max == null) ? '' : ' · 最高 ' + g.max + ' 分';
|
||||||
|
let html = '<tr class="att-day"><td colspan="4">' + esc(g.key || '未知时间') +
|
||||||
|
'<span class="att-day-sub">共 ' + g.items.length + ' 次' + maxTxt + '</span></td></tr>';
|
||||||
|
html += g.items.map(a => {
|
||||||
const sc = (a.score == null) ? '<span class="muted">未判分</span>' : esc(a.score);
|
const sc = (a.score == null) ? '<span class="muted">未判分</span>' : esc(a.score);
|
||||||
const best = (a.is_best && attempts > 1) ? ' <span class="tag tag-best">最佳</span>' : '';
|
const best = (a.is_best && attempts > 1) ? ' <span class="tag tag-best">最佳</span>' : '';
|
||||||
|
// 当日最高:仅当天有多次提交时才标,避免"一天只考一次"时每行都挂标签、反而看不出重点
|
||||||
|
const dayBest = (a.day_best && (a.day_count || 0) >= 2) ? ' <span class="tag tag-day">当日最高</span>' : '';
|
||||||
const tmp = a.temp_save ? ' <span class="tag tag-gray">暂存</span>' : '';
|
const tmp = a.temp_save ? ' <span class="tag tag-gray">暂存</span>' : '';
|
||||||
const no = attempts > 1 ? (a.idx || (desc.length - i)) : 1;
|
// 时间未知(submit_ts=0)时无从判断先后,次序显示 '-',而不是误报"第 1 次"
|
||||||
|
const noTxt = (attempts > 1 && a.submit_ts) ? ('第 ' + (a.idx || 1) + ' 次') : '-';
|
||||||
return '<tr>' +
|
return '<tr>' +
|
||||||
'<td class="num">' + (attempts > 1 ? '第 ' + no + ' 次' : '-') + '</td>' +
|
'<td class="num">' + noTxt + '</td>' +
|
||||||
'<td class="num">' + esc(a.submit_at || '-') + '</td>' +
|
'<td class="num">' + esc(a.submit_at || '-') + '</td>' +
|
||||||
'<td class="num">' + sc + best + tmp + '</td>' +
|
'<td class="num">' + sc + best + dayBest + tmp + '</td>' +
|
||||||
'<td class="num">' + esc(a.duration_txt || '-') + '</td>' +
|
'<td class="num">' + esc(a.duration_txt || '-') + '</td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
return html;
|
||||||
|
}).join('');
|
||||||
const bestScore = (r.best_score == null) ? r.score : r.best_score;
|
const bestScore = (r.best_score == null) ? r.score : r.best_score;
|
||||||
$('#attTitle').textContent = (r.name || '') + '(' + r.sid + ') · 共 ' + attempts + ' 次考试' +
|
$('#attTitle').textContent = (r.name || '') + '(' + r.sid + ') · 共 ' + attempts + ' 次考试' +
|
||||||
(bestScore == null ? '' : ' · 最高 ' + bestScore + ' 分');
|
(bestScore == null ? '' : ' · 最高 ' + bestScore + ' 分');
|
||||||
@@ -964,6 +1080,8 @@
|
|||||||
// 用 setTimeout 链而非 setInterval,便于动态调整间隔;后台标签页暂停刷新,回到前台立即补一次。
|
// 用 setTimeout 链而非 setInterval,便于动态调整间隔;后台标签页暂停刷新,回到前台立即补一次。
|
||||||
function schedule() {
|
function schedule() {
|
||||||
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
||||||
|
// 鉴权失败已停止自动刷新(handleAuthError),别再排下一轮把用户反复踢走
|
||||||
|
if (state._authStopped) return;
|
||||||
if (!$('#autoRefresh').checked) return;
|
if (!$('#autoRefresh').checked) return;
|
||||||
const base = (parseInt($('#intervalSel').value, 10) || 15) * 1000;
|
const base = (parseInt($('#intervalSel').value, 10) || 15) * 1000;
|
||||||
const backoff = Math.min(8, Math.pow(2, state.connFail)) * base;
|
const backoff = Math.min(8, Math.pow(2, state.connFail)) * base;
|
||||||
@@ -1214,47 +1332,487 @@
|
|||||||
showAlert('已清除新数据高亮', 'info');
|
showAlert('已清除新数据高亮', 'info');
|
||||||
});
|
});
|
||||||
|
|
||||||
/* 导出排序切换(即时保存到服务端配置) */
|
/* 导出排序文案(统一导出弹窗用) */
|
||||||
$('#exportSortSel').addEventListener('change', async () => {
|
function exportSortLabel(sort) {
|
||||||
state.exportSort = $('#exportSortSel').value;
|
return (sort === 'sid') ? '按学号'
|
||||||
if ($('#cfgExportSort')) $('#cfgExportSort').value = state.exportSort;
|
|
||||||
try {
|
|
||||||
await api('config_save', { method: 'POST', body: { export_sort: state.exportSort } });
|
|
||||||
} catch (e) { /* 静默:本地已生效 */ }
|
|
||||||
});
|
|
||||||
|
|
||||||
// 导出跟随当前班级 / 组别筛选(统计卡片与表格显示什么,就导出什么)
|
|
||||||
$('#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_desc') ? '按成绩降序'
|
||||||
: (sort === 'score_asc') ? '按成绩升序'
|
: (sort === 'score_asc') ? '按成绩升序'
|
||||||
: '按提交时间';
|
: '按提交时间';
|
||||||
const latest = ($('#exportLatest') && $('#exportLatest').checked) ? 'latest' : 'all';
|
}
|
||||||
const split = ($('#exportSplit') && $('#exportSplit').checked) ? 'cls' : '';
|
function currentExportSort() {
|
||||||
const qs = 'api.php?action=export&projectId=' + encodeURIComponent(pid) +
|
const el = $('#exSort') || $('#exportSortSel');
|
||||||
'&nameQid=' + encodeURIComponent($('#nameFieldSel').value) +
|
const sort = el ? el.value : 'time';
|
||||||
'&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp) +
|
return (sort === 'sid') ? 'sid' : ((sort === 'score_desc') ? 'score_desc' : ((sort === 'score_asc') ? 'score_asc' : 'time'));
|
||||||
'&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');
|
过去工具栏有一排导出按钮(导出成绩 / 每次考试 / 缺考 / 多考试导出 / 多考试打印),
|
||||||
|
按钮太多、口径分散。现在统一成一个「导出」按钮 + 一个弹窗:
|
||||||
|
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 {
|
||||||
|
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();
|
||||||
|
|
||||||
|
/** 把当前弹窗里的选择收集成对象(导出 / 打印都基于它拼查询串) */
|
||||||
|
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 || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 班级 / 组别多选(不选 = 全部) ---------------- */
|
||||||
|
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 => {
|
||||||
|
const pid = (p.parent_id || '0');
|
||||||
|
if (pid && pid !== '0' && pid !== '') parentIds.add(pid);
|
||||||
});
|
});
|
||||||
$('#btnExportAbsent').addEventListener('click', () => {
|
const folderSet = new Set();
|
||||||
|
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 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>';
|
||||||
|
} else {
|
||||||
|
const cur = $('#projectSel').value;
|
||||||
|
list.forEach((p, i) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const checked = (p.id === cur) ? ' checked' : '';
|
||||||
|
tr.innerHTML =
|
||||||
|
'<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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
exUpdPickCount();
|
||||||
|
}
|
||||||
|
function exPickedIds() {
|
||||||
|
return $$('#exExamBody .ex-chk').filter(c => c.checked).map(c => c.value);
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
$('#exClsSel').addEventListener('change', () => {
|
||||||
|
exFillGroups(exSelVals($('#exGrpSel')));
|
||||||
|
exOpts = exCollect();
|
||||||
|
exSaveOpts(exOpts);
|
||||||
|
exSyncSummary();
|
||||||
|
});
|
||||||
|
$('#exCheckAll').addEventListener('change', () => {
|
||||||
|
$$('#exExamBody .ex-chk').forEach(c => { c.checked = $('#exCheckAll').checked; });
|
||||||
|
exUpdPickCount();
|
||||||
|
});
|
||||||
|
$('#btnExPickAll').addEventListener('click', () => {
|
||||||
|
$$('#exExamBody .ex-chk').forEach(c => { c.checked = true; });
|
||||||
|
exUpdPickCount();
|
||||||
|
});
|
||||||
|
$('#btnExPickNone').addEventListener('click', () => {
|
||||||
|
$$('#exExamBody .ex-chk').forEach(c => { c.checked = false; });
|
||||||
|
exUpdPickCount();
|
||||||
|
});
|
||||||
|
$('#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;
|
const pid = $('#projectSel').value;
|
||||||
if (!pid) return showAlert('请先选择问卷 / 考试');
|
if (!pid) return null;
|
||||||
const cls = $('#clsSel').value, grp = $('#grpSel').value;
|
return base + 'action=export_absent&projectId=' + encodeURIComponent(pid) +
|
||||||
location.href = 'api.php?action=export_absent&projectId=' + encodeURIComponent(pid) +
|
'&nameQid=' + nameQid + '&cls=' + cls + '&grp=' + grp +
|
||||||
'&nameQid=' + encodeURIComponent($('#nameFieldSel').value) +
|
(isPrint ? '&format=json' : '');
|
||||||
'&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp);
|
}
|
||||||
showAlert(exportTip('缺考名单', cls, grp), 'info');
|
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('正在导出' + 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() {
|
function closeTopModal() {
|
||||||
@@ -1553,6 +2111,9 @@
|
|||||||
const css =
|
const css =
|
||||||
'@page{size:A4 ' + orient + ';margin:14mm 10mm}' +
|
'@page{size:A4 ' + orient + ';margin:14mm 10mm}' +
|
||||||
'html,body{margin:0;padding:0}' +
|
'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}' +
|
'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}' +
|
'h2{margin:0 0 6px;font-size:17px;text-align:center}' +
|
||||||
'.sub{text-align:center;font-size:12px;color:#444;margin-bottom:4px}' +
|
'.sub{text-align:center;font-size:12px;color:#444;margin-bottom:4px}' +
|
||||||
@@ -1565,10 +2126,20 @@
|
|||||||
'td.c,th.c{text-align:center}' +
|
'td.c,th.c{text-align:center}' +
|
||||||
'td.score{font-weight:700}' +
|
'td.score{font-weight:700}' +
|
||||||
'td.fail{color:#b00}' +
|
'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{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}' +
|
'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">' +
|
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>';
|
'<title>' + esc(title) + '</title><style>' + css + '</style></head><body>' + bodyHtml + '</body></html>';
|
||||||
@@ -1640,9 +2211,16 @@
|
|||||||
|
|
||||||
function printTable() {
|
function printTable() {
|
||||||
const scope = $('#ptScope').value;
|
const scope = $('#ptScope').value;
|
||||||
|
const rowScore = ($('#ptRowScore') && $('#ptRowScore').value === 'best') ? 'best' : 'latest';
|
||||||
let rows = (scope === 'all')
|
let rows = (scope === 'all')
|
||||||
? expandAttemptsForPrint(state._sorted || [], true)
|
? expandAttemptsForPrint(state._sorted || [], true)
|
||||||
: (state._sorted || []).slice();
|
: (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');
|
if (!rows.length) return showAlert('当前没有可打印的成绩', 'warn');
|
||||||
|
|
||||||
const sortMode = $('#ptSort').value;
|
const sortMode = $('#ptSort').value;
|
||||||
@@ -1753,8 +2331,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body += '<div class="sign">教师签字:____________ 教研组长:____________ 日期:____ 年 ____ 月 ____ 日</div>';
|
|
||||||
|
|
||||||
printDoc(title, body, { orient: orient });
|
printDoc(title, body, { orient: orient });
|
||||||
showAlert('已调起打印,如未弹出请在打印对话框中选择正确打印机', 'info');
|
showAlert('已调起打印,如未弹出请在打印对话框中选择正确打印机', 'info');
|
||||||
}
|
}
|
||||||
@@ -1886,10 +2462,7 @@
|
|||||||
const meta = '<h2>' + esc(title) + '</h2>' +
|
const meta = '<h2>' + esc(title) + '</h2>' +
|
||||||
(proj ? '<div class="sub">' + esc(proj) + '</div>' : '') +
|
(proj ? '<div class="sub">' + esc(proj) + '</div>' : '') +
|
||||||
'<div class="meta">' + esc(printMetaLine(['共 ' + list.length + ' 人'])) + '</div>';
|
'<div class="meta">' + esc(printMetaLine(['共 ' + list.length + ' 人'])) + '</div>';
|
||||||
const sign = '<div class="sign">教师签字:____________ 日期:____ 年 ____ 月 ____ 日</div>' +
|
printDoc(title, meta + body, { orient: 'portrait' });
|
||||||
'<div class="foot">' + esc(NY.appName || '') + ' · 缺考名单,请核对后签字确认</div>';
|
|
||||||
|
|
||||||
printDoc(title, meta + body + sign, { orient: 'portrait' });
|
|
||||||
showAlert('已调起打印,如未弹出请在打印对话框中选择正确打印机', 'info');
|
showAlert('已调起打印,如未弹出请在打印对话框中选择正确打印机', 'info');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -393,6 +393,11 @@ td.att { font-weight: 700; color: var(--text-2); }
|
|||||||
.tag-att { background: #e8eefc; color: #3457c9; cursor: pointer; }
|
.tag-att { background: #e8eefc; color: #3457c9; cursor: pointer; }
|
||||||
.tag-new { background: var(--ok-weak); color: var(--ok); }
|
.tag-new { background: var(--ok-weak); color: var(--ok); }
|
||||||
.tag-best { background: #fff1dc; color: #b26a06; }
|
.tag-best { background: #fff1dc; color: #b26a06; }
|
||||||
|
/* 当日最高(历史成绩按天分组后,组内最高分) */
|
||||||
|
.tag-day { background: var(--primary-weak); color: var(--primary-2); }
|
||||||
|
/* 历史成绩弹窗:按提交日期分组的分组行 */
|
||||||
|
tr.att-day > td { background: var(--border-2); font-weight: 600; color: var(--text-2); }
|
||||||
|
tr.att-day > td .att-day-sub { margin-left: 8px; font-weight: 400; font-size: 12px; color: var(--text-3); }
|
||||||
|
|
||||||
/* 多次刷题:成员榜可点击行 */
|
/* 多次刷题:成员榜可点击行 */
|
||||||
tr.clickable { cursor: pointer; }
|
tr.clickable { cursor: pointer; }
|
||||||
@@ -426,6 +431,78 @@ table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
|
|||||||
/* 弹窗内的表格不做吸顶:弹窗自身是滚动容器,吸顶表头会与内容错层(且贴边观感差) */
|
/* 弹窗内的表格不做吸顶:弹窗自身是滚动容器,吸顶表头会与内容错层(且贴边观感差) */
|
||||||
.modal-body table.grid th { position: static; }
|
.modal-body table.grid th { position: static; }
|
||||||
.modal-body table.grid { box-shadow: inset 0 0 0 1px var(--border-2); border-radius: 8px; }
|
.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 {
|
.modal-foot {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
padding: 12px 18px; border-top: 1px solid var(--border); background: #fbfcfe;
|
padding: 12px 18px; border-top: 1px solid var(--border); background: #fbfcfe;
|
||||||
@@ -618,6 +695,51 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
|||||||
.dash-controls .inline { color: #fff; }
|
.dash-controls .inline { color: #fff; }
|
||||||
.dash-controls .btn { background: rgba(255, 255, 255, .14); border-color: transparent; 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); }
|
.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 { 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; }
|
.sec-title .tag-gray { background: var(--border-2); color: var(--text-3); padding: 1px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
||||||
@@ -684,6 +806,9 @@ body.dash.fs-on .card b { font-size: 20px; }
|
|||||||
body.dash.fs-on .group-compare { gap: 12px; }
|
body.dash.fs-on .group-compare { gap: 12px; }
|
||||||
body.dash.fs-on .gpanel { padding: 12px 14px; }
|
body.dash.fs-on .gpanel { padding: 12px 14px; }
|
||||||
body.dash.fs-on .member-list { max-height: none; }
|
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; }
|
||||||
|
|
||||||
|
|
||||||
/* ================= 随机抽取页 ================= */
|
/* ================= 随机抽取页 ================= */
|
||||||
|
|||||||
+191
-3
@@ -282,23 +282,48 @@ function ny_load_config(): array
|
|||||||
}
|
}
|
||||||
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
|
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
|
||||||
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
|
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
|
||||||
|
// 账号/密码一律转成字符串:历史数据里若存成 null,不改这里就会表现为"未配置卷王登录账号"
|
||||||
|
$cfg['username'] = trim((string)($cfg['username'] ?? ''));
|
||||||
|
$cfg['password'] = (string)($cfg['password'] ?? '');
|
||||||
return $cfg;
|
return $cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ny_save_config(array $cfg): bool
|
function ny_save_config(array $cfg): bool
|
||||||
{
|
{
|
||||||
// 双写:SQLite 为主(成功即算成功),文件为备份(DB 挂掉时还能读回来)
|
// 双写:SQLite 为主(成功即算成功),文件为备份(DB 挂掉时还能读回来)
|
||||||
|
// 关键:两处都必须做"回读校验"。以前只管写不管是否真落盘,data/ 目录不可写时
|
||||||
|
// 登录页会照常提示"登录成功",但下一个请求读到的账号仍是空的 → 无限跳登录页。
|
||||||
|
$GLOBALS['__ny_cfg_save_error'] = '';
|
||||||
|
$notes = [];
|
||||||
|
$json = json_encode($cfg, JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
$ok = false;
|
$ok = false;
|
||||||
if (class_exists('Db') && Db::pdo() !== null) {
|
if (class_exists('Db') && Db::pdo() !== null) {
|
||||||
try {
|
try {
|
||||||
Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE));
|
if (Db::settingsSet('config', $json)) {
|
||||||
$GLOBALS['__ny_cfg_source'] = 'db';
|
$GLOBALS['__ny_cfg_source'] = 'db';
|
||||||
$ok = true;
|
$ok = true;
|
||||||
|
} else {
|
||||||
|
$notes[] = '数据库写入失败:' . (Db::lastError() ?: '未知原因');
|
||||||
|
}
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$ok = false;
|
$notes[] = '数据库写入异常:' . $e->getMessage();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
$notes[] = '数据库不可用(' . (class_exists('Db') ? Db::lastError() : 'Db 类未加载') . ')';
|
||||||
}
|
}
|
||||||
|
|
||||||
$fileOk = ny_write_json(ny_config_file(), $cfg);
|
$fileOk = ny_write_json(ny_config_file(), $cfg);
|
||||||
|
if ($fileOk) {
|
||||||
|
// 回读一致才算真成功
|
||||||
|
$back = ny_read_json(ny_config_file(), null);
|
||||||
|
if (!is_array($back) || json_encode($back, JSON_UNESCAPED_UNICODE) !== $json) {
|
||||||
|
$fileOk = false;
|
||||||
|
$notes[] = '配置文件回读不一致:' . ny_config_file();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$notes[] = '配置文件写入失败:' . ny_config_file();
|
||||||
|
}
|
||||||
// 尽量收紧配置文件权限(仅属主可读写),降低明文密码被其它本地用户读取的风险。
|
// 尽量收紧配置文件权限(仅属主可读写),降低明文密码被其它本地用户读取的风险。
|
||||||
// Windows 下 chmod 基本无效(真正的防护是 data/ 目录禁止 Web 访问,见 .htaccess / 部署文档);
|
// Windows 下 chmod 基本无效(真正的防护是 data/ 目录禁止 Web 访问,见 .htaccess / 部署文档);
|
||||||
// 明文密码仍需保留以便卷王 token 失效时平台自动重新登录,无法改为只存哈希。
|
// 明文密码仍需保留以便卷王 token 失效时平台自动重新登录,无法改为只存哈希。
|
||||||
@@ -306,11 +331,149 @@ function ny_save_config(array $cfg): bool
|
|||||||
@chmod(ny_config_file(), 0600);
|
@chmod(ny_config_file(), 0600);
|
||||||
}
|
}
|
||||||
if (!$ok) {
|
if (!$ok) {
|
||||||
$GLOBALS['__ny_cfg_source'] = 'file';
|
$GLOBALS['__ny_cfg_source'] = $fileOk ? 'file' : 'default';
|
||||||
|
}
|
||||||
|
if (!$ok && !$fileOk) {
|
||||||
|
$GLOBALS['__ny_cfg_save_error'] = '配置未落盘:' . implode(';', $notes);
|
||||||
|
} elseif ($notes !== []) {
|
||||||
|
// 有一处成功即不影响使用,仅记录降级信息
|
||||||
|
$GLOBALS['__ny_cfg_save_error'] = implode(';', $notes);
|
||||||
}
|
}
|
||||||
return $ok || $fileOk;
|
return $ok || $fileOk;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 上一次 ny_save_config 的问题描述(空 = 完全正常) */
|
||||||
|
function ny_config_save_error(): string
|
||||||
|
{
|
||||||
|
return (string)($GLOBALS['__ny_cfg_save_error'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置落盘诊断:登录/鉴权失败时优先看这里,用来区分
|
||||||
|
* 「账号没配」与「账号配了但写不进去 / 读不回来」这两类完全不同的故障。
|
||||||
|
*/
|
||||||
|
function ny_config_diag(): array
|
||||||
|
{
|
||||||
|
$dir = rtrim((string)NY_DATA, '/\\');
|
||||||
|
$file = ny_config_file();
|
||||||
|
$dbOk = class_exists('Db') && Db::pdo() !== null;
|
||||||
|
|
||||||
|
$dbWritable = false;
|
||||||
|
if ($dbOk) {
|
||||||
|
$probe = '__ny_probe_' . bin2hex(random_bytes(4));
|
||||||
|
$dbWritable = Db::settingsSet($probe, '1');
|
||||||
|
Db::settingsDelete($probe);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cfgWritable = false;
|
||||||
|
$probeFile = $dir . '/.__ny_write_probe';
|
||||||
|
$fp = @fopen($probeFile, 'wb');
|
||||||
|
if ($fp) {
|
||||||
|
$cfgWritable = (fwrite($fp, '1') !== false);
|
||||||
|
fclose($fp);
|
||||||
|
@unlink($probeFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$c = ny_load_config();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'storage' => ny_config_source(),
|
||||||
|
'data_dir' => $dir,
|
||||||
|
'dir_writable' => is_dir($dir) && is_writable($dir),
|
||||||
|
'cfg_file' => $file,
|
||||||
|
'cfg_writable' => $cfgWritable,
|
||||||
|
'db_connected' => $dbOk,
|
||||||
|
'db_writable' => $dbWritable,
|
||||||
|
'db_error' => (class_exists('Db') && !$dbOk) ? Db::lastError() : '',
|
||||||
|
'has_username' => trim((string)($c['username'] ?? '')) !== '',
|
||||||
|
'has_password' => ((string)($c['password'] ?? '')) !== '',
|
||||||
|
'username' => (string)($c['username'] ?? ''),
|
||||||
|
'save_error' => ny_config_save_error(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把接口/表单传入的配置片段合并进已有配置(api.php config_save 用)。
|
||||||
|
* 抽出来是为了可单测,同时在这里统一守住"账号/密码不能被误清空"。
|
||||||
|
*
|
||||||
|
* @return array{config:array,conn_changed:bool}
|
||||||
|
*/
|
||||||
|
function ny_apply_config_input(array $old, array $in): array
|
||||||
|
{
|
||||||
|
$allowed = ['base_url', 'api_prefix', 'username', 'timeout', 'refresh_interval', 'page_size', 'name_keywords', 'history_display', 'export_sort'];
|
||||||
|
$new = $old;
|
||||||
|
foreach ($allowed as $k) {
|
||||||
|
if (array_key_exists($k, $in)) {
|
||||||
|
$new[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 账号不允许被改空:设置页留空视为"不改",否则一旦保存就会陷入"未配置账号"死循环
|
||||||
|
if (array_key_exists('username', $in)) {
|
||||||
|
$u = trim((string)($in['username'] ?? ''));
|
||||||
|
if ($u === '') {
|
||||||
|
$new['username'] = (string)($old['username'] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 密码:保留首尾空格的原貌,空 / 占位符 = 不修改
|
||||||
|
if (array_key_exists('password', $in)) {
|
||||||
|
$pwdIn = (string)$in['password'];
|
||||||
|
if ($pwdIn !== '' && $pwdIn !== '******') {
|
||||||
|
$new['password'] = $pwdIn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (['verify_ssl', 'include_temp_save', 'dedupe'] as $k) {
|
||||||
|
if (array_key_exists($k, $in)) {
|
||||||
|
$new[$k] = filter_var($in[$k], FILTER_VALIDATE_BOOL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 布局配置
|
||||||
|
if (array_key_exists('layout', $in)) {
|
||||||
|
$new['layout'] = ($in['layout'] === 'side') ? 'side' : 'default';
|
||||||
|
}
|
||||||
|
// 历史成绩展示方式:click(点击成员弹窗,默认) / inline(明细内全部展开)
|
||||||
|
if (array_key_exists('history_display', $in)) {
|
||||||
|
$new['history_display'] = ($in['history_display'] === 'inline') ? 'inline' : 'click';
|
||||||
|
}
|
||||||
|
// 成绩导出排序
|
||||||
|
if (array_key_exists('export_sort', $in)) {
|
||||||
|
$v = (string)$in['export_sort'];
|
||||||
|
$new['export_sort'] = in_array($v, ['time','sid','score_desc','score_asc'], true) ? $v : 'time';
|
||||||
|
}
|
||||||
|
if (array_key_exists('pass_rate', $in)) {
|
||||||
|
$pr = (float)$in['pass_rate'];
|
||||||
|
$new['pass_rate'] = ($pr >= 1 && $pr <= 100) ? $pr : 60;
|
||||||
|
}
|
||||||
|
$new['base_url'] = rtrim((string)$new['base_url'], '/');
|
||||||
|
$new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/');
|
||||||
|
$new['timeout'] = (int)$new['timeout'];
|
||||||
|
$new['refresh_interval'] = (int)$new['refresh_interval'];
|
||||||
|
if ($new['refresh_interval'] < 3) {
|
||||||
|
$new['refresh_interval'] = 3;
|
||||||
|
}
|
||||||
|
// 默认满分
|
||||||
|
$fs = (int)($in['full_score'] ?? $new['full_score'] ?? 100);
|
||||||
|
if ($fs < 1) {
|
||||||
|
$fs = 100;
|
||||||
|
}
|
||||||
|
$new['full_score'] = $fs;
|
||||||
|
|
||||||
|
// 账号/密码强制字符串(历史数据里可能存成 null,导致 (string) 后变成空 → 报"未配置账号")
|
||||||
|
$new['username'] = (string)($new['username'] ?? '');
|
||||||
|
$new['password'] = (string)($new['password'] ?? '');
|
||||||
|
|
||||||
|
// 仅当连接信息真的变了才清 token + 重连;保存满分/布局等不应触发登录卷王
|
||||||
|
$connKeys = ['base_url', 'api_prefix', 'username', 'password', 'verify_ssl'];
|
||||||
|
$connChanged = false;
|
||||||
|
foreach ($connKeys as $k) {
|
||||||
|
if ((string)($old[$k] ?? '') !== (string)($new[$k] ?? '')) {
|
||||||
|
$connChanged = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['config' => $new, 'conn_changed' => $connChanged];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析「本卷满分 + 合格率」:
|
* 解析「本卷满分 + 合格率」:
|
||||||
* 优先取该试卷的独立配置(试卷之间互相隔离),没有则用全局配置。
|
* 优先取该试卷的独立配置(试卷之间互相隔离),没有则用全局配置。
|
||||||
@@ -341,6 +504,31 @@ function ny_resolve_score_cfg(string $projectId, array $c): array
|
|||||||
return [$fs, $pr];
|
return [$fs, $pr];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHP 会话目录是否可用(登录状态能不能保持住)。
|
||||||
|
* 目录不可写时 session_start() 只会告警,登录态存不下来 → 表现为"登录完又被踢回登录页"。
|
||||||
|
*/
|
||||||
|
function ny_session_usable(?string &$why = null): bool
|
||||||
|
{
|
||||||
|
$dir = (string)@session_save_path();
|
||||||
|
// save_path 可能是 "N;/path"(多级目录散列),取分号后的真实路径
|
||||||
|
if (preg_match('/^\d+;(.*)$/', $dir, $m) === 1) {
|
||||||
|
$dir = $m[1];
|
||||||
|
}
|
||||||
|
if ($dir === '') {
|
||||||
|
$dir = sys_get_temp_dir();
|
||||||
|
}
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
$why = '会话目录不存在:' . $dir;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!is_writable($dir)) {
|
||||||
|
$why = '会话目录不可写:' . $dir;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** 安全的字符串化(避免数组/对象转字符串报错) */
|
/** 安全的字符串化(避免数组/对象转字符串报错) */
|
||||||
function ny_str(mixed $v): string
|
function ny_str(mixed $v): string
|
||||||
{
|
{
|
||||||
|
|||||||
+241
-35
@@ -1,35 +1,51 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* 班级数据看板(独立页面):每个班级一张专属看板,无概览、无返回按钮。
|
* 对外数据大屏(两种形态,由 URL 决定):
|
||||||
* URL 支持 ?pid=<projectId>&class=<班级名> 分享与刷新恢复;缺省自动选第一个班级。
|
* 1) 无 ?class= → 大屏首页:先选试卷/考试,再以卡片网格列出该卷的班级,点击进入该班大屏;
|
||||||
|
* 2) 有 ?class=<班级名> → 该班独立大屏:只有试卷选择 / 自动刷新开关 / 全屏,
|
||||||
|
* 不出现班级选择、「← 主系统」「退出」,并且屏蔽浏览器后退键(退不回首页)。
|
||||||
|
* URL 仍支持 ?pid= 与 &auto= 便于分享与刷新恢复。
|
||||||
*/
|
*/
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
require __DIR__ . '/bootstrap.php';
|
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');
|
header('Location: login.php');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
$pageTitle = $isClass ? ($clsParam . ' · 班级考试大屏') : '数据大屏 · 选择班级';
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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">
|
<link rel="stylesheet" href="assets/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="dash">
|
<!-- dash-class = 单班大屏(隐藏班级选择/返回/退出 + 锁后退);dash-home = 首页班级入口 -->
|
||||||
|
<body class="dash <?= $isClass ? 'dash-class' : 'dash-home' ?>">
|
||||||
|
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
|
|
||||||
<div class="dash-hero">
|
<div class="dash-hero">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<h1 class="brand-title">NY问卷考试系统数据平台</h1>
|
<h1 class="brand-title">NY问卷考试系统数据平台</h1>
|
||||||
|
<?php if ($isClass): ?>
|
||||||
|
<span class="brand-cls" id="heroClass"><?= htmlspecialchars($clsParam) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="dash-controls">
|
<div class="dash-controls">
|
||||||
<select id="projectSel" style="min-width:160px"><option value="">请选择问卷 / 考试</option></select>
|
<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>
|
<label class="switch"><input type="checkbox" id="autoRefresh" checked><span></span></label>
|
||||||
<select id="intervalSel" style="width:auto">
|
<select id="intervalSel" style="width:auto">
|
||||||
<option value="5">5 秒</option>
|
<option value="5">5 秒</option>
|
||||||
@@ -40,20 +56,23 @@ if (!ny_is_auth()) {
|
|||||||
</select>
|
</select>
|
||||||
<button class="btn" id="btnRefresh">刷新</button>
|
<button class="btn" id="btnRefresh">刷新</button>
|
||||||
<button class="btn" id="btnFullscreen" title="切换全屏">⛶ 全屏</button>
|
<button class="btn" id="btnFullscreen" title="切换全屏">⛶ 全屏</button>
|
||||||
<a class="btn back" href="index.php" title="返回主系统(成绩明细 / 名单 / 抽取)">← 主系统</a>
|
<a class="btn back" id="linkIndex" href="index.php" title="返回主系统(成绩明细 / 名单 / 抽取)">← 主系统</a>
|
||||||
<a class="btn back" href="logout.php" title="退出登录">退出</a>
|
<?php if (!$publicView): ?>
|
||||||
|
<a class="btn back" id="linkLogout" href="logout.php" title="退出登录">退出</a>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
|
<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>
|
<span id="heroSub">选择一个问卷与班级查看考试整体情况</span>
|
||||||
|
<?php if ($publicView): ?><span class="pub-tag" title="本页面为公开页面,无需登录即可查看">公开 · 无需登录</span><?php endif; ?>
|
||||||
<span class="spacer" style="flex:1"></span>
|
<span class="spacer" style="flex:1"></span>
|
||||||
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
|
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ================= 班级看板(单班,无概览) ================= -->
|
<!-- ================= 看板区:首页=班级入口卡片,单班页=该班看板 ================= -->
|
||||||
<div id="board">
|
<div id="board">
|
||||||
<!-- 6 个汇总卡片 -->
|
<!-- 5 个汇总卡片(仅单班大屏使用) -->
|
||||||
<section class="cards" id="boardSummary">
|
<section class="cards" id="boardSummary">
|
||||||
<div class="card accent-blue"><span>总人数</span><b id="bTotal">-</b></div>
|
<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>
|
<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>
|
<div class="card accent-indigo"><span>合格率<span id="bPassLabel">(≥60%满分)</span></span><b id="bPass">-</b></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="sec-title">分组对比 / 考试成员</div>
|
<div class="sec-title" id="boardTitle">分组对比 / 考试成员</div>
|
||||||
<div id="boardBody">
|
<div id="boardBody">
|
||||||
<div class="empty">请选择问卷与班级查看考试整体情况</div>
|
<div class="empty">请选择问卷与班级查看考试整体情况</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,6 +112,26 @@ function rateColor(r){
|
|||||||
return 'var(--danger)';
|
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) */
|
/* FLIP 行移动画:新数据行滑到新排名、其它行顺势下移(尊重 prefers-reduced-motion) */
|
||||||
const REDUCED_MOTION = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
const REDUCED_MOTION = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||||
function captureFlip(container){
|
function captureFlip(container){
|
||||||
@@ -137,7 +176,10 @@ function writeUrl({pid, cls, auto}){
|
|||||||
if (pid !== undefined) u.searchParams.set('pid', pid);
|
if (pid !== undefined) u.searchParams.set('pid', pid);
|
||||||
if (cls !== undefined) u.searchParams.set('class', cls);
|
if (cls !== undefined) u.searchParams.set('class', cls);
|
||||||
if (auto !== undefined) u.searchParams.set('auto', auto ? '1' : '0');
|
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 DASH = null;
|
||||||
let CUR = readUrl();
|
let CUR = readUrl();
|
||||||
|
|
||||||
|
/* 形态:URL 带 class = 单班大屏(不显示班级选择、锁后退);否则是「首页 → 选班级进入」 */
|
||||||
|
const IS_CLASS = !!CUR.cls;
|
||||||
|
/* 当前班级:单班页取自 URL,首页取自下拉 */
|
||||||
|
function currentClass(){ return IS_CLASS ? (CUR.cls || '') : ($('#classSel') ? $('#classSel').value : ''); }
|
||||||
|
|
||||||
/* ---------- API ---------- */
|
/* ---------- API ---------- */
|
||||||
async function api(action, params){
|
async function api(action, params){
|
||||||
let url = 'api.php?action=' + encodeURIComponent(action);
|
let url = 'api.php?action=' + encodeURIComponent(action);
|
||||||
@@ -161,10 +208,43 @@ async function api(action, params){
|
|||||||
const j = await res.json();
|
const j = await res.json();
|
||||||
if (j && j.ok) return j.data;
|
if (j && j.ok) return j.data;
|
||||||
const err = new Error((j && (j.msg || j.error)) || '请求失败');
|
const err = new Error((j && (j.msg || j.error)) || '请求失败');
|
||||||
err.auth = !!(j && j.extra && j.extra.auth);
|
// 401 = 本系统会话已失效,同样需要重新登录(防死循环闸门见 handleAuthFail)
|
||||||
|
err.auth = !!(j && j.extra && j.extra.auth) || res.status === 401;
|
||||||
|
err.extra = (j && j.extra) || null;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 鉴权失败:防反复跳登录页的死循环 ----------
|
||||||
|
* 若刚登录完(120 秒内)又鉴权失败,说明"重新登录"解决不了问题(多半是凭据没存住),
|
||||||
|
* 此时不再跳转,直接把服务端原因 + 落盘诊断显示出来,让人能自查。 */
|
||||||
|
function authJustLoggedIn(){
|
||||||
|
try {
|
||||||
|
const t = parseInt(sessionStorage.getItem('ny_login_at') || '0', 10);
|
||||||
|
return t > 0 && (Date.now() - t) < 120000;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
}
|
||||||
|
function authJumpCount(){
|
||||||
|
try { return parseInt(sessionStorage.getItem('ny_auth_jumps') || '0', 10) || 0; } catch (e) { return 0; }
|
||||||
|
}
|
||||||
|
function showAuthFailureBox(e){
|
||||||
|
const d = (e.extra && e.extra.diag) || null;
|
||||||
|
const yn = v => (v ? '是' : '否');
|
||||||
|
const lines = d ? [
|
||||||
|
'配置来源:' + (d.storage || '-') + '(配置文件可写=' + yn(d.cfg_writable) + ')',
|
||||||
|
'数据目录:' + (d.data_dir || '-') + '(可写=' + yn(d.dir_writable) + ')',
|
||||||
|
'数据库:' + (d.db_connected ? ('已连接(可写=' + yn(d.db_writable) + ')') : ('未连接 ' + (d.db_error || ''))),
|
||||||
|
'已存账号:' + (d.has_username ? (d.username || '已设置') : '空 ← 这就是「未配置账号」的原因'),
|
||||||
|
'已存密码:' + (d.has_password ? '已保存' : '空'),
|
||||||
|
d.save_error ? ('保存异常:' + d.save_error) : '',
|
||||||
|
].filter(Boolean) : [];
|
||||||
|
$('#boardBody').innerHTML =
|
||||||
|
'<div class="empty" style="text-align:left;line-height:1.9">' +
|
||||||
|
'<b>无法访问卷王</b><br>' + esc(e.message || '') +
|
||||||
|
(lines.length ? '<pre style="white-space:pre-wrap;margin:10px 0 0;text-align:left;font-size:12px">' + esc(lines.join('\n')) + '</pre>' : '') +
|
||||||
|
'<div style="margin-top:12px"><a class="btn btn-primary" href="login.php">重新登录</a></div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 问卷列表(双策略文件夹识别 + 缓存) ---------- */
|
/* ---------- 问卷列表(双策略文件夹识别 + 缓存) ---------- */
|
||||||
function renderProjects(list){
|
function renderProjects(list){
|
||||||
const sel = $('#projectSel');
|
const sel = $('#projectSel');
|
||||||
@@ -227,7 +307,7 @@ async function refreshClassSel(){
|
|||||||
}
|
}
|
||||||
// 项目数据来自 api (dashboard),先调用一次拿班级
|
// 项目数据来自 api (dashboard),先调用一次拿班级
|
||||||
try {
|
try {
|
||||||
const data = await api('dashboard', { projectId: pid });
|
const data = await api('dashboard', dashParams({ projectId: pid }));
|
||||||
DASH = data;
|
DASH = data;
|
||||||
// 合格率阈值可配(全局或本卷独立),卡片标题实时反映
|
// 合格率阈值可配(全局或本卷独立),卡片标题实时反映
|
||||||
const bl = document.getElementById('bPassLabel');
|
const bl = document.getElementById('bPassLabel');
|
||||||
@@ -237,10 +317,17 @@ async function refreshClassSel(){
|
|||||||
? classes.map(c => '<option value="' + esc(c) + '">' + esc(c) + '</option>').join('')
|
? classes.map(c => '<option value="' + esc(c) + '">' + esc(c) + '</option>').join('')
|
||||||
: '<option value="">暂无班级</option>';
|
: '<option value="">暂无班级</option>';
|
||||||
sel.disabled = !classes.length;
|
sel.disabled = !classes.length;
|
||||||
// 默认 class:URL 指定 > 上次选择 > 第一个
|
if (IS_CLASS) {
|
||||||
if (CUR.cls && classes.includes(CUR.cls)) {
|
/* 单班大屏:班级由 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;
|
sel.value = CUR.cls;
|
||||||
} else {
|
} else {
|
||||||
|
// 默认 class:上次选择 > 第一个
|
||||||
const last = LS.get('ny_board_last_cls_' + pid, '');
|
const last = LS.get('ny_board_last_cls_' + pid, '');
|
||||||
if (last && classes.includes(last)) sel.value = last;
|
if (last && classes.includes(last)) sel.value = last;
|
||||||
else if (classes.length) sel.value = classes[0];
|
else if (classes.length) sel.value = classes[0];
|
||||||
@@ -249,6 +336,9 @@ async function refreshClassSel(){
|
|||||||
renderBoard();
|
renderBoard();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.auth) {
|
if (e.auth) {
|
||||||
|
const jumps = authJumpCount();
|
||||||
|
if (authJustLoggedIn() || jumps >= 2) { showAuthFailureBox(e); return; }
|
||||||
|
try { sessionStorage.setItem('ny_auth_jumps', String(jumps + 1)); } catch (err) {}
|
||||||
// 卷王账号未配置或登录失效:跳转登录页自愈
|
// 卷王账号未配置或登录失效:跳转登录页自愈
|
||||||
$('#boardBody').innerHTML = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
|
$('#boardBody').innerHTML = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
|
||||||
setTimeout(() => { location.href = 'login.php'; }, 1200);
|
setTimeout(() => { location.href = 'login.php'; }, 1200);
|
||||||
@@ -271,15 +361,10 @@ function membersTable(members){
|
|||||||
const rk = (m.rank == null ? '' : m.rank);
|
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 badge = rk === 1 ? 'rk rk-1' : (rk === 2 ? 'rk rk-2' : (rk === 3 ? 'rk rk-3' : 'rk'));
|
||||||
const sc = m.score;
|
const sc = m.score;
|
||||||
// 同样按「占满分的百分比」着色,满分不是 100 时不会整列误标红
|
// 红绿由后台配置的满分成数 / 合格率决定(见 scoreGrade)
|
||||||
const cls = (sc == null) ? '' : (function () {
|
const g = scoreGrade(sc);
|
||||||
const full = (window.DASH && DASH.full_score) ? Number(DASH.full_score) : 100;
|
const cls = g.cls;
|
||||||
if (!(full > 0)) return '';
|
const tip = g.cls ? ' title="占满分 ' + g.pct.toFixed(1) + '% 合格线 ' + g.line + ' 分"' : '';
|
||||||
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');
|
|
||||||
})();
|
|
||||||
const att = (m.attempts || 1) > 1;
|
const att = (m.attempts || 1) > 1;
|
||||||
const tag = att ? ' <span class="tag tag-att" title="查看 ' + m.attempts + ' 次提交明细">刷' + m.attempts + '次</span>' : '';
|
const tag = att ? ' <span class="tag tag-att" title="查看 ' + m.attempts + ' 次提交明细">刷' + m.attempts + '次</span>' : '';
|
||||||
// 注意:不要把 sid 直接拼进 onclick="...",JSON.stringify 产生的双引号会提前闭合
|
// 注意:不要把 sid 直接拼进 onclick="...",JSON.stringify 产生的双引号会提前闭合
|
||||||
@@ -287,7 +372,7 @@ function membersTable(members){
|
|||||||
return '<tr' + (att ? ' class="clickable"' : '') + ' data-sid="' + esc(m.sid) + '"' + (att ? ' title="查看提交明细"' : '') + '>' +
|
return '<tr' + (att ? ' class="clickable"' : '') + ' data-sid="' + esc(m.sid) + '"' + (att ? ' title="查看提交明细"' : '') + '>' +
|
||||||
'<td class="num"><span class="' + badge + '">' + rk + '</span></td>' +
|
'<td class="num"><span class="' + badge + '">' + rk + '</span></td>' +
|
||||||
'<td class="name">' + esc(m.name) + tag + '</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>';
|
'</tr>';
|
||||||
}).join('') +
|
}).join('') +
|
||||||
'</tbody></table></div>';
|
'</tbody></table></div>';
|
||||||
@@ -299,11 +384,13 @@ function openAttempts(sid){
|
|||||||
if (!info) return;
|
if (!info) return;
|
||||||
const list = info.list || [];
|
const list = info.list || [];
|
||||||
const rows = list.map(a => {
|
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 tmp = a.temp_save ? ' <span class="tag tag-gray">暂存</span>' : '';
|
||||||
|
const sc = a.score == null ? '<span class="muted">未判分</span>' : esc(a.score);
|
||||||
return '<tr>' +
|
return '<tr>' +
|
||||||
'<td class="num">' + esc(a.submit_at) + '</td>' +
|
'<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>' +
|
'<td class="num">' + esc(a.duration_txt || '-') + '</td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -338,13 +425,85 @@ document.addEventListener('keydown', e => {
|
|||||||
if (open.length) open[open.length - 1].classList.remove('open');
|
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)一一对应,保证空态左侧色条一致 */
|
/* 占位卡片配色:与真实看板卡片(renderBoard)一一对应,保证空态左侧色条一致 */
|
||||||
const PLACEHOLDER_ACCENT = {
|
const PLACEHOLDER_ACCENT = {
|
||||||
'总人数': 'accent-blue', '已提交': 'accent-green', '缺考': 'accent-orange',
|
'总人数': 'accent-blue', '已提交': 'accent-green', '缺考': 'accent-orange',
|
||||||
'平均分': 'accent-teal', '合格率': 'accent-indigo'
|
'平均分': 'accent-teal', '合格率': 'accent-indigo'
|
||||||
};
|
};
|
||||||
function renderPlaceholderBoard(){
|
function renderPlaceholderBoard(msg){
|
||||||
|
const m = msg || '选择一个问卷与班级查看考试整体情况';
|
||||||
const cards = [
|
const cards = [
|
||||||
['总人数','-'],['已提交','-'],['缺考','-'],
|
['总人数','-'],['已提交','-'],['缺考','-'],
|
||||||
['平均分','-'],['合格率','-']
|
['平均分','-'],['合格率','-']
|
||||||
@@ -352,10 +511,13 @@ function renderPlaceholderBoard(){
|
|||||||
$('#boardSummary').innerHTML = cards.map(c =>
|
$('#boardSummary').innerHTML = cards.map(c =>
|
||||||
// 占位卡片与真实看板卡片顺序、配色一致(accent-* 决定左侧色条),避免空态蓝条与真实卡片不一致
|
// 占位卡片与真实看板卡片顺序、配色一致(accent-* 决定左侧色条),避免空态蓝条与真实卡片不一致
|
||||||
'<div class="card ' + PLACEHOLDER_ACCENT[c[0]] + '"><span>' + c[0] + '</span><b>' + c[1] + '</b></div>').join('');
|
'<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(承载当前问卷/班级提示)
|
// 注意:heroTitle 已随导航栏精简移除,只保留 heroSub(承载当前问卷/班级提示)
|
||||||
const _hs = document.getElementById('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'); }
|
const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +534,10 @@ function markStale(msg){
|
|||||||
// 具体原因放进 title(鼠标悬停可见),正文保持简短
|
// 具体原因放进 title(鼠标悬停可见),正文保持简短
|
||||||
if (lu) { lu.textContent = '刷新中断(仍显示上次数据)'; if (msg) lu.title = String(msg); }
|
if (lu) { lu.textContent = '刷新中断(仍显示上次数据)'; if (msg) lu.title = String(msg); }
|
||||||
}
|
}
|
||||||
function handleAuthFail(){
|
function handleAuthFail(e){
|
||||||
|
const jumps = authJumpCount();
|
||||||
|
if (authJustLoggedIn() || jumps >= 2) { showAuthFailureBox(e || new Error('鉴权失败')); return; }
|
||||||
|
try { sessionStorage.setItem('ny_auth_jumps', String(jumps + 1)); } catch (err) {}
|
||||||
$('#boardBody').innerHTML = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
|
$('#boardBody').innerHTML = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
|
||||||
setTimeout(() => { location.href = 'login.php'; }, 1200);
|
setTimeout(() => { location.href = 'login.php'; }, 1200);
|
||||||
}
|
}
|
||||||
@@ -383,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 pv(v){ return dv(v, '-') + (v === undefined || v === null || v === '' ? '' : '%'); }
|
||||||
|
|
||||||
function renderBoard(){
|
function renderBoard(){
|
||||||
|
// 首页形态:不出单班看板,改为渲染班级入口卡片(boardSummary 由 CSS 隐藏)
|
||||||
|
if (!IS_CLASS) { renderClassCards(); return; }
|
||||||
|
|
||||||
const pid = $('#projectSel').value;
|
const pid = $('#projectSel').value;
|
||||||
const cls = $('#classSel').value;
|
const cls = currentClass();
|
||||||
CUR.pid = pid; CUR.cls = cls;
|
CUR.pid = pid; CUR.cls = cls;
|
||||||
writeUrl({ pid, cls });
|
writeUrl({ pid, cls });
|
||||||
|
|
||||||
@@ -403,6 +571,8 @@ function renderBoard(){
|
|||||||
|
|
||||||
// 顶部提示:当前班级 / 问卷(heroTitle 已移除,只更新 heroSub)
|
// 顶部提示:当前班级 / 问卷(heroTitle 已移除,只更新 heroSub)
|
||||||
const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || '';
|
const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || '';
|
||||||
|
const _hc = document.getElementById('heroClass');
|
||||||
|
if (_hc) _hc.textContent = cls + ' · 该班独立大屏';
|
||||||
|
|
||||||
// 构建"学号 -> 多次刷题明细"映射(班级级 members 已含全班所有人的 attempts_list)
|
// 构建"学号 -> 多次刷题明细"映射(班级级 members 已含全班所有人的 attempts_list)
|
||||||
const MAP = {};
|
const MAP = {};
|
||||||
@@ -496,6 +666,15 @@ function schedule(){
|
|||||||
const sec = parseInt($('#intervalSel').value, 10) || 15;
|
const sec = parseInt($('#intervalSel').value, 10) || 15;
|
||||||
timer = setInterval(refreshBoard, sec * 1000);
|
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){
|
async function refreshBoard(manual){
|
||||||
const pid = $('#projectSel').value;
|
const pid = $('#projectSel').value;
|
||||||
if (!pid) return;
|
if (!pid) return;
|
||||||
@@ -504,7 +683,7 @@ async function refreshBoard(manual){
|
|||||||
const btn = $('#btnRefresh');
|
const btn = $('#btnRefresh');
|
||||||
if (manual && btn) { btn.disabled = true; btn.textContent = '刷新中…'; }
|
if (manual && btn) { btn.disabled = true; btn.textContent = '刷新中…'; }
|
||||||
try {
|
try {
|
||||||
const fresh = await api('dashboard', { projectId: pid });
|
const fresh = await api('dashboard', dashParams({ projectId: pid }));
|
||||||
DASH = fresh;
|
DASH = fresh;
|
||||||
// 项目集合可能换:刷新班级下拉但保留当前选择
|
// 项目集合可能换:刷新班级下拉但保留当前选择
|
||||||
const sel = $('#classSel');
|
const sel = $('#classSel');
|
||||||
@@ -524,7 +703,7 @@ async function refreshBoard(manual){
|
|||||||
$('#lastUpdate').textContent = fresh.updated_at || '-';
|
$('#lastUpdate').textContent = fresh.updated_at || '-';
|
||||||
markLive();
|
markLive();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.auth) { handleAuthFail(); return; }
|
if (e.auth) { handleAuthFail(e); return; }
|
||||||
// 静默续期:保留旧数据,但把实时点变红提示中断
|
// 静默续期:保留旧数据,但把实时点变红提示中断
|
||||||
markStale(e.message);
|
markStale(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -532,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(); });
|
$('#projectSel').addEventListener('change', () => { CUR.pid = $('#projectSel').value; CUR.cls = ''; refreshClassSel(); schedule(); });
|
||||||
$('#classSel').addEventListener('change', () => { CUR.cls = $('#classSel').value; renderBoard(); });
|
$('#classSel').addEventListener('change', () => { CUR.cls = $('#classSel').value; renderBoard(); });
|
||||||
@@ -584,10 +780,20 @@ loadProjects().then(() => {
|
|||||||
if (CUR.pid) {
|
if (CUR.pid) {
|
||||||
$('#projectSel').value = CUR.pid;
|
$('#projectSel').value = CUR.pid;
|
||||||
refreshClassSel().then(() => { renderBoard(); schedule(); });
|
refreshClassSel().then(() => { renderBoard(); schedule(); });
|
||||||
|
} else if (IS_CLASS && $('#projectSel').value) {
|
||||||
|
// 公开链接可能不带 pid:自动落到该卷第一份考试,别让访客对着空页面
|
||||||
|
CUR.pid = $('#projectSel').value;
|
||||||
|
refreshClassSel().then(() => { renderBoard(); schedule(); });
|
||||||
} else {
|
} else {
|
||||||
refreshClassSel();
|
refreshClassSel();
|
||||||
}
|
}
|
||||||
|
// 首页文案指向"选考试 → 进班级",避免顶栏停在旧的单班提示上
|
||||||
|
if (!IS_CLASS) {
|
||||||
|
const _hs = document.getElementById('heroSub');
|
||||||
|
if (_hs) _hs.textContent = '先选择一份问卷 / 考试,再点击班级卡片进入该班大屏';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
lockHistory();
|
||||||
// auto 参数
|
// auto 参数
|
||||||
if (CUR.auto === '0') $('#autoRefresh').checked = false;
|
if (CUR.auto === '0') $('#autoRefresh').checked = false;
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+40
-3
@@ -39,7 +39,7 @@
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `projects` | GET | — | 卷王项目(问卷 / 考试)列表 |
|
| `projects` | GET | — | 卷王项目(问卷 / 考试)列表 |
|
||||||
| `data` | GET | `projectId`、`nameQid`、`force` | 主系统数据:统计、成绩行、名单、缺考、未匹配 |
|
| `data` | GET | `projectId`、`nameQid`、`force` | 主系统数据:统计、成绩行、名单、缺考、未匹配 |
|
||||||
| `dashboard` | GET | `projectId`、`nameQid`、`force` | 按班级 / 组别聚合的看板数据 |
|
| `dashboard` | GET | `projectId`、`nameQid`、`force`、`class` | 按班级 / 组别聚合的看板数据。`class` 非空且未登录时,服务端只回这一个班(并标记 `public`) |
|
||||||
|
|
||||||
`data` 响应关键字段:
|
`data` 响应关键字段:
|
||||||
|
|
||||||
@@ -75,12 +75,49 @@ _cache { cached, at }
|
|||||||
|
|
||||||
| Action | 方法 | 参数 | 说明 |
|
| Action | 方法 | 参数 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `export` | GET | `projectId`、`cls`、`grp`、`mode=latest`、`split=cls`、`sort`、`cols` | 成绩明细 `.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 | 同上 | 缺考名单 `.xlsx` |
|
| `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` | 白名单导入模板 / 白名单列表 |
|
| `draw_export` | GET | `type=import\|list` | 白名单导入模板 / 白名单列表 |
|
||||||
|
|
||||||
`sort` 取值:`time` / `sid` / `score_desc` / `score_asc`。
|
`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` | **默认** | 两行表头的「每次考试」块表:一列「第几次」被所有考试共用(逐行 1、2、3…,不合并),每场考试占 **1 列**(第 1 行考试名 + 第 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
|
```js
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
```
|
```
|
||||||
ny-exam-platform/
|
ny-exam-platform/
|
||||||
├─ index.php 主系统页面(服务端渲染骨架 + 前端挂载)
|
├─ index.php 主系统页面(服务端渲染骨架 + 前端挂载)
|
||||||
├─ dashboard.php 数据看板页面
|
├─ dashboard.php 数据大屏:首页(班级入口)+ ?class= 单班大屏(锁后退)
|
||||||
├─ draw.php 随机抽取页面
|
├─ draw.php 随机抽取页面
|
||||||
├─ login.php / logout.php 登录与登出
|
├─ login.php / logout.php 登录与登出
|
||||||
├─ api.php 所有后端接口(单一入口,?action=xxx)
|
├─ api.php 所有后端接口(单一入口,?action=xxx)
|
||||||
|
|||||||
+50
-4
@@ -14,9 +14,45 @@ bash tests/run-all.sh
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `tests/test-aggregate.php` | PHP | 聚合、去重、考试次数、排序 |
|
| `tests/test-aggregate.php` | PHP | 聚合、去重、考试次数、排序 |
|
||||||
| `tests/test-new-features.php` | PHP | 再次提交置顶排序、XLSX 多表、名单导入列识别 |
|
| `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-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-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` 后缀的文件 → 被拒绝
|
- [ ] 导入含 `.php` 后缀的文件 → 被拒绝
|
||||||
- [ ] 导入正常 `.xlsx` → 成功,且 `data/uploads/` 为空
|
- [ ] 导入正常 `.xlsx` → 成功,且 `data/uploads/` 为空
|
||||||
- [ ] 导出成绩 / 导出缺考 → 能打开,学号不是科学计数法
|
- [ ] 工具栏「导出」只一个按钮 → 弹窗里四种内容都能出表,学号不是科学计数法
|
||||||
- [ ] 打印预览 → 默认成绩降序
|
- [ ] 成绩明细「每人成绩」切 最高 / 最新 → 成绩列跟着变,文件名带 `_最高成绩` / `_最新成绩`
|
||||||
- [ ] 数据看板全屏进入 / 退出都正常
|
- [ ] 每次考试导出 → 最高分红字、合格次数达标绿底、未达标留白、整场未考合并成「未考」;每场考试只占 1 列,「第几次」逐行 1/2/3 不被合并
|
||||||
|
- [ ] 多考试打印 → 版式与导出的 Excel 一致,「合格次数」标签没被合并吞掉
|
||||||
|
- [ ] 打印预览 → 默认成绩降序,没有教师签字栏、没有站点域名页脚;**绿底 / 红字真的印出来了**
|
||||||
|
(打印样式带 `print-color-adjust: exact`,不需要在对话框里勾「背景图形」)
|
||||||
|
- [ ] 导出弹窗「附加内容 · 附缺考名单」→ 每次考试 / 多考试对比也出「缺考名单」工作表
|
||||||
|
- [ ] 在每卷配置里改满分成数 / 合格率 → 数据大屏成员榜的红绿跟着变
|
||||||
|
- [ ] 数据大屏首页 → 选试卷后列出班级卡片,点击进入单班大屏
|
||||||
|
- [ ] 单班大屏 → 顶栏只有试卷 / 自动刷新 / 全屏,没有班级选择与退出;按浏览器后退键**退不回去**
|
||||||
|
- [ ] 复制单班大屏链接在**无痕窗口**打开 → 直接看到该班数据(免登录),顶栏有「公开 · 无需登录」角标;
|
||||||
|
看不到其它班级的成绩;大屏首页仍要求登录
|
||||||
|
- [ ] 数据大屏全屏进入 / 退出都正常
|
||||||
- [ ] 随机抽取 → 名额守恒
|
- [ ] 随机抽取 → 名额守恒
|
||||||
|
|
||||||
## 相关
|
## 相关
|
||||||
|
|||||||
+65
-15
@@ -1,20 +1,43 @@
|
|||||||
# 数据看板
|
# 数据大屏
|
||||||
|
|
||||||
数据看板(`dashboard.php`)是**面向投屏 / 大屏**的汇总视图,适合考试进行中实时监控提交进度。
|
数据大屏(`dashboard.php`)是**面向投屏 / 大屏**的汇总视图,适合考试进行中实时监控提交进度。
|
||||||
|
它有两种形态,由 URL 决定:
|
||||||
|
|
||||||
从主页面顶部导航 **📊 数据看板** 打开(新标签页)。
|
| 形态 | 打开方式 | 长什么样 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **首页(班级入口)** | 从主页面顶部导航 **📊 数据大屏** 打开,或直接访问 `dashboard.php` | 先选试卷 / 考试,下方以卡片列出该卷的所有班级;点卡片进入该班大屏 |
|
||||||
|
| **单班大屏** | 点首页的班级卡片(等价于 `dashboard.php?pid=<问卷ID>&class=<班级名>`) | 只展示这一个班:5 张汇总卡 + 分组对比 / 成员榜 |
|
||||||
|
|
||||||
## 顶部操作
|
首页是"入口",不是概览页——它不堆指标,只做一件事:让你**一次看到有哪些班,点进去看单个班**。
|
||||||
|
|
||||||
| 控件 | 说明 |
|
## 单班大屏是公开的,可以直接发链接
|
||||||
| --- | --- |
|
|
||||||
| 问卷 / 考试 | 选择要展示的试卷 |
|
`dashboard.php?pid=<问卷ID>&class=<班级名>` **不需要登录**,任何人打开都能看到这一个班的数据
|
||||||
| 班级 | 选择后只展示该班级(需先选试卷) |
|
(顶栏带「公开 · 无需登录」角标)。适合直接把链接发到班级群让孩子或家长自己看。
|
||||||
| 自动刷新开关 + 间隔 | 与主页面一致的 5 / 10 / 15 / 30 / 60 / 120 秒 |
|
|
||||||
| 刷新 | 立即拉取一次(跳过缓存) |
|
::: danger 公开的是"这一个班",不是全卷
|
||||||
| ⛶ 全屏 | 进入 / 退出全屏,布局自动铺满 |
|
接口在**服务端**先按 `class` 把名单和答卷裁剪掉,再回传数据:公开链接拿不到全卷汇总,
|
||||||
| ← 主系统 | 返回主页面(成绩明细 / 名单 / 抽取);全屏状态下也可直接返回 |
|
也拿不到其它班级的成绩。想看别的班,打开那个班自己的链接即可;不带 `class` 参数的
|
||||||
| 退出 | 退出登录 |
|
大屏首页仍然要求登录。
|
||||||
|
:::
|
||||||
|
|
||||||
|
::: warning 单班大屏没有回头路
|
||||||
|
单班大屏**不显示**班级下拉、「← 主系统」和「退出」,页面内部也不存在回到首页的链接;
|
||||||
|
浏览器**后退键同样被屏蔽**(页面加载后立刻 `pushState` 占位,任何 `popstate` 都再推回去)。
|
||||||
|
想回主系统或换班,请在地址栏改 URL,或关闭标签页重新从首页进入。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 顶部控件
|
||||||
|
|
||||||
|
| 控件 | 首页 | 单班大屏 |
|
||||||
|
| --- | :-: | :-: |
|
||||||
|
| 问卷 / 考试 | ✅ | ✅ 切换后若该班不在新卷名单里,会自动落到第一个班 |
|
||||||
|
| 班级 | ✅ | ❌ 由 URL 固定,不提供切换 |
|
||||||
|
| 自动刷新开关 + 间隔 | ✅ | ✅ |
|
||||||
|
| 刷新 | ✅ | ✅ |
|
||||||
|
| ⛶ 全屏 | ✅ | ✅ |
|
||||||
|
| ← 主系统 | ✅ | ❌ 由 CSS 隐藏 |
|
||||||
|
| 退出 | ✅ | ❌ 页面里不再渲染 |
|
||||||
|
|
||||||
::: tip 后台标签页会自动暂停
|
::: tip 后台标签页会自动暂停
|
||||||
切换到其他标签页后看板会**停止轮询**,切回来时立即补拉一次数据并重新开始计时——
|
切换到其他标签页后看板会**停止轮询**,切回来时立即补拉一次数据并重新开始计时——
|
||||||
@@ -22,14 +45,41 @@
|
|||||||
**刷新中断(仍显示上次数据)**,鼠标悬停可看到具体原因。
|
**刷新中断(仍显示上次数据)**,鼠标悬停可看到具体原因。
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## 汇总卡片
|
## 首页:班级入口卡片
|
||||||
|
|
||||||
`总人数 / 已提交 / 缺考 / 提交率 / 平均分 / 合格率(≥X%满分)`
|
每张卡片给出该班的速览,点整张卡片(或聚焦后按回车)进入该班大屏:
|
||||||
|
|
||||||
|
`总人数 / 已提交 / 缺考 / 平均分 / 合格率` + 一条提交率进度条;没交过卷的班也能一眼看到缺考数。
|
||||||
|
|
||||||
|
::: tip 首页也在自动刷新
|
||||||
|
首页同样受自动刷新开关控制(默认 15 秒),卡片上的数字会跟着变,适合把整层楼/整个年级的
|
||||||
|
提交进度投在一块屏上。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 汇总卡片(单班大屏)
|
||||||
|
|
||||||
|
`总人数 / 已提交 / 缺考 / 平均分 / 合格率(≥X%满分)`
|
||||||
|
|
||||||
合格率卡片的阈值标签会随 [每卷配置](./project-config.md) 实时变化。
|
合格率卡片的阈值标签会随 [每卷配置](./project-config.md) 实时变化。
|
||||||
|
|
||||||
|
## 成绩的红绿怎么定的
|
||||||
|
|
||||||
|
成员榜(以及「刷 N 次」下钻弹窗)里的成绩颜色**完全按 [每卷配置](./project-config.md) 里
|
||||||
|
管理员设的满分与合格率**判定,不是写死的:
|
||||||
|
|
||||||
|
| 占满分的比例 | 颜色 |
|
||||||
|
| --- | --- |
|
||||||
|
| ≥ 90%(合格线本身高过 90% 时则以合格线为准) | 🟢 绿 |
|
||||||
|
| ≥ 合格率 × 满分(合格但未达优秀) | 🟡 黄 |
|
||||||
|
| < 合格率 × 满分(不及格) | 🔴 红 |
|
||||||
|
|
||||||
|
例如把本卷满分改成 150、合格率改成 80%,那么 100 分只占 66.7% → 判红,130 分(86.7%)判黄,
|
||||||
|
145 分(96.7%)判绿。鼠标悬停在分数上会显示「占满分 xx% 合格线 xx 分」。
|
||||||
|
|
||||||
## 班级 / 组别下钻
|
## 班级 / 组别下钻
|
||||||
|
|
||||||
|
(即单班大屏的主体部分)
|
||||||
|
|
||||||
看板主体按**班级**分块,每块内再按**组别**对比,指标与统计口径完全相同:
|
看板主体按**班级**分块,每块内再按**组别**对比,指标与统计口径完全相同:
|
||||||
总人数、已提交、缺考、提交率、平均分、合格率、最高分、最低分、平均用时。
|
总人数、已提交、缺考、提交率、平均分、合格率、最高分、最低分、平均用时。
|
||||||
|
|
||||||
|
|||||||
+151
-44
@@ -1,88 +1,195 @@
|
|||||||
# 导出与打印
|
# 导出与打印
|
||||||
|
|
||||||
## 导出成绩明细
|
工具栏下方只有一个 **导出** 按钮,所有导出与打印的选项都收在这个弹窗里,不再散落多个小按钮。
|
||||||
|
选项在你打开弹窗前就配好了会**自动记住**(存本地),下次打开就是上次的设置。
|
||||||
|
|
||||||
工具栏下方「导出」条里的 **导出成绩**,生成 `.xlsx`(零依赖,仓库内自行实现 XLSX 写入,不需要 `zip` 扩展也能用)。
|
> 生成的是 `.xlsx`:零依赖,仓库内自行实现 XLSX 写入,没有 `zip` 扩展也能用。
|
||||||
导出控件单独成行,与筛选、刷新分开,不会挤在一起。
|
|
||||||
|
|
||||||
### 三种可选模式
|
## 导出弹窗
|
||||||
|
|
||||||
|
| 分区 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| **导出内容** | 成绩明细 / 每次考试 / 缺考名单 / 多考试对比(四选一,选中后下方分区跟着变) |
|
||||||
|
| **统计范围** | 班级 / 组别多选(不选 = 全部,按住 `Ctrl` / `⌘` 多选) |
|
||||||
|
| **成绩口径与列** | 每人成绩(最新 / 最高)、排序、出表方式、按班级分工作表、附缺考名单 |
|
||||||
|
| **多考试对比** | 表格形态、对比成绩、勾选要对比的考试 |
|
||||||
|
| **附加内容** | 附缺考名单(成绩明细 / 每次考试 / 多考试对比 都出工作表) |
|
||||||
|
| **标记与合格次数** | 合格次数阈值、最高分标红、输出「合格次数」行 |
|
||||||
|
| **打印设置** | 纸张方向、打印标题 |
|
||||||
|
|
||||||
|
- 分区随「导出内容」显隐:选 **缺考名单** 时只保留范围与打印设置;
|
||||||
|
选 **多考试对比** 时才出现考试勾选表;
|
||||||
|
- **附加内容** 与 **标记与合格次数** 是三类成绩表共用的,不随导出内容消失;
|
||||||
|
「按班级分工作表」只有成绩明细支持(置灰不可勾选);
|
||||||
|
- 弹窗底部有**一句话汇总**(导什么、哪些考试、多大范围),导错了一眼能看出来;
|
||||||
|
- 打印走隐藏 iframe 调起,**不会被浏览器弹窗拦截挡掉**,也不会多开标签页。
|
||||||
|
|
||||||
|
### 成绩明细:每人最新成绩 / 最高成绩
|
||||||
|
|
||||||
|
**默认按每人最新成绩显示**(最近一次真实提交),可选 **最高成绩**(该生在这些考试里的最高分)。
|
||||||
|
|
||||||
| 选项 | 效果 |
|
| 选项 | 效果 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 导出排序 | `按提交时间` / `按学号` / `按成绩降序` / `按成绩升序` |
|
| 每人成绩 | `最新成绩`(默认)/ `最高成绩` —— 每人一行时的代表成绩 |
|
||||||
| 按最新成绩 | 每人**只导一行**:最近一次成绩 + 最高分 + 考试次数 |
|
| 排序 | `按提交时间` / `按学号` / `按成绩降序` / `按成绩升序` |
|
||||||
| 按班级分表 | 一个班级一个工作表(xlsx 多工作表) |
|
| 出表方式 | `每人一行(汇总)`(默认)/ `每次考试一行(明细)`:明细模式保留每位学生的历史提交 |
|
||||||
|
| 附缺考名单 | 默认开;只导成绩时取消勾选(接口 `noAbsent=1`)。**每次考试 / 多考试对比 也支持**,口径为「名单里有、所选考试全都没交」 |
|
||||||
|
| 按班级分工作表 | 一个班级一个工作表(xlsx 多工作表) |
|
||||||
|
|
||||||
### 列
|
默认列:`学号 / 姓名 / 班级 / 组别 / 成绩 / 最高分 / 考试次数 / 最近提交时间 / 考试时长 / 考试时长(秒)`。
|
||||||
|
|
||||||
默认列(全部记录模式):
|
## 「每次考试」块表(多场对比,主推格式)
|
||||||
|
|
||||||
`学号 / 姓名 / 班级 / 组别 / 成绩 / 考试次数 / 第几次 / 提交时间 / 考试时长 / 考试时长(秒)`
|
勾选多场考试,选 **两行表头** 形态。**一列「第几次」被所有考试共用**(逐行写 1、2、3…,不合并),
|
||||||
|
每场考试占 **1 列**,学生块末尾多一行 **合格次数**。
|
||||||
|
|
||||||
按最新成绩模式:
|
| 学号 | 姓名 | 第几次 | 第一次月考·数学 | 第一次月考·英语 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| | | | 成绩 | 成绩 |
|
||||||
|
| (合并) | (合并) | 1 | 112 | 130 |
|
||||||
|
| (合并) | (合并) | 2 | 100 | 129 |
|
||||||
|
| (合并) | (合并) | 3 | **136** | **133** |
|
||||||
|
| (合并) | (合并) | **合格次数** | 3 | 3 |
|
||||||
|
| 356002 | 李四 | 1 | (合并)**未考** | 128 |
|
||||||
|
| (合并) | (合并) | **合格次数** | | 1 |
|
||||||
|
|
||||||
`学号 / 姓名 / 班级 / 组别 / 最新成绩 / 最高分 / 考试次数 / 最近提交时间 / 考试时长 / 考试时长(秒)`
|
规则:
|
||||||
|
|
||||||
> 学号等长数字列统一按**文本格式**写入,不会被 Excel 转成科学计数法,也不会被当作公式执行。
|
- **学生块高度** = 该生在所有勾选考试里最多的那次次数 + 1(末行是合格次数);
|
||||||
|
- **学号 / 姓名**按学生块纵向合并;**第几次**逐行写各自的次序号(1、2、3…),**不合并**
|
||||||
|
——合并会把 2、3 吞掉只剩 1;
|
||||||
|
- **每场考试占 1 列**:第 1 行是考试名、第 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(需已登录):
|
需要对接其它系统时,可以带参数直接 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 | 必填 |
|
| `projectId` | 试卷 ID | 必填(`export_multi` 用 `projectIds`,逗号分隔,可只传一场) |
|
||||||
| `cls` / `grp` | 班级 / 组别 | 与页面筛选一致 |
|
| `cls` / `grp` | 班级 / 组别 | 与页面筛选一致;`export_multi` 支持**多选**(逗号分隔),空 = 全部 |
|
||||||
| `mode` | `latest` | 每人一行最新成绩;缺省导出全部记录 |
|
| `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` | 按班级拆分多工作表 |
|
| `split` | `cls` | 按班级拆分多工作表 |
|
||||||
| `sort` | `time` / `sid` / `score_desc` / `score_asc` | 排序 |
|
| `cols` | 逗号分隔列名 | 从默认列中挑选(仅 `export`) |
|
||||||
| `cols` | 逗号分隔列名 | 从默认列中挑选 |
|
|
||||||
|
`format=json` 返回的 `cell_styles` 是 `行,列 → red | green`,行与列都是 **1-based**;
|
||||||
|
前端据此加红字 / 绿底,合并区间内的非首格继承左上角首格的标记。
|
||||||
|
|
||||||
## 相关
|
## 相关
|
||||||
|
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ NY 问卷考试系统数据平台是一个**零第三方运行依赖**的 PHP 8
|
|||||||
| 试卷设置 | 为当前选中的试卷单独设置满分 / 合格率 / 免考名单,试卷间互相隔离 |
|
| 试卷设置 | 为当前选中的试卷单独设置满分 / 合格率 / 免考名单,试卷间互相隔离 |
|
||||||
| 导出 / 打印 | 成绩明细、缺考名单 `.xlsx`;成绩明细按成绩降序打印,支持分组小计、合格标记、附缺考名单、签字栏 |
|
| 导出 / 打印 | 成绩明细、缺考名单 `.xlsx`;成绩明细按成绩降序打印,支持分组小计、合格标记、附缺考名单、签字栏 |
|
||||||
|
|
||||||
### 数据看板 `dashboard.php`
|
### 数据大屏 `dashboard.php`
|
||||||
|
|
||||||
大屏看板,适合投屏。支持全屏切换与自动刷新。
|
首页先选试卷,再用卡片列出该卷的班级;点卡片进入该班专属大屏。
|
||||||
|
大屏页支持全屏切换与自动刷新,**没有返回按钮,浏览器后退键也被屏蔽**(只能靠地址栏或关标签页离开)。
|
||||||
|
|
||||||
### 随机抽取 `draw.php`
|
### 随机抽取 `draw.php`
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ features:
|
|||||||
| 页面 | 入口 | 用途 |
|
| 页面 | 入口 | 用途 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 主系统 | `index.php` | 成绩明细、统计卡片、名单管理、试卷设置、导出与打印 |
|
| 主系统 | `index.php` | 成绩明细、统计卡片、名单管理、试卷设置、导出与打印 |
|
||||||
| 数据看板 | `dashboard.php` | 大屏展示,适合投屏实时监控提交进度 |
|
| 数据大屏 | `dashboard.php` | 首页列出班级入口,点进班级专属大屏(可全屏、自动刷新),适合投屏 |
|
||||||
| 随机抽取 | `draw.php` | 从名单或某试卷已提交人员中随机抽取,支持白名单导出 |
|
| 随机抽取 | `draw.php` | 从名单或某试卷已提交人员中随机抽取,支持白名单导出 |
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -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=…&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.
@@ -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>第一次月考·数学</td><td>第一次月考·英语</td></tr><tr><td>成绩</td><td>成绩</td></tr></thead><tbody><tr><td rowspan="5">356001</td><td rowspan="5">张三</td><td>1</td><td>112</td><td class="max">112</td></tr><tr><td>2</td><td>100</td><td>99</td></tr><tr><td>3</td><td class="max">136</td><td></td></tr><tr><td>4</td><td>127</td><td></td></tr><tr><td>合格次数</td><td class="pass">3</td><td class="pass">1</td></tr><tr><td rowspan="4">356002</td><td rowspan="4">李四</td><td>1</td><td rowspan="3" class="absent">未考</td><td>130</td></tr><tr><td>2</td><td>129</td></tr><tr><td>3</td><td class="max">133</td></tr><tr><td>合格次数</td><td></td><td class="pass">3</td></tr><tr><td rowspan="2">356003</td><td rowspan="2">王五</td><td>1</td><td class="max">95</td><td class="absent">未考</td></tr><tr><td>合格次数</td><td>0</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>
|
||||||
@@ -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>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>
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
{
|
||||||
|
"block": {
|
||||||
|
"title": "多考试导出 · 每次考试(两行表头)",
|
||||||
|
"sheet": {
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
"学号",
|
||||||
|
"姓名",
|
||||||
|
"第几次",
|
||||||
|
"第一次月考·数学",
|
||||||
|
"第一次月考·英语"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"成绩",
|
||||||
|
"成绩"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"356001",
|
||||||
|
"张三",
|
||||||
|
"1",
|
||||||
|
"112",
|
||||||
|
"112"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"2",
|
||||||
|
"100",
|
||||||
|
"99"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"3",
|
||||||
|
"136",
|
||||||
|
""
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"4",
|
||||||
|
"127",
|
||||||
|
""
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"合格次数",
|
||||||
|
"3",
|
||||||
|
"1"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"356002",
|
||||||
|
"李四",
|
||||||
|
"1",
|
||||||
|
"未考",
|
||||||
|
"130"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"2",
|
||||||
|
"",
|
||||||
|
"129"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"3",
|
||||||
|
"",
|
||||||
|
"133"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"合格次数",
|
||||||
|
"",
|
||||||
|
"3"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"356003",
|
||||||
|
"王五",
|
||||||
|
"1",
|
||||||
|
"95",
|
||||||
|
"未考"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"合格次数",
|
||||||
|
"0",
|
||||||
|
""
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"merges": [
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
7,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
7,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
8,
|
||||||
|
4,
|
||||||
|
10,
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
8,
|
||||||
|
1,
|
||||||
|
11,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
8,
|
||||||
|
2,
|
||||||
|
11,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
12,
|
||||||
|
1,
|
||||||
|
13,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
12,
|
||||||
|
2,
|
||||||
|
13,
|
||||||
|
2
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"header_rows": 2,
|
||||||
|
"cell_styles": {
|
||||||
|
"3,5": "red",
|
||||||
|
"5,4": "red",
|
||||||
|
"7,4": "green",
|
||||||
|
"7,5": "green",
|
||||||
|
"10,5": "red",
|
||||||
|
"11,5": "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"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"356003",
|
||||||
|
"王五",
|
||||||
|
"95",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"0"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"merges": [],
|
||||||
|
"header_rows": 1,
|
||||||
|
"cell_styles": {
|
||||||
|
"2,5": "red",
|
||||||
|
"2,7": "green",
|
||||||
|
"3,3": "red"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"pass_line": 110,
|
||||||
|
"pass_times": 1,
|
||||||
|
"exam_name": "第一次月考·数学"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,23 +80,9 @@ $rosterCount = Roster::count();
|
|||||||
|
|
||||||
<section class="subbar">
|
<section class="subbar">
|
||||||
<span class="subbar-label">导出</span>
|
<span class="subbar-label">导出</span>
|
||||||
<label class="subbar-item">排序
|
<span class="hint" id="exportBarHint">成绩明细 / 每次考试 / 缺考名单 / 多考试对比,都在「导出」里配置</span>
|
||||||
<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="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button class="btn" id="btnExport">导出成绩</button>
|
<button class="btn btn-primary" id="btnExportOpen" title="导出与打印:选好导出内容、范围与标记方式,再导出 Excel 或直接打印">导出</button>
|
||||||
<button class="btn" id="btnExportAbsent">导出缺考</button>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="cards" id="statCards">
|
<section class="cards" id="statCards">
|
||||||
@@ -424,10 +410,18 @@ $rosterCount = Roster::count();
|
|||||||
<div class="fi">
|
<div class="fi">
|
||||||
<label>打印范围</label>
|
<label>打印范围</label>
|
||||||
<select id="ptScope">
|
<select id="ptScope">
|
||||||
<option value="latest" selected>按最新成绩打印(每人一行)</option>
|
<option value="latest" selected>每人一行(汇总)</option>
|
||||||
<option value="all">打印全部记录(含每位学生历史提交)</option>
|
<option value="all">每次考试一行(含历史提交)</option>
|
||||||
</select>
|
</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>
|
||||||
<div class="fi checks">
|
<div class="fi checks">
|
||||||
<label><input type="checkbox" id="ptRank" checked> 显示名次(全表连续编号)</label>
|
<label><input type="checkbox" id="ptRank" checked> 显示名次(全表连续编号)</label>
|
||||||
@@ -436,6 +430,7 @@ $rosterCount = Roster::count();
|
|||||||
<label><input type="checkbox" id="ptAbsent" title="成绩表后另起一页附缺考名单,含备注列供手写"> 附缺考名单(另起一页)</label>
|
<label><input type="checkbox" id="ptAbsent" title="成绩表后另起一页附缺考名单,含备注列供手写"> 附缺考名单(另起一页)</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">打印内容<b>不受页面当前筛选外的影响</b>,始终按当前班级/组别筛选结果输出;表头会在每页自动重复,末尾附签字栏。</p>
|
<p class="hint">打印内容<b>不受页面当前筛选外的影响</b>,始终按当前班级/组别筛选结果输出;表头会在每页自动重复,末尾附签字栏。</p>
|
||||||
|
<p class="hint">要打印<b>多场考试对比</b>或<b>「每次考试」表</b>(最高分标红、合格次数达标绿底)?用工具栏的 <b>导出</b> 弹窗,选「多考试对比」或「每次考试」后点「打印」。</p>
|
||||||
<p class="hint" id="ptHint"></p>
|
<p class="hint" id="ptHint"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
@@ -477,6 +472,186 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= 统一导出 / 打印 =================
|
||||||
|
所有导出(成绩明细 / 每次考试 / 缺考名单 / 多考试对比)与打印都在这一个弹窗里配置:
|
||||||
|
先选「导出什么」,再按需展开「范围」「列与标记」「打印」三个区块。 -->
|
||||||
|
<div class="modal" id="exportModal">
|
||||||
|
<div class="modal-box wide">
|
||||||
|
<div class="modal-head"><h3>导出 / 打印</h3><button class="x" data-close>×</button></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- 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" id="btnExPrint">打印</button>
|
||||||
|
<button class="btn btn-primary" id="btnExExport">导出 Excel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
window.NY = {
|
window.NY = {
|
||||||
appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>,
|
appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>,
|
||||||
|
|||||||
+58
-2
@@ -465,6 +465,9 @@ class DataService
|
|||||||
$one = $r;
|
$one = $r;
|
||||||
$one['idx'] = 1;
|
$one['idx'] = 1;
|
||||||
$one['is_best'] = true;
|
$one['is_best'] = true;
|
||||||
|
$one['day'] = self::dayKeyOf((int)($r['submit_ts'] ?? 0), (string)($r['submit_at'] ?? ''));
|
||||||
|
$one['day_best'] = true;
|
||||||
|
$one['day_count'] = 1;
|
||||||
$out[] = $one;
|
$out[] = $one;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -485,6 +488,9 @@ class DataService
|
|||||||
'best_score' => $r['best_score'] ?? null,
|
'best_score' => $r['best_score'] ?? null,
|
||||||
'idx' => (int)($a['idx'] ?? 1),
|
'idx' => (int)($a['idx'] ?? 1),
|
||||||
'is_best' => !empty($a['is_best']),
|
'is_best' => !empty($a['is_best']),
|
||||||
|
'day' => (string)($a['day'] ?? ''),
|
||||||
|
'day_best' => !empty($a['day_best']),
|
||||||
|
'day_count' => (int)($a['day_count'] ?? 1),
|
||||||
'last_submit_ts' => (int)($r['last_submit_ts'] ?? 0),
|
'last_submit_ts' => (int)($r['last_submit_ts'] ?? 0),
|
||||||
'last_submit_at' => (string)($r['last_submit_at'] ?? '-'),
|
'last_submit_at' => (string)($r['last_submit_at'] ?? '-'),
|
||||||
];
|
];
|
||||||
@@ -552,6 +558,28 @@ class DataService
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取某次提交的「年月日」分组键。
|
||||||
|
*
|
||||||
|
* 关键:一律用服务端本地时区 date('Y-m-d', $ts) 计算,**不能用 UTC**(gmdate / JS toISOString),
|
||||||
|
* 否则零点前后提交的答卷会被算到前一天/后一天(东八区会整体错一天)。
|
||||||
|
* 无有效时间戳(createAt 缺失导致 ny_to_timestamp 返回 0)时,退回从显示串取前 10 位;
|
||||||
|
* 仍取不到则返回空串,上层归为「未知时间」分组并排在最后。
|
||||||
|
*
|
||||||
|
* @param int $ts 提交时间戳(秒),0 表示未知
|
||||||
|
* @param string $submitAt 展示用时间串(形如 2026-09-08 09:41:20)
|
||||||
|
*/
|
||||||
|
public static function dayKeyOf(int $ts, string $submitAt = ''): string
|
||||||
|
{
|
||||||
|
if ($ts > 0) {
|
||||||
|
return date('Y-m-d', $ts);
|
||||||
|
}
|
||||||
|
if ($submitAt !== '' && $submitAt !== '-' && preg_match('/^(\d{4}-\d{2}-\d{2})/', $submitAt, $m) === 1) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按学号聚合一次提交的答卷记录,解决"同人多次刷卷"在看板中被当成多个人的问题。
|
* 按学号聚合一次提交的答卷记录,解决"同人多次刷卷"在看板中被当成多个人的问题。
|
||||||
*
|
*
|
||||||
@@ -589,15 +617,37 @@ class DataService
|
|||||||
}
|
}
|
||||||
// 最近一次提交(升序排序后的最后一份)
|
// 最近一次提交(升序排序后的最后一份)
|
||||||
$lastSubmitTs = (int)($sorted[count($sorted) - 1]['submit_ts'] ?? 0);
|
$lastSubmitTs = (int)($sorted[count($sorted) - 1]['submit_ts'] ?? 0);
|
||||||
|
|
||||||
|
// 按「提交时间年月日」分组统计:每天的最高分与提交次数。
|
||||||
|
// 历史弹窗据此按天分类,并在每个分类内标记当日最高分。
|
||||||
|
$dayMax = []; // day => 当天最高分
|
||||||
|
$dayCount = []; // day => 当天提交次数(含未判分)
|
||||||
|
foreach ($sorted as $a) {
|
||||||
|
$day = self::dayKeyOf((int)($a['submit_ts'] ?? 0), (string)($a['submit_at'] ?? ''));
|
||||||
|
$dayCount[$day] = ($dayCount[$day] ?? 0) + 1;
|
||||||
|
if ($a['score'] !== null && (!array_key_exists($day, $dayMax) || $a['score'] > $dayMax[$day])) {
|
||||||
|
$dayMax[$day] = $a['score'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 标记是否已给"最佳"打过标(同分时只标最早达到该分数的那次)
|
// 标记是否已给"最佳"打过标(同分时只标最早达到该分数的那次)
|
||||||
$bestMarked = false;
|
$bestMarked = false;
|
||||||
|
// 每天是否已标记过"当日最高"(同分时同样只标最早达到的那次,与全局 is_best 一致)
|
||||||
|
$dayMarked = [];
|
||||||
$idx = 0;
|
$idx = 0;
|
||||||
$attemptsList = array_map(static function (array $a) use (&$idx, $maxScore, &$bestMarked): array {
|
$attemptsList = array_map(
|
||||||
|
static function (array $a) use (&$idx, $maxScore, &$bestMarked, &$dayMarked, $dayMax, $dayCount): array {
|
||||||
$idx++;
|
$idx++;
|
||||||
$isBest = (!$bestMarked && $a['score'] !== null && $maxScore !== null && (float)$a['score'] === (float)$maxScore);
|
$isBest = (!$bestMarked && $a['score'] !== null && $maxScore !== null && (float)$a['score'] === (float)$maxScore);
|
||||||
if ($isBest) {
|
if ($isBest) {
|
||||||
$bestMarked = true;
|
$bestMarked = true;
|
||||||
}
|
}
|
||||||
|
$day = self::dayKeyOf((int)($a['submit_ts'] ?? 0), (string)($a['submit_at'] ?? ''));
|
||||||
|
$dayBest = ($a['score'] !== null && array_key_exists($day, $dayMax)
|
||||||
|
&& (float)$a['score'] === (float)$dayMax[$day] && empty($dayMarked[$day]));
|
||||||
|
if ($dayBest) {
|
||||||
|
$dayMarked[$day] = true;
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
'idx' => $idx,
|
'idx' => $idx,
|
||||||
'is_best' => $isBest,
|
'is_best' => $isBest,
|
||||||
@@ -608,8 +658,14 @@ class DataService
|
|||||||
'duration_txt' => $a['duration_txt'],
|
'duration_txt' => $a['duration_txt'],
|
||||||
'answer_id' => $a['answer_id'],
|
'answer_id' => $a['answer_id'],
|
||||||
'temp_save' => $a['temp_save'],
|
'temp_save' => $a['temp_save'],
|
||||||
|
// 按天分类用:day 为空串表示时间未知
|
||||||
|
'day' => $day,
|
||||||
|
'day_best' => $dayBest,
|
||||||
|
'day_count' => (int)($dayCount[$day] ?? 1),
|
||||||
];
|
];
|
||||||
}, $sorted);
|
},
|
||||||
|
$sorted
|
||||||
|
);
|
||||||
|
|
||||||
// 代表成绩选择(仅影响主明细表那一行的成绩,不影响考试次数 / 历史明细)
|
// 代表成绩选择(仅影响主明细表那一行的成绩,不影响考试次数 / 历史明细)
|
||||||
if ($dedupe) {
|
if ($dedupe) {
|
||||||
|
|||||||
+40
-3
@@ -170,12 +170,18 @@ class Db
|
|||||||
// ---------------- 设置 ----------------
|
// ---------------- 设置 ----------------
|
||||||
|
|
||||||
|
|
||||||
/** SQLite 用 ON CONFLICT 做 upsert(其它方言见历史版本) */
|
/**
|
||||||
public static function settingsSet(string $k, string $v): void
|
* SQLite 用 ON CONFLICT 做 upsert(其它方言见历史版本)
|
||||||
|
*
|
||||||
|
* 返回是否真的写成功:以前这里 void + 内部吞异常,导致"配置没落盘但上层以为成功"
|
||||||
|
* (典型后果:登录成功→下一请求仍报"未配置账号"→反复跳登录页的死循环)。
|
||||||
|
* 因此改为回读校验后返回 bool,并把失败原因放进 self::lastError()。
|
||||||
|
*/
|
||||||
|
public static function settingsSet(string $k, string $v): bool
|
||||||
{
|
{
|
||||||
$pdo = self::pdo();
|
$pdo = self::pdo();
|
||||||
if (!$pdo) {
|
if (!$pdo) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$p = self::prefix();
|
$p = self::prefix();
|
||||||
@@ -184,6 +190,37 @@ class Db
|
|||||||
ON CONFLICT(k) DO UPDATE SET v=excluded.v, updated_at=excluded.updated_at"
|
ON CONFLICT(k) DO UPDATE SET v=excluded.v, updated_at=excluded.updated_at"
|
||||||
);
|
);
|
||||||
$st->execute([$k, $v, time()]);
|
$st->execute([$k, $v, time()]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 回读校验:磁盘满 / 目录不可写 / 只读挂载时 execute 可能不报错但实际未落盘
|
||||||
|
try {
|
||||||
|
$p = self::prefix();
|
||||||
|
$st = $pdo->prepare("SELECT v FROM {$p}settings WHERE k = ?");
|
||||||
|
$st->execute([$k]);
|
||||||
|
$got = $st->fetchColumn();
|
||||||
|
if ($got === false || (string)$got !== $v) {
|
||||||
|
self::$err = '写入后回读不一致(可能磁盘已满或数据库只读)';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function settingsDelete(string $k): void
|
||||||
|
{
|
||||||
|
$pdo = self::pdo();
|
||||||
|
if (!$pdo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$p = self::prefix();
|
||||||
|
$st = $pdo->prepare("DELETE FROM {$p}settings WHERE k = ?");
|
||||||
|
$st->execute([$k]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
self::$err = $e->getMessage();
|
self::$err = $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|||||||
+184
-20
@@ -5,7 +5,10 @@
|
|||||||
* 仅用 PHP 内置函数生成最小可用的 .xlsx 文件:
|
* 仅用 PHP 内置函数生成最小可用的 .xlsx 文件:
|
||||||
* - 有 zip 扩展时走 ZipArchive;无 zip 扩展时回退到"手写 ZIP(stored)"实现,保证任何环境都能导出
|
* - 有 zip 扩展时走 ZipArchive;无 zip 扩展时回退到"手写 ZIP(stored)"实现,保证任何环境都能导出
|
||||||
* - 单元格统一按「共享字符串 + 文本格式(numFmtId=49)」写入,学号/账号等长数字不会被转成科学计数法
|
* - 单元格统一按「共享字符串 + 文本格式(numFmtId=49)」写入,学号/账号等长数字不会被转成科学计数法
|
||||||
* - 第一行固定为表头(加粗 + 居中)
|
* - 表头行(默认第 1 行,可用 header_rows 指定多行)加粗 + 居中 + 边框
|
||||||
|
* - 支持合并单元格(merges),被合并的非首格写「空值 + 同样的样式」,保证合并区域四周边框完整
|
||||||
|
* - 支持单元格级样式(cell_styles):按 [行,列] 指定「红字 / 绿底」等标记,
|
||||||
|
* 用于"每场最高分标红、合格次数达标标绿"
|
||||||
*/
|
*/
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
@@ -17,13 +20,26 @@ final class XlsxWriter
|
|||||||
private array $strings = [];
|
private array $strings = [];
|
||||||
/** @var array<int,int> 索引 => 出现次数(sst 的 count 用,区别于 uniqueCount) */
|
/** @var array<int,int> 索引 => 出现次数(sst 的 count 用,区别于 uniqueCount) */
|
||||||
private array $strCount = [];
|
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 = [];
|
private array $sheets = [];
|
||||||
|
|
||||||
// 样式索引(见 stylesXml)
|
// 样式索引(见 stylesXml)
|
||||||
private const S_DEFAULT = 0; // 常规
|
private const S_DEFAULT = 0; // 常规(无边框)
|
||||||
private const S_HEADER = 1; // 表头:加粗 + 居中
|
private const S_HEADER = 1; // 表头:加粗 + 居中 + 细边框
|
||||||
private const S_TEXT = 2; // 文本格式(numFmtId=49)
|
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 二进制内容(兼容旧调用)
|
* 生成单表 xlsx 二进制内容(兼容旧调用)
|
||||||
@@ -48,7 +64,11 @@ final class XlsxWriter
|
|||||||
* 因此既不会出现长学号被转成科学计数法,也不会把以 = + - @ 开头的内容
|
* 因此既不会出现长学号被转成科学计数法,也不会把以 = + - @ 开头的内容
|
||||||
* 当成公式执行(天然规避 CSV 公式注入问题)。
|
* 当成公式执行(天然规避 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 文件二进制
|
* @return string xlsx 文件二进制
|
||||||
*/
|
*/
|
||||||
public static function renderMulti(array $sheets): string
|
public static function renderMulti(array $sheets): string
|
||||||
@@ -66,15 +86,90 @@ final class XlsxWriter
|
|||||||
'name' => self::safeSheetName((string)($sh['name'] ?? ''), count($w->sheets) + 1, $w->sheets),
|
'name' => self::safeSheetName((string)($sh['name'] ?? ''), count($w->sheets) + 1, $w->sheets),
|
||||||
'rows' => $rows,
|
'rows' => $rows,
|
||||||
'widths' => array_values((array)($sh['widths'] ?? [])),
|
'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 === []) {
|
if ($w->sheets === []) {
|
||||||
// 保底:至少输出一张空表
|
// 保底:至少输出一张空表
|
||||||
$w->sheets[] = ['name' => 'Sheet1', 'rows' => [['']], 'widths' => []];
|
$w->sheets[] = ['name' => 'Sheet1', 'rows' => [['']], 'widths' => [], 'merges' => [], 'header_rows' => 1, 'cell_styles' => []];
|
||||||
}
|
}
|
||||||
return $w->build();
|
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
|
private function build(): string
|
||||||
@@ -103,7 +198,15 @@ final class XlsxWriter
|
|||||||
$cells[$r][$c] = $this->intern((string)($val ?? ''));
|
$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'];
|
$names[] = $sh['name'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +268,7 @@ final class XlsxWriter
|
|||||||
return $i;
|
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;
|
$dim = 'A1:' . self::colName($maxCol) . $maxRow;
|
||||||
|
|
||||||
@@ -175,18 +278,49 @@ final class XlsxWriter
|
|||||||
$cols .= '<col min="' . $c . '" max="' . $c . '" width="' . $w . '" customWidth="1"/>';
|
$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 = '';
|
$data = '';
|
||||||
for ($r = 1; $r <= $maxRow; $r++) {
|
for ($r = 1; $r <= $maxRow; $r++) {
|
||||||
|
$baseStyle = $r <= $headerRows ? self::S_HEADER : self::S_TEXT;
|
||||||
$line = '';
|
$line = '';
|
||||||
$has = false;
|
$has = false;
|
||||||
for ($c = 1; $c <= $maxCol; $c++) {
|
for ($c = 1; $c <= $maxCol; $c++) {
|
||||||
if (!isset($cells[$r][$c])) {
|
$sw = isset($swallowed[$r][$c]);
|
||||||
|
if (!isset($cells[$r][$c]) && !$sw) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$has = true;
|
$has = true;
|
||||||
$s = $r === 1 ? self::S_HEADER : self::S_TEXT;
|
$ref = self::colName($c) . $r;
|
||||||
$line .= '<c r="' . self::colName($c) . $r . '" s="' . $s . '" t="s"><v>' .
|
// 单元格级标记(红字 / 绿底)优先;合并区间跟随左上角首格的样式,
|
||||||
$cells[$r][$c] . '</v></c>';
|
// 否则"合并成一格但只有左边半截有底色"会很难看。
|
||||||
|
$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) {
|
if (!$has) {
|
||||||
continue;
|
continue;
|
||||||
@@ -194,6 +328,15 @@ final class XlsxWriter
|
|||||||
$data .= '<row r="' . $r . '" spans="1:' . $maxCol . '">' . $line . '</row>';
|
$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" .
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||||
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
|
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
|
||||||
'<dimension ref="' . $dim . '"/>' .
|
'<dimension ref="' . $dim . '"/>' .
|
||||||
@@ -201,6 +344,7 @@ final class XlsxWriter
|
|||||||
'<sheetFormatPr defaultRowHeight="13.5"/>' .
|
'<sheetFormatPr defaultRowHeight="13.5"/>' .
|
||||||
($cols !== '' ? '<cols>' . $cols . '</cols>' : '') .
|
($cols !== '' ? '<cols>' . $cols . '</cols>' : '') .
|
||||||
'<sheetData>' . $data . '</sheetData>' .
|
'<sheetData>' . $data . '</sheetData>' .
|
||||||
|
$mergeXml .
|
||||||
'</worksheet>';
|
'</worksheet>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,18 +417,38 @@ final class XlsxWriter
|
|||||||
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
|
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
|
||||||
// numFmts: 49 = @(文本)
|
// numFmts: 49 = @(文本)
|
||||||
'<numFmts count="1"><numFmt numFmtId="49" formatCode="@"/></numFmts>' .
|
'<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><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><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>' .
|
'</fonts>' .
|
||||||
'<fills count="2"><fill><patternFill patternType="none"/></fill>' .
|
// 填充:0 none / 1 gray125(Excel 约定占位)/ 2 合格绿底 / 3 表头绿底
|
||||||
'<fill><patternFill patternType="gray125"/></fill></fills>' .
|
// 绿色沿用导出截图里的浅绿 (#63C384 系):既醒目又不至于盖住黑色文字
|
||||||
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' .
|
'<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>' .
|
'<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="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="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="0" xfId="0" applyNumberFormat="1"><alignment vertical="center"/></xf>' . // 2 文本
|
'<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>' .
|
'</cellXfs>' .
|
||||||
'<cellStyles count="1"><cellStyle name="常规" xfId="0" builtinId="0"/></cellStyles>' .
|
'<cellStyles count="1"><cellStyle name="常规" xfId="0" builtinId="0"/></cellStyles>' .
|
||||||
'</styleSheet>';
|
'</styleSheet>';
|
||||||
|
|||||||
@@ -0,0 +1,853 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 多考试导出:成绩对比矩阵 + 「每次考试」块表的纯逻辑(不依赖卷王客户端,便于单测)
|
||||||
|
*
|
||||||
|
* 两种版式:
|
||||||
|
* 1) 成绩对比(ny_assemble_multi_exam_matrix):一行一个学生、一列一场考试,只取最新 / 最高分
|
||||||
|
* 2) 每次考试块表(ny_assemble_block_sheet):两行表头,一行一次考试,学号 / 姓名 / 班级 / 组别
|
||||||
|
* 纵向合并单元格,没考的考试合并成「未考」一格(见需求截图版式)
|
||||||
|
*
|
||||||
|
* 说明:本文件刻意只用 PHP 7.0+ 语法(不用箭头函数 / 数字分隔符),
|
||||||
|
* 便于在只有 PHP 7.x 的机器上直接跑回归测试。
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取某个学生在某场考试里的"单元格成绩"。
|
||||||
|
*
|
||||||
|
* - highest:取真实最高分(跨全部尝试,含重考),即聚合行的 best_score
|
||||||
|
* - latest :取 attempts_list 中「最近一次非暂存提交」的成绩;
|
||||||
|
* attempts_list 全为暂存草稿时退回最后一份;列表缺失时退回代表行 score
|
||||||
|
*
|
||||||
|
* 与单考试"按最新成绩"导出口径完全一致(跳过 temp_save 草稿,避免导出的"最新成绩"是草稿分)。
|
||||||
|
*
|
||||||
|
* @return float|null 未提交 / 无成绩返回 null
|
||||||
|
*/
|
||||||
|
function ny_exam_cell_score(array $r, string $mode): ?float
|
||||||
|
{
|
||||||
|
if ($mode === 'highest') {
|
||||||
|
$v = $r['best_score'] ?? null;
|
||||||
|
return ($v === null || $v === '') ? null : (float)$v;
|
||||||
|
}
|
||||||
|
// 最近一次非暂存提交
|
||||||
|
$list = array_values((array)($r['attempts_list'] ?? []));
|
||||||
|
$last = null;
|
||||||
|
for ($i = count($list) - 1; $i >= 0; $i--) {
|
||||||
|
if (empty($list[$i]['temp_save'])) {
|
||||||
|
$last = $list[$i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($last === null && $list !== []) {
|
||||||
|
$last = $list[count($list) - 1]; // 全是暂存时退回最后一份
|
||||||
|
}
|
||||||
|
if ($last === null) {
|
||||||
|
$v = $r['score'] ?? null; // 无明细:退回代表行
|
||||||
|
return ($v === null || $v === '') ? null : (float)$v;
|
||||||
|
}
|
||||||
|
$v = $last['score'] ?? null;
|
||||||
|
return ($v === null || $v === '') ? null : (float)$v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把多场考试已构建好的结果,组装成"成绩对比"二维矩阵。
|
||||||
|
*
|
||||||
|
* @param array $exams 关联数组 pid => ['name'=>string, 'rows'=>array](保持插入顺序即考试展示顺序)
|
||||||
|
* @param string $scoreMode 'latest' | 'highest'
|
||||||
|
* @return array{head:array,rows:array,exam_order:array,exam_names:array}
|
||||||
|
*/
|
||||||
|
function ny_assemble_multi_exam_matrix(array $exams, string $scoreMode): array
|
||||||
|
{
|
||||||
|
$examOrder = [];
|
||||||
|
$examNames = [];
|
||||||
|
$scoreByExam = []; // pid => [sid => float|null]
|
||||||
|
$nameBySid = []; // sid => 姓名(首个非空)
|
||||||
|
|
||||||
|
foreach ($exams as $pid => $ex) {
|
||||||
|
$examOrder[] = $pid;
|
||||||
|
$examNames[$pid] = trim((string)($ex['name'] ?? '')) !== '' ? (string)$ex['name'] : (string)$pid;
|
||||||
|
$map = [];
|
||||||
|
foreach ((array)($ex['rows'] ?? []) as $r) {
|
||||||
|
$sid = trim((string)($r['sid'] ?? ''));
|
||||||
|
if ($sid === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($nameBySid[$sid]) && trim((string)($r['name'] ?? '')) !== '') {
|
||||||
|
$nameBySid[$sid] = (string)$r['name'];
|
||||||
|
}
|
||||||
|
$map[$sid] = ny_exam_cell_score($r, $scoreMode);
|
||||||
|
}
|
||||||
|
$scoreByExam[$pid] = $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 学生并集(在任一选中考试里有提交即纳入)
|
||||||
|
$sidSet = [];
|
||||||
|
foreach ($scoreByExam as $map) {
|
||||||
|
foreach ($map as $sid => $_) {
|
||||||
|
$sidSet[$sid] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$sids = array_keys($sidSet);
|
||||||
|
// 默认按学号排序(数值优先,否则自然序)
|
||||||
|
usort($sids, static function ($a, $b): int {
|
||||||
|
return ny_sid_cmp((string)$a, (string)$b);
|
||||||
|
});
|
||||||
|
|
||||||
|
$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;
|
||||||
|
$line[] = ($sc === null) ? '' : $sc;
|
||||||
|
}
|
||||||
|
$rows[] = $line;
|
||||||
|
}
|
||||||
|
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 │考试名3 │ ← 第 1 行:考试名
|
||||||
|
* ├──────┼──────┼────────┼────────┼────────┼────────┤
|
||||||
|
* │ │ │ │ 成绩 │ 成绩 │ 成绩 │ ← 第 2 行(前导列纵向贯穿两行)
|
||||||
|
* ├──────┼──────┼────────┼────────┼────────┼────────┤
|
||||||
|
* │24030…│ 张三 │ 1 │ 127 │ 118 │ 134 │
|
||||||
|
* │(合并)│(合并)│ 2 │ 132 │ 121 │ 136 │ ← 最高分用红字
|
||||||
|
* │ │ │ … │ … │ … │ … │
|
||||||
|
* │ │ │合格次数│ 2 │ 0 │ 1 │ ← 达标绿底,未达标不标底
|
||||||
|
* └──────┴──────┴────────┴────────┴────────┴────────┘
|
||||||
|
*
|
||||||
|
* 约定:
|
||||||
|
* - 一名学生占 max(各场有效次数) + 1 行(+1 是末尾的「合格次数」汇总行)
|
||||||
|
* - 学号 / 姓名 两列按学生块纵向合并;「第几次」逐行写各自次序号(1、2、3…),**不合并**
|
||||||
|
* (合并会把 2、3 吞掉只留 1,见截图反馈)
|
||||||
|
* - 每场考试占 **1 列**:第 1 行考试名、第 2 行「成绩」小标题
|
||||||
|
* (历史上每场占 2 列,第 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------------- 列布局 ----------------
|
||||||
|
前导列:班级? / 组别? / 学号 / 姓名 / 第几次(各考试共用一列)
|
||||||
|
考试区:每场 1 列(第 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每场考试占 1 列:第 1 行考试名、第 2 行「成绩」小标题(无需横向合并)
|
||||||
|
$examCol = [];
|
||||||
|
$c = $colExam0;
|
||||||
|
foreach ($examOrder as $pid) {
|
||||||
|
$examCol[$pid] = $c;
|
||||||
|
$h1[] = $examNames[$pid];
|
||||||
|
$h2[] = '成绩';
|
||||||
|
$c += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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[] = ''; // 该生这场没考到第 i 次 → 留空
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$line[] = $a['score'];
|
||||||
|
// 最高分标红:只标一处(同分只标最早达成的那一行)
|
||||||
|
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];
|
||||||
|
// 「第几次」不合并:每行是不同的次序号(合并只剩第一行的 1)
|
||||||
|
}
|
||||||
|
// 完全没考的考试:该场该列 × 学生块高 → 合并成「未考」
|
||||||
|
foreach ($examOrder as $pid) {
|
||||||
|
if ($lists[$pid] !== []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cc = $examCol[$pid];
|
||||||
|
$rows[$start - 1][$cc - 1] = $absent;
|
||||||
|
if ($end > $start) { // 单行块无需合并(1×1 合并是非法区间,Excel 会提示修复)
|
||||||
|
$merges[] = [$start, $cc, $end, $cc];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 考试列每场 1 列,表头两行各写「考试名 / 成绩」,无需合并
|
||||||
|
|
||||||
|
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[] = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$line[] = $a['score'];
|
||||||
|
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;
|
||||||
|
if ($rn > $start) { // 单行块无需合并(1×1 合并是非法区间)
|
||||||
|
$merges[] = [$start, $cx, $rn, $cx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合格次数行(每个学生块一行)
|
||||||
|
$rn++;
|
||||||
|
$passLineRow = [];
|
||||||
|
if ($withCls) { $passLineRow[] = ''; }
|
||||||
|
if ($withGrp) { $passLineRow[] = ''; }
|
||||||
|
$passLineRow[] = '';
|
||||||
|
$passLineRow[] = '';
|
||||||
|
$passLineRow[] = '合格次数'; // 落在「第几次」列,与截图一致
|
||||||
|
foreach ($examOrder as $pid) {
|
||||||
|
$list = $lists[$pid];
|
||||||
|
if ($list === []) {
|
||||||
|
// 整场未考:与「未考」合并格保持一致,不再单独判断合格次数
|
||||||
|
$passLineRow[] = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cnt = 0;
|
||||||
|
foreach ($list as $a) {
|
||||||
|
if ($a['score'] !== '' && $a['score'] !== null && (float)$a['score'] >= $passLine) {
|
||||||
|
$cnt++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* 合格次数始终显示(含 0 次);只有达到阈值才标绿底。
|
||||||
|
之前写 '' 会把「考了但没达标」的人整格隐藏,用户要看全部人次。 */
|
||||||
|
$passLineRow[] = (string)$cnt;
|
||||||
|
if ($cnt >= $passTimes) {
|
||||||
|
$marks[$rn . ',' . $examCol[$pid]] = 'green'; // 达标:绿底
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$rows[] = $passLineRow;
|
||||||
|
$total++;
|
||||||
|
|
||||||
|
/* 学号 / 姓名 纵向合并到「合格次数」行(这两列该行本就是空的,合并正确);
|
||||||
|
「第几次」列逐行是 1、2、3…,不合并——末行「合格次数」标签也写在这一列。 */
|
||||||
|
$merges[] = [$start, $colSid, $rn, $colSid];
|
||||||
|
$merges[] = [$start, $colName, $rn, $colName];
|
||||||
|
$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
|
||||||
|
- 姓名同样按最长姓名估宽
|
||||||
|
- 每场考试只占 1 列,列宽要装得下该列表头(考试名),中文按 2 个半角宽算 */
|
||||||
|
$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));
|
||||||
|
|
||||||
|
// 考试列:取最长的考试名估一个统一宽度
|
||||||
|
$examW = 8;
|
||||||
|
foreach ($examOrder as $pid) {
|
||||||
|
$nm = (string)$examNames[$pid];
|
||||||
|
$len = mb_strlen($nm, 'UTF-8');
|
||||||
|
$w = 0;
|
||||||
|
for ($k = 0; $k < $len; $k++) {
|
||||||
|
$w += (strlen((string)mb_substr($nm, $k, 1, 'UTF-8')) > 1) ? 2 : 1;
|
||||||
|
}
|
||||||
|
$w += 2; // 左右留白
|
||||||
|
if ($w > $examW) {
|
||||||
|
$examW = $w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$examW = min(18, max(8, $examW));
|
||||||
|
|
||||||
|
$widths = [];
|
||||||
|
if ($withCls) {
|
||||||
|
$widths[] = 14; // 班级名通常较长(如「计算机2401」)
|
||||||
|
}
|
||||||
|
if ($withGrp) {
|
||||||
|
$widths[] = 9;
|
||||||
|
}
|
||||||
|
$widths[] = $sidW; // 学号
|
||||||
|
$widths[] = $nameW; // 姓名
|
||||||
|
$widths[] = 10; // 第几次(同时承载「合格次数」标签)
|
||||||
|
foreach ($examOrder as $pid) {
|
||||||
|
$widths[] = $examW; // 每场考试 1 列(列宽装得下表头考试名)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -10,13 +10,17 @@ require __DIR__ . '/bootstrap.php';
|
|||||||
// 先加载配置:下面已登录分支要用 $cfg 构造 SurveyKingClient 探活,必须在前面就准备好
|
// 先加载配置:下面已登录分支要用 $cfg 构造 SurveyKingClient 探活,必须在前面就准备好
|
||||||
$cfg = ny_load_config();
|
$cfg = ny_load_config();
|
||||||
|
|
||||||
|
$notice = ''; // 非阻断提示(例如"上次会话已失效,请重新登录")
|
||||||
|
|
||||||
if (ny_is_auth()) {
|
if (ny_is_auth()) {
|
||||||
// 已登录但卷王 token 可能已过期:用当前 token 探一次卷王 /system,能 200 即视为有效,否则清掉本地会话强制重新登录
|
// 已登录但卷王 token 可能已过期:用当前 token 探一次卷王,能通即视为有效,否则清掉本地会话强制重新登录
|
||||||
try {
|
try {
|
||||||
$client = new SurveyKingClient($cfg);
|
$client = new SurveyKingClient($cfg);
|
||||||
$client->ping();
|
$client->ping();
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
// 卷王 token 已失效或配置错误 → 清会话 + 强制显示登录页(避免 login.php 把人踢回 index.php 形成死循环)
|
// 卷王 token 已失效或配置错误 → 清会话 + 强制显示登录页(避免 login.php 把人踢回 index.php 形成死循环)
|
||||||
|
// 把失败原因带出来,避免用户只看到登录框却不知道为什么被踢回来
|
||||||
|
$notice = '已退出之前的登录(' . $e->getMessage() . ')';
|
||||||
$_SESSION = [];
|
$_SESSION = [];
|
||||||
@session_destroy();
|
@session_destroy();
|
||||||
}
|
}
|
||||||
@@ -43,13 +47,36 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
try {
|
try {
|
||||||
$client = new SurveyKingClient($test);
|
$client = new SurveyKingClient($test);
|
||||||
$client->getToken(true);
|
$client->getToken(true);
|
||||||
|
|
||||||
// 登录成功:把账号与密码写回配置(本地单用户工具,持久化密码以便 token
|
// 登录成功:把账号与密码写回配置(本地单用户工具,持久化密码以便 token
|
||||||
// 失效时平台能自动重新登录卷王,避免后台刷新陷入"未配置账号"的死状态)
|
// 失效时平台能自动重新登录卷王,避免后台刷新陷入"未配置账号"的死状态)
|
||||||
$cfg['username'] = $user;
|
$cfg['username'] = $user;
|
||||||
$cfg['password'] = $pwd;
|
$cfg['password'] = $pwd;
|
||||||
ny_save_config($cfg);
|
$saved = ny_save_config($cfg);
|
||||||
|
|
||||||
|
// 关键:必须回读确认真的存住了。以前这里不看返回值,data 目录不可写时
|
||||||
|
// 会显示"登录成功",但下一个请求读到的账号仍是空的 → 无限跳回登录页。
|
||||||
|
$back = ny_load_config();
|
||||||
|
$persistOk = $saved
|
||||||
|
&& trim((string)($back['username'] ?? '')) === $user
|
||||||
|
&& (string)($back['password'] ?? '') === $pwd;
|
||||||
|
|
||||||
|
if (!$persistOk) {
|
||||||
|
$d = ny_config_diag();
|
||||||
|
$err = "登录卷王成功,但凭据没能保存到本机配置,继续进入会立刻又提示“未配置账号”。\n"
|
||||||
|
. "请检查 data 目录写权限后重试:\n"
|
||||||
|
. "· 配置来源:" . $d['storage'] . "(可写=" . ($d['cfg_writable'] ? '是' : '否') . ")\n"
|
||||||
|
. "· 数据目录:" . $d['data_dir'] . "(可写=" . ($d['dir_writable'] ? '是' : '否') . ")\n"
|
||||||
|
. "· 数据库:" . ($d['db_connected'] ? '已连接(可写=' . ($d['db_writable'] ? '是' : '否') . ')' : '未连接 ' . $d['db_error']) . "\n"
|
||||||
|
. "· 保存错误:" . ($d['save_error'] !== '' ? $d['save_error'] : '(无)');
|
||||||
|
} elseif (!ny_session_usable($sessWhy)) {
|
||||||
|
// 会话存不住时同样会"登录成功→立刻被踢回登录页",必须在这一步拦住
|
||||||
|
$err = "凭据已保存,但 PHP 会话不可用,登录状态无法保持:\n" . $sessWhy
|
||||||
|
. "\n请在 php.ini 中把 session.save_path 指向一个可写目录后重试。";
|
||||||
|
} else {
|
||||||
ny_login();
|
ny_login();
|
||||||
$ok = true;
|
$ok = true;
|
||||||
|
}
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$err = '登录失败:' . $e->getMessage();
|
$err = '登录失败:' . $e->getMessage();
|
||||||
}
|
}
|
||||||
@@ -75,9 +102,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="login-tip">系统连接信息已配置(<?= htmlspecialchars(parse_url($cfg['base_url'], PHP_URL_HOST)) ?>/<?= htmlspecialchars(trim($cfg['api_prefix'], '/')) ?>)。请输入账号密码完成登录。</p>
|
<p class="login-tip">系统连接信息已配置(<?= htmlspecialchars(parse_url($cfg['base_url'], PHP_URL_HOST)) ?>/<?= htmlspecialchars(trim($cfg['api_prefix'], '/')) ?>)。请输入账号密码完成登录。</p>
|
||||||
|
<?php if (trim((string)$cfg['username']) === ''): ?>
|
||||||
|
<div class="alert" style="white-space:pre-wrap">⚠️ 本机尚未保存卷王账号(配置来源:<?= htmlspecialchars(ny_config_source()) ?>)。
|
||||||
|
如果每次登录成功都回到本页,多半是 data 目录不可写导致账号没存住——
|
||||||
|
请让 data/ 目录(<?= htmlspecialchars(NY_DATA) ?>)对 PHP 进程可写后重试。</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($err !== ''): ?>
|
<?php if ($err !== ''): ?>
|
||||||
<div class="alert" style="white-space:pre-wrap"><?= htmlspecialchars($err) ?></div>
|
<div class="alert" style="white-space:pre-wrap"><?= htmlspecialchars($err) ?></div>
|
||||||
|
<?php elseif ($notice !== ''): ?>
|
||||||
|
<div class="alert" style="white-space:pre-wrap"><?= htmlspecialchars($notice) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
@@ -96,7 +130,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
<div class="login-brand" style="justify-content:center"><span class="logo">NY</span>
|
<div class="login-brand" style="justify-content:center"><span class="logo">NY</span>
|
||||||
<div class="brand-text"><strong>登录成功</strong><small>正在进入数据平台…</small></div>
|
<div class="brand-text"><strong>登录成功</strong><small>正在进入数据平台…</small></div>
|
||||||
</div>
|
</div>
|
||||||
<script>setTimeout(function(){ location.href='index.php'; }, 600);</script>
|
<script>
|
||||||
|
// 记录"本次登录成功"的时间戳:前端据此判断"刚登录完又报鉴权失败",
|
||||||
|
// 这种情况不再自动跳回登录页(否则会形成死循环),而是把真实错误显示出来。
|
||||||
|
try { sessionStorage.setItem('ny_login_at', String(Date.now())); sessionStorage.removeItem('ny_auth_jumps'); } catch (e) {}
|
||||||
|
setTimeout(function(){ location.href='index.php'; }, 600);
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
+23
-6
@@ -11,14 +11,17 @@ pick() {
|
|||||||
if [ -n "$cand" ] && [ -x "$cand" ]; then echo "$cand"; return; fi
|
if [ -n "$cand" ] && [ -x "$cand" ]; then echo "$cand"; return; fi
|
||||||
command -v "$fallback" 2>/dev/null || echo "$fallback"
|
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)"
|
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 "PHP = $PHP"
|
||||||
echo "NODE = $NODE"
|
echo "NODE = $NODE"
|
||||||
|
|
||||||
echo "================ PHP 自测 ================"
|
echo "================ PHP 自测 ================"
|
||||||
for f in tests/test-aggregate.php tests/test-new-features.php tools/test-xlsxwriter.php; do
|
for f in tests/test-aggregate.php tests/test-new-features.php tests/test-config-persist.php tools/test-xlsxwriter.php tests/test-multi-export.php; do
|
||||||
echo ""
|
echo ""
|
||||||
echo ">>> $f"
|
echo ">>> $f"
|
||||||
"$PHP" $INI "$f"
|
"$PHP" $INI "$f"
|
||||||
@@ -30,15 +33,29 @@ echo "================ Node e2e(需 mock-server + PHP 内置服务器) =====
|
|||||||
# 彻底避免污染生产 data/ny.db(曾因此把登录页连接信息写成 127.0.0.1)。
|
# 彻底避免污染生产 data/ny.db(曾因此把登录页连接信息写成 127.0.0.1)。
|
||||||
NY_DATA_TMP="$(pwd)/.ny-test-data"
|
NY_DATA_TMP="$(pwd)/.ny-test-data"
|
||||||
mkdir -p "$NY_DATA_TMP"
|
mkdir -p "$NY_DATA_TMP"
|
||||||
for f in tools/test-config-save.js tools/test-upload-guard.js tools/test-whitelist.js; do
|
# 说明:test-login-loop.js 会清空数据目录里的账密来复现"未配置账号"现场,
|
||||||
|
# 即使不传 NY_DATA 它也会自己建临时目录,绝不回退到项目 data/。
|
||||||
|
# 这几段依赖 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 ""
|
||||||
echo ">>> $f (NY_DATA=$NY_DATA_TMP)"
|
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
|
done
|
||||||
# test-draw-alloc.js 不读写连接配置,沿用项目 data/ 即可
|
# test-draw-alloc.js 不读写连接配置,沿用项目 data/ 即可
|
||||||
echo ""
|
echo ""
|
||||||
echo ">>> tools/test-draw-alloc.js"
|
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 ""
|
echo ""
|
||||||
|
if [ "$e2e_fail" -gt 0 ]; then
|
||||||
|
echo "全部测试跑完,但有 $e2e_fail 个 Node 段失败(多为 mock-server 缺 data/roster.json 导致的环境问题)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo "全部测试完成。"
|
echo "全部测试完成。"
|
||||||
|
|||||||
@@ -89,6 +89,75 @@ $r4 = $agg4['rows'][0];
|
|||||||
check('单次提交 attempts = 1', ($r4['attempts'] ?? 0) === 1);
|
check('单次提交 attempts = 1', ($r4['attempts'] ?? 0) === 1);
|
||||||
check('单次提交 attempts_list = 1', count($r4['attempts_list'] ?? []) === 1);
|
check('单次提交 attempts_list = 1', count($r4['attempts_list'] ?? []) === 1);
|
||||||
|
|
||||||
|
echo "\n== 按提交时间年月日分组:每个分类内标记当日最高 ==\n";
|
||||||
|
/** 按 [时间戳, 分数] 造行;ts=0 表示时间未知 */
|
||||||
|
function makeRowsAt(string $sid, array $items): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
foreach ($items as $it) {
|
||||||
|
[$ts, $sc] = $it;
|
||||||
|
$rows[] = [
|
||||||
|
'sid' => $sid,
|
||||||
|
'name' => '测试生',
|
||||||
|
'cls' => '1班',
|
||||||
|
'grp' => 'A',
|
||||||
|
'score' => $sc,
|
||||||
|
'submit_ts' => $ts,
|
||||||
|
'submit_at' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : '-',
|
||||||
|
'duration' => 120,
|
||||||
|
'duration_txt' => '2分0秒',
|
||||||
|
'answer_id' => 'ans-' . $ts,
|
||||||
|
'matched' => true,
|
||||||
|
'temp_save' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
// 09-08 三次(70/95/88),09-09 两次(60/90)
|
||||||
|
$rows5 = makeRowsAt('S005', [
|
||||||
|
[strtotime('2026-09-08 09:00:00'), 70.0],
|
||||||
|
[strtotime('2026-09-08 11:00:00'), 95.0],
|
||||||
|
[strtotime('2026-09-08 15:00:00'), 88.0],
|
||||||
|
[strtotime('2026-09-09 10:00:00'), 60.0],
|
||||||
|
[strtotime('2026-09-09 14:00:00'), 90.0],
|
||||||
|
]);
|
||||||
|
$agg5 = DataService::aggregateByStudent($rows5, true);
|
||||||
|
$r5 = $agg5['rows'][0];
|
||||||
|
$al5 = $r5['attempts_list'];
|
||||||
|
check('5 次提交 attempts_list = 5', count($al5) === 5);
|
||||||
|
check('前 3 条归属 2026-09-08', ($al5[0]['day'] ?? '') === '2026-09-08' && ($al5[2]['day'] ?? '') === '2026-09-08',
|
||||||
|
'got ' . ($al5[0]['day'] ?? '?'));
|
||||||
|
check('后 2 条归属 2026-09-09', ($al5[3]['day'] ?? '') === '2026-09-09' && ($al5[4]['day'] ?? '') === '2026-09-09',
|
||||||
|
'got ' . ($al5[3]['day'] ?? '?'));
|
||||||
|
check('09-08 当天次数 = 3', ($al5[0]['day_count'] ?? 0) === 3, 'got ' . ($al5[0]['day_count'] ?? '?'));
|
||||||
|
check('09-09 当天次数 = 2', ($al5[3]['day_count'] ?? 0) === 2, 'got ' . ($al5[3]['day_count'] ?? '?'));
|
||||||
|
$dayBestIdx = array_values(array_filter($al5, fn($a) => !empty($a['day_best'])));
|
||||||
|
check('每个分类各标记 1 个当日最高(共 2 个)', count($dayBestIdx) === 2, 'got ' . count($dayBestIdx));
|
||||||
|
check('09-08 当日最高 = 95(第 2 次)', (int)($dayBestIdx[0]['idx'] ?? 0) === 2 && (float)($dayBestIdx[0]['score'] ?? -1) === 95.0,
|
||||||
|
'got idx=' . ($dayBestIdx[0]['idx'] ?? '?') . ' score=' . ($dayBestIdx[0]['score'] ?? '?'));
|
||||||
|
check('09-09 当日最高 = 90(第 5 次)', (int)($dayBestIdx[1]['idx'] ?? 0) === 5 && (float)($dayBestIdx[1]['score'] ?? -1) === 90.0,
|
||||||
|
'got idx=' . ($dayBestIdx[1]['idx'] ?? '?') . ' score=' . ($dayBestIdx[1]['score'] ?? '?'));
|
||||||
|
check('全局最佳仍是 95(第 2 次)', (int)($al5[1]['is_best'] ?? 0) === 1 || !empty($al5[1]['is_best']));
|
||||||
|
|
||||||
|
echo "\n== 时间未知(submit_ts=0)归入「未知时间」分组 ==\n";
|
||||||
|
$rows6 = makeRowsAt('S006', [[0, 50.0], [0, 80.0]]);
|
||||||
|
$agg6 = DataService::aggregateByStudent($rows6, true);
|
||||||
|
$al6 = $agg6['rows'][0]['attempts_list'];
|
||||||
|
check('时间未知时 day = 空串', ($al6[0]['day'] ?? 'x') === '' && ($al6[1]['day'] ?? 'x') === '',
|
||||||
|
'got ' . var_export($al6[0]['day'] ?? null, true));
|
||||||
|
check('未知时间组次数 = 2', ($al6[0]['day_count'] ?? 0) === 2, 'got ' . ($al6[0]['day_count'] ?? '?'));
|
||||||
|
check('未知时间组内仍标记最高(80)', !empty($al6[1]['day_best']) && (float)($al6[1]['score'] ?? -1) === 80.0);
|
||||||
|
|
||||||
|
echo "\n== 日期键时区边界(不能用 UTC,否则零点前后错一天)==\n";
|
||||||
|
check('00:30 仍算当天(东八区不会退到前一天)',
|
||||||
|
DataService::dayKeyOf((int)strtotime('2026-09-08 00:30:00')) === '2026-09-08',
|
||||||
|
'got ' . DataService::dayKeyOf((int)strtotime('2026-09-08 00:30:00')));
|
||||||
|
check('23:30 仍算当天',
|
||||||
|
DataService::dayKeyOf((int)strtotime('2026-09-08 23:30:00')) === '2026-09-08',
|
||||||
|
'got ' . DataService::dayKeyOf((int)strtotime('2026-09-08 23:30:00')));
|
||||||
|
check('无时间戳时从展示串取日期', DataService::dayKeyOf(0, '2026-09-08 09:41:20') === '2026-09-08');
|
||||||
|
check('无时间戳且展示串为 - 时返回空串', DataService::dayKeyOf(0, '-') === '');
|
||||||
|
|
||||||
echo "\n========================================\n";
|
echo "\n========================================\n";
|
||||||
echo "通过 {$pass} · 失败 {$fail}\n";
|
echo "通过 {$pass} · 失败 {$fail}\n";
|
||||||
exit($fail === 0 ? 0 : 1);
|
exit($fail === 0 ? 0 : 1);
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 配置落盘 / 鉴权死循环 回归测试
|
||||||
|
*
|
||||||
|
* 背景:登录页"登录成功"后立刻又报"未配置卷王登录账号"并反复跳回登录页。
|
||||||
|
* 根因是 ny_save_config 只写不校验(Db::settingsSet 内部吞异常且返回 void),
|
||||||
|
* data/ 目录不可写时上层以为保存成功,下一个请求读到的账号仍是空的。
|
||||||
|
*
|
||||||
|
* 覆盖:
|
||||||
|
* 1) ny_save_config 必须回读校验并真实反映落盘结果
|
||||||
|
* 2) ny_load_config 把 null 账号强制成空字符串(历史脏数据不再伪装成"未配置")
|
||||||
|
* 3) ny_apply_config_input:账号/密码不许被设置页误清空,连接信息才触发重连
|
||||||
|
* 4) ny_config_diag 能给出可自查的诊断字段
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// 必须赶在 bootstrap 之前把数据目录重定向到临时目录,避免污染生产 data/ny.db
|
||||||
|
$tmpDir = sys_get_temp_dir() . '/ny-cfgtest-' . getmypid() . '-' . bin2hex(random_bytes(3));
|
||||||
|
putenv('NY_DATA=' . $tmpDir);
|
||||||
|
$_ENV['NY_DATA'] = $tmpDir;
|
||||||
|
require __DIR__ . '/../bootstrap.php';
|
||||||
|
|
||||||
|
register_shutdown_function(static function () use ($tmpDir): void {
|
||||||
|
// 清理临时数据目录(Windows 下先删文件再删目录)
|
||||||
|
if (!is_dir($tmpDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (glob($tmpDir . '/*') ?: [] as $f) {
|
||||||
|
@unlink($f);
|
||||||
|
}
|
||||||
|
@rmdir($tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
$pass = 0;
|
||||||
|
$fail = 0;
|
||||||
|
function check(string $name, bool $cond, string $detail = ''): void
|
||||||
|
{
|
||||||
|
global $pass, $fail;
|
||||||
|
if ($cond) { $pass++; echo " ✅ {$name}\n"; }
|
||||||
|
else { $fail++; echo " ❌ {$name} {$detail}\n"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n== 1) 保存后回读(模拟登录页写凭据) ==\n";
|
||||||
|
$cfg0 = ny_default_config();
|
||||||
|
$cfg0['username'] = 'teacher01';
|
||||||
|
$cfg0['password'] = 'P@ss w0rd';
|
||||||
|
$saved = ny_save_config($cfg0);
|
||||||
|
check('ny_save_config 返回 true', $saved === true, 'save_error=' . ny_config_save_error());
|
||||||
|
|
||||||
|
$back = ny_load_config();
|
||||||
|
check('回读账号一致', ($back['username'] ?? '') === 'teacher01', json_encode($back['username'] ?? null));
|
||||||
|
check('回读密码一致(含空格原样保留)', ($back['password'] ?? '') === 'P@ss w0rd', json_encode($back['password'] ?? null));
|
||||||
|
check('配置来源为 db 或 file', in_array(ny_config_source(), ['db', 'file'], true), ny_config_source());
|
||||||
|
check('无保存错误提示', ny_config_save_error() === '', ny_config_save_error());
|
||||||
|
|
||||||
|
echo "\n== 2) 脏数据兜底:账号被存成 null ==\n";
|
||||||
|
$dirty = $cfg0;
|
||||||
|
$dirty['username'] = null;
|
||||||
|
ny_write_json(ny_config_file(), $dirty);
|
||||||
|
// 强制走文件分支读取:把 DB 里的记录清掉
|
||||||
|
if (class_exists('Db') && Db::pdo() !== null) {
|
||||||
|
Db::settingsDelete('config');
|
||||||
|
}
|
||||||
|
$dirtyCfg = ny_load_config();
|
||||||
|
check('null 账号 → 空字符串而不是 null', $dirtyCfg['username'] === '', var_export($dirtyCfg['username'], true));
|
||||||
|
check('账号为字符串类型', is_string($dirtyCfg['username']));
|
||||||
|
|
||||||
|
echo "\n== 3) 设置保存:账号/密码不被误清空 ==\n";
|
||||||
|
$old = ny_default_config();
|
||||||
|
$old['username'] = 'admin';
|
||||||
|
$old['password'] = 'secret';
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['username' => '']);
|
||||||
|
check('账号传空 → 保留原账号(防止陷入未配置死循环)', $r['config']['username'] === 'admin', $r['config']['username']);
|
||||||
|
check('账号传空 → 不算连接信息变化', $r['conn_changed'] === false);
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['username' => null]);
|
||||||
|
check('账号传 null → 保留原账号', $r['config']['username'] === 'admin', var_export($r['config']['username'], true));
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['password' => '******']);
|
||||||
|
check('密码占位符 ****** → 不修改', $r['config']['password'] === 'secret');
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['password' => '']);
|
||||||
|
check('密码传空 → 不修改', $r['config']['password'] === 'secret');
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['password' => 'newpwd']);
|
||||||
|
check('密码传新值 → 更新', $r['config']['password'] === 'newpwd');
|
||||||
|
check('密码变化 → 标记连接信息已变', $r['conn_changed'] === true);
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['username' => 'admin2']);
|
||||||
|
check('账号变化 → 标记连接信息已变', $r['conn_changed'] === true);
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['full_score' => 90, 'page_size' => 50]);
|
||||||
|
check('仅改满分/分页 → 不触发重连', $r['conn_changed'] === false);
|
||||||
|
check('满分已写入', (int)$r['config']['full_score'] === 90);
|
||||||
|
check('未传的字段保持原值', (int)$r['config']['page_size'] === 50 && $r['config']['username'] === 'admin');
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['refresh_interval' => 1]);
|
||||||
|
check('刷新间隔最小值收敛到 3', (int)$r['config']['refresh_interval'] === 3);
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['base_url' => 'https://exam.nxxy335.top/']);
|
||||||
|
check('base_url 去掉末尾斜杠', $r['config']['base_url'] === 'https://exam.nxxy335.top', $r['config']['base_url']);
|
||||||
|
|
||||||
|
echo "\n== 4) 落盘诊断 ==\n";
|
||||||
|
$d = ny_config_diag();
|
||||||
|
check('诊断含 storage', isset($d['storage']));
|
||||||
|
check('诊断含目录可写标记', array_key_exists('dir_writable', $d));
|
||||||
|
check('诊断含配置文件可写标记', array_key_exists('cfg_writable', $d));
|
||||||
|
check('诊断含数据库可写标记', array_key_exists('db_writable', $d));
|
||||||
|
check('诊断含是否已存账号', array_key_exists('has_username', $d));
|
||||||
|
check('临时目录可写时 cfg_writable=true', $d['cfg_writable'] === true, json_encode($d, JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
echo "\n== 5) 数据库写入回读校验 ==\n";
|
||||||
|
if (class_exists('Db') && Db::pdo() !== null) {
|
||||||
|
check('Db::settingsSet 成功返回 true', Db::settingsSet('__ny_test_key', 'hello') === true);
|
||||||
|
check('Db::settingsGet 读回一致', Db::settingsGet('__ny_test_key', '') === 'hello');
|
||||||
|
Db::settingsDelete('__ny_test_key');
|
||||||
|
check('Db::settingsDelete 生效', Db::settingsGet('__ny_test_key', 'x') === 'x');
|
||||||
|
} else {
|
||||||
|
echo " ⚠️ 无 pdo_sqlite,跳过数据库回读用例\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n----------------------------------------\n";
|
||||||
|
echo "通过 {$pass} 项,失败 {$fail} 项\n";
|
||||||
|
exit($fail === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 多考试对比导出(成绩交叉矩阵)回归测试
|
||||||
|
*
|
||||||
|
* 覆盖 ny_exam_cell_score / ny_assemble_multi_exam_matrix:
|
||||||
|
* 1) 矩阵结构:第一行 = 考试名称,前两列 = 学号 / 姓名
|
||||||
|
* 2) 默认按学号升序
|
||||||
|
* 3) 最新成绩 = attempts_list 中最近一次「非暂存」提交;最高成绩 = 跨全部尝试的最高分
|
||||||
|
* 4) 草稿(temp_save)不计入"最新成绩",但计入"最高成绩"
|
||||||
|
* 5) 重考分数更低时,最新≠最高
|
||||||
|
* 6) 未考的考试列为空;学生并集(任一考试有提交即纳入)
|
||||||
|
* 7) 同一学号跨考试姓名以首个非空为准
|
||||||
|
*/
|
||||||
|
require __DIR__ . '/../lib/multi_export.php';
|
||||||
|
|
||||||
|
$fail = 0;
|
||||||
|
$pass = 0;
|
||||||
|
function check(string $name, bool $cond, string $detail = ''): void
|
||||||
|
{
|
||||||
|
global $fail, $pass;
|
||||||
|
if ($cond) {
|
||||||
|
$pass++;
|
||||||
|
echo " ✅ {$name}\n";
|
||||||
|
} else {
|
||||||
|
$fail++;
|
||||||
|
echo " ❌ {$name} {$detail}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造一名学生的聚合行(与 DataService::aggregateByStudent 产出的结构保持一致)。
|
||||||
|
* 分数按时间升序给出(旧 -> 新),tempMarks 标记对应提交是否为草稿。
|
||||||
|
*/
|
||||||
|
function stuRow(string $sid, string $name, array $scoresAsc, array $tempMarks = [], ?float $best = null): array
|
||||||
|
{
|
||||||
|
$list = [];
|
||||||
|
$t = 1000000; // 不用数字分隔符,保证 PHP 7.3+ 都能跑回归测试
|
||||||
|
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 ?? (float)max($scoresAsc),
|
||||||
|
'attempts' => count($scoresAsc),
|
||||||
|
'attempts_list'=> $list,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 场景 ---------------- */
|
||||||
|
$examMath = [
|
||||||
|
'name' => '数学期中',
|
||||||
|
'rows' => [
|
||||||
|
// 张三:第一次 90,重考降到 80(最新≠最高);草稿 95 只算最高
|
||||||
|
stuRow('2021001', '张三', [90.0, 80.0], [false, false], 90.0),
|
||||||
|
stuRow('2021002', '李四', [70.0]),
|
||||||
|
// 草稿分:time1 真实 60,time2 草稿 99 → 最新应取 60,最高取 99
|
||||||
|
stuRow('2021004', '赵六', [60.0, 99.0], [false, true], 99.0),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$examEnglish = [
|
||||||
|
'name' => '英语期中',
|
||||||
|
'rows' => [
|
||||||
|
stuRow('2021001', '张三', [85.0]),
|
||||||
|
stuRow('2021003', '王五', [60.0]),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
echo "== 最新成绩矩阵 ==\n";
|
||||||
|
$m = ny_assemble_multi_exam_matrix(['math' => $examMath, 'eng' => $examEnglish], 'latest');
|
||||||
|
check('表头第一列是学号', ($m['head'][0] ?? '') === '学号', json_encode($m['head']));
|
||||||
|
check('表头第二列是姓名', ($m['head'][1] ?? '') === '姓名');
|
||||||
|
check('表头第三列起是考试名称', ($m['head'][2] ?? '') === '数学期中' && ($m['head'][3] ?? '') === '英语期中', json_encode($m['head']));
|
||||||
|
check('考试列顺序保持选择顺序', $m['exam_order'] === ['math', 'eng']);
|
||||||
|
// 学号排序:2021001,2021002,2021003,2021004
|
||||||
|
// 注意:PHP 会把数字字符串键自动转成 int('2021001' -> 2021001),排序结果本身正确,
|
||||||
|
// 这里用 strval 归一后比较排序顺序(显示层 XlsxWriter 会再转回字符串,无影响)。
|
||||||
|
$sids = array_map('strval', array_column($m['rows'], 0));
|
||||||
|
check('按学号升序', $sids === ['2021001', '2021002', '2021003', '2021004'], json_encode($sids));
|
||||||
|
// 张三:数学最新=80(重考降分,跳过草稿),英语最新=85
|
||||||
|
$zhang = $m['rows'][0];
|
||||||
|
check('张三行:姓名正确', $zhang[1] === '张三');
|
||||||
|
check('张三-数学最新=80(重考降分)', (string)$zhang[2] === '80', (string)$zhang[2]);
|
||||||
|
check('张三-英语最新=85', (string)$zhang[3] === '85', (string)$zhang[3]);
|
||||||
|
// 李四只在数学:英语为空
|
||||||
|
$li = $m['rows'][1];
|
||||||
|
check('李四-数学=70', (string)$li[2] === '70', (string)$li[2]);
|
||||||
|
check('李四-英语为空', $li[3] === '', var_export($li[3], true));
|
||||||
|
// 王五只在英语:数学为空
|
||||||
|
$wang = $m['rows'][2];
|
||||||
|
check('王五-数学为空', $wang[2] === '', var_export($wang[2], true));
|
||||||
|
check('王五-英语=60', (string)$wang[3] === '60', (string)$wang[3]);
|
||||||
|
// 赵六:数学最新跳过草稿=60
|
||||||
|
$zhao = $m['rows'][3];
|
||||||
|
check('赵六-数学最新跳过草稿=60', (string)$zhao[2] === '60', (string)$zhao[2]);
|
||||||
|
|
||||||
|
echo "== 最高成绩矩阵 ==\n";
|
||||||
|
$mh = ny_assemble_multi_exam_matrix(['math' => $examMath, 'eng' => $examEnglish], 'highest');
|
||||||
|
$zhangH = $mh['rows'][0];
|
||||||
|
check('张三-数学最高=90(含重考)', (string)$zhangH[2] === '90', (string)$zhangH[2]);
|
||||||
|
check('张三-英语最高=85', (string)$zhangH[3] === '85', (string)$zhangH[3]);
|
||||||
|
$zhaoH = $mh['rows'][3];
|
||||||
|
check('赵六-数学最高含草稿=99', (string)$zhaoH[2] === '99', (string)$zhaoH[2]);
|
||||||
|
|
||||||
|
echo "== 跨考试姓名 / 空考试 ==\n";
|
||||||
|
// 仅一场考试时仍可正常产出;姓名取首个非空
|
||||||
|
$single = ny_assemble_multi_exam_matrix(['math' => $examMath], 'latest');
|
||||||
|
check('单场考试表头正确', $single['head'] === ['学号', '姓名', '数学期中']);
|
||||||
|
check('单场考试行数=3', count($single['rows']) === 3, (string)count($single['rows']));
|
||||||
|
|
||||||
|
echo "== ny_exam_cell_score 单测 ==\n";
|
||||||
|
$r0 = stuRow('X', 'Y', [10.0, 20.0]); // latest=20, highest=20
|
||||||
|
check('latest=20', ny_exam_cell_score($r0, 'latest') === 20.0);
|
||||||
|
check('highest=20', ny_exam_cell_score($r0, 'highest') === 20.0);
|
||||||
|
$r1 = stuRow('X', 'Y', [20.0, 10.0]); // 重考降分
|
||||||
|
check('latest 取最近=10', ny_exam_cell_score($r1, 'latest') === 10.0);
|
||||||
|
check('highest 取最高=20', ny_exam_cell_score($r1, 'highest') === 20.0);
|
||||||
|
$r2 = stuRow('X', 'Y', [50.0, 88.0], [false, true]); // 草稿
|
||||||
|
check('latest 跳过草稿=50', ny_exam_cell_score($r2, 'latest') === 50.0);
|
||||||
|
check('highest 含草稿=88', ny_exam_cell_score($r2, 'highest') === 88.0);
|
||||||
|
$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 列)',
|
||||||
|
$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);
|
||||||
|
/* 每场考试固定占 1 列(历史上每场占 2 列,第 2 列是遗留空位,
|
||||||
|
表现为"每场右边多出一根空白列");「第几次」逐行写各自次序号,不纵向合并
|
||||||
|
(合并会把 2、3 吞掉,只剩首行的 1)。 */
|
||||||
|
check('张三块首行:第几次=1,两场成绩(每场 1 列)',
|
||||||
|
rowEq($rows[2], ['356001', '张三', '1', '112', '112']),
|
||||||
|
json_encode($rows[2], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('张三第 2 行:第几次=2,数学 100 / 英语 100',
|
||||||
|
rowEq($rows[3], ['', '', '2', '100', '100']),
|
||||||
|
json_encode($rows[3], JSON_UNESCAPED_UNICODE));
|
||||||
|
/* 严格校验:3 个前导列 + 每场 1 列,所有行宽必须完全一致。
|
||||||
|
rowEq 会把短的一侧按空串补齐,宽松比较测不出「多一根空白列」这种回归。 */
|
||||||
|
check('每场考试只占 1 列:所有行宽一致 = 3 + 考试数',
|
||||||
|
count($rows) === 9 && !array_filter(array_map('count', $rows), static fn($n) => $n !== 5),
|
||||||
|
'列数序列 = ' . json_encode(array_map('count', $rows)));
|
||||||
|
check('张三第 3 行:第几次=3,数学 136 / 英语空',
|
||||||
|
$rows[4] === ['', '', '3', '136', ''],
|
||||||
|
json_encode($rows[4], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('张三第 4 行:第几次=4,数学 127 / 英语空',
|
||||||
|
$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('李四第 2 / 3 行:第几次 2、3(未考列在合并区内留空)',
|
||||||
|
$rows[7] === ['', '', '2', '', '129'] && $rows[8] === ['', '', '3', '', '133'],
|
||||||
|
json_encode([$rows[7], $rows[8]], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('「第几次」逐行递增,不是被合并压成的一串 1',
|
||||||
|
array_map(static fn($r) => (string)$r[2], array_slice($rows, 2)) === ['1', '2', '3', '4', '1', '2', '3'],
|
||||||
|
json_encode(array_map(static fn($r) => $r[2], array_slice($rows, 2)), 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('合并区间:表头纵向 + 学号姓名纵向 + 未考单列纵向',
|
||||||
|
$refs === ['A1:A2', 'A3:A6', 'A7:A9', 'B1:B2', 'B3:B6', 'B7:B9', 'C1:C2', 'D7:D9'],
|
||||||
|
json_encode($refs));
|
||||||
|
check('「第几次」列没有任何纵向合并(表头 C1:C2 除外)',
|
||||||
|
!array_filter($bs['merges'], static fn($m) => (int)$m[0] > 1 && (int)$m[1] === 3 && (int)$m[3] === 3),
|
||||||
|
json_encode($bs['merges']));
|
||||||
|
check('表头第 1 行没有任何横向合并(考试名各占 1 列)',
|
||||||
|
!array_filter($bs['merges'], static fn($m) => (int)$m[0] === 1 && (int)$m[2] === 1 && $m[1] !== $m[3]),
|
||||||
|
json_encode($bs['merges']));
|
||||||
|
/* 列宽够用就好:考试列要装得下表头考试名(中文按 2 个半角宽算)。 */
|
||||||
|
check('列宽 = 学号 10 / 姓名 9 / 第几次 10 / 每场考试 1 列(按考试名估宽)',
|
||||||
|
$bs['widths'] === [10, 9, 10, 8, 8], 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 次',
|
||||||
|
$pp[6] === ['', '', '合格次数', '3', '1'],
|
||||||
|
json_encode($pp[6], JSON_UNESCAPED_UNICODE));
|
||||||
|
// 李四块:3 行成绩 + 1 行合格次数(第 11 行)
|
||||||
|
check('李四合格次数行:数学未考留空 / 英语 3 次',
|
||||||
|
$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 行第 5 列 → 红字
|
||||||
|
check('张三英语最高分 112 标红', ($pm['3,5'] ?? '') === 'red', json_encode($pm));
|
||||||
|
// 李四英语最高分 133 在第 10 行第 5 列 → 红字
|
||||||
|
check('李四英语最高分 133 标红', ($pm['10,5'] ?? '') === 'red', json_encode($pm));
|
||||||
|
// 张三合格次数(第 7 行):数学 3 达标绿底、英语 1 达标绿底
|
||||||
|
check('张三数学合格次数达标绿底', ($pm['7,4'] ?? '') === 'green', json_encode($pm));
|
||||||
|
check('张三英语合格次数达标绿底', ($pm['7,5'] ?? '') === 'green', json_encode($pm));
|
||||||
|
// 李四:数学未考 → 不标绿;英语 3 次达标绿底
|
||||||
|
check('李四数学未考不标绿', !isset($pm['11,4']), json_encode($pm));
|
||||||
|
check('李四英语合格次数达标绿底', ($pm['11,5'] ?? '') === '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 次时张三仍显示实际合格次数(不隐藏)',
|
||||||
|
$hp[6] === ['', '', '合格次数', '3', '1'],
|
||||||
|
json_encode($hp[6], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('阈值 4 次时不达标不标绿',
|
||||||
|
!isset($bh['cell_styles']['7,4']) && !isset($bh['cell_styles']['7,5']),
|
||||||
|
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'],
|
||||||
|
json_encode(mergeRefs($bEmpty['merges'])));
|
||||||
|
|
||||||
|
// 考试列顺序跟随传入顺序
|
||||||
|
$bOrder = ny_assemble_block_sheet(['e' => $shotEng, 'm' => $shotMath]);
|
||||||
|
check('考试列顺序跟随传入顺序(英语在前)',
|
||||||
|
$bOrder['rows'][0][3] === '英语' && $bOrder['rows'][0][4] === '数学',
|
||||||
|
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);
|
||||||
|
|
||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
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=…&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);
|
||||||
|
})();
|
||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
// 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 (单场宽表)');
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?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 = [
|
||||||
|
// 「未考」的口径是「整场考试里根本没有这个学号」——不是给个空成绩,
|
||||||
|
// 所以李四整场从数学的 rows 里去掉(写在 rows 里只会渲染成空白单元格)。
|
||||||
|
'm' => ['name' => '第一次月考·数学', 'rows' => [
|
||||||
|
sample_stu('356001', '张三', 136.0, [112.0, 100.0, 136.0, 127.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
@@ -13,7 +13,30 @@ const PUB_B64 = Buffer.from(publicKey).toString('base64');
|
|||||||
const PASSWORD = 'surveyking';
|
const PASSWORD = 'surveyking';
|
||||||
let TOKEN = '';
|
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 人姓名有空格/错别字
|
// 构造答卷:前 30 人提交,其中 2 人姓名有空格/错别字
|
||||||
const answers = roster.slice(0, 30).map((s, i) => {
|
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', () => {
|
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 + ' 人)');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
// 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('<b>') >= 0);
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('结果:' + pass + ' 通过,' + fail + ' 失败');
|
||||||
|
module.exports = { sheetToHtml: sheetToHtml, PRINT_CSS: PRINT_CSS };
|
||||||
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
@@ -7,8 +7,8 @@ const { spawn } = require('child_process');
|
|||||||
|
|
||||||
const ROOT = path.resolve(__dirname, '..');
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
const PORT = 18081;
|
const PORT = 18081;
|
||||||
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
|
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
|
||||||
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
|
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
|
||||||
|
|
||||||
// 1) 备份 / 准备文件存储模式
|
// 1) 备份 / 准备文件存储模式
|
||||||
// 数据目录默认用项目下的 data/,但优先读 NY_DATA 环境变量(bootstrap.php 同样支持):
|
// 数据目录默认用项目下的 data/,但优先读 NY_DATA 环境变量(bootstrap.php 同样支持):
|
||||||
|
|||||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// e2e: 登录后立刻又报"未配置账号"、反复跳登录页的死循环
|
||||||
|
//
|
||||||
|
// 复现前提:本机配置里账号为空(或没存住)。旧代码 login.php 不看 ny_save_config 结果,
|
||||||
|
// 登录成功页照常显示"登录成功",但下一个请求 ny_load_config() 读到的账号仍是空的
|
||||||
|
// → 抛"未配置卷王登录账号" → 前端 auth 打标 → 跳登录页 → 死循环。
|
||||||
|
//
|
||||||
|
// 本测试锁死三件事:
|
||||||
|
// 1) 登录后凭据必须真的落盘(config_get 能读回账号、has_password=true)
|
||||||
|
// 2) 登录后立刻拉数据必须成功(不再出现"未配置账号")
|
||||||
|
// 3) 真出现鉴权失败时,响应必须带 extra.auth + extra.diag(前端据此停止自动跳转并给出可自查信息)
|
||||||
|
//
|
||||||
|
// 运行:node tools/test-login-loop.js
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
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/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 子进程。
|
||||||
|
// 注意:绝不能回退到项目下的 data/ —— 本测试会清空账密并重建库,落到真实目录等于破坏本地配置
|
||||||
|
// (曾因默认回退到 data/ 把 ny.db 删掉)。未显式指定时一律新建临时目录。
|
||||||
|
const os = require('os');
|
||||||
|
function resolveDataDir() {
|
||||||
|
let p = process.env.NY_DATA || path.join(os.tmpdir(), 'ny-login-loop-' + process.pid);
|
||||||
|
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2);
|
||||||
|
p = path.resolve(p);
|
||||||
|
process.env.NY_DATA = p;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
const DATA = resolveDataDir();
|
||||||
|
fs.mkdirSync(DATA, { recursive: true });
|
||||||
|
const cfgFile = path.join(DATA, 'config.json');
|
||||||
|
const tokenFile = path.join(DATA, 'cache', 'token.json');
|
||||||
|
const dbFile = path.join(DATA, 'ny.db');
|
||||||
|
const origCfg = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, 'utf8') : null;
|
||||||
|
const hasDb = fs.existsSync(dbFile);
|
||||||
|
if (hasDb) { try { fs.unlinkSync(dbFile); } catch (e) {} }
|
||||||
|
try { fs.unlinkSync(tokenFile); } catch (e) {}
|
||||||
|
|
||||||
|
// 关键:模拟"账号没配/没存住"的现场——连接信息是对的,账号密码是空的
|
||||||
|
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));
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg);
|
||||||
|
else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile);
|
||||||
|
} catch (e) {}
|
||||||
|
try { if (fs.existsSync(dbFile)) fs.unlinkSync(dbFile); } catch (e) {}
|
||||||
|
try { if (fs.existsSync(tokenFile)) fs.unlinkSync(tokenFile); } 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: 'pipe' });
|
||||||
|
srv = spawn(PHP, INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]), { cwd: ROOT, stdio: 'pipe' });
|
||||||
|
|
||||||
|
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 = [];
|
||||||
|
let CSRF = '';
|
||||||
|
const jarHeader = () => (cookieJar.length ? cookieJar.map(c => c.replace(/;.*$/, '')).join('; ') : '');
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function req(method, p, body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (body && CSRF && !body._csrf) body = Object.assign({}, body, { _csrf: CSRF });
|
||||||
|
const data = body ? JSON.stringify(body) : '';
|
||||||
|
const h = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) };
|
||||||
|
if (jarHeader()) h['Cookie'] = jarHeader();
|
||||||
|
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);
|
||||||
|
const raw = Buffer.concat(bufs).toString('utf8');
|
||||||
|
let j = null; try { j = JSON.parse(raw); } catch (e) {}
|
||||||
|
resolve({ status: res.statusCode, body: j, raw, loc: res.headers.location });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
r.on('error', reject);
|
||||||
|
if (data) r.write(data);
|
||||||
|
r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function formPost(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'), loc: res.headers.location }); });
|
||||||
|
});
|
||||||
|
r.on('error', reject);
|
||||||
|
r.write(data); r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const delay = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await delay(2200);
|
||||||
|
|
||||||
|
console.log('\n--- 1) 登录:凭据必须真的落盘(登录前本机账号为空)---');
|
||||||
|
const lg = await formPost('/login.php', { username: MOCK.user, password: MOCK.pass });
|
||||||
|
chk('login.php 返回 200/302', lg.status === 200 || lg.status === 302, 'status=' + lg.status);
|
||||||
|
chk('登录页未出现"没能保存到本机配置"', lg.raw.indexOf('没能保存到本机配置') < 0, (lg.raw.match(/没能保存到本机配置[^<]*/) || [''])[0].slice(0, 160));
|
||||||
|
chk('登录页未出现"登录失败"', lg.raw.indexOf('登录失败') < 0, (lg.raw.match(/登录失败[^<]*/) || [''])[0].slice(0, 160));
|
||||||
|
chk('拿到会话 cookie', cookieJar.some(c => c.indexOf('NYSESS=') === 0), JSON.stringify(cookieJar));
|
||||||
|
chk('成功页写入 ny_login_at(前端防重复跳转依据)', lg.raw.indexOf('ny_login_at') >= 0);
|
||||||
|
|
||||||
|
const ac = await req('GET', '/api.php?action=auth_check');
|
||||||
|
chk('auth_check 已登录', ac.body && ac.body.data && ac.body.data.auth === true, ac.raw);
|
||||||
|
if (!(ac.body && ac.body.data && ac.body.data.auth)) { cleanup(); process.exit(1); }
|
||||||
|
CSRF = (ac.body && ac.body.data && ac.body.data.csrf_token) || CSRF;
|
||||||
|
|
||||||
|
const g1 = await req('GET', '/api.php?action=config_get');
|
||||||
|
CSRF = (g1.body && g1.body.data && g1.body.data.csrf_token) || CSRF;
|
||||||
|
chk('账号已回读到配置', g1.body && g1.body.data && g1.body.data.username === MOCK.user, JSON.stringify(g1.body && g1.body.data && g1.body.data.username));
|
||||||
|
chk('has_password=true(密码已存)', g1.body && g1.body.data && g1.body.data.has_password === true);
|
||||||
|
chk('响应不含明文密码', !('password' in ((g1.body && g1.body.data) || {})));
|
||||||
|
|
||||||
|
console.log('\n--- 2) 登录后立刻拉数据:不应再出现"未配置账号" ---');
|
||||||
|
const d1 = await req('GET', '/api.php?action=data&projectId=p1&force=1');
|
||||||
|
chk('data 成功', d1.body && d1.body.ok === true, (d1.raw || '').slice(0, 200));
|
||||||
|
chk('data 未报未配置账号', (d1.raw || '').indexOf('未配置卷王登录账号') < 0, (d1.raw || '').slice(0, 160));
|
||||||
|
chk('data 未被标记为鉴权失败', !(d1.body && d1.body.extra && d1.body.extra.auth), JSON.stringify(d1.body && d1.body.extra));
|
||||||
|
|
||||||
|
console.log('\n--- 3) 鉴权真失败时:必须带 auth 标记 + 可自查的诊断 ---');
|
||||||
|
const sv = await req('POST', '/api.php?action=config_save', { password: 'definitely-wrong' });
|
||||||
|
chk('改错密码已保存', sv.body && sv.body.ok === true, (sv.raw || '').slice(0, 160));
|
||||||
|
const d2 = await req('GET', '/api.php?action=data&projectId=p1&force=1');
|
||||||
|
chk('此时 data 失败', d2.body && d2.body.ok === false, (d2.raw || '').slice(0, 160));
|
||||||
|
chk('extra.auth=true', !!(d2.body && d2.body.extra && d2.body.extra.auth), JSON.stringify(d2.body && d2.body.extra));
|
||||||
|
const diag = d2.body && d2.body.extra && d2.body.extra.diag;
|
||||||
|
chk('extra.diag 存在', !!diag, JSON.stringify(diag));
|
||||||
|
if (diag) {
|
||||||
|
chk('diag.has_username=true(账号其实存住了)', diag.has_username === true);
|
||||||
|
chk('diag 含目录/文件/数据库可写标记', 'dir_writable' in diag && 'cfg_writable' in diag && 'db_writable' in diag);
|
||||||
|
chk('diag 含配置来源', typeof diag.storage === 'string' && diag.storage !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n--- 4) 改回正确密码后应恢复 ---');
|
||||||
|
const sv2 = await req('POST', '/api.php?action=config_save', { password: MOCK.pass });
|
||||||
|
chk('恢复密码已保存', sv2.body && sv2.body.ok === true);
|
||||||
|
const d3 = await req('GET', '/api.php?action=data&projectId=p1&force=1');
|
||||||
|
chk('data 恢复正常', d3.body && d3.body.ok === true, (d3.raw || '').slice(0, 160));
|
||||||
|
|
||||||
|
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
|
||||||
|
cleanup();
|
||||||
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
|
})();
|
||||||
@@ -12,8 +12,8 @@ const { spawn } = require('child_process');
|
|||||||
const ROOT = path.resolve(__dirname, '..');
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
const PHP_PORT = 18081;
|
const PHP_PORT = 18081;
|
||||||
const MOCK_PORT = 19911;
|
const MOCK_PORT = 19911;
|
||||||
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
|
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
|
||||||
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
|
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
|
||||||
const DEBUG = !!process.env.NY_DEBUG;
|
const DEBUG = !!process.env.NY_DEBUG;
|
||||||
|
|
||||||
// 数据目录优先读 NY_DATA 环境变量(与 bootstrap.php 一致),让测试整体指向临时目录,
|
// 数据目录优先读 NY_DATA 环境变量(与 bootstrap.php 一致),让测试整体指向临时目录,
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ const { spawn } = require('child_process');
|
|||||||
const ROOT = path.resolve(__dirname, '..');
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
const PHP_PORT = 18082;
|
const PHP_PORT = 18082;
|
||||||
const MOCK_PORT = 19911;
|
const MOCK_PORT = 19911;
|
||||||
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
|
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
|
||||||
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
|
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
|
||||||
const DEBUG = !!process.env.NY_DEBUG;
|
const DEBUG = !!process.env.NY_DEBUG;
|
||||||
|
|
||||||
function resolveDataDir() {
|
function resolveDataDir() {
|
||||||
|
|||||||
@@ -99,6 +99,168 @@ if ($parts !== []) {
|
|||||||
@unlink($manualOut);
|
@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);
|
@unlink($out);
|
||||||
echo PHP_EOL . "结果:$pass 通过,$fail 失败" . PHP_EOL;
|
echo PHP_EOL . "结果:$pass 通过,$fail 失败" . PHP_EOL;
|
||||||
exit($fail === 0 ? 0 : 1);
|
exit($fail === 0 ? 0 : 1);
|
||||||
|
|||||||
Reference in New Issue
Block a user