Compare commits

..
10 Commits
Author SHA1 Message Date
nxxy335 e87986fcc6 多考试块表:每场考试改为单列并修正第几次列合并
- ny_assemble_block_sheet() 中每场考试由 2 列改为 1 列,删除历史遗留的
  「是否最佳」空白列,消除每场右侧多余空白窄列
- 删除「第几次」列的纵向合并,改为逐行显示 1/2/3…,不再被吞成 1
- 合格次数行同步改为每场 1 列;未考合并加 $end > $start 跨行守卫,
  规避单行学生产生的非法 1x1 合并(Excel 修复提示)
- 列宽按考试名估算(CJK 记 2 半角宽),clamp 8~18
- 同步更新 test-multi-export.php(95 项含 4 条防回归断言)、
  make-samples.php(李四数学改为真正整场未考)、导出/接口/测试文档
  及样例预览文件;全量回归 404 项 0 失败
2026-10-06 12:22:16 +08:00
nxxy335 d05fd4a9b5 feat(export/dashboard): 导出增强与大屏公开化
导出-每次考试/多考试对比:
- 合格次数阈值等标记项抽为常显区块,三类成绩表共用
- 附缺考名单扩展到多考试导出(取所选考试全都没交的人)
- 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列
- 列宽按实际内容估算,合并区空位只给最小宽度
- 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底
- 打印标题改为「成绩明细」,缺考名单分页单独成页
- 打印样式加 print-color-adjust:exact,绿底/红字可正常印出

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

测试/样例/文档同步更新,全量回归通过。
2026-09-27 22:04:17 +08:00
nxxy335 0ba9c7c33c feat(export): 多考试对比导出(横向交叉表)
新增 export_multi 接口与多考试选择弹窗:
- 第一行显示考试名称,前两列固定为学号/姓名,默认按学号排序
- 每场考试成绩可选「最新成绩」或「最高成绩」(latest 跳过 temp_save 草稿,highest 取跨全部尝试最高分)
- 学生取并集(任一考试有提交即纳入),未考的考试单元格留空
- 纯逻辑抽离到 lib/multi_export.php,新增 26 项回归测试(tests/test-multi-export.php)
2026-09-25 08:32:24 +08:00
nxxy335 f661862d46 fix(auth): 修复登录后反复报未配置账号并跳回登录页的死循环
根因:ny_save_config 只写不校验,Db::settingsSet 内部吞异常且返回 void。
data/ 目录不可写时登录页照常显示登录成功,但下一请求读到的账号仍是空的
→ 抛未配置卷王登录账号 → 前端 auth 打标 → 跳登录页 → 无限循环。

修复:
- Db::settingsSet 改返回 bool 并回读校验;新增 settingsDelete
- ny_save_config 双写后均做回读校验,失败写入 ny_config_save_error()
- 新增 ny_config_diag()/ny_session_usable(),暴露来源/可写性/是否已存账号
- ny_load_config 把 null 账号强制成空字符串(脏数据不再伪装成未配置)
- login.php 存凭据后回读确认,存不住/会话不可写直接报错而不是假成功
- login.php 账号为空时页面直接给出 data 目录写权限提示
- config_save 合并逻辑抽成 ny_apply_config_input:账号/密码不许被改空
- 鉴权失败响应带 extra.diag,前端 401 也算 auth
- 前端防死循环:刚登录 120s 内或已跳 2 次则停止自动跳转,弹窗给出真实原因+诊断

测试:新增 tests/test-config-persist.php(29) 与 tools/test-login-loop.js(19);
run-all.sh 纳入两者。全套 148+48=196 项通过。
2026-09-14 08:44:09 +08:00
nxxy335 555f500dc7 成绩明细历史记录:按提交年月日分类,每个分类标记最高分
新增 DataService::dayKeyOf(),日期键一律用服务端本地时区计算(避免 UTC 导致东八区零点前后错一天);aggregateByStudent 为 attempts_list 每项附加 day/day_best/day_count,expandAttempts 透传。

弹窗按天分组渲染分组行(日期 + 共N次 + 最高X分),组内新到旧;当日最高标签仅在当天有多次提交时显示,避免单次日每行都挂标签。

修复:submit_ts=0(时间未知)的记录原本被误标为第1次(升序排序 idx=1 但展示在末尾),现显示 -;未知时间统一归入「未知时间」组。

aggregate 单测 17 -> 33(跨天归属/日内次数/每日最高/未知时间组/时区边界 00:30 与 23:30);全量 148 项全绿。
2026-09-14 07:28:02 +08:00
nxxy335 eb5d06d392 修复考试白名单驱动缺/待考名单:改用卷王项目参与者(Project Partners)真实模型
旧代码误读 surveySetting.whiteLists(实为题目选项白名单),导致线上从未触发。现改为读取 /api/project/partner/list 的 RESPONDENT_SYS_USER/IMP_USER 参与者作为应考名单,并保留 whiteLists 作为极老版本兜底。

dashboard.php 移除未实现的提交率卡片;全_score 默认 100 已在 data 层修正。e2e 测试 mock 与断言同步改为真实模型,全量 132 测试通过。
2026-09-13 11:02:34 +08:00
nxxy335 94ac381579 完成批次 L-R:限流移除/白名单驱动/打印增强/全屏/表格对齐/主题变量/滚动闪烁修复
- 移除登录失败限流;fix-config.php 修正 e2e 串味的 127.0.0.1 连接

- 白名单驱动缺/待考名单(DataService + SurveyKingClient::getWhitelist,复用已取 survey)

- 打印成绩明细增强(范围/合格列默认关/去页脚);看板全屏占满;表格 vertical-align:middle;隐藏登陆账号;导入默认追加

- 主题变量收口(:root 新增 brand/primary-2/ok-2 等,替换硬编码渐变);删死规则

- 表头/数据列对齐修复(index 未匹配表、dashboard 成员榜、draw 结果/历史/排除表);看板占位卡片配色一致

- 修复滚动闪烁:row-drop-in 动画经 _dropAnim 门控,仅强制重绘时播放

