Compare commits
10
Commits
58f9c8433a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e87986fcc6 | ||
|
|
d05fd4a9b5 | ||
|
|
0ba9c7c33c | ||
|
|
f661862d46 | ||
|
|
555f500dc7 | ||
|
|
eb5d06d392 | ||
|
|
94ac381579 | ||
|
|
804b04de60 | ||
|
|
25a2e8a42c | ||
|
|
f271066266 |
@@ -1,6 +1,12 @@
|
|||||||
# 运行时数据:含账号密码、API 令牌、学生名单,禁止入库
|
# 运行时数据:含账号密码、API 令牌、学生名单,禁止入库
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# WorkBuddy 项目数据目录(记忆 / 配置缓存),非源码,禁止入库
|
||||||
|
.workbuddy/
|
||||||
|
|
||||||
|
# e2e 隔离用的临时数据目录(run-all.sh 自动创建,不入库)
|
||||||
|
.ny-test-data/
|
||||||
|
|
||||||
# 依赖与系统文件
|
# 依赖与系统文件
|
||||||
node_modules/
|
node_modules/
|
||||||
vendor/
|
vendor/
|
||||||
@@ -8,6 +14,7 @@ vendor/
|
|||||||
|
|
||||||
# VitePress 文档站构建产物与缓存
|
# VitePress 文档站构建产物与缓存
|
||||||
docs/.vitepress/dist/
|
docs/.vitepress/dist/
|
||||||
|
docs/.vitepress/dist*/
|
||||||
docs/.vitepress/cache/
|
docs/.vitepress/cache/
|
||||||
docs/.vitepress/config.mjs.timestamp-*.mjs
|
docs/.vitepress/config.mjs.timestamp-*.mjs
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -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 与单测中复用。
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 带短期缓存的数据构建(含并发锁,避免多人同时刷新时重复打卷王造成惊群)
|
* 带短期缓存的数据构建(含并发锁,避免多人同时刷新时重复打卷王造成惊群)
|
||||||
*
|
*
|
||||||
@@ -87,6 +93,8 @@ function ny_build_data(array $c, string $projectId, string $nameQid, bool $force
|
|||||||
(string)($c['name_keywords'] ?? ''),
|
(string)($c['name_keywords'] ?? ''),
|
||||||
(int)($c['page_size'] ?? 100),
|
(int)($c['page_size'] ?? 100),
|
||||||
Roster::fingerprint(),
|
Roster::fingerprint(),
|
||||||
|
// 本卷独立配置(免考名单/满分/合格率)变化也应让缓存失效,否则改免考后可能短暂看到旧数据
|
||||||
|
json_encode(DataService::projectConfig($projectId) ?? []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($ttl > 0 && !$force) {
|
if ($ttl > 0 && !$force) {
|
||||||
@@ -110,17 +118,25 @@ function ny_build_data(array $c, string $projectId, string $nameQid, bool $force
|
|||||||
// 等不到(构建很慢或锁不被支持)就自己构建,保证可用性
|
// 等不到(构建很慢或锁不被支持)就自己构建,保证可用性
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// try/finally:无论 build 是否抛异常都释放构建锁,避免异常时锁句柄挂到进程退出造成惊群
|
||||||
|
$result = null;
|
||||||
|
try {
|
||||||
$result = DataService::build($c, $projectId, $nameQid);
|
$result = DataService::build($c, $projectId, $nameQid);
|
||||||
if ($ttl > 0) {
|
if ($ttl > 0) {
|
||||||
DataCache::write($key, $result);
|
DataCache::write($key, $result);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
DataCache::releaseLock($lock);
|
DataCache::releaseLock($lock);
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -128,7 +144,7 @@ if ($action !== 'auth_check' && !ny_is_auth()) {
|
|||||||
// CSRF 防护:所有写操作必须携带会话绑定的 token(GET 读取类接口不受影响)
|
// CSRF 防护:所有写操作必须携带会话绑定的 token(GET 读取类接口不受影响)
|
||||||
$WRITE_ACTIONS = [
|
$WRITE_ACTIONS = [
|
||||||
'config_save', 'roster_save', 'roster_import', 'roster_clear',
|
'config_save', 'roster_save', 'roster_import', 'roster_clear',
|
||||||
'snapshot_save', 'snapshot_delete', 'unmatched_map', 'project_config_save',
|
'unmatched_map', 'project_config_save',
|
||||||
];
|
];
|
||||||
if (in_array($action, $WRITE_ACTIONS, true) && !ny_csrf_ok()) {
|
if (in_array($action, $WRITE_ACTIONS, true) && !ny_csrf_ok()) {
|
||||||
ny_json(['ok' => false, 'msg' => '安全校验失败(CSRF token 缺失或不匹配),请刷新页面后重试'], 403);
|
ny_json(['ok' => false, 'msg' => '安全校验失败(CSRF token 缺失或不匹配),请刷新页面后重试'], 403);
|
||||||
@@ -152,76 +168,22 @@ try {
|
|||||||
$c['db_driver'] = class_exists('Db') ? Db::driver() : '';
|
$c['db_driver'] = class_exists('Db') ? Db::driver() : '';
|
||||||
$c['db_configured'] = class_exists('Db') && Db::isConfigured();
|
$c['db_configured'] = class_exists('Db') && Db::isConfigured();
|
||||||
$c['db_connected'] = class_exists('Db') && Db::isConnected();
|
$c['db_connected'] = class_exists('Db') && Db::isConnected();
|
||||||
$c['db_error'] = class_exists('Db') ? Db::lastError() : '';
|
// 仅在未连接时回传错误详情(供前端诊断连接失败原因);
|
||||||
|
// 已连接时一律不回传 lastError,避免把 SQL/文件路径等内部信息泄露给客户端。
|
||||||
|
$c['db_error'] = (class_exists('Db') && !Db::isConnected()) ? Db::lastError() : '';
|
||||||
ny_ok($c);
|
ny_ok($c);
|
||||||
|
|
||||||
case 'config_save':
|
case 'config_save':
|
||||||
$raw = (string)file_get_contents('php://input');
|
$raw = ny_request_body();
|
||||||
$in = json_decode($raw, true);
|
$in = json_decode($raw, true);
|
||||||
if (!is_array($in)) {
|
if (!is_array($in)) {
|
||||||
$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');
|
||||||
@@ -250,7 +212,7 @@ try {
|
|||||||
|
|
||||||
// ---------------- 用弹窗内(未保存)的凭据测试连接 ----------------
|
// ---------------- 用弹窗内(未保存)的凭据测试连接 ----------------
|
||||||
case 'config_test':
|
case 'config_test':
|
||||||
$raw = (string)file_get_contents('php://input');
|
$raw = ny_request_body();
|
||||||
$in = json_decode($raw, true);
|
$in = json_decode($raw, true);
|
||||||
if (!is_array($in)) {
|
if (!is_array($in)) {
|
||||||
$in = $_POST;
|
$in = $_POST;
|
||||||
@@ -346,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'] ?? '')) ?: '(未分班)';
|
||||||
@@ -455,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':
|
||||||
@@ -502,12 +496,49 @@ try {
|
|||||||
}
|
}
|
||||||
// 解析完立即删除:Web 目录下不再留存任何上传文件
|
// 解析完立即删除:Web 目录下不再留存任何上传文件
|
||||||
@unlink($dest);
|
@unlink($dest);
|
||||||
$mode = (string)($_POST['mode'] ?? 'replace');
|
// 默认追加:与「导入名单默认为追加」一致(学号名单管理导入)
|
||||||
|
$mode = (string)($_POST['mode'] ?? 'append');
|
||||||
if ($mode === 'append') {
|
if ($mode === 'append') {
|
||||||
$merged = array_merge(Roster::all(), $res['students']);
|
// 追加:仅加入名单中尚不存在的成员(无学号时按姓名判重),避免重复行。
|
||||||
Roster::save($merged);
|
// 学号与姓名分两套判重键:带学号者以 sid 判重,无学号者以姓名判重,
|
||||||
|
// 避免「已存在带学号 A 的行」被「同名但空学号的导入行」误判为不重复而重复插入。
|
||||||
|
$existing = Roster::all();
|
||||||
|
$seenSid = [];
|
||||||
|
$seenName = [];
|
||||||
|
foreach ($existing as $s) {
|
||||||
|
$sid = strtolower(trim((string)($s['sid'] ?? '')));
|
||||||
|
$name = strtolower(trim((string)($s['name'] ?? '')));
|
||||||
|
if ($sid !== '') {
|
||||||
|
$seenSid[$sid] = true;
|
||||||
|
} elseif ($name !== '') {
|
||||||
|
$seenName[$name] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$added = [];
|
||||||
|
foreach ($res['students'] as $s) {
|
||||||
|
$sid = strtolower(trim((string)($s['sid'] ?? '')));
|
||||||
|
$name = strtolower(trim((string)($s['name'] ?? '')));
|
||||||
|
$dup = false;
|
||||||
|
if ($sid !== '') {
|
||||||
|
if (isset($seenSid[$sid])) { $dup = true; }
|
||||||
|
else { $seenSid[$sid] = true; }
|
||||||
|
} elseif ($name !== '') {
|
||||||
|
if (isset($seenName[$name])) { $dup = true; }
|
||||||
|
else { $seenName[$name] = true; }
|
||||||
|
}
|
||||||
|
if ($dup) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$added[] = $s;
|
||||||
|
}
|
||||||
|
if (!Roster::save(array_merge($existing, $added))) {
|
||||||
|
ny_fail('名单追加失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
|
||||||
|
}
|
||||||
|
$res['count'] = count($added); // 仅计入真正新增的人数
|
||||||
} else {
|
} else {
|
||||||
Roster::save($res['students']);
|
if (!Roster::save($res['students'])) {
|
||||||
|
ny_fail('名单导入失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ny_ok([
|
ny_ok([
|
||||||
'students' => Roster::all(),
|
'students' => Roster::all(),
|
||||||
@@ -517,8 +548,13 @@ try {
|
|||||||
'duplicates' => $res['duplicates'] ?? 0,
|
'duplicates' => $res['duplicates'] ?? 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ---------------- 从卷王白名单导入待考名单(已移除)----------------
|
||||||
|
// 现改为:DataService::build 自动读取每套试卷卷内设置的「答题白名单」,
|
||||||
|
// 白名单成员即「应考名单」,未设置时回退到全名单(按现在的方案显示)。
|
||||||
|
// 名单管理里的「⬇ 从白名单导入」按钮已删除。
|
||||||
|
|
||||||
case 'roster_save':
|
case 'roster_save':
|
||||||
$raw = (string)file_get_contents('php://input');
|
$raw = ny_request_body();
|
||||||
$in = json_decode($raw, true);
|
$in = json_decode($raw, true);
|
||||||
if (!is_array($in) || !isset($in['students']) || !is_array($in['students'])) {
|
if (!is_array($in) || !isset($in['students']) || !is_array($in['students'])) {
|
||||||
ny_fail('参数错误');
|
ny_fail('参数错误');
|
||||||
@@ -536,7 +572,9 @@ try {
|
|||||||
'login' => trim((string)($s['login'] ?? '')),
|
'login' => trim((string)($s['login'] ?? '')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
Roster::save($students);
|
if (!Roster::save($students)) {
|
||||||
|
ny_fail('名单保存失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
|
||||||
|
}
|
||||||
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta()]);
|
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta()]);
|
||||||
|
|
||||||
case 'roster_clear':
|
case 'roster_clear':
|
||||||
@@ -545,7 +583,7 @@ try {
|
|||||||
|
|
||||||
// ---------------- 未匹配答卷 → 加入名单 ----------------
|
// ---------------- 未匹配答卷 → 加入名单 ----------------
|
||||||
case 'unmatched_map':
|
case 'unmatched_map':
|
||||||
$raw = (string)file_get_contents('php://input');
|
$raw = ny_request_body();
|
||||||
$in = json_decode($raw, true);
|
$in = json_decode($raw, true);
|
||||||
if (!is_array($in)) {
|
if (!is_array($in)) {
|
||||||
$in = $_POST;
|
$in = $_POST;
|
||||||
@@ -575,67 +613,11 @@ try {
|
|||||||
if (!$found) {
|
if (!$found) {
|
||||||
$roster[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login];
|
$roster[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login];
|
||||||
}
|
}
|
||||||
Roster::save($roster);
|
if (!Roster::save($roster)) {
|
||||||
|
ny_fail('映射保存失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
|
||||||
|
}
|
||||||
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta(), 'mapped' => $name]);
|
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta(), 'mapped' => $name]);
|
||||||
|
|
||||||
// ---------------- 成绩快照 / 趋势 ----------------
|
|
||||||
case 'snapshot_save':
|
|
||||||
$projectId = (string)($_GET['projectId'] ?? $_POST['projectId'] ?? '');
|
|
||||||
if ($projectId === '') {
|
|
||||||
ny_fail('请先选择问卷/考试');
|
|
||||||
}
|
|
||||||
$raw = (string)file_get_contents('php://input');
|
|
||||||
$in = json_decode($raw, true);
|
|
||||||
if (!is_array($in)) {
|
|
||||||
$in = $_POST;
|
|
||||||
}
|
|
||||||
$label = trim((string)($in['label'] ?? ''));
|
|
||||||
// 复用缓存构建(不强制,快照本就是"当前看到的成绩")
|
|
||||||
$c = cfg();
|
|
||||||
$nameQid = (string)($_GET['nameQid'] ?? '');
|
|
||||||
$built = ny_build_data($c, $projectId, $nameQid);
|
|
||||||
$snap = Snapshot::summarize($built['result'], $label);
|
|
||||||
$file = Snapshot::save($projectId, $snap);
|
|
||||||
ny_ok(['file' => $file, 'label' => $snap['label'], 'at_txt' => $snap['at_txt'], 'count' => count($snap['students'])]);
|
|
||||||
|
|
||||||
case 'snapshot_list':
|
|
||||||
$projectId = (string)($_GET['projectId'] ?? '');
|
|
||||||
if ($projectId === '') {
|
|
||||||
ny_fail('请先选择问卷/考试');
|
|
||||||
}
|
|
||||||
ny_ok(['snapshots' => Snapshot::list($projectId)]);
|
|
||||||
|
|
||||||
case 'snapshot_delete':
|
|
||||||
$projectId = (string)($_GET['projectId'] ?? $_POST['projectId'] ?? '');
|
|
||||||
$raw = (string)file_get_contents('php://input');
|
|
||||||
$in = json_decode($raw, true);
|
|
||||||
if (!is_array($in)) {
|
|
||||||
$in = $_POST;
|
|
||||||
}
|
|
||||||
$file = (string)($in['file'] ?? '');
|
|
||||||
if ($projectId === '' || $file === '') {
|
|
||||||
ny_fail('参数错误');
|
|
||||||
}
|
|
||||||
ny_ok(['deleted' => Snapshot::delete($projectId, $file)]);
|
|
||||||
|
|
||||||
case 'snapshot_trend':
|
|
||||||
$projectId = (string)($_GET['projectId'] ?? '');
|
|
||||||
if ($projectId === '') {
|
|
||||||
ny_fail('请先选择问卷/考试');
|
|
||||||
}
|
|
||||||
// 支持跨项目对比:projectId 可用逗号分隔多个项目,取各自快照混合趋势
|
|
||||||
$pids = array_values(array_filter(array_map('trim', explode(',', $projectId))));
|
|
||||||
$snaps = [];
|
|
||||||
foreach ($pids as $pid) {
|
|
||||||
foreach (Snapshot::list($pid) as $s) {
|
|
||||||
$full = Snapshot::get($pid, $s['file']);
|
|
||||||
if ($full !== null) {
|
|
||||||
$snaps[] = $full;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ny_ok(Snapshot::trend($snaps));
|
|
||||||
|
|
||||||
// ---------------- 试卷独立配置(满分 / 合格率 / 免考名单)——仅对当前试卷生效 ----------------
|
// ---------------- 试卷独立配置(满分 / 合格率 / 免考名单)——仅对当前试卷生效 ----------------
|
||||||
case 'project_config':
|
case 'project_config':
|
||||||
$projectId = (string)($_GET['projectId'] ?? '');
|
$projectId = (string)($_GET['projectId'] ?? '');
|
||||||
@@ -661,7 +643,7 @@ try {
|
|||||||
if ($projectId === '') {
|
if ($projectId === '') {
|
||||||
ny_fail('请先选择问卷/考试');
|
ny_fail('请先选择问卷/考试');
|
||||||
}
|
}
|
||||||
$raw = (string)file_get_contents('php://input');
|
$raw = ny_request_body();
|
||||||
$in = json_decode($raw, true);
|
$in = json_decode($raw, true);
|
||||||
if (!is_array($in)) {
|
if (!is_array($in)) {
|
||||||
$in = $_POST;
|
$in = $_POST;
|
||||||
@@ -764,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= 参数挑选) ----------------
|
||||||
@@ -776,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 !== ''
|
||||||
@@ -799,24 +789,67 @@ try {
|
|||||||
// ---------------- 数据行 ----------------
|
// ---------------- 数据行 ----------------
|
||||||
$sortMode = (string)($_GET['sort'] ?? '');
|
$sortMode = (string)($_GET['sort'] ?? '');
|
||||||
if ($isLatest) {
|
if ($isLatest) {
|
||||||
// 每人一行:成绩取「最近一次提交」的分数(不受 dedupe 影响),并附最高分与考试次数
|
// 每人一行:成绩按「最新成绩」(最近一次真实提交)或「最高成绩」(best_score),
|
||||||
|
// 并附最高分与考试次数。
|
||||||
|
// 注意:include_temp_save 开启时 attempts_list 可能含暂存草稿,取最新成绩应跳过 temp_save,
|
||||||
|
// 否则导出的"最新成绩"可能是一份草稿分。
|
||||||
$all = [];
|
$all = [];
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
$list = array_values((array)($r['attempts_list'] ?? []));
|
$list = array_values((array)($r['attempts_list'] ?? []));
|
||||||
$last = $list !== [] ? $list[count($list) - 1] : null;
|
$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]; // 全是暂存时退回最后一份
|
||||||
|
}
|
||||||
|
// 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') {
|
||||||
@@ -827,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 {
|
||||||
@@ -834,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;
|
||||||
}
|
}
|
||||||
@@ -868,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'] ?? []));
|
||||||
@@ -891,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);
|
||||||
@@ -902,9 +982,256 @@ 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 = (string)file_get_contents('php://input');
|
$raw = ny_request_body();
|
||||||
$in = json_decode($raw, true);
|
$in = json_decode($raw, true);
|
||||||
if (!is_array($in)) {
|
if (!is_array($in)) {
|
||||||
$in = $_POST + $_GET;
|
$in = $_POST + $_GET;
|
||||||
@@ -946,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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
+1032
-296
File diff suppressed because it is too large
Load Diff
+57
-18
@@ -35,6 +35,7 @@
|
|||||||
log: [], // 历史批次,新的在前
|
log: [], // 历史批次,新的在前
|
||||||
customRatios: {}, // 自定义比例:{ 组名: 权重 }
|
customRatios: {}, // 自定义比例:{ 组名: 权重 }
|
||||||
drawing: false,
|
drawing: false,
|
||||||
|
all: false, // 「全部」模式:人数始终跟随当前候选池(手工改输入框即解除)
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------------- 接口 ---------------- */
|
/* ---------------- 接口 ---------------- */
|
||||||
@@ -82,7 +83,7 @@
|
|||||||
/** 兼容旧 showAlert 调用 → 走 toast(draw.js 内部已有 esc,此处直接复用) */
|
/** 兼容旧 showAlert 调用 → 走 toast(draw.js 内部已有 esc,此处直接复用) */
|
||||||
function showAlert(msg, type) {
|
function showAlert(msg, type) {
|
||||||
if (msg === '' || msg == null) return;
|
if (msg === '' || msg == null) return;
|
||||||
const t = type === 'info' ? 'ok' : (type === 'error' ? 'error' : '');
|
const t = type === 'info' ? 'ok' : (type === 'error' ? 'error' : (type === 'warn' ? 'warn' : ''));
|
||||||
showToast(msg, t);
|
showToast(msg, t);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +346,11 @@
|
|||||||
|
|
||||||
function renderStats() {
|
function renderStats() {
|
||||||
const pool = currentPool();
|
const pool = currentPool();
|
||||||
|
// 「全部」模式:候选池变化(改筛选 / 改来源 / 改排除)时人数跟着变
|
||||||
|
if (state.all) {
|
||||||
|
const ni = $('#numInput');
|
||||||
|
if (ni) ni.value = String(Math.max(1, pool.length));
|
||||||
|
}
|
||||||
$('#dPool').textContent = pool.length;
|
$('#dPool').textContent = pool.length;
|
||||||
$('#dPicked').textContent = state.picked.length;
|
$('#dPicked').textContent = state.picked.length;
|
||||||
$('#dHistory').textContent = state.history.size;
|
$('#dHistory').textContent = state.history.size;
|
||||||
@@ -395,9 +401,11 @@
|
|||||||
showAlert('当前筛选下没有可抽取的人员(可关闭「排除已抽中」、调整班级 / 组别或排除名单)');
|
showAlert('当前筛选下没有可抽取的人员(可关闭「排除已抽中」、调整班级 / 组别或排除名单)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let n = parseInt($('#numInput').value, 10);
|
// 「全部」模式:人数始终等于当前候选池,切换筛选后不会沿用旧数字
|
||||||
|
let n = state.all ? pool.length : parseInt($('#numInput').value, 10);
|
||||||
if (!n || n < 1) n = 1;
|
if (!n || n < 1) n = 1;
|
||||||
if (n > pool.length) n = pool.length;
|
let capped = false;
|
||||||
|
if (n > pool.length) { n = pool.length; capped = true; }
|
||||||
|
|
||||||
state.drawing = true;
|
state.drawing = true;
|
||||||
const stage = $('#drawStage');
|
const stage = $('#drawStage');
|
||||||
@@ -437,9 +445,15 @@
|
|||||||
picked.forEach(m => state.history.add(String(m.sid)));
|
picked.forEach(m => state.history.add(String(m.sid)));
|
||||||
|
|
||||||
stage.className = 'draw-stage done';
|
stage.className = 'draw-stage done';
|
||||||
stage.textContent = n === 1
|
if (n === 1 && picked.length) {
|
||||||
? (picked[0].name || picked[0].sid)
|
// 只抽 1 人时把学号 / 班级 / 组别一起显示出来,教师不用再去表里找
|
||||||
: ('🎉 已抽取 ' + n + ' 人');
|
const p0 = picked[0];
|
||||||
|
const meta = [p0.sid, p0.cls, p0.grp ? (p0.grp + '组') : ''].filter(Boolean).join(' · ');
|
||||||
|
stage.innerHTML = '<div>' + esc(p0.name || p0.sid) +
|
||||||
|
(meta ? '<div class="draw-stage-sub">' + esc(meta) + '</div>' : '') + '</div>';
|
||||||
|
} else {
|
||||||
|
stage.textContent = '🎉 已抽取 ' + n + ' 人';
|
||||||
|
}
|
||||||
|
|
||||||
renderResult();
|
renderResult();
|
||||||
renderStats();
|
renderStats();
|
||||||
@@ -449,8 +463,12 @@
|
|||||||
state.drawing = false;
|
state.drawing = false;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
const tail = detail.length ? '(' + detail.join(' / ') + ')' : '';
|
const tail = detail.length ? '(' + detail.join(' / ') + ')' : '';
|
||||||
|
if (capped) {
|
||||||
|
showAlert('候选池只有 ' + pool.length + ' 人,已按 ' + n + ' 人抽取(可关闭「排除已抽中」或调整筛选扩大候选)', 'warn');
|
||||||
|
} else {
|
||||||
showAlert('已抽取 ' + n + ' 人' + tail + (n > 1 ? ',可点击右上角按钮下载白名单' : ''), 'info');
|
showAlert('已抽取 ' + n + ' 人' + tail + (n > 1 ? ',可点击右上角按钮下载白名单' : ''), 'info');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderResult() {
|
function renderResult() {
|
||||||
const tb = $('#drawBody');
|
const tb = $('#drawBody');
|
||||||
@@ -458,15 +476,17 @@
|
|||||||
tb.innerHTML = '<tr><td colspan="6" class="empty">尚未抽取</td></tr>';
|
tb.innerHTML = '<tr><td colspan="6" class="empty">尚未抽取</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tb.innerHTML = state.picked.map((m, i) =>
|
// 抽取结果表不显示登陆账号(仅导出白名单才含该列),故只渲染 5 个数据列 + 操作列
|
||||||
'<tr>' +
|
tb.innerHTML = state.picked.map((m, i) => {
|
||||||
|
return '<tr>' +
|
||||||
'<td class="num">' + (i + 1) + '</td>' +
|
'<td class="num">' + (i + 1) + '</td>' +
|
||||||
'<td class="sid">' + esc(m.sid) + '</td>' +
|
'<td class="sid">' + esc(m.sid) + '</td>' +
|
||||||
'<td class="name">' + esc(m.name) + '</td>' +
|
'<td class="name">' + esc(m.name) + '</td>' +
|
||||||
'<td>' + esc(m.cls || '') + '</td>' +
|
'<td>' + esc(m.cls || '') + '</td>' +
|
||||||
'<td>' + esc(m.grp || '') + '</td>' +
|
'<td>' + esc(m.grp || '') + '</td>' +
|
||||||
'<td><button class="btn sm danger" data-drop="' + i + '" title="移出结果并加入排除名单">排除</button></td>' +
|
'<td class="act"><button class="btn sm danger" data-drop="' + i + '" title="移出结果并加入排除名单">排除</button></td>' +
|
||||||
'</tr>').join('');
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================= 历史记录 ================= */
|
/* ================= 历史记录 ================= */
|
||||||
@@ -513,7 +533,7 @@
|
|||||||
'</span></div>' +
|
'</span></div>' +
|
||||||
'<details class="log-detail"><summary>查看成员(' + e.count + ')</summary>' +
|
'<details class="log-detail"><summary>查看成员(' + e.count + ')</summary>' +
|
||||||
'<div class="table-scroll short"><table class="grid compact">' +
|
'<div class="table-scroll short"><table class="grid compact">' +
|
||||||
'<thead><tr><th style="width:56px">序号</th><th style="width:28%">学号</th><th style="width:26%">姓名</th><th>班级</th><th>组别</th></tr></thead>' +
|
'<thead><tr><th style="width:8%;text-align:right">序号</th><th style="width:28%">学号</th><th style="width:26%">姓名</th><th style="width:24%">班级</th><th style="width:14%">组别</th></tr></thead>' +
|
||||||
'<tbody>' + rows + '</tbody></table></div>' +
|
'<tbody>' + rows + '</tbody></table></div>' +
|
||||||
'</details>' +
|
'</details>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
@@ -627,6 +647,7 @@
|
|||||||
cls: $('#clsSel').value,
|
cls: $('#clsSel').value,
|
||||||
grp: $('#grpSel').value,
|
grp: $('#grpSel').value,
|
||||||
num: $('#numInput').value,
|
num: $('#numInput').value,
|
||||||
|
all: state.all,
|
||||||
exclude: $('#excludePicked').checked,
|
exclude: $('#excludePicked').checked,
|
||||||
mode: $('#balanceMode').value,
|
mode: $('#balanceMode').value,
|
||||||
customRatios: cr,
|
customRatios: cr,
|
||||||
@@ -700,6 +721,8 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
$('#numInput').addEventListener('change', saveSettings);
|
$('#numInput').addEventListener('change', saveSettings);
|
||||||
|
// 手工改人数即解除「全部」模式
|
||||||
|
$('#numInput').addEventListener('input', () => { state.all = false; });
|
||||||
$('#excludePicked').addEventListener('change', () => { renderStats(); saveSettings(); });
|
$('#excludePicked').addEventListener('change', () => { renderStats(); saveSettings(); });
|
||||||
$('#balanceMode').addEventListener('change', () => {
|
$('#balanceMode').addEventListener('change', () => {
|
||||||
renderRatioBox();
|
renderRatioBox();
|
||||||
@@ -718,9 +741,10 @@
|
|||||||
b.addEventListener('click', () => {
|
b.addEventListener('click', () => {
|
||||||
const v = b.dataset.num;
|
const v = b.dataset.num;
|
||||||
if (v === 'all') {
|
if (v === 'all') {
|
||||||
const pool = currentPool();
|
state.all = true;
|
||||||
$('#numInput').value = String(Math.max(1, pool.length));
|
$('#numInput').value = String(Math.max(1, currentPool().length));
|
||||||
} else {
|
} else {
|
||||||
|
state.all = false;
|
||||||
$('#numInput').value = v;
|
$('#numInput').value = v;
|
||||||
}
|
}
|
||||||
saveSettings();
|
saveSettings();
|
||||||
@@ -865,11 +889,23 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/* --- 弹窗通用关闭 --- */
|
/* --- 弹窗通用关闭:只关最上层那一个,避免连带关掉下层的弹窗 --- */
|
||||||
document.addEventListener('click', e => {
|
document.addEventListener('click', e => {
|
||||||
if (e.target.matches('[data-close]') || e.target.classList.contains('modal')) {
|
const isClose = (e.target.matches && e.target.matches('[data-close]')) ||
|
||||||
$$('.modal').forEach(m => m.classList.remove('open'));
|
(e.target.classList && e.target.classList.contains('modal'));
|
||||||
}
|
if (!isClose) return;
|
||||||
|
const open = $$('.modal.open');
|
||||||
|
if (!open.length) return;
|
||||||
|
const top = open[open.length - 1];
|
||||||
|
// 点遮罩时只响应最上层弹窗自身的遮罩
|
||||||
|
if (e.target.classList && e.target.classList.contains('modal') && e.target !== top) return;
|
||||||
|
top.classList.remove('open');
|
||||||
|
});
|
||||||
|
/* Esc 关闭最上层弹窗 */
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key !== 'Escape' && e.key !== 'Esc') return;
|
||||||
|
const open = $$('.modal.open');
|
||||||
|
if (open.length) open[open.length - 1].classList.remove('open');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,7 +921,9 @@
|
|||||||
const mode = $('#balanceMode').value;
|
const mode = $('#balanceMode').value;
|
||||||
const show = (mode === 'custom') && g.key;
|
const show = (mode === 'custom') && g.key;
|
||||||
box.classList.toggle('hidden', !show);
|
box.classList.toggle('hidden', !show);
|
||||||
if (!show) return;
|
// 隐藏时必须清空:否则旧的 data-gk 输入框还会被 saveSettings 读走,
|
||||||
|
// 下次切成自定义比例时会误用上一批分组的权重
|
||||||
|
if (!show) { box.innerHTML = ''; return; }
|
||||||
box.innerHTML =
|
box.innerHTML =
|
||||||
'<p class="hint" style="margin:0 0 6px">按上面分组的顺序填写权重,例:组A=3,组B=1。留空=不计该组。</p>' +
|
'<p class="hint" style="margin:0 0 6px">按上面分组的顺序填写权重,例:组A=3,组B=1。留空=不计该组。</p>' +
|
||||||
g.gk.map((k, i) => {
|
g.gk.map((k, i) => {
|
||||||
@@ -912,6 +950,7 @@
|
|||||||
const mode = s.mode || (s.balance === false ? 'off' : 'weight');
|
const mode = s.mode || (s.balance === false ? 'off' : 'weight');
|
||||||
$('#balanceMode').value = mode;
|
$('#balanceMode').value = mode;
|
||||||
if (s.num) $('#numInput').value = s.num;
|
if (s.num) $('#numInput').value = s.num;
|
||||||
|
state.all = !!s.all;
|
||||||
if (s.source === 'project') {
|
if (s.source === 'project') {
|
||||||
state.source = 'project';
|
state.source = 'project';
|
||||||
$('#sourceSel').value = 'project';
|
$('#sourceSel').value = 'project';
|
||||||
|
|||||||
+207
-55
@@ -6,14 +6,22 @@
|
|||||||
--text: #1d2433;
|
--text: #1d2433;
|
||||||
--text-2: #5b6577;
|
--text-2: #5b6577;
|
||||||
--text-3: #8b95a7;
|
--text-3: #8b95a7;
|
||||||
|
/* 主色阶:--primary 基准、--primary-2 悬停/激活深一档、--primary-3 浅色渐变端 */
|
||||||
--primary: #2f6bff;
|
--primary: #2f6bff;
|
||||||
|
--primary-2: #2559db;
|
||||||
|
--primary-3: #5b8bff;
|
||||||
--primary-weak: #eaf0ff;
|
--primary-weak: #eaf0ff;
|
||||||
|
--brand-2: #6a4bff; /* 品牌渐变收尾(紫)—— logo/面板条/登录条 */
|
||||||
--ok: #12a86a;
|
--ok: #12a86a;
|
||||||
|
--ok-2: #3ec99a; /* 成功渐变亮端(toast) */
|
||||||
--ok-weak: #e7f7ef;
|
--ok-weak: #e7f7ef;
|
||||||
--warn: #e08c1a;
|
--warn: #e08c1a;
|
||||||
|
--warn-2: #f0a54a; /* 警告渐变亮端(toast) */
|
||||||
--warn-weak: #fdf3e2;
|
--warn-weak: #fdf3e2;
|
||||||
--danger: #e0483d;
|
--danger: #e0483d;
|
||||||
|
--danger-2: #ef6c64; /* 危险渐变亮端(toast) */
|
||||||
--danger-weak: #fdecea;
|
--danger-weak: #fdecea;
|
||||||
|
--ink: #1b2440; /* 登录页深底 */
|
||||||
--shadow: 0 1px 2px rgba(20, 30, 60, .05), 0 8px 24px rgba(20, 30, 60, .06);
|
--shadow: 0 1px 2px rgba(20, 30, 60, .05), 0 8px 24px rgba(20, 30, 60, .06);
|
||||||
--shadow-lg: 0 12px 32px rgba(20, 30, 60, .12);
|
--shadow-lg: 0 12px 32px rgba(20, 30, 60, .12);
|
||||||
--radius: 12px;
|
--radius: 12px;
|
||||||
@@ -120,7 +128,7 @@ body {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||||
.btn-primary:hover { background: #2559db; border-color: #2559db; }
|
.btn-primary:hover { background: var(--primary-2); border-color: var(--primary-2); }
|
||||||
.btn.sm { padding: 5px 10px; font-size: 12px; }
|
.btn.sm { padding: 5px 10px; font-size: 12px; }
|
||||||
.btn.xs { padding: 3px 8px; font-size: 11px; }
|
.btn.xs { padding: 3px 8px; font-size: 11px; }
|
||||||
.btn.danger { color: var(--danger); border-color: #f3cfcb; }
|
.btn.danger { color: var(--danger); border-color: #f3cfcb; }
|
||||||
@@ -162,6 +170,24 @@ select, input[type=text], input[type=number], input[type=password] {
|
|||||||
select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47,107,255,.12); }
|
select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47,107,255,.12); }
|
||||||
.field.btns select { width: 100px; }
|
.field.btns select { width: 100px; }
|
||||||
|
|
||||||
|
/* ---------- 副工具栏:导出等次要操作独立成行,避免挤在主工具栏里 ---------- */
|
||||||
|
.subbar {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
|
||||||
|
margin-top: 10px; padding: 10px 14px;
|
||||||
|
background: var(--panel); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.subbar-label {
|
||||||
|
font-size: 12px; font-weight: 700; color: var(--text-3); letter-spacing: .5px;
|
||||||
|
padding-right: 12px; border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.subbar-item {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 12px; color: var(--text-2); cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.subbar-item input[type=checkbox] { width: auto; height: auto; margin: 0; cursor: pointer; }
|
||||||
|
.subbar-item select { width: auto; min-width: 112px; height: 30px; padding: 0 8px; font-size: 12px; }
|
||||||
|
|
||||||
.switch { position: relative; display: inline-block; width: 40px; height: 22px; flex: none; }
|
.switch { position: relative; display: inline-block; width: 40px; height: 22px; flex: none; }
|
||||||
.switch input { opacity: 0; width: 0; height: 0; }
|
.switch input { opacity: 0; width: 0; height: 0; }
|
||||||
.switch span {
|
.switch span {
|
||||||
@@ -242,8 +268,6 @@ select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px
|
|||||||
.dot.stale { background: var(--danger); animation: blink 1s infinite; }
|
.dot.stale { background: var(--danger); animation: blink 1s infinite; }
|
||||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
|
||||||
|
|
||||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
||||||
@media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } }
|
|
||||||
|
|
||||||
/* ---------- 三 panel 布局容器(默认 / 成绩明细与缺考名单同行) ---------- */
|
/* ---------- 三 panel 布局容器(默认 / 成绩明细与缺考名单同行) ---------- */
|
||||||
.layout {
|
.layout {
|
||||||
@@ -278,25 +302,31 @@ select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 表格 ---------- */
|
/* ---------- 表格 ---------- */
|
||||||
.table-scroll { overflow: auto; max-height: 62vh; }
|
.table-scroll { overflow: auto; max-height: 62vh; scrollbar-gutter: stable; }
|
||||||
.table-scroll.short { max-height: 320px; }
|
.table-scroll.short { max-height: 320px; }
|
||||||
.table-scroll.mid { max-height: 380px; }
|
.table-scroll.mid { max-height: 380px; }
|
||||||
|
|
||||||
table.grid { width: 100%; border-collapse: collapse; font-size: 13px; }
|
table.grid { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; table-layout: fixed; }
|
||||||
table.grid th {
|
table.grid th {
|
||||||
position: sticky; top: 0; z-index: 2;
|
position: sticky; top: 0; z-index: 2;
|
||||||
background: linear-gradient(180deg, #fbfcfe, #f4f7fc); color: var(--text-2);
|
background: linear-gradient(180deg, #fbfcfe, #f4f7fc); color: var(--text-2);
|
||||||
text-align: left; font-weight: 600; font-size: 12px;
|
text-align: left; font-weight: 600; font-size: 12px;
|
||||||
padding: 11px 14px; border-bottom: 1px solid var(--border);
|
padding: 11px 14px;
|
||||||
white-space: nowrap;
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
/* 用 box-shadow 画分隔线而非 border:border 在 border-collapse:separate + sticky 下
|
||||||
|
不会与下方 td 边框产生折叠重绘缝隙(消除吸顶表头在滚动边界的 1px 双线/错位观感) */
|
||||||
|
background-clip: padding-box;
|
||||||
box-shadow: inset 0 -1px 0 var(--border);
|
box-shadow: inset 0 -1px 0 var(--border);
|
||||||
}
|
}
|
||||||
table.grid th.sortable { cursor: pointer; user-select: none; }
|
table.grid th.sortable { cursor: pointer; user-select: none; }
|
||||||
table.grid th.sortable:hover { color: var(--primary); }
|
table.grid th.sortable:hover { color: var(--primary); }
|
||||||
table.grid th .arrow { color: var(--primary); margin-left: 3px; }
|
table.grid th .arrow { color: var(--primary); margin-left: 3px; }
|
||||||
table.grid td { padding: 10px 14px; border-bottom: 1px solid var(--border-2); }
|
table.grid td { padding: 10px 14px; border-bottom: 1px solid var(--border-2); vertical-align: middle; background-clip: padding-box; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
/* 含按钮的操作列:不能被 ellipsis 的 overflow:hidden 裁掉按钮 */
|
||||||
|
table.grid td.act { overflow: visible; text-overflow: clip; }
|
||||||
/* 虚拟滚动占位行:不占视觉空间、不响应 hover/条纹、不显示边框 */
|
/* 虚拟滚动占位行:不占视觉空间、不响应 hover/条纹、不显示边框 */
|
||||||
table.grid tbody tr.vspacer td { padding: 0; border: 0; background: transparent !important; }
|
table.grid tbody tr.vspacer td { padding: 0; border: 0; background: transparent !important; }
|
||||||
|
table.grid tbody tr.vspacer:nth-child(even) td { background: transparent !important; }
|
||||||
table.grid tbody tr.vspacer:hover td { background: transparent !important; }
|
table.grid tbody tr.vspacer:hover td { background: transparent !important; }
|
||||||
table.grid tbody tr:nth-child(even) td { background: #fbfcff; }
|
table.grid tbody tr:nth-child(even) td { background: #fbfcff; }
|
||||||
table.grid tbody tr:hover td { background: var(--primary-weak); }
|
table.grid tbody tr:hover td { background: var(--primary-weak); }
|
||||||
@@ -304,22 +334,36 @@ table.grid.compact td, table.grid.compact th { padding: 7px 12px; }
|
|||||||
td.empty { text-align: center; color: var(--text-3); padding: 32px 0; }
|
td.empty { text-align: center; color: var(--text-3); padding: 32px 0; }
|
||||||
td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
td.sid { font-weight: 600; color: var(--text-2); font-variant-numeric: tabular-nums; }
|
td.sid { font-weight: 600; color: var(--text-2); font-variant-numeric: tabular-nums; }
|
||||||
|
/* 数值列右对齐:成绩 / 次数 / 时长纵向扫读时更容易对齐比对 */
|
||||||
|
table.grid td.num, table.grid td.score { text-align: right; }
|
||||||
|
/* 表头对齐 = data-k 派生,与单元格的正则保持一致;避免"表头左对齐、数据右对齐"造成的视觉错列 */
|
||||||
|
table.grid thead th[data-k="score"],
|
||||||
|
table.grid thead th[data-k="attempts"],
|
||||||
|
table.grid thead th[data-k="submit_ts"],
|
||||||
|
table.grid thead th[data-k="duration"] { text-align: right; }
|
||||||
|
table.grid td.score { font-weight: 700; font-size: 14px; }
|
||||||
|
table.grid td.name { font-weight: 600; }
|
||||||
td.name { font-weight: 600; }
|
td.name { font-weight: 600; }
|
||||||
td.score { font-weight: 700; font-variant-numeric: tabular-nums; }
|
td.score { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
.score-high { color: var(--ok); }
|
.score-high { color: var(--ok); }
|
||||||
.score-mid { color: var(--warn); }
|
.score-mid { color: var(--warn); }
|
||||||
.score-low { color: var(--danger); }
|
.score-low { color: var(--danger); }
|
||||||
.diff { animation: flash 1.6s ease-out; }
|
|
||||||
@keyframes flash { from { background: #fff6d9; } to { background: transparent; } }
|
/* 新数据行动画:FLIP 滑动(行移动) + 全新行落入;尊重 prefers-reduced-motion */
|
||||||
|
@keyframes rowDrop { from { opacity: 0; transform: translateY(-12px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
table.grid tbody tr.row-drop-in { animation: rowDrop .42s ease; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
table.grid tbody tr.row-drop-in { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Toast 顶部飘窗通知 ---------- */
|
/* ---------- Toast 顶部飘窗通知 ---------- */
|
||||||
.toast {
|
.toast {
|
||||||
background: linear-gradient(135deg, #2f6bff, #5b8bff);
|
background: linear-gradient(135deg, var(--primary), var(--primary-3));
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.toast-ok { background: linear-gradient(135deg, #12a86a, #3ec99a); color: #fff; }
|
.toast-ok { background: linear-gradient(135deg, var(--ok), var(--ok-2)); color: #fff; }
|
||||||
.toast-error{ background: linear-gradient(135deg, #e0483d, #ef6c64); color: #fff; }
|
.toast-error{ background: linear-gradient(135deg, var(--danger), var(--danger-2)); color: #fff; }
|
||||||
.toast-warn { background: linear-gradient(135deg, #e08c1a, #f0a54a); color: #fff; }
|
.toast-warn { background: linear-gradient(135deg, var(--warn), var(--warn-2)); color: #fff; }
|
||||||
@keyframes toastIn {
|
@keyframes toastIn {
|
||||||
from { opacity: 0; transform: translateY(-12px) scale(.96); }
|
from { opacity: 0; transform: translateY(-12px) scale(.96); }
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
@@ -349,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; }
|
||||||
@@ -379,6 +428,81 @@ table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
|
|||||||
.x { border: 0; background: none; font-size: 22px; line-height: 1; cursor: pointer; color: var(--text-3); }
|
.x { border: 0; background: none; font-size: 22px; line-height: 1; cursor: pointer; color: var(--text-3); }
|
||||||
.x:hover { color: var(--text); }
|
.x:hover { color: var(--text); }
|
||||||
.modal-body { padding: 18px; overflow: auto; }
|
.modal-body { padding: 18px; overflow: auto; }
|
||||||
|
/* 弹窗内的表格不做吸顶:弹窗自身是滚动容器,吸顶表头会与内容错层(且贴边观感差) */
|
||||||
|
.modal-body table.grid th { position: static; }
|
||||||
|
.modal-body table.grid { box-shadow: inset 0 0 0 1px var(--border-2); border-radius: 8px; }
|
||||||
|
/* ================= 统一导出 / 打印弹窗 ================= */
|
||||||
|
.ex-sec {
|
||||||
|
padding: 12px 14px; margin: 0 0 12px;
|
||||||
|
border: 1px solid var(--border); border-radius: 10px; background: #fbfcfe;
|
||||||
|
}
|
||||||
|
.ex-sec[hidden] { display: none; }
|
||||||
|
.ex-sec-title {
|
||||||
|
font-size: 12px; font-weight: 700; color: var(--text-2);
|
||||||
|
letter-spacing: .3px; margin: 0 0 10px;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.ex-sec-title small { font-weight: 500; color: var(--text-3, #8b93a1); }
|
||||||
|
|
||||||
|
/* 导出内容:2×2 卡片单选,一眼看清四种导出各是什么 */
|
||||||
|
.ex-types { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
.ex-type {
|
||||||
|
display: flex; align-items: flex-start; gap: 9px; padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border); border-radius: 9px; background: #fff;
|
||||||
|
cursor: pointer; transition: border-color .15s, box-shadow .15s, background .15s;
|
||||||
|
}
|
||||||
|
.ex-type:hover { border-color: #b9c2d0; }
|
||||||
|
.ex-type:has(input:checked) {
|
||||||
|
border-color: var(--primary, #2f6fed); background: #f3f7ff;
|
||||||
|
box-shadow: 0 0 0 1px var(--primary, #2f6fed) inset;
|
||||||
|
}
|
||||||
|
.ex-type input { margin-top: 3px; flex: none; }
|
||||||
|
.ex-type-t { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||||
|
.ex-type-t b { font-size: 13px; color: var(--text); }
|
||||||
|
.ex-type-t small { font-size: 11.5px; color: var(--text-3, #8b93a1); line-height: 1.45; }
|
||||||
|
|
||||||
|
/* 字段行 */
|
||||||
|
.ex-row { display: flex; flex-wrap: wrap; gap: 14px 18px; align-items: flex-start; }
|
||||||
|
.ex-field {
|
||||||
|
display: flex; flex-direction: column; gap: 6px; min-width: 150px;
|
||||||
|
font-size: 12px; font-weight: 600; color: var(--text-2);
|
||||||
|
}
|
||||||
|
.ex-field select, .ex-field input[type=text], .ex-field input[type=number] {
|
||||||
|
font-size: 13px; font-weight: 400; color: var(--text);
|
||||||
|
}
|
||||||
|
.ex-field-tip {
|
||||||
|
min-width: 200px; flex: 1 1 240px; font-weight: 400;
|
||||||
|
display: flex; flex-direction: column; gap: 4px; justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.ex-field-grow { flex: 1 1 240px; }
|
||||||
|
.ex-field-grow input { width: 100%; }
|
||||||
|
|
||||||
|
/* 班级 / 组别多选:竖排标签 + 列表,便于 Ctrl 多选 */
|
||||||
|
#exClsSel, #exGrpSel {
|
||||||
|
width: 168px; height: 96px; padding: 4px 6px; font-size: 12px;
|
||||||
|
border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
background: #fff; color: var(--text); outline: none;
|
||||||
|
}
|
||||||
|
#exGrpSel { width: 132px; }
|
||||||
|
|
||||||
|
.ex-checks {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 8px 20px; margin-top: 12px;
|
||||||
|
font-size: 12.5px; color: var(--text);
|
||||||
|
}
|
||||||
|
.ex-checks label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
|
||||||
|
|
||||||
|
/* 多考试:勾选列表 */
|
||||||
|
.ex-pick { margin-top: 12px; }
|
||||||
|
.ex-pick-head {
|
||||||
|
display: flex; align-items: center; gap: 10px; margin: 0 0 8px;
|
||||||
|
font-size: 12.5px; font-weight: 600; color: var(--text-2);
|
||||||
|
}
|
||||||
|
.ex-pick-head small { font-weight: 500; color: var(--text-3, #8b93a1); }
|
||||||
|
.table-scroll.mid { max-height: 208px; overflow: auto; }
|
||||||
|
|
||||||
|
/* 合规的旧类名保留(其它地方仍在用 .me-bar 之类的排版习惯) */
|
||||||
|
.me-bar { display: flex; align-items: center; gap: 16px; margin: 0 0 10px; }
|
||||||
|
.me-bar .chk { margin: 0; white-space: nowrap; }
|
||||||
.modal-foot {
|
.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;
|
||||||
@@ -393,11 +517,6 @@ table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
|
|||||||
.fi label .req { color: var(--danger, #e5484d); margin-left: 2px; }
|
.fi label .req { color: var(--danger, #e5484d); margin-left: 2px; }
|
||||||
.fi-inline { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; }
|
.fi-inline { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; }
|
||||||
.fi-inline .chk { font-weight: 500; font-size: 13px; color: var(--text); }
|
.fi-inline .chk { font-weight: 500; font-size: 13px; color: var(--text); }
|
||||||
.fullscores { display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
.fullscores .row { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.fullscores label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.fullscores input { width: 90px; flex: none; }
|
|
||||||
.fullscores .empty { padding: 6px 0; color: var(--text-3); font-size: 12px; text-align: left; }
|
|
||||||
.hint { margin: 0; font-size: 11px; color: var(--text-3); }
|
.hint { margin: 0; font-size: 11px; color: var(--text-3); }
|
||||||
.fi.checks { flex-direction: row; flex-wrap: wrap; gap: 18px; }
|
.fi.checks { flex-direction: row; flex-wrap: wrap; gap: 18px; }
|
||||||
.fi.checks label { display: flex; align-items: center; gap: 6px; font-weight: 500; font-size: 13px; color: var(--text); }
|
.fi.checks label { display: flex; align-items: center; gap: 6px; font-weight: 500; font-size: 13px; color: var(--text); }
|
||||||
@@ -461,9 +580,6 @@ table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
|
|||||||
.login-box .fi { margin-bottom: 14px; }
|
.login-box .fi { margin-bottom: 14px; }
|
||||||
.login-actions { display: flex; gap: 10px; margin-top: 6px; }
|
.login-actions { display: flex; gap: 10px; margin-top: 6px; }
|
||||||
.login-actions .btn { flex: 1; }
|
.login-actions .btn { flex: 1; }
|
||||||
.login-msg { margin-top: 12px; font-size: 12px; min-height: 18px; white-space: pre-wrap; }
|
|
||||||
.login-msg.ok { color: var(--ok); }
|
|
||||||
.login-msg.err { color: var(--danger); }
|
|
||||||
|
|
||||||
/* ---------- 全屏适配 ---------- */
|
/* ---------- 全屏适配 ---------- */
|
||||||
body.fs-on .wrap { max-width: none; padding-top: 8px; }
|
body.fs-on .wrap { max-width: none; padding-top: 8px; }
|
||||||
@@ -483,7 +599,6 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
|||||||
|
|
||||||
/* 平板横屏(769~1024):卡片改 4 列、工具栏字段占满整行 */
|
/* 平板横屏(769~1024):卡片改 4 列、工具栏字段占满整行 */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.grid-2 { grid-template-columns: 1fr; }
|
|
||||||
.field.grow, .field.grow-2 { flex: 1 1 100%; min-width: 0; }
|
.field.grow, .field.grow-2 { flex: 1 1 100%; min-width: 0; }
|
||||||
.cards { grid-template-columns: repeat(4, 1fr); }
|
.cards { grid-template-columns: repeat(4, 1fr); }
|
||||||
.toolbar { gap: 14px; }
|
.toolbar { gap: 14px; }
|
||||||
@@ -503,8 +618,6 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
|||||||
.field { flex: 1 1 100%; min-width: 0; }
|
.field { flex: 1 1 100%; min-width: 0; }
|
||||||
.field.btns { flex: 1 1 100%; flex-wrap: wrap; row-gap: 8px; }
|
.field.btns { flex: 1 1 100%; flex-wrap: wrap; row-gap: 8px; }
|
||||||
.field.btns select { width: 100%; }
|
.field.btns select { width: 100%; }
|
||||||
/* 导出区的复选标签在小屏独占一行,避免和按钮挤成一团 */
|
|
||||||
.field.btns > label[style*="cursor:pointer"] { flex: 1 1 auto; }
|
|
||||||
.cards { grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
.cards { grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||||
.card { padding: 10px 12px; }
|
.card { padding: 10px 12px; }
|
||||||
.card b { font-size: 20px; }
|
.card b { font-size: 20px; }
|
||||||
@@ -565,24 +678,72 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
|||||||
.dash-hero .brand-title { color: #fff; font-size: 17px; }
|
.dash-hero .brand-title { color: #fff; font-size: 17px; }
|
||||||
.dash-hero a.back { color: #fff; opacity: .9; font-size: 13px; text-decoration: none; font-weight: 600; }
|
.dash-hero a.back { color: #fff; opacity: .9; font-size: 13px; text-decoration: none; font-weight: 600; }
|
||||||
.dash-hero a.back:hover { opacity: 1; text-decoration: underline; }
|
.dash-hero a.back:hover { opacity: 1; text-decoration: underline; }
|
||||||
.dash-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
.dash-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
.dash-controls select { width: 230px; height: 38px; }
|
.dash-controls select { width: 230px; height: 38px; }
|
||||||
|
/* 看板顶栏的「主系统 / 退出」用描边区分于同排的半透明操作按钮 */
|
||||||
|
.dash-controls .btn.back {
|
||||||
|
background: transparent; border-color: rgba(255, 255, 255, .45); font-weight: 600;
|
||||||
|
}
|
||||||
|
.dash-controls .btn.back:hover { background: rgba(255, 255, 255, .18); border-color: #fff; }
|
||||||
|
/* 窄屏:下拉与按钮换行铺开,避免 230px 固定宽把顶栏撑出横向滚动条 */
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.dash-hero { padding: 14px 16px; }
|
||||||
|
.dash-controls { width: 100%; gap: 8px; }
|
||||||
|
.dash-controls select { width: auto; flex: 1 1 160px; min-width: 0; }
|
||||||
|
.dash-controls .btn { flex: 0 0 auto; }
|
||||||
|
}
|
||||||
.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; }
|
||||||
|
|
||||||
.class-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; margin-top: 14px; }
|
|
||||||
.class-card {
|
|
||||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
|
||||||
padding: 16px 18px; box-shadow: var(--shadow); transition: transform .18s, box-shadow .18s;
|
|
||||||
}
|
|
||||||
.class-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-lg); }
|
|
||||||
.class-card h3 { margin: 0 0 2px; font-size: 16px; display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
|
|
||||||
.class-card .rate-num { font-size: 15px; font-weight: 800; white-space: nowrap; }
|
|
||||||
.class-card .sub { font-size: 12px; color: var(--text-3); margin-bottom: 12px; }
|
|
||||||
.bar { height: 9px; border-radius: 999px; background: var(--border-2); overflow: hidden; }
|
.bar { height: 9px; border-radius: 999px; background: var(--border-2); overflow: hidden; }
|
||||||
.bar > i { display: block; height: 100%; border-radius: 999px; transition: width .5s ease; }
|
.bar > i { display: block; height: 100%; border-radius: 999px; transition: width .5s ease; }
|
||||||
.class-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; margin-top: 14px; }
|
.class-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; margin-top: 14px; }
|
||||||
@@ -590,23 +751,10 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
|||||||
.class-metrics .m span { color: var(--text-3); font-size: 11px; }
|
.class-metrics .m span { color: var(--text-3); font-size: 11px; }
|
||||||
.class-metrics .m b { font-size: 17px; font-weight: 700; }
|
.class-metrics .m b { font-size: 17px; font-weight: 700; }
|
||||||
.metric-ok b { color: var(--ok); }
|
.metric-ok b { color: var(--ok); }
|
||||||
.metric-warn b { color: var(--warn); }
|
|
||||||
.metric-danger b { color: var(--danger); }
|
.metric-danger b { color: var(--danger); }
|
||||||
|
|
||||||
.empty { text-align: center; color: var(--text-3); padding: 40px 0; }
|
.empty { text-align: center; color: var(--text-3); padding: 40px 0; }
|
||||||
|
|
||||||
/* 班级卡片:可点击进入该班详情 */
|
|
||||||
.class-card.clickable { cursor: pointer; }
|
|
||||||
.class-card.clickable:hover { border-color: var(--primary); }
|
|
||||||
.grp-tag {
|
|
||||||
display: inline-block; margin-top: 12px; padding: 2px 10px; border-radius: 999px;
|
|
||||||
font-size: 11px; font-weight: 600; color: var(--primary); background: var(--primary-weak);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 班级详情头部 */
|
|
||||||
.detail-head { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-top: 18px; }
|
|
||||||
.detail-head h2 { margin: 0; font-size: 18px; font-weight: 700; }
|
|
||||||
|
|
||||||
/* 分组对比:A 在左、B 在右 */
|
/* 分组对比:A 在左、B 在右 */
|
||||||
.group-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 14px; }
|
.group-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 14px; }
|
||||||
@media (max-width: 860px) { .group-compare { grid-template-columns: 1fr; } }
|
@media (max-width: 860px) { .group-compare { grid-template-columns: 1fr; } }
|
||||||
@@ -641,8 +789,8 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
|||||||
.rk-3 { background: #fbe9dd; color: #b45309; }
|
.rk-3 { background: #fbe9dd; color: #b45309; }
|
||||||
|
|
||||||
/* 防溢出:网格子项允许收缩,长内容不撑破页面 */
|
/* 防溢出:网格子项允许收缩,长内容不撑破页面 */
|
||||||
.class-grid, .group-compare, .cards, .gpanel, .class-card { min-width: 0; }
|
.group-compare, .cards, .gpanel { min-width: 0; }
|
||||||
.gpanel, .class-card { overflow: hidden; }
|
.gpanel { overflow: hidden; }
|
||||||
.member-list td.name { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.member-list td.name { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
/* 看板全屏:整页不溢出屏幕(外层锁 100vh + overflow:hidden,内部区域自行滚动) */
|
/* 看板全屏:整页不溢出屏幕(外层锁 100vh + overflow:hidden,内部区域自行滚动) */
|
||||||
@@ -655,13 +803,12 @@ body.dash.fs-on .sec-title { margin: 12px 0 4px; font-size: 14px; }
|
|||||||
body.dash.fs-on .cards { gap: 10px; margin-top: 10px; }
|
body.dash.fs-on .cards { gap: 10px; margin-top: 10px; }
|
||||||
body.dash.fs-on .card { padding: 10px 12px; }
|
body.dash.fs-on .card { padding: 10px 12px; }
|
||||||
body.dash.fs-on .card b { font-size: 20px; }
|
body.dash.fs-on .card b { font-size: 20px; }
|
||||||
body.dash.fs-on #overview, body.dash.fs-on #detail {
|
|
||||||
flex: 1 1 auto; min-height: 0; overflow: auto;
|
|
||||||
}
|
|
||||||
body.dash.fs-on .class-grid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
|
|
||||||
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: 30vh; }
|
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; }
|
||||||
|
|
||||||
|
|
||||||
/* ================= 随机抽取页 ================= */
|
/* ================= 随机抽取页 ================= */
|
||||||
@@ -689,6 +836,11 @@ body.dash.fs-on .member-list { max-height: 30vh; }
|
|||||||
background: linear-gradient(180deg, #f2fbf5, #e9f7ee);
|
background: linear-gradient(180deg, #f2fbf5, #e9f7ee);
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
}
|
}
|
||||||
|
/* 只抽 1 人时的副行(学号 · 班级 · 组别),字号小于主名字 */
|
||||||
|
.draw-stage-sub {
|
||||||
|
margin-top: 8px; font-size: 14px; font-weight: 600;
|
||||||
|
color: var(--text-2); letter-spacing: 0;
|
||||||
|
}
|
||||||
@keyframes drawShake {
|
@keyframes drawShake {
|
||||||
from { transform: translateY(-1px) scale(.995); }
|
from { transform: translateY(-1px) scale(.995); }
|
||||||
to { transform: translateY(1px) scale(1.005); }
|
to { transform: translateY(1px) scale(1.005); }
|
||||||
|
|||||||
+223
-54
@@ -9,7 +9,14 @@ const NY_APP_NAME = 'NY问卷考试系统数据平台';
|
|||||||
const NY_VERSION = '1.0.0';
|
const NY_VERSION = '1.0.0';
|
||||||
|
|
||||||
define('NY_ROOT', str_replace('\\', '/', __DIR__));
|
define('NY_ROOT', str_replace('\\', '/', __DIR__));
|
||||||
define('NY_DATA', NY_ROOT . '/data');
|
// 数据目录默认指向项目下的 data/;测试可通过 NY_DATA 环境变量整体重定向到临时目录,
|
||||||
|
// 避免 e2e 把 mock 服务的连接配置写进生产库(曾因此把生产 base_url 改成 127.0.0.1 导致登录失败)。
|
||||||
|
$nyData = NY_ROOT . '/data';
|
||||||
|
$nyDataEnv = getenv('NY_DATA');
|
||||||
|
if ($nyDataEnv !== false && $nyDataEnv !== '') {
|
||||||
|
$nyData = rtrim(str_replace('\\', '/', $nyDataEnv), '/');
|
||||||
|
}
|
||||||
|
define('NY_DATA', $nyData);
|
||||||
|
|
||||||
// ---------------- 环境预检 ----------------
|
// ---------------- 环境预检 ----------------
|
||||||
if (PHP_VERSION_ID < 80000) {
|
if (PHP_VERSION_ID < 80000) {
|
||||||
@@ -119,6 +126,19 @@ function ny_csrf_token(): string
|
|||||||
return $_SESSION['ny_csrf'];
|
return $_SESSION['ny_csrf'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取请求原始 body(仅读一次并缓存)。
|
||||||
|
* 写接口既要校验 CSRF(从 body 取 _csrf)又要解析业务 JSON,二者都需读 php://input;
|
||||||
|
* 大多数 SAPI 下 php://input 可重复读,但个别环境只能读一次,因此这里统一缓存避免第二次读为空。
|
||||||
|
*/
|
||||||
|
function ny_request_body(): string
|
||||||
|
{
|
||||||
|
if (!isset($GLOBALS['__ny_body'])) {
|
||||||
|
$GLOBALS['__ny_body'] = (string)@file_get_contents('php://input');
|
||||||
|
}
|
||||||
|
return $GLOBALS['__ny_body'];
|
||||||
|
}
|
||||||
|
|
||||||
/** 校验 CSRF token(写操作调用)。失败返回 false 并应拒绝请求。 */
|
/** 校验 CSRF token(写操作调用)。失败返回 false 并应拒绝请求。 */
|
||||||
function ny_csrf_ok(): bool
|
function ny_csrf_ok(): bool
|
||||||
{
|
{
|
||||||
@@ -129,7 +149,7 @@ function ny_csrf_ok(): bool
|
|||||||
$given = (string)$_POST['_csrf'];
|
$given = (string)$_POST['_csrf'];
|
||||||
} else {
|
} else {
|
||||||
// JSON body 里的 _csrf(api.php 写接口多走 JSON)
|
// JSON body 里的 _csrf(api.php 写接口多走 JSON)
|
||||||
$raw = (string)file_get_contents('php://input');
|
$raw = ny_request_body();
|
||||||
if ($raw !== '') {
|
if ($raw !== '') {
|
||||||
$j = json_decode($raw, true);
|
$j = json_decode($raw, true);
|
||||||
if (is_array($j) && isset($j['_csrf'])) {
|
if (is_array($j) && isset($j['_csrf'])) {
|
||||||
@@ -141,53 +161,9 @@ function ny_csrf_ok(): bool
|
|||||||
return $given !== '' && hash_equals($token, $given);
|
return $given !== '' && hash_equals($token, $given);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- 登录失败限流 ----------------
|
// ---------------- 登录失败限流(已移除) ----------------
|
||||||
/**
|
// 注:单机内部工具场景下,5 次失败锁 10 分钟的限制常因配置写错/测环境串味而把正常用户挡在门外,
|
||||||
* 登录失败限流:按 IP 记录连续失败次数与时间戳(写入 data/login_fail.json)。
|
// 且无实际爆破价值(仅内网使用)。应需求已整体移除:登录不再限流,失败只即时提示原因。
|
||||||
* 5 次失败后锁 10 分钟。仅防御暴力破解,不做超强语义(单机小工具足够)。
|
|
||||||
*/
|
|
||||||
function ny_login_throttle_check(): array
|
|
||||||
{
|
|
||||||
$path = NY_DATA . '/login_fail.json';
|
|
||||||
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
|
||||||
$rec = ['count' => 0, 'until' => 0];
|
|
||||||
if (is_file($path)) {
|
|
||||||
$j = json_decode((string)file_get_contents($path), true);
|
|
||||||
if (is_array($j) && isset($j[$ip])) {
|
|
||||||
$rec = array_merge($rec, $j[$ip]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $rec;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ny_login_throttle_hit(): void
|
|
||||||
{
|
|
||||||
$path = NY_DATA . '/login_fail.json';
|
|
||||||
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
|
||||||
$all = is_file($path) ? (json_decode((string)file_get_contents($path), true) ?: []) : [];
|
|
||||||
$rec = isset($all[$ip]) ? $all[$ip] : ['count' => 0, 'until' => 0];
|
|
||||||
$rec['count'] = ((int)($rec['count'] ?? 0)) + 1;
|
|
||||||
if ($rec['count'] >= 5) {
|
|
||||||
$rec['count'] = 0; // 重新计数,进入冷却
|
|
||||||
$rec['until'] = time() + 600; // 锁 10 分钟
|
|
||||||
}
|
|
||||||
$all[$ip] = $rec;
|
|
||||||
@file_put_contents($path, json_encode($all, JSON_UNESCAPED_UNICODE), LOCK_EX);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ny_login_throttle_reset(): void
|
|
||||||
{
|
|
||||||
$path = NY_DATA . '/login_fail.json';
|
|
||||||
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
|
||||||
if (!is_file($path)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$all = json_decode((string)file_get_contents($path), true) ?: [];
|
|
||||||
if (isset($all[$ip])) {
|
|
||||||
unset($all[$ip]);
|
|
||||||
@file_put_contents($path, json_encode($all, JSON_UNESCAPED_UNICODE), LOCK_EX);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 判断是否已安装(只看文件 config:base_url 非空即视为已具备连接配置) */
|
/** 判断是否已安装(只看文件 config:base_url 非空即视为已具备连接配置) */
|
||||||
function ny_is_installed(): bool
|
function ny_is_installed(): bool
|
||||||
@@ -200,7 +176,7 @@ function ny_is_installed(): bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- 目录初始化 ----------------
|
// ---------------- 目录初始化 ----------------
|
||||||
foreach (['', '/cache', '/uploads', '/snapshots'] as $sub) {
|
foreach (['', '/cache', '/uploads'] as $sub) {
|
||||||
$dir = NY_DATA . $sub;
|
$dir = NY_DATA . $sub;
|
||||||
if (!is_dir($dir)) {
|
if (!is_dir($dir)) {
|
||||||
@mkdir($dir, 0777, true);
|
@mkdir($dir, 0777, true);
|
||||||
@@ -306,29 +282,198 @@ 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 / 部署文档);
|
||||||
|
// 明文密码仍需保留以便卷王 token 失效时平台自动重新登录,无法改为只存哈希。
|
||||||
|
if ($fileOk && is_file(ny_config_file())) {
|
||||||
|
@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];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析「本卷满分 + 合格率」:
|
* 解析「本卷满分 + 合格率」:
|
||||||
* 优先取该试卷的独立配置(试卷之间互相隔离),没有则用全局配置。
|
* 优先取该试卷的独立配置(试卷之间互相隔离),没有则用全局配置。
|
||||||
@@ -359,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
|
||||||
{
|
{
|
||||||
@@ -499,7 +669,6 @@ spl_autoload_register(function (string $class): void {
|
|||||||
'Roster' => NY_ROOT . '/lib/Roster.php',
|
'Roster' => NY_ROOT . '/lib/Roster.php',
|
||||||
'DataService' => NY_ROOT . '/lib/DataService.php',
|
'DataService' => NY_ROOT . '/lib/DataService.php',
|
||||||
'DataCache' => NY_ROOT . '/lib/DataCache.php',
|
'DataCache' => NY_ROOT . '/lib/DataCache.php',
|
||||||
'Snapshot' => NY_ROOT . '/lib/Snapshot.php',
|
|
||||||
'Db' => NY_ROOT . '/lib/Db.php',
|
'Db' => NY_ROOT . '/lib/Db.php',
|
||||||
];
|
];
|
||||||
if (isset($map[$class]) && is_file($map[$class])) {
|
if (isset($map[$class]) && is_file($map[$class])) {
|
||||||
|
|||||||
+342
-51
@@ -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,28 +56,32 @@ 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" id="linkIndex" href="index.php" title="返回主系统(成绩明细 / 名单 / 抽取)">← 主系统</a>
|
||||||
|
<?php if (!$publicView): ?>
|
||||||
|
<a class="btn back" id="linkLogout" href="logout.php" title="退出登录">退出</a>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
<div class="card warn accent-orange"><span>缺考</span><b id="bAbsent">-</b></div>
|
<div class="card warn accent-orange"><span>缺考</span><b id="bAbsent">-</b></div>
|
||||||
<div class="card accent-purple"><span>提交率</span><b id="bRate">-</b></div>
|
|
||||||
<div class="card accent-teal"><span>平均分</span><b id="bAvg">-</b></div>
|
<div class="card accent-teal"><span>平均分</span><b id="bAvg">-</b></div>
|
||||||
<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>
|
||||||
@@ -92,6 +112,56 @@ 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) */
|
||||||
|
const REDUCED_MOTION = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||||
|
function captureFlip(container){
|
||||||
|
const m = new Map();
|
||||||
|
if (!container) return m;
|
||||||
|
container.querySelectorAll('tr[data-sid]').forEach(tr => {
|
||||||
|
const sid = tr.getAttribute('data-sid');
|
||||||
|
if (sid) m.set(sid, tr.getBoundingClientRect().top);
|
||||||
|
});
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
function playFlip(container, prev){
|
||||||
|
if (!container || !prev || REDUCED_MOTION) return;
|
||||||
|
container.querySelectorAll('tr[data-sid]').forEach(tr => {
|
||||||
|
const sid = tr.getAttribute('data-sid');
|
||||||
|
if (!sid) return;
|
||||||
|
const oldTop = prev.get(sid);
|
||||||
|
if (oldTop === undefined) { tr.classList.add('row-drop-in'); return; }
|
||||||
|
const dy = oldTop - tr.getBoundingClientRect().top;
|
||||||
|
if (Math.abs(dy) < 1) return;
|
||||||
|
tr.style.transition = 'none';
|
||||||
|
tr.style.transform = 'translateY(' + dy + 'px)';
|
||||||
|
void tr.offsetWidth;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
tr.style.transition = 'transform .42s cubic-bezier(.22,.61,.36,1)';
|
||||||
|
tr.style.transform = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* URL 状态:?pid=&class=&auto=1|0 */
|
/* URL 状态:?pid=&class=&auto=1|0 */
|
||||||
function readUrl(){
|
function readUrl(){
|
||||||
const u = new URL(location.href);
|
const u = new URL(location.href);
|
||||||
@@ -106,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 : ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 本地缓存 */
|
/* 本地缓存 */
|
||||||
@@ -122,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);
|
||||||
@@ -130,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');
|
||||||
@@ -196,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');
|
||||||
@@ -206,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];
|
||||||
@@ -218,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);
|
||||||
@@ -235,18 +356,23 @@ function membersTable(members){
|
|||||||
return '<div class="empty" style="padding:18px 0">暂无考试成员</div>';
|
return '<div class="empty" style="padding:18px 0">暂无考试成员</div>';
|
||||||
}
|
}
|
||||||
return '<div class="member-list"><table class="grid compact">' +
|
return '<div class="member-list"><table class="grid compact">' +
|
||||||
'<thead><tr><th style="width:60px">排名</th><th>姓名</th><th style="width:80px">成绩</th></tr></thead><tbody>' +
|
'<thead><tr><th style="width:20%;text-align:right">排名</th><th style="width:55%">姓名</th><th style="width:25%" data-k="score">成绩</th></tr></thead><tbody>' +
|
||||||
members.map(m => {
|
members.map(m => {
|
||||||
const rk = 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;
|
||||||
const cls = sc == null ? '' : (sc >= 90 ? 'score-high' : (sc >= 60 ? 'score-mid' : 'score-low'));
|
// 红绿由后台配置的满分成数 / 合格率决定(见 scoreGrade)
|
||||||
|
const g = scoreGrade(sc);
|
||||||
|
const cls = g.cls;
|
||||||
|
const tip = g.cls ? ' title="占满分 ' + g.pct.toFixed(1) + '% 合格线 ' + g.line + ' 分"' : '';
|
||||||
const att = (m.attempts || 1) > 1;
|
const 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>' : '';
|
||||||
return '<tr' + (att ? ' class="clickable" onclick="openAttempts(' + JSON.stringify(m.sid) + ')"' : '') + '>' +
|
// 注意:不要把 sid 直接拼进 onclick="...",JSON.stringify 产生的双引号会提前闭合
|
||||||
|
// HTML 属性(onclick="openAttempts("123")"),点击必然报错。改用 data-sid + 事件委托。
|
||||||
|
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>';
|
||||||
@@ -258,17 +384,23 @@ 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('');
|
||||||
$('#attTitle').textContent = info.name + ' · 共 ' + (info.attempts || list.length) + ' 次提交';
|
$('#attTitle').textContent = info.name + ' · 共 ' + (info.attempts || list.length) + ' 次提交';
|
||||||
$('#attBody').innerHTML = list.length
|
$('#attBody').innerHTML = list.length
|
||||||
? '<table class="grid compact"><thead><tr><th>提交时间</th><th>成绩</th><th>考试时长</th></tr></thead><tbody>' + rows + '</tbody></table>'
|
? '<table class="grid compact att-table"><thead><tr>' +
|
||||||
|
'<th data-k="submit_ts" style="width:40%">提交时间</th>' +
|
||||||
|
'<th data-k="score" style="width:30%">成绩</th>' +
|
||||||
|
'<th data-k="duration" style="width:30%">考试时长</th>' +
|
||||||
|
'</tr></thead><tbody>' + rows + '</tbody></table>'
|
||||||
: '<div class="empty">无提交明细</div>';
|
: '<div class="empty">无提交明细</div>';
|
||||||
document.querySelector('#attemptsModal').classList.add('open');
|
document.querySelector('#attemptsModal').classList.add('open');
|
||||||
}
|
}
|
||||||
@@ -276,19 +408,116 @@ function closeAttempts(){
|
|||||||
const m = document.querySelector('#attemptsModal');
|
const m = document.querySelector('#attemptsModal');
|
||||||
if (m) m.classList.remove('open');
|
if (m) m.classList.remove('open');
|
||||||
}
|
}
|
||||||
|
// 成员榜下钻:事件委托(#boardBody 常驻,内部表格会被整体重绘)
|
||||||
|
(function bindMemberDrill(){
|
||||||
|
const box = document.querySelector('#boardBody');
|
||||||
|
if (!box) return;
|
||||||
|
box.addEventListener('click', e => {
|
||||||
|
const tr = e.target.closest ? e.target.closest('tr[data-sid]') : null;
|
||||||
|
if (!tr || !box.contains(tr)) return;
|
||||||
|
openAttempts(tr.getAttribute('data-sid'));
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
// Esc 关闭最上层弹窗
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key !== 'Escape' && e.key !== 'Esc') return;
|
||||||
|
const open = Array.prototype.slice.call(document.querySelectorAll('.modal.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') || '');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
/* ---------- 看板占位(未选问卷/班级) ---------- */
|
/* ---------- 看板占位(未选问卷/班级) ---------- */
|
||||||
function renderPlaceholderBoard(){
|
/* 占位卡片配色:与真实看板卡片(renderBoard)一一对应,保证空态左侧色条一致 */
|
||||||
|
const PLACEHOLDER_ACCENT = {
|
||||||
|
'总人数': 'accent-blue', '已提交': 'accent-green', '缺考': 'accent-orange',
|
||||||
|
'平均分': 'accent-teal', '合格率': 'accent-indigo'
|
||||||
|
};
|
||||||
|
function renderPlaceholderBoard(msg){
|
||||||
|
const m = msg || '选择一个问卷与班级查看考试整体情况';
|
||||||
const cards = [
|
const cards = [
|
||||||
['总人数','-'],['已提交','-'],['缺考','-'],
|
['总人数','-'],['已提交','-'],['缺考','-'],
|
||||||
['提交率','-'],['平均分','-'],['合格率','-']
|
['平均分','-'],['合格率','-']
|
||||||
];
|
];
|
||||||
$('#boardSummary').innerHTML = cards.map(c =>
|
$('#boardSummary').innerHTML = cards.map(c =>
|
||||||
'<div class="card"><span>' + c[0] + '</span><b>' + c[1] + '</b></div>').join('');
|
// 占位卡片与真实看板卡片顺序、配色一致(accent-* 决定左侧色条),避免空态蓝条与真实卡片不一致
|
||||||
$('#boardBody').innerHTML = '<div class="empty">请选择问卷与班级查看考试整体情况</div>';
|
'<div class="card ' + PLACEHOLDER_ACCENT[c[0]] + '"><span>' + c[0] + '</span><b>' + c[1] + '</b></div>').join('');
|
||||||
|
$('#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'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,17 +531,28 @@ function markStale(msg){
|
|||||||
const d = $('#liveDot');
|
const d = $('#liveDot');
|
||||||
if (d) { d.classList.remove('on'); d.classList.add('stale'); }
|
if (d) { d.classList.remove('on'); d.classList.add('stale'); }
|
||||||
const lu = $('#lastUpdate');
|
const lu = $('#lastUpdate');
|
||||||
if (lu) lu.textContent = '刷新中断(仍显示上次数据)';
|
// 具体原因放进 title(鼠标悬停可见),正文保持简短
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 渲染单班看板 ---------- */
|
/* ---------- 渲染单班看板 ---------- */
|
||||||
|
/** 数值兜底:后端字段缺失时显示占位符,避免界面出现 NaN / undefined */
|
||||||
|
function dv(v, dash){ return (v === undefined || v === null || v === '' || Number.isNaN(v)) ? (dash || '-') : 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 });
|
||||||
|
|
||||||
@@ -331,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 = {};
|
||||||
@@ -345,25 +587,23 @@ function renderBoard(){
|
|||||||
const totalAttempts = (overall.attempts_total || 0);
|
const totalAttempts = (overall.attempts_total || 0);
|
||||||
const _hs2 = document.getElementById('heroSub');
|
const _hs2 = document.getElementById('heroSub');
|
||||||
if (_hs2) _hs2.textContent = cls + ' · 问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' +
|
if (_hs2) _hs2.textContent = cls + ' · 问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' +
|
||||||
'总 ' + c.total + ' / 已交 ' + c.submitted + ' / 缺考 ' + c.absent +
|
'总 ' + dv(c.total, 0) + ' / 已交 ' + dv(c.submitted, 0) + ' / 缺考 ' + dv(c.absent, 0) +
|
||||||
(totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : '');
|
(totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : '');
|
||||||
|
|
||||||
// 6 卡汇总
|
// 5 卡汇总(提交率卡片已移除)
|
||||||
const color = rateColor(c.rate || 0);
|
|
||||||
$('#boardSummary').innerHTML =
|
$('#boardSummary').innerHTML =
|
||||||
'<div class="card accent-blue"><span>总人数</span><b>' + c.total + '</b></div>' +
|
'<div class="card accent-blue"><span>总人数</span><b>' + dv(c.total, 0) + '</b></div>' +
|
||||||
'<div class="card ok accent-green"><span>已提交</span><b>' + c.submitted + '</b></div>' +
|
'<div class="card ok accent-green"><span>已提交</span><b>' + dv(c.submitted, 0) + '</b></div>' +
|
||||||
'<div class="card warn accent-orange"><span>缺考</span><b>' + c.absent + '</b></div>' +
|
'<div class="card warn accent-orange"><span>缺考</span><b>' + dv(c.absent, 0) + '</b></div>' +
|
||||||
'<div class="card accent-purple"><span>提交率</span><b style="color:' + color + '">' + c.rate + '%</b></div>' +
|
'<div class="card accent-teal"><span>平均分</span><b>' + dv(c.avg) + '</b></div>' +
|
||||||
'<div class="card accent-teal"><span>平均分</span><b>' + (c.avg != null ? c.avg : '-') + '</b></div>' +
|
'<div class="card accent-indigo"><span>合格率(≥' + (DASH && DASH.pass_rate != null ? DASH.pass_rate : 60) + '%满分)</span><b>' + pv(c.pass_rate) + '</b></div>';
|
||||||
'<div class="card accent-indigo"><span>合格率(≥' + (DASH && DASH.pass_rate != null ? DASH.pass_rate : 60) + '%满分)</span><b>' + c.pass_rate + '%</b></div>';
|
|
||||||
|
|
||||||
const groups = (c.groups || []).filter(g => g.group !== '(未分组)');
|
const groups = (c.groups || []).filter(g => g.group !== '(未分组)');
|
||||||
const box = $('#boardBody');
|
const box = $('#boardBody');
|
||||||
if (!groups.length) {
|
if (!groups.length) {
|
||||||
box.innerHTML = '<div class="gpanel">' +
|
box.innerHTML = '<div class="gpanel">' +
|
||||||
'<div class="ghead"><strong>本班考试成员</strong>' +
|
'<div class="ghead"><strong>本班考试成员</strong>' +
|
||||||
'<span class="rate-num">' + c.submitted + ' 人</span></div>' +
|
'<span class="rate-num">' + dv(c.submitted, 0) + ' 人</span></div>' +
|
||||||
'<div class="sub">该班级未设置分组(A/B),以下按成绩从高到低排列' +
|
'<div class="sub">该班级未设置分组(A/B),以下按成绩从高到低排列' +
|
||||||
(c.attempts > c.submitted ? ' · 共 ' + c.attempts + ' 次刷题' : '') + '</div>' +
|
(c.attempts > c.submitted ? ' · 共 ' + c.attempts + ' 次刷题' : '') + '</div>' +
|
||||||
membersTable(c.members) + '</div>';
|
membersTable(c.members) + '</div>';
|
||||||
@@ -372,17 +612,17 @@ function renderBoard(){
|
|||||||
const gc = rateColor(g.rate || 0);
|
const gc = rateColor(g.rate || 0);
|
||||||
return '<div class="gpanel">' +
|
return '<div class="gpanel">' +
|
||||||
'<div class="ghead"><strong>' + esc(g.group) + ' 组</strong>' +
|
'<div class="ghead"><strong>' + esc(g.group) + ' 组</strong>' +
|
||||||
'<span class="rate-num" style="color:' + gc + '">' + g.rate + '%</span></div>' +
|
'<span class="rate-num" style="color:' + gc + '">' + dv(g.rate, 0) + '%</span></div>' +
|
||||||
'<div class="sub">已提交 ' + g.submitted + ' / 总人数 ' + g.total + ' · 缺考 ' + g.absent +
|
'<div class="sub">已提交 ' + dv(g.submitted, 0) + ' / 总人数 ' + dv(g.total, 0) + ' · 缺考 ' + dv(g.absent, 0) +
|
||||||
(g.attempts > g.submitted ? ' · 共 ' + g.attempts + ' 次刷题' : '') + '</div>' +
|
(g.attempts > g.submitted ? ' · 共 ' + g.attempts + ' 次刷题' : '') + '</div>' +
|
||||||
'<div class="bar"><i style="width:' + g.rate + '%;background:' + gc + '"></i></div>' +
|
'<div class="bar"><i style="width:' + dv(g.rate, 0) + '%;background:' + gc + '"></i></div>' +
|
||||||
'<div class="class-metrics">' +
|
'<div class="class-metrics">' +
|
||||||
'<div class="m"><span>平均分</span><b>' + (g.avg != null ? g.avg : '-') + '</b></div>' +
|
'<div class="m"><span>平均分</span><b>' + dv(g.avg) + '</b></div>' +
|
||||||
'<div class="m"><span>合格率</span><b>' + g.pass_rate + '%</b></div>' +
|
'<div class="m"><span>合格率</span><b>' + pv(g.pass_rate) + '</b></div>' +
|
||||||
'<div class="m metric-ok"><span>最高分</span><b>' + (g.max != null ? g.max : '-') + '</b></div>' +
|
'<div class="m metric-ok"><span>最高分</span><b>' + dv(g.max) + '</b></div>' +
|
||||||
'<div class="m metric-danger"><span>最低分</span><b>' + (g.min != null ? g.min : '-') + '</b></div>' +
|
'<div class="m metric-danger"><span>最低分</span><b>' + dv(g.min) + '</b></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="mem-title">考试成员 · 按成绩降序(' + g.submitted + ' 人)</div>' +
|
'<div class="mem-title">考试成员 · 按成绩降序(' + dv(g.submitted, 0) + ' 人)</div>' +
|
||||||
membersTable(g.members) +
|
membersTable(g.members) +
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('') + '</div>';
|
}).join('') + '</div>';
|
||||||
@@ -426,13 +666,24 @@ 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;
|
||||||
|
// 标签页在后台时不做自动轮询(省流量、也避免无人值守时反复请求卷王)
|
||||||
|
if (!manual && document.hidden) return;
|
||||||
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');
|
||||||
@@ -445,11 +696,14 @@ async function refreshBoard(manual){
|
|||||||
sel.disabled = !classes.length;
|
sel.disabled = !classes.length;
|
||||||
if (classes.length) { sel.value = classes[0]; CUR.cls = classes[0]; writeUrl({ cls: classes[0] }); }
|
if (classes.length) { sel.value = classes[0]; CUR.cls = classes[0]; writeUrl({ cls: classes[0] }); }
|
||||||
}
|
}
|
||||||
|
// 刷新前捕获各行位置,刷新后做 FLIP:新数据行滑入、其它行下移
|
||||||
|
const flipPrev = captureFlip($('#boardBody'));
|
||||||
renderBoard();
|
renderBoard();
|
||||||
|
playFlip($('#boardBody'), flipPrev);
|
||||||
$('#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 {
|
||||||
@@ -457,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(); });
|
||||||
@@ -474,6 +745,16 @@ $('#btnFullscreen').addEventListener('click', toggleFullscreen);
|
|||||||
document.addEventListener('fullscreenchange', onFsChange);
|
document.addEventListener('fullscreenchange', onFsChange);
|
||||||
document.addEventListener('webkitfullscreenchange', onFsChange);
|
document.addEventListener('webkitfullscreenchange', onFsChange);
|
||||||
|
|
||||||
|
/* 切回前台:立刻补一次数据并重排定时器;切到后台:停掉轮询 */
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
if (timer) { clearInterval(timer); timer = null; }
|
||||||
|
} else {
|
||||||
|
refreshBoard();
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/* ---------- 偏好恢复(自动刷新开关 + 间隔,含 ?interval= 深链) ---------- */
|
/* ---------- 偏好恢复(自动刷新开关 + 间隔,含 ?interval= 深链) ---------- */
|
||||||
function restoreDashPrefs(){
|
function restoreDashPrefs(){
|
||||||
const qs = new URLSearchParams(location.search);
|
const qs = new URLSearchParams(location.search);
|
||||||
@@ -499,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>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { defineConfig } from 'vitepress'
|
import { defineConfig } from 'vitepress'
|
||||||
|
|
||||||
// 部署到子路径时,构建前设置环境变量即可:
|
// 站点部署在域名的 /ny-exam-platform 子路径下,故 base 默认为该路径。
|
||||||
// DOCS_BASE=/ny-exam-docs/ npm run docs:build
|
// 本地预览时用 DOCS_BASE=/ 覆盖:DOCS_BASE=/ npm run docs:build
|
||||||
const base = process.env.DOCS_BASE || '/'
|
const base = process.env.DOCS_BASE || '/ny-exam-platform/'
|
||||||
|
// 输出目录。默认 .vitepress/dist;若需要保留旧产物(或环境禁止删除目录),
|
||||||
|
// 可用 DOCS_OUT=.vitepress/dist2 npm run docs:build 输出到新目录。
|
||||||
|
const outDir = process.env.DOCS_OUT || '.vitepress/dist'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base,
|
base,
|
||||||
|
outDir,
|
||||||
lang: 'zh-CN',
|
lang: 'zh-CN',
|
||||||
title: 'NY 问卷考试系统数据平台',
|
title: 'NY 问卷考试系统数据平台',
|
||||||
description: '基于 PHP 8 + 卷王(SurveyKing)社区版开放 API 的零依赖考试数据统计平台',
|
description: '基于 PHP 8 + 卷王(SurveyKing)社区版开放 API 的零依赖考试数据统计平台',
|
||||||
@@ -68,7 +72,6 @@ export default defineConfig({
|
|||||||
{ text: '名单管理', link: '/guide/roster' },
|
{ text: '名单管理', link: '/guide/roster' },
|
||||||
{ text: '成绩明细与统计口径', link: '/guide/scores' },
|
{ text: '成绩明细与统计口径', link: '/guide/scores' },
|
||||||
{ text: '试卷设置(满分/合格率/免考)', link: '/guide/project-config' },
|
{ text: '试卷设置(满分/合格率/免考)', link: '/guide/project-config' },
|
||||||
{ text: '快照、趋势与异常检测', link: '/guide/snapshot' },
|
|
||||||
{ text: '导出与打印', link: '/guide/export-print' }
|
{ text: '导出与打印', link: '/guide/export-print' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
+42
-15
@@ -16,7 +16,7 @@
|
|||||||
## 写操作白名单
|
## 写操作白名单
|
||||||
|
|
||||||
`config_save`、`roster_save`、`roster_import`、`roster_clear`、
|
`config_save`、`roster_save`、`roster_import`、`roster_clear`、
|
||||||
`snapshot_save`、`snapshot_delete`、`unmatched_map`、`project_config_save`
|
`unmatched_map`、`project_config_save`
|
||||||
|
|
||||||
## 接口清单
|
## 接口清单
|
||||||
|
|
||||||
@@ -38,8 +38,8 @@
|
|||||||
| Action | 方法 | 参数 | 说明 |
|
| Action | 方法 | 参数 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `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` 响应关键字段:
|
||||||
|
|
||||||
@@ -50,7 +50,6 @@ rows[] { sid, name, cls, grp, score, best_score, attempts, attempts_list[]
|
|||||||
roster[] { sid, name, cls, grp, login }
|
roster[] { sid, name, cls, grp, login }
|
||||||
absent[] 缺考名单
|
absent[] 缺考名单
|
||||||
unmatched[] 未匹配答卷
|
unmatched[] 未匹配答卷
|
||||||
anomalies { fast_high[], frequent[], identical[] }
|
|
||||||
project_cfg 本卷配置
|
project_cfg 本卷配置
|
||||||
_cache { cached, at }
|
_cache { cached, at }
|
||||||
```
|
```
|
||||||
@@ -72,25 +71,53 @@ _cache { cached, at }
|
|||||||
| `project_config` | GET | `projectId` | 返回 `cfg`、`effective`(生效值)、`global`(全局默认) |
|
| `project_config` | GET | `projectId` | 返回 `cfg`、`effective`(生效值)、`global`(全局默认) |
|
||||||
| `project_config_save` | POST | JSON `{ full_score, pass_rate, exempt[] }` | 留空/0 = 清除该项,回落全局 |
|
| `project_config_save` | POST | JSON `{ full_score, pass_rate, exempt[] }` | 留空/0 = 清除该项,回落全局 |
|
||||||
|
|
||||||
### 快照
|
|
||||||
|
|
||||||
| Action | 方法 | 参数 | 说明 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `snapshot_save` | POST | `projectId`、JSON `{ label }` | 保存当前成绩快照 |
|
|
||||||
| `snapshot_list` | GET | `projectId` | 列表 |
|
|
||||||
| `snapshot_delete` | POST | `projectId`、JSON `{ file }` | 删除 |
|
|
||||||
| `snapshot_trend` | GET | `projectId`(可逗号分隔多个,跨卷对比) | 趋势 |
|
|
||||||
|
|
||||||
### 导出
|
### 导出
|
||||||
|
|
||||||
| 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,18 +12,17 @@
|
|||||||
```
|
```
|
||||||
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)
|
||||||
├─ bootstrap.php 常量、自动加载、配置读写、CSRF、限流、公共函数
|
├─ bootstrap.php 常量、自动加载、配置读写、CSRF、限流、公共函数
|
||||||
├─ lib/
|
├─ lib/
|
||||||
│ ├─ SurveyKingClient.php 卷王 API 客户端(登录 / 令牌 / 项目 / 答卷)
|
│ ├─ SurveyKingClient.php 卷王 API 客户端(登录 / 令牌 / 项目 / 答卷)
|
||||||
│ ├─ DataService.php 数据组装:姓名→学号匹配、统计、去重、异常检测
|
│ ├─ DataService.php 数据组装:姓名→学号匹配、统计、去重
|
||||||
│ ├─ Roster.php 名单管理(xlsx/csv 导入、列识别、持久化)
|
│ ├─ Roster.php 名单管理(xlsx/csv 导入、列识别、持久化)
|
||||||
│ ├─ Db.php SQLite 封装(三方言兼容的 DDL 与 upsert)
|
│ ├─ Db.php SQLite 封装(三方言兼容的 DDL 与 upsert)
|
||||||
│ ├─ DataCache.php 结果缓存(TTL + flock 防惊群)
|
│ ├─ DataCache.php 结果缓存(TTL + flock 防惊群)
|
||||||
│ ├─ Snapshot.php 成绩快照与趋势
|
|
||||||
│ ├─ XlsxReader.php 零依赖 XLSX 读取
|
│ ├─ XlsxReader.php 零依赖 XLSX 读取
|
||||||
│ └─ XlsxWriter.php 零依赖 XLSX 写入(支持多工作表)
|
│ └─ XlsxWriter.php 零依赖 XLSX 写入(支持多工作表)
|
||||||
├─ assets/ style.css / app.js / draw.js
|
├─ assets/ style.css / app.js / draw.js
|
||||||
@@ -33,8 +32,7 @@ ny-exam-platform/
|
|||||||
│ ├─ roster.json 名单(文件备份)
|
│ ├─ roster.json 名单(文件备份)
|
||||||
│ ├─ project_config.json 每卷配置(文件备份)
|
│ ├─ project_config.json 每卷配置(文件备份)
|
||||||
│ ├─ cache/ 卷王令牌 + 结果缓存
|
│ ├─ cache/ 卷王令牌 + 结果缓存
|
||||||
│ ├─ uploads/ 导入临时目录(解析后即删)
|
│ └─ uploads/ 导入临时目录(解析后即删)
|
||||||
│ └─ snapshots/ 成绩快照
|
|
||||||
├─ tests/ PHP 自测 + 一键运行脚本
|
├─ tests/ PHP 自测 + 一键运行脚本
|
||||||
├─ tools/ mock 服务与 Node e2e
|
├─ tools/ mock 服务与 Node e2e
|
||||||
└─ docs/ 本文档站(VitePress)
|
└─ docs/ 本文档站(VitePress)
|
||||||
@@ -53,7 +51,7 @@ ny-exam-platform/
|
|||||||
│ ├─► Roster::all() 读名单
|
│ ├─► Roster::all() 读名单
|
||||||
│ ├─► 姓名 → 学号匹配
|
│ ├─► 姓名 → 学号匹配
|
||||||
│ ├─► dedupe(决定代表成绩)
|
│ ├─► dedupe(决定代表成绩)
|
||||||
│ └─► 统计 / 异常检测
|
│ └─► 统计
|
||||||
└─► ny_resolve_score_cfg() 解析本卷满分与合格率
|
└─► ny_resolve_score_cfg() 解析本卷满分与合格率
|
||||||
└─► ny_ok(JSON)
|
└─► ny_ok(JSON)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ PRAGMA foreign_keys = ON;
|
|||||||
| 配置 | `ny_settings['config']` | `data/config.json` |
|
| 配置 | `ny_settings['config']` | `data/config.json` |
|
||||||
| 名单 | `ny_roster` | `data/roster.json` |
|
| 名单 | `ny_roster` | `data/roster.json` |
|
||||||
| 每卷配置 | `ny_project_config` | `data/project_config.json` |
|
| 每卷配置 | `ny_project_config` | `data/project_config.json` |
|
||||||
| 成绩快照 | 文件系统 `data/snapshots/` | — |
|
|
||||||
| 结果缓存 | 文件系统 `data/cache/` | — |
|
| 结果缓存 | 文件系统 `data/cache/` | — |
|
||||||
| 卷王令牌 | 文件系统 `data/cache/token.json` | — |
|
| 卷王令牌 | 文件系统 `data/cache/token.json` | — |
|
||||||
|
|
||||||
|
|||||||
+51
-7
@@ -13,10 +13,46 @@ 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/`。
|
||||||
|
|
||||||
可通过环境变量覆盖运行时路径:
|
可通过环境变量覆盖运行时路径:
|
||||||
|
|
||||||
@@ -64,8 +100,6 @@ e2e 会备份并修改 `data/ny.db`、`data/config.json`、`data/roster.json`,
|
|||||||
```bash
|
```bash
|
||||||
git status data/ # data/ 已被 gitignore,需手工核对
|
git status data/ # data/ 已被 gitignore,需手工核对
|
||||||
```
|
```
|
||||||
|
|
||||||
另外测试前请删除 `data/login_fail.json`,否则可能触发登录限流。
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## 写新测试时注意
|
## 写新测试时注意
|
||||||
@@ -87,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`,不需要在对话框里勾「背景图形」)
|
||||||
|
- [ ] 导出弹窗「附加内容 · 附缺考名单」→ 每次考试 / 多考试对比也出「缺考名单」工作表
|
||||||
|
- [ ] 在每卷配置里改满分成数 / 合格率 → 数据大屏成员榜的红绿跟着变
|
||||||
|
- [ ] 数据大屏首页 → 选试卷后列出班级卡片,点击进入单班大屏
|
||||||
|
- [ ] 单班大屏 → 顶栏只有试卷 / 自动刷新 / 全屏,没有班级选择与退出;按浏览器后退键**退不回去**
|
||||||
|
- [ ] 复制单班大屏链接在**无痕窗口**打开 → 直接看到该班数据(免登录),顶栏有「公开 · 无需登录」角标;
|
||||||
|
看不到其它班级的成绩;大屏首页仍要求登录
|
||||||
|
- [ ] 数据大屏全屏进入 / 退出都正常
|
||||||
- [ ] 随机抽取 → 名额守恒
|
- [ ] 随机抽取 → 名额守恒
|
||||||
|
|
||||||
## 相关
|
## 相关
|
||||||
|
|||||||
+73
-14
@@ -1,31 +1,90 @@
|
|||||||
# 数据看板
|
# 数据大屏
|
||||||
|
|
||||||
数据看板(`dashboard.php`)是**面向投屏 / 大屏**的汇总视图,适合考试进行中实时监控提交进度。
|
数据大屏(`dashboard.php`)是**面向投屏 / 大屏**的汇总视图,适合考试进行中实时监控提交进度。
|
||||||
|
它有两种形态,由 URL 决定:
|
||||||
|
|
||||||
从主页面顶部导航 **📊 数据看板** 打开(新标签页)。
|
| 形态 | 打开方式 | 长什么样 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **首页(班级入口)** | 从主页面顶部导航 **📊 数据大屏** 打开,或直接访问 `dashboard.php` | 先选试卷 / 考试,下方以卡片列出该卷的所有班级;点卡片进入该班大屏 |
|
||||||
|
| **单班大屏** | 点首页的班级卡片(等价于 `dashboard.php?pid=<问卷ID>&class=<班级名>`) | 只展示这一个班:5 张汇总卡 + 分组对比 / 成员榜 |
|
||||||
|
|
||||||
## 顶部操作
|
首页是"入口",不是概览页——它不堆指标,只做一件事:让你**一次看到有哪些班,点进去看单个班**。
|
||||||
|
|
||||||
| 控件 | 说明 |
|
## 单班大屏是公开的,可以直接发链接
|
||||||
| --- | --- |
|
|
||||||
| 问卷 / 考试 | 选择要展示的试卷 |
|
|
||||||
| 班级 | 选择后只展示该班级(需先选试卷) |
|
|
||||||
| 自动刷新开关 + 间隔 | 与主页面一致的 5 / 10 / 15 / 30 / 60 / 120 秒 |
|
|
||||||
| 刷新 | 立即拉取一次(跳过缓存) |
|
|
||||||
| ⛶ 全屏 | 进入 / 退出全屏,布局自动铺满 |
|
|
||||||
|
|
||||||
## 汇总卡片
|
`dashboard.php?pid=<问卷ID>&class=<班级名>` **不需要登录**,任何人打开都能看到这一个班的数据
|
||||||
|
(顶栏带「公开 · 无需登录」角标)。适合直接把链接发到班级群让孩子或家长自己看。
|
||||||
|
|
||||||
`总人数 / 已提交 / 缺考 / 提交率 / 平均分 / 合格率(≥X%满分)`
|
::: danger 公开的是"这一个班",不是全卷
|
||||||
|
接口在**服务端**先按 `class` 把名单和答卷裁剪掉,再回传数据:公开链接拿不到全卷汇总,
|
||||||
|
也拿不到其它班级的成绩。想看别的班,打开那个班自己的链接即可;不带 `class` 参数的
|
||||||
|
大屏首页仍然要求登录。
|
||||||
|
:::
|
||||||
|
|
||||||
|
::: warning 单班大屏没有回头路
|
||||||
|
单班大屏**不显示**班级下拉、「← 主系统」和「退出」,页面内部也不存在回到首页的链接;
|
||||||
|
浏览器**后退键同样被屏蔽**(页面加载后立刻 `pushState` 占位,任何 `popstate` 都再推回去)。
|
||||||
|
想回主系统或换班,请在地址栏改 URL,或关闭标签页重新从首页进入。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 顶部控件
|
||||||
|
|
||||||
|
| 控件 | 首页 | 单班大屏 |
|
||||||
|
| --- | :-: | :-: |
|
||||||
|
| 问卷 / 考试 | ✅ | ✅ 切换后若该班不在新卷名单里,会自动落到第一个班 |
|
||||||
|
| 班级 | ✅ | ❌ 由 URL 固定,不提供切换 |
|
||||||
|
| 自动刷新开关 + 间隔 | ✅ | ✅ |
|
||||||
|
| 刷新 | ✅ | ✅ |
|
||||||
|
| ⛶ 全屏 | ✅ | ✅ |
|
||||||
|
| ← 主系统 | ✅ | ❌ 由 CSS 隐藏 |
|
||||||
|
| 退出 | ✅ | ❌ 页面里不再渲染 |
|
||||||
|
|
||||||
|
::: tip 后台标签页会自动暂停
|
||||||
|
切换到其他标签页后看板会**停止轮询**,切回来时立即补拉一次数据并重新开始计时——
|
||||||
|
投屏时长开也不会在无人观看时反复请求卷王。自动刷新失败时,「最后更新」会显示
|
||||||
|
**刷新中断(仍显示上次数据)**,鼠标悬停可看到具体原因。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 首页:班级入口卡片
|
||||||
|
|
||||||
|
每张卡片给出该班的速览,点整张卡片(或聚焦后按回车)进入该班大屏:
|
||||||
|
|
||||||
|
`总人数 / 已提交 / 缺考 / 平均分 / 合格率` + 一条提交率进度条;没交过卷的班也能一眼看到缺考数。
|
||||||
|
|
||||||
|
::: 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 分」。
|
||||||
|
|
||||||
## 班级 / 组别下钻
|
## 班级 / 组别下钻
|
||||||
|
|
||||||
|
(即单班大屏的主体部分)
|
||||||
|
|
||||||
看板主体按**班级**分块,每块内再按**组别**对比,指标与统计口径完全相同:
|
看板主体按**班级**分块,每块内再按**组别**对比,指标与统计口径完全相同:
|
||||||
总人数、已提交、缺考、提交率、平均分、合格率、最高分、最低分、平均用时。
|
总人数、已提交、缺考、提交率、平均分、合格率、最高分、最低分、平均用时。
|
||||||
|
|
||||||
点击班级内任意成员可查看其**每一次提交**(时间、成绩、时长),最高分有标记。
|
点击考过多次的成员(带「刷 N 次」标签)可查看其**每一次提交**(时间、成绩、时长),最佳成绩有标记。
|
||||||
|
按 `Esc` 或点击弹窗外部可关闭。
|
||||||
|
|
||||||
> 未分班的人归入「(未分班)」,未分组的人归入「(未分组)」,不会丢失。
|
> 未分班的人归入「(未分班)」,未分组的人归入「(未分组)」,不会丢失。
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -44,7 +44,15 @@
|
|||||||
|
|
||||||
顶部卡片:`候选人数 / 本次抽取 / 累计已抽取 / 已排除 / 抽取来源`。
|
顶部卡片:`候选人数 / 本次抽取 / 累计已抽取 / 已排除 / 抽取来源`。
|
||||||
|
|
||||||
结果表格:`序号 / 学号 / 姓名 / 班级 / 组别 / 操作`。
|
结果表格:`序号 / 学号 / 姓名 / 班级 / 组别 / 登陆账号 / 操作`。
|
||||||
|
|
||||||
|
「登陆账号」列让你在导出前就能核对:显示为 `学号 ⚠` 表示名单里没填这一列,
|
||||||
|
导出时会自动回退用学号——**这会导致学生登不进考试**,请先回主系统「名单管理」补全。
|
||||||
|
|
||||||
|
::: tip 「全部」按钮会跟随候选池
|
||||||
|
点 **全部** 后抽取人数进入跟随模式:切换班级 / 组别 / 来源时人数自动更新为当前
|
||||||
|
候选池大小。手工改动人数输入框即解除该模式。
|
||||||
|
:::
|
||||||
|
|
||||||
| 按钮 | 说明 |
|
| 按钮 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -57,8 +65,8 @@
|
|||||||
|
|
||||||
## 登陆账号说明
|
## 登陆账号说明
|
||||||
|
|
||||||
名单中的**登陆账号**列只在这里用于导出白名单,主系统界面任何位置都不展示。
|
名单中的**登陆账号**列在这里既用于导出白名单,也会直接显示在结果表格中(便于导出前核对)。
|
||||||
若名单里没有这一列,导出的白名单该字段为空。
|
若名单里没有这一列,抽取结果里会用 `学号 ⚠` 标出,导出时回退用学号。
|
||||||
|
|
||||||
::: warning 模板文件名
|
::: warning 模板文件名
|
||||||
卷王模板中该列表头为「登陆账号」(不是"登录账号"),导出时已按此命名。
|
卷王模板中该列表头为「登陆账号」(不是"登录账号"),导出时已按此命名。
|
||||||
|
|||||||
+153
-28
@@ -1,70 +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 人」的情况。
|
||||||
|
|
||||||
## 导出缺考名单
|
## 打印
|
||||||
|
|
||||||
工具栏 **导出缺考**,列为 `学号 / 姓名 / 班级 / 组别`,按学号升序。
|
| 选项 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 打印标题 | 默认按考试名自动生成,可改成学校 / 考试正式名称 |
|
||||||
|
| 纸张方向 | 多考试打印默认 **横向**(列多不折行),可改纵向 |
|
||||||
|
| 成绩明细的分组对比 | 按班级或组别分段,段首给出小计(人数 / 平均 / 最高 / 最低 / 合格人数与合格率) |
|
||||||
|
| 成绩明细的显示选项 | 显示名次、显示「是否合格」列、显示考试次数、附缺考名单 |
|
||||||
|
|
||||||
## 打印成绩明细
|
- 表头在**每一页自动重复**,行不会被切断到两页;两行表头都放进 `<thead>`;
|
||||||
|
- 页眉含范围(班级 / 组别)、打印时间、人数、平均分、最高 / 最低、合格人数与合格线;
|
||||||
|
- 页脚含**教师 / 教研组长签字栏与日期**,可直接签字归档。
|
||||||
|
|
||||||
成绩明细面板右上角 **打印**:
|
成绩明细面板右上角的**打印**与导出弹窗里的打印设置是同一套(打印标题、方向、分组对比都在弹窗里改)。
|
||||||
|
|
||||||
- 默认按**成绩降序**;
|
::: tip 绿底 / 红字要能印出来
|
||||||
- 可选「按班级 / 组别分组对比」,每组段首给出小计;
|
打印样式里写了 `print-color-adjust: exact`,**不需要**在打印对话框里手动勾选「背景图形」,
|
||||||
- 走浏览器打印通道,可直接另存为 PDF。
|
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**;
|
||||||
|
前端据此加红字 / 绿底,合并区间内的非首格继承左上角首格的标记。
|
||||||
|
|
||||||
## 相关
|
## 相关
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -18,9 +18,10 @@
|
|||||||
务必从登录响应的 `Set-Cookie` 中取出 `sk-token`,并在后续每个请求带上
|
务必从登录响应的 `Set-Cookie` 中取出 `sk-token`,并在后续每个请求带上
|
||||||
`Cookie: sk-token=<token>`。本平台已处理此细节。
|
`Cookie: sk-token=<token>`。本平台已处理此细节。
|
||||||
|
|
||||||
**Q:登录一直失败并被锁住**
|
**Q:登录一直失败**
|
||||||
|
|
||||||
连续 5 次失败会锁定 10 分钟。等待即可;如需立即解锁,删除 `data/login_fail.json`。
|
登录失败只会在登录页即时提示原因(账号/密码错、地址不可达等),不会因多次失败被锁。
|
||||||
|
若登录页连接信息显示成 `127.0.0.1` 等错误地址,用 `php tools/fix-config.php` 修正即可。
|
||||||
|
|
||||||
**Q:卷王改了密码后平台一直提示未登录**
|
**Q:卷王改了密码后平台一直提示未登录**
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@
|
|||||||
|
|
||||||
**Q:可以把平台搬到另一台服务器吗?**
|
**Q:可以把平台搬到另一台服务器吗?**
|
||||||
|
|
||||||
可以。整个目录复制过去,并把 `data/` 一起带上(里面有配置、名单、快照),确保 `data/` 可写。
|
可以。整个目录复制过去,并把 `data/` 一起带上(里面有配置、名单、每卷配置),确保 `data/` 可写。
|
||||||
|
|
||||||
## 安全
|
## 安全
|
||||||
|
|
||||||
|
|||||||
@@ -97,11 +97,10 @@ Apache 下 `data/` 默认即被保护。仍需确认:
|
|||||||
|
|
||||||
| 路径 | 需要 | 说明 |
|
| 路径 | 需要 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `data/` | 可写 | 配置、名单、令牌、快照的根目录 |
|
| `data/` | 可写 | 配置、名单、令牌的根目录 |
|
||||||
| `data/ny.db` | 可写 | SQLite 主库(启用 `pdo_sqlite` 时) |
|
| `data/ny.db` | 可写 | SQLite 主库(启用 `pdo_sqlite` 时) |
|
||||||
| `data/uploads/` | 可写 | 导入名单时的临时落盘,解析后立即删除 |
|
| `data/uploads/` | 可写 | 导入名单时的临时落盘,解析后立即删除 |
|
||||||
| `data/cache/` | 可写 | 卷王令牌缓存、结果缓存 |
|
| `data/cache/` | 可写 | 卷王令牌缓存、结果缓存 |
|
||||||
| `data/snapshots/` | 可写 | 成绩快照 |
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chown -R www:www data
|
chown -R www:www data
|
||||||
|
|||||||
@@ -26,16 +26,15 @@ NY 问卷考试系统数据平台是一个**零第三方运行依赖**的 PHP 8
|
|||||||
| 试卷切换 | 下拉选择卷王中的问卷 / 考试,自动拉取该卷全部答卷 |
|
| 试卷切换 | 下拉选择卷王中的问卷 / 考试,自动拉取该卷全部答卷 |
|
||||||
| 姓名关联 | 自动识别答卷中的「姓名」题,也可手动指定字段;未匹配答卷单独面板 + 一键补入名单 |
|
| 姓名关联 | 自动识别答卷中的「姓名」题,也可手动指定字段;未匹配答卷单独面板 + 一键补入名单 |
|
||||||
| 统计卡片 | 名单总人数、已提交、缺考、平均分、最高分、最低分、合格率、平均用时;选择班级后全部联动 |
|
| 统计卡片 | 名单总人数、已提交、缺考、平均分、最高分、最低分、合格率、平均用时;选择班级后全部联动 |
|
||||||
| 成绩明细 | 虚拟滚动(无分页)、点击表头排序、搜索、考试次数、新提交绿色高亮(30 秒自动清除) |
|
| 成绩明细 | 虚拟滚动(无分页)、点击表头排序、搜索(提示同步显示命中数)、考试次数、新提交绿色高亮(30 秒自动清除) |
|
||||||
| 缺考名单 | 名单中有但未提交的人,支持复制学号 / 姓名、打印 |
|
| 缺考名单 | 名单中有但未提交的人,支持复制学号 / 姓名、打印(按班级分组) |
|
||||||
| 试卷设置 | 为当前选中的试卷单独设置满分 / 合格率 / 免考名单,试卷间互相隔离 |
|
| 试卷设置 | 为当前选中的试卷单独设置满分 / 合格率 / 免考名单,试卷间互相隔离 |
|
||||||
| 快照 / 趋势 | 保存带标签的时间点快照,横向对比每名学生成绩变化 |
|
| 导出 / 打印 | 成绩明细、缺考名单 `.xlsx`;成绩明细按成绩降序打印,支持分组小计、合格标记、附缺考名单、签字栏 |
|
||||||
| 异常检测 | 短时长高分、高频重考、雷同成绩三类线索 |
|
|
||||||
| 导出 / 打印 | 成绩明细、缺考名单 `.xlsx`;成绩明细按成绩降序打印,可选分组对比 |
|
|
||||||
|
|
||||||
### 数据看板 `dashboard.php`
|
### 数据大屏 `dashboard.php`
|
||||||
|
|
||||||
大屏看板,适合投屏。支持全屏切换与自动刷新。
|
首页先选试卷,再用卡片列出该卷的班级;点卡片进入该班专属大屏。
|
||||||
|
大屏页支持全屏切换与自动刷新,**没有返回按钮,浏览器后退键也被屏蔽**(只能靠地址栏或关标签页离开)。
|
||||||
|
|
||||||
### 随机抽取 `draw.php`
|
### 随机抽取 `draw.php`
|
||||||
|
|
||||||
|
|||||||
+15
-1
@@ -9,6 +9,7 @@
|
|||||||
| 名单总人数 | 名单条目数;被当前试卷设为**免考**的人不计入 |
|
| 名单总人数 | 名单条目数;被当前试卷设为**免考**的人不计入 |
|
||||||
| 已提交 | 匹配到名单、且提交了答卷的人数(去重后的代表成绩行数) |
|
| 已提交 | 匹配到名单、且提交了答卷的人数(去重后的代表成绩行数) |
|
||||||
| 缺考 | `max(0, 名单总人数 − 已提交)` |
|
| 缺考 | `max(0, 名单总人数 − 已提交)` |
|
||||||
|
| 提交率 | `已提交 ÷ 名单总人数 × 100%`——考试进行中最该盯的指标 |
|
||||||
| 提交率 | `已提交 ÷ 名单总人数 × 100%` |
|
| 提交率 | `已提交 ÷ 名单总人数 × 100%` |
|
||||||
| 平均分 | 所有**有成绩**的答卷成绩平均值;无成绩者不计入分母 |
|
| 平均分 | 所有**有成绩**的答卷成绩平均值;无成绩者不计入分母 |
|
||||||
| 最高分 / 最低分 | 同上,取自所有有成绩的答卷 |
|
| 最高分 / 最低分 | 同上,取自所有有成绩的答卷 |
|
||||||
@@ -40,7 +41,20 @@
|
|||||||
| 提交时间 | **最近一次**提交时间(重考低分也会让这行按最新时间排序到最上面) |
|
| 提交时间 | **最近一次**提交时间(重考低分也会让这行按最新时间排序到最上面) |
|
||||||
| 考试时长 | 最近一次答卷的时长;后台手动录入等没有起止时间的答卷为 `-` |
|
| 考试时长 | 最近一次答卷的时长;后台手动录入等没有起止时间的答卷为 `-` |
|
||||||
|
|
||||||
点击任意一行可查看该学生的**每一次提交**(时间、成绩、时长),最高分有标记。
|
点击任意一行可查看该学生的**每一次提交**(时间、成绩、时长),最佳成绩有标记。
|
||||||
|
|
||||||
|
### 成绩颜色
|
||||||
|
|
||||||
|
成绩按**占本卷满分的百分比**着色,而不是写死的 90 / 60 分:
|
||||||
|
|
||||||
|
| 颜色 | 条件 |
|
||||||
|
| --- | --- |
|
||||||
|
| 绿色 | ≥ 满分的 90% |
|
||||||
|
| 橙色 | ≥ 合格率线(默认满分的 60%,可在[每卷配置](./project-config.md)里改) |
|
||||||
|
| 红色 | 低于合格率线 |
|
||||||
|
|
||||||
|
这样把满分改成 50 分或 20 分制时,不会因为绝对分数偏低而整列误标红。
|
||||||
|
数据看板的成员榜使用同一套规则。
|
||||||
|
|
||||||
## 排序与搜索
|
## 排序与搜索
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
| 配置项 | 默认值 | 说明 |
|
| 配置项 | 默认值 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 卷王系统地址 | `https://exam.nxxy335.top` | 结尾不带 `/` |
|
| 卷王系统地址 | `https://exam.nxxy335.top` | 结尾不带 `/`,可直接在设置里修改 |
|
||||||
| 接口前缀 | `/api` | 社区版 `/api`;企业版可能用 `/admin-api` |
|
| 接口前缀 | `/api` | 社区版 `/api`;企业版可能用 `/admin-api`;可直接在设置里修改 |
|
||||||
| 登录账号 | `admin` | 卷王后台管理员账号 |
|
| 登录账号 | `admin` | 卷王后台管理员账号 |
|
||||||
| 登录密码 | `123456` | 不回显;留空表示不修改 |
|
| 登录密码 | `123456` | 不回显;留空表示不修改 |
|
||||||
| 校验 HTTPS 证书 | 勾选 | 自签名证书取消勾选 |
|
| 校验 HTTPS 证书 | 勾选 | 自签名证书取消勾选 |
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
# 快照、趋势与异常检测
|
|
||||||
|
|
||||||
这一组功能用于**跨时间**看成绩,而不是只看当前这一刻。
|
|
||||||
|
|
||||||
## 快照
|
|
||||||
|
|
||||||
工具栏 **存快照** 打开快照面板,填写标签(留空自动用「快照 MM-DD HH:II」)后保存。
|
|
||||||
|
|
||||||
一份快照包含:
|
|
||||||
|
|
||||||
- 保存时间与标签;
|
|
||||||
- 试卷 ID 与名称;
|
|
||||||
- 当时的整体统计(总人数、已提交、平均分、合格率……);
|
|
||||||
- 每名学生的 `学号 / 姓名 / 班级 / 组别 / 成绩 / 最高分 / 考试次数 / 最近提交时间`(按学号排序)。
|
|
||||||
|
|
||||||
存储位置:`data/snapshots/<projectId>/<unix秒>_<md5(标签)>.json`。
|
|
||||||
|
|
||||||
快照**按试卷分目录**,所以不同试卷的快照互不干扰。
|
|
||||||
|
|
||||||
| 操作 | 位置 |
|
|
||||||
| --- | --- |
|
|
||||||
| 保存当前快照 | 快照弹窗 → 保存当前快照 |
|
|
||||||
| 刷新列表 | 快照弹窗 → 刷新列表 |
|
|
||||||
| 删除某份 | 列表每行的删除按钮 |
|
|
||||||
|
|
||||||
## 趋势
|
|
||||||
|
|
||||||
**趋势** 面板把同一试卷的多份快照横向铺开,按学号对齐,可以直接看出每名学生
|
|
||||||
在几个时间点上的分数变化(上升 / 下降 / 缺考)。
|
|
||||||
|
|
||||||
典型用法:
|
|
||||||
|
|
||||||
1. 月考卷 A 考完 → 存快照「10月月考」;
|
|
||||||
2. 期中卷 B 考完 → 存快照「期中」;
|
|
||||||
3. 打开趋势,逐人对比两次成绩。
|
|
||||||
|
|
||||||
## 异常检测
|
|
||||||
|
|
||||||
工具栏 **异常** 打开异常线索面板。它**不是判定作弊**,只是把值得人工复核的对象挑出来,
|
|
||||||
避免在上千行数据里肉眼找。
|
|
||||||
|
|
||||||
### 短时长高分
|
|
||||||
|
|
||||||
命中条件:
|
|
||||||
|
|
||||||
```
|
|
||||||
有时长记录
|
|
||||||
且 时长 < max(60秒, 平均时长 × 40%)
|
|
||||||
且 成绩 ≥ 满分提示值 × 85%
|
|
||||||
```
|
|
||||||
|
|
||||||
> 「满分提示值」取当前数据中的最高分(`max_score`),至少为 1。
|
|
||||||
|
|
||||||
### 高频重考
|
|
||||||
|
|
||||||
命中条件:`考试次数 ≥ max(4, ceil(平均考试次数 + 3))`
|
|
||||||
|
|
||||||
即:至少 4 次才算高频,且要明显高于整体平均水平。
|
|
||||||
|
|
||||||
### 雷同成绩
|
|
||||||
|
|
||||||
同一分数出现 **≥ 3 人** 时列为疑似,按人数降序排列并列出成员。
|
|
||||||
|
|
||||||
::: warning 请人工复核
|
|
||||||
异常检测基于统计特征,误报很正常(例如题目全是选择题时同分很常见)。
|
|
||||||
请把它当作"值得看一眼"的线索,不要直接作为处理依据。
|
|
||||||
:::
|
|
||||||
|
|
||||||
## 相关
|
|
||||||
|
|
||||||
- [成绩明细与统计口径](./scores.md)
|
|
||||||
- [导出与打印](./export-print.md)
|
|
||||||
+3
-6
@@ -32,9 +32,6 @@ features:
|
|||||||
- icon: ⚙️
|
- icon: ⚙️
|
||||||
title: 每卷独立配置
|
title: 每卷独立配置
|
||||||
details: 满分、合格率、免考名单按试卷隔离设置,互不干扰;未设置时回落到全局默认值。
|
details: 满分、合格率、免考名单按试卷隔离设置,互不干扰;未设置时回落到全局默认值。
|
||||||
- icon: 📈
|
|
||||||
title: 快照与趋势
|
|
||||||
details: 随时保存成绩快照,横向对比每名学生在多个时间点的成绩变化,并给出异常线索。
|
|
||||||
- icon: 🎲
|
- icon: 🎲
|
||||||
title: 随机抽取
|
title: 随机抽取
|
||||||
details: 按班级 / 组别均衡或加权抽取,累计去重、排除管理、历史批次与白名单导出。
|
details: 按班级 / 组别均衡或加权抽取,累计去重、排除管理、历史批次与白名单导出。
|
||||||
@@ -43,7 +40,7 @@ features:
|
|||||||
details: 零依赖生成 .xlsx,支持多工作表、最新成绩视图、成绩明细与缺考名单打印。
|
details: 零依赖生成 .xlsx,支持多工作表、最新成绩视图、成绩明细与缺考名单打印。
|
||||||
- icon: 🔒
|
- icon: 🔒
|
||||||
title: 安全内建
|
title: 安全内建
|
||||||
details: 会话登录、CSRF 令牌、登录失败限流,上传文件白名单 + 随机名 + 解析后即删。
|
details: 会话登录、CSRF 令牌,上传文件白名单 + 随机名 + 解析后即删。
|
||||||
---
|
---
|
||||||
|
|
||||||
## 三分钟了解它
|
## 三分钟了解它
|
||||||
@@ -52,8 +49,8 @@ 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": "第一次月考·数学"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
## P0:屏蔽数据目录(必做)
|
## P0:屏蔽数据目录(必做)
|
||||||
|
|
||||||
`data/` 位于 Web 根目录内,里面有 `ny.db`(配置 / 名单 / 卷王账号密码)、
|
`data/` 位于 Web 根目录内,里面有 `ny.db`(配置 / 名单 / 卷王账号密码)、
|
||||||
`config.json`、`roster.json`、令牌缓存与快照。**必须禁止直接访问。**
|
`config.json`、`roster.json`、令牌缓存。**必须禁止直接访问。**
|
||||||
|
|
||||||
### nginx(生产环境默认,必做)
|
### nginx(生产环境默认,必做)
|
||||||
|
|
||||||
@@ -132,7 +132,6 @@ deny all;
|
|||||||
| `data/ny.db` | 配置、名单、每卷配置(SQLite) |
|
| `data/ny.db` | 配置、名单、每卷配置(SQLite) |
|
||||||
| `data/config.json` | 配置(文件备份) |
|
| `data/config.json` | 配置(文件备份) |
|
||||||
| `data/roster.json` | 名单(文件备份) |
|
| `data/roster.json` | 名单(文件备份) |
|
||||||
| `data/snapshots/` | 成绩快照 |
|
|
||||||
|
|
||||||
## P2:改掉卷王默认口令
|
## P2:改掉卷王默认口令
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,11 @@
|
|||||||
- 卷王令牌缓存在 `data/cache/token.json`;令牌失效时平台会自动重新登录;
|
- 卷王令牌缓存在 `data/cache/token.json`;令牌失效时平台会自动重新登录;
|
||||||
若卷王侧也认证失败,前端会先调用 `logout.php` 清会话再跳转登录页,避免出现跳转死循环。
|
若卷王侧也认证失败,前端会先调用 `logout.php` 清会话再跳转登录页,避免出现跳转死循环。
|
||||||
|
|
||||||
## 登录限流
|
## 登录失败处理
|
||||||
|
|
||||||
连续 **5 次**登录失败 → 锁定 **10 分钟**。状态记录在 `data/login_fail.json`。
|
登录失败(卷王账号/密码错、地址不可达等)只会在登录页即时提示原因,**不做失败次数限流**——
|
||||||
|
单机内网工具场景下,限流常因配置写错或测试串味把正常用户挡在门外,且无实际防爆破价值。
|
||||||
应急解锁:删除 `data/login_fail.json`。
|
若怀疑连接配置被测试污染导致连不上真实卷王,用 `php tools/fix-config.php` 一键修正连接地址。
|
||||||
|
|
||||||
## CSRF 防护
|
## CSRF 防护
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
`X-CSRF-Token` 头中自动携带;
|
`X-CSRF-Token` 头中自动携带;
|
||||||
- 后端对写操作维护**白名单** `$WRITE_ACTIONS`,命中白名单的请求一律校验:
|
- 后端对写操作维护**白名单** `$WRITE_ACTIONS`,命中白名单的请求一律校验:
|
||||||
`config_save` / `roster_save` / `roster_import` / `roster_clear` /
|
`config_save` / `roster_save` / `roster_import` / `roster_clear` /
|
||||||
`snapshot_save` / `snapshot_delete` / `unmatched_map` / `project_config_save`。
|
`unmatched_map` / `project_config_save`。
|
||||||
|
|
||||||
## 文件上传
|
## 文件上传
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
|
|
||||||
## 目录保护
|
## 目录保护
|
||||||
|
|
||||||
程序启动时会在 `data/`、`data/cache/`、`data/uploads/`、`data/snapshots/`
|
程序启动时会在 `data/`、`data/cache/`、`data/uploads/`
|
||||||
自动生成 `.htaccess`(`Deny from all`)与 `index.html` 占位。
|
自动生成 `.htaccess`(`Deny from all`)与 `index.html` 占位。
|
||||||
|
|
||||||
> **nginx 不读取 `.htaccess`**,必须显式配置。见 [部署加固清单](./hardening.md)。
|
> **nginx 不读取 `.htaccess`**,必须显式配置。见 [部署加固清单](./hardening.md)。
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
| 全站 HTTPS | 登录密码经 RSA 加密,但仍建议全站 TLS |
|
| 全站 HTTPS | 登录密码经 RSA 加密,但仍建议全站 TLS |
|
||||||
| IP 白名单 | 内网使用时在 nginx 层限制来源 IP |
|
| IP 白名单 | 内网使用时在 nginx 层限制来源 IP |
|
||||||
| 隐藏版本号 | 关闭 `expose_php`、隐藏 nginx 版本号 |
|
| 隐藏版本号 | 关闭 `expose_php`、隐藏 nginx 版本号 |
|
||||||
| 定期备份 | 备份 `data/`(配置、名单、快照、SQLite 库) |
|
| 定期备份 | 备份 `data/`(配置、名单、每卷配置、SQLite 库) |
|
||||||
| 最小权限 | PHP-FPM 进程只对 `data/` 可写,其它目录只读 |
|
| 最小权限 | PHP-FPM 进程只对 `data/` 可写,其它目录只读 |
|
||||||
| 改默认密码 | 卷王后台默认 `admin/123456` 务必修改 |
|
| 改默认密码 | 卷王后台默认 `admin/123456` 务必修改 |
|
||||||
|
|
||||||
|
|||||||
@@ -110,18 +110,18 @@ $rosterCount = Roster::count();
|
|||||||
<table class="grid">
|
<table class="grid">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:70px">序号</th>
|
<th style="width:8%">序号</th>
|
||||||
<th style="width:26%">学号</th>
|
<th style="width:22%">学号</th>
|
||||||
<th style="width:22%">姓名</th>
|
<th style="width:18%">姓名</th>
|
||||||
<th>班级</th>
|
<th style="width:24%">班级</th>
|
||||||
<th>组别</th>
|
<th style="width:18%">组别</th>
|
||||||
<th style="width:90px">操作</th>
|
<th style="width:10%">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="drawBody"><tr><td colspan="6" class="empty">尚未抽取</td></tr></tbody>
|
<tbody id="drawBody"><tr><td colspan="6" class="empty">尚未抽取</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">两个下载均为两列「<b>姓名</b> / <b>登陆账号</b>」,登陆账号取名单中导入的「登陆账号」列(缺失时回退学号),按文本格式保存,可直接导入卷王白名单。</p>
|
<p class="hint">抽取结果表不显示登陆账号;「⬇ 白名单导入模板」「⬇ 白名单列表」与「📋 复制名单」均导出两列「<b>姓名</b> / <b>登陆账号</b>」,登陆账号取名单中导入的「登陆账号」列。<b>若名单未填登陆账号,导出时会自动回退用学号</b>,请先到主系统「名单管理」补全,否则学生无法登录考试。</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
<div class="table-scroll mid">
|
<div class="table-scroll mid">
|
||||||
<table class="grid compact">
|
<table class="grid compact">
|
||||||
<thead><tr><th style="width:64px">排除</th><th style="width:24%">学号</th><th style="width:22%">姓名</th><th>班级</th><th>组别</th></tr></thead>
|
<thead><tr><th style="width:10%;text-align:center">排除</th><th style="width:24%">学号</th><th style="width:22%">姓名</th><th style="width:26%">班级</th><th style="width:18%">组别</th></tr></thead>
|
||||||
<tbody id="exclBody"><tr><td colspan="5" class="empty">加载中…</td></tr></tbody>
|
<tbody id="exclBody"><tr><td colspan="5" class="empty">加载中…</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,39 +70,26 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
<div class="field btns">
|
<div class="field btns">
|
||||||
<button class="btn btn-primary" id="btnRefresh">立即刷新</button>
|
<button class="btn btn-primary" id="btnRefresh">立即刷新</button>
|
||||||
|
<button class="btn btn-primary" id="btnProjCfg" title="为当前选中的试卷单独设置满分 / 合格率 / 免考名单">试卷设置</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="field grow-2">
|
<div class="field grow-2">
|
||||||
<label>搜索</label>
|
<label>搜索</label>
|
||||||
<input type="text" id="kw" placeholder="输入学号或姓名筛选">
|
<input type="text" id="kw" placeholder="输入学号或姓名筛选">
|
||||||
</div>
|
</div>
|
||||||
<div class="field btns" style="flex-wrap:wrap;gap:8px">
|
</section>
|
||||||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-2)">导出排序
|
|
||||||
<select id="exportSortSel" style="height:30px;border-radius:8px;border:1px solid var(--border);padding:0 6px;background:#fff">
|
<section class="subbar">
|
||||||
<option value="time">按提交时间</option>
|
<span class="subbar-label">导出</span>
|
||||||
<option value="sid">按学号</option>
|
<span class="hint" id="exportBarHint">成绩明细 / 每次考试 / 缺考名单 / 多考试对比,都在「导出」里配置</span>
|
||||||
<option value="score_desc">按成绩降序</option>
|
<span class="spacer"></span>
|
||||||
<option value="score_asc">按成绩升序</option>
|
<button class="btn btn-primary" id="btnExportOpen" title="导出与打印:选好导出内容、范围与标记方式,再导出 Excel 或直接打印">导出</button>
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-2);cursor:pointer" title="每人只导一行:最近一次成绩 + 最高分 + 考试次数">
|
|
||||||
<input type="checkbox" id="exportLatest" style="width:auto;height:auto">按最新成绩
|
|
||||||
</label>
|
|
||||||
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-2);cursor:pointer" title="按班级拆分多个工作表(xlsx 多表)">
|
|
||||||
<input type="checkbox" id="exportSplit" style="width:auto;height:auto">按班级分表
|
|
||||||
</label>
|
|
||||||
<button class="btn" id="btnExport">导出成绩</button>
|
|
||||||
<button class="btn" id="btnExportAbsent">导出缺考</button>
|
|
||||||
<button class="btn" id="btnSnapshot" title="保存当前成绩为一份带标签的时间点快照">存快照</button>
|
|
||||||
<button class="btn" id="btnTrend" title="查看本卷各快照间每名学生的成绩趋势">趋势</button>
|
|
||||||
<button class="btn" id="btnAnomaly" title="异常检测线索:短时长高分 / 高频重考 / 雷同成绩">异常</button>
|
|
||||||
<button class="btn btn-primary" id="btnProjCfg" title="为当前选中的试卷单独设置满分 / 合格率 / 免考名单">试卷设置</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="cards" id="statCards">
|
<section class="cards" id="statCards">
|
||||||
<div class="card accent-blue"><span>名单总人数</span><b id="sTotal">-</b></div>
|
<div class="card accent-blue"><span>名单总人数</span><b id="sTotal">-</b></div>
|
||||||
<div class="card ok accent-green"><span>已提交</span><b id="sSubmit">-</b></div>
|
<div class="card ok accent-green"><span>已提交</span><b id="sSubmit">-</b></div>
|
||||||
<div class="card warn accent-orange"><span>缺考</span><b id="sAbsent">-</b></div>
|
<div class="card warn accent-orange"><span>缺考</span><b id="sAbsent">-</b></div>
|
||||||
|
<div class="card accent-purple"><span>提交率</span><b id="sRate">-</b></div>
|
||||||
<div class="card accent-teal"><span>平均分</span><b id="sAvg">-</b></div>
|
<div class="card accent-teal"><span>平均分</span><b id="sAvg">-</b></div>
|
||||||
<div class="card accent-green"><span>最高分</span><b id="sMax">-</b></div>
|
<div class="card accent-green"><span>最高分</span><b id="sMax">-</b></div>
|
||||||
<div class="card accent-red"><span>最低分</span><b id="sMin">-</b></div>
|
<div class="card accent-red"><span>最低分</span><b id="sMin">-</b></div>
|
||||||
@@ -124,14 +111,14 @@ $rosterCount = Roster::count();
|
|||||||
<table class="grid" id="dataTable">
|
<table class="grid" id="dataTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="sortable" data-k="sid">学号</th>
|
<th class="sortable" data-k="sid" style="width:16%">学号</th>
|
||||||
<th class="sortable" data-k="name">姓名</th>
|
<th class="sortable" data-k="name" style="width:12%">姓名</th>
|
||||||
<th class="sortable" data-k="cls">班级</th>
|
<th class="sortable" data-k="cls" style="width:10%">班级</th>
|
||||||
<th class="sortable" data-k="grp">组别</th>
|
<th class="sortable" data-k="grp" style="width:7%">组别</th>
|
||||||
<th class="sortable" data-k="score">成绩</th>
|
<th class="sortable" data-k="score" style="width:10%">成绩</th>
|
||||||
<th class="sortable" data-k="attempts" title="该生提交过的次数,点击表头可排序">考试次数</th>
|
<th class="sortable" data-k="attempts" style="width:11%" title="该生提交过的次数,点击表头可排序">考试次数</th>
|
||||||
<th class="sortable" data-k="submit_ts" title="最近一次提交时间">提交时间</th>
|
<th class="sortable" data-k="submit_ts" style="width:22%" title="最近一次提交时间">提交时间</th>
|
||||||
<th class="sortable" data-k="duration">考试时长</th>
|
<th class="sortable" data-k="duration" style="width:12%">考试时长</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="tbody"><tr><td colspan="8" class="empty">请选择问卷 / 考试</td></tr></tbody>
|
<tbody id="tbody"><tr><td colspan="8" class="empty">请选择问卷 / 考试</td></tr></tbody>
|
||||||
@@ -149,17 +136,17 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
<div class="table-scroll short">
|
<div class="table-scroll short">
|
||||||
<table class="grid compact" id="absentTable">
|
<table class="grid compact" id="absentTable">
|
||||||
<thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>组别</th></tr></thead>
|
<thead><tr><th style="width:25%">学号</th><th style="width:30%">姓名</th><th style="width:25%">班级</th><th style="width:20%">组别</th></tr></thead>
|
||||||
<tbody id="absentBody"><tr><td colspan="4" class="empty">—</td></tr></tbody>
|
<tbody id="absentBody"><tr><td colspan="4" class="empty">—</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="unmatchedPanel">
|
<section class="panel" id="unmatchedPanel">
|
||||||
<div class="panel-head"><h2>未匹配答卷 <small>姓名不在名单中</small></h2></div>
|
<div class="panel-head"><h2>未匹配答卷 <small id="unmatchedHint">姓名不在名单中</small></h2></div>
|
||||||
<div class="table-scroll short">
|
<div class="table-scroll short">
|
||||||
<table class="grid compact">
|
<table class="grid compact">
|
||||||
<thead><tr><th>姓名</th><th>成绩</th><th>提交时间</th><th style="width:90px">操作</th></tr></thead>
|
<thead><tr><th style="width:25%">姓名</th><th style="width:18%" data-k="score">成绩</th><th style="width:35%" data-k="submit_ts">提交时间</th><th style="width:22%">操作</th></tr></thead>
|
||||||
<tbody id="unmatchedBody"><tr><td colspan="4" class="empty">—</td></tr></tbody>
|
<tbody id="unmatchedBody"><tr><td colspan="4" class="empty">—</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,9 +231,14 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fi full">
|
<div class="fi full">
|
||||||
<label>系统地址(不可在此修改)</label>
|
<label>系统地址</label>
|
||||||
<input type="text" id="cfgBase" readonly>
|
<input type="text" id="cfgBase" placeholder="https://exam.nxxy335.top" spellcheck="false">
|
||||||
<p class="hint">如需修改请编辑 <code>data/config.json</code>(保存后清 token 自动重连)</p>
|
<p class="hint">卷王后台地址,结尾不要带 <code>/</code>;修改后保存会自动重连</p>
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>接口前缀</label>
|
||||||
|
<input type="text" id="cfgPrefix" placeholder="/api" spellcheck="false">
|
||||||
|
<p class="hint">社区版 <code>/api</code>;企业版可能是 <code>/admin-api</code></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="fi">
|
<div class="fi">
|
||||||
<label>登录账号</label>
|
<label>登录账号</label>
|
||||||
@@ -261,7 +253,7 @@ $rosterCount = Roster::count();
|
|||||||
<div id="pingResult" class="ping-result hidden"></div>
|
<div id="pingResult" class="ping-result hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
<span class="hint" style="align-self:center">只有修改账号/密码才会触发重连与测连接</span>
|
<span class="hint" style="align-self:center">修改地址 / 前缀 / 账号 / 密码后保存会自动重新连接</span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button class="btn" id="btnTestConn">测试连接</button>
|
<button class="btn" id="btnTestConn">测试连接</button>
|
||||||
<button class="btn" data-close>取消</button>
|
<button class="btn" data-close>取消</button>
|
||||||
@@ -283,9 +275,10 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="roster-bar">
|
<div class="roster-bar">
|
||||||
|
<button class="btn sm" id="btnTpl" title="下载名单导入模板(CSV,含「登陆账号」列)">⬇ 下载模板</button>
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<label class="chk"><input type="radio" name="impMode" value="replace" checked> 覆盖</label>
|
<label class="chk"><input type="radio" name="impMode" value="replace"> 覆盖</label>
|
||||||
<label class="chk"><input type="radio" name="impMode" value="append"> 追加</label>
|
<label class="chk"><input type="radio" name="impMode" value="append" checked> 追加</label>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="rosterSearch" placeholder="搜索学号 / 姓名">
|
<input type="text" id="rosterSearch" placeholder="搜索学号 / 姓名">
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
@@ -296,7 +289,7 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
<div class="table-scroll mid">
|
<div class="table-scroll mid">
|
||||||
<table class="grid compact" id="rosterTable">
|
<table class="grid compact" id="rosterTable">
|
||||||
<thead><tr><th style="width:34px"><input type="checkbox" id="rosterCheckAll" title="全选"></th><th style="width:24%">学号</th><th style="width:20%">姓名</th><th>班级</th><th>组别</th><th>登陆账号</th></tr></thead>
|
<thead><tr><th style="width:6%"><input type="checkbox" id="rosterCheckAll" title="全选"></th><th style="width:26%">学号</th><th style="width:22%">姓名</th><th style="width:26%">班级</th><th style="width:20%">组别</th></tr></thead>
|
||||||
<tbody id="rosterBody"></tbody>
|
<tbody id="rosterBody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -338,7 +331,7 @@ $rosterCount = Roster::count();
|
|||||||
<label>组别</label>
|
<label>组别</label>
|
||||||
<input type="text" id="addGrp" placeholder="如 A / B,可选">
|
<input type="text" id="addGrp" placeholder="如 A / B,可选">
|
||||||
</div>
|
</div>
|
||||||
<div class="fi">
|
<div class="fi" hidden>
|
||||||
<label>登陆账号</label>
|
<label>登陆账号</label>
|
||||||
<input type="text" id="addLogin" placeholder="可选,用于抽取页登录">
|
<input type="text" id="addLogin" placeholder="可选,用于抽取页登录">
|
||||||
</div>
|
</div>
|
||||||
@@ -380,60 +373,15 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 成绩快照 / 趋势 -->
|
|
||||||
<div class="modal" id="snapshotModal">
|
|
||||||
<div class="modal-box wide">
|
|
||||||
<div class="modal-head"><h3 id="snapTitle">成绩快照</h3><button class="x" data-close>×</button></div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="fi" style="margin-bottom:10px">
|
|
||||||
<label>快照标签(可选)</label>
|
|
||||||
<input type="text" id="snapLabel" placeholder="如:第一次考试、补考后…">
|
|
||||||
</div>
|
|
||||||
<div class="snap-toolbar" style="display:flex;gap:8px;margin-bottom:12px">
|
|
||||||
<button class="btn sm btn-primary" id="btnSnapSave">保存当前快照</button>
|
|
||||||
<span class="spacer"></span>
|
|
||||||
<button class="btn sm" id="btnSnapRefresh">刷新列表</button>
|
|
||||||
<button class="btn sm" id="btnSnapTrend">查看趋势</button>
|
|
||||||
</div>
|
|
||||||
<div class="table-scroll mid">
|
|
||||||
<table class="grid compact" id="snapTable">
|
|
||||||
<thead><tr><th>标签</th><th>保存时间</th><th>人数</th><th>项目</th><th style="width:70px">操作</th></tr></thead>
|
|
||||||
<tbody id="snapBody"><tr><td colspan="5" class="empty">暂无快照</td></tr></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-foot">
|
|
||||||
<span class="spacer"></span>
|
|
||||||
<button class="btn" data-close>关闭</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 跨快照趋势 -->
|
|
||||||
<div class="modal" id="trendModal">
|
|
||||||
<div class="modal-box wide">
|
|
||||||
<div class="modal-head"><h3>成绩趋势 <small id="trendSub"></small></h3><button class="x" data-close>×</button></div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="table-scroll" style="max-height:60vh">
|
|
||||||
<table class="grid compact" id="trendTable">
|
|
||||||
<thead id="trendHead"></thead>
|
|
||||||
<tbody id="trendBody"><tr><td colspan="10" class="empty">加载中…</td></tr></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p class="hint" id="trendHint">趋势值为每名学生的「最高分」,未出现该快照的记「—」</p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-foot">
|
|
||||||
<span class="spacer"></span>
|
|
||||||
<button class="btn" data-close>关闭</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 成绩明细打印设置 -->
|
<!-- 成绩明细打印设置 -->
|
||||||
<div class="modal" id="printTableModal">
|
<div class="modal" id="printTableModal">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<div class="modal-head"><h3>打印成绩明细</h3><button class="x" data-close>×</button></div>
|
<div class="modal-head"><h3>打印成绩明细</h3><button class="x" data-close>×</button></div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
<div class="fi">
|
||||||
|
<label>打印标题</label>
|
||||||
|
<input type="text" id="ptTitle" placeholder="默认:试卷名 + 成绩明细">
|
||||||
|
</div>
|
||||||
<div class="fi">
|
<div class="fi">
|
||||||
<label>排序方式</label>
|
<label>排序方式</label>
|
||||||
<select id="ptSort">
|
<select id="ptSort">
|
||||||
@@ -450,12 +398,39 @@ $rosterCount = Roster::count();
|
|||||||
<option value="cls">按班级分组</option>
|
<option value="cls">按班级分组</option>
|
||||||
<option value="grp">按组别分组</option>
|
<option value="grp">按组别分组</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="hint">分组后每个班/组单独一段,段首显示小计(人数/平均分/合格率)</p>
|
<p class="hint">分组后每个班/组单独一段,段首显示小计(人数/平均/最高/最低/合格率)</p>
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>纸张方向</label>
|
||||||
|
<select id="ptOrient">
|
||||||
|
<option value="portrait" selected>纵向(A4,列少时清晰)</option>
|
||||||
|
<option value="landscape">横向(A4,列多时不易折行)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>打印范围</label>
|
||||||
|
<select id="ptScope">
|
||||||
|
<option value="latest" selected>每人一行(汇总)</option>
|
||||||
|
<option value="all">每次考试一行(含历史提交)</option>
|
||||||
|
</select>
|
||||||
|
<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>
|
||||||
|
<label><input type="checkbox" id="ptPass"> 显示「是否合格」列(默认不显示)</label>
|
||||||
<label><input type="checkbox" id="ptAttempts" checked> 显示考试次数</label>
|
<label><input type="checkbox" id="ptAttempts" checked> 显示考试次数</label>
|
||||||
|
<label><input type="checkbox" id="ptAbsent" title="成绩表后另起一页附缺考名单,含备注列供手写"> 附缺考名单(另起一页)</label>
|
||||||
</div>
|
</div>
|
||||||
|
<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">
|
||||||
@@ -497,21 +472,186 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 异常检测线索 -->
|
<!-- ================= 统一导出 / 打印 =================
|
||||||
<div class="modal" id="anomalyModal">
|
所有导出(成绩明细 / 每次考试 / 缺考名单 / 多考试对比)与打印都在这一个弹窗里配置:
|
||||||
|
先选「导出什么」,再按需展开「范围」「列与标记」「打印」三个区块。 -->
|
||||||
|
<div class="modal" id="exportModal">
|
||||||
<div class="modal-box wide">
|
<div class="modal-box wide">
|
||||||
<div class="modal-head"><h3>异常检测线索</h3><button class="x" data-close>×</button></div>
|
<div class="modal-head"><h3>导出 / 打印</h3><button class="x" data-close>×</button></div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p class="hint" style="margin:0 0 12px">以下为自动统计的「防作弊线索」,仅供人工复核判断,不代表结论。</p>
|
|
||||||
<div id="anomalyBody"></div>
|
<!-- 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>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button class="btn" data-close>关闭</button>
|
<button class="btn" data-close>取消</button>
|
||||||
|
<button class="btn" id="btnExPrint">打印</button>
|
||||||
|
<button class="btn btn-primary" id="btnExExport">导出 Excel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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) ?>,
|
||||||
|
|||||||
@@ -103,15 +103,4 @@ final class DataCache
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清空所有数据缓存(名单变更 / 手动清缓存时使用) */
|
|
||||||
public static function clear(): int
|
|
||||||
{
|
|
||||||
$n = 0;
|
|
||||||
foreach (glob(self::dir() . '/data_*.json') ?: [] as $f) {
|
|
||||||
if (@unlink($f)) {
|
|
||||||
$n++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $n;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+146
-111
@@ -30,9 +30,15 @@ class DataService
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// children 在根或子节点
|
// 不同卷王版本题目可能挂在 children / questions / list 下,统一兼容
|
||||||
$self = $node;
|
$self = $node;
|
||||||
$children = (isset($node['children']) && is_array($node['children'])) ? $node['children'] : null;
|
$children = null;
|
||||||
|
foreach (['children', 'questions', 'list', 'items'] as $ck) {
|
||||||
|
if (isset($node[$ck]) && is_array($node[$ck])) {
|
||||||
|
$children = $node[$ck];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$id = isset($self['id']) ? (string)$self['id'] : '';
|
$id = isset($self['id']) ? (string)$self['id'] : '';
|
||||||
$title = isset($self['title']) ? self::cleanTitle((string)$self['title']) : '';
|
$title = isset($self['title']) ? self::cleanTitle((string)$self['title']) : '';
|
||||||
@@ -138,26 +144,15 @@ class DataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$students = Roster::all();
|
// 基础名单(全名单):用于答卷匹配,不受白名单影响
|
||||||
// 免考:从名单中剔除,使其既不计入「总人数」也不计入「缺考」
|
$rosterAll = Roster::all();
|
||||||
if ($exempt !== []) {
|
if ($exempt !== []) {
|
||||||
$students = array_values(array_filter(
|
$rosterAll = array_values(array_filter(
|
||||||
$students,
|
$rosterAll,
|
||||||
static fn(array $s): bool => !isset($exempt[trim((string)($s['sid'] ?? ''))])
|
static fn(array $s): bool => !isset($exempt[trim((string)($s['sid'] ?? ''))])
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// 姓名 -> 学号 / 班级(规范化)
|
// 注:$students(应考名单)与 $studentInfo 在解析出 $survey(含白名单)后统一构建,见下方
|
||||||
$studentInfo = [];
|
|
||||||
foreach ($students as $s) {
|
|
||||||
$k = ny_norm_name((string)$s['name']);
|
|
||||||
if ($k !== '') {
|
|
||||||
$studentInfo[$k] = [
|
|
||||||
'sid' => (string)$s['sid'],
|
|
||||||
'cls' => (string)($s['cls'] ?? ''),
|
|
||||||
'grp' => (string)($s['grp'] ?? ''),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$project = $client->getProject($projectId);
|
$project = $client->getProject($projectId);
|
||||||
$survey = $project['survey'] ?? null;
|
$survey = $project['survey'] ?? null;
|
||||||
@@ -167,6 +162,75 @@ class DataService
|
|||||||
$survey = $decoded;
|
$survey = $decoded;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 卷内「考试白名单 / 通讯录名单」:决定「应考名单」(缺/待考名单来源)。
|
||||||
|
// 真实来源 = 卷王项目「参与者(受访者)」(t_project_partner, type=RESPONDENT_SYS_USER/IMP_USER),
|
||||||
|
// 由 SurveyKingClient::getWhitelist -> getRespondentPartners(/api/project/partner/list) 读取;
|
||||||
|
// 未配置受访者名单时「按现在的方案显示」(使用全名单)。
|
||||||
|
$paperWhitelist = [];
|
||||||
|
try {
|
||||||
|
$paperWhitelist = $client->getWhitelist($projectId, $survey);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$paperWhitelist = [];
|
||||||
|
}
|
||||||
|
$useWhitelist = ($paperWhitelist !== []);
|
||||||
|
$whitelistTotal = 0;
|
||||||
|
|
||||||
|
if ($useWhitelist) {
|
||||||
|
// 以学号 / 登陆账号 / 姓名(大小写不敏感)把白名单成员匹配回名单,
|
||||||
|
// 命中的取名单里的姓名/班级/组别;未命中的以白名单值作占位,避免漏统计。
|
||||||
|
$rosterMap = [];
|
||||||
|
foreach ($rosterAll as $s) {
|
||||||
|
$sid = strtolower(trim((string)($s['sid'] ?? '')));
|
||||||
|
$login = strtolower(trim((string)($s['login'] ?? '')));
|
||||||
|
$name = strtolower(trim((string)($s['name'] ?? '')));
|
||||||
|
if ($sid) { $rosterMap['sid:' . $sid] = $s; }
|
||||||
|
if ($login) { $rosterMap['login:' . $login] = $s; }
|
||||||
|
if ($name) { $rosterMap['name:' . $name] = $s; }
|
||||||
|
}
|
||||||
|
$students = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach ($paperWhitelist as $v) {
|
||||||
|
$key = strtolower(trim((string)$v));
|
||||||
|
$m = $rosterMap['sid:' . $key] ?? $rosterMap['login:' . $key] ?? $rosterMap['name:' . $key] ?? null;
|
||||||
|
if ($m !== null) {
|
||||||
|
$sid = trim((string)($m['sid'] ?? ''));
|
||||||
|
if ($sid !== '' && isset($seen[$sid])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($sid !== '') { $seen[$sid] = true; }
|
||||||
|
$students[] = $m;
|
||||||
|
} else {
|
||||||
|
if (isset($seen['wk:' . $key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen['wk:' . $key] = true;
|
||||||
|
$students[] = ['sid' => $v, 'name' => $v, 'cls' => '', 'grp' => '', 'login' => ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$whitelistTotal = count($students);
|
||||||
|
} else {
|
||||||
|
$students = $rosterAll;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 姓名 -> 学号 / 班级(规范化):全名单 + 白名单独有成员都纳入,
|
||||||
|
// 保证所有提交(含白名单外提交)都能匹配到姓名。
|
||||||
|
$studentInfo = [];
|
||||||
|
$addInfo = static function (array $s) use (&$studentInfo): void {
|
||||||
|
$k = ny_norm_name((string)($s['name'] ?? ''));
|
||||||
|
if ($k !== '') {
|
||||||
|
$studentInfo[$k] = [
|
||||||
|
'sid' => (string)$s['sid'],
|
||||||
|
'cls' => (string)($s['cls'] ?? ''),
|
||||||
|
'grp' => (string)($s['grp'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
foreach ($rosterAll as $s) { $addInfo($s); }
|
||||||
|
if ($useWhitelist) {
|
||||||
|
foreach ($students as $s) { $addInfo($s); }
|
||||||
|
}
|
||||||
|
|
||||||
$questions = self::flattenQuestions($survey);
|
$questions = self::flattenQuestions($survey);
|
||||||
|
|
||||||
$keywords = (string)($cfg['name_keywords'] ?? '姓名');
|
$keywords = (string)($cfg['name_keywords'] ?? '姓名');
|
||||||
@@ -218,7 +282,7 @@ class DataService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$v = ny_flatten_value($answerMap[$qid]);
|
$v = ny_flatten_value($answerMap[$qid]);
|
||||||
if ($v !== '' && isset($nameMap[ny_norm_name($v)])) {
|
if ($v !== '' && isset($studentInfo[ny_norm_name($v)])) {
|
||||||
$name = $v;
|
$name = $v;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -227,7 +291,7 @@ class DataService
|
|||||||
if ($name === '' && $questions === []) {
|
if ($name === '' && $questions === []) {
|
||||||
foreach ($answerMap as $v) {
|
foreach ($answerMap as $v) {
|
||||||
$s = ny_flatten_value($v);
|
$s = ny_flatten_value($v);
|
||||||
if ($s !== '' && isset($nameMap[ny_norm_name($s)])) {
|
if ($s !== '' && isset($studentInfo[ny_norm_name($s)])) {
|
||||||
$name = $s;
|
$name = $s;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -252,8 +316,8 @@ class DataService
|
|||||||
$ts = ny_to_timestamp($a['updateAt'] ?? null);
|
$ts = ny_to_timestamp($a['updateAt'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 考试时长
|
// 考试时长(metaInfo 可能缺失,先整体判空避免每答案两条 Undefined key 警告)
|
||||||
$ai = $a['metaInfo']['answerInfo'] ?? [];
|
$ai = ($a['metaInfo'] ?? [])['answerInfo'] ?? [];
|
||||||
$start = is_array($ai) ? (int)($ai['startTime'] ?? 0) : 0;
|
$start = is_array($ai) ? (int)($ai['startTime'] ?? 0) : 0;
|
||||||
$end = is_array($ai) ? (int)($ai['endTime'] ?? 0) : 0;
|
$end = is_array($ai) ? (int)($ai['endTime'] ?? 0) : 0;
|
||||||
$dur = null;
|
$dur = null;
|
||||||
@@ -356,9 +420,6 @@ class DataService
|
|||||||
'attempts_total'=> $attemptsTotal,
|
'attempts_total'=> $attemptsTotal,
|
||||||
];
|
];
|
||||||
|
|
||||||
// 异常检测(防作弊线索,仅供教师人工判断,不做自动判定)
|
|
||||||
$anomalies = self::detectAnomalies($rows, $stats);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'project' => [
|
'project' => [
|
||||||
'id' => ny_str($project['id'] ?? $projectId),
|
'id' => ny_str($project['id'] ?? $projectId),
|
||||||
@@ -372,9 +433,10 @@ class DataService
|
|||||||
'absent' => $absent,
|
'absent' => $absent,
|
||||||
'unmatched' => $unmatched,
|
'unmatched' => $unmatched,
|
||||||
'stats' => $stats,
|
'stats' => $stats,
|
||||||
'anomalies' => $anomalies,
|
|
||||||
'roster' => $students,
|
'roster' => $students,
|
||||||
'roster_count' => count($students),
|
'roster_count' => count($students),
|
||||||
|
'whitelist_driven' => $useWhitelist,
|
||||||
|
'whitelist_total' => $whitelistTotal,
|
||||||
// 本卷独立配置(前端据此回显满分/合格率/免考);full_score 已按本卷解析
|
// 本卷独立配置(前端据此回显满分/合格率/免考);full_score 已按本卷解析
|
||||||
'project_cfg' => [
|
'project_cfg' => [
|
||||||
'full_score' => $pCfg['full_score'] ?? null,
|
'full_score' => $pCfg['full_score'] ?? null,
|
||||||
@@ -385,89 +447,6 @@ class DataService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 异常检测(防作弊线索):基于聚合后的学生行,输出三类线索供教师人工复核。
|
|
||||||
* 1) 短时长高得分:用时远低于平均(<平均 40% 且 <180s)却接近满分;
|
|
||||||
* 2) 高频重考:考试次数明显多于多数人(>1 且 >= 平均次数+3);
|
|
||||||
* 3) 雷同成绩:多人分数完全相同(疑似互抄,需满足该分数下人数 >=3)。
|
|
||||||
*
|
|
||||||
* 返回值:['fast_high'=>[], 'frequent'=>[], 'identical'=>[['score'=>x,'count'=>n,'names'=>[...]]]]
|
|
||||||
*/
|
|
||||||
public static function detectAnomalies(array $rows, array $stats): array
|
|
||||||
{
|
|
||||||
$avgDur = (int)($stats['avg_duration'] ?? 0);
|
|
||||||
$fullHint = max(1, (float)($stats['max_score'] ?? 100));
|
|
||||||
|
|
||||||
$fastHigh = [];
|
|
||||||
$frequent = [];
|
|
||||||
$attemptsAll = [];
|
|
||||||
|
|
||||||
foreach ($rows as $r) {
|
|
||||||
$dur = $r['duration'] ?? null;
|
|
||||||
$score = $r['score'] ?? null;
|
|
||||||
$attempts = (int)($r['attempts'] ?? 1);
|
|
||||||
$attemptsAll[] = $attempts;
|
|
||||||
|
|
||||||
// 短时长高得分:有时长记录、明显快于平均、分数 >= 满分 85%
|
|
||||||
if ($dur !== null && $avgDur > 0 && $dur < max(60, (int)($avgDur * 0.4))
|
|
||||||
&& $score !== null && $score >= $fullHint * 0.85) {
|
|
||||||
$fastHigh[] = [
|
|
||||||
'sid' => (string)($r['sid'] ?? ''),
|
|
||||||
'name' => (string)($r['name'] ?? ''),
|
|
||||||
'cls' => (string)($r['cls'] ?? ''),
|
|
||||||
'score' => $score,
|
|
||||||
'duration' => $dur,
|
|
||||||
'duration_txt' => (string)($r['duration_txt'] ?? ''),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 高频重考:考试次数明显偏多
|
|
||||||
if ($attempts > 1) {
|
|
||||||
$frequent[] = [
|
|
||||||
'sid' => (string)($r['sid'] ?? ''),
|
|
||||||
'name' => (string)($r['name'] ?? ''),
|
|
||||||
'cls' => (string)($r['cls'] ?? ''),
|
|
||||||
'attempts' => $attempts,
|
|
||||||
'best_score' => $r['best_score'] ?? null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 高频重考阈值:平均次数 + 3(且至少 4 次才够"高频")
|
|
||||||
$avgAttempts = $attemptsAll !== [] ? array_sum($attemptsAll) / count($attemptsAll) : 0;
|
|
||||||
$freqThreshold = max(4, (int)ceil($avgAttempts + 3));
|
|
||||||
$frequent = array_values(array_filter($frequent, static fn($f) => $f['attempts'] >= $freqThreshold));
|
|
||||||
usort($frequent, static fn($a, $b) => $b['attempts'] <=> $a['attempts']);
|
|
||||||
|
|
||||||
// 雷同成绩:按分数分组,出现 >=3 人同分(且分数不为 null)视为疑似
|
|
||||||
$scoreGroups = [];
|
|
||||||
foreach ($rows as $r) {
|
|
||||||
$score = $r['score'] ?? null;
|
|
||||||
if ($score === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$key = (string)$score;
|
|
||||||
$scoreGroups[$key][] = [
|
|
||||||
'sid' => (string)($r['sid'] ?? ''),
|
|
||||||
'name' => (string)($r['name'] ?? ''),
|
|
||||||
'cls' => (string)($r['cls'] ?? ''),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
$identical = [];
|
|
||||||
foreach ($scoreGroups as $score => $members) {
|
|
||||||
if (count($members) >= 3) {
|
|
||||||
$identical[] = ['score' => (float)$score, 'count' => count($members), 'members' => $members];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
usort($identical, static fn($a, $b) => $b['count'] <=> $a['count']);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'fast_high' => $fastHigh,
|
|
||||||
'frequent' => $frequent,
|
|
||||||
'identical' => $identical,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把聚合后的学生行展开为"每次考试一行",用于导出全部考试记录 / 明细内全部展示。
|
* 把聚合后的学生行展开为"每次考试一行",用于导出全部考试记录 / 明细内全部展示。
|
||||||
* 每行携带该次考试的成绩、提交时间、时长,以及该学生的总考试次数 attempts 与第几次 idx。
|
* 每行携带该次考试的成绩、提交时间、时长,以及该学生的总考试次数 attempts 与第几次 idx。
|
||||||
@@ -486,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;
|
||||||
}
|
}
|
||||||
@@ -506,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'] ?? '-'),
|
||||||
];
|
];
|
||||||
@@ -573,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 '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按学号聚合一次提交的答卷记录,解决"同人多次刷卷"在看板中被当成多个人的问题。
|
* 按学号聚合一次提交的答卷记录,解决"同人多次刷卷"在看板中被当成多个人的问题。
|
||||||
*
|
*
|
||||||
@@ -610,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,
|
||||||
@@ -629,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) {
|
||||||
|
|||||||
+49
-73
@@ -56,17 +56,6 @@ class Db
|
|||||||
return self::$err;
|
return self::$err;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 连接参数(SQLite 固定,保留接口以兼容既有调用) */
|
|
||||||
public static function params(): array
|
|
||||||
{
|
|
||||||
return ['driver' => 'sqlite', 'file' => self::dbFile()];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function saveParams(array $p): bool
|
|
||||||
{
|
|
||||||
return true; // SQLite 无需外部配置
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 建立/复用连接;失败返回 null 而不抛异常(上层要能优雅降级) */
|
/** 建立/复用连接;失败返回 null 而不抛异常(上层要能优雅降级) */
|
||||||
public static function connect(array $p = []): PDO
|
public static function connect(array $p = []): PDO
|
||||||
{
|
{
|
||||||
@@ -180,32 +169,19 @@ class Db
|
|||||||
|
|
||||||
// ---------------- 设置 ----------------
|
// ---------------- 设置 ----------------
|
||||||
|
|
||||||
public static function settingsAll(): array
|
|
||||||
{
|
|
||||||
$pdo = self::pdo();
|
|
||||||
if (!$pdo) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$p = self::prefix();
|
|
||||||
$st = $pdo->query("SELECT k, v FROM {$p}settings");
|
|
||||||
$out = [];
|
|
||||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
|
||||||
$out[(string)$r['k']] = (string)$r['v'];
|
|
||||||
}
|
|
||||||
return $out;
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
self::$err = $e->getMessage();
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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();
|
||||||
@@ -214,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();
|
||||||
}
|
}
|
||||||
@@ -265,12 +272,16 @@ class Db
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 全量替换名单(roster_save 语义) */
|
/**
|
||||||
public static function rosterReplace(array $students): void
|
* 全量替换名单(roster_save 语义)
|
||||||
|
* @return bool 成功 true;DB 不可用或事务失败 false(失败原因见 lastError)
|
||||||
|
*/
|
||||||
|
public static function rosterReplace(array $students): bool
|
||||||
{
|
{
|
||||||
$pdo = self::pdo();
|
$pdo = self::pdo();
|
||||||
if (!$pdo) {
|
if (!$pdo) {
|
||||||
return;
|
self::$err = '数据库未连接';
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$p = self::prefix();
|
$p = self::prefix();
|
||||||
@@ -296,11 +307,13 @@ class Db
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
$pdo->commit();
|
$pdo->commit();
|
||||||
|
return true;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
if ($pdo->inTransaction()) {
|
if ($pdo->inTransaction()) {
|
||||||
$pdo->rollBack();
|
$pdo->rollBack();
|
||||||
}
|
}
|
||||||
self::$err = $e->getMessage();
|
self::$err = $e->getMessage();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,29 +334,6 @@ class Db
|
|||||||
|
|
||||||
// ---------------- 每卷独立配置(满分 / 合格率 / 免考名单) ----------------
|
// ---------------- 每卷独立配置(满分 / 合格率 / 免考名单) ----------------
|
||||||
|
|
||||||
/** @return array<string,array> 全部试卷配置 [project_id => cfg] */
|
|
||||||
public static function projectConfigAll(): array
|
|
||||||
{
|
|
||||||
$pdo = self::pdo();
|
|
||||||
if (!$pdo) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$p = self::prefix();
|
|
||||||
$st = $pdo->query("SELECT project_id, config_json FROM {$p}project_config");
|
|
||||||
$out = [];
|
|
||||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
|
||||||
$j = json_decode((string)($r['config_json'] ?? '{}'), true);
|
|
||||||
if (is_array($j)) {
|
|
||||||
$out[(string)$r['project_id']] = $j;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $out;
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
self::$err = $e->getMessage();
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array 单卷配置(缺省返回空结构) */
|
/** @return array 单卷配置(缺省返回空结构) */
|
||||||
public static function projectConfigGet(string $projectId): array
|
public static function projectConfigGet(string $projectId): array
|
||||||
@@ -394,18 +384,4 @@ class Db
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function projectConfigDelete(string $projectId): void
|
|
||||||
{
|
|
||||||
$pdo = self::pdo();
|
|
||||||
if (!$pdo) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$p = self::prefix();
|
|
||||||
$st = $pdo->prepare("DELETE FROM {$p}project_config WHERE project_id = ?");
|
|
||||||
$st->execute([$projectId]);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
self::$err = $e->getMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-27
@@ -110,9 +110,23 @@ class Roster
|
|||||||
'login' => trim((string)($s['login'] ?? '')),
|
'login' => trim((string)($s['login'] ?? '')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
// 按学号去重(同 sid 取最后一条):roster 表 sid 为主键,重复 sid 会触发约束冲突导致整批回滚。
|
||||||
|
// 去重避免「保存成功但名单实际没变」的静默丢失。
|
||||||
|
$bySid = [];
|
||||||
|
$noSid = [];
|
||||||
|
foreach ($clean as $s) {
|
||||||
|
if ((string)($s['sid'] ?? '') !== '') {
|
||||||
|
$bySid[$s['sid']] = $s;
|
||||||
|
} else {
|
||||||
|
$noSid[] = $s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$clean = array_values($bySid);
|
||||||
|
foreach ($noSid as $s) {
|
||||||
|
$clean[] = $s;
|
||||||
|
}
|
||||||
if (self::useDb()) {
|
if (self::useDb()) {
|
||||||
Db::rosterReplace($clean);
|
return Db::rosterReplace($clean);
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return ny_write_json(self::file(), [
|
return ny_write_json(self::file(), [
|
||||||
'students' => $clean,
|
'students' => $clean,
|
||||||
@@ -157,31 +171,6 @@ class Roster
|
|||||||
return $mt . ':' . $sz . ':' . $hash;
|
return $mt . ':' . $sz . ':' . $hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 首次使用时,若名单为空且存在 导入模板.xlsx,自动导入
|
|
||||||
*/
|
|
||||||
public static function ensureSeeded(): bool
|
|
||||||
{
|
|
||||||
if (self::count() > 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
$candidates = [
|
|
||||||
dirname(NY_ROOT) . '/导入模板.xlsx',
|
|
||||||
NY_ROOT . '/导入模板.xlsx',
|
|
||||||
NY_DATA . '/导入模板.xlsx',
|
|
||||||
];
|
|
||||||
foreach ($candidates as $f) {
|
|
||||||
if (is_file($f)) {
|
|
||||||
try {
|
|
||||||
$res = self::importFile($f, 'xlsx');
|
|
||||||
return $res['count'] > 0;
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
// 忽略
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析上传文件 -> 学生数组
|
* 解析上传文件 -> 学生数组
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* 成绩快照:把某次「拉取到的成绩」落盘成一份带标签的时间点存档,
|
|
||||||
* 用于跨时间 / 跨试卷的趋势对比(例如某学生几次考试的分数变化)。
|
|
||||||
*
|
|
||||||
* 存储位置:data/snapshots/<projectId>/<unix秒>_<md5(标签)>.json
|
|
||||||
* 每份快照内容为 { label, at, project:{id,name}, stats, students:[{sid,name,cls,grp,score,best_score,attempts,last_submit_at}] }
|
|
||||||
*/
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
final class Snapshot
|
|
||||||
{
|
|
||||||
private static function dir(string $projectId): string
|
|
||||||
{
|
|
||||||
$d = NY_DATA . '/snapshots/' . preg_replace('/[^A-Za-z0-9_-]/', '_', $projectId);
|
|
||||||
if (!is_dir($d)) {
|
|
||||||
@mkdir($d, 0777, true);
|
|
||||||
}
|
|
||||||
return $d;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 从聚合结果里抽取轻量学生摘要(只保留趋势对比需要的字段) */
|
|
||||||
public static function summarize(array $result, string $label): array
|
|
||||||
{
|
|
||||||
$students = [];
|
|
||||||
foreach ((array)($result['rows'] ?? []) as $r) {
|
|
||||||
$students[] = [
|
|
||||||
'sid' => (string)($r['sid'] ?? ''),
|
|
||||||
'name' => (string)($r['name'] ?? ''),
|
|
||||||
'cls' => (string)($r['cls'] ?? ''),
|
|
||||||
'grp' => (string)($r['grp'] ?? ''),
|
|
||||||
'score' => ($r['score'] ?? null) === null ? null : (float)$r['score'],
|
|
||||||
'best_score' => ($r['best_score'] ?? null) === null ? null : (float)$r['best_score'],
|
|
||||||
'attempts' => (int)($r['attempts'] ?? 1),
|
|
||||||
'last_submit_at' => (string)($r['last_submit_at'] ?? ($r['submit_at'] ?? '-')),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
// 按学号排序,保证趋势表行序稳定
|
|
||||||
usort($students, static function ($a, $b): int {
|
|
||||||
$sa = $a['sid']; $sb = $b['sid'];
|
|
||||||
if (is_numeric($sa) && is_numeric($sb)) {
|
|
||||||
return $sa <=> $sb;
|
|
||||||
}
|
|
||||||
return strnatcmp((string)$sa, (string)$sb);
|
|
||||||
});
|
|
||||||
|
|
||||||
return [
|
|
||||||
'label' => trim($label) === '' ? '快照 ' . date('m-d H:i') : trim($label),
|
|
||||||
'at' => time(),
|
|
||||||
'at_txt' => date('Y-m-d H:i:s'),
|
|
||||||
'project' => [
|
|
||||||
'id' => (string)($result['project']['id'] ?? ''),
|
|
||||||
'name' => (string)($result['project']['name'] ?? ''),
|
|
||||||
],
|
|
||||||
'stats' => $result['stats'] ?? [],
|
|
||||||
'students' => $students,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 保存一份快照,返回文件名 */
|
|
||||||
public static function save(string $projectId, array $snap): string
|
|
||||||
{
|
|
||||||
$dir = self::dir($projectId);
|
|
||||||
$name = time() . '_' . substr(md5($snap['label'] . $snap['at']), 0, 8) . '.json';
|
|
||||||
@file_put_contents($dir . '/' . $name, json_encode($snap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
|
||||||
return $name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 列出某项目的全部快照(按时间倒序) */
|
|
||||||
public static function list(string $projectId): array
|
|
||||||
{
|
|
||||||
$dir = self::dir($projectId);
|
|
||||||
$out = [];
|
|
||||||
foreach (glob($dir . '/*.json') ?: [] as $f) {
|
|
||||||
$raw = @file_get_contents($f);
|
|
||||||
if ($raw === false) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$j = json_decode($raw, true);
|
|
||||||
if (!is_array($j)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$out[] = [
|
|
||||||
'file' => basename($f),
|
|
||||||
'label' => (string)($j['label'] ?? basename($f)),
|
|
||||||
'at' => (int)($j['at'] ?? 0),
|
|
||||||
'at_txt' => (string)($j['at_txt'] ?? ''),
|
|
||||||
'project' => $j['project'] ?? [],
|
|
||||||
'count' => count((array)($j['students'] ?? [])),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
usort($out, static fn($a, $b) => ($b['at'] <=> $a['at']));
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 读取单份快照完整内容 */
|
|
||||||
public static function get(string $projectId, string $file): ?array
|
|
||||||
{
|
|
||||||
$file = basename($file); // 防路径穿越
|
|
||||||
$path = self::dir($projectId) . '/' . $file;
|
|
||||||
if (!is_file($path)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
$j = json_decode((string)@file_get_contents($path), true);
|
|
||||||
return is_array($j) ? $j : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除单份快照 */
|
|
||||||
public static function delete(string $projectId, string $file): bool
|
|
||||||
{
|
|
||||||
$file = basename($file);
|
|
||||||
$path = self::dir($projectId) . '/' . $file;
|
|
||||||
if (is_file($path)) {
|
|
||||||
return @unlink($path);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 跨快照趋势:以学号为行、各快照为列,输出每个学生在每份快照里的成绩。
|
|
||||||
* 用 best_score(真实最高分)作为趋势值,未出现记 null。
|
|
||||||
*/
|
|
||||||
public static function trend(array $snapshots): array
|
|
||||||
{
|
|
||||||
// 按时间升序排,最早的列在最前
|
|
||||||
$snaps = $snapshots;
|
|
||||||
usort($snaps, static fn($a, $b) => ($a['at'] <=> $b['at']));
|
|
||||||
|
|
||||||
$cols = [];
|
|
||||||
$bySid = [];
|
|
||||||
foreach ($snaps as $sn) {
|
|
||||||
$colKey = (string)$sn['at'] . ':' . (string)$sn['label'];
|
|
||||||
$cols[] = ['key' => $colKey, 'label' => (string)$sn['label'], 'at_txt' => (string)($sn['at_txt'] ?? '')];
|
|
||||||
foreach ((array)($sn['students'] ?? []) as $st) {
|
|
||||||
$sid = (string)($st['sid'] ?? '');
|
|
||||||
if ($sid === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!isset($bySid[$sid])) {
|
|
||||||
$bySid[$sid] = [
|
|
||||||
'sid' => $sid,
|
|
||||||
'name' => (string)($st['name'] ?? ''),
|
|
||||||
'cls' => (string)($st['cls'] ?? ''),
|
|
||||||
'scores' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
$bySid[$sid]['scores'][$colKey] = ($st['best_score'] ?? null) === null
|
|
||||||
? null
|
|
||||||
: (float)$st['best_score'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = array_values($bySid);
|
|
||||||
// 按学号排序
|
|
||||||
usort($rows, static function ($a, $b): int {
|
|
||||||
if (is_numeric($a['sid']) && is_numeric($b['sid'])) {
|
|
||||||
return $a['sid'] <=> $b['sid'];
|
|
||||||
}
|
|
||||||
return strnatcmp((string)$a['sid'], (string)$b['sid']);
|
|
||||||
});
|
|
||||||
|
|
||||||
return ['columns' => $cols, 'rows' => $rows];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -483,6 +483,177 @@ class SurveyKingClient
|
|||||||
return is_array($r['data']) ? $r['data'] : [];
|
return is_array($r['data']) ? $r['data'] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取「应考名单」来源(待考 / 缺考名单由此驱动)。
|
||||||
|
*
|
||||||
|
* 卷王真实模型(社区版 v1.13 与开源版一致):
|
||||||
|
* 限制谁可以答卷的是项目「参与者(受访者)」—— t_project_partner 中
|
||||||
|
* type = RESPONDENT_SYS_USER(2) / RESPONDENT_IMP_USER(3) 的记录,
|
||||||
|
* 经 /api/project/partner/list 返回,每条带 status(0未访问 / 1已访问未答 / 2已答题)。
|
||||||
|
* 这才是「考试白名单 / 通讯录名单」的真实载体。
|
||||||
|
*
|
||||||
|
* 旧代码误把 surveySetting.whiteLists(按「题目选项」白名单,限制某题可选项)当成应考名单,
|
||||||
|
* 字段形状根本不对,导致线上从未触发。这里以真实模型为准,whiteLists 仅作极老版本的兜底。
|
||||||
|
*
|
||||||
|
* @param string $projectId 问卷 ID
|
||||||
|
* @param array|null $survey 已解析的 survey(仅极老版本白名单兜底用,新模型不依赖它)
|
||||||
|
* @return array<int,string> 应考成员标识:优先姓名(便于与本地名单按姓名匹配),其次登陆账号 / 学号
|
||||||
|
*/
|
||||||
|
public function getWhitelist(string $projectId, ?array $survey = null): array
|
||||||
|
{
|
||||||
|
// 1) 真实模型:项目「受访者」参与者 = 应考名单
|
||||||
|
$partners = [];
|
||||||
|
try {
|
||||||
|
$partners = $this->getRespondentPartners($projectId);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->log[] = 'getRespondentPartners 失败(回退白名单兜底):' . $e->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($partners !== []) {
|
||||||
|
$out = [];
|
||||||
|
foreach ($partners as $p) {
|
||||||
|
if (!is_array($p)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$type = (int)($p['type'] ?? 0);
|
||||||
|
if ($type !== 2 && $type !== 3) {
|
||||||
|
continue; // 仅取受访者(系统用户 / 导入用户),排除 OWNER / COLLABORATOR
|
||||||
|
}
|
||||||
|
$name = trim((string)($p['userName'] ?? ''));
|
||||||
|
$userId = trim((string)($p['userId'] ?? ''));
|
||||||
|
$user = $p['user'] ?? null;
|
||||||
|
$login = '';
|
||||||
|
if (is_array($user)) {
|
||||||
|
if ($name === '') {
|
||||||
|
$name = trim((string)($user['name'] ?? ''));
|
||||||
|
}
|
||||||
|
$login = trim((string)($user['login'] ?? $user['account'] ?? $user['username'] ?? ''));
|
||||||
|
if ($userId === '') {
|
||||||
|
$userId = trim((string)($user['id'] ?? ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 与本地名单 / 答卷姓名匹配的标识:姓名 > 登陆账号 > userId
|
||||||
|
$key = $name !== '' ? $name : ($login !== '' ? $login : $userId);
|
||||||
|
if ($key !== '') {
|
||||||
|
$out[] = $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 去重(大小写不敏感),保留首次出现顺序
|
||||||
|
$seen = [];
|
||||||
|
$res = [];
|
||||||
|
foreach ($out as $item) {
|
||||||
|
$k = strtolower($item);
|
||||||
|
if (!isset($seen[$k])) {
|
||||||
|
$seen[$k] = true;
|
||||||
|
$res[] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($res !== []) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 兜底:极老 / 定制版本可能把应考名单放在 surveySetting.whiteLists(实为题目选项白名单)
|
||||||
|
if (is_array($survey)) {
|
||||||
|
$setting = null;
|
||||||
|
if (isset($survey['surveySetting']) && is_array($survey['surveySetting'])) {
|
||||||
|
$setting = $survey['surveySetting'];
|
||||||
|
} elseif (isset($survey['survey']) && is_array($survey['survey']) && isset($survey['survey']['surveySetting'])) {
|
||||||
|
$setting = $survey['survey']['surveySetting'];
|
||||||
|
} else {
|
||||||
|
// 顶层直接是白名单结构
|
||||||
|
foreach (['whiteLists', 'whiteList', 'whitelist'] as $wk) {
|
||||||
|
if (isset($survey[$wk]) && (is_array($survey[$wk]) || is_string($survey[$wk]))) {
|
||||||
|
$setting = $survey;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (is_array($setting)) {
|
||||||
|
return self::parseWhitelist($setting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取项目的「受访者」参与者列表(应考名单真实来源)。
|
||||||
|
* GET /api/project/partner/list?projectId=...&types=2,3(RESPONDENT_SYS_USER / RESPONDENT_IMP_USER),自动翻页。
|
||||||
|
* @return array<int,array{id:string,type:int,userName:string,userId:string,status:int,user?:array}>
|
||||||
|
*/
|
||||||
|
public function getRespondentPartners(string $projectId): array
|
||||||
|
{
|
||||||
|
$size = 200;
|
||||||
|
$all = [];
|
||||||
|
for ($page = 1; $page <= 50; $page++) {
|
||||||
|
$r = $this->request('/project/partner/list', [
|
||||||
|
'projectId' => $projectId,
|
||||||
|
'types' => '2,3',
|
||||||
|
'current' => $page,
|
||||||
|
'pageSize' => $size,
|
||||||
|
]);
|
||||||
|
$data = $r['data'];
|
||||||
|
$list = is_array($data) ? ($data['list'] ?? $data) : [];
|
||||||
|
if (!is_array($list) || $list === []) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
foreach ($list as $p) {
|
||||||
|
if (is_array($p)) {
|
||||||
|
$all[] = $p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$total = is_array($data) ? (int)($data['total'] ?? 0) : 0;
|
||||||
|
if ($total > 0 && count($all) >= $total) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (count($list) < $size) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 surveySetting 中解析白名单成员(去重、保留首次出现顺序)。
|
||||||
|
* @return array<int,string>
|
||||||
|
*/
|
||||||
|
public static function parseWhitelist(array $setting): array
|
||||||
|
{
|
||||||
|
$raw = $setting['whiteLists'] ?? $setting['whiteList'] ?? $setting['whitelist'] ?? null;
|
||||||
|
if ($raw === null || $raw === [] || $raw === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
$walk = static function ($node) use (&$out, &$walk): void {
|
||||||
|
if (is_array($node)) {
|
||||||
|
// 列表或对象都逐值递归(兼容 { 题ID: [值] })
|
||||||
|
foreach ($node as $v) {
|
||||||
|
$walk($v);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (is_string($node) && trim($node) !== '') {
|
||||||
|
$out[] = trim($node);
|
||||||
|
} elseif (is_scalar($node) && $node !== '' && $node !== null) {
|
||||||
|
$out[] = (string)$node;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$walk($raw);
|
||||||
|
|
||||||
|
// 去重(大小写不敏感),保留首次出现顺序
|
||||||
|
$seen = [];
|
||||||
|
$res = [];
|
||||||
|
foreach ($out as $item) {
|
||||||
|
$key = strtolower($item);
|
||||||
|
if (!isset($seen[$key])) {
|
||||||
|
$seen[$key] = true;
|
||||||
|
$res[] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
/** 答卷列表(自动翻页取全量) */
|
/** 答卷列表(自动翻页取全量) */
|
||||||
public function listAnswers(string $projectId): array
|
public function listAnswers(string $projectId): array
|
||||||
{
|
{
|
||||||
|
|||||||
+194
-24
@@ -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);
|
||||||
|
|
||||||
@@ -15,13 +18,28 @@ final class XlsxWriter
|
|||||||
private array $strIndex = [];
|
private array $strIndex = [];
|
||||||
/** @var array<int,string> 索引 => 共享字符串 */
|
/** @var array<int,string> 索引 => 共享字符串 */
|
||||||
private array $strings = [];
|
private array $strings = [];
|
||||||
/** @var array<int,array{name:string,rows:array,widths:array}> 工作表(按输出顺序) */
|
/** @var array<int,int> 索引 => 出现次数(sst 的 count 用,区别于 uniqueCount) */
|
||||||
|
private array $strCount = [];
|
||||||
|
/** @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 二进制内容(兼容旧调用)
|
||||||
@@ -46,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
|
||||||
@@ -64,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
|
||||||
@@ -101,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'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,11 +261,14 @@ final class XlsxWriter
|
|||||||
$i = count($this->strings);
|
$i = count($this->strings);
|
||||||
$this->strIndex[$s] = $i;
|
$this->strIndex[$s] = $i;
|
||||||
$this->strings[$i] = $s;
|
$this->strings[$i] = $s;
|
||||||
|
$this->strCount[$i] = 0;
|
||||||
}
|
}
|
||||||
return $this->strIndex[$s];
|
$i = $this->strIndex[$s];
|
||||||
|
$this->strCount[$i] = ($this->strCount[$i] ?? 0) + 1;
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -170,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;
|
||||||
@@ -189,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 . '"/>' .
|
||||||
@@ -196,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>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,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>';
|
||||||
@@ -287,14 +456,15 @@ final class XlsxWriter
|
|||||||
|
|
||||||
private function sharedStringsXml(): string
|
private function sharedStringsXml(): string
|
||||||
{
|
{
|
||||||
$count = count($this->strings);
|
$unique = count($this->strings);
|
||||||
|
$total = array_sum($this->strCount);
|
||||||
$body = '';
|
$body = '';
|
||||||
foreach ($this->strings as $s) {
|
foreach ($this->strings as $s) {
|
||||||
$body .= '<si><t xml:space="preserve">' . self::xml((string)$s) . '</t></si>';
|
$body .= '<si><t xml:space="preserve">' . self::xml((string)$s) . '</t></si>';
|
||||||
}
|
}
|
||||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||||
'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="' . $count .
|
'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="' . $total .
|
||||||
'" uniqueCount="' . $count . '">' . $body . '</sst>';
|
'" uniqueCount="' . $unique . '">' . $body . '</sst>';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function coreXml(string $title): string
|
private function coreXml(string $title): string
|
||||||
|
|||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -7,13 +7,20 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
require __DIR__ . '/bootstrap.php';
|
require __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
|
// 先加载配置:下面已登录分支要用 $cfg 构造 SurveyKingClient 探活,必须在前面就准备好
|
||||||
|
$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();
|
||||||
}
|
}
|
||||||
@@ -23,17 +30,10 @@ if (ny_is_auth()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$cfg = ny_load_config();
|
|
||||||
$err = '';
|
$err = '';
|
||||||
$ok = false;
|
$ok = false;
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
// 登录失败限流:5 次失败锁 10 分钟,防止暴力破解
|
|
||||||
$throttle = ny_login_throttle_check();
|
|
||||||
if ($throttle['until'] > time()) {
|
|
||||||
$wait = (int)(($throttle['until'] - time()) / 60) + 1;
|
|
||||||
$err = '尝试次数过多,请 ' . $wait . ' 分钟后再试。';
|
|
||||||
} else {
|
|
||||||
$user = trim((string)($_POST['username'] ?? $cfg['username']));
|
$user = trim((string)($_POST['username'] ?? $cfg['username']));
|
||||||
$pwd = (string)($_POST['password'] ?? '');
|
$pwd = (string)($_POST['password'] ?? '');
|
||||||
// 允许用户在本页调整账号;密码必须输入
|
// 允许用户在本页调整账号;密码必须输入
|
||||||
@@ -47,18 +47,38 @@ 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();
|
||||||
ny_login_throttle_reset();
|
|
||||||
$ok = true;
|
$ok = true;
|
||||||
} catch (Throwable $e) {
|
|
||||||
ny_login_throttle_hit();
|
|
||||||
$err = '登录失败:' . $e->getMessage();
|
|
||||||
}
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$err = '登录失败:' . $e->getMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,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">
|
||||||
@@ -103,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; ?>
|
||||||
|
|||||||
+41
-7
@@ -4,12 +4,24 @@
|
|||||||
set -e
|
set -e
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
PHP="${PHP_BIN:-C:/Users/Zhang/.workbuddy/binaries/php/php.exe}"
|
# 运行期路径:优先用环境变量,其次用本机工作区路径,最后退回 PATH 里的 php / node
|
||||||
NODE="${NODE_BIN:-C:/Users/Zhang/.workbuddy/binaries/node/versions/22.22.2-2/node.exe}"
|
# (工作区 node 的版本目录会随升级变化,写死路径会直接 No such file or directory)
|
||||||
INI="-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite"
|
pick() {
|
||||||
|
local cand="$1" fallback="$2"
|
||||||
|
if [ -n "$cand" ] && [ -x "$cand" ]; then echo "$cand"; return; fi
|
||||||
|
command -v "$fallback" 2>/dev/null || echo "$fallback"
|
||||||
|
}
|
||||||
|
# 本项目用 PHP 7.4+ 语法(箭头函数 / 类型化属性),必须是 PHP 8;
|
||||||
|
# 工作区里已备好便携版 8.2.34(其 php.ini 已开启 pdo_sqlite / zip / mbstring),
|
||||||
|
# 想换别的解释器就设 PHP_BIN=... 覆盖
|
||||||
|
PHP="$(pick "${PHP_BIN:-C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe}" php)"
|
||||||
|
NODE="$(pick "${NODE_BIN:-}" node)"
|
||||||
|
INI="${PHP_INI:-}"
|
||||||
|
echo "PHP = $PHP"
|
||||||
|
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"
|
||||||
@@ -17,11 +29,33 @@ done
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "================ Node e2e(需 mock-server + PHP 内置服务器) ================"
|
echo "================ Node e2e(需 mock-server + PHP 内置服务器) ================"
|
||||||
for f in tools/test-config-save.js tools/test-draw-alloc.js tools/test-upload-guard.js; do
|
# e2e 会把 mock 连接配置写进数据目录;用 NY_DATA 把这两个会改写配置的测试整体指向临时目录,
|
||||||
|
# 彻底避免污染生产 data/ny.db(曾因此把登录页连接信息写成 127.0.0.1)。
|
||||||
|
NY_DATA_TMP="$(pwd)/.ny-test-data"
|
||||||
|
mkdir -p "$NY_DATA_TMP"
|
||||||
|
# 说明: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"
|
echo ">>> $f (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/ 即可
|
||||||
|
echo ""
|
||||||
|
echo ">>> 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);
|
||||||
|
|
||||||
+22
-74
@@ -1,10 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* 新功能回归测试:
|
* 新功能回归测试:
|
||||||
* 1) 异常检测 detectAnomalies(短时长高分 / 高频重考 / 雷同成绩)
|
* 1) 成绩明细排序:再次提交应置顶(last_submit_ts 排序)
|
||||||
* 2) 成绩快照 Snapshot::summarize / trend
|
* 2) XlsxWriter 多工作表 renderMulti(分表 + 名字去重/截断)
|
||||||
* 3) 成绩明细排序:再次提交应置顶(last_submit_ts 排序)
|
* 3) 名单导入列识别 rowsToStudents(表头识别 / 去重 / Excel 数字尾巴)
|
||||||
* 4) XlsxWriter 多工作表 renderMulti(分表 + 名字去重/截断)
|
|
||||||
*/
|
*/
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
require __DIR__ . '/../bootstrap.php';
|
require __DIR__ . '/../bootstrap.php';
|
||||||
@@ -18,75 +17,6 @@ function check(string $name, bool $cond, string $detail = ''): void
|
|||||||
else { $fail++; echo " ❌ {$name} {$detail}\n"; }
|
else { $fail++; echo " ❌ {$name} {$detail}\n"; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============ 1) 异常检测 ============ */
|
|
||||||
echo "\n== 异常检测 ==\n";
|
|
||||||
|
|
||||||
// 构造:3 人同分(雷同)、1 人短时长高分、1 人高频重考
|
|
||||||
function mkRow(string $sid, string $name, ?float $score, ?int $duration, int $attempts): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'sid' => $sid, 'name' => $name, 'cls' => '1班', 'grp' => 'A',
|
|
||||||
'score' => $score, 'duration' => $duration, 'duration_txt' => $duration === null ? '-' : ($duration . '秒'),
|
|
||||||
'attempts' => $attempts, 'best_score' => $score,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = [
|
|
||||||
mkRow('S01', '甲', 90.0, 600, 1),
|
|
||||||
mkRow('S02', '乙', 90.0, 610, 1), // 与甲同分
|
|
||||||
mkRow('S03', '丙', 90.0, 620, 1), // 三人同分 → 雷同
|
|
||||||
mkRow('S04', '丁', 95.0, 30, 1), // 短时长(30s)+ 高分
|
|
||||||
mkRow('S05', '戊', 70.0, 500, 9), // 高频重考 9 次
|
|
||||||
];
|
|
||||||
$stats = ['avg_duration' => 600, 'max_score' => 100.0];
|
|
||||||
$anom = DataService::detectAnomalies($rows, $stats);
|
|
||||||
|
|
||||||
check('雷同成绩:捕获 90 分 3 人组', count($anom['identical']) >= 1 && (float)$anom['identical'][0]['score'] === 90.0,
|
|
||||||
json_encode($anom['identical'], JSON_UNESCAPED_UNICODE));
|
|
||||||
check('雷同成绩:人数 = 3', (int)($anom['identical'][0]['count'] ?? 0) === 3);
|
|
||||||
check('短时长高分:捕获丁', count($anom['fast_high']) >= 1 && ($anom['fast_high'][0]['sid'] ?? '') === 'S04',
|
|
||||||
json_encode($anom['fast_high'], JSON_UNESCAPED_UNICODE));
|
|
||||||
check('高频重考:捕获戊(9 次)', count($anom['frequent']) >= 1 && ($anom['frequent'][0]['sid'] ?? '') === 'S05',
|
|
||||||
json_encode($anom['frequent'], JSON_UNESCAPED_UNICODE));
|
|
||||||
check('正常学生(甲 1 次)不被误报高频', !in_array('S01', array_column($anom['frequent'], 'sid'), true));
|
|
||||||
|
|
||||||
/* ============ 2) 快照 summarize / trend ============ */
|
|
||||||
echo "\n== 成绩快照 ==\n";
|
|
||||||
|
|
||||||
$result1 = [
|
|
||||||
'project' => ['id' => 'P1', 'name' => '数学测验'],
|
|
||||||
'stats' => ['avg_score' => 85],
|
|
||||||
'rows' => [
|
|
||||||
['sid' => 'S01', 'name' => '甲', 'cls' => '1班', 'grp' => 'A', 'score' => 90, 'best_score' => 95, 'attempts' => 2, 'last_submit_at' => '2026-09-10 10:00:00'],
|
|
||||||
['sid' => 'S02', 'name' => '乙', 'cls' => '1班', 'grp' => 'A', 'score' => 80, 'best_score' => 80, 'attempts' => 1, 'last_submit_at' => '2026-09-10 10:05:00'],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
$result2 = [
|
|
||||||
'project' => ['id' => 'P1', 'name' => '数学测验'],
|
|
||||||
'stats' => ['avg_score' => 88],
|
|
||||||
'rows' => [
|
|
||||||
['sid' => 'S01', 'name' => '甲', 'cls' => '1班', 'grp' => 'A', 'score' => 92, 'best_score' => 96, 'attempts' => 3, 'last_submit_at' => '2026-09-10 12:00:00'],
|
|
||||||
['sid' => 'S03', 'name' => '丙', 'cls' => '2班', 'grp' => 'B', 'score' => 88, 'best_score' => 88, 'attempts' => 1, 'last_submit_at' => '2026-09-10 12:10:00'],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$snap1 = Snapshot::summarize($result1, '第一次');
|
|
||||||
$snap2 = Snapshot::summarize($result2, '第二次');
|
|
||||||
$snap1['at'] = 100; $snap2['at'] = 200; // 固定时间戳便于排序
|
|
||||||
|
|
||||||
check('summarize 抽取学生数 = 2', count($snap1['students']) === 2);
|
|
||||||
check('summarize 保留 best_score', (float)$snap1['students'][0]['best_score'] === 95.0);
|
|
||||||
check('summarize 按学号排序', $snap1['students'][0]['sid'] === 'S01' && $snap1['students'][1]['sid'] === 'S02');
|
|
||||||
|
|
||||||
$trend = Snapshot::trend([$snap1, $snap2]);
|
|
||||||
check('trend 列数 = 2(两份快照)', count($trend['columns']) === 2, 'got ' . count($trend['columns']));
|
|
||||||
check('trend 列按时间升序(先第一次后第二次)', $trend['columns'][0]['label'] === '第一次' && $trend['columns'][1]['label'] === '第二次');
|
|
||||||
check('trend 行数 = 3(S01/S02/S03 并集)', count($trend['rows']) === 3, 'got ' . count($trend['rows']));
|
|
||||||
$s01 = null; foreach ($trend['rows'] as $r) { if ($r['sid'] === 'S01') $s01 = $r; }
|
|
||||||
check('S01 两次快照成绩 = 95 / 96', $s01 !== null && $s01['scores'][$trend['columns'][0]['key']] == 95 && $s01['scores'][$trend['columns'][1]['key']] == 96);
|
|
||||||
$s02 = null; foreach ($trend['rows'] as $r) { if ($r['sid'] === 'S02') $s02 = $r; }
|
|
||||||
check('S02 第二次快照缺席记 null', $s02 !== null && !array_key_exists($trend['columns'][1]['key'], $s02['scores']));
|
|
||||||
|
|
||||||
/* ============ 3) 排序:再次提交置顶 ============ */
|
/* ============ 3) 排序:再次提交置顶 ============ */
|
||||||
echo "\n== 排序(last_submit_ts 置顶)==\n";
|
echo "\n== 排序(last_submit_ts 置顶)==\n";
|
||||||
// 甲:代表成绩=最高分(98,早提交);最近一次提交=重考 70(晚提交)→ last_submit_ts 更晚,应排在乙前
|
// 甲:代表成绩=最高分(98,早提交);最近一次提交=重考 70(晚提交)→ last_submit_ts 更晚,应排在乙前
|
||||||
@@ -101,7 +31,7 @@ usort($aggRows, static function (array $x, array $y) use ($sortTs): int {
|
|||||||
});
|
});
|
||||||
check('重考后的甲(last_submit_ts=5000)排在乙前', $aggRows[0]['sid'] === 'S01', json_encode($aggRows, JSON_UNESCAPED_UNICODE));
|
check('重考后的甲(last_submit_ts=5000)排在乙前', $aggRows[0]['sid'] === 'S01', json_encode($aggRows, JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
/* ============ 4) XlsxWriter 多工作表 ============ */
|
/* ============ 2) XlsxWriter 多工作表 ============ */
|
||||||
echo "\n== XlsxWriter 多工作表 ==\n";
|
echo "\n== XlsxWriter 多工作表 ==\n";
|
||||||
$sheets = [
|
$sheets = [
|
||||||
['name' => '1班', 'rows' => [['学号', '姓名'], ['S01', '甲']], 'widths' => [16, 14]],
|
['name' => '1班', 'rows' => [['学号', '姓名'], ['S01', '甲']], 'widths' => [16, 14]],
|
||||||
@@ -132,6 +62,24 @@ if (class_exists('ZipArchive')) {
|
|||||||
check('产出 5 个工作表文件', count($names) === 5, 'got ' . count($names) . ': ' . json_encode($names));
|
check('产出 5 个工作表文件', count($names) === 5, 'got ' . count($names) . ': ' . json_encode($names));
|
||||||
check('sheet 文件名连续 sheet1..sheet5', in_array('xl/worksheets/sheet5.xml', $names, true));
|
check('sheet 文件名连续 sheet1..sheet5', in_array('xl/worksheets/sheet5.xml', $names, true));
|
||||||
|
|
||||||
|
/* ============ 3) 名单导入列识别 ============ */
|
||||||
|
echo "\n== 名单导入列识别 ==\n";
|
||||||
|
$meta = [];
|
||||||
|
$parsed = Roster::rowsToStudents([
|
||||||
|
['学号', '姓名', '班级', '组别', '登陆账号'],
|
||||||
|
['2024012001.0', '张三', '1班', 'A', 'stu001'],
|
||||||
|
['2024012002', '李四', '1班', 'B', 'stu002'],
|
||||||
|
['2024012001', '张三重复', '1班', 'A', 'stu001'], // 学号重复 → 跳过
|
||||||
|
['', ''], // 空行 → 跳过
|
||||||
|
], $meta);
|
||||||
|
check('识别到 2 人(重复学号与空行被跳过)', count($parsed) === 2, json_encode($parsed, JSON_UNESCAPED_UNICODE));
|
||||||
|
check('学号去掉 Excel 数字尾巴 .0', ($parsed[0]['sid'] ?? '') === '2024012001', (string)($parsed[0]['sid'] ?? ''));
|
||||||
|
check('识别班级 / 组别 / 登陆账号列',
|
||||||
|
($parsed[0]['cls'] ?? '') === '1班' && ($parsed[0]['grp'] ?? '') === 'A' && ($parsed[0]['login'] ?? '') === 'stu001',
|
||||||
|
json_encode($parsed[0] ?? [], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('重复学号记入 duplicates', (int)($meta['duplicates'] ?? 0) === 1, (string)($meta['duplicates'] ?? ''));
|
||||||
|
check('空行记入 skipped', (int)($meta['skipped'] ?? 0) === 1, (string)($meta['skipped'] ?? ''));
|
||||||
|
|
||||||
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,106 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 文档站本地预览服务
|
||||||
|
*
|
||||||
|
* 站点是按 /ny-exam-platform 子路径构建的(base: '/ny-exam-platform/'),
|
||||||
|
* 直接把 dist 目录当根来跑静态服务会导致所有资源 404。
|
||||||
|
* 这里做一层前缀重写:把 /ny-exam-platform/<path> 映射到 dist 目录下的 <path>,
|
||||||
|
* 根路径 / 自动跳转到 /ny-exam-platform/。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node tools/docs-preview.js [端口] [dist目录]
|
||||||
|
* node tools/docs-preview.js 5199 docs/.vitepress/dist-final
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const PORT = parseInt(process.argv[2], 10) || 5199;
|
||||||
|
const DIST = path.resolve(process.argv[3] || path.join(__dirname, '..', 'docs', '.vitepress', 'dist-final'));
|
||||||
|
const BASE = (process.env.DOCS_BASE || '/ny-exam-platform').replace(/\/+$/, '');
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.mjs': 'text/javascript; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.woff': 'font/woff',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
'.ttf': 'font/ttf',
|
||||||
|
'.txt': 'text/plain; charset=utf-8',
|
||||||
|
'.map': 'application/json; charset=utf-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!fs.existsSync(DIST)) {
|
||||||
|
console.error('[docs-preview] 找不到构建产物:' + DIST);
|
||||||
|
console.error('请先构建:DOCS_OUT=.vitepress/dist-final npx vitepress build .');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 URL 中的前缀剥掉,得到 dist 内的相对路径 */
|
||||||
|
function toRel(urlPath) {
|
||||||
|
let p = decodeURIComponent(urlPath.split('?')[0].split('#')[0]);
|
||||||
|
if (p === BASE || p === BASE + '/') return '/index.html';
|
||||||
|
if (p.startsWith(BASE + '/')) p = p.slice(BASE.length);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 依次尝试:原样 / .html / 目录下 index.html,模拟静态托管的行为 */
|
||||||
|
function resolveFile(rel) {
|
||||||
|
const clean = path.normalize(rel).replace(/^([/\\])+/, '');
|
||||||
|
const full = path.join(DIST, clean);
|
||||||
|
if (full.indexOf(DIST) !== 0) return null; // 防目录穿越
|
||||||
|
const candidates = [full, full + '.html', path.join(full, 'index.html')];
|
||||||
|
for (const c of candidates) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
|
||||||
|
} catch (e) { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const urlPath = req.url || '/';
|
||||||
|
|
||||||
|
// 根路径 / 直接跳到子路径首页
|
||||||
|
if (urlPath === '/' || urlPath === '') {
|
||||||
|
res.writeHead(302, { Location: BASE + '/' });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rel = toRel(urlPath);
|
||||||
|
const file = resolveFile(rel);
|
||||||
|
if (!file) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end('404 Not Found: ' + urlPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(file).toLowerCase();
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': MIME[ext] || 'application/octet-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
});
|
||||||
|
fs.createReadStream(file).pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, '127.0.0.1', () => {
|
||||||
|
console.log('[docs-preview] 文档站已启动');
|
||||||
|
console.log(' 访问地址:http://127.0.0.1:' + PORT + BASE + '/');
|
||||||
|
console.log(' 构建目录:' + DIST);
|
||||||
|
});
|
||||||
|
server.on('error', e => {
|
||||||
|
console.error('[docs-preview] 启动失败:' + e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 修复 / 重置系统连接配置(终端一次性工具)
|
||||||
|
*
|
||||||
|
* 背景:
|
||||||
|
* 连接配置同时存在于 data/ny.db(SQLite,加载时优先)与 data/config.json(文件备份)。
|
||||||
|
* ny_load_config() 以 DB 为准,因此一旦部署实例的 ny.db 被 e2e 测试串味或误改写成
|
||||||
|
* 127.0.0.1 / mock 地址,登录页就会显示「系统连接信息已配置(127.0.0.1/api)」且连不上真实卷王。
|
||||||
|
* 本工具通过 ny_save_config 双写,一次性把两处都纠正回正确的生产地址。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* php tools/fix-config.php [--base-url=https://exam.nxxy335.top] [--api-prefix=/api] [--keep-creds]
|
||||||
|
*
|
||||||
|
* 默认行为:
|
||||||
|
* - base_url 修正为 https://exam.nxxy335.top(可用 --base-url 覆盖)
|
||||||
|
* - api_prefix 修正为 /api
|
||||||
|
* - 账号/密码清空(绝不臆造真实卷王凭据),需在登录页重新输入真实账号
|
||||||
|
* - 加 --keep-creds 则保留现有账号密码不动
|
||||||
|
*
|
||||||
|
* 测试隔离:可用 NY_DATA=/tmp/xxx 指向临时目录,不影响生产库。
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/../bootstrap.php';
|
||||||
|
|
||||||
|
$args = array_slice($argv, 1);
|
||||||
|
$baseUrl = 'https://exam.nxxy335.top';
|
||||||
|
$apiPrefix = '/api';
|
||||||
|
$keepCreds = false;
|
||||||
|
foreach ($args as $a) {
|
||||||
|
if (strncmp($a, '--base-url=', 11) === 0) {
|
||||||
|
$baseUrl = substr($a, 11);
|
||||||
|
} elseif (strncmp($a, '--api-prefix=', 13) === 0) {
|
||||||
|
$apiPrefix = substr($a, 13);
|
||||||
|
} elseif ($a === '--keep-creds') {
|
||||||
|
$keepCreds = true;
|
||||||
|
} elseif ($a === '--help' || $a === '-h') {
|
||||||
|
echo "用法: php tools/fix-config.php [--base-url=URL] [--api-prefix=/api] [--keep-creds]\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$cfg = ny_load_config();
|
||||||
|
$before = (string)($cfg['base_url'] ?? '');
|
||||||
|
$cfg['base_url'] = rtrim(trim($baseUrl), '/');
|
||||||
|
$cfg['api_prefix'] = '/' . ltrim(trim($apiPrefix), '/');
|
||||||
|
if (!$keepCreds) {
|
||||||
|
$cfg['username'] = '';
|
||||||
|
$cfg['password'] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = ny_save_config($cfg);
|
||||||
|
if (!$ok) {
|
||||||
|
fwrite(STDERR, "保存失败:DB 与文件均不可写?请检查 " . NY_DATA . " 目录权限。\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = (string) parse_url($cfg['base_url'], PHP_URL_HOST);
|
||||||
|
echo "修复前 base_url = " . ($before === '' ? '(空)' : $before) . "\n";
|
||||||
|
echo "修复后 base_url = " . $cfg['base_url'] . "\n";
|
||||||
|
echo "api_prefix = " . $cfg['api_prefix'] . "\n";
|
||||||
|
echo "登录页将显示 = 系统连接信息已配置(" . $host . $cfg['api_prefix'] . ")\n";
|
||||||
|
echo "账号/密码 = " . ($keepCreds ? '保持不变' : '已清空(请在登录页重新输入真实卷王账号)') . "\n";
|
||||||
|
echo "配置来源 = " . ny_config_source() . "\n";
|
||||||
|
echo "OK\n";
|
||||||
@@ -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;
|
||||||
+45
-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) => {
|
||||||
@@ -43,6 +66,7 @@ answers.push({
|
|||||||
|
|
||||||
const SURVEY = {
|
const SURVEY = {
|
||||||
id: 'root', title: '高一数学月考', type: 'survey',
|
id: 'root', title: '高一数学月考', type: 'survey',
|
||||||
|
surveySetting: {},
|
||||||
children: [
|
children: [
|
||||||
{ id: 'pg1', type: 'pagination', title: '基本信息', children: [
|
{ id: 'pg1', type: 'pagination', title: '基本信息', children: [
|
||||||
{ id: 'q1', type: 'text', title: '<p>姓名</p>' },
|
{ id: 'q1', type: 'text', title: '<p>姓名</p>' },
|
||||||
@@ -59,6 +83,19 @@ const PROJECTS = [
|
|||||||
{ id: 'p2', name: '课后满意度调查', mode: 'survey', status: 1, total: 5 },
|
{ id: 'p2', name: '课后满意度调查', mode: 'survey', status: 1, total: 5 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// p1 的「受访者」参与者 = 应考名单(真实卷王模型:t_project_partner type=RESPONDENT_IMP_USER=3)
|
||||||
|
// status: 0未访问 / 1已访问未答 / 2已答题
|
||||||
|
const PARTNERS = {
|
||||||
|
p1: [
|
||||||
|
{ id: 'pp1', type: 3, userName: '90001', userId: '90001', status: 0 },
|
||||||
|
{ id: 'pp2', type: 3, userName: '90002', userId: '90002', status: 0 },
|
||||||
|
{ id: 'pp3', type: 3, userName: '90003', userId: '90003', status: 0 },
|
||||||
|
{ id: 'pp4', type: 3, userName: '90004', userId: '90004', status: 0 },
|
||||||
|
{ id: 'pp5', type: 3, userName: '90005', userId: '90005', status: 0 },
|
||||||
|
],
|
||||||
|
p2: [],
|
||||||
|
};
|
||||||
|
|
||||||
function send(res, code, obj, headers) {
|
function send(res, code, obj, headers) {
|
||||||
res.writeHead(code, Object.assign({ 'Content-Type': 'application/json; charset=utf-8' }, headers || {}));
|
res.writeHead(code, Object.assign({ 'Content-Type': 'application/json; charset=utf-8' }, headers || {}));
|
||||||
res.end(JSON.stringify(obj));
|
res.end(JSON.stringify(obj));
|
||||||
@@ -128,11 +165,17 @@ const server = http.createServer((req, res) => {
|
|||||||
const slice = all.slice((cur - 1) * size, cur * size);
|
const slice = all.slice((cur - 1) * size, cur * size);
|
||||||
return send(res, 200, { code: 0, data: { total: all.length, list: slice } });
|
return send(res, 200, { code: 0, data: { total: all.length, list: slice } });
|
||||||
}
|
}
|
||||||
|
if (p === '/api/project/partner/list') {
|
||||||
|
const pid = u.searchParams.get('projectId');
|
||||||
|
const list = PARTNERS[pid] || [];
|
||||||
|
return send(res, 200, { code: 0, data: { total: list.length, list } });
|
||||||
|
}
|
||||||
|
|
||||||
return send(res, 200, { code: 404, data: null, msg: 'no route ' + p });
|
return send(res, 200, { code: 404, data: null, msg: 'no route ' + p });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
||||||
+18
-12
@@ -7,14 +7,27 @@ 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) 备份 / 准备文件存储模式
|
||||||
const cfgFile = path.join(ROOT, 'data', 'config.json');
|
// 数据目录默认用项目下的 data/,但优先读 NY_DATA 环境变量(bootstrap.php 同样支持):
|
||||||
|
// 测试统一把 NY_DATA 指向临时目录,彻底避免把 mock 配置写进生产库(曾导致生产 base_url 被改成 127.0.0.1)。
|
||||||
|
// Git Bash 风格路径 /c/Users/... 在 Windows 上 path.resolve 会误判成 C:\c\...,需还原成 C:\Users\...;
|
||||||
|
// 规范化后写回 process.env.NY_DATA,让子进程(PHP 内置服务器)也拿到正确的 Windows 绝对路径。
|
||||||
|
function resolveDataDir() {
|
||||||
|
let p = process.env.NY_DATA || path.join(ROOT, 'data');
|
||||||
|
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 cfgBak = cfgFile + '.bak';
|
const cfgBak = cfgFile + '.bak';
|
||||||
const origCfg = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, 'utf8') : null;
|
const origCfg = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, 'utf8') : null;
|
||||||
const tokenFile = path.join(ROOT, 'data', 'cache', 'token.json');
|
const tokenFile = path.join(DATA, 'cache', 'token.json');
|
||||||
const tokenBak = tokenFile + '.bak';
|
const tokenBak = tokenFile + '.bak';
|
||||||
const hasToken = fs.existsSync(tokenFile);
|
const hasToken = fs.existsSync(tokenFile);
|
||||||
const tokenSnap = hasToken ? fs.readFileSync(tokenFile, 'utf8') : null;
|
const tokenSnap = hasToken ? fs.readFileSync(tokenFile, 'utf8') : null;
|
||||||
@@ -22,15 +35,11 @@ if (hasToken) fs.renameSync(tokenFile, tokenBak);
|
|||||||
|
|
||||||
// SQLite 优先:若不先移走 data/ny.db,里面的旧配置会盖掉本测试写入的 config.json,
|
// SQLite 优先:若不先移走 data/ny.db,里面的旧配置会盖掉本测试写入的 config.json,
|
||||||
// 导致登录用的是上一次遗留的账号密码(表现为"登录成功但 auth_check=false")。
|
// 导致登录用的是上一次遗留的账号密码(表现为"登录成功但 auth_check=false")。
|
||||||
// 同时清掉登录限流记录,避免上一轮失败把本轮锁住。
|
const dbFile = path.join(DATA, 'ny.db');
|
||||||
const dbFile = path.join(ROOT, 'data', 'ny.db');
|
|
||||||
const dbBak = dbFile + '.bak';
|
const dbBak = dbFile + '.bak';
|
||||||
const hasDb = fs.existsSync(dbFile);
|
const hasDb = fs.existsSync(dbFile);
|
||||||
if (hasDb) { try { fs.renameSync(dbFile, dbBak); } catch (e) {} }
|
if (hasDb) { try { fs.renameSync(dbFile, dbBak); } catch (e) {} }
|
||||||
|
|
||||||
const throttleFile = path.join(ROOT, 'data', 'login_fail.json');
|
|
||||||
if (fs.existsSync(throttleFile)) { try { fs.unlinkSync(throttleFile); } catch (e) {} }
|
|
||||||
|
|
||||||
fs.writeFileSync(cfgFile, JSON.stringify({
|
fs.writeFileSync(cfgFile, JSON.stringify({
|
||||||
base_url: 'http://127.0.0.1:19911',
|
base_url: 'http://127.0.0.1:19911',
|
||||||
api_prefix: '/api',
|
api_prefix: '/api',
|
||||||
@@ -67,9 +76,6 @@ function cleanup() {
|
|||||||
if (fs.existsSync(dbFile)) fs.unlinkSync(dbFile);
|
if (fs.existsSync(dbFile)) fs.unlinkSync(dbFile);
|
||||||
if (hasDb && fs.existsSync(dbBak)) fs.renameSync(dbBak, dbFile);
|
if (hasDb && fs.existsSync(dbBak)) fs.renameSync(dbBak, dbFile);
|
||||||
} catch(e) { console.error('cleanup db failed:', e.message); }
|
} catch(e) { console.error('cleanup db failed:', e.message); }
|
||||||
try {
|
|
||||||
if (fs.existsSync(throttleFile)) fs.unlinkSync(throttleFile);
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
}
|
||||||
process.on('exit', cleanup);
|
process.on('exit', cleanup);
|
||||||
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
})();
|
||||||
+16
-14
@@ -12,13 +12,24 @@ 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;
|
||||||
|
|
||||||
const DATA = path.join(ROOT, 'data');
|
// 数据目录优先读 NY_DATA 环境变量(与 bootstrap.php 一致),让测试整体指向临时目录,
|
||||||
|
// 不污染生产库(曾因写 mock 配置把生产 base_url 改成 127.0.0.1)。
|
||||||
|
// Git Bash 风格 /c/Users/... 在 Windows 上 path.resolve 会误判成 C:\c\...,需还原;并把规范化后的
|
||||||
|
// Windows 绝对路径写回 process.env.NY_DATA,让子进程(PHP 内置服务器)也拿到正确路径。
|
||||||
|
function resolveDataDir() {
|
||||||
|
let p = process.env.NY_DATA || path.join(ROOT, 'data');
|
||||||
|
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 UPLOADS = path.join(DATA, 'uploads');
|
const UPLOADS = path.join(DATA, 'uploads');
|
||||||
const throttleFile = path.join(DATA, 'login_fail.json');
|
|
||||||
|
|
||||||
// ---------- 备份 / 还原运行时数据 ----------
|
// ---------- 备份 / 还原运行时数据 ----------
|
||||||
// ny.db 必须移走:配置是 DB 优先,留着库里的真实配置会盖掉本测试写入的 config.json。
|
// ny.db 必须移走:配置是 DB 优先,留着库里的真实配置会盖掉本测试写入的 config.json。
|
||||||
@@ -41,13 +52,6 @@ for (const f of ['config.json', 'roster.json', 'project_config.json']) {
|
|||||||
catch (e) { console.error(' 备份失败(复制)' + f + ':' + e.message); }
|
catch (e) { console.error(' 备份失败(复制)' + f + ':' + e.message); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 清空登录限流记录:用覆盖写而不是删除(部分环境下删除会被安全策略拦掉)
|
|
||||||
function clearThrottle() {
|
|
||||||
try { fs.writeFileSync(throttleFile, '{}'); } catch (e) {}
|
|
||||||
try { if (fs.existsSync(throttleFile)) fs.unlinkSync(throttleFile); } catch (e) {}
|
|
||||||
}
|
|
||||||
clearThrottle();
|
|
||||||
|
|
||||||
const cfgFile = path.join(DATA, 'config.json');
|
const cfgFile = path.join(DATA, 'config.json');
|
||||||
fs.writeFileSync(cfgFile, JSON.stringify({
|
fs.writeFileSync(cfgFile, JSON.stringify({
|
||||||
base_url: 'http://127.0.0.1:' + MOCK_PORT,
|
base_url: 'http://127.0.0.1:' + MOCK_PORT,
|
||||||
@@ -81,7 +85,6 @@ function cleanup() {
|
|||||||
const p = path.join(DATA, f);
|
const p = path.join(DATA, f);
|
||||||
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch (e) {}
|
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch (e) {}
|
||||||
}
|
}
|
||||||
clearThrottle();
|
|
||||||
// 统一用「覆盖写回 + 尽力删除备份」的方式还原,避免删除被拦导致还原失败
|
// 统一用「覆盖写回 + 尽力删除备份」的方式还原,避免删除被拦导致还原失败
|
||||||
for (const [p, bak] of moved.concat(copied)) {
|
for (const [p, bak] of moved.concat(copied)) {
|
||||||
try { if (fs.existsSync(bak)) fs.copyFileSync(bak, p); } catch (e) {}
|
try { if (fs.existsSync(bak)) fs.copyFileSync(bak, p); } catch (e) {}
|
||||||
@@ -199,9 +202,8 @@ function chk(name, cond, extra) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录(失败会触发 5 次锁定,所以每次尝试前清掉限流记录)
|
// 登录(最多重试 3 次)
|
||||||
for (let i = 0; i < 3 && CSRF === ''; i++) {
|
for (let i = 0; i < 3 && CSRF === ''; i++) {
|
||||||
clearThrottle(); // 上一次尝试若失败会累计限流,这里先清零
|
|
||||||
const lg = await formPost('/login.php', { username: 'mock', password: 'surveyking' });
|
const lg = await formPost('/login.php', { username: 'mock', password: 'surveyking' });
|
||||||
const ac = await get('/api.php?action=auth_check');
|
const ac = await get('/api.php?action=auth_check');
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// e2e: 卷内「考试白名单 / 通讯录名单」自动驱动缺/待考名单(DataService::build)
|
||||||
|
// 运行:node tools/test-whitelist.js (NY_DEBUG=1 可打印调试信息)
|
||||||
|
//
|
||||||
|
// 真实卷王模型:应考名单 = 项目「参与者(受访者)」(t_project_partner, type=RESPONDENT_SYS_USER/IMP_USER),
|
||||||
|
// 经 /api/project/partner/list 返回;不再是旧代码误读的 surveySetting.whiteLists(题目选项白名单)。
|
||||||
|
// 覆盖:
|
||||||
|
// 1) p1 配置了受访者名单(Project Partners)→ 应考名单恰好为受访者,缺考仅来自受访者(不含名单外成员)
|
||||||
|
// 2) p2 未配置受访者 → 按现状(全名单)显示,名单外成员仍在应考/缺考中
|
||||||
|
// 3) 已移除的 roster_import_whitelist 接口不再可用(按钮已删除,改为自动驱动)
|
||||||
|
// 4) 上传防护不被破坏(导入后 data/uploads 即清)
|
||||||
|
const http = require('http');
|
||||||
|
const net = require('net');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const PHP_PORT = 18082;
|
||||||
|
const MOCK_PORT = 19911;
|
||||||
|
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
|
||||||
|
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
|
||||||
|
const DEBUG = !!process.env.NY_DEBUG;
|
||||||
|
|
||||||
|
function resolveDataDir() {
|
||||||
|
let p = process.env.NY_DATA || path.join(ROOT, 'data');
|
||||||
|
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 UPLOADS = path.join(DATA, 'uploads');
|
||||||
|
|
||||||
|
const moved = [], copied = [];
|
||||||
|
for (const f of ['ny.db', 'cache/token.json']) {
|
||||||
|
const p = path.join(DATA, f);
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
const bak = p + '.nybak';
|
||||||
|
try { fs.renameSync(p, bak); moved.push([p, bak]); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const f of ['config.json', 'roster.json', 'project_config.json']) {
|
||||||
|
const p = path.join(DATA, f);
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
const bak = p + '.nybak';
|
||||||
|
try { fs.copyFileSync(p, bak); copied.push([p, bak]); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cfgFile = path.join(DATA, 'config.json');
|
||||||
|
fs.writeFileSync(cfgFile, JSON.stringify({
|
||||||
|
base_url: 'http://127.0.0.1:' + MOCK_PORT,
|
||||||
|
api_prefix: '/api',
|
||||||
|
username: 'mock',
|
||||||
|
password: 'surveyking',
|
||||||
|
verify_ssl: false,
|
||||||
|
timeout: 10,
|
||||||
|
refresh_interval: 15,
|
||||||
|
page_size: 50,
|
||||||
|
include_temp_save: false,
|
||||||
|
dedupe: true,
|
||||||
|
name_keywords: '姓名,名字',
|
||||||
|
full_score: 100,
|
||||||
|
pass_rate: 60,
|
||||||
|
layout: 'default',
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
let mock = null, srv = null, cleanedUp = false;
|
||||||
|
function killAll() { for (const p of [srv, mock]) { try { p && p.kill('SIGKILL'); } catch (e) {} } }
|
||||||
|
function cleanup() {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
cleanedUp = true;
|
||||||
|
killAll();
|
||||||
|
for (const f of ['ny.db', 'ny.db-wal', 'ny.db-shm', 'config.json', 'cache/token.json']) {
|
||||||
|
const p = path.join(DATA, f);
|
||||||
|
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch (e) {}
|
||||||
|
}
|
||||||
|
for (const [p, bak] of moved.concat(copied)) {
|
||||||
|
try { if (fs.existsSync(bak)) fs.copyFileSync(bak, p); } catch (e) {}
|
||||||
|
try { if (fs.existsSync(bak)) fs.unlinkSync(bak); } 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: DEBUG ? 'inherit' : 'ignore' });
|
||||||
|
const phpArgs = INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PHP_PORT, '-t', ROOT]);
|
||||||
|
srv = spawn(PHP, phpArgs, { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
|
||||||
|
|
||||||
|
function waitPort(port, timeoutMs) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
(function attempt() {
|
||||||
|
const s = net.connect({ host: '127.0.0.1', port });
|
||||||
|
const done = (ok) => { s.destroy(); resolve(ok); };
|
||||||
|
s.setTimeout(800, () => done(false));
|
||||||
|
s.on('connect', () => done(true));
|
||||||
|
s.on('error', () => { if (Date.now() > deadline) return done(false); setTimeout(attempt, 300); });
|
||||||
|
s.on('timeout', () => { if (Date.now() > deadline) return done(false); setTimeout(attempt, 300); });
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieJar = [];
|
||||||
|
let CSRF = '';
|
||||||
|
function baseHeaders() {
|
||||||
|
const h = {};
|
||||||
|
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
function absorb(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 request(method, urlPath, headers, bodyBuf) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const h = Object.assign({}, headers || {});
|
||||||
|
if (bodyBuf) h['Content-Length'] = bodyBuf.length;
|
||||||
|
const r = http.request({ host: '127.0.0.1', port: PHP_PORT, method, path: urlPath, headers: h }, res => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', c => chunks.push(c));
|
||||||
|
res.on('end', () => { absorb(res); const raw = Buffer.concat(chunks).toString('utf8'); let j = null; try { j = JSON.parse(raw); } catch (e) {} resolve({ status: res.statusCode, body: j, raw }); });
|
||||||
|
});
|
||||||
|
r.on('error', reject);
|
||||||
|
if (bodyBuf) r.write(bodyBuf);
|
||||||
|
r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const get = (p) => request('GET', p, baseHeaders());
|
||||||
|
function formPost(p, fields) {
|
||||||
|
const body = Buffer.from(new URLSearchParams(fields).toString());
|
||||||
|
return request('POST', p, Object.assign({ 'Content-Type': 'application/x-www-form-urlencoded' }, baseHeaders()), body);
|
||||||
|
}
|
||||||
|
function upload(urlPath, fields, file) {
|
||||||
|
const boundary = '----nyUpload' + Math.random().toString(16).slice(2);
|
||||||
|
const parts = [];
|
||||||
|
for (const [k, v] of Object.entries(fields)) parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
|
||||||
|
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${file.filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`));
|
||||||
|
parts.push(Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content));
|
||||||
|
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
|
||||||
|
return request('POST', urlPath, Object.assign({ 'Content-Type': 'multipart/form-data; boundary=' + boundary }, baseHeaders()), Buffer.concat(parts));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 : '')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const mockUp = await waitPort(MOCK_PORT, 15000);
|
||||||
|
const phpUp = await waitPort(PHP_PORT, 15000);
|
||||||
|
chk('mock 卷王服务已就绪', mockUp);
|
||||||
|
chk('PHP 内置服务器已就绪', phpUp);
|
||||||
|
if (!mockUp || !phpUp) { console.log(' 服务未就绪,终止测试'); cleanup(); process.exit(1); }
|
||||||
|
|
||||||
|
for (let i = 0; i < 3 && CSRF === ''; i++) {
|
||||||
|
await formPost('/login.php', { username: 'mock', password: 'surveyking' });
|
||||||
|
const ac = await get('/api.php?action=auth_check');
|
||||||
|
if (ac.body && ac.body.data && ac.body.data.auth === true) {
|
||||||
|
const cg = await get('/api.php?action=config_get');
|
||||||
|
CSRF = (cg.body && cg.body.data && cg.body.data.csrf_token) || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chk('已登录并取到 CSRF token', CSRF !== '');
|
||||||
|
if (CSRF === '') { console.log(' 登录失败,终止测试'); cleanup(); process.exit(1); }
|
||||||
|
|
||||||
|
console.log('\n--- 1) 先导入一份名单(含白名单内 90001-90005 + 一个白名单外 90099) ---');
|
||||||
|
const csv = '学号,姓名,班级\n90001,甲,1班\n90002,乙,1班\n90003,丙,1班\n90004,丁,1班\n90005,戊,1班\n90099,外,1班\n';
|
||||||
|
const imp = await upload('/api.php?action=roster_import', { mode: 'replace', _csrf: CSRF }, {
|
||||||
|
filename: 'roster.csv', content: Buffer.from('\xEF\xBB\xBF' + csv, 'utf8'),
|
||||||
|
});
|
||||||
|
chk('名单导入成功', !!(imp.body && imp.body.ok === true), (imp.body && (imp.body.error || imp.body.msg)) || '');
|
||||||
|
chk('导入人数为 6', !!(imp.body && imp.body.data && imp.body.data.imported === 6), imp.body ? String(imp.body.data && imp.body.data.imported) : '');
|
||||||
|
|
||||||
|
console.log('\n--- 2) p1 卷内白名单驱动应考名单 ---');
|
||||||
|
const d1 = await get('/api.php?action=data&projectId=p1');
|
||||||
|
chk('data(p1) 成功', !!(d1.body && d1.body.ok === true), (d1.body && (d1.body.error || d1.body.msg)) || d1.raw.slice(0, 120));
|
||||||
|
const d1d = (d1.body && d1.body.data) || {};
|
||||||
|
chk('p1 标记为白名单驱动', d1d.whitelist_driven === true, 'whitelist_driven=' + d1d.whitelist_driven);
|
||||||
|
chk('p1 白名单应考 5 人', d1d.whitelist_total === 5, 'whitelist_total=' + d1d.whitelist_total);
|
||||||
|
const r1 = (d1d.roster || []).map(s => String(s.sid)).sort();
|
||||||
|
chk('p1 应考名单恰好 90001-90005', JSON.stringify(r1) === JSON.stringify(['90001', '90002', '90003', '90004', '90005']), JSON.stringify(r1));
|
||||||
|
const a1 = (d1d.absent || []).map(s => String(s.sid)).sort();
|
||||||
|
chk('p1 缺考仅来自白名单(不含 90099)', !a1.includes('90099'), JSON.stringify(a1));
|
||||||
|
chk('p1 缺考是白名单子集', a1.every(s => r1.includes(s)), JSON.stringify(a1));
|
||||||
|
|
||||||
|
console.log('\n--- 3) p2 未设置白名单 → 按现状(全名单)---');
|
||||||
|
const d2 = await get('/api.php?action=data&projectId=p2');
|
||||||
|
chk('data(p2) 成功', !!(d2.body && d2.body.ok === true), (d2.body && (d2.body.error || d2.body.msg)) || '');
|
||||||
|
const d2d = (d2.body && d2.body.data) || {};
|
||||||
|
chk('p2 非白名单驱动', d2d.whitelist_driven === false, 'whitelist_driven=' + d2d.whitelist_driven);
|
||||||
|
const r2 = (d2d.roster || []).map(s => String(s.sid)).sort();
|
||||||
|
chk('p2 含白名单外成员 90099', r2.includes('90099'), JSON.stringify(r2));
|
||||||
|
|
||||||
|
console.log('\n--- 4) 已移除的 roster_import_whitelist 接口不再可用 ---');
|
||||||
|
const removed = await formPost('/api.php?action=roster_import_whitelist', { projectId: 'p1', mode: 'replace', _csrf: CSRF });
|
||||||
|
chk('roster_import_whitelist 已停用', !!(removed.body && removed.body.ok === false), (removed.body && (removed.body.error || removed.body.msg)) || '');
|
||||||
|
|
||||||
|
console.log('\n--- 5) 上传防护仍有效(导入后 data/uploads 即清) ---');
|
||||||
|
const left = fs.existsSync(UPLOADS) ? fs.readdirSync(UPLOADS).filter(f => f !== '.htaccess' && f !== 'index.html') : [];
|
||||||
|
chk('data/uploads 无残留', left.length === 0, JSON.stringify(left));
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(`结果:${pass} 通过 / ${fail} 失败(共 ${pass + fail})`);
|
||||||
|
cleanup();
|
||||||
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
|
})();
|
||||||
@@ -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