- 测试:新增 test-whitelist.js;run-all.sh 全绿 132
2026-09-13 10:25:48 +08:00
nxxy335 804b04de60 移除登录失败限流;修复连接信息被 e2e 串味为 127.0.0.1
- bootstrap.php/login.php:整体移除「5 次失败锁 10 分钟」限流,登录失败仅即时提示原因
- 新增 tools/fix-config.php:一键双写修正连接地址(默认 exam.nxxy335.top),清空账号密码让用户重输真实卷王凭据
- NY_DATA 隔离落地:run-all.sh 给写配置的 e2e 指向临时目录;修 Windows Git Bash /c/... 路径规范化 bug;.ny-test-data 加 .gitignore
- 清理 dead throttle 引用(e2e 脚本 + docs)
- 修 login.php 真实 bug:ny_is_auth() 分支在加载 $cfg 前引用导致 Undefined variable 警告
- 全量测试 116 通过(PHP 44 + e2e 72)
2026-09-11 17:25:22 +08:00
nxxy335 25a2e8a42c 文档部署到 /ny-exam-platform 子路径 + 移除快照/趋势/异常 + 打印与 UI 全面完善
文档站(#88)
- base 默认改为 /ny-exam-platform/,可用 DOCS_BASE=/ 覆盖本地预览
- 新增 DOCS_OUT 环境变量,避免构建时清空输出目录失败导致产物半截
- 新增 tools/docs-preview.js:按子路径重写路由的本地预览服务
- 删除 docs/guide/snapshot.md,同步清理全部相关文档

移除存快照 / 趋势 / 异常(#89)
- 后端:删除 api.php 的 snapshot_save/list/delete/trend 四个 action 与
  $WRITE_ACTIONS 中的同名条目;删除 lib/Snapshot.php 与自动加载注册;
  DataService 移除 detectAnomalies() 及其调用
- 前端:移除三个按钮与三个弹窗、相关 JS 处理与 state 字段
- 测试:移除对应用例,补充 5 条名单导入列识别断言(17+9+18+32+29+11 全过)

打印完善(#90)
- 改用隐藏 iframe 输出,规避弹窗拦截;@page A4 支持纵向/横向
- 表头每页重复、行内不跨页断开、全表连续名次、可选「是否合格」列
- 分组小计(人数/平均/最高/最低/合格人数与合格率)、可附缺考名单另起一页
- 末尾签字栏;缺考名单打印改为同一套输出

UI / 交互 / Bug 修复(#91)
- dashboard.php:补「← 主系统 / 退出」入口(此前全屏后无法离开);
  修复成员下钻 onclick 里 JSON.stringify 双引号提前闭合属性导致点击报错,
  改为 data-sid 事件委托;补 Esc 关弹窗;后台标签页暂停轮询、切回自动补拉;
  刷新中断原因写入 title;数值字段统一兜底,避免 NaN / undefined%
- draw.php / draw.js:结果表新增「登陆账号」列(导出前可核对,缺失显示 学号 ⚠);
  弹窗只关最上层且支持 Esc;「全部」改为跟随候选池,手工改人数即解除;
  候选池不足时给出明确提示;自定义比例框隐藏时清空,避免旧权重被误读
- app.js / dashboard.php:成绩着色改为按「占本卷满分的百分比」判断,
  修复满分不是 100 时整列误标红
- style.css:数值列右对齐;新增 subbar、draw-stage-sub;
  看板顶栏窄屏下拉与按钮换行,消除横向滚动
2026-09-11 16:23:08 +08:00
nxxy335 f271066266 docs: 支持 DOCS_OUT 自定义输出目录(环境禁止清空 dist 时可输出到新目录) 2026-09-10 23:40:28 +08:00
59 changed files with 7187 additions and 1528 deletions
+7
View File
@@ -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
+487 -157
View File
@@ -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
// 等不到(构建很慢或锁不被支持)就自己构建,保证可用性 // 等不到(构建很慢或锁不被支持)就自己构建,保证可用性
} }
$result = DataService::build($c, $projectId, $nameQid); // try/finally:无论 build 是否抛异常都释放构建锁,避免异常时锁句柄挂到进程退出造成惊群
if ($ttl > 0) { $result = null;
DataCache::write($key, $result); try {
$result = DataService::build($c, $projectId, $nameQid);
if ($ttl > 0) {
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 { // 出表方式 = 「每次考试一行」时,成绩达合格线的行标绿底(与多考试块表的口径一致)。
$head = []; // $marks 每次调用先重置,因此分班级多工作表时各表的标记不会串到别的表里。
$marks = [];
$toRows = static function (array $list) use ($cols, $colLabels, $mode, $passLineVal, $scoreColIdx, &$marks): array {
$marks = [];
$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,
]); ]);
} }
+1065 -329
View File
File diff suppressed because it is too large Load Diff
+63 -24
View File
@@ -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,7 +463,11 @@
state.drawing = false; state.drawing = false;
btn.disabled = false; btn.disabled = false;
const tail = detail.length ? '(' + detail.join(' / ') + ')' : ''; const tail = detail.length ? '(' + detail.join(' / ') + ')' : '';
showAlert('已抽取 ' + n + ' 人' + tail + (n > 1 ? ',可点击右上角按钮下载白名单' : ''), 'info'); if (capped) {
showAlert('候选池只有 ' + pool.length + ' 人,已按 ' + n + ' 人抽取(可关闭「排除已抽中」或调整筛选扩大候选)', 'warn');
} else {
showAlert('已抽取 ' + n + ' 人' + tail + (n > 1 ? ',可点击右上角按钮下载白名单' : ''), 'info');
}
} }
function renderResult() { function renderResult() {
@@ -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) => {
'<td class="num">' + (i + 1) + '</td>' + return '<tr>' +
'<td class="sid">' + esc(m.sid) + '</td>' + '<td class="num">' + (i + 1) + '</td>' +
'<td class="name">' + esc(m.name) + '</td>' + '<td class="sid">' + esc(m.sid) + '</td>' +
'<td>' + esc(m.cls || '') + '</td>' + '<td class="name">' + esc(m.name) + '</td>' +
'<td>' + esc(m.grp || '') + '</td>' + '<td>' + esc(m.cls || '') + '</td>' +
'<td><button class="btn sm danger" data-drop="' + i + '" title="移出结果并加入排除名单">排除</button></td>' + '<td>' + esc(m.grp || '') + '</td>' +
'</tr>').join(''); '<td class="act"><button class="btn sm danger" data-drop="' + i + '" title="移出结果并加入排除名单">排除</button></td>' +
'</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
View File
@@ -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); }
+225 -56
View File
@@ -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
View File
@@ -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>
+7 -4
View File
@@ -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
View File
@@ -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
+4 -6
View File
@@ -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)
``` ```
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -44,7 +44,15 @@
顶部卡片:`候选人数 / 本次抽取 / 累计已抽取 / 已排除 / 抽取来源`。 顶部卡片:`候选人数 / 本次抽取 / 累计已抽取 / 已排除 / 抽取来源`。
结果表格:`序号 / 学号 / 姓名 / 班级 / 组别 / 操作`。 结果表格:`序号 / 学号 / 姓名 / 班级 / 组别 / 登陆账号 / 操作`。
「登陆账号」列让你在导出前就能核对:显示为 `学号 ⚠` 表示名单里没填这一列,
导出时会自动回退用学号——**这会导致学生登不进考试**,请先回主系统「名单管理」补全。
::: tip 「全部」按钮会跟随候选池
点 **全部** 后抽取人数进入跟随模式:切换班级 / 组别 / 来源时人数自动更新为当前
候选池大小。手工改动人数输入框即解除该模式。
:::
| 按钮 | 说明 | | 按钮 | 说明 |
| --- | --- | | --- | --- |
@@ -57,8 +65,8 @@
## 登陆账号说明 ## 登陆账号说明
名单中的**登陆账号**列只在这里用于导出白名单,主系统界面任何位置都不展示。 名单中的**登陆账号**列在这里既用于导出白名单,也会直接显示在结果表格中(便于导出前核对)。
若名单里没有这一列,导出的白名单该字段为空。 若名单里没有这一列,抽取结果里会用 `学号 ⚠` 标出,导出时回退用学号。
::: warning 模板文件名 ::: warning 模板文件名
卷王模板中该列表头为「登陆账号」(不是"登录账号"),导出时已按此命名。 卷王模板中该列表头为「登陆账号」(不是"登录账号"),导出时已按此命名。
+153 -28
View File
@@ -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
View File
@@ -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/` 可写。
## 安全 ## 安全
+1 -2
View File
@@ -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
+6 -7
View File
@@ -26,16 +26,15 @@ NY 问卷考试系统数据平台是一个**零第三方运行依赖**的 PHP 8
| 试卷切换 | 下拉选择卷王中的问卷 / 考试,自动拉取该卷全部答卷 | | 试卷切换 | 下拉选择卷王中的问卷 / 考试,自动拉取该卷全部答卷 |
| 姓名关联 | 自动识别答卷中的「姓名」题,也可手动指定字段;未匹配答卷单独面板 + 一键补入名单 | | 姓名关联 | 自动识别答卷中的「姓名」题,也可手动指定字段;未匹配答卷单独面板 + 一键补入名单 |
| 统计卡片 | 名单总人数、已提交、缺考、平均分、最高分、最低分、合格率、平均用时;选择班级后全部联动 | | 统计卡片 | 名单总人数、已提交、缺考、平均分、最高分、最低分、合格率、平均用时;选择班级后全部联动 |
| 成绩明细 | 虚拟滚动(无分页)、点击表头排序、搜索、考试次数、新提交绿色高亮(30 秒自动清除) | | 成绩明细 | 虚拟滚动(无分页)、点击表头排序、搜索(提示同步显示命中数)、考试次数、新提交绿色高亮(30 秒自动清除) |
| 缺考名单 | 名单中有但未提交的人,支持复制学号 / 姓名、打印 | | 缺考名单 | 名单中有但未提交的人,支持复制学号 / 姓名、打印(按班级分组) |
| 试卷设置 | 为当前选中的试卷单独设置满分 / 合格率 / 免考名单,试卷间互相隔离 | | 试卷设置 | 为当前选中的试卷单独设置满分 / 合格率 / 免考名单,试卷间互相隔离 |
| 快照 / 趋势 | 保存带标签的时间点快照,横向对比每名学生成绩变化 | | 导出 / 打印 | 成绩明细、缺考名单 `.xlsx`;成绩明细按成绩降序打印,支持分组小计、合格标记、附缺考名单、签字栏 |
| 异常检测 | 短时长高分、高频重考、雷同成绩三类线索 |
| 导出 / 打印 | 成绩明细、缺考名单 `.xlsx`;成绩明细按成绩降序打印,可选分组对比 |
### 数据看板 `dashboard.php` ### 数据大屏 `dashboard.php`
大屏看板,适合投屏。支持全屏切换与自动刷新。 首页先选试卷,再用卡片列出该卷的班级;点卡片进入该班专属大屏。
大屏页支持全屏切换与自动刷新,**没有返回按钮,浏览器后退键也被屏蔽**(只能靠地址栏或关标签页离开)。
### 随机抽取 `draw.php` ### 随机抽取 `draw.php`
+15 -1
View File
@@ -9,6 +9,7 @@
| 名单总人数 | 名单条目数;被当前试卷设为**免考**的人不计入 | | 名单总人数 | 名单条目数;被当前试卷设为**免考**的人不计入 |
| 已提交 | 匹配到名单、且提交了答卷的人数(去重后的代表成绩行数) | | 已提交 | 匹配到名单、且提交了答卷的人数(去重后的代表成绩行数) |
| 缺考 | `max(0, 名单总人数 − 已提交)` | | 缺考 | `max(0, 名单总人数 − 已提交)` |
| 提交率 | `已提交 ÷ 名单总人数 × 100%`——考试进行中最该盯的指标 |
| 提交率 | `已提交 ÷ 名单总人数 × 100%` | | 提交率 | `已提交 ÷ 名单总人数 × 100%` |
| 平均分 | 所有**有成绩**的答卷成绩平均值;无成绩者不计入分母 | | 平均分 | 所有**有成绩**的答卷成绩平均值;无成绩者不计入分母 |
| 最高分 / 最低分 | 同上,取自所有有成绩的答卷 | | 最高分 / 最低分 | 同上,取自所有有成绩的答卷 |
@@ -40,7 +41,20 @@
| 提交时间 | **最近一次**提交时间(重考低分也会让这行按最新时间排序到最上面) | | 提交时间 | **最近一次**提交时间(重考低分也会让这行按最新时间排序到最上面) |
| 考试时长 | 最近一次答卷的时长;后台手动录入等没有起止时间的答卷为 `-` | | 考试时长 | 最近一次答卷的时长;后台手动录入等没有起止时间的答卷为 `-` |
点击任意一行可查看该学生的**每一次提交**(时间、成绩、时长),最高分有标记。 点击任意一行可查看该学生的**每一次提交**(时间、成绩、时长),最佳成绩有标记。
### 成绩颜色
成绩按**占本卷满分的百分比**着色,而不是写死的 90 / 60 分:
| 颜色 | 条件 |
| --- | --- |
| 绿色 | ≥ 满分的 90% |
| 橙色 | ≥ 合格率线(默认满分的 60%,可在[每卷配置](./project-config.md)里改) |
| 红色 | 低于合格率线 |
这样把满分改成 50 分或 20 分制时,不会因为绝对分数偏低而整列误标红。
数据看板的成员榜使用同一套规则。
## 排序与搜索 ## 排序与搜索
+2 -2
View File
@@ -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 证书 | 勾选 | 自签名证书取消勾选 |
-72
View File
@@ -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
View File
@@ -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` | 从名单或某试卷已提交人员中随机抽取,支持白名单导出 |
![主系统界面](/images/scores.png) ![主系统界面](/images/scores.png)
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<title>数据大屏首页(预览)</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="../../assets/style.css">
<style>
/* 预览页专属:补上 hero 里样式类所需的小件(原页面由 dashboard.php 内联给出) */
.brand-text{display:flex;flex-direction:column;gap:2px}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--border-2);vertical-align:1px}
.dot.on{background:var(--ok)}
.dot.stale{background:var(--danger)}
.spacer{flex:1}
.preview-tip{margin:12px 0 0;padding:8px 12px;border:1px dashed var(--border);border-radius:var(--radius);
font-size:12px;color:var(--text-3);background:var(--panel-weak,#f7f8fc)}
</style></head>
<body class="dash dash-home">
<div class="wrap">
<div class="dash-hero">
<div class="brand"><h1 class="brand-title">NY问卷考试系统数据平台</h1></div>
<div class="dash-controls">
<select id="projectSel"><option value="p1">高一数学月考(31 份)</option></select>
<span id="classSelWrap"><select id="classSel" disabled><option value="">请先选问卷</option></select></span>
<label class="switch"><input type="checkbox" id="autoRefresh" checked><span></span></label>
<select id="intervalSel"><option value="15" selected>15 秒</option></select>
<button class="btn" id="btnRefresh">刷新</button>
<button class="btn" id="btnFullscreen">⛶ 全屏</button>
<a class="btn back" id="linkIndex" href="index.php">← 主系统</a>
<a class="btn back" id="linkLogout" href="logout.php">退出</a>
</div>
</div>
<div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
<span id="heroSub">共 4 个班级 · 点击卡片进入该班大屏</span>
<span class="spacer"></span>
最后更新:<span id="lastUpdate">2026-09-27 20:57:25</span><i id="liveDot" class="dot on"></i>
</div>
<div class="preview-tip">静态预览:这张页面由生产环境 dashboard.php 的 <code>clsCardHtml()</code> 渲染,
数据取自 mock 卷王。真实站点上点卡片会整页跳转到 <code>dashboard.php?pid=…&amp;class=…</code>,
进入该班独立大屏(那里不再有班级选择、返回主系统与退出,浏览器后退键也被锁住)。</div>
<div id="board">
<section class="cards" id="boardSummary">
<div class="card accent-blue"><span>总人数</span><b>0</b></div>
<div class="card ok accent-green"><span>已提交</span><b>0</b></div>
<div class="card warn accent-orange"><span>缺考</span><b>0</b></div>
<div class="card accent-teal"><span>平均分</span><b>-</b></div>
<div class="card accent-indigo"><span>合格率</span><b>-</b></div>
</section>
<div class="sec-title" id="boardTitle">班级入口 <span class="tag-gray">点击卡片进入该班独立大屏</span></div>
<div id="boardBody"><div class="cls-grid"><div class="cls-card" data-cls="高一(1)班" role="button" tabindex="0" title="点击进入「高一(1)班」的独立大屏"><div class="cls-head"><span class="cls-name">高一(1)班</span><span class="cls-go">进入 ›</span></div><div class="cls-kpis"><div class="cls-kpi "><span>总人数</span><b>2</b></div><div class="cls-kpi k-ok"><span>已提交</span><b>2</b></div><div class="cls-kpi "><span>缺考</span><b>0</b></div><div class="cls-kpi "><span>平均分</span><b>81</b></div><div class="cls-kpi k-ok"><span>合格率</span><b>100%</b></div></div><div class="cls-bar"><i style="width:100%;background:var(--ok)"></i></div><div class="cls-sub"><span>提交率 100%</span></div></div><div class="cls-card" data-cls="高一(2)班" role="button" tabindex="0" title="点击进入「高一(2)班」的独立大屏"><div class="cls-head"><span class="cls-name">高一(2)班</span><span class="cls-go">进入 ›</span></div><div class="cls-kpis"><div class="cls-kpi "><span>总人数</span><b>1</b></div><div class="cls-kpi k-ok"><span>已提交</span><b>1</b></div><div class="cls-kpi "><span>缺考</span><b>0</b></div><div class="cls-kpi "><span>平均分</span><b>93</b></div><div class="cls-kpi k-ok"><span>合格率</span><b>100%</b></div></div><div class="cls-bar"><i style="width:100%;background:var(--ok)"></i></div><div class="cls-sub"><span>提交率 100%</span></div></div><div class="cls-card" data-cls="高一(3)班" role="button" tabindex="0" title="点击进入「高一(3)班」的独立大屏"><div class="cls-head"><span class="cls-name">高一(3)班</span><span class="cls-go">进入 ›</span></div><div class="cls-kpis"><div class="cls-kpi "><span>总人数</span><b>1</b></div><div class="cls-kpi k-ok"><span>已提交</span><b>1</b></div><div class="cls-kpi "><span>缺考</span><b>0</b></div><div class="cls-kpi "><span>平均分</span><b>90</b></div><div class="cls-kpi k-ok"><span>合格率</span><b>100%</b></div></div><div class="cls-bar"><i style="width:100%;background:var(--ok)"></i></div><div class="cls-sub"><span>提交率 100%</span></div></div><div class="cls-card" data-cls="高一(4)班" role="button" tabindex="0" title="点击进入「高一(4)班」的独立大屏"><div class="cls-head"><span class="cls-name">高一(4)班</span><span class="cls-go">进入 ›</span></div><div class="cls-kpis"><div class="cls-kpi "><span>总人数</span><b>1</b></div><div class="cls-kpi k-ok"><span>已提交</span><b>1</b></div><div class="cls-kpi "><span>缺考</span><b>0</b></div><div class="cls-kpi "><span>平均分</span><b>76</b></div><div class="cls-kpi k-ok"><span>合格率</span><b>100%</b></div></div><div class="cls-bar"><i style="width:100%;background:var(--ok)"></i></div><div class="cls-sub"><span>提交率 100%</span></div></div></div></div>
</div>
</div>
<script>/* 预览页:把"全屏/刷新"按钮做成 plainly 不可点,仅示意版式 */
document.addEventListener('DOMContentLoaded', function () {
["#btnFullscreen", "#btnRefresh", "#projectSel", "#classSelWrap"].forEach(function (s) {
var el = document.querySelector(s); if (el) el.setAttribute("disabled", "disabled");
});
});</script>
</body></html>
Binary file not shown.
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<title>多考试导出 · 每次考试(两行表头)(预览)</title><style>body{font-family:"Microsoft YaHei","PingFang SC",sans-serif;color:#222;background:#f7f7f7;margin:0;padding:24px}.page{background:#fff;border:1px solid #e3e3e3;border-radius:6px;padding:20px 22px;margin:0 auto 20px;max-width:1100px}.page h2{font-size:16px;margin:0 0 4px}.page .sub{font-size:12px;color:#777;margin:0 0 14px}table.bt{border-collapse:collapse;border:1px solid #999;width:100%;background:#fff;font-size:11px}table.bt td,table.bt th{border:1px solid #999;text-align:center;padding:3px 5px}table.bt th,table.bt thead td{background:#eaeaea;font-weight:700}table.bt thead tr:first-child td{background:#eaeaea}table.bt td.absent{color:#888}table.bt td.max{color:#d0021b;font-weight:700}table.bt td.pass{background:#63c384;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}html,body,.pass,.absent,.max{-webkit-print-color-adjust:exact;print-color-adjust:exact}.legend{margin:10px 0 0;font-size:12px;color:#555}.legend i{display:inline-block;width:11px;height:11px;vertical-align:-1px;margin:0 4px 0 12px}.legend .r{color:#d0021b;font-weight:700}.legend .g{background:#63c384;font-weight:700}.sign{margin-top:16px;font-size:12px}</style></head><body>
<div class="page"><h2>多考试导出 · 每次考试(两行表头)</h2><p class="sub">合格线 110 分,达 1 次标绿</p>
<table class="bt"><thead><tr><td rowspan="2">学号</td><td rowspan="2">姓名</td><td rowspan="2">第几次</td><td>第一次月考·数学</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>
+7
View File
@@ -0,0 +1,7 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<title>单场导出 · 每次考试(每人一行的宽表)(预览)</title><style>body{font-family:"Microsoft YaHei","PingFang SC",sans-serif;color:#222;background:#f7f7f7;margin:0;padding:24px}.page{background:#fff;border:1px solid #e3e3e3;border-radius:6px;padding:20px 22px;margin:0 auto 20px;max-width:1100px}.page h2{font-size:16px;margin:0 0 4px}.page .sub{font-size:12px;color:#777;margin:0 0 14px}table.bt{border-collapse:collapse;border:1px solid #999;width:100%;background:#fff;font-size:11px}table.bt td,table.bt th{border:1px solid #999;text-align:center;padding:3px 5px}table.bt th,table.bt thead td{background:#eaeaea;font-weight:700}table.bt thead tr:first-child td{background:#eaeaea}table.bt td.absent{color:#888}table.bt td.max{color:#d0021b;font-weight:700}table.bt td.pass{background:#63c384;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}html,body,.pass,.absent,.max{-webkit-print-color-adjust:exact;print-color-adjust:exact}.legend{margin:10px 0 0;font-size:12px;color:#555}.legend i{display:inline-block;width:11px;height:11px;vertical-align:-1px;margin:0 4px 0 12px}.legend .r{color:#d0021b;font-weight:700}.legend .g{background:#63c384;font-weight:700}.sign{margin-top:16px;font-size:12px}</style></head><body>
<div class="page"><h2>单场导出 · 每次考试(每人一行的宽表)</h2><p class="sub">第一次月考·数学 · 合格线 110 分,达 1 次标绿</p>
<table class="bt"><thead><tr><td>学号</td><td>姓名</td><td>第1次</td><td>第2次</td><td>第3次</td><td>第4次</td><td>合格次数</td></tr></thead><tbody><tr><td>356001</td><td>张三</td><td>112</td><td>100</td><td class="max">136</td><td>127</td><td class="pass">3</td></tr><tr><td>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>
+222
View File
@@ -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": "第一次月考·数学"
}
}
}
+1 -2
View File
@@ -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:改掉卷王默认口令
+7 -7
View File
@@ -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` 务必修改 |
+8 -8
View File
@@ -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>
+238 -98
View File
@@ -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>&times;</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>&times;</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>&times;</button></div> <div class="modal-head"><h3>打印成绩明细</h3><button class="x" data-close>&times;</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>&times;</button></div> <div class="modal-head"><h3>导出 / 打印</h3><button class="x" data-close>&times;</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) ?>,
-11
View File
@@ -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;
}
} }
+164 -129
View File
@@ -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。
@@ -484,8 +463,11 @@ class DataService
if ($list === []) { if ($list === []) {
// 没有明细时退回代表行本身 // 没有明细时退回代表行本身
$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,27 +617,55 @@ 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(
$idx++; static function (array $a) use (&$idx, $maxScore, &$bestMarked, &$dayMarked, $dayMax, $dayCount): array {
$isBest = (!$bestMarked && $a['score'] !== null && $maxScore !== null && (float)$a['score'] === (float)$maxScore); $idx++;
if ($isBest) { $isBest = (!$bestMarked && $a['score'] !== null && $maxScore !== null && (float)$a['score'] === (float)$maxScore);
$bestMarked = true; if ($isBest) {
} $bestMarked = true;
return [ }
'idx' => $idx, $day = self::dayKeyOf((int)($a['submit_ts'] ?? 0), (string)($a['submit_at'] ?? ''));
'is_best' => $isBest, $dayBest = ($a['score'] !== null && array_key_exists($day, $dayMax)
'submit_at' => $a['submit_at'], && (float)$a['score'] === (float)$dayMax[$day] && empty($dayMarked[$day]));
'submit_ts' => $a['submit_ts'], if ($dayBest) {
'score' => $a['score'], $dayMarked[$day] = true;
'duration' => $a['duration'], }
'duration_txt' => $a['duration_txt'], return [
'answer_id' => $a['answer_id'], 'idx' => $idx,
'temp_save' => $a['temp_save'], 'is_best' => $isBest,
]; 'submit_at' => $a['submit_at'],
}, $sorted); 'submit_ts' => $a['submit_ts'],
'score' => $a['score'],
'duration' => $a['duration'],
'duration_txt' => $a['duration_txt'],
'answer_id' => $a['answer_id'],
'temp_save' => $a['temp_save'],
// 按天分类用:day 为空串表示时间未知
'day' => $day,
'day_best' => $dayBest,
'day_count' => (int)($dayCount[$day] ?? 1),
];
},
$sorted
);
// 代表成绩选择(仅影响主明细表那一行的成绩,不影响考试次数 / 历史明细) // 代表成绩选择(仅影响主明细表那一行的成绩,不影响考试次数 / 历史明细)
if ($dedupe) { if ($dedupe) {
+49 -73
View File
@@ -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
View File
@@ -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;
}
/** /**
* 解析上传文件 -> 学生数组 * 解析上传文件 -> 学生数组
-164
View File
@@ -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];
}
}
+171
View File
@@ -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
{ {
+197 -27
View File
@@ -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
@@ -61,18 +83,93 @@ final class XlsxWriter
continue; // 空表不输出,避免 Excel 提示修复 continue; // 空表不输出,避免 Excel 提示修复
} }
$w->sheets[] = [ $w->sheets[] = [
'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
+853
View File
@@ -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,
];
}
+62 -30
View File
@@ -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,42 +30,55 @@ 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 分钟,防止暴力破解 $user = trim((string)($_POST['username'] ?? $cfg['username']));
$throttle = ny_login_throttle_check(); $pwd = (string)($_POST['password'] ?? '');
if ($throttle['until'] > time()) { // 允许用户在本页调整账号;密码必须输入
$wait = (int)(($throttle['until'] - time()) / 60) + 1; if ($user === '' || $pwd === '') {
$err = '尝试次数过多,请 ' . $wait . ' 分钟后再试。'; $err = '请输入账号与密码。';
} else { } else {
$user = trim((string)($_POST['username'] ?? $cfg['username'])); // 用当前配置的连接信息 + 本页输入的账密做一次卷王登录
$pwd = (string)($_POST['password'] ?? ''); $test = $cfg;
// 允许用户在本页调整账号;密码必须输入 $test['username'] = $user;
if ($user === '' || $pwd === '') { $test['password'] = $pwd;
$err = '请输入账号与密码。'; try {
} else { $client = new SurveyKingClient($test);
// 用当前配置的连接信息 + 本页输入的账密做一次卷王登录 $client->getToken(true);
$test = $cfg;
$test['username'] = $user; // 登录成功:把账号与密码写回配置(本地单用户工具,持久化密码以便 token
$test['password'] = $pwd; // 失效时平台能自动重新登录卷王,避免后台刷新陷入"未配置账号"的死状态)
try { $cfg['username'] = $user;
$client = new SurveyKingClient($test); $cfg['password'] = $pwd;
$client->getToken(true); $saved = ny_save_config($cfg);
// 登录成功:把账号与密码写回配置(本地单用户工具,持久化密码以便 token
// 失效时平台能自动重新登录卷王,避免后台刷新陷入"未配置账号"的死状态) // 关键:必须回读确认真的存住了。以前这里不看返回值,data 目录不可写时
$cfg['username'] = $user; // 会显示"登录成功",但下一个请求读到的账号仍是空的 → 无限跳回登录页。
$cfg['password'] = $pwd; $back = ny_load_config();
ny_save_config($cfg); $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
View File
@@ -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 "全部测试完成。"
+69
View File
@@ -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);
+125
View File
@@ -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);
+440
View File
@@ -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
View File
@@ -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);
+106
View File
@@ -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);
});
+64
View File
@@ -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";
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env node
/**
* 生成数据大屏首页的可打开预览:docs/samples/dash-home-preview.html
*
* 关键点与 make-samples.js 一致——卡片**直接调用 dashboard.php 里生产环境的 clsCardHtml()**,
* 不是另写一份渲染逻辑,所以预览长什么样,线上首页就长什么样,不会两边漂移。
* 数据取自本机 mock-server(tools/mock-server.js)+ PHP 内置服务器,名单与答卷都是真跑出来的。
*
* 用法:node tools/make-dash-sample.js
*/
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const http = require('http');
const { spawn } = require('child_process');
const ROOT = path.join(__dirname, '..');
const PORT = 18095;
const INI = process.env.PHP_INI || '';
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
/* ---------- 临时数据目录:绝不动项目 data/ ---------- */
let p = process.env.NY_DATA || path.join(os.tmpdir(), 'ny-dash-sample-' + process.pid);
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2);
const DATA = path.resolve(p);
process.env.NY_DATA = DATA;
fs.mkdirSync(DATA, { recursive: true });
fs.writeFileSync(path.join(DATA, 'config.json'), JSON.stringify({
base_url: 'http://127.0.0.1:19911',
api_prefix: '/api',
username: '', password: '',
verify_ssl: false, timeout: 10,
refresh_interval: 15, page_size: 50,
dedupe: true, include_temp_save: false,
name_keywords: '姓名,名字',
full_score: 100, layout: 'default',
}, null, 2));
/* 名单:mock 的「卷内参与者」只放行 90001-90005,应考名单会被收敛到这几人,
所以把 5 人分散到 4 个班,其余人做无答卷的填充(让卡片同时能看到缺考数)。 */
const STUDENTS = [];
[['90001', '高一(1)班'], ['90002', '高一(2)班'], ['90003', '高一(3)班'],
['90004', '高一(4)班'], ['90005', '高一(1)班']].forEach(([sid, cls]) =>
STUDENTS.push({ sid, name: sid, cls, grp: 'A组', login: '' }));
['高一(1)班', '高一(2)班', '高一(3)班', '高一(4)班'].forEach((cls, ci) => {
for (let i = 1; i <= 11; i++) {
const sid = String(9200 + ci * 100 + i);
STUDENTS.push({ sid, name: sid, cls, grp: i % 2 ? 'A组' : 'B组', login: '' });
}
});
fs.writeFileSync(path.join(DATA, 'roster.json'), JSON.stringify({ students: STUDENTS, updated_at: '2026-01-01 00:00:00' }));
let mock = null, srv = null, cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
try { srv && srv.kill('SIGKILL'); } catch (e) {}
try { mock && mock.kill('SIGKILL'); } catch (e) {}
try { fs.rmSync(DATA, { recursive: true, force: true }); } catch (e) {}
}
process.on('exit', cleanup);
process.on('uncaughtException', e => { console.error('FATAL', e); cleanup(); process.exit(2); });
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'ignore' });
srv = spawn(PHP, INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]), { cwd: ROOT, stdio: 'ignore' });
/* ---------- 极简 HTTP ---------- */
const jar = [];
function collect(res) {
for (const sc of (res.headers['set-cookie'] || [])) {
const pair = sc.split(';')[0];
const i = jar.findIndex(x => x.startsWith(pair.split('=')[0] + '='));
if (i >= 0) jar[i] = pair; else jar.push(pair);
}
}
const cookie = () => (jar.length ? jar.join('; ') : '');
function req(method, p) {
return new Promise((resolve, reject) => {
const h = { 'Accept': 'application/json' };
if (cookie()) h['Cookie'] = cookie();
const r = http.request({ host: '127.0.0.1', port: PORT, method, path: p, headers: h }, res => {
const bufs = [];
res.on('data', c => bufs.push(c));
res.on('end', () => { collect(res); resolve({ raw: Buffer.concat(bufs).toString('utf8') }); });
});
r.on('error', reject);
r.end();
});
}
// 登录必须走表单编码(login.php 读 $_POST),只发一个空 POST 是登不上的
function formPost(p, fields) {
return new Promise((resolve, reject) => {
const body = new URLSearchParams(fields).toString();
const h = { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body) };
if (cookie()) h['Cookie'] = cookie();
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path: p, headers: h }, res => {
const bufs = [];
res.on('data', c => bufs.push(c));
res.on('end', () => { collect(res); resolve({ raw: Buffer.concat(bufs).toString('utf8') }); });
});
r.on('error', reject);
r.write(body); r.end();
});
}
const delay = ms => new Promise(r => setTimeout(r, ms));
/* ---------- 抽出生产环境的卡片渲染函数 ---------- */
const dashSrc = fs.readFileSync(path.join(ROOT, 'dashboard.php'), 'utf8');
const m = dashSrc.match(/function clsCardHtml\(c\)\{[\s\S]*?\n\}/);
if (!m) { console.error('未能从 dashboard.php 抽取 clsCardHtml'); cleanup(); process.exit(1); }
const esc = s => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const dv = (v, d) => (v === undefined || v === null || v === '' ? (d === undefined ? '-' : d) : v);
const pv = v => dv(v, '-') + (v === undefined || v === null || v === '' ? '' : '%');
const rateColor = r => (r >= 90 ? 'var(--ok)' : (r >= 60 ? 'var(--warn)' : 'var(--danger)'));
const clsCardHtml = new Function('esc', 'dv', 'pv', 'rateColor', m[0] + ' return clsCardHtml;')(esc, dv, pv, rateColor);
(async () => {
await delay(2200);
await formPost('/login.php', { username: 'mock', password: 'surveyking' }); // 顺带把账密写进临时配置
const pr = await req('GET', '/api.php?action=projects');
const projects = ((JSON.parse(pr.raw).data) || []).filter(x => x.id && x.id !== 'p2');
if (!projects.length) { console.error('mock 没返回可用问卷,原始响应:\n' + pr.raw.slice(0, 800)); cleanup(); process.exit(1); }
const project = projects[0];
const dr = await req('GET', '/api.php?action=dashboard&projectId=' + encodeURIComponent(project.id));
const data = JSON.parse(dr.raw).data || {};
const classes = data.classes || [];
if (!classes.length) { console.error('dashboard 没返回班级,名单是否为空?'); cleanup(); process.exit(1); }
const cards = classes.map(clsCardHtml).join('');
const projectName = project.name || project.id;
const html = [
'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">',
'<title>数据大屏首页(预览)</title>',
'<meta name="viewport" content="width=device-width, initial-scale=1">',
'<link rel="stylesheet" href="../../assets/style.css">',
'<style>',
'/* 预览页专属:补上 hero 里样式类所需的小件(原页面由 dashboard.php 内联给出) */',
'.brand-text{display:flex;flex-direction:column;gap:2px}',
'.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--border-2);vertical-align:1px}',
'.dot.on{background:var(--ok)}',
'.dot.stale{background:var(--danger)}',
'.spacer{flex:1}',
'.preview-tip{margin:12px 0 0;padding:8px 12px;border:1px dashed var(--border);border-radius:var(--radius);',
' font-size:12px;color:var(--text-3);background:var(--panel-weak,#f7f8fc)}',
'</style></head>',
'<body class="dash dash-home">',
'<div class="wrap">',
' <div class="dash-hero">',
' <div class="brand"><h1 class="brand-title">NY问卷考试系统数据平台</h1></div>',
' <div class="dash-controls">',
' <select id="projectSel"><option value="' + esc(project.id) + '">' + esc(project.name) +
'(' + project.total + ' 份)</option></select>',
' <span id="classSelWrap"><select id="classSel" disabled><option value="">请先选问卷</option></select></span>',
' <label class="switch"><input type="checkbox" id="autoRefresh" checked><span></span></label>',
' <select id="intervalSel"><option value="15" selected>15 秒</option></select>',
' <button class="btn" id="btnRefresh">刷新</button>',
' <button class="btn" id="btnFullscreen">⛶ 全屏</button>',
' <a class="btn back" id="linkIndex" href="index.php">← 主系统</a>',
' <a class="btn back" id="linkLogout" href="logout.php">退出</a>',
' </div>',
' </div>',
' <div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;flex-wrap:wrap;">',
' <span id="heroSub">共 ' + classes.length + ' 个班级 · 点击卡片进入该班大屏</span>',
' <span class="spacer"></span>',
' 最后更新:<span id="lastUpdate">' + esc(data.updated_at || '-') + '</span><i id="liveDot" class="dot on"></i>',
' </div>',
' <div class="preview-tip">静态预览:这张页面由生产环境 dashboard.php 的 <code>clsCardHtml()</code> 渲染,',
' 数据取自 mock 卷王。真实站点上点卡片会整页跳转到 <code>dashboard.php?pid=…&amp;class=…</code>,',
' 进入该班独立大屏(那里不再有班级选择、返回主系统与退出,浏览器后退键也被锁住)。</div>',
' <div id="board">',
' <section class="cards" id="boardSummary">',
' <div class="card accent-blue"><span>总人数</span><b>0</b></div>',
' <div class="card ok accent-green"><span>已提交</span><b>0</b></div>',
' <div class="card warn accent-orange"><span>缺考</span><b>0</b></div>',
' <div class="card accent-teal"><span>平均分</span><b>-</b></div>',
' <div class="card accent-indigo"><span>合格率</span><b>-</b></div>',
' </section>',
' <div class="sec-title" id="boardTitle">班级入口 <span class="tag-gray">点击卡片进入该班独立大屏</span></div>',
' <div id="boardBody"><div class="cls-grid">' + cards + '</div></div>',
' </div>',
'</div>',
'<script>/* 预览页:把"全屏/刷新"按钮做成 plainly 不可点,仅示意版式 */',
'document.addEventListener(\'DOMContentLoaded\', function () {',
' ["#btnFullscreen", "#btnRefresh", "#projectSel", "#classSelWrap"].forEach(function (s) {',
' var el = document.querySelector(s); if (el) el.setAttribute("disabled", "disabled");',
' });',
'});</script>',
'</body></html>',
].join('\n');
const out = path.join(ROOT, 'docs', 'samples', 'dash-home-preview.html');
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, html, 'utf8');
console.log('已生成 ' + path.relative(ROOT, out));
console.log(' 问卷:' + projectName + ' 班级:' + classes.map(c => c.class).join('、'));
cleanup();
process.exit(0);
})();
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env node
/**
* 把 tools/make-samples.php 产出的数据渲染成可打开的样例页面。
*
* 关键点:页面里的表格**直接调用 assets/app.js 里生产环境的 sheetToHtml()**——
* 不是另写一份渲染逻辑,所以预览长什么样,打印就长什么样,不会两边漂移。
* 配色 / 字号沿用 printDoc() 里打印用的那套样式。
*
* 用法:node tools/make-samples.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const data = JSON.parse(fs.readFileSync(path.join(ROOT, 'docs', 'samples', 'sheet-data.json'), 'utf8'));
/* ---- 抽出生产环境的 sheetToHtml ---- */
const appSrc = fs.readFileSync(path.join(ROOT, 'assets', 'app.js'), 'utf8');
const m = appSrc.match(/function sheetToHtml\(sheet\)\s*\{[\s\S]*?\n \}/);
if (!m) {
console.error('未能从 assets/app.js 抽取 sheetToHtml');
process.exit(1);
}
const esc = s => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// eslint-disable-next-line no-new-func
const sheetToHtml = new Function('esc', 'return ' + m[0])(esc);
/* ---- 沿用 printDoc() 里的打印配色 ---- */
const PRINT_CSS = [
'body{font-family:"Microsoft YaHei","PingFang SC",sans-serif;color:#222;background:#f7f7f7;margin:0;padding:24px}',
'.page{background:#fff;border:1px solid #e3e3e3;border-radius:6px;padding:20px 22px;margin:0 auto 20px;max-width:1100px}',
'.page h2{font-size:16px;margin:0 0 4px}',
'.page .sub{font-size:12px;color:#777;margin:0 0 14px}',
// 与 printDoc() 里的打印样式保持一致(表头渲染成 <td>,所以底色写 thead td)
'table.bt{border-collapse:collapse;border:1px solid #999;width:100%;background:#fff;font-size:11px}',
'table.bt td,table.bt th{border:1px solid #999;text-align:center;padding:3px 5px}',
'table.bt th,table.bt thead td{background:#eaeaea;font-weight:700}',
'table.bt thead tr:first-child td{background:#eaeaea}',
'table.bt td.absent{color:#888}',
'table.bt td.max{color:#d0021b;font-weight:700}',
'table.bt td.pass{background:#63c384;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}',
'html,body,.pass,.absent,.max{-webkit-print-color-adjust:exact;print-color-adjust:exact}',
'.legend{margin:10px 0 0;font-size:12px;color:#555}',
'.legend i{display:inline-block;width:11px;height:11px;vertical-align:-1px;margin:0 4px 0 12px}',
'.legend .r{color:#d0021b;font-weight:700}',
'.legend .g{background:#63c384;font-weight:700}',
'.sign{margin-top:16px;font-size:12px}',
].join('');
function render(item) {
const s = item.sheet;
const table = sheetToHtml(s);
const meta = item.meta || {};
const title = esc(item.title);
const sub = meta.exam_name
? esc(meta.exam_name) + ' · 合格线 ' + meta.pass_line + ' 分,达 ' + meta.pass_times + ' 次标绿'
: '合格线 ' + meta.pass_line + ' 分,达 ' + meta.pass_times + ' 次标绿';
return [
'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">',
'<title>' + title + '(预览)</title><style>' + PRINT_CSS + '</style></head><body>',
'<div class="page"><h2>' + title + '</h2><p class="sub">' + sub + '</p>',
table,
'<p class="legend">标记说明:<span class="r">最高分</span> 标红、'
+ '<i class="r">红字</i>;<span class="g">合格次数</span> 始终显示(含 0 次),'
+ '达 ' + meta.pass_times + ' 次标绿、<i class="g">绿底</i>;未达标显示次数、不标绿。',
'<p class="sign">教师签字:______________  教研组长签字:______________  日期:____________</p>',
'</div></body></html>',
].join('\n');
}
const outDir = path.join(ROOT, 'docs', 'samples');
fs.writeFileSync(path.join(outDir, 'preview-multi-block.html'), render(data.block));
fs.writeFileSync(path.join(outDir, 'preview-single-wide.html'), render(data.wide));
console.log('已生成:');
console.log(' docs/samples/preview-multi-block.html (多考试块表)');
console.log(' docs/samples/preview-single-wide.html (单场宽表)');
+127
View File
@@ -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
View File
@@ -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 + ' 人)');
}); });
+150
View File
@@ -0,0 +1,150 @@
/* 多考试打印渲染回归测试
*
* 「多考试打印」的版式由 assets/app.js 的 sheetToHtml() 负责:把服务端装配好的
* 二维表 + merges(1-based 闭区间)+ cell_styles(red=最高分红字 / green=合格绿底)
* 翻成带 colspan / rowspan 的 HTML。
* 本测试直接从 app.js 抽取该函数源码执行(不复制一份实现,避免两边漂移),
* 用与 tests/test-multi-export.php 同一套版式校验渲染结果。
*
* 运行:node tools/test-block-print.js
*/
const fs = require('fs');
const path = require('path');
let pass = 0, fail = 0;
function chk(name, ok, extra) {
if (ok) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
}
/* ---------- 1) 从生产代码里抽取 sheetToHtml ---------- */
const appPath = path.join(__dirname, '..', 'assets', 'app.js');
const appSrc = fs.readFileSync(appPath, 'utf8');
const m = appSrc.match(/function sheetToHtml\(sheet\)\s*\{[\s\S]*?\n \}/);
chk('能从 assets/app.js 抽到 sheetToHtml', !!m);
if (!m) {
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
process.exit(1);
}
const PRINT_CSS = (appSrc.match(/function printDoc\(title, bodyHtml, opts\)\s*\{[\s\S]*?\n \}/) || [''])[0];
const esc = s => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// eslint-disable-next-line no-new-func
const sheetToHtml = new Function('esc', 'return ' + m[0])(esc);
/* ---------- 2) 真实装配结果(截图版式:共享「第几次」列 + 合格次数行) ----------
列:1 学号 | 2 姓名 | 3 第几次 | 4-5 数学 | 6-7 英语
合格线 110,阈值 1 次。 */
const FIXTURE = {
header_rows: 2,
rows: [
['学号', '姓名', '第几次', '数学', '', '英语', ''],
['', '', '', '成绩', '', '成绩', ''],
// 张三:数学 3 次(112/100/136),英语 1 次(112)
['356001', '张三', '1', '112', '', '112', ''],
['', '', '2', '100', '', '', ''],
['', '', '3', '136', '', '', ''],
['', '', '合格次数', '3', '', '1', ''],
// 李四:数学整场未考,英语 3 次(130/129/133)
['356002', '李四', '1', '未考', '', '130', ''],
['', '', '2', '', '', '129', ''],
['', '', '3', '', '', '133', ''],
['', '', '合格次数', '', '', '3', ''],
// 王五:数学 1 次(88,未达合格线),英语整场未考
['356003', '王五', '1', '88', '', '未考', ''],
['', '', '合格次数', '0', '', '', ''],
],
merges: [
[1, 1, 2, 1], [1, 2, 2, 2], [1, 3, 2, 3], [1, 4, 1, 5], [1, 6, 1, 7], // 表头
[3, 1, 6, 1], [3, 2, 6, 2], [3, 3, 5, 3], // 张三:学号/姓名并入末行,「第几次」只到末次考试
[7, 1, 10, 1], [7, 2, 10, 2], [7, 4, 10, 5], // 李四:学号/姓名并入末行;数学整场「未考」跨 2 列 × 4 行
[11, 6, 11, 7], // 王五:英语整场「未考」跨 2 列
],
cell_styles: {
'5,4': 'red', // 张三 数学最高分 136(1-based 行 5) → 红字
'9,6': 'red', // 李四 英语最高分 133(1-based 行 9) → 红字
'6,4': 'green', // 张三 数学合格次数 3 → 绿底
'6,6': 'green', // 张三 英语合格次数 1 → 绿底
'10,6': 'green', // 李四 英语合格次数 3 → 绿底
},
};
const html = sheetToHtml(FIXTURE);
const thead = (html.match(/<thead>([\s\S]*?)<\/thead>/) || [])[1] || '';
const tbody = (html.match(/<tbody>([\s\S]*?)<\/tbody>/) || [])[1] || '';
const count = (s, needle) => s.split(needle).length - 1;
/* ---------- 3) 断言 ---------- */
chk('表头 2 行进 <thead>(多页打印自动重复)', count(thead, '<tr>') === 2 && count(tbody, '<tr>') === 10,
'thead=' + count(thead, '<tr>') + ' tbody=' + count(tbody, '<tr>'));
chk('学号表头纵向合并 2 行', thead.indexOf('<td rowspan="2">学号</td>') >= 0);
chk('姓名表头纵向合并 2 行', thead.indexOf('<td rowspan="2">姓名</td>') >= 0);
chk('第几次表头纵向合并 2 行', thead.indexOf('<td rowspan="2">第几次</td>') >= 0);
chk('考试名称横跨 2 列',
thead.indexOf('<td colspan="2">数学</td>') >= 0 && thead.indexOf('<td colspan="2">英语</td>') >= 0);
chk('第 2 行表头是「成绩」小标题', thead.indexOf('<td>成绩</td>') >= 0);
chk('张三学号纵向合并 4 行(3 次考试 + 合格次数行)', tbody.indexOf('<td rowspan="4">356001</td>') >= 0);
chk('李四学号纵向合并 4 行', tbody.indexOf('<td rowspan="4">356002</td>') >= 0);
chk('王五学号不合并(只有 1 次考试)', tbody.indexOf('<td>356003</td>') >= 0);
chk('「合格次数」标签落在「第几次」列且 3 处都在', count(tbody, '<td>合格次数</td>') === 3,
'实际 ' + count(tbody, '<td>合格次数</td>'));
chk('未考合并 2 列 × 4 行(李四·数学)',
tbody.indexOf('<td rowspan="4" colspan="2" class="absent">未考</td>') >= 0);
chk('未考合并 2 列 × 1 行(王五·英语)',
tbody.indexOf('<td colspan="2" class="absent">未考</td>') >= 0);
chk('最高分红字 2 处(张三 136 / 李四 133)', count(tbody, 'class="max">') === 2,
'实际 ' + count(tbody, 'class="max">'));
chk('合格次数绿底 3 处(张三 2 场 + 李四 1 场)', count(tbody, 'class="pass">') === 3,
'实际 ' + count(tbody, 'class="pass">'));
chk('王五数学 0 次未达标 → 不标绿', tbody.indexOf('>0</td>') >= 0 && tbody.indexOf('class="pass">0') < 0);
/* td 数守恒:总格位数 − 被合并吞掉的格位。合并一旦算错(重叠 / 漏格)这里就会炸 */
const covered = new Set();
FIXTURE.merges.forEach(mg => {
for (let r = mg[0] - 1; r <= mg[2] - 1; r++) {
for (let c = mg[1] - 1; c <= mg[3] - 1; c++) {
if (r === mg[0] - 1 && c === mg[1] - 1) continue;
covered.add(r + '|' + c);
}
}
});
const capacity = FIXTURE.rows.reduce((s, row) => s + row.length, 0);
const eaten = FIXTURE.rows.reduce((s, row, r) =>
s + row.filter((_, c) => covered.has(r + '|' + c)).length, 0);
chk('td 数守恒(' + capacity + ' − ' + eaten + ' = ' + (capacity - eaten) + ')',
count(html, '<td') === capacity - eaten, '实际 ' + count(html, '<td'));
/* 标记只落在 cell_styles 指定的格子上:合并首格带上 class 且 rowspan/colspan 完整,
同行未参与合并的格子不受牵连,越界键被忽略 */
const markHtml = sheetToHtml({
rows: [['a', 'b', 'c'], ['1', '2', '3']],
merges: [[1, 1, 2, 2]],
cell_styles: { '1,1': 'green', '99,99': 'red' },
header_rows: 1,
});
chk('合并首格带标记:class 正确且 rowspan/colspan 完整',
markHtml.indexOf('<td rowspan="2" colspan="2" class="pass">a</td>') >= 0, markHtml);
chk('同行未合并格不受相邻合并影响', markHtml.indexOf('<td>c</td>') >= 0);
chk('越界标记键被忽略', markHtml.indexOf('class="max"') < 0);
chk('打印 CSS 含块表样式(居中 / 未考灰 / 最高分红 / 合格绿)',
PRINT_CSS.indexOf('table.bt') >= 0 && PRINT_CSS.indexOf('td.absent') >= 0
&& PRINT_CSS.indexOf('td.max') >= 0 && PRINT_CSS.indexOf('td.pass') >= 0);
/* 绿底印不印得出来,取决于 print-color-adjust 有没有写上——浏览器默认关闭"背景图形" */
chk('打印 CSS 声明了 print-color-adjust:exact(否则底纹不会被打印)',
/print-color-adjust:\s*exact/.test(PRINT_CSS) && PRINT_CSS.indexOf('-webkit-print-color-adjust') >= 0);
chk('绿底单元格自己也声明了 print-color-adjust',
/td\.pass\{[^}]*print-color-adjust:\s*exact/.test(PRINT_CSS));
/* 缺考名单等附加工作表需要另起一页,否则会挤在成绩表底部 */
chk('打印 CSS 让缺考名单(h3.grp.page-break)另起一页',
/h3\.grp\.page-break\{[^}]*page-break-before:\s*always/.test(PRINT_CSS));
/* 表头渲染成 <td>,底色必须写 thead td,写成 th 是死规则、两行表头会变白 */
chk('表头底色命中实际渲染的 <td>(thead td,不是死规则 th)',
/table\.bt thead td\{background:/.test(PRINT_CSS));
chk('HTML 已转义',
sheetToHtml({ rows: [['<b>x</b>']], merges: [], header_rows: 1 }).indexOf('&lt;b&gt;') >= 0);
console.log('');
console.log('结果:' + pass + ' 通过,' + fail + ' 失败');
module.exports = { sheetToHtml: sheetToHtml, PRINT_CSS: PRINT_CSS };
process.exit(fail === 0 ? 0 : 1);
+18 -12
View File
@@ -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); });
+296
View File
@@ -0,0 +1,296 @@
// e2e: 数据大屏两种形态 —— 首页班级入口 + 单班独立大屏(含后退锁定 / 顶栏控件收敛)
//
// 覆盖点:
// 1) 无 ?class= → dash-home:仍显示班级下拉,顶栏出现"选择考试 → 点卡片进入"文案
// 2) ?class=X → dash-class:顶栏隐藏班级选择 / ← 主系统,页面内不出现回首页的链接
// 3) 后退锁定:lockHistory() 只在 IS_CLASS 时挂,首页不受影响
// 4) 卡片渲染:直接把 dashboard.php 里的 clsCardHtml 抽出来跑,断言卡片结构与跳转参数
// 5) 公开访问:带 ?class= 的班级大屏免登录,且接口只回该班数据(其它班级不泄漏)
//
// 运行:node tools/test-dashboard-nav.js
const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const PORT = 18091;
const INI = process.env.PHP_INI || '';
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
const MOCK = { user: 'mock', pass: 'surveyking' };
// NY_DATA 指向临时目录:名单/配置/缓存一律写临时目录,绝不碰项目 data/
let p = process.env.NY_DATA || path.join(os.tmpdir(), 'ny-dash-nav-' + process.pid);
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2);
const DATA = path.resolve(p);
process.env.NY_DATA = DATA;
fs.mkdirSync(DATA, { recursive: true });
const cfgFile = path.join(DATA, 'config.json');
// 名单:5 人有答卷(与 mock 的 PARTNERS 90001-90005 对齐),再补 30 名无答卷者凑出 3 个班
const ROSTER = { students: [], updated_at: '2026-01-01 00:00:00' };
const back = [
['90001', '高一(1)班', 'A组'], ['90002', '高一(1)班', 'A组'], ['90003', '高一(1)班', 'B组'],
['90004', '高一(2)班', 'A组'], ['90005', '高一(2)班', 'B组'],
];
back.forEach(([sid, cls, grp]) => ROSTER.students.push({ sid, name: sid, cls, grp, login: '' }));
['高一(1)班', '高一(2)班', '高一(3)班'].forEach((cls, ci) => {
for (let i = 1; i <= 10; i++) {
const sid = String(9100 + ci * 100 + i);
ROSTER.students.push({ sid, name: sid, cls, grp: i % 2 ? 'A组' : 'B组', login: '' });
}
});
try { fs.unlinkSync(cfgFile); } catch (e) {}
// 起始配置:连的是本机 mock,账号留空(登录时再写入,与 test-login-loop 同口径)
fs.writeFileSync(cfgFile, JSON.stringify({
base_url: 'http://127.0.0.1:19911',
api_prefix: '/api',
username: '',
password: '',
verify_ssl: false,
timeout: 10,
refresh_interval: 15,
page_size: 50,
dedupe: true,
include_temp_save: false,
name_keywords: '姓名,名字',
full_score: 100,
layout: 'default',
}, null, 2));
fs.writeFileSync(path.join(DATA, 'roster.json'), JSON.stringify(ROSTER));
let cleanedUp = false, mock = null, srv = null;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
try { srv && srv.kill('SIGKILL'); } catch (e) {}
try { mock && mock.kill('SIGKILL'); } catch (e) {}
try { fs.rmSync(DATA, { recursive: true, force: true }); } catch (e) {}
}
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(1); });
process.on('uncaughtException', e => { console.error('FATAL', e); cleanup(); process.exit(2); });
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'ignore' });
srv = spawn(PHP, INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]), { cwd: ROOT, stdio: 'ignore' });
let pass = 0, fail = 0;
function chk(name, cond, extra) {
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
}
const cookieJar = [];
function collect(res) {
for (const sc of (res.headers['set-cookie'] || [])) {
const pair = sc.split(';')[0];
const name = pair.split('=')[0];
const i = cookieJar.findIndex(x => x.startsWith(name + '='));
if (i >= 0) cookieJar[i] = pair; else cookieJar.push(pair);
}
}
const jarHeader = () => (cookieJar.length ? cookieJar.map(c => c.replace(/;.*$/, '')).join('; ') : '');
// jar 省略 → 用当前登录会话;传 [] → 完全不带 Cookie 的匿名请求(验公开访问)
function get(p, jar) {
return new Promise((resolve, reject) => {
const h = { 'Accept': 'text/html,application/json' };
const ck = jar === undefined ? jarHeader()
: (jar || []).map(c => c.replace(/;.*$/, '')).join('; ');
if (ck) h['Cookie'] = ck;
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'GET', path: p, headers: h }, res => {
const bufs = [];
res.on('data', c => bufs.push(c));
res.on('end', () => {
// 匿名请求不回灌 Cookie:否则会用它自己的新会话覆盖掉登录会话
if (jar === undefined) collect(res);
resolve({ status: res.statusCode, raw: Buffer.concat(bufs).toString('utf8'), loc: res.headers.location });
});
});
r.on('error', reject);
r.end();
});
}
function post(p, fields) {
return new Promise((resolve, reject) => {
const data = new URLSearchParams(fields).toString();
const h = { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(data) };
if (jarHeader()) h['Cookie'] = jarHeader();
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path: p, headers: h }, res => {
const bufs = [];
res.on('data', c => bufs.push(c));
res.on('end', () => { collect(res); resolve({ status: res.statusCode, raw: Buffer.concat(bufs).toString('utf8') }); });
});
r.on('error', reject);
r.write(data); r.end();
});
}
const delay = ms => new Promise(r => setTimeout(r, ms));
const dashSrc = fs.readFileSync(path.join(ROOT, 'dashboard.php'), 'utf8');
const cssSrc = fs.readFileSync(path.join(ROOT, 'assets', 'style.css'), 'utf8');
(async () => {
await delay(2200);
console.log('\n--- 0) 登录 ---');
await post('/login.php', { username: MOCK.user, password: MOCK.pass });
const ac = await get('/api.php?action=auth_check');
chk('已登录', /"auth"\s*:\s*true/.test(ac.raw), ac.raw.slice(0, 120));
console.log('\n--- 1) 首页形态(无 ?class=)---');
const home = await get('/dashboard.php');
chk('首页返回 200', home.status === 200, 'status=' + home.status);
chk('body 带 dash-home、不带 dash-class',
/<body class="dash dash-home">/.test(home.raw) && !/dash dash-class/.test(home.raw),
(home.raw.match(/<body class="[^"]*"/) || [''])[0]);
chk('首页仍显示班级下拉(机构形态不变)', /id="classSelWrap"/.test(home.raw));
chk('首页保留「← 主系统 / 退出」入口(仅单班页隐藏)',
/id="linkIndex" href="index\.php"/.test(home.raw) && /id="linkLogout" href="logout\.php"/.test(home.raw));
chk('首页首屏提示为"先选考试再点班级"', /先选择一份问卷 \/ 考试,再点击班级卡片进入该班大屏/.test(home.raw));
chk('首页把 renderBoard 分流到 renderClassCards',
/if \(!IS_CLASS\) \{ renderClassCards\(\); return; \}/.test(dashSrc));
chk('首页自动刷新照旧可用(autoRefresh/intervalSel 都在)',
/id="autoRefresh"/.test(home.raw) && /id="intervalSel"/.test(home.raw) && /id="btnFullscreen"/.test(home.raw));
console.log('\n--- 2) 单班大屏形态(?pid=p1&class=高一(1)班)---');
const one = await get('/dashboard.php?pid=p1&class=' + encodeURIComponent('高一(1)班'));
chk('单班页返回 200', one.status === 200, 'status=' + one.status);
chk('body 带 dash-class、不带 dash-home',
/<body class="dash dash-class">/.test(one.raw) && !/dash dash-home/.test(one.raw),
(one.raw.match(/<body class="[^"]*"/) || [''])[0]);
chk('顶栏显示当前班级徽标', /id="heroClass"[^>]*>高一\(1\)班/.test(one.raw),
(one.raw.match(/id="heroClass"[^>]*/) || [''])[0]);
chk('页面内没有任何指向大屏首页的链接', !/href="dashboard\.php"/.test(one.raw));
chk('lockHistory() 在启动时被调起', /lockHistory\(\);/.test(dashSrc));
chk('后退锁定只在单班形态生效', /if \(!IS_CLASS \|\| historyLocked\) return;/.test(dashSrc));
chk('lockHistory 用 pushState 把本页重新推回栈顶',
/history\.pushState\(\{ nyLock: 1 \}, '', location\.href\)/.test(dashSrc) &&
/popstate/.test(dashSrc));
chk('首页形态不锁后退(IS_CLASS 为假时不 push)', !/if \(IS_CLASS(?![\s\S]{0,40}return;\})[\s\S]{0,40}pushState/.test(
dashSrc.slice(dashSrc.indexOf('function lockHistory'), dashSrc.indexOf('function lockHistory') + 400)));
console.log('\n--- 3) 顶栏控件收敛(CSS 隐藏规则)---');
chk('隐藏班级选择', /body\.dash-class #classSelWrap,\s*\n?\s*body\.dash-class #linkIndex \{ display: none !important; \}/.test(cssSrc));
chk('隐藏「← 主系统」', /body\.dash-class #linkIndex/.test(cssSrc));
chk('已不再靠 CSS 藏退出按钮(该形态下根本不渲染)', !/#linkLogout/.test(cssSrc));
chk('首页隐藏 5 张汇总卡', /body\.dash-home #boardSummary \{ display: none; \}/.test(cssSrc));
chk('单班页仍保留试卷/自动刷新/全屏三个控件',
/id="projectSel"/.test(one.raw) && /id="autoRefresh"/.test(one.raw) && /id="btnFullscreen"/.test(one.raw));
chk('单班页的「← 主系统」靠 CSS 隐藏',
/id="linkIndex"/.test(one.raw) && /display: none !important/.test(
cssSrc.slice(cssSrc.indexOf('body.dash-class #classSelWrap'), cssSrc.indexOf('body.dash-home #boardSummary'))));
chk('单班页(已登录)仍渲染退出按钮,由 CSS 隐藏', /id="linkLogout"/.test(one.raw));
console.log('\n--- 4) 班级卡片渲染(把 dashboard.php 里的 clsCardHtml 抽出来实跑)---');
const m = dashSrc.match(/function clsCardHtml\(c\)\{[\s\S]*?\n\}/);
chk('能抽出 clsCardHtml 函数体', !!m);
if (m) {
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const dv = (v, d) => (v === undefined || v === null || v === '' ? (d === undefined ? '-' : d) : v);
const pv = v => dv(v, '-') + (v === undefined || v === null || v === '' ? '' : '%');
const rateColor = r => (r >= 90 ? '#12a86a' : (r >= 60 ? '#e08c1a' : '#e0483d'));
const fn = new Function('esc', 'dv', 'pv', 'rateColor', m[0] + ' return clsCardHtml;')(esc, dv, pv, rateColor);
const html = fn({
class: '高一(1)班', total: 20, submitted: 3, absent: 17, rate: 15,
attempts: 5, avg: 78.5, pass_rate: 66.7,
});
chk('卡片带上班级名与进入入口', /data-cls="高一\(1\)班"/.test(html) && /cls-go/.test(html));
chk('卡片是按钮语义(role/tabindex)', /role="button"/.test(html) && /tabindex="0"/.test(html));
chk('5 项指标齐全', (html.match(/cls-kpi /g) || []).length === 5, '实际 ' + (html.match(/cls-kpi /g) || []).length + ' 项');
chk('提交率进度条按 rate 取值', /cls-bar"><i style="width:15%/.test(html), (html.match(/width:\d+%/) || [''])[0]);
chk('多次刷题徽标透传', /共 5 次刷题/.test(html));
chk('无成绩时的缺考数照常显示', /<span>缺考<\/span><b>17<\/b>/.test(html));
}
console.log('\n--- 5) 进入班级的跳转 ---');
const e = dashSrc.match(/function enterClass\(cls\)\{[\s\S]*?\n\}/);
chk('能抽出 enterClass 函数体', !!e);
if (e) {
chk('跳转时带上 pid + class + auto',
/searchParams\.set\('pid'/.test(e[0]) && /searchParams\.set\('class'/.test(e[0]) && /searchParams\.set\('auto'/.test(e[0]));
chk('是整页跳转(location.href,不是前端路由)', /location\.href = u\.pathname \+ u\.search;/.test(e[0]));
chk('卡片点击/回车都走事件委托(网格重绘不掉监听)',
/box\.addEventListener\('click'/.test(dashSrc) && /box\.addEventListener\('keydown'/.test(dashSrc) &&
/closest\('\.cls-card'\)/.test(dashSrc));
}
chk('首页卡片容器类名与样式一致(cls-grid)',
/<div class="cls-grid">/.test(dashSrc) && /\.cls-grid \{/.test(cssSrc));
console.log('\n--- 5.5) 成绩红绿口径(必须跟随后台配置的满分成数 / 合格率)---');
const sgSrc = dashSrc.match(/function scoreGrade\(sc\)\{[\s\S]*?\n\}/);
chk('能从 dashboard.php 抽出 scoreGrade', !!sgSrc);
if (sgSrc) {
// scoreGrade 里读的是脚本作用域的 DASH(let 声明,不在 window 上),
// 这里把 DASH 作为形参传进去,还原同一条作用域链
// eslint-disable-next-line no-new-func
const mk = new Function('DASH', sgSrc[0] + '; return scoreGrade;');
const g100 = mk({ full_score: 100, pass_rate: 60 });
const g150 = mk({ full_score: 150, pass_rate: 80 });
const gCur = mk({ full_score: 100, pass_rate: 95 });
chk('默认口径:95 绿 / 70 黄 / 50 红',
g100(95).cls === 'score-high' && g100(70).cls === 'score-mid' && g100(50).cls === 'score-low',
[95, 70, 50].map(v => v + '→' + g100(v).cls).join(' '));
// 后台把合格率改成 80、满分改成 150:100 分只占 66.7%,必须判红,不能再按 100 分制当成优秀
chk('后台改「满分 150 / 合格率 80%」后,100 分判红(不再按 100 分制当优秀)',
g150(100).cls === 'score-low', '100→' + g150(100).cls);
chk('同一配置下 130 分(86.7%)判黄、145 分(96.7%)判绿',
g150(130).cls === 'score-mid' && g150(145).cls === 'score-high',
[130, 145].map(v => v + '→' + g150(v).cls).join(' '));
// 合格率 85%:88 分合格但没到 90 分优秀线 → 黄
chk('合格率 85% 时 88 分判黄(合格但未达优秀线,绿线仍是 90)',
mk({ full_score: 100, pass_rate: 85 })(88).cls === 'score-mid',
'88→' + mk({ full_score: 100, pass_rate: 85 })(88).cls);
// 合格率 95%:合格线本身高过 90 分,绿线跟着抬到 95 —— 92/94 都不及格判红,96 判绿
chk('合格线高过 90% 时绿线跟着抬(合格率 95% → 92/94 判红、96 判绿)',
gCur(92).cls === 'score-low' && gCur(94).cls === 'score-low' && gCur(96).cls === 'score-high',
[92, 94, 96].map(v => v + '→' + gCur(v).cls).join(' '));
chk('缺成绩/越界分数不套样式',
g100(null).cls === '' && g100(undefined).cls === '' && g100('abc').cls === '');
chk('合格线按满分折算后回传(150×80% = 120)', g150(100).line === 120, 'line=' + g150(100).line);
chk('scoreGrade 不再通过 window.DASH 取值(let 声明取不到 window,只能读自由变量 DASH)',
!/window\.DASH/.test(sgSrc[0]));
chk('membersTable 也不再走 window.DASH',
!/window\.DASH/.test(dashSrc.slice(dashSrc.indexOf('function membersTable'), dashSrc.indexOf('function membersTable') + 1600)));
}
console.log('\n--- 6) 公开访问:班级大屏免登录 ---');
const CLS2 = encodeURIComponent('高一(2)班');
const anonPage = await get('/dashboard.php?pid=p1&class=' + CLS2, []);
chk('未登录访问班级大屏返回 200(不再被弹到登录页)',
anonPage.status === 200 && !anonPage.loc, 'status=' + anonPage.status + ' loc=' + (anonPage.loc || ''));
chk('匿名页同样是 dash-class 形态', /<body class="dash dash-class">/.test(anonPage.raw));
chk('公开页不再渲染退出按钮', !/id="linkLogout"/.test(anonPage.raw));
chk('公开页渲染「公开 · 无需登录」角标', /class="pub-tag"/.test(anonPage.raw));
chk('取数带 class(公开访问后端要按班级裁剪,两处调用都不能漏)',
(dashSrc.match(/api\('dashboard',\s*dashParams\(/g) || []).length === 2,
'实际 ' + (dashSrc.match(/api\('dashboard',\s*dashParams\(/g) || []).length + ' 处');
chk('dashParams 在单班形态下补充 class 参数',
/if \(IS_CLASS && CUR\.cls\) p\.class = CUR\.cls;/.test(dashSrc));
const anonApi = await get('/api.php?action=dashboard&projectId=p1&class=' + CLS2, []);
const aj = JSON.parse(anonApi.raw);
chk('未登录调用 dashboard 接口成功', aj.ok === true, anonApi.raw.slice(0, 120));
chk('classes 只回指定班级',
Array.isArray(aj.data.classes) && aj.data.classes.length === 1 && aj.data.classes[0].class === '高一(2)班',
'返回 ' + (aj.data.classes || []).map(x => x.class).join('/'));
chk('别班数据没有泄漏', !JSON.stringify(aj.data.classes).includes('高一(1)班'));
chk('overall 已换成该班汇总且键名兼容',
aj.data.overall && typeof aj.data.overall.attempts_total === 'number',
'attempts_total=' + (aj.data.overall || {}).attempts_total);
chk('overall 不含全卷名单总数', aj.data.overall && aj.data.overall.roster_total === aj.data.classes[0].total,
'roster_total=' + (aj.data.overall || {}).roster_total + ' class.total=' + (aj.data.classes[0] || {}).total);
chk('响应带 public 标记', aj.data.public === true);
const anonNoCls = await get('/api.php?action=dashboard&projectId=p1', []);
chk('未登录且不指定 class → 拒绝(不能拿全卷数据)',
JSON.parse(anonNoCls.raw).ok === false, anonNoCls.raw.slice(0, 120));
const homeAnon = await get('/dashboard.php', []);
chk('未登录访问大屏首页仍然跳转登录页',
/login\.php/.test(homeAnon.loc || ''), 'loc=' + (homeAnon.loc || ''));
console.log('\n通过 ' + pass + ' 项,失败 ' + fail + ' 项');
cleanup();
process.exit(fail ? 1 : 0);
})();
+186
View File
@@ -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
View File
@@ -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) {
+213
View File
@@ -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);
})();
+162
View File
@@ -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);