Compare commits

...
3 Commits
Author SHA1 Message Date
nxxy335 58f9c8433a chore: 移除误提交的 VitePress 临时文件并忽略之 2026-09-10 22:44:39 +08:00
nxxy335 a3be81b261 fix(security): 修复上传名单文件可被 URL 直连访问(nginx 下可 getshell)
严重漏洞:roster_import 使用 $_FILES 的原始扩展名拼 roster_YYYYMMDD_HHMMSS.<ext>
落在 Web 根目录内的 data/uploads/,既无扩展名白名单(可上传 .php),文件名又
可按秒枚举;而 bootstrap 只写了 data/.htaccess,nginx 不读 .htaccess,
生产环境(nginx + PHP-FPM)下可直接远程代码执行。

修复(api.php roster_import 四层防护):
- 扩展名白名单:仅 xlsx / xlsm / csv / txt
- 随机文件名:bin2hex(random_bytes(16)),杜绝枚举
- 解析完成后 @unlink 立即删除,Web 目录下不留存任何上传文件
- 20MB 大小限制 + UPLOAD_ERR 检查

加固(bootstrap.php):data 及 cache/uploads/snapshots 每个目录都写
.htaccess(Deny from all)+ index.html 占位;README 与文档补充 nginx
location ^~ /data/ { deny all; return 404; } 规则。

测试:新增 e2e tools/test-upload-guard.js(11 断言,覆盖 .php/.phtml 被拒、
无残留、合法 csv 导入成功且即删、无可枚举文件名),并入 tests/run-all.sh。
全套 90 项通过。

docs: 新增 VitePress 文档站(18 页)
- 使用指南 12 页:简介/部署/快速上手/系统设置/名单管理/统计口径/试卷设置/
  快照趋势异常/导出打印/数据看板/随机抽取/常见问题
- 安全 2 页:安全总览(含本次漏洞复盘)与部署加固清单(P0-P2)
- 开发 4 页:架构与目录/接口一览/SQLite 存储/测试
- 配图迁移至 docs/public/images/;.gitignore 忽略文档构建产物
2026-09-10 22:42:13 +08:00
nxxy335 157f257728 feat: 成绩明细打印 + SQLite + 每卷独立配置(满分/合格率/免考) + 导航精简
- 成绩明细打印:默认成绩降序,可选按班级/组别分组对比(段首小计)
- 修复看板退出全屏无反应(exitFullscreen 挂在 document 而非 documentElement)
- 导航栏精简:三页统一为单一主标题,移除 Logo 与副标题
- 优化三端布局:重排断点、竖排隐藏 spacer、触摸设备热区
- 全面启用 SQLite(data/ny.db):配置/名单/每卷配置入库,无 pdo_sqlite 自动降级文件
- 新增独立合格率配置(全局 + 每卷),合格率不再写死 60%
- 新增免考配置,仅对当前试卷生效,试卷间隔离
- 试卷独立满分从设置移出,改到主页面「试卷设置」
- 测试:新增 4 条每卷配置隔离断言,全套 113 项通过
2026-09-10 20:22:17 +08:00
41 changed files with 5731 additions and 227 deletions
+5
View File
@@ -5,5 +5,10 @@ data/
node_modules/ node_modules/
vendor/ vendor/
*.log *.log
# VitePress 文档站构建产物与缓存
docs/.vitepress/dist/
docs/.vitepress/cache/
docs/.vitepress/config.mjs.timestamp-*.mjs
.DS_Store .DS_Store
Thumbs.db Thumbs.db
+40 -8
View File
@@ -14,15 +14,44 @@
| 统计卡片 | 名单总人数、已提交、缺考、提交率、平均分、**合格率**(≥60% 满分)、最高分、最低分、平均用时;**选择班级后,全部卡片只统计该班级** | | 统计卡片 | 名单总人数、已提交、缺考、提交率、平均分、**合格率**(≥60% 满分)、最高分、最低分、平均用时;**选择班级后,全部卡片只统计该班级** |
| 缺考名单 | 名单中有、但未提交的人 | | 缺考名单 | 名单中有、但未提交的人 |
| 未匹配答卷 | 提交了但姓名不在名单中的答卷,方便排查 | | 未匹配答卷 | 提交了但姓名不在名单中的答卷,方便排查 |
| 名单导入 | 上传 `.xlsx` / `.csv`,自动识别「学号」「姓名」「班级」列(班级可选);支持覆盖 / 追加、手动增删改 | | 名单导入 | 上传 `.xlsx` / `.xlsm` / `.csv` / `.txt`,自动识别「学号」「姓名」「班级」列(班级可选);支持覆盖 / 追加、手动增删改 |
| 导出 | 成绩明细 CSV(学号/姓名/班级/成绩/提交时间/考试时长)、缺考名单 CSV(学号/姓名/班级) | | 导出 | 成绩明细 / 缺考名单 `.xlsx`(零依赖生成,支持多工作表、按最新成绩、按班级分表) |
| 排序 / 班级 | 点击表头排序,**成绩明细默认按提交时间降序**;支持学号/姓名/班级/成绩/提交时间/时长排序;按班级下拉筛选 | | 排序 / 班级 | 点击表头排序,**成绩明细默认按提交时间降序**;支持学号/姓名/班级/成绩/提交时间/时长排序;按班级下拉筛选 |
| 全屏 / 自适应 | 右上角「全屏」按钮进入全屏(布局自动铺满、表格更高);页面适配手机 / 平板 / 桌面三端 | | 全屏 / 自适应 | 右上角「全屏」按钮进入全屏(布局自动铺满、表格更高);页面适配手机 / 平板 / 桌面三端 |
## 环境要求 ## 环境要求
- PHP ≥ 8.0(需启用 `curl`、`json`、`mbstring`、`openssl`(可选,用于 RSA 加密密码);`zip` 可选,无 zip 扩展时会用内置解析器读 xlsx) - PHP ≥ 8.0(需启用 `curl`、`json`、`mbstring`、`openssl`(可选,用于 RSA 加密密码);`zip` 可选,无 zip 扩展时会用内置解析器读 xlsx)
- 无需数据库、无需 Composer 依赖,数据以 JSON 存放在 `data/` - 推荐启用 `pdo_sqlite`(用于 SQLite 存储);未启用时自动降级为 JSON 文件存储
- 无需数据库服务、无需 Composer 依赖
## 文档
完整文档见 `docs/`(VitePress):
```bash
cd docs
npm install
npm run docs:dev # 本地预览
npm run docs:build # 构建静态站到 docs/.vitepress/dist
```
## 安全
- 登录凭据即卷王后台账密;连续 5 次失败锁定 10 分钟
- 所有写接口强制校验会话绑定的 CSRF 令牌
- 上传文件:扩展名白名单(`.xlsx` / `.xlsm` / `.csv` / `.txt`)+ 随机文件名 + **解析后立即删除**
- **部署时必须屏蔽 `data/` 目录**,否则配置、名单、卷王账密可被直接下载:
```nginx
# nginx 不读 .htaccess,必须显式配置
location ^~ /data/ {
deny all;
return 404;
}
```
详见 `docs/security/hardening.md`。
## 部署 ## 部署
@@ -76,7 +105,8 @@ Windows 也可双击 `启动平台.bat`。
- **平台启动时不预设任何名单**,请通过右上角 **名单管理** 上传你自己的名单(`.xlsx` / `.csv`)。 - **平台启动时不预设任何名单**,请通过右上角 **名单管理** 上传你自己的名单(`.xlsx` / `.csv`)。
- 名单文件可包含「学号」「姓名」两列,**「班级」为可选列**;含班级时,成绩明细会显示班级列并支持按班级筛选(一份考试涉及多个班级时很实用)。 - 名单文件可包含「学号」「姓名」两列,**「班级」为可选列**;含班级时,成绩明细会显示班级列并支持按班级筛选(一份考试涉及多个班级时很实用)。
- 上传方式:名单管理 → 点击或拖拽文件 → 选择覆盖 / 追加 → **保存名单**;也可手动增 / 删 / 改。 - 上传方式:名单管理 → 点击或拖拽文件 → 选择覆盖 / 追加 → **保存名单**;也可手动增 / 删 / 改。
- 名单数据保存在 `data/roster.json`。 - 上传文件仅用于解析,落盘后**立即删除**,不会残留在 `data/uploads/`。
- 名单数据保存在 SQLite `data/ny.db`(无 `pdo_sqlite` 时回落到 `data/roster.json`)。
## 目录结构 ## 目录结构
@@ -91,16 +121,18 @@ ny-exam-platform/
│ ├─ Roster.php 学号名单管理(xlsx/csv 导入与持久化) │ ├─ Roster.php 学号名单管理(xlsx/csv 导入与持久化)
│ └─ XlsxReader.php 零依赖 XLSX 读取器 │ └─ XlsxReader.php 零依赖 XLSX 读取器
├─ assets/ 前端样式与脚本 ├─ assets/ 前端样式与脚本
├─ data/ 配置、名单、令牌缓存(需可写) ├─ data/ 配置、名单、SQLite 库、令牌缓存(需可写,且必须禁止 Web 直接访问)
└─ tools/ 开发辅助(PHP 语法校验脚本) ├─ docs/ VitePress 文档站
├─ tests/ PHP 自测 + 一键运行脚本
└─ tools/ 开发辅助(mock 服务、e2e 测试)
``` ```
## 统计口径与显示说明 ## 统计口径与显示说明
- **班级联动统计**:顶部「班级」下拉选择某一班级后,统计卡片(总人数 / 已提交 / 缺考 / 提交率 / 平均分 / 合格率 / 最高分 / 最低分 / 平均用时)与成绩明细、缺考名单都只统计该班级;选「全部班级」则统计整体。切换班级在本地即时重算,不额外请求服务器。 - **班级联动统计**:顶部「班级」下拉选择某一班级后,统计卡片(总人数 / 已提交 / 缺考 / 提交率 / 平均分 / 合格率 / 最高分 / 最低分 / 平均用时)与成绩明细、缺考名单都只统计该班级;选「全部班级」则统计整体。切换班级在本地即时重算,不额外请求服务器。
- **面板计数随班级**:选择班级后,成绩明细标题右侧的「共 X 人已提交 · 名单 X 人」、缺考名单标题右侧的「共 X 人」也都只统计该班级(不再用全部班级的数据)。 - **面板计数随班级**:选择班级后,成绩明细标题右侧的「共 X 人已提交 · 名单 X 人」、缺考名单标题右侧的「共 X 人」也都只统计该班级(不再用全部班级的数据)。
- **每套试卷独立满分**:不同试卷满分不同,可在「设置 → 满分」中按当前选中的试卷单独设置,保存后仅作用于该试卷;未选中试卷时设置的满分作为所有试卷的默认值。合格率按「成绩 ≥ 满分 × 60%」计算。 - **每套试卷独立满分 / 合格率 / 免考**:在主页面「试卷设置」里为**当前选中的试卷**单独设置,试卷之间互相隔离;未设置时回落到「设置」里的全局默认值。
- **合格率**:成绩 ≥ 满分 × 60% 视为合格(满分见上)。 - **合格率**:成绩 ≥ 满分 × 合格率阈值 视为合格;阈值全局默认 60%,可在「试卷设置」里按卷覆盖,卡片标题会实时显示当前阈值。
- **平均用时不换行**:平均用时(如「9 分 55 秒」)强制单行显示,避免「秒」字换行。 - **平均用时不换行**:平均用时(如「9 分 55 秒」)强制单行显示,避免「秒」字换行。
- **成绩明细不再显示每人的「状态」列**:缺考人员统一在「缺考名单」面板查看,成绩明细只保留学号 / 姓名 / 班级 / 成绩 / 提交时间 / 考试时长。 - **成绩明细不再显示每人的「状态」列**:缺考人员统一在「缺考名单」面板查看,成绩明细只保留学号 / 姓名 / 班级 / 成绩 / 提交时间 / 考试时长。
- **新增数据标记**:每次刷新后,相较上一次新出现的提交(按学号判断)整行显示绿色底纹,方便一眼看出谁刚交卷;首次加载不标记。 - **新增数据标记**:每次刷新后,相较上一次新出现的提交(按学号判断)整行显示绿色底纹,方便一眼看出谁刚交卷;首次加载不标记。
+103 -31
View File
@@ -128,7 +128,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', 'snapshot_save', 'snapshot_delete', '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);
@@ -194,9 +194,11 @@ try {
$v = (string)$in['export_sort']; $v = (string)$in['export_sort'];
$new['export_sort'] = in_array($v, ['time','sid','score_desc','score_asc'], true) ? $v : 'time'; $new['export_sort'] = in_array($v, ['time','sid','score_desc','score_asc'], true) ? $v : 'time';
} }
// 各试卷独立满分(覆盖整个 full_scores 映射) // 说明:「各试卷独立满分」已移出设置,改为在主页面针对当前试卷配置
if (array_key_exists('full_scores', $in) && is_array($in['full_scores'])) { // (project_config / project_config_save),设置里只保留「全局默认满分」。
$new['full_scores'] = $in['full_scores']; 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['base_url'] = rtrim((string)$new['base_url'], '/');
$new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/'); $new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/');
@@ -211,18 +213,6 @@ try {
$fs = 100; $fs = 100;
} }
$new['full_score'] = $fs; $new['full_score'] = $fs;
// 各试卷独立满分:仅当显式指定 apply_pid 时才写入/清除该卷(修复 bug:之前会误用默认满分覆盖当前卷)
$applyPid = trim((string)($in['apply_pid'] ?? ''));
if ($applyPid !== '') {
$scores = is_array($new['full_scores'] ?? null) ? $new['full_scores'] : [];
$applyScore = (int)($in['apply_score'] ?? 0);
if ($applyScore > 0) {
$scores[$applyPid] = $applyScore;
} else {
unset($scores[$applyPid]);
}
$new['full_scores'] = $scores;
}
// 仅当连接信息真的变了才清 token + 重连;保存满分/布局等不应触发登录卷王 // 仅当连接信息真的变了才清 token + 重连;保存满分/布局等不应触发登录卷王
$connKeys = ['base_url', 'api_prefix', 'username', 'password', 'verify_ssl']; $connKeys = ['base_url', 'api_prefix', 'username', 'password', 'verify_ssl'];
$connChanged = false; $connChanged = false;
@@ -329,15 +319,13 @@ try {
$c = cfg(); $c = cfg();
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force'])); $built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
$result = $built['result']; $result = $built['result'];
$fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : []; // 满分:本卷独立优先,其次全局默认
$fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100); [$fs, $passRate] = ny_resolve_score_cfg($projectId, $c);
if ($fs < 1) {
$fs = 100;
}
$result['config'] = [ $result['config'] = [
'refresh_interval' => (int)$c['refresh_interval'], 'refresh_interval' => (int)$c['refresh_interval'],
'base_url' => $c['base_url'], 'base_url' => $c['base_url'],
'full_score' => $fs, 'full_score' => $fs,
'pass_rate' => $passRate,
]; ];
// 缓存来源标记:便于前端/排查时知道这次是实时拉取还是命中短期缓存 // 缓存来源标记:便于前端/排查时知道这次是实时拉取还是命中短期缓存
$result['_cache'] = ['cached' => $built['cached'], 'at' => $built['at']]; $result['_cache'] = ['cached' => $built['cached'], 'at' => $built['at']];
@@ -353,11 +341,7 @@ try {
$c = cfg(); $c = cfg();
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force'])); $built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
$result = $built['result']; $result = $built['result'];
$fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : []; [$fs, $passRate] = ny_resolve_score_cfg($projectId, $c);
$fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100);
if ($fs < 1) {
$fs = 100;
}
$students = $result['roster'] ?: []; $students = $result['roster'] ?: [];
$rows = $result['rows'] ?: []; $rows = $result['rows'] ?: [];
@@ -374,13 +358,14 @@ try {
} }
// 通用统计器:给定学生集合 + 答卷集合,算一组指标 // 通用统计器:给定学生集合 + 答卷集合,算一组指标
$statOf = static function (array $stu, array $rs, int $fs): array { // 合格率阈值可配(全局 pass_rate 或本卷独立),不再写死 60%
$statOf = static function (array $stu, array $rs, int $fs) use ($passRate): array {
$total = count($stu); $total = count($stu);
$submitted = count($rs); $submitted = count($rs);
$attempts = array_sum(array_map(static fn($r) => (int)($r['attempts'] ?? 1), $rs)); $attempts = array_sum(array_map(static fn($r) => (int)($r['attempts'] ?? 1), $rs));
$scores = array_values(array_filter(array_map(static fn($r) => $r['score'] ?? null, $rs), static fn($v) => $v !== null)); $scores = array_values(array_filter(array_map(static fn($r) => $r['score'] ?? null, $rs), static fn($v) => $v !== null));
$avg = $scores !== [] ? round(array_sum($scores) / count($scores), 2) : null; $avg = $scores !== [] ? round(array_sum($scores) / count($scores), 2) : null;
$pass = count(array_filter($rs, static fn($r) => ($r['score'] ?? null) !== null && ($r['score'] >= 0.6 * $fs))); $pass = count(array_filter($rs, static fn($r) => ($r['score'] ?? null) !== null && ($r['score'] >= ($passRate / 100) * $fs)));
$passRate = $submitted > 0 ? round($pass / $submitted * 100, 1) : 0; $passRate = $submitted > 0 ? round($pass / $submitted * 100, 1) : 0;
$durs = array_values(array_filter(array_map(static fn($r) => $r['duration'] ?? null, $rs), static fn($v) => $v !== null)); $durs = array_values(array_filter(array_map(static fn($r) => $r['duration'] ?? null, $rs), static fn($v) => $v !== null));
$avgDur = $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null; $avgDur = $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null;
@@ -473,6 +458,7 @@ try {
ny_ok([ ny_ok([
'project' => $result['project'], 'project' => $result['project'],
'full_score' => $fs, 'full_score' => $fs,
'pass_rate' => $passRate,
'overall' => $result['stats'], 'overall' => $result['stats'],
'classes' => $classes, 'classes' => $classes,
'updated_at' => $result['updated_at'], 'updated_at' => $result['updated_at'],
@@ -486,9 +472,25 @@ try {
if (empty($_FILES['file']) || !is_uploaded_file($_FILES['file']['tmp_name'])) { if (empty($_FILES['file']) || !is_uploaded_file($_FILES['file']['tmp_name'])) {
ny_fail('请选择要导入的文件'); ny_fail('请选择要导入的文件');
} }
$f = $_FILES['file']; $f = $_FILES['file'];
$ext = strtolower(pathinfo((string)$f['name'], PATHINFO_EXTENSION)); $err = (int)($f['error'] ?? UPLOAD_ERR_OK);
$dest = NY_DATA . '/uploads/roster_' . date('Ymd_His') . '.' . ($ext !== '' ? $ext : 'dat'); if ($err !== UPLOAD_ERR_OK) {
ny_fail('上传失败(错误码 ' . $err . ')');
}
if ((int)($f['size'] ?? 0) > 20 * 1024 * 1024) {
ny_fail('文件过大(上限 20MB)');
}
// 扩展名白名单:杜绝上传 .php/.phtml 等可执行后缀到 Web 目录
$ext = strtolower(pathinfo((string)$f['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ['xlsx', 'xlsm', 'csv', 'txt'], true)) {
ny_fail('仅支持 .xlsx / .xlsm / .csv / .txt 文件');
}
// 随机文件名:杜绝按时间枚举(原 roster_YYYYMMDD_HHMMSS.ext 可被遍历)
$dir = NY_DATA . '/uploads';
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
$dest = $dir . '/' . bin2hex(random_bytes(16)) . '.' . $ext;
if (!@move_uploaded_file($f['tmp_name'], $dest)) { if (!@move_uploaded_file($f['tmp_name'], $dest)) {
ny_fail('文件保存失败,请检查 data/uploads 目录权限'); ny_fail('文件保存失败,请检查 data/uploads 目录权限');
} }
@@ -498,6 +500,8 @@ try {
@unlink($dest); @unlink($dest);
ny_fail($e->getMessage()); ny_fail($e->getMessage());
} }
// 解析完立即删除:Web 目录下不再留存任何上传文件
@unlink($dest);
$mode = (string)($_POST['mode'] ?? 'replace'); $mode = (string)($_POST['mode'] ?? 'replace');
if ($mode === 'append') { if ($mode === 'append') {
$merged = array_merge(Roster::all(), $res['students']); $merged = array_merge(Roster::all(), $res['students']);
@@ -632,6 +636,74 @@ try {
} }
ny_ok(Snapshot::trend($snaps)); ny_ok(Snapshot::trend($snaps));
// ---------------- 试卷独立配置(满分 / 合格率 / 免考名单)——仅对当前试卷生效 ----------------
case 'project_config':
$projectId = (string)($_GET['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
$c = cfg();
[$fs, $pr] = ny_resolve_score_cfg($projectId, $c);
$pc = DataService::projectConfig($projectId);
ny_ok([
'project_id' => $projectId,
'cfg' => $pc,
// 解析后的生效值(前端回显用;null 表示"跟随全局")
'effective' => ['full_score' => $fs, 'pass_rate' => $pr],
'global' => [
'full_score' => (int)($c['full_score'] ?? 100),
'pass_rate' => (float)($c['pass_rate'] ?? 60),
],
]);
case 'project_config_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;
}
$old = DataService::projectConfig($projectId);
// 满分:留空/0 = 清除(跟随全局)
$fsIn = $in['full_score'] ?? null;
$fs = ($fsIn === null || $fsIn === '' || (int)$fsIn < 1) ? null : (int)$fsIn;
// 合格率:留空 = 清除(跟随全局);否则夹到 1~100
$prIn = $in['pass_rate'] ?? null;
$pr = ($prIn === null || $prIn === '') ? null : max(1, min(100, (float)$prIn));
// 免考名单:支持数组或逗号/换行分隔的字符串
$exIn = $in['exempt'] ?? null;
$exempt = [];
if (is_array($exIn)) {
foreach ($exIn as $v) {
$v = trim((string)$v);
if ($v !== '') {
$exempt[] = $v;
}
}
} elseif (is_string($exIn) && trim($exIn) !== '') {
foreach (preg_split('/[,,\s]+/u', trim($exIn)) ?: [] as $v) {
$v = trim($v);
if ($v !== '') {
$exempt[] = $v;
}
}
}
$new = [
'full_score' => $fs,
'pass_rate' => $pr,
'exempt' => array_values(array_unique($exempt)),
];
DataService::projectConfigSave($projectId, $new);
$c2 = cfg();
[$effFs, $effPr] = ny_resolve_score_cfg($projectId, $c2);
ny_ok([
'cfg' => $new,
'effective' => ['full_score' => $effFs, 'pass_rate' => $effPr],
]);
// ---------------- 导出(支持按班级 / 组别筛选,与页面当前筛选保持一致) ---------------- // ---------------- 导出(支持按班级 / 组别筛选,与页面当前筛选保持一致) ----------------
case 'export': case 'export':
case 'export_absent': case 'export_absent':
+185 -48
View File
@@ -261,6 +261,7 @@
state.roster = d.roster || []; state.roster = d.roster || [];
state.anomalies = d.anomalies || { fast_high: [], frequent: [], identical: [] }; state.anomalies = d.anomalies || { fast_high: [], frequent: [], identical: [] };
state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100; state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100;
state.passRate = (d.config && d.config.pass_rate != null) ? (parseFloat(d.config.pass_rate) || 60) : 60;
// 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据" // 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据"
// (纳入 attempts 与 last_submit_ts 才能发现"重考但分数更低"这种提交) // (纳入 attempts 与 last_submit_ts 才能发现"重考但分数更低"这种提交)
@@ -456,7 +457,8 @@
const avg = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 100) / 100 : null; const avg = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 100) / 100 : null;
const full = state.fullScore || 100; const full = state.fullScore || 100;
const passCount = rows.filter(r => r.score !== null && r.score !== undefined && r.score >= 0.6 * full).length; const pr = (state.passRate == null ? 60 : state.passRate); // 合格率阈值可配
const passCount = rows.filter(r => r.score !== null && r.score !== undefined && r.score >= (pr / 100) * full).length;
const passRate = submitted > 0 ? Math.round(passCount / submitted * 1000) / 10 : 0; const passRate = submitted > 0 ? Math.round(passCount / submitted * 1000) / 10 : 0;
let maxScore = null, minScore = null; let maxScore = null, minScore = null;
@@ -490,6 +492,9 @@
set('#sMax', st.maxScore == null ? '-' : st.maxScore); set('#sMax', st.maxScore == null ? '-' : st.maxScore);
set('#sMin', st.minScore == null ? '-' : st.minScore); set('#sMin', st.minScore == null ? '-' : st.minScore);
set('#sPass', st.passRate + '%'); set('#sPass', st.passRate + '%');
// 合格率阈值可配(全局或本卷独立),卡片标题实时反映当前生效值
const lbl = $('#sPassLabel');
if (lbl) lbl.textContent = '(≥' + (state.passRate == null ? 60 : state.passRate) + '%满分)';
set('#sDur', st.avgDur == null ? '-' : fmtDur(st.avgDur)); set('#sDur', st.avgDur == null ? '-' : fmtDur(st.avgDur));
} }
@@ -854,10 +859,20 @@
const el = document.documentElement; const el = document.documentElement;
if (!document.fullscreenElement && !document.webkitFullscreenElement) { if (!document.fullscreenElement && !document.webkitFullscreenElement) {
const req = el.requestFullscreen || el.webkitRequestFullscreen; const req = el.requestFullscreen || el.webkitRequestFullscreen;
if (req) req.call(el); if (req) {
const p = req.call(el);
// 部分浏览器返回 Promise,被拒绝时静默吞掉会让用户以为"点了没反应"
if (p && typeof p.catch === 'function') {
p.catch(() => { showAlert('当前浏览器/环境不允许进入全屏,请按 F11 手动切换', 'warn'); });
}
}
} else { } else {
// exitFullscreen 挂在 document 上(documentElement.exitFullscreen 恒为 undefined)
const exit = document.exitFullscreen || document.webkitExitFullscreen; const exit = document.exitFullscreen || document.webkitExitFullscreen;
if (exit) exit.call(document); if (exit) {
const p = exit.call(document);
if (p && typeof p.catch === 'function') p.catch(() => {});
}
} }
} }
function onFsChange() { function onFsChange() {
@@ -890,9 +905,9 @@
// 布局 // 布局
const layout = c.layout || LS.get(LS_LAYOUT, 'default'); const layout = c.layout || LS.get(LS_LAYOUT, 'default');
(document.querySelector('input[name=cfgLayout][value="' + layout + '"]') || {}).checked = true; (document.querySelector('input[name=cfgLayout][value="' + layout + '"]') || {}).checked = true;
// 满分:默认 + 各试卷独立 // 全局默认满分 / 合格率(每卷的独立配置移到主页面「试卷设置」)
$('#cfgFull').value = c.full_score || 100; $('#cfgFull').value = c.full_score || 100;
renderFullScores((c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {}); if ($('#cfgPassRate')) $('#cfgPassRate').value = (c.pass_rate == null ? 60 : c.pass_rate);
// 历史成绩展示方式 + 导出排序 // 历史成绩展示方式 + 导出排序
state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click'; state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click';
state.exportSort = (c.export_sort === 'sid') ? 'sid' : ((c.export_sort === 'score_desc') ? 'score_desc' : ((c.export_sort === 'score_asc') ? 'score_asc' : 'time')); state.exportSort = (c.export_sort === 'sid') ? 'sid' : ((c.export_sort === 'score_desc') ? 'score_desc' : ((c.export_sort === 'score_asc') ? 'score_asc' : 'time'));
@@ -914,39 +929,6 @@
$('#settingsModal').classList.add('open'); $('#settingsModal').classList.add('open');
} }
// 渲染"各试卷独立满分"列表
// 注意:#fullScoresHint 必须位于 #fullScoresBox 之外(见 index.php),
// 否则 box.innerHTML 覆盖会销毁它,下面的 textContent 赋值会抛 "Cannot set properties of null"。
// 这里仍保留 null 守卫,避免任何 DOM 结构变动导致整个设置弹窗打不开。
function setFullScoresHint(text) {
const hint = $('#fullScoresHint');
if (hint) hint.textContent = text;
}
function renderFullScores(scores) {
const box = $('#fullScoresBox');
if (!box) return;
if (!state.projects.length) {
box.innerHTML = '<span class="hint">暂无试卷,请先连接卷王系统并选择问卷</span>';
setFullScoresHint('给某卷填入数字即可单独覆盖默认满分;留空=用默认');
return;
}
// 排除文件夹项:模式为文件夹的无可答卷
const surveys = state.projects.filter(p => !((p.mode || '').toLowerCase().includes('folder')));
if (!surveys.length) {
box.innerHTML = '<span class="hint">尚未获取到可答卷</span>';
setFullScoresHint('给某卷填入数字即可单独覆盖默认满分;留空=用默认');
return;
}
box.innerHTML = surveys.map(p => {
const val = scores[p.id] != null ? scores[p.id] : '';
return '<div class="row">' +
'<label title="' + esc(p.name) + '">' + esc(p.name) + '</label>' +
'<input type="number" min="1" max="1000" data-pid="' + esc(p.id) + '" value="' + esc(val) + '" placeholder="默认">' +
'</div>';
}).join('');
setFullScoresHint('默认分值为空时使用上方"默认满分";填写即单独覆盖该卷');
}
/* ---------------- 名单 ---------------- */ /* ---------------- 名单 ---------------- */
let rosterDraft = []; let rosterDraft = [];
const rosterSel = new Set(); // 勾选待删除的行下标(对应 rosterDraft 索引) const rosterSel = new Set(); // 勾选待删除的行下标(对应 rosterDraft 索引)
@@ -1163,8 +1145,11 @@
include_temp_save: $('#cfgTemp').checked, include_temp_save: $('#cfgTemp').checked,
verify_ssl: $('#cfgSsl').checked, verify_ssl: $('#cfgSsl').checked,
full_score: parseInt($('#cfgFull').value, 10) || 100, full_score: parseInt($('#cfgFull').value, 10) || 100,
pass_rate: (function () {
const v = $('#cfgPassRate') ? parseFloat($('#cfgPassRate').value) : NaN;
return (isNaN(v) || v < 1 || v > 100) ? 60 : v;
})(),
layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default', layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default',
full_scores: collectFullScores(),
username: $('#cfgUser').value.trim(), username: $('#cfgUser').value.trim(),
history_display: $('#cfgHistoryDisplay').value, history_display: $('#cfgHistoryDisplay').value,
export_sort: $('#cfgExportSort').value, export_sort: $('#cfgExportSort').value,
@@ -1245,15 +1230,6 @@
$('#btnTestConn').addEventListener('click', testConnection); $('#btnTestConn').addEventListener('click', testConnection);
// 收集"各试卷独立满分" -> {projectId: score} // 收集"各试卷独立满分" -> {projectId: score}
function collectFullScores() {
const out = {};
$$('#fullScoresBox input[data-pid]').forEach(inp => {
const v = parseInt(inp.value, 10);
if (inp.dataset.pid && !isNaN(v) && v > 0) out[inp.dataset.pid] = v;
});
return out;
}
/* 名单上传 */ /* 名单上传 */
const zone = $('#dropZone'), fileInput = $('#rosterFile'); const zone = $('#dropZone'), fileInput = $('#rosterFile');
zone.addEventListener('click', () => fileInput.click()); zone.addEventListener('click', () => fileInput.click());
@@ -1503,6 +1479,167 @@
$('#trendModal').classList.add('open'); $('#trendModal').classList.add('open');
}); });
/* 成绩明细打印:默认按成绩降序,可选按班级/组别分组对比 */
function printTable() {
const rows = (state._sorted || []).slice();
if (!rows.length) return showAlert('当前没有可打印的成绩', 'warn');
const sortMode = $('#ptSort').value;
const groupBy = $('#ptGroup').value; // none | cls | grp
const showRank = $('#ptRank').checked;
const showAtt = $('#ptAttempts').checked;
const full = state.fullScore || 100;
const passRate = (state.passRate == null ? 60 : state.passRate);
// 排序(打印独立于页面排序,默认成绩降序;null 成绩恒排末尾)
const cmp = (a, b) => {
const an = (a.score == null), bn = (b.score == null);
if (an && bn) return 0;
if (an) return 1;
if (bn) return -1;
if (sortMode === 'score_desc') return (b.score - a.score) || ((b.submit_ts || 0) - (a.submit_ts || 0));
if (sortMode === 'score_asc') return (a.score - b.score) || ((b.submit_ts || 0) - (a.submit_ts || 0));
if (sortMode === 'sid') return String(a.sid).localeCompare(String(b.sid), 'zh', { numeric: true });
return ((b.submit_ts || 0) - (a.submit_ts || 0)); // time
};
rows.sort(cmp);
const passOf = r => (r.score != null && r.score >= (passRate / 100) * full);
const statOf = list => {
const sc = list.map(r => r.score).filter(v => v != null);
const avg = sc.length ? (sc.reduce((x, y) => x + y, 0) / sc.length) : null;
const pass = list.filter(passOf).length;
return {
n: list.length,
avg: avg == null ? '—' : avg.toFixed(1),
passRate: list.length ? Math.round(pass / list.length * 1000) / 10 : 0,
};
};
const head = '<tr><th>' + (showRank ? '名次' : '') + '</th><th>学号</th><th>姓名</th>' +
(groupBy === 'none' ? '<th>班级</th><th>组别</th>' : '') +
'<th>成绩</th>' + (showAtt ? '<th>考试次数</th>' : '') + '<th>提交时间</th></tr>';
let html = '';
let rank = 0;
const renderRows = (list, offsetRank) => list.map((r, i) => {
const rk = (r.score == null) ? '' : (offsetRank + i + 1);
return '<tr>' + (showRank ? '<td class="c">' + rk + '</td>' : '') +
'<td>' + esc(r.sid || '') + '</td>' +
'<td>' + esc(r.name || '') + '</td>' +
(groupBy === 'none' ? '<td>' + esc(r.cls || '') + '</td><td>' + esc(r.grp || '') + '</td>' : '') +
'<td class="c"><b>' + (r.score == null ? '—' : r.score) + '</b></td>' +
(showAtt ? '<td class="c">' + (r.attempts || 1) + '</td>' : '') +
'<td>' + esc(r.submit_at || '') + '</td></tr>';
}).join('');
if (groupBy === 'none') {
html = '<table><thead>' + head + '</thead><tbody>' + renderRows(rows, 0) + '</tbody></table>';
} else {
const keyOf = (groupBy === 'cls') ? (r => (r.cls || '未分班')) : (r => (r.grp || '未分组'));
const groups = {};
rows.forEach(r => { const k = keyOf(r); (groups[k] = groups[k] || []).push(r); });
Object.keys(groups).sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true }))
.forEach(k => {
const list = groups[k];
const st = statOf(list);
html += '<h3 class="grp">' + esc(k) +
' <small>(' + st.n + ' 人 · 平均 ' + st.avg + ' · 合格率 ' + st.passRate + '%)</small></h3>';
html += '<table><thead>' + head + '</thead><tbody>' + renderRows(list, 0) + '</tbody></table>';
});
}
const all = statOf(rows);
const title = (state.projectName ? state.projectName + ' · ' : '') + '成绩明细';
const cls = $('#clsSel').value, grp = $('#grpSel').value;
const scope = (cls ? cls + '班 ' : '') + (grp ? grp + '组' : '');
const w = window.open('', '_blank');
if (!w) return showAlert('浏览器拦截了弹窗,请允许后重试', 'error');
w.document.write('<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>' + esc(title) + '</title>' +
'<style>' +
'body{font-family:"Microsoft YaHei",sans-serif;margin:20px;color:#000}' +
'h2{margin:0 0 4px;font-size:18px}' +
'.meta{font-size:12px;color:#555;margin-bottom:14px}' +
'h3.grp{font-size:14px;margin:18px 0 6px;padding:6px 8px;background:#f0f0f0;border-left:4px solid #333}' +
'h3.grp small{font-weight:400;color:#555}' +
'table{border-collapse:collapse;width:100%;margin-bottom:10px;font-size:12px}' +
'th,td{border:1px solid #666;padding:5px 8px;text-align:left}' +
'th{background:#e8e8e8;font-weight:700}' +
'td.c{text-align:center}' +
'@media print{body{margin:0}h3.grp{page-break-after:avoid}table{page-break-inside:auto}tr{page-break-inside:avoid}}' +
'</style></head><body>' +
'<h2>' + esc(title) + '</h2>' +
'<div class="meta">' + esc(scope) + ' 共 ' + all.n + ' 人 · 平均 ' + all.avg +
' · 合格率 ' + all.passRate + '%(合格线:满分的 ' + passRate + '%,即 ' +
(Math.round(passRate / 100 * full * 10) / 10) + ' 分) · 打印时间:' + new Date().toLocaleString() + '</div>' +
html +
'<scr' + 'ipt>window.onload=function(){setTimeout(function(){window.print()},300)}</scr' + 'ipt>' +
'</body></html>');
w.document.close();
}
$('#btnPrintTable').addEventListener('click', () => {
if (!curPid()) return showAlert('请先选择问卷 / 考试');
const n = (state._sorted || []).length;
$('#ptHint').textContent = n ? ('当前将打印 ' + n + ' 条记录') : '暂无数据';
$('#printTableModal').classList.add('open');
});
$('#btnPtPrint').addEventListener('click', () => {
$('#printTableModal').classList.remove('open');
printTable();
});
/* 试卷独立设置(满分 / 合格率 / 免考名单)——仅对当前选中试卷生效 */
async function openProjCfg() {
const pid = curPid();
if (!pid) return showAlert('请先选择问卷 / 考试');
const pname = (($('#projectSel').selectedOptions && $('#projectSel').selectedOptions[0]) || {}).textContent || '';
$('#projCfgSub').textContent = pname ? ('· ' + pname) : '';
try {
const d = await api('project_config', { params: { projectId: pid } });
const cfg = d.cfg || {}, eff = d.effective || {}, g = d.global || {};
$('#pcFull').value = (cfg.full_score == null ? '' : cfg.full_score);
$('#pcPassRate').value = (cfg.pass_rate == null ? '' : cfg.pass_rate);
$('#pcExempt').value = (cfg.exempt || []).join('\n');
$('#pcFullHint').textContent = '当前生效:' + (eff.full_score || '-') +
'(全局默认 ' + (g.full_score || '-') + ')';
$('#pcPassHint').textContent = '当前生效:' + (eff.pass_rate || '-') +
'%(全局默认 ' + (g.pass_rate || '-') + '%)';
} catch (e) { showAlert(e.message); return; }
$('#projCfgModal').classList.add('open');
}
$('#btnProjCfg').addEventListener('click', openProjCfg);
$('#btnPcSave').addEventListener('click', async () => {
const pid = curPid();
if (!pid) return showAlert('请先选择问卷 / 考试');
try {
await api('project_config_save', { method: 'POST', params: { projectId: pid }, body: {
full_score: $('#pcFull').value.trim(),
pass_rate: $('#pcPassRate').value.trim(),
exempt: $('#pcExempt').value,
}});
$('#projCfgModal').classList.remove('open');
showAlert('本卷设置已保存(仅对当前试卷生效)', 'info');
refresh(false, true); // 免考会改变名单/缺考口径,需强制重拉
} catch (e) { showAlert(e.message); }
});
$('#btnPcReset').addEventListener('click', async () => {
const pid = curPid();
if (!pid) return;
if (!confirm('确定清除本卷的全部独立设置(恢复跟随全局默认)?')) return;
try {
await api('project_config_save', { method: 'POST', params: { projectId: pid }, body: {
full_score: '', pass_rate: '', exempt: '',
}});
$('#projCfgModal').classList.remove('open');
showAlert('本卷设置已清除', 'info');
refresh(false, true);
} catch (e) { showAlert(e.message); }
});
/* 异常检测线索 */ /* 异常检测线索 */
function renderAnomaly() { function renderAnomaly() {
const a = state.anomalies || { fast_high: [], frequent: [], identical: [] }; const a = state.anomalies || { fast_high: [], frequent: [], identical: [] };
+46 -10
View File
@@ -53,7 +53,12 @@ body {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
box-shadow: 0 2px 12px rgba(20, 30, 60, .05); box-shadow: 0 2px 12px rgba(20, 30, 60, .05);
} }
.brand { display: flex; align-items: center; gap: 12px; } .brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
/* 导航栏主标题(已移除 Logo 与副标题,只保留单一主标题) */
.brand-title {
margin: 0; font-size: 16px; font-weight: 700; letter-spacing: .3px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.logo { .logo {
width: 38px; height: 38px; width: 38px; height: 38px;
border-radius: 10px; border-radius: 10px;
@@ -469,32 +474,47 @@ body.fs-on .card b { font-size: 28px; }
body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; } body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
/* ---------- 三端自适应(桌面 / 平板 / 手机) ---------- */ /* ---------- 三端自适应(桌面 / 平板 / 手机) ---------- */
/* 大屏笔记本:略收边距,统计卡片保持一行 */
@media (max-width: 1180px) { @media (max-width: 1180px) {
.wrap { padding: 16px 16px 40px; } .wrap { padding: 16px 16px 40px; }
.topbar { padding: 10px 16px; } .topbar { padding: 10px 16px; }
} }
/* 平板横屏(769~1024):卡片改 4 列、工具栏字段占满整行 */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.grid-2 { grid-template-columns: 1fr; } .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); }
.toolbar { gap: 14px; }
} }
/* 平板竖屏 / 大手机(<=768):整体收紧,工具栏与按钮自动换行 */
@media (max-width: 768px) { @media (max-width: 768px) {
body { font-size: 13px; } body { font-size: 13px; }
.topbar { flex-wrap: wrap; gap: 10px; } .topbar { flex-wrap: wrap; gap: 10px; }
.brand-text small { display: none; } .brand { flex: 1 1 100%; }
.brand-title { font-size: 15px; }
.top-right { flex-wrap: wrap; gap: 8px; } .top-right { flex-wrap: wrap; gap: 8px; }
.top-right .btn { padding: 6px 10px; font-size: 12px; } .top-right .btn { padding: 6px 10px; font-size: 12px; }
.btn b { font-size: 10px; } .btn b { font-size: 10px; }
.wrap { padding: 12px 12px 36px; } .wrap { padding: 12px 12px 36px; }
.toolbar { padding: 12px; gap: 10px; } .toolbar { padding: 12px; gap: 10px 12px; }
.field { flex: 1 1 100%; min-width: 0; } .field { flex: 1 1 100%; min-width: 0; }
.field.btns { flex: 1 1 100%; } .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; }
.panel-head { flex-direction: column; align-items: flex-start; gap: 6px; } .card span { font-size: 11px; }
/* 面板头部(含按钮)竖排,按钮自动换行 */
.panel-head { flex-direction: column; align-items: flex-start; gap: 8px; }
/* 竖排时 .spacer(flex:1)会把内容上下撑开,必须隐藏 */
.panel-head > .spacer { display: none; }
.panel-head .updated { flex-wrap: wrap; width: 100%; }
.panel-head .btn, .panel-head .updated .btn { flex: 0 0 auto; }
.table-scroll { max-height: 56vh; } .table-scroll { max-height: 56vh; }
table.grid { font-size: 12px; } table.grid { font-size: 12px; }
table.grid th { padding: 8px 10px; white-space: nowrap; } table.grid th { padding: 8px 10px; white-space: nowrap; }
@@ -503,19 +523,36 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
.modal-box, .modal-box.wide { width: 100%; max-height: 92vh; } .modal-box, .modal-box.wide { width: 100%; max-height: 92vh; }
.form-grid { grid-template-columns: 1fr; } .form-grid { grid-template-columns: 1fr; }
.modal-body { padding: 14px; } .modal-body { padding: 14px; }
/* 弹窗底栏在小屏换行,按钮可点满宽 */
.modal-foot { flex-wrap: wrap; padding: 10px 14px; }
.modal-foot > .spacer { display: none; }
.modal-foot .btn { flex: 1 1 auto; }
.roster-bar { flex-wrap: wrap; }
.roster-bar .chk { font-size: 11px; } .roster-bar .chk { font-size: 11px; }
/* 名单表在窄屏可横向滚动,避免 6 列挤爆 */
#rosterTable { min-width: 560px; }
} }
/* 手机(<=480):卡片两列,进一步压缩字号与内边距 */
@media (max-width: 480px) { @media (max-width: 480px) {
.cards { grid-template-columns: repeat(2, 1fr); } .cards { grid-template-columns: repeat(2, 1fr); gap: 8px; }
.brand-text strong { font-size: 14px; } .brand-title { font-size: 14px; }
.topbar .logo { width: 32px; height: 32px; font-size: 13px; }
.btn { padding: 6px 9px; font-size: 12px; } .btn { padding: 6px 9px; font-size: 12px; }
.field label { font-size: 11px; } .field label { font-size: 11px; }
.roster-bar { gap: 8px; } .roster-bar { gap: 8px; }
.roster-bar input[type=text] { width: 120px; } .roster-bar input[type=text] { width: 120px; }
.panel-head h2 { font-size: 14px; } .panel-head h2 { font-size: 14px; }
#rosterBody input { font-size: 12px; } #rosterBody input { font-size: 12px; }
.modal-box { max-height: 94vh; }
.modal-body { padding: 12px; }
/* 工具条按钮在小屏铺满,便于点按 */
.field.btns .btn { flex: 1 1 auto; }
}
/* 触摸设备:增大点按热区,去掉 hover 残留高亮 */
@media (hover: none) and (pointer: coarse) {
.btn { min-height: 34px; }
table.grid tbody tr:hover td { background: inherit; }
} }
/* ---------- 数据看板 ---------- */ /* ---------- 数据看板 ---------- */
@@ -525,8 +562,7 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
.dash-hero .brand strong { color: #fff; font-size: 17px; } .dash-hero .brand-title { color: #fff; font-size: 17px; }
.dash-hero .brand small { color: rgba(255, 255, 255, .72); }
.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: 10px; flex-wrap: wrap; }
+91 -12
View File
@@ -200,15 +200,19 @@ function ny_is_installed(): bool
} }
// ---------------- 目录初始化 ---------------- // ---------------- 目录初始化 ----------------
foreach (['', '/cache', '/uploads'] as $sub) { foreach (['', '/cache', '/uploads', '/snapshots'] 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);
} }
} // Apache:禁止直接访问(nginx 不读 .htaccess,需在 server 配置中额外 deny,见 docs/部署与安全)
// 保护数据目录(Apache) if (!file_exists($dir . '/.htaccess')) {
if (!file_exists(NY_DATA . '/.htaccess')) { @file_put_contents($dir . '/.htaccess', "Deny from all\n");
@file_put_contents(NY_DATA . '/.htaccess', "Deny from all\n"); }
// 兜底:即使目录被索引也不暴露文件清单
if (!file_exists($dir . '/index.html')) {
@file_put_contents($dir . '/index.html', "<!doctype html><meta charset=\"utf-8\"><title>403 Forbidden</title><h1>403 Forbidden</h1>\n");
}
} }
// ---------------- 默认配置 ---------------- // ---------------- 默认配置 ----------------
@@ -227,7 +231,9 @@ function ny_default_config(): array
'dedupe' => true, 'dedupe' => true,
'name_keywords' => '姓名,名字,学生姓名,考生姓名,您的姓名,姓名:,姓名:', 'name_keywords' => '姓名,名字,学生姓名,考生姓名,您的姓名,姓名:,姓名:',
'full_score' => 100, 'full_score' => 100,
'full_scores' => [], // 全局默认合格率(百分比,如 60 表示"≥满分的 60%"算合格)。
// 每份试卷可在主页面单独覆盖(project_config),试卷之间互相隔离。
'pass_rate' => 60,
'layout' => 'default', 'layout' => 'default',
// 历史成绩展示方式:click=点击成员弹窗查看(默认);inline=成绩明细内直接展开每次考试 // 历史成绩展示方式:click=点击成员弹窗查看(默认);inline=成绩明细内直接展开每次考试
'history_display' => 'click', 'history_display' => 'click',
@@ -258,16 +264,45 @@ function ny_load_config(): array
{ {
$GLOBALS['__ny_cfg_source'] = 'default'; $GLOBALS['__ny_cfg_source'] = 'default';
// 默认配置 + 文件覆盖(DB 后端本批次已下线,统一走文件存储) $cfg = ny_default_config();
$cfg = ny_default_config(); $file = ny_config_file();
$saved = ny_read_json(ny_config_file(), null); $saved = null;
// 1) 优先读 SQLite(data/ny.db 的 ny_settings.config)
if (class_exists('Db') && Db::pdo() !== null) {
$raw = Db::settingsGet('config', '');
if ($raw !== '') {
$j = json_decode($raw, true);
if (is_array($j)) {
$saved = $j;
$GLOBALS['__ny_cfg_source'] = 'db';
}
}
// DB 可用但尚无记录 → 把已有 config.json 迁进来(老数据不丢,只迁一次)
if ($saved === null) {
$fromFile = ny_read_json($file, null);
if (is_array($fromFile)) {
Db::settingsSet('config', json_encode($fromFile, JSON_UNESCAPED_UNICODE));
$saved = $fromFile;
$GLOBALS['__ny_cfg_source'] = 'db';
}
}
}
// 2) DB 不可用时退回文件
if ($saved === null) {
$saved = ny_read_json($file, null);
if (is_array($saved)) {
$GLOBALS['__ny_cfg_source'] = 'file';
}
}
if (is_array($saved)) { if (is_array($saved)) {
foreach ($cfg as $k => $v) { foreach ($cfg as $k => $v) {
if (array_key_exists($k, $saved)) { if (array_key_exists($k, $saved)) {
$cfg[$k] = is_bool($v) ? (bool)$saved[$k] : $saved[$k]; $cfg[$k] = is_bool($v) ? (bool)$saved[$k] : $saved[$k];
} }
} }
$GLOBALS['__ny_cfg_source'] = 'file';
} }
$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'], '/');
@@ -276,8 +311,52 @@ function ny_load_config(): array
function ny_save_config(array $cfg): bool function ny_save_config(array $cfg): bool
{ {
$GLOBALS['__ny_cfg_source'] = 'file'; // 双写:SQLite 为主(成功即算成功),文件为备份(DB 挂掉时还能读回来)
return ny_write_json(ny_config_file(), $cfg); $ok = false;
if (class_exists('Db') && Db::pdo() !== null) {
try {
Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE));
$GLOBALS['__ny_cfg_source'] = 'db';
$ok = true;
} catch (Throwable $e) {
$ok = false;
}
}
$fileOk = ny_write_json(ny_config_file(), $cfg);
if (!$ok) {
$GLOBALS['__ny_cfg_source'] = 'file';
}
return $ok || $fileOk;
}
/**
* 解析「本卷满分 + 合格率」:
* 优先取该试卷的独立配置(试卷之间互相隔离),没有则用全局配置。
*
* @return array{0:int,1:float} [满分, 合格率百分比(如 60 表示 60%)]
*/
function ny_resolve_score_cfg(string $projectId, array $c): array
{
$pCfg = class_exists('DataService') ? DataService::projectConfig($projectId) : [];
$fs = (int)($pCfg['full_score'] ?? 0);
if ($fs < 1) {
$fs = (int)($c['full_score'] ?? 100);
}
if ($fs < 1) {
$fs = 100;
}
$pr = $pCfg['pass_rate'] ?? null;
if ($pr === null || $pr === '') {
$pr = $c['pass_rate'] ?? 60;
}
$pr = (float)$pr;
if ($pr <= 0 || $pr > 100) {
$pr = 60;
}
return [$fs, $pr];
} }
/** 安全的字符串化(避免数组/对象转字符串报错) */ /** 安全的字符串化(避免数组/对象转字符串报错) */
+29 -16
View File
@@ -25,11 +25,7 @@ if (!ny_is_auth()) {
<div class="dash-hero"> <div class="dash-hero">
<div class="brand"> <div class="brand">
<span class="logo">NY</span> <h1 class="brand-title">NY问卷考试系统数据平台</h1>
<div class="brand-text">
<strong id="heroTitle">班级看板</strong>
<small id="heroSub">选择一个问卷与班级查看考试整体情况</small>
</div>
</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>
@@ -47,7 +43,9 @@ if (!ny_is_auth()) {
</div> </div>
</div> </div>
<div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;"> <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 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>
@@ -60,7 +58,7 @@ if (!ny_is_auth()) {
<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-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>合格率(≥60%满分)</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">分组对比 / 考试成员</div>
@@ -200,6 +198,9 @@ async function refreshClassSel(){
try { try {
const data = await api('dashboard', { projectId: pid }); const data = await api('dashboard', { projectId: pid });
DASH = data; DASH = data;
// 合格率阈值可配(全局或本卷独立),卡片标题实时反映
const bl = document.getElementById('bPassLabel');
if (bl) bl.textContent = '(≥' + (data.pass_rate == null ? 60 : data.pass_rate) + '%满分)';
const classes = (data.classes || []).map(c => c.class); const classes = (data.classes || []).map(c => c.class);
sel.innerHTML = classes.length sel.innerHTML = classes.length
? classes.map(c => '<option value="' + esc(c) + '">' + esc(c) + '</option>').join('') ? classes.map(c => '<option value="' + esc(c) + '">' + esc(c) + '</option>').join('')
@@ -285,8 +286,9 @@ function renderPlaceholderBoard(){
$('#boardSummary').innerHTML = cards.map(c => $('#boardSummary').innerHTML = cards.map(c =>
'<div class="card"><span>' + c[0] + '</span><b>' + c[1] + '</b></div>').join(''); '<div class="card"><span>' + c[0] + '</span><b>' + c[1] + '</b></div>').join('');
$('#boardBody').innerHTML = '<div class="empty">请选择问卷与班级查看考试整体情况</div>'; $('#boardBody').innerHTML = '<div class="empty">请选择问卷与班级查看考试整体情况</div>';
$('#heroTitle').textContent = '班级看板'; // 注意:heroTitle 已随导航栏精简移除,只保留 heroSub(承载当前问卷/班级提示)
$('#heroSub').textContent = '选择一个问卷与班级查看考试整体情况'; const _hs = document.getElementById('heroSub');
if (_hs) _hs.textContent = '选择一个问卷与班级查看考试整体情况';
const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); } const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); }
} }
@@ -327,9 +329,8 @@ function renderBoard(){
return; return;
} }
// 顶部品牌区:动态标题 // 顶部提示:当前班级 / 问卷(heroTitle 已移除,只更新 heroSub)
const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || ''; const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || '';
$('#heroTitle').textContent = cls + ' · 班级看板';
// 构建"学号 -> 多次刷题明细"映射(班级级 members 已含全班所有人的 attempts_list) // 构建"学号 -> 多次刷题明细"映射(班级级 members 已含全班所有人的 attempts_list)
const MAP = {}; const MAP = {};
@@ -342,7 +343,8 @@ function renderBoard(){
const overall = DASH.overall || {}; const overall = DASH.overall || {};
const totalAttempts = (overall.attempts_total || 0); const totalAttempts = (overall.attempts_total || 0);
$('#heroSub').textContent = '问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' + const _hs2 = document.getElementById('heroSub');
if (_hs2) _hs2.textContent = cls + ' · 问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' +
'总 ' + c.total + ' / 已交 ' + c.submitted + ' / 缺考 ' + c.absent + '总 ' + c.total + ' / 已交 ' + c.submitted + ' / 缺考 ' + c.absent +
(totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : ''); (totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : '');
@@ -354,7 +356,7 @@ function renderBoard(){
'<div class="card warn accent-orange"><span>缺考</span><b>' + c.absent + '</b></div>' + '<div class="card warn accent-orange"><span>缺考</span><b>' + c.absent + '</b></div>' +
'<div class="card accent-purple"><span>提交率</span><b style="color:' + color + '">' + c.rate + '%</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>' + (c.avg != null ? 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>合格率</span><b>' + 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');
@@ -392,10 +394,21 @@ function toggleFullscreen(){
const el = document.documentElement; const el = document.documentElement;
if (!document.fullscreenElement && !document.webkitFullscreenElement) { if (!document.fullscreenElement && !document.webkitFullscreenElement) {
const req = el.requestFullscreen || el.webkitRequestFullscreen; const req = el.requestFullscreen || el.webkitRequestFullscreen;
if (req) req.call(el); if (req) {
const p = req.call(el);
// 部分浏览器(尤其 Safari)会返回 Promise,被拒绝时静默吞掉会让人以为"点了没反应"
if (p && typeof p.catch === 'function') {
p.catch(() => { alert('当前浏览器/环境不允许进入全屏,请按 F11 手动切换'); });
}
}
} else { } else {
const ex = el.exitFullscreen || el.webkitExitFullscreen; // 注意:exitFullscreen 挂在 document 上,不是 documentElement 上
if (ex) ex.call(document); // (写成 el.exitFullscreen 恒为 undefined → 退出全屏点了没反应)
const ex = document.exitFullscreen || document.webkitExitFullscreen;
if (ex) {
const p = ex.call(document);
if (p && typeof p.catch === 'function') p.catch(() => {});
}
} }
} }
function onFsChange(){ function onFsChange(){
+129
View File
@@ -0,0 +1,129 @@
import { defineConfig } from 'vitepress'
// 部署到子路径时,构建前设置环境变量即可:
// DOCS_BASE=/ny-exam-docs/ npm run docs:build
const base = process.env.DOCS_BASE || '/'
export default defineConfig({
base,
lang: 'zh-CN',
title: 'NY 问卷考试系统数据平台',
description: '基于 PHP 8 + 卷王(SurveyKing)社区版开放 API 的零依赖考试数据统计平台',
cleanUrls: true,
ignoreDeadLinks: true,
head: [
['meta', { name: 'theme-color', content: '#2563eb' }],
['meta', { name: 'referrer', content: 'no-referrer' }]
],
themeConfig: {
// outlineTitle: '本页目录',
outline: [2, 3],
lastUpdated: { text: '最后更新于' },
search: {
provider: 'local',
options: {
locales: {
zh: {
translations: {
button: { buttonText: '搜索文档', buttonAriaLabel: '搜索文档' },
modal: {
noResultsText: '未找到相关结果',
resetButtonTitle: '清除查询条件',
footer: {
selectText: '选择',
navigateText: '切换',
closeText: '关闭'
}
}
}
}
}
}
},
nav: [
{ text: '首页', link: '/' },
{ text: '使用指南', link: '/guide/introduction', activeMatch: '/guide/' },
{ text: '安全', link: '/security/', activeMatch: '/security/' },
{ text: '开发', link: '/dev/architecture', activeMatch: '/dev/' }
],
sidebar: {
'/guide/': [
{
text: '开始',
items: [
{ text: '平台简介', link: '/guide/introduction' },
{ text: '环境要求与部署', link: '/guide/install' },
{ text: '快速上手', link: '/guide/quickstart' }
]
},
{
text: '核心功能',
items: [
{ text: '系统设置', link: '/guide/settings' },
{ text: '名单管理', link: '/guide/roster' },
{ text: '成绩明细与统计口径', link: '/guide/scores' },
{ text: '试卷设置(满分/合格率/免考)', link: '/guide/project-config' },
{ text: '快照、趋势与异常检测', link: '/guide/snapshot' },
{ text: '导出与打印', link: '/guide/export-print' }
]
},
{
text: '其他页面',
items: [
{ text: '数据看板', link: '/guide/dashboard' },
{ text: '随机抽取', link: '/guide/draw' }
]
},
{
text: '帮助',
items: [
{ text: '常见问题', link: '/guide/faq' }
]
}
],
'/security/': [
{
text: '安全',
items: [
{ text: '安全总览', link: '/security/' },
{ text: '部署加固清单', link: '/security/hardening' }
]
}
],
'/dev/': [
{
text: '开发',
items: [
{ text: '架构与目录', link: '/dev/architecture' },
{ text: '接口一览', link: '/dev/api' },
{ text: '数据存储(SQLite)', link: '/dev/database' },
{ text: '测试', link: '/dev/testing' }
]
}
]
},
socialLinks: [],
footer: {
message: '基于 PHP 8 + 卷王(SurveyKing)社区版开放 API 构建,零第三方运行依赖。',
copyright: 'Copyright © NY 问卷考试系统数据平台'
},
docFooter: {
prev: '上一页',
next: '下一页'
},
returnToTopLabel: '回到顶部',
sidebarMenuLabel: '菜单',
darkModeSwitchLabel: '主题',
lightModeSwitchTitle: '切换到浅色模式',
darkModeSwitchTitle: '切换到深色模式'
}
})
+106
View File
@@ -0,0 +1,106 @@
# 接口一览
所有接口统一走 `api.php?action=<name>`,响应为 JSON:
```jsonc
// 成功
{ "ok": true, "data": { ... } }
// 失败
{ "ok": false, "error": "错误信息" }
```
- 未登录时返回 `{ "ok": false, "error": "...", "auth": false }`,前端会清会话并跳登录页;
- **写操作**必须在 JSON 体里带 `_csrf` 字段(或 `X-CSRF-Token` 头),
令牌从 `config_get` 接口获取。
## 写操作白名单
`config_save`、`roster_save`、`roster_import`、`roster_clear`、
`snapshot_save`、`snapshot_delete`、`unmatched_map`、`project_config_save`
## 接口清单
### 认证与配置
| Action | 方法 | 参数 | 说明 |
| --- | --- | --- | --- |
| `auth_check` | GET | — | 是否已登录 |
| `config_get` | GET | — | 返回配置(密码不回显)+ `csrf_token` + `storage` / `db_driver` / `db_connected` |
| `config_save` | POST | JSON 配置对象 | 保存;返回 `conn_changed` 与 `ping` 结果 |
| `config_test` | POST | 连接信息 | 测试连接,不落库 |
| `ping` | GET | — | 探测卷王是否可达 |
| `diag` | GET | — | 诊断信息(存储后端、DB 状态等) |
> **只有连接信息(地址 / 前缀 / 账号 / 密码 / 证书校验)变化时**,`config_save` 才会清除令牌并重新登录。
### 数据
| Action | 方法 | 参数 | 说明 |
| --- | --- | --- | --- |
| `projects` | GET | — | 卷王项目(问卷 / 考试)列表 |
| `data` | GET | `projectId`、`nameQid`、`force` | 主系统数据:统计、成绩行、名单、缺考、未匹配、异常 |
| `dashboard` | GET | `projectId`、`nameQid`、`force` | 按班级 / 组别聚合的看板数据 |
`data` 响应关键字段:
```
stats { total, submitted, absent, rate, avg, pass_rate, max, min, avg_duration }
rows[] { sid, name, cls, grp, score, best_score, attempts, attempts_list[],
submit_ts, submit_at, last_submit_ts, duration, duration_txt }
roster[] { sid, name, cls, grp, login }
absent[] 缺考名单
unmatched[] 未匹配答卷
anomalies { fast_high[], frequent[], identical[] }
project_cfg 本卷配置
_cache { cached, at }
```
### 名单
| Action | 方法 | 参数 | 说明 |
| --- | --- | --- | --- |
| `roster` | GET | — | 名单与元信息 |
| `roster_import` | POST | `multipart`:`file`、`mode=replace\|append` | 导入(扩展名白名单 + 随机名 + 解析后即删) |
| `roster_save` | POST | JSON `{ students: [...] }` | 全量替换(DELETE + INSERT) |
| `roster_clear` | POST | — | 清空 |
| `unmatched_map` | POST | JSON `{ name, sid, cls, grp, login }` | 未匹配答卷补入名单(学号已存在则更新姓名) |
### 每卷配置
| Action | 方法 | 参数 | 说明 |
| --- | --- | --- | --- |
| `project_config` | GET | `projectId` | 返回 `cfg`、`effective`(生效值)、`global`(全局默认) |
| `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 | 方法 | 参数 | 说明 |
| --- | --- | --- | --- |
| `export` | GET | `projectId`、`cls`、`grp`、`mode=latest`、`split=cls`、`sort`、`cols` | 成绩明细 `.xlsx` |
| `export_absent` | GET | 同上 | 缺考名单 `.xlsx` |
| `draw_export` | GET | `type=import\|list` | 白名单导入模板 / 白名单列表 |
`sort` 取值:`time` / `sid` / `score_desc` / `score_asc`。
## 前端调用约定
```js
// 读
const d = await api('data', { params: { projectId, force: 1 } })
// 写:api() 会自动注入 _csrf
await api('roster_save', { json: { students } })
```
## 相关
- [架构与目录](./architecture.md)
- [测试](./testing.md)
+110
View File
@@ -0,0 +1,110 @@
# 架构与目录
## 技术栈
- **后端**:PHP 8(过程式 + 少量静态类),零 Composer 依赖
- **前端**:原生 HTML / CSS / JavaScript(无框架、无构建步骤)
- **存储**:SQLite(PDO),缺失 `pdo_sqlite` 时自动降级为 JSON 文件
- **外部依赖**:仅卷王(SurveyKing)开放 API
## 目录结构
```
ny-exam-platform/
├─ index.php 主系统页面(服务端渲染骨架 + 前端挂载)
├─ dashboard.php 数据看板页面
├─ draw.php 随机抽取页面
├─ login.php / logout.php 登录与登出
├─ api.php 所有后端接口(单一入口,?action=xxx)
├─ bootstrap.php 常量、自动加载、配置读写、CSRF、限流、公共函数
├─ lib/
│ ├─ SurveyKingClient.php 卷王 API 客户端(登录 / 令牌 / 项目 / 答卷)
│ ├─ DataService.php 数据组装:姓名→学号匹配、统计、去重、异常检测
│ ├─ Roster.php 名单管理(xlsx/csv 导入、列识别、持久化)
│ ├─ Db.php SQLite 封装(三方言兼容的 DDL 与 upsert)
│ ├─ DataCache.php 结果缓存(TTL + flock 防惊群)
│ ├─ Snapshot.php 成绩快照与趋势
│ ├─ XlsxReader.php 零依赖 XLSX 读取
│ └─ XlsxWriter.php 零依赖 XLSX 写入(支持多工作表)
├─ assets/ style.css / app.js / draw.js
├─ data/ 运行时数据(需可写,已 gitignore)
│ ├─ ny.db SQLite 主库
│ ├─ config.json 配置(文件备份)
│ ├─ roster.json 名单(文件备份)
│ ├─ project_config.json 每卷配置(文件备份)
│ ├─ cache/ 卷王令牌 + 结果缓存
│ ├─ uploads/ 导入临时目录(解析后即删)
│ └─ snapshots/ 成绩快照
├─ tests/ PHP 自测 + 一键运行脚本
├─ tools/ mock 服务与 Node e2e
└─ docs/ 本文档站(VitePress)
```
## 请求流程
```
浏览器 ──► api.php?action=data
│
├─► ny_is_auth() 未登录 → 401 + {auth:false}
├─► 写操作 → ny_csrf_ok() 校验
├─► ny_build_data()(带结果缓存)
│ └─► DataService::build()
│ ├─► SurveyKingClient::listAnswers() 拉卷王答卷
│ ├─► Roster::all() 读名单
│ ├─► 姓名 → 学号匹配
│ ├─► dedupe(决定代表成绩)
│ └─► 统计 / 异常检测
└─► ny_resolve_score_cfg() 解析本卷满分与合格率
└─► ny_ok(JSON)
```
## 关键设计
### 单一入口 + 白名单
`api.php` 用 `switch ($action)` 分发。所有写操作集中在 `$WRITE_ACTIONS` 白名单里统一校验 CSRF,
新增写接口时**必须**把 action 加进该数组。
### 配置双写
`ny_save_config()` 同时写 SQLite 与 `data/config.json`;`ny_load_config()` 优先读库,
库为空时自动从文件迁回。这样即使数据库不可用,也不会静默回落到出厂的 `admin/123456`。
### 只在连接信息变化时才重新登录
`config_save` 会比较 `base_url / api_prefix / username / password / verify_ssl` 五项,
只有真变化才删除令牌缓存并重新 ping。修改满分、布局、刷新间隔不会打断数据刷新。
### 结果缓存
`DataCache` 以「配置 + 名单指纹 + 试卷 ID」为键缓存构建结果,默认 TTL 15 秒;
用 `flock` 防止并发惊群。名单指纹(`Roster::fingerprint()`)参与缓存键,改名单会自动失效。
点「立即刷新」带 `force=1` 跳过缓存。
### 内存防护
拉取卷王答卷时单页条数被夹在 20–200 之间,避免单页响应过大导致
`json_decode` 一次性撑爆内存(曾出现过 128M 耗尽的问题)。
### 零依赖 XLSX
- 读取:`XlsxReader` 直接解析 ZIP(优先 `ZipArchive`,无 `zip` 扩展时用内置实现)与 XML;
- 写入:`XlsxWriter` 手写 ZIP(stored 模式),单元格统一写共享字符串 +
`numFmtId=49` 文本样式,天然防公式注入、防长学号变科学计数法;
- `renderMulti()` 支持多工作表,表名做 31 字截断与 `(2)` 去重。
## 前端要点
| 主题 | 实现 |
| --- | --- |
| 大表格 | 虚拟滚动(占位行 + 可视区渲染),`ROW_H=41`,overscan 12 |
| 自动刷新 | `setTimeout` 链 + 失败指数退避;`visibilitychange` 时后台暂停 |
| 提示 | 顶部 Toast 系统,`showAlert(msg, type)` 4 种颜色,最多 5 条 |
| 全屏 | `document.exitFullscreen()`(注意不是 `documentElement`) |
| 事件绑定 | 表格行点击用**事件委托**挂在 `#tbody` 上,避免内联 `onclick` 作用域问题 |
## 相关
- [接口一览](./api.md)
- [数据存储(SQLite)](./database.md)
- [测试](./testing.md)
+113
View File
@@ -0,0 +1,113 @@
# 数据存储(SQLite)
平台使用 **SQLite 单文件数据库** `data/ny.db`,零配置、无需额外服务。
## 启用条件与降级
```php
Db::isConfigured() // 是否有 pdo_sqlite 扩展
Db::isConnected() // 是否真的连上了
Db::lastError() // 失败原因
```
| 环境 | 行为 |
| --- | --- |
| 有 `pdo_sqlite` | 使用 `data/ny.db`(同时继续双写 JSON 文件作为备份) |
| 无 `pdo_sqlite` | 整体降级为 JSON 文件存储,功能不受影响 |
> 前端可通过 `config_get` 返回的 `storage`(`db` / `file`)判断当前用的是哪种。
## 连接参数
连接建立时施加的 PRAGMA:
```sql
PRAGMA journal_mode = WAL; -- 读写并发
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000; -- 写锁等待 5 秒
PRAGMA foreign_keys = ON;
```
连接**每请求只尝试一次**,失败后返回 `null` 而非抛异常,保证上层能优雅降级。
## 表结构
所有建表 / 加列都是**幂等**的(`CREATE TABLE IF NOT EXISTS` + `PRAGMA table_info` 探测后 `ALTER TABLE`),可重复执行。
### `ny_settings` — 全局配置
| 列 | 类型 | 说明 |
| --- | --- | --- |
| `k` | TEXT PK | 键,目前只有 `config` |
| `v` | TEXT | 整份配置的 JSON |
| `updated_at` | — | 更新时间 |
### `ny_roster` — 学号名单
| 列 | 类型 | 说明 |
| --- | --- | --- |
| `sid` | TEXT PK | 学号 |
| `name` | TEXT | 姓名 |
| `cls` | TEXT | 班级 |
| `grp` | TEXT | 组别 |
| `login` | TEXT | 登陆账号(仅用于抽取页白名单导出) |
| `sort` | INTEGER | 排序 |
> `login` 列是后期加入的,旧库会在启动时自动 `ALTER TABLE ADD COLUMN`(幂等)。
### `ny_project_config` — 每卷独立配置
| 列 | 类型 | 说明 |
| --- | --- | --- |
| `project_id` | TEXT PK | 卷王试卷 ID |
| `config_json` | TEXT | `{ full_score, pass_rate, exempt[] }` |
| `updated_at` | — | 更新时间 |
## 迁移策略
首次在有 SQLite 的环境运行时:
1. `ny_settings` 为空 → 从 `data/config.json` 迁入;
2. `ny_roster` 为空 → 从 `data/roster.json` 迁入(`Roster::migrateFileToDbOnce()`);
3. 每卷配置同理从 `data/project_config.json` 迁入。
**迁移只发生在目标表为空时**,不会覆盖已入库的数据;重复执行安全。
## 读写路径
| 数据 | 主存储 | 备份 |
| --- | --- | --- |
| 配置 | `ny_settings['config']` | `data/config.json` |
| 名单 | `ny_roster` | `data/roster.json` |
| 每卷配置 | `ny_project_config` | `data/project_config.json` |
| 成绩快照 | 文件系统 `data/snapshots/` | — |
| 结果缓存 | 文件系统 `data/cache/` | — |
| 卷王令牌 | 文件系统 `data/cache/token.json` | — |
`ny_save_config()` 双写:先写库,再写文件。这样即使数据库突然不可用,也不会静默回落到出厂默认值。
## 维护
```bash
# 备份(推荐:直接复制文件)
cp data/ny.db data/ny.db.bak
# 完整性检查
php -r '$p=new PDO("sqlite:data/ny.db"); var_dump($p->query("PRAGMA integrity_check")->fetchAll());'
# 手动查看名单
php -r '$p=new PDO("sqlite:data/ny.db"); print_r($p->query("SELECT * FROM ny_roster LIMIT 5")->fetchAll(PDO::FETCH_ASSOC));'
```
::: tip 命令行加载扩展
PHP CLI 常常不加载 Web 端已启用的扩展,需要显式指定:
```bash
php -d extension_dir="C:/php/ext" -d extension=pdo_sqlite script.php
```
:::
## 相关
- [架构与目录](./architecture.md)
- [接口一览](./api.md)
+98
View File
@@ -0,0 +1,98 @@
# 测试
回归测试分两类:**PHP 自测**(纯逻辑断言)与 **Node e2e**(起 mock 服务 + PHP 内置服务器,走真实 HTTP)。
## 一键运行
```bash
bash tests/run-all.sh
```
脚本会依次执行:
| 文件 | 类型 | 覆盖 |
| --- | --- | --- |
| `tests/test-aggregate.php` | PHP | 聚合、去重、考试次数、排序 |
| `tests/test-new-features.php` | PHP | 异常检测、快照、趋势、XLSX 多表 |
| `tools/test-xlsxwriter.php` | PHP | XLSX 写入与 ZIP 打包 |
| `tools/test-config-save.js` | Node e2e | 配置保存、连接变化检测、每卷配置隔离、CSRF |
| `tools/test-draw-alloc.js` | Node e2e | 随机抽取名额分配(加权 / 自定义 / 守恒 fuzz) |
可通过环境变量覆盖运行时路径:
```bash
PHP_BIN=/usr/bin/php NODE_BIN=$(which node) bash tests/run-all.sh
```
## 单独运行 PHP 自测
```bash
php -d extension_dir="C:/php/ext" -d extension=pdo_sqlite tests/test-aggregate.php
php tests/test-new-features.php
```
输出为逐条 `✓ / ✗` 断言,结尾给出通过数 / 总数,失败时非零退出。
## 单独运行 Node e2e
e2e 需要两个进程:
1. **mock 卷王服务**(必须在 `tools/` 目录下启动,内部用 `__dirname` 解析相对路径):
```bash
cd tools
node mock-server.js # 默认监听 19911
```
2. **PHP 内置服务器**(端口 18081):
```bash
php -S 127.0.0.1:18081
```
然后:
```bash
node tools/test-config-save.js
node tools/test-draw-alloc.js
```
::: warning 测试会污染 `data/`
e2e 会备份并修改 `data/ny.db`、`data/config.json`、`data/roster.json`,
并在结束后恢复。若中途异常中断,请手动检查并还原:
```bash
git status data/ # data/ 已被 gitignore,需手工核对
```
另外测试前请删除 `data/login_fail.json`,否则可能触发登录限流。
:::
## 写新测试时注意
| 陷阱 | 说明 |
| --- | --- |
| CSRF | 写接口必须带 `_csrf`,e2e 里先从 `config_get` 取全局 token |
| DB 优先 | 配置读的是 **DB 优先**,只备份 `config.json` 不够,必须一起备份 `data/ny.db` |
| `/tmp` 路径 | Node 在 Windows 下会把 `/tmp/x.js` 解析成 `C:\tmp\x.js`,脚本请写在项目目录内 |
| `json_decode` | 必须传 `true` 得到关联数组,否则 `is_array(stdClass)` 为 `false` 会静默跳过 |
| mock 账号 | mock-server 接受账号 `mock` |
## 手工冒烟清单
改完代码建议至少过一遍:
- [ ] 登录 → 主页面三张卡片有数字
- [ ] 切换班级 → 卡片与表格联动
- [ ] 点某一行 → 能看到该学生的每次提交
- [ ] 导入含 `.php` 后缀的文件 → 被拒绝
- [ ] 导入正常 `.xlsx` → 成功,且 `data/uploads/` 为空
- [ ] 导出成绩 / 导出缺考 → 能打开,学号不是科学计数法
- [ ] 打印预览 → 默认成绩降序
- [ ] 数据看板全屏进入 / 退出都正常
- [ ] 随机抽取 → 名额守恒
## 相关
- [架构与目录](./architecture.md)
- [接口一览](./api.md)
+48
View File
@@ -0,0 +1,48 @@
# 数据看板
数据看板(`dashboard.php`)是**面向投屏 / 大屏**的汇总视图,适合考试进行中实时监控提交进度。
从主页面顶部导航 **📊 数据看板** 打开(新标签页)。
## 顶部操作
| 控件 | 说明 |
| --- | --- |
| 问卷 / 考试 | 选择要展示的试卷 |
| 班级 | 选择后只展示该班级(需先选试卷) |
| 自动刷新开关 + 间隔 | 与主页面一致的 5 / 10 / 15 / 30 / 60 / 120 秒 |
| 刷新 | 立即拉取一次(跳过缓存) |
| ⛶ 全屏 | 进入 / 退出全屏,布局自动铺满 |
## 汇总卡片
`总人数 / 已提交 / 缺考 / 提交率 / 平均分 / 合格率(≥X%满分)`
合格率卡片的阈值标签会随 [每卷配置](./project-config.md) 实时变化。
## 班级 / 组别下钻
看板主体按**班级**分块,每块内再按**组别**对比,指标与统计口径完全相同:
总人数、已提交、缺考、提交率、平均分、合格率、最高分、最低分、平均用时。
点击班级内任意成员可查看其**每一次提交**(时间、成绩、时长),最高分有标记。
> 未分班的人归入「(未分班)」,未分组的人归入「(未分组)」,不会丢失。
## 常见问题
### 全屏按钮点了没反应
- 浏览器要求全屏必须由**用户手势**触发,脚本自动调用 `requestFullscreen()` 会被拒绝;
- 退出全屏调用的是 `document.exitFullscreen()`(注意不是 `document.documentElement`),
平台已修正过这一点;
- 若页面被嵌在 `<iframe>` 中,需要给 iframe 加 `allowfullscreen` 属性。
### 数据不更新
确认自动刷新开关打开;若结果缓存时间较长,点 **刷新** 会带 `force=1` 跳过缓存。
## 相关
- [成绩明细与统计口径](./scores.md)
- [系统设置](./settings.md)
+70
View File
@@ -0,0 +1,70 @@
# 随机抽取
随机抽取(`draw.php`)用于从名单中抽人——抽查、抽考、抽签、抽取上台展示等。
从主页面顶部导航 **🎲 随机抽取** 打开(新标签页)。
## 抽取来源
| 来源 | 说明 |
| --- | --- |
| 学号名单(全部) | 从完整名单中抽 |
| 某试卷已提交人员 | 只从交过卷的人里抽(需要先选试卷) |
可再用 **班级 / 组别** 下拉缩小候选池。
## 抽取人数
输入框直接填,或用快捷按钮 `1` / `5` / `10` / `全部`。
## 分组比例
决定有分组时名额如何分配:
| 模式 | 说明 |
| --- | --- |
| 按人数加权(默认) | 人多的组分到更多名额,采用**最大余额法**,保证总数守恒 |
| 各组均分(5:5) | 名额均分到组(不是按人数加权),余数给排在前面的小组 |
| 自定义比例 | 为每个组手动指定比例 |
| 不分组 | 忽略组别,整池随机 |
::: tip 5:5 的含义
「各组均分」是**名额均分到组**,不等于每组抽到的人数与其人数成正比。
如果你想"人多的班多抽几个",请用默认的**按人数加权**。
:::
## 去重与排除
| 功能 | 说明 |
| --- | --- |
| 排除已抽中 | 本次会话内已抽中的人不再被抽到;**重置结果**后清零 |
| 排除管理 | 手动勾选不参与抽取的人(如请假、已抽过);支持清空排除 |
## 结果区
顶部卡片:`候选人数 / 本次抽取 / 累计已抽取 / 已排除 / 抽取来源`。
结果表格:`序号 / 学号 / 姓名 / 班级 / 组别 / 操作`。
| 按钮 | 说明 |
| --- | --- |
| 📜 历史 | 查看历次抽取批次。**重置结果不会清空历史** |
| 📋 复制名单 | 复制姓名 / 登陆账号,可直接粘贴到微信或 Excel |
| ⬇ 白名单导入模板 | 导出为卷王白名单导入模板 `.xlsx` |
| ⬇ 白名单列表 | 导出为白名单列表 `.xlsx` |
| 重置结果 | 清空本次结果与累计已抽取(历史保留) |
| 清空历史 | 清空全部历史记录 |
## 登陆账号说明
名单中的**登陆账号**列只在这里用于导出白名单,主系统界面任何位置都不展示。
若名单里没有这一列,导出的白名单该字段为空。
::: warning 模板文件名
卷王模板中该列表头为「登陆账号」(不是"登录账号"),导出时已按此命名。
:::
## 相关
- [名单管理](./roster.md)
- [系统设置](./settings.md)
+72
View File
@@ -0,0 +1,72 @@
# 导出与打印
## 导出成绩明细
工具栏 **导出成绩**,生成 `.xlsx`(零依赖,仓库内自行实现 XLSX 写入,不需要 `zip` 扩展也能用)。
### 三种可选模式
| 选项 | 效果 |
| --- | --- |
| 导出排序 | `按提交时间` / `按学号` / `按成绩降序` / `按成绩升序` |
| 按最新成绩 | 每人**只导一行**:最近一次成绩 + 最高分 + 考试次数 |
| 按班级分表 | 一个班级一个工作表(xlsx 多工作表) |
### 列
默认列(全部记录模式):
`学号 / 姓名 / 班级 / 组别 / 成绩 / 考试次数 / 第几次 / 提交时间 / 考试时长 / 考试时长(秒)`
按最新成绩模式:
`学号 / 姓名 / 班级 / 组别 / 最新成绩 / 最高分 / 考试次数 / 最近提交时间 / 考试时长 / 考试时长(秒)`
> 学号等长数字列统一按**文本格式**写入,不会被 Excel 转成科学计数法,也不会被当作公式执行。
### 文件名
```
成绩明细_<试卷名>[_<班级>][_<组别>][_最新成绩]_<YYYYMMDD_HHMMSS>.xlsx
```
### 跟随筛选
导出**完全跟随页面当前的班级 / 组别筛选**,缺考口径也与页面一致
(筛选后名单 − 筛选后已提交,按学号匹配),不会出现"页面上 30 人、导出 50 人"的情况。
## 导出缺考名单
工具栏 **导出缺考**,列为 `学号 / 姓名 / 班级 / 组别`,按学号升序。
## 打印成绩明细
成绩明细面板右上角 **打印**:
- 默认按**成绩降序**;
- 可选「按班级 / 组别分组对比」,每组段首给出小计;
- 走浏览器打印通道,可直接另存为 PDF。
缺考名单面板右上角 **打印** 同理。
## 直接调用导出接口
需要对接其它系统时,可以带参数直接 GET(需已登录):
```
api.php?action=export&projectId=xxx&cls=1&grp=A&mode=latest&split=cls&sort=score_desc&cols=sid,name,score
```
| 参数 | 取值 | 说明 |
| --- | --- | --- |
| `projectId` | 试卷 ID | 必填 |
| `cls` / `grp` | 班级 / 组别 | 与页面筛选一致 |
| `mode` | `latest` | 每人一行最新成绩;缺省导出全部记录 |
| `split` | `cls` | 按班级拆分多工作表 |
| `sort` | `time` / `sid` / `score_desc` / `score_asc` | 排序 |
| `cols` | 逗号分隔列名 | 从默认列中挑选 |
## 相关
- [成绩明细与统计口径](./scores.md)
- [接口一览](../dev/api.md)
+111
View File
@@ -0,0 +1,111 @@
# 常见问题
## 连接与登录
**Q:提示"无法连接卷王系统"**
依次检查:
1. 卷王地址与端口是否正确、是否带多余的路径或结尾 `/`;
2. 服务器防火墙 / 安全组是否放行;
3. 接口前缀是否为 `/api`(企业版可能是 `/admin-api`);
4. 账号密码是否正确;
5. 在**平台所在服务器本机**执行 `curl http://127.0.0.1:19911/api/system` 验证卷王本身是否可达。
**Q:自己写的客户端登录成功却返回 401 "Full authentication is required to access this resource"**
卷王社区版 v1.13 的 `JwtTokenFilter` **只从 Cookie `sk-token` 读取令牌,不读 `Authorization` 头**。
务必从登录响应的 `Set-Cookie` 中取出 `sk-token`,并在后续每个请求带上
`Cookie: sk-token=<token>`。本平台已处理此细节。
**Q:登录一直失败并被锁住**
连续 5 次失败会锁定 10 分钟。等待即可;如需立即解锁,删除 `data/login_fail.json`。
**Q:卷王改了密码后平台一直提示未登录**
到 **设置** 里重新填写密码并保存(连接信息变化会自动清除本地令牌并重新登录)。
## 匹配与数据
**Q:读到了答卷但学号为空**
说明答卷里的姓名与名单不一致:
- 到 **姓名字段** 下拉里手动指定正确的题目;
- 或到 **设置 → 姓名题关键词** 里补充关键词;
- **未匹配答卷** 面板会列出这些人,可据此核对(常见原因是姓名前后有空格、用了别名)。
**Q:成绩列全是 `-`**
该问卷不是考试模式,或没有开启自动评分,卷王未返回 `examScore`。
**Q:考试时长全是 `-`**
答卷的 `metaInfo.answerInfo` 中没有开始 / 结束时间(例如后台手动录入的答卷)。
**Q:同一个人交了多次,看板里的成绩取哪次?**
由 **设置 → 同一学号去重** 决定代表成绩取「最新」还是「最高」。
但**考试次数始终是全部提交次数**,点击成员可查看每一次提交。
**Q:我想让某个学生不计入某张试卷的缺考**
用 [试卷设置 → 免考名单](./project-config.md#免考名单),只对该试卷生效,
不要从名单里删除(否则其它试卷也会少人)。
**Q:合格率是按 60 分算吗?**
不是写死的 60 分,而是「成绩 ≥ 满分 × 合格率阈值」。阈值全局默认 60%,
可在 [试卷设置](./project-config.md) 里为单张试卷覆盖。卡片标题会显示当前阈值。
**Q:切换班级后数字变了,是不是重新请求了服务器?**
没有。切班级是本地即时重算,不额外打卷王。
## 性能
**Q:人多的时候页面卡 / 后台刷新失败**
已内置以下优化:结果缓存(默认 15 秒)、虚拟滚动、搜索防抖、页面切到后台自动暂停刷新、
分页大小夹在 20–200。仍有问题时可适当调大「结果缓存(秒)」并调小刷新频率。
**Q:后台刷新报错 "Allowed memory size exhausted"**
拉取大量答卷时 PHP 内存不足。程序已把单页条数夹在 20–200,并在入口尝试把
`memory_limit` 提到 512M。若主机用 `php_admin_value memory_limit` 硬锁,
`ini_set` 会静默失效,需要改 `php.ini` 或 FPM 池配置。
## 存储与迁移
**Q:数据存在哪?**
启用 `pdo_sqlite` 时存在 `data/ny.db`(表 `ny_settings` / `ny_roster` / `ny_project_config`);
未启用时回落到 `data/config.json`、`data/roster.json`、`data/project_config.json`。
两者始终双写,切换不会丢数据。
**Q:可以把平台搬到另一台服务器吗?**
可以。整个目录复制过去,并把 `data/` 一起带上(里面有配置、名单、快照),确保 `data/` 可写。
## 安全
**Q:上传的名单文件会被别人访问到吗?**
不会。上传文件落在 `data/uploads/`,扩展名白名单(`.xlsx` / `.xlsm` / `.csv` / `.txt`)、
随机文件名,且**解析完成后立刻删除**。另外请确保按
[部署加固清单](../security/hardening.md) 在 Web 服务器上屏蔽 `/data/` 目录。
**Q:为什么配置文件能被直接下载?**
说明 `/data/` 没有被屏蔽。Apache 下仓库会自动生成 `.htaccess`;
**nginx 不读 `.htaccess`**,必须显式加:
```nginx
location ^~ /data/ {
deny all;
return 404;
}
```
详见 [部署加固清单](../security/hardening.md)。
+133
View File
@@ -0,0 +1,133 @@
# 环境要求与部署
## 环境要求
| 项目 | 要求 | 说明 |
| --- | --- | --- |
| PHP | ≥ 8.0 | 推荐 8.1+ |
| `curl` | 必需 | 调用卷王 API |
| `json` / `mbstring` | 必需 | 数据编解码、中文处理 |
| `openssl` | 推荐 | 登录时对密码做 RSA 加密;缺失时降级为明文提交 |
| `zip` | 可选 | 缺失时用内置解析器读取 xlsx(写入 xlsx 内置实现无需此扩展) |
| `pdo_sqlite` | 推荐 | 启用 SQLite 存储;缺失时自动降级为 JSON 文件存储 |
| 数据库 | 无 | 不需要 MySQL / PostgreSQL |
| Composer | 无 | 没有任何第三方依赖 |
> 用 `php -m` 可以查看已加载的扩展。
## 部署方式
### 方式一:PHP 内置服务器(体验 / 内网临时使用)
```bash
cd ny-exam-platform
php -S 0.0.0.0:8080
```
浏览器打开 <http://localhost:8080>。Windows 下也可双击 `启动平台.bat`。
::: warning 仅用于内网体验
PHP 内置服务器是单进程、无并发能力的开发服务器,**不要**用于生产。
并且它**不解析 `.htaccess`**,`data/` 目录会可被直接访问,请务必参考
[安全加固清单](../security/hardening.md)。
:::
### 方式二:Nginx + PHP-FPM(推荐生产)
假设站点根目录为 `/www/sites/sj.exam.nxxy335.top/index`,那么把仓库内容放在该目录下即可。
```nginx
server {
listen 80;
server_name sj.example.com;
root /www/sites/sj.exam.nxxy335.top/index;
index index.php index.html;
charset utf-8;
client_max_body_size 20m; # 名单文件导入上限,与后端 20MB 校验一致
# ---- 安全:禁止直接访问数据目录(nginx 不读 .htaccess,必须显式配置)----
location ^~ /data/ {
deny all;
return 404;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# 不要暴露隐藏文件
location ~ /\. {
deny all;
return 404;
}
}
```
校验并重载:
```bash
nginx -t && nginx -s reload
```
### 方式三:Apache
仓库会在 `data/` 及其子目录自动生成 `.htaccess`(内容 `Deny from all`),
Apache 下 `data/` 默认即被保护。仍需确认:
- 虚拟主机 `AllowOverride` 不为 `None`(否则 `.htaccess` 被忽略);
- `mod_rewrite` 若启用,确认没有规则把 `/data/` 放行。
### 方式四:宝塔面板
1. 新建站点 → 根目录指向仓库目录;
2. PHP 版本选 8.0+,安装 `fileinfo`、`opcache`(可选);
3. 站点 → 配置文件,在 `server {}` 内加入上面的 `location ^~ /data/` 段;
4. 站点 → 目录权限,确保 `data/` 可写(运行用户通常为 `www`);
5. 若使用反向代理,确认 `/api.php` 的 `action` 参数未被缓存。
## 目录权限
| 路径 | 需要 | 说明 |
| --- | --- | --- |
| `data/` | 可写 | 配置、名单、令牌、快照的根目录 |
| `data/ny.db` | 可写 | SQLite 主库(启用 `pdo_sqlite` 时) |
| `data/uploads/` | 可写 | 导入名单时的临时落盘,解析后立即删除 |
| `data/cache/` | 可写 | 卷王令牌缓存、结果缓存 |
| `data/snapshots/` | 可写 | 成绩快照 |
```bash
chown -R www:www data
chmod -R 755 data
```
Windows 下给 `IIS_IUSRS` 或站点应用程序池身份赋予 `data` 的「修改」权限。
## 升级
仓库是纯文件部署,升级即覆盖文件:
```bash
git pull
```
覆盖后 `data/` 保留(其中存放配置与名单,并且在 `.gitignore` 中),
程序会在首次访问时自动完成目录初始化与数据迁移。
::: tip 关于 SQLite 迁移
首次在有 `pdo_sqlite` 的环境运行时,`data/config.json` 与 `data/roster.json`
会被自动迁移进 `data/ny.db`,之后以数据库为准,文件作为备份继续双写。
迁移是幂等的,重复执行不会覆盖已入库的数据。
:::
## 下一步
- [快速上手](./quickstart.md)
- [安全加固清单](../security/hardening.md)
+61
View File
@@ -0,0 +1,61 @@
# 平台简介
NY 问卷考试系统数据平台是一个**零第三方运行依赖**的 PHP 8 应用,直接对接
[卷王问卷系统(SurveyKing)社区版 v1.13](https://gitee.com/surveyking/surveyking) 的开放 API。
它不改变卷王本身的任何数据,只读拉取答卷,然后在本地做「姓名 → 学号」关联、
统计聚合、导出与展示。
## 它能解决什么问题
卷王自带的考试统计偏"问卷视角",而实际教学/培训场景通常需要的是"名单视角":
- 谁**没考**(而不是谁考了)—— 需要拿完整名单去比对;
- 某个班 / 某个组的成绩分布如何 —— 需要按班级、组别切片;
- 同一个人考了多次,该看哪一次 —— 需要去重策略 + 完整的考试次数;
- 不同试卷满分不同 —— 需要每卷独立的满分与合格率;
- 成绩要交给教务系统 —— 需要干净的 `.xlsx` 导出;
- 要抽查 / 抽考 —— 需要可去重、可留痕的随机抽取。
## 功能总览
### 主系统 `index.php`
| 模块 | 说明 |
| --- | --- |
| 试卷切换 | 下拉选择卷王中的问卷 / 考试,自动拉取该卷全部答卷 |
| 姓名关联 | 自动识别答卷中的「姓名」题,也可手动指定字段;未匹配答卷单独面板 + 一键补入名单 |
| 统计卡片 | 名单总人数、已提交、缺考、平均分、最高分、最低分、合格率、平均用时;选择班级后全部联动 |
| 成绩明细 | 虚拟滚动(无分页)、点击表头排序、搜索、考试次数、新提交绿色高亮(30 秒自动清除) |
| 缺考名单 | 名单中有但未提交的人,支持复制学号 / 姓名、打印 |
| 试卷设置 | 为当前选中的试卷单独设置满分 / 合格率 / 免考名单,试卷间互相隔离 |
| 快照 / 趋势 | 保存带标签的时间点快照,横向对比每名学生成绩变化 |
| 异常检测 | 短时长高分、高频重考、雷同成绩三类线索 |
| 导出 / 打印 | 成绩明细、缺考名单 `.xlsx`;成绩明细按成绩降序打印,可选分组对比 |
### 数据看板 `dashboard.php`
大屏看板,适合投屏。支持全屏切换与自动刷新。
### 随机抽取 `draw.php`
从「学号名单」或「某试卷已提交人员」中随机抽取,支持:
- 按班级 / 组别筛选;
- 名额分配四种模式:按人数加权(默认)、各组均分、自定义比例、不分组;
- 排除已抽中(本次会话内去重)、排除管理(手动指定不参与的人);
- 历史批次留痕、白名单导入模板 / 白名单列表导出。
![名单管理](/images/roster.png)
## 设计约束
- **零 Composer 依赖**:不使用任何 PHP 第三方库,XLSX 读写、ZIP 打包均由仓库内代码完成;
- **不修改卷王数据**:全程只读(登录 + 项目列表 + 答卷列表 + 答卷详情);
- **可降级**:没有 `zip` 扩展时用内置解析器读 xlsx;没有 `pdo_sqlite` 时自动回落到 JSON 文件存储;
- **单目录部署**:整个目录复制到站点目录即可,只需保证 `data/` 可写。
## 下一步
- [环境要求与部署](./install.md)
- [快速上手](./quickstart.md)
+67
View File
@@ -0,0 +1,67 @@
# 试卷设置(满分 / 合格率 / 免考)
不同试卷满分往往不同(100 分制、150 分制、50 道题每题 2 分……),合格率要求也可能不同。
所以**满分的每卷配置放在主页面「试卷设置」里,而不是系统设置**——设置里只保留全局默认值。
## 打开方式
主页面工具栏 → **试卷设置**(按钮为高亮主色)。配置**只对当前选中的试卷生效**。
## 优先级
```
本卷配置 > 全局默认(系统设置)
```
未设置某项时显示"跟随全局(X)",保存后卡片标题与统计口径立即更新。
## 满分
- 用于合格率阈值计算和卡片展示;
- 留空或填 `0` = **清除本卷配置**,回落全局默认;
- 全局默认满分在 **设置 → 全局默认满分** 中修改。
## 合格率
- 百分比,含义是「成绩 ≥ 满分 × X%」记合格。例如满分 150、合格率 60 → 90 分及格;
- 留空 = 跟随全局;录入范围会被夹到 `1 ~ 100`;
- 保存后统计卡片标题会变成 `合格率 (≥60%满分)`,与实际口径一致。
::: tip 为什么不再写死 60%
早期版本把「≥60% 满分」硬编码在代码里,一旦满分不是 100 就会产生误导。
现在阈值可配、可每卷覆盖,且界面上直接显示当前阈值。
:::
## 免考名单
某些人因故不参加本卷考试(缓考、免修、已单独补考等),如果直接把他们从名单里删除,
其它试卷的统计也会受影响。免考名单解决的就是这个:
- 填写**学号**列表,支持逗号、中文逗号、空格、换行分隔;
- 生效范围:仅当前试卷;
- 效果:这些人**不计入名单总人数,也不计入缺考**,彻底从本卷的分母中移除;
- 若他们实际上也交了卷,答卷仍会出现在成绩明细中。
示例输入:
```
2024012001, 2024012002
2024012015
```
## 清除本卷设置
弹窗左下角 **清除本卷设置** 会删除该试卷的全部独立配置(满分、合格率、免考),
之后完全跟随全局。
## 存储
| 环境 | 位置 |
| --- | --- |
| 启用 `pdo_sqlite` | `data/ny.db` 表 `ny_project_config`(主键 `project_id`) |
| 未启用 | `data/project_config.json` |
## 相关
- [系统设置](./settings.md)(全局默认值)
- [成绩明细与统计口径](./scores.md)
+82
View File
@@ -0,0 +1,82 @@
# 快速上手
从零到看到第一行成绩,一共四步。
## 第 1 步:登录
浏览器打开站点根路径,会直接进入登录页。
::: info 本平台没有独立账号体系
登录时填写的**就是卷王后台的管理员账号与密码**——平台用这组账密向卷王换取令牌。
连接信息(卷王地址、接口前缀)已在 `data/config.json` 中预置,登录页只要求账号与密码。
:::
- 账号:卷王后台管理员账号(默认带出配置中的值,可现场改)
- 密码:卷王后台密码
登录成功后账密会写回配置并持久化,这样令牌失效时平台能自动重新登录。
连续失败 5 次会锁定 10 分钟。
## 第 2 步:配置连接信息
右上角 **设置** → 填写:
| 配置项 | 示例 | 说明 |
| --- | --- | --- |
| 卷王系统地址 | `https://exam.nxxy335.top` | 结尾不要带 `/` |
| 接口前缀 | `/api` | 社区版默认 `/api` |
| 登录账号 / 密码 | — | 保存后不会回显密码,留空表示不修改 |
| 姓名题关键词 | `姓名,名字,学生姓名` | 用于自动识别哪道题填的是姓名 |
| 全局默认满分 | `100` | **每套试卷的满分请在主页面「试卷设置」里单独设** |
| 全局默认合格率 | `60` | 百分比,60 表示"≥满分的 60%"为合格 |
| 校验 HTTPS 证书 | 勾选 | 自签名证书可取消勾选 |
| 默认刷新间隔 | `15` | 秒 |
先点 **测试连接**,出现「连接成功」后再点 **保存**。
![系统设置](/images/settings.png)
::: warning 保存时机
只有**连接信息**(地址 / 前缀 / 账号 / 密码 / 证书校验)发生变化时,保存才会清除本地令牌并重新登录卷王。
修改满分、合格率、布局等其它项不会打断当前数据刷新。
:::
## 第 3 步:导入学号名单
右上角 **名单管理 <b>0</b>** → 点击或拖拽文件 → 选择 **覆盖 / 追加** → **保存名单**。
名单文件支持 `.xlsx` / `.xlsm` / `.csv` / `.txt`,至少包含:
| 学号 | 姓名 | 班级(可选) | 组别(可选) |
| --- | --- | --- | --- |
| 2023001 | 张三 | 1班 | A |
| 2023002 | 李四 | 1班 | B |
- 表头名称不严格要求,程序按关键词识别(如「学号 / 学籍号 / 编号」均可);
- **班级**列存在时,成绩明细会多出班级列,并支持按班级筛选与联动统计;
- **组别**列用于分组打印与随机抽取的分组比例;
- 也可以直接在弹窗里 **+ 新增** 逐条录入,勾选后 **删除选中**(二次确认)。
| 安全说明 |
| --- |
| 上传文件会先落盘到 `data/uploads/` 后立即解析再删除,扩展名白名单 + 随机文件名,不会残留可供 URL 直连访问的文件。详见 [安全总览](../security/)。 |
## 第 4 步:选择试卷,开始看数据
回到主页面,左上角 **问卷 / 考试** 下拉选择目标试卷,数据会自动拉取并按设定的间隔刷新。
- **姓名字段**默认为「自动识别」,若学号大面积为空,可手动指定正确的题目;
- 顶部 **班级 / 组别** 下拉可切片,统计卡片会同步联动;
- 新提交的答卷整行绿色高亮,30 秒后自动清除,也可手动「清除高亮」;
- 若某份试卷满分不是 100,点 **试卷设置** 单独配置。
## 常见问题速查
| 现象 | 排查方向 |
| --- | --- |
| 提示"无法连接卷王系统" | 地址 / 端口 / 防火墙 / 接口前缀 / 账密;在服务器本机 `curl http://127.0.0.1:1991/api/system` 验证 |
| 学号列为空 | 姓名不一致(空格、别名);手动指定姓名字段;在设置中补充关键词 |
| 成绩全是 `-` | 该问卷不是考试模式或未开启自动评分,卷王未返回 `examScore` |
| 时长全是 `-` | 答卷 `metaInfo.answerInfo` 中没有起止时间(例如后台手动录入) |
完整列表见 [常见问题](./faq.md)。
+85
View File
@@ -0,0 +1,85 @@
# 名单管理
名单是平台的"参照系":所有统计都以名单为分母,缺考名单 = 名单中有、但未提交的人。
## 打开方式
主页面右上角 **名单管理 <b>N</b>**(N 为当前人数)。也可以在随机抽取页点击 **名单管理** 跳转回来。
![名单管理](/images/roster.png)
## 导入文件
支持 `.xlsx` / `.xlsm` / `.csv` / `.txt`,单次上限 20 MB。
1. 点击上传区或把文件拖进来;
2. 选择 **覆盖**(替换现有名单)或 **追加**(合并到现有名单);
3. 点 **保存名单**。
> 导入后弹窗内会立即显示解析结果,此时**尚未落库**,检查无误再点保存。
### 列识别规则
程序先找表头行:某一行同时命中「学号类」与「姓名类」关键词,就把它当作表头,从下一行开始读数据。
| 字段 | 识别关键词(正则,容错空格) | 缺失时 |
| --- | --- | --- |
| 学号 | `学号` `编号` `序号` `学员号` `号码` `no` `id` | 取第 0 列 |
| 姓名 | `姓名` `名字` `学生姓名` `姓氏` `成员` | 取第 1 列(若学号占了第 0 列) |
| 班级 | `班级` `class` | 留空,界面不显示该列 |
| 组别 | `组别` `分组` `组次` `组` `group` | 留空 |
| 登陆账号 | `登陆账号` `登录账号` `账号` `手机号` `手机` | 留空 |
其它说明:
- 学号会自动去掉 Excel 数字尾巴(如 `2024012001.0` → `2024012001`);
- 同一学号重复出现时保留首次,其余记为「重复跳过」;
- 学号与姓名均为空的行记为「跳过」;
- CSV 自动识别并剥离 BOM,非 UTF-8 编码(GBK 等)会自动转码。
### 示例文件
| 学号 | 姓名 | 班级 | 组别 | 登陆账号 |
| --- | --- | --- | --- | --- |
| 2024012001 | 张三 | 1班 | A | stu001 |
| 2024012002 | 李四 | 1班 | B | stu002 |
| 2024012003 | 王五 | 2班 | A | stu003 |
**登陆账号** 只用于随机抽取页导出「白名单导入模板 / 白名单列表」,界面任何位置都不显示。
## 手动维护
| 操作 | 说明 |
| --- | --- |
| 新增 | 点 **+ 新增** 弹出录入框,填学号 / 姓名 / 班级 / 组别 / 登陆账号 |
| 编辑 | 直接在表格里改单元格 |
| 删除 | 勾选行 → **删除选中** → 二次确认 |
| 清空 | **清空** → 二次确认,清空全部名单 |
::: warning
**保存名单**是全量替换(后端 `DELETE` + `INSERT`),请确认弹窗内列表无误再保存。
:::
## 从未匹配答卷补入名单
主页面 **未匹配答卷** 面板列出"提交了但姓名不在名单中"的答卷。点 **加入名单**,
填写学号 / 班级 / 组别后即可补入,避免下次仍然匹配不上。
## 试卷级免考
只想让某几个人在**某一张试卷**中不计入统计?用 [试卷设置 → 免考名单](./project-config.md#免考名单),
而不是把他们从名单里删掉——免考只在当前试卷生效,不会影响其它试卷。
## 存储位置
| 环境 | 位置 |
| --- | --- |
| 启用 `pdo_sqlite`(默认) | `data/ny.db` 表 `ny_roster` |
| 未启用 `pdo_sqlite` | `data/roster.json` |
首次在有 SQLite 的环境运行时,`roster.json` 会自动迁移进数据库(仅当库为空时)。
## 相关
- [成绩明细与统计口径](./scores.md)
- [试卷设置](./project-config.md)
+85
View File
@@ -0,0 +1,85 @@
# 成绩明细与统计口径
这一页说明**每个数字到底是怎么算出来的**,以及表格里的每一列是什么含义。
## 统计卡片
| 卡片 | 计算方式 |
| --- | --- |
| 名单总人数 | 名单条目数;被当前试卷设为**免考**的人不计入 |
| 已提交 | 匹配到名单、且提交了答卷的人数(去重后的代表成绩行数) |
| 缺考 | `max(0, 名单总人数 − 已提交)` |
| 提交率 | `已提交 ÷ 名单总人数 × 100%` |
| 平均分 | 所有**有成绩**的答卷成绩平均值;无成绩者不计入分母 |
| 最高分 / 最低分 | 同上,取自所有有成绩的答卷 |
| 合格率 | `成绩 ≥ 满分 × 合格率阈值` 的人数 ÷ **已提交人数**。阈值全局默认 60%,可在 [试卷设置](./project-config.md) 针对单卷覆盖 |
| 平均用时 | 所有**有时长**的答卷时长平均值(秒),格式化为「X 分 Y 秒」 |
::: tip 卡片标题会跟着配置变
合格率卡片的标题会实时显示当前阈值,例如「合格率 (≥70%满分)」,避免你误以为还是 60%。
:::
## 班级 / 组别联动
选择顶部 **班级** 下拉后:
- 全部统计卡片只统计该班级;
- 成绩明细只显示该班级;
- 缺考名单只显示该班级,标题右侧计数同步变化;
- 切换班级是**本地即时重算**,不会额外请求服务器。
再选 **组别** 可做二次切片。
## 成绩明细列
| 列 | 说明 |
| --- | --- |
| 学号 / 姓名 / 班级 / 组别 | 来自名单;未匹配到的答卷不会进入本表 |
| 成绩 | 卷王返回的 `examScore`;该问卷不是考试模式时为 `-` |
| 考试次数 | 该学生**全部提交次数**,与「去重」开关无关 |
| 提交时间 | **最近一次**提交时间(重考低分也会让这行按最新时间排序到最上面) |
| 考试时长 | 最近一次答卷的时长;后台手动录入等没有起止时间的答卷为 `-` |
点击任意一行可查看该学生的**每一次提交**(时间、成绩、时长),最高分有标记。
## 排序与搜索
- 点击表头即可排序,支持学号 / 姓名 / 班级 / 组别 / 成绩 / 考试次数 / 提交时间 / 时长;
- 默认排序键是**最近提交时间**,所以刚交卷的人一定在最上面;
- 搜索框支持学号 / 姓名模糊匹配,200 ms 防抖;
- 成绩明细**无分页**,采用虚拟滚动,几千行也不卡;页面切到后台时自动暂停刷新。
## 新数据高亮
每次刷新时,与上一次相比新出现的提交(按学号判断)整行显示绿色底纹,
**30 秒后自动清除**;也可以点 **清除高亮** 立刻去掉。首屏加载不标记。
## 去重策略
设置里的「同一学号去重」只决定**代表成绩**取哪一份(最新 / 最高):
| 开关 | 成绩明细 | 考试次数 | 点击行内的历史 |
| --- | --- | --- | --- |
| 开启 | 一人一行 | 仍是全部次数 | 仍是全部提交 |
| 关闭 | 一人多行 | 仍是全部次数 | 仍是全部提交 |
`best_score` 字段始终是跨全部尝试的真实最高分。
## 未匹配答卷
提交了、但姓名不在名单中的答卷会单独成面板。常见原因:
- 姓名前后有空格、写了别名、用了繁体字;
- 姓名题选错了(到 **姓名字段** 下拉里手动指定);
- 名单里根本没有这个人。
可直接在面板里点 **加入名单** 补录。
## 缺考名单
名单中有、但未提交的人。支持 **复制学号**(逗号分隔)、**复制姓名**、**打印**。
## 相关
- [试卷设置(满分 / 合格率 / 免考)](./project-config.md)
- [导出与打印](./export-print.md)
+70
View File
@@ -0,0 +1,70 @@
# 系统设置
点击右上角 **设置** 打开。设置保存到 `data/ny.db`(SQLite);无 `pdo_sqlite` 时落到 `data/config.json`,两者始终双写。
## 连接
| 配置项 | 默认值 | 说明 |
| --- | --- | --- |
| 卷王系统地址 | `https://exam.nxxy335.top` | 结尾不带 `/` |
| 接口前缀 | `/api` | 社区版 `/api`;企业版可能用 `/admin-api` |
| 登录账号 | `admin` | 卷王后台管理员账号 |
| 登录密码 | `123456` | 不回显;留空表示不修改 |
| 校验 HTTPS 证书 | 勾选 | 自签名证书取消勾选 |
| 超时(秒) | `20` | 单次 HTTP 超时 |
::: tip 登录流程(社区版 v1.13)
`GET /api/system` 取 RSA 公钥 → 公钥加密密码 → `POST /api/public/login` 换取 JWT。
注意 v1.13 服务端的 `JwtTokenFilter` **只从 Cookie `sk-token` 读取令牌,不读 `Authorization` 头**,
所以客户端会把登录响应 `Set-Cookie` 中的 `sk-token` 原样带在后续每个请求的 `Cookie` 头里
(同时带 `Authorization` 头以兼容旧版)。平台已内置该流程与令牌缓存,并向下兼容
`/system/auth/login`、`/admin-api/oauth2/token` 两种登录方式。
这也是"自己写的客户端明明拿到 JWT 却仍返回 401 *Full authentication is required*"的根因。
:::
点击 **测试连接** 会立即做一次登录 + 项目列表探测,返回明确的成功 / 失败原因。
::: warning 保存时会自动重新登录的情况
仅当 **地址、接口前缀、账号、密码、证书校验** 五项中任意一项发生变化时,保存才会清除本地令牌缓存
(`data/cache/token.json`)并重新登录卷王。其余项(满分、合格率、刷新间隔、布局等)保存不会打断刷新。
:::
## 数据
| 配置项 | 默认值 | 说明 |
| --- | --- | --- |
| 姓名题关键词 | `姓名,名字,学生姓名,…` | 逗号分隔,用于自动识别答卷中的姓名题 |
| 全局默认满分 | `100` | 试卷未单独设置时使用;每卷满分在主页面「试卷设置」设置 |
| 全局默认合格率 | `60` | 百分比。60 表示「成绩 ≥ 满分 × 60%」记合格 |
| 同一学号去重 | 勾选 | 决定**代表成绩**取最新还是最高分;不影响考试次数与历史完整性 |
| 包含暂存答卷 | 不勾选 | 是否统计未正式提交的答卷 |
| 每页拉取条数 | `200` | 拉取卷王答卷的分页大小,内部会夹在 20–200 之间 |
| 结果缓存(秒) | `15` | 0 = 每次实时拉取。点「立即刷新」会带 `force=1` 跳过缓存 |
::: details 去重到底影响什么
- **代表成绩**:勾选去重后,同一学号只展示一行,取「最新」或「最高」(由去重策略决定);
- **考试次数**:始终是**全部提交次数**,与去重开关无关;
- **历史**:始终完整,点击任意一行可查看该学生的每一次提交。
:::
## 界面
| 配置项 | 默认值 | 说明 |
| --- | --- | --- |
| 默认刷新间隔 | `15` 秒 | 可选 5 / 10 / 15 / 30 / 60 / 120 秒 |
| 布局 | `default` | 成绩明细与侧栏面板的排列方式,适配手机 / 平板 / 桌面三端 |
| 历史成绩展示 | `click` | `click` = 点击成员弹窗查看;`inline` = 成绩明细内直接展开每次考试 |
| 成绩导出排序 | `time` | `time` 提交时间 / `sid` 学号 / `score_desc` 成绩降序 / `score_asc` 成绩升序 |
## 保存与诊断
- 保存后会返回明确结果;若连接信息变化,会附带 `ping` 结果告诉你是否真的连上了卷王;
- 接口 `action=diag` 与 `action=ping` 可用于排障(需已登录);
- 若页面提示 `storage=file`,说明 `pdo_sqlite` 未启用,当前使用 JSON 文件存储,功能不受影响但并发写安全性略低。
## 相关
- [试卷设置(每卷独立)](./project-config.md)
- [数据存储(SQLite)](../dev/database.md)
+72
View File
@@ -0,0 +1,72 @@
# 快照、趋势与异常检测
这一组功能用于**跨时间**看成绩,而不是只看当前这一刻。
## 快照
工具栏 **存快照** 打开快照面板,填写标签(留空自动用「快照 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)
+63
View File
@@ -0,0 +1,63 @@
---
layout: home
hero:
name: NY 问卷考试系统数据平台
text: 考试成绩实时统计与名单管理
tagline: 基于 PHP 8 + 卷王(SurveyKing)社区版开放 API · 零 Composer 依赖 · 开箱即用
image:
src: /images/home.png
alt: NY 问卷考试系统数据平台
actions:
- theme: brand
text: 快速上手
link: /guide/quickstart
- theme: alt
text: 环境要求与部署
link: /guide/install
- theme: alt
text: 安全加固清单
link: /security/hardening
features:
- icon: 🔗
title: 直连卷王
details: 自动完成 RSA 加密登录、JWT / sk-token 令牌缓存,选择试卷即拉取全部答卷。
- icon: 🧩
title: 姓名自动关联学号
details: 用答卷中的「姓名」题匹配名单,未匹配答卷单独成面板,支持一键补入名单。
- icon: 📊
title: 多维度统计
details: 总人数 / 已提交 / 缺考 / 提交率 / 平均分 / 合格率 / 最高最低分 / 平均用时,按班级即时联动。
- icon: ⚙️
title: 每卷独立配置
details: 满分、合格率、免考名单按试卷隔离设置,互不干扰;未设置时回落到全局默认值。
- icon: 📈
title: 快照与趋势
details: 随时保存成绩快照,横向对比每名学生在多个时间点的成绩变化,并给出异常线索。
- icon: 🎲
title: 随机抽取
details: 按班级 / 组别均衡或加权抽取,累计去重、排除管理、历史批次与白名单导出。
- icon: 📤
title: 导出与打印
details: 零依赖生成 .xlsx,支持多工作表、最新成绩视图、成绩明细与缺考名单打印。
- icon: 🔒
title: 安全内建
details: 会话登录、CSRF 令牌、登录失败限流,上传文件白名单 + 随机名 + 解析后即删。
---
## 三分钟了解它
平台由三个页面组成,顶部导航栏可自由切换:
| 页面 | 入口 | 用途 |
| --- | --- | --- |
| 主系统 | `index.php` | 成绩明细、统计卡片、名单管理、快照 / 趋势 / 异常、导出与打印 |
| 数据看板 | `dashboard.php` | 大屏展示,适合投屏实时监控提交进度 |
| 随机抽取 | `draw.php` | 从名单或某试卷已提交人员中随机抽取,支持白名单导出 |
![主系统界面](/images/scores.png)
::: tip 还没部署?
先看 [环境要求与部署](/guide/install),再按 [快速上手](/guide/quickstart) 完成连接配置与名单导入,全程不需要写一行代码。
:::
+2521
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "ny-exam-platform-docs",
"version": "1.0.0",
"private": true,
"description": "NY 问卷考试系统数据平台 · 使用文档",
"scripts": {
"docs:dev": "vitepress dev .",
"docs:build": "vitepress build .",
"docs:preview": "vitepress preview ."
},
"devDependencies": {
"vitepress": "^1.6.3"
}
}

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

+156
View File
@@ -0,0 +1,156 @@
# 部署加固清单
按 **P0 → P2** 顺序执行。P0 不做等于把配置和名单暴露在公网上。
## P0:屏蔽数据目录(必做)
`data/` 位于 Web 根目录内,里面有 `ny.db`(配置 / 名单 / 卷王账号密码)、
`config.json`、`roster.json`、令牌缓存与快照。**必须禁止直接访问。**
### nginx(生产环境默认,必做)
```nginx
location ^~ /data/ {
deny all;
return 404;
}
```
若站点部署在子路径下(例如 `/index/`),改为对应的前缀:
```nginx
location ^~ /index/data/ {
deny all;
return 404;
}
```
```bash
nginx -t && nginx -s reload
```
验证(应返回 404 或 403,而不是文件内容):
```bash
curl -I https://你的域名/data/config.json
```
### Apache
仓库已自动生成 `.htaccess`。仍需确认:
```apache
<Directory "/path/to/ny-exam-platform/data">
Require all denied
</Directory>
```
且虚拟主机的 `AllowOverride` 不为 `None`。
### PHP 内置服务器(开发环境)
**内置服务器既不解析 `.htaccess`,也没有 location 规则**,开发时不要把 8080 端口暴露到公网。
## P0:排查是否已被上传过 Webshell
如果你运行过修复前的版本,请立即检查:
```bash
ls -la data/uploads/
```
- 正常情况:目录为空(新版本解析后立即删除);
- 若存在 `.php` / `.phtml` / `.phar` / `.htaccess` 之外的可疑文件,立即删除;
- 检查 nginx access 日志中是否有对 `/data/uploads/*.php` 的 `POST` 请求;
- 如确认被利用,请**轮换卷王后台密码**并重新部署干净的目录。
## P1:全站 HTTPS
```nginx
server {
listen 80;
server_name sj.example.com;
return 301 https://$host$request_uri;
}
```
登录密码经 RSA 加密后再提交,但令牌 Cookie 在 HTTP 下可被中间人截获。
## P1:限制上传体积
与后端 20 MB 校验保持一致:
```nginx
client_max_body_size 20m;
```
PHP 侧:
```ini
upload_max_filesize = 20M
post_max_size = 24M
```
## P1:关闭信息泄漏
```nginx
server_tokens off;
```
```ini
expose_php = Off
display_errors = Off
log_errors = On
```
## P1:PHP-FPM 最小权限
- PHP-FPM 进程用户(如 `www`)只对 `data/` 有写权限,其它目录只读;
- 禁止在 Web 根目录放 `.git`:
```nginx
location ~ /\.git {
deny all;
return 404;
}
```
## P2:来源 IP 限制(内网场景)
```nginx
allow 10.0.0.0/8;
allow 192.168.0.0/16;
deny all;
```
## P2:定期备份
备份 `data/` 目录即可,包含:
| 文件 | 内容 |
| --- | --- |
| `data/ny.db` | 配置、名单、每卷配置(SQLite) |
| `data/config.json` | 配置(文件备份) |
| `data/roster.json` | 名单(文件备份) |
| `data/snapshots/` | 成绩快照 |
## P2:改掉卷王默认口令
卷王后台默认 `admin / 123456` 请务必修改,并在平台 **设置** 中同步更新。
## 加固自检清单
- [ ] `curl -I https://域名/data/config.json` 返回 404 / 403
- [ ] `curl -I https://域名/data/ny.db` 返回 404 / 403
- [ ] 上传一个 `.php` 后缀文件被拒绝
- [ ] 正常导入 `.xlsx` 后 `data/uploads/` 为空
- [ ] 全站 HTTPS,HTTP 自动跳转
- [ ] `server_tokens off`、`expose_php = Off`
- [ ] `data/` 可写,其它目录只读
- [ ] 卷王后台已改默认密码
- [ ] `data/` 已纳入定期备份
## 相关
- [安全总览](./index.md)
- [环境要求与部署](../guide/install.md)
+80
View File
@@ -0,0 +1,80 @@
# 安全总览
平台内建了多层防护,但**部署方式决定了大部分安全性**——请务必同时阅读
[部署加固清单](./hardening.md)。
## 认证
- 采用 PHP Session 鉴权,未登录访问任意页面会被重定向到 `login.php`;
- 会话 Cookie 为 `NYSESS`,`HttpOnly` + `SameSite=Lax`;
- **登录凭据就是卷王后台账密**:平台用它向卷王换取令牌,不维护独立账号体系;
- 卷王令牌缓存在 `data/cache/token.json`;令牌失效时平台会自动重新登录;
若卷王侧也认证失败,前端会先调用 `logout.php` 清会话再跳转登录页,避免出现跳转死循环。
## 登录限流
连续 **5 次**登录失败 → 锁定 **10 分钟**。状态记录在 `data/login_fail.json`。
应急解锁:删除 `data/login_fail.json`。
## CSRF 防护
- 会话绑定 CSRF 令牌:`ny_csrf_token()` 生成,`ny_csrf_ok()` 校验;
- 令牌通过 `config_get` 接口下发给前端,前端在写请求的 JSON 体 `_csrf` 字段与
`X-CSRF-Token` 头中自动携带;
- 后端对写操作维护**白名单** `$WRITE_ACTIONS`,命中白名单的请求一律校验:
`config_save` / `roster_save` / `roster_import` / `roster_clear` /
`snapshot_save` / `snapshot_delete` / `unmatched_map` / `project_config_save`。
## 文件上传
::: danger 历史风险(已修复)
早期版本中上传文件按 `roster_YYYYMMDD_HHMMSS.<原始扩展名>` 保存在 `data/uploads/`,
存在两个问题:
1. **扩展名未校验**——上传 `.php` 会被存成 `roster_20260910_180400.php`;
2. **文件名可枚举**——按时间 1 秒粒度即可遍历。
在 nginx + PHP-FPM 下(`.htaccess` 无效),这构成**任意文件上传 → 远程代码执行(RCE)**。
:::
当前实现(四层防护):
| 层 | 措施 |
| --- | --- |
| 1 | **扩展名白名单**:仅允许 `.xlsx` / `.xlsm` / `.csv` / `.txt`,其余直接拒绝 |
| 2 | **随机文件名**:`bin2hex(random_bytes(16))`,无法枚举 |
| 3 | **解析后立即删除**:`@unlink($dest)`,Web 目录下不留存任何上传文件 |
| 4 | **大小限制**:单个文件 ≤ 20 MB;同时建议 nginx 配置 `client_max_body_size 20m` |
另外,上传接口属于写操作,同样受 CSRF 保护。
## XSS / 注入
- 所有输出到 HTML 的值均经过 `htmlspecialchars()`;
- 导出 XLSX 时单元格统一写入**共享字符串**并施加 `numFmtId=49` 文本样式,
天然防止 CSV / Excel 公式注入,也避免长学号被转成科学计数法;
- 数据访问全部使用 PDO 预处理语句 / 参数绑定。
## 目录保护
程序启动时会在 `data/`、`data/cache/`、`data/uploads/`、`data/snapshots/`
自动生成 `.htaccess`(`Deny from all`)与 `index.html` 占位。
> **nginx 不读取 `.htaccess`**,必须显式配置。见 [部署加固清单](./hardening.md)。
## 建议的额外措施
| 措施 | 说明 |
| --- | --- |
| 全站 HTTPS | 登录密码经 RSA 加密,但仍建议全站 TLS |
| IP 白名单 | 内网使用时在 nginx 层限制来源 IP |
| 隐藏版本号 | 关闭 `expose_php`、隐藏 nginx 版本号 |
| 定期备份 | 备份 `data/`(配置、名单、快照、SQLite 库) |
| 最小权限 | PHP-FPM 进程只对 `data/` 可写,其它目录只读 |
| 改默认密码 | 卷王后台默认 `admin/123456` 务必修改 |
## 相关
- [部署加固清单](./hardening.md)
- [环境要求与部署](../guide/install.md)
+1 -5
View File
@@ -26,11 +26,7 @@ $rosterCount = Roster::count();
<header class="topbar"> <header class="topbar">
<div class="brand"> <div class="brand">
<span class="logo">NY</span> <h1 class="brand-title">NY问卷考试系统数据平台</h1>
<div class="brand-text">
<strong>随机抽取</strong>
<small>名单 / 已提交人员 → 卷王白名单</small>
</div>
</div> </div>
<div class="top-right"> <div class="top-right">
<a class="btn" href="index.php#roster" title="前往主系统管理学号名单">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></a> <a class="btn" href="index.php#roster" title="前往主系统管理学号名单">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></a>
+79 -17
View File
@@ -22,11 +22,7 @@ $rosterCount = Roster::count();
<header class="topbar"> <header class="topbar">
<div class="brand"> <div class="brand">
<span class="logo">NY</span> <h1 class="brand-title">NY问卷考试系统数据平台</h1>
<div class="brand-text">
<strong>NY问卷考试系统数据平台</strong>
<small>卷王问卷系统 · 实时数据统计</small>
</div>
</div> </div>
<div class="top-right"> <div class="top-right">
<div id="conn" class="conn conn-off"><i></i><span>未连接</span></div> <div id="conn" class="conn conn-off"><i></i><span>未连接</span></div>
@@ -99,6 +95,7 @@ $rosterCount = Roster::count();
<button class="btn" id="btnSnapshot" title="保存当前成绩为一份带标签的时间点快照">存快照</button> <button class="btn" id="btnSnapshot" title="保存当前成绩为一份带标签的时间点快照">存快照</button>
<button class="btn" id="btnTrend" title="查看本卷各快照间每名学生的成绩趋势">趋势</button> <button class="btn" id="btnTrend" title="查看本卷各快照间每名学生的成绩趋势">趋势</button>
<button class="btn" id="btnAnomaly" title="异常检测线索:短时长高分 / 高频重考 / 雷同成绩">异常</button> <button class="btn" id="btnAnomaly" title="异常检测线索:短时长高分 / 高频重考 / 雷同成绩">异常</button>
<button class="btn btn-primary" id="btnProjCfg" title="为当前选中的试卷单独设置满分 / 合格率 / 免考名单">试卷设置</button>
</div> </div>
</section> </section>
@@ -109,7 +106,7 @@ $rosterCount = Roster::count();
<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>
<div class="card accent-indigo"><span>合格率(≥60%满分)</span><b id="sPass">-</b></div> <div class="card accent-indigo"><span>合格率<span id="sPassLabel">(≥60%满分)</span></span><b id="sPass">-</b></div>
<div class="card accent-gray"><span>平均用时</span><b id="sDur">-</b></div> <div class="card accent-gray"><span>平均用时</span><b id="sDur">-</b></div>
</section> </section>
@@ -119,6 +116,7 @@ $rosterCount = Roster::count();
<h2>成绩明细 <small id="tableHint"></small></h2> <h2>成绩明细 <small id="tableHint"></small></h2>
<div class="updated"> <div class="updated">
<button class="btn sm" id="btnClearHl" hidden title="清除新数据的绿色高亮标记(每 30 秒自动清除一次)">清除高亮 <b id="hlBadge">0</b></button> <button class="btn sm" id="btnClearHl" hidden title="清除新数据的绿色高亮标记(每 30 秒自动清除一次)">清除高亮 <b id="hlBadge">0</b></button>
<button class="btn sm" id="btnPrintTable" title="打印成绩明细(默认按成绩降序,可选分组对比)">打印</button>
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i> 最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
</div> </div>
</div> </div>
@@ -197,9 +195,14 @@ $rosterCount = Roster::count();
<p class="hint">实际生效上限 200,超出会自动夹到 200(防止单页内存耗尽)</p> <p class="hint">实际生效上限 200,超出会自动夹到 200(防止单页内存耗尽)</p>
</div> </div>
<div class="fi"> <div class="fi">
<label>默认满分(用于合格率)</label> <label>全局默认满分</label>
<input type="number" id="cfgFull" min="1" max="1000"> <input type="number" id="cfgFull" min="1" max="1000">
<p class="hint" id="cfgFullHint">未选中试卷时的默认值</p> <p class="hint" id="cfgFullHint">全局默认;每份试卷可在主页面「试卷设置」中单独覆盖</p>
</div>
<div class="fi">
<label>全局默认合格率(%)</label>
<input type="number" id="cfgPassRate" min="1" max="100" step="1">
<p class="hint">达到满分的该百分比即算合格;每份试卷可单独覆盖</p>
</div> </div>
<div class="fi"> <div class="fi">
<label>历史成绩展示方式</label> <label>历史成绩展示方式</label>
@@ -224,15 +227,6 @@ $rosterCount = Roster::count();
<input type="text" id="cfgKeywords"> <input type="text" id="cfgKeywords">
<p class="hint">英文逗号分隔,用于自动识别"姓名"题目</p> <p class="hint">英文逗号分隔,用于自动识别"姓名"题目</p>
</div> </div>
<div class="fi full">
<label>各试卷独立满分</label>
<div id="fullScoresBox" class="fullscores">
<span class="hint">未选中试卷时,此处为空;选中某套试卷后保存即在设置中为该卷单独设定满分</span>
</div>
<!-- 注意:#fullScoresHint 必须放在 #fullScoresBox 之外,
否则 renderFullScores() 的 box.innerHTML 覆盖会把它销毁,导致 textContent 赋值报 null -->
<p class="hint" id="fullScoresHint">给某卷填入数字即可单独覆盖默认满分;留空=用默认</p>
</div>
<div class="fi full checks"> <div class="fi full checks">
<label><input type="checkbox" id="cfgDedupe"> 同一学号只保留最新一份答卷</label> <label><input type="checkbox" id="cfgDedupe"> 同一学号只保留最新一份答卷</label>
<label><input type="checkbox" id="cfgTemp"> 包含"暂存"未提交的答卷</label> <label><input type="checkbox" id="cfgTemp"> 包含"暂存"未提交的答卷</label>
@@ -435,6 +429,74 @@ $rosterCount = Roster::count();
</div> </div>
</div> </div>
<!-- 成绩明细打印设置 -->
<div class="modal" id="printTableModal">
<div class="modal-box">
<div class="modal-head"><h3>打印成绩明细</h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<div class="fi">
<label>排序方式</label>
<select id="ptSort">
<option value="score_desc" selected>按成绩降序(默认)</option>
<option value="score_asc">按成绩升序</option>
<option value="sid">按学号</option>
<option value="time">按提交时间</option>
</select>
</div>
<div class="fi">
<label>分组对比</label>
<select id="ptGroup">
<option value="none" selected>不分组(连续打印)</option>
<option value="cls">按班级分组</option>
<option value="grp">按组别分组</option>
</select>
<p class="hint">分组后每个班/组单独一段,段首显示小计(人数/平均分/合格率)</p>
</div>
<div class="fi checks">
<label><input type="checkbox" id="ptRank" checked> 显示名次</label>
<label><input type="checkbox" id="ptAttempts" checked> 显示考试次数</label>
</div>
<p class="hint" id="ptHint"></p>
</div>
<div class="modal-foot">
<span class="spacer"></span>
<button class="btn" data-close>取消</button>
<button class="btn btn-primary" id="btnPtPrint">打印</button>
</div>
</div>
</div>
<!-- 试卷独立设置(满分 / 合格率 / 免考名单)——仅对当前选中试卷生效 -->
<div class="modal" id="projCfgModal">
<div class="modal-box">
<div class="modal-head"><h3>试卷设置 <small id="projCfgSub"></small></h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<p class="hint" style="margin:0 0 12px">以下设置<b>仅对当前选中的这份试卷生效</b>,各试卷之间互不影响。留空表示跟随全局默认。</p>
<div class="fi">
<label>本卷满分</label>
<input type="number" id="pcFull" min="1" max="1000" placeholder="留空=用全局默认">
<p class="hint" id="pcFullHint"></p>
</div>
<div class="fi">
<label>本卷合格率(%)</label>
<input type="number" id="pcPassRate" min="1" max="100" step="1" placeholder="留空=用全局默认">
<p class="hint" id="pcPassHint"></p>
</div>
<div class="fi">
<label>免考学生(学号)</label>
<textarea id="pcExempt" rows="4" placeholder="每行一个学号,也可用逗号分隔"></textarea>
<p class="hint">免考学生不计入「名单总人数」与「缺考名单」</p>
</div>
</div>
<div class="modal-foot">
<button class="btn danger" id="btnPcReset">清除本卷设置</button>
<span class="spacer"></span>
<button class="btn" data-close>取消</button>
<button class="btn btn-primary" id="btnPcSave">保存</button>
</div>
</div>
</div>
<!-- 异常检测线索 --> <!-- 异常检测线索 -->
<div class="modal" id="anomalyModal"> <div class="modal" id="anomalyModal">
<div class="modal-box wide"> <div class="modal-box wide">
+68
View File
@@ -79,11 +79,73 @@ class DataService
/** /**
* 组装结果 * 组装结果
*/ */
/**
* 读取某份试卷的独立配置(满分 / 合格率 / 免考名单)。
* 优先 SQLite(ny_project_config 表);不可用时退回 config.json 里的 project_config 字段,
* 保证没有 pdo_sqlite 的环境也能用(只是隔离粒度仍在,只是落到文件)。
*
* @return array{full_score:?int,pass_rate:?float,exempt:array<int,string>}
*/
public static function projectConfig(string $projectId): array
{
$def = ['full_score' => null, 'pass_rate' => null, 'exempt' => []];
if ($projectId === '') {
return $def;
}
if (class_exists('Db') && Db::pdo() !== null) {
return Db::projectConfigGet($projectId);
}
// 文件回退
$all = [];
$saved = ny_read_json(NY_DATA . '/project_config.json', null);
if (is_array($saved)) {
$all = $saved;
}
$one = $all[$projectId] ?? null;
return is_array($one) ? array_merge($def, $one) : $def;
}
/** 写入某份试卷的独立配置 */
public static function projectConfigSave(string $projectId, array $cfg): void
{
if ($projectId === '') {
return;
}
if (class_exists('Db') && Db::pdo() !== null) {
Db::projectConfigSet($projectId, $cfg);
return;
}
$all = [];
$saved = ny_read_json(NY_DATA . '/project_config.json', null);
if (is_array($saved)) {
$all = $saved;
}
$all[$projectId] = $cfg;
ny_write_json(NY_DATA . '/project_config.json', $all);
}
public static function build(array $cfg, string $projectId, string $nameQid = ''): array public static function build(array $cfg, string $projectId, string $nameQid = ''): array
{ {
$client = new SurveyKingClient($cfg); $client = new SurveyKingClient($cfg);
// 每卷独立配置(满分 / 合格率 / 免考名单):试卷之间互相隔离
$pCfg = self::projectConfig($projectId);
$exempt = [];
foreach ((array)($pCfg['exempt'] ?? []) as $sid) {
$sid = trim((string)$sid);
if ($sid !== '') {
$exempt[$sid] = true;
}
}
$students = Roster::all(); $students = Roster::all();
// 免考:从名单中剔除,使其既不计入「总人数」也不计入「缺考」
if ($exempt !== []) {
$students = array_values(array_filter(
$students,
static fn(array $s): bool => !isset($exempt[trim((string)($s['sid'] ?? ''))])
));
}
// 姓名 -> 学号 / 班级(规范化) // 姓名 -> 学号 / 班级(规范化)
$studentInfo = []; $studentInfo = [];
foreach ($students as $s) { foreach ($students as $s) {
@@ -313,6 +375,12 @@ class DataService
'anomalies' => $anomalies, 'anomalies' => $anomalies,
'roster' => $students, 'roster' => $students,
'roster_count' => count($students), 'roster_count' => count($students),
// 本卷独立配置(前端据此回显满分/合格率/免考);full_score 已按本卷解析
'project_cfg' => [
'full_score' => $pCfg['full_score'] ?? null,
'pass_rate' => $pCfg['pass_rate'] ?? null,
'exempt' => array_values(array_map('strval', array_keys($exempt))),
],
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
]; ];
} }
+379 -51
View File
@@ -1,83 +1,411 @@
<?php <?php
/** /**
* 数据库适配层(旧:pgsql/mysql/sqlite 三方言支持) * 数据库适配层 —— SQLite 单文件后端(零配置、无需额外服务)
* 本批次已下线,仅保留文件存储。如未来需要接入 DB,请在此文件实现 PDO 单例。 *
* 设计要点:
* 1. 数据文件固定为 data/ny.db(SQLite),不需要用户配置任何连接信息。
* 2. 若环境没有 pdo_sqlite 扩展,整个层自动降级为「不可用」,
* 上层(Roster / 配置)会回退到 JSON 文件存储 —— 保证老环境不会挂。
* 3. 所有建表/加列都是幂等的(IF NOT EXISTS / 探测列后 ALTER),可重复执行。
* 4. 首次启用时自动把已有的 data/roster.json、data/config.json 迁移进来,
* 老数据不丢(迁移只发生在目标表为空时)。
*
* 表结构:
* ny_settings (k TEXT PK, v TEXT) 全局配置(键 'config' 存整份配置 JSON)
* ny_roster (sid TEXT PK, name, cls, grp, login)
* ny_project_config (project_id TEXT PK, config_json TEXT) 每份试卷独立配置(满分/合格率/免考,试卷间隔离)
*/ */
declare(strict_types=1); declare(strict_types=1);
class Db class Db
{ {
/** @var PDO|null 当前不可用,始终返回 null */
private static ?PDO $pdo = null; private static ?PDO $pdo = null;
private static string $err = '';
public static function isConfigured(): bool private static bool $initTried = false;
{
return false;
}
public static function isConnected(): bool
{
return false;
}
public static function driver(): string
{
return '';
}
public static function lastError(): string
{
return '';
}
public static function params(): array
{
return [];
}
public static function saveParams(array $p): bool
{
return false;
}
public static function connect(array $p): PDO
{
throw new RuntimeException('数据库后端已下线,请使用文件存储(data/config.json / data/roster.json)');
}
public static function pdo(): ?PDO
{
return null;
}
public static function prefix(): string public static function prefix(): string
{ {
return 'ny_'; return 'ny_';
} }
public static function migrate(): int /** 数据文件路径 */
public static function dbFile(): string
{ {
return 0; return NY_DATA . '/ny.db';
} }
public static function ensureRosterLogin(): void {} /** 是否具备启用条件(有 pdo_sqlite 扩展) */
public static function isConfigured(): bool
{
return extension_loaded('pdo_sqlite');
}
/** 是否已真正连上 */
public static function isConnected(): bool
{
return self::pdo() !== null;
}
public static function driver(): string
{
return self::pdo() !== null ? 'sqlite' : '';
}
public static function lastError(): string
{
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 而不抛异常(上层要能优雅降级) */
public static function connect(array $p = []): PDO
{
if (self::$pdo instanceof PDO) {
return self::$pdo;
}
if (!self::isConfigured()) {
self::$err = 'PHP 未启用 pdo_sqlite 扩展,已回退到文件存储';
throw new RuntimeException(self::$err);
}
$file = self::dbFile();
$dir = dirname($file);
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
try {
$pdo = new PDO('sqlite:' . $file, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
// WAL + 合理超时,避免多人同时刷新时 "database is locked"
$pdo->exec('PRAGMA journal_mode=WAL');
$pdo->exec('PRAGMA synchronous=NORMAL');
$pdo->exec('PRAGMA busy_timeout=5000');
$pdo->exec('PRAGMA foreign_keys=ON');
self::$pdo = $pdo;
self::$err = '';
self::migrate();
return $pdo;
} catch (Throwable $e) {
self::$pdo = null;
self::$err = $e->getMessage();
throw new RuntimeException('SQLite 连接失败:' . $e->getMessage());
}
}
/** 获取连接(不可用返回 null) */
public static function pdo(): ?PDO
{
if (self::$pdo instanceof PDO) {
return self::$pdo;
}
if (self::$initTried) {
return null; // 已经试过且失败,不再反复重试(每次请求最多试一次)
}
self::$initTried = true;
try {
return self::connect();
} catch (Throwable $e) {
return null;
}
}
/** 建表 + 索引(幂等,可重复调用) */
public static function migrate(): int
{
$pdo = self::$pdo;
if (!$pdo) {
return 0;
}
$p = self::prefix();
$pdo->exec("CREATE TABLE IF NOT EXISTS {$p}settings (
k TEXT PRIMARY KEY,
v TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL DEFAULT 0
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$p}roster (
sid TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
cls TEXT NOT NULL DEFAULT '',
grp TEXT NOT NULL DEFAULT '',
login TEXT NOT NULL DEFAULT '',
sort INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$p}project_config (
project_id TEXT PRIMARY KEY,
config_json TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL DEFAULT 0
)");
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_{$p}roster_cls ON {$p}roster(cls)");
self::ensureRosterLogin();
return 3;
}
/** 旧库缺少 login 列时补上(幂等) */
public static function ensureRosterLogin(): void
{
$pdo = self::$pdo;
if (!$pdo) {
return;
}
$p = self::prefix();
try {
$cols = $pdo->query("PRAGMA table_info({$p}roster)")->fetchAll(PDO::FETCH_ASSOC);
$has = false;
foreach ($cols as $c) {
if (($c['name'] ?? '') === 'login') {
$has = true;
break;
}
}
if (!$has) {
$pdo->exec("ALTER TABLE {$p}roster ADD COLUMN login TEXT NOT NULL DEFAULT ''");
}
} catch (Throwable $e) {
self::$err = $e->getMessage();
}
}
// ---------------- 设置 ----------------
public static function settingsAll(): array public static function settingsAll(): array
{ {
return []; $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 [];
}
} }
public static function settingsSet(string $k, string $v): void {} /** SQLite 用 ON CONFLICT 做 upsert(其它方言见历史版本) */
public static function settingsSet(string $k, string $v): void
{
$pdo = self::pdo();
if (!$pdo) {
return;
}
try {
$p = self::prefix();
$st = $pdo->prepare(
"INSERT INTO {$p}settings (k, v, updated_at) VALUES (?, ?, ?)
ON CONFLICT(k) DO UPDATE SET v=excluded.v, updated_at=excluded.updated_at"
);
$st->execute([$k, $v, time()]);
} catch (Throwable $e) {
self::$err = $e->getMessage();
}
}
public static function settingsGet(string $k, string $def = ''): string
{
$pdo = self::pdo();
if (!$pdo) {
return $def;
}
try {
$p = self::prefix();
$st = $pdo->prepare("SELECT v FROM {$p}settings WHERE k = ?");
$st->execute([$k]);
$v = $st->fetchColumn();
return $v === false ? $def : (string)$v;
} catch (Throwable $e) {
self::$err = $e->getMessage();
return $def;
}
}
// ---------------- 名单 ----------------
public static function rosterAll(): array public static function rosterAll(): array
{ {
return []; $pdo = self::pdo();
if (!$pdo) {
return [];
}
try {
$p = self::prefix();
$st = $pdo->query("SELECT sid, name, cls, grp, login FROM {$p}roster ORDER BY sort ASC, sid ASC");
$out = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
$out[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
'grp' => (string)($r['grp'] ?? ''),
'login' => (string)($r['login'] ?? ''),
];
}
return $out;
} catch (Throwable $e) {
self::$err = $e->getMessage();
return [];
}
} }
public static function rosterReplace(array $students): void {} /** 全量替换名单(roster_save 语义) */
public static function rosterReplace(array $students): void
{
$pdo = self::pdo();
if (!$pdo) {
return;
}
try {
$p = self::prefix();
$pdo->beginTransaction();
$pdo->exec("DELETE FROM {$p}roster");
$st = $pdo->prepare(
"INSERT INTO {$p}roster (sid, name, cls, grp, login, sort, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)"
);
$i = 0;
foreach ($students as $s) {
if (!is_array($s)) {
continue;
}
$st->execute([
(string)($s['sid'] ?? ''),
(string)($s['name'] ?? ''),
(string)($s['cls'] ?? ''),
(string)($s['grp'] ?? ''),
(string)($s['login'] ?? ''),
$i++,
time(),
]);
}
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
self::$err = $e->getMessage();
}
}
public static function rosterCount(): int public static function rosterCount(): int
{ {
return 0; $pdo = self::pdo();
if (!$pdo) {
return 0;
}
try {
$p = self::prefix();
return (int)$pdo->query("SELECT COUNT(*) FROM {$p}roster")->fetchColumn();
} catch (Throwable $e) {
self::$err = $e->getMessage();
return 0;
}
}
// ---------------- 每卷独立配置(满分 / 合格率 / 免考名单) ----------------
/** @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 单卷配置(缺省返回空结构) */
public static function projectConfigGet(string $projectId): array
{
$def = ['full_score' => null, 'pass_rate' => null, 'exempt' => []];
if ($projectId === '') {
return $def;
}
$pdo = self::pdo();
if (!$pdo) {
return $def;
}
try {
$p = self::prefix();
$st = $pdo->prepare("SELECT config_json FROM {$p}project_config WHERE project_id = ?");
$st->execute([$projectId]);
$v = $st->fetchColumn();
if ($v === false) {
return $def;
}
$j = json_decode((string)$v, true);
return is_array($j) ? array_merge($def, $j) : $def;
} catch (Throwable $e) {
self::$err = $e->getMessage();
return $def;
}
}
/** 写入单卷配置(整份覆盖;null 表示"用全局默认") */
public static function projectConfigSet(string $projectId, array $cfg): void
{
if ($projectId === '') {
return;
}
$pdo = self::pdo();
if (!$pdo) {
return;
}
try {
$p = self::prefix();
$st = $pdo->prepare(
"INSERT INTO {$p}project_config (project_id, config_json, updated_at) VALUES (?, ?, ?)
ON CONFLICT(project_id) DO UPDATE SET config_json=excluded.config_json, updated_at=excluded.updated_at"
);
$st->execute([$projectId, json_encode($cfg, JSON_UNESCAPED_UNICODE), time()]);
} catch (Throwable $e) {
self::$err = $e->getMessage();
}
}
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();
}
} }
} }
+43 -1
View File
@@ -19,10 +19,49 @@ class Roster
return class_exists('Db') && Db::pdo() !== null; return class_exists('Db') && Db::pdo() !== null;
} }
/**
* 首次启用 SQLite 时,把已有的 data/roster.json 迁进数据库(老数据不丢)。
* 只在「DB 名单为空 且 文件有数据」时执行一次,之后由 DB 全权负责。
*/
private static function migrateFileToDbOnce(): void
{
if (!self::useDb()) {
return;
}
try {
if (Db::rosterCount() > 0) {
return; // DB 已有数据,不覆盖
}
$d = ny_read_json(self::file(), null);
if (!is_array($d) || empty($d['students']) || !is_array($d['students'])) {
return;
}
$clean = [];
foreach ($d['students'] as $s) {
if (!is_array($s)) {
continue;
}
$clean[] = [
'sid' => trim((string)($s['sid'] ?? '')),
'name' => trim((string)($s['name'] ?? '')),
'cls' => trim((string)($s['cls'] ?? '')),
'grp' => trim((string)($s['grp'] ?? '')),
'login' => trim((string)($s['login'] ?? '')),
];
}
if ($clean !== []) {
Db::rosterReplace($clean);
}
} catch (Throwable $e) {
// 迁移失败不影响主流程:仍可退回文件读取
}
}
/** @return array<int,array{sid:string,name:string,cls:string}> */ /** @return array<int,array{sid:string,name:string,cls:string}> */
public static function all(): array public static function all(): array
{ {
if (self::useDb()) { if (self::useDb()) {
self::migrateFileToDbOnce();
return Db::rosterAll(); return Db::rosterAll();
} }
$d = ny_read_json(self::file(), null); $d = ny_read_json(self::file(), null);
@@ -105,7 +144,10 @@ class Roster
public static function fingerprint(): string public static function fingerprint(): string
{ {
if (self::useDb()) { if (self::useDb()) {
return 'db:' . self::count(); // 人数 + 内容哈希:名单内容变了指纹才变(只靠人数会在"换人"时误命中缓存)
$n = self::count();
$hash = md5(json_encode(self::all(), JSON_UNESCAPED_UNICODE));
return 'db:' . $n . ':' . $hash;
} }
$f = self::file(); $f = self::file();
$mt = is_file($f) ? (string)filemtime($f) : '0'; $mt = is_file($f) ? (string)filemtime($f) : '0';
+1 -1
View File
@@ -17,7 +17,7 @@ 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; do for f in tools/test-config-save.js tools/test-draw-alloc.js tools/test-upload-guard.js; do
echo "" echo ""
echo ">>> $f" echo ">>> $f"
"$NODE" "$f" "$NODE" "$f"
+55 -27
View File
@@ -20,6 +20,17 @@ const hasToken = fs.existsSync(tokenFile);
const tokenSnap = hasToken ? fs.readFileSync(tokenFile, 'utf8') : null; const tokenSnap = hasToken ? fs.readFileSync(tokenFile, 'utf8') : null;
if (hasToken) fs.renameSync(tokenFile, tokenBak); if (hasToken) fs.renameSync(tokenFile, tokenBak);
// SQLite 优先:若不先移走 data/ny.db,里面的旧配置会盖掉本测试写入的 config.json,
// 导致登录用的是上一次遗留的账号密码(表现为"登录成功但 auth_check=false")。
// 同时清掉登录限流记录,避免上一轮失败把本轮锁住。
const dbFile = path.join(ROOT, 'data', 'ny.db');
const dbBak = dbFile + '.bak';
const hasDb = fs.existsSync(dbFile);
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',
@@ -51,6 +62,14 @@ function cleanup() {
try { try {
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile); if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
} catch(e) { console.error('cleanup token failed:', e.message); } } catch(e) { console.error('cleanup token failed:', e.message); }
// 恢复 SQLite(本轮测试新建的 ny.db 丢弃,换回原有的)
try {
if (fs.existsSync(dbFile)) fs.unlinkSync(dbFile);
if (hasDb && fs.existsSync(dbBak)) fs.renameSync(dbBak, dbFile);
} 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); });
@@ -235,35 +254,44 @@ async function login() {
const after = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : ''; const after = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
chk('token 保持未变', before === after, 'changed=' + (before !== after)); chk('token 保持未变', before === after, 'changed=' + (before !== after));
console.log('\n--- 4) 各试卷独立满分:仅当 apply_pid 显式指定时写 ---'); console.log('\n--- 4) 试卷独立配置(满分/合格率/免考)已从设置移出,改走 project_config ---');
// 不传 apply_pid,也不传 full_scores → full_scores 应保留原值(保持空映射) // 每卷满分已不在 config_save 里;改为 project_config_save(试卷间隔离)
const cs2 = await req('POST', '/api.php?action=config_save', { const pc1 = await req('POST', '/api.php?action=project_config_save&projectId=p1', {
full_score: 90, full_score: 150, pass_rate: 70, exempt: 'S1,S2',
}); });
const _cs2fs = cs2.body && cs2.body.data && cs2.body.data.config chk('project_config_save 200', pc1.status === 200, 'status=' + pc1.status + ' ' + JSON.stringify(pc1.body));
? cs2.body.data.config.full_scores : undefined; chk('生效满分=150', pc1.body && pc1.body.data && pc1.body.data.effective
chk('未传 apply_pid → full_scores 保持空映射(不被覆盖为非空)', && pc1.body.data.effective.full_score === 150,
_cs2fs === undefined || Object.keys(_cs2fs).length === 0, JSON.stringify(pc1.body && pc1.body.data && pc1.body.data.effective));
JSON.stringify(_cs2fs)); chk('生效合格率=70', pc1.body && pc1.body.data && pc1.body.data.effective
// 显式 apply_pid='p1', apply_score=85 → 写入 && Number(pc1.body.data.effective.pass_rate) === 70,
const cs3 = await req('POST', '/api.php?action=config_save', { JSON.stringify(pc1.body && pc1.body.data && pc1.body.data.effective));
full_score: 90,
apply_pid: 'p1', // 隔离性:p2 未配置 → 跟随全局(注:上面的 config_save 已把全局满分改成 95,
apply_score: 85, // 所以这里不能写死 100,必须与响应里的 global 比对)
const pc2 = await req('GET', '/api.php?action=project_config&projectId=p2');
const gFs = pc2.body && pc2.body.data && pc2.body.data.global ? pc2.body.data.global.full_score : null;
const gPr = pc2.body && pc2.body.data && pc2.body.data.global ? pc2.body.data.global.pass_rate : null;
chk('p2 隔离:满分跟随全局(=' + gFs + ')', pc2.body && pc2.body.data && pc2.body.data.effective
&& pc2.body.data.effective.full_score === gFs,
JSON.stringify(pc2.body && pc2.body.data && pc2.body.data.effective) + ' global=' + gFs);
chk('p2 隔离:合格率跟随全局(=' + gPr + ')', pc2.body && pc2.body.data && pc2.body.data.effective
&& Number(pc2.body.data.effective.pass_rate) === Number(gPr),
JSON.stringify(pc2.body && pc2.body.data && pc2.body.data.effective) + ' global=' + gPr);
// 免考名单往返
const pc3 = await req('GET', '/api.php?action=project_config&projectId=p1');
const ex = pc3.body && pc3.body.data && pc3.body.data.cfg && pc3.body.data.cfg.exempt;
chk('免考名单往返 = [S1,S2]', Array.isArray(ex) && ex.length === 2 && ex[0] === 'S1' && ex[1] === 'S2',
JSON.stringify(ex));
// 清除本卷设置 → 回到全局
const pc4 = await req('POST', '/api.php?action=project_config_save&projectId=p1', {
full_score: '', pass_rate: '', exempt: '',
}); });
chk('apply_pid=85 → full_scores.p1=85', cs3.body && cs3.body.data && cs3.body.data.config chk('清除后 p1 回到全局满分(=' + gFs + ')', pc4.body && pc4.body.data && pc4.body.data.effective
&& cs3.body.data.config.full_scores && cs3.body.data.config.full_scores.p1 === 85, && pc4.body.data.effective.full_score === gFs,
JSON.stringify(cs3.body.data && cs3.body.data.config && cs3.body.data.config.full_scores)); JSON.stringify(pc4.body && pc4.body.data && pc4.body.data.effective) + ' global=' + gFs);
// apply_pid='p1', apply_score=0 → 清除
const cs4 = await req('POST', '/api.php?action=config_save', {
full_score: 90,
apply_pid: 'p1',
apply_score: 0,
});
chk('apply_pid=0 → 清除 p1',
cs4.body && cs4.body.data && cs4.body.data.config
&& (!cs4.body.data.config.full_scores || cs4.body.data.config.full_scores.p1 === undefined),
JSON.stringify(cs4.body.data && cs4.body.data.config && cs4.body.data.config.full_scores));
console.log('\n--- 5) 修改连接信息:应清 token + 重新登录(ping)---'); console.log('\n--- 5) 修改连接信息:应清 token + 重新登录(ping)---');
const tokBefore = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : ''; const tokBefore = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
+260
View File
@@ -0,0 +1,260 @@
// e2e: 上传文件防护(扩展名白名单 + 随机文件名 + 解析后即删)
// 运行:node tools/test-upload-guard.js (NY_DEBUG=1 可打印调试信息)
//
// 覆盖的漏洞:早期版本按 roster_YYYYMMDD_HHMMSS.<原始扩展名> 存在 data/uploads/,
// 既不做扩展名校验(可上传 .php 造成 RCE),文件名又可枚举;且 nginx 下 .htaccess 无效。
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 = 18081;
const MOCK_PORT = 19911;
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
const DEBUG = !!process.env.NY_DEBUG;
const DATA = path.join(ROOT, 'data');
const UPLOADS = path.join(DATA, 'uploads');
const throttleFile = path.join(DATA, 'login_fail.json');
// ---------- 备份 / 还原运行时数据 ----------
// ny.db 必须移走:配置是 DB 优先,留着库里的真实配置会盖掉本测试写入的 config.json。
// roster.json 只能复制不能移走:mock-server 启动时要读它,移走会让 mock 直接崩。
const moved = []; // [路径, 备份路径] —— 移走
const 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) { console.error(' 备份失败(移走)' + f + ':' + e.message); }
}
}
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) { 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');
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) {}
}
clearThrottle();
// 统一用「覆盖写回 + 尽力删除备份」的方式还原,避免删除被拦导致还原失败
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' });
mock.on('exit', (code) => { if (DEBUG) console.log(' [debug] mock 退出,code=' + code); });
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' });
/** 轮询直到端口可连接(避免使用固定 sleep 导致的偶发失败) */
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);
});
})();
});
}
// ---------- HTTP 辅助 ----------
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\n` +
'Content-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`));
const body = Buffer.concat(parts);
return request('POST', urlPath,
Object.assign({ 'Content-Type': 'multipart/form-data; boundary=' + boundary }, baseHeaders()), body);
}
function listUploads() {
if (!fs.existsSync(UPLOADS)) return [];
return fs.readdirSync(UPLOADS).filter(f => f !== '.htaccess' && f !== 'index.html');
}
// ---------- 断言 ----------
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);
}
// 登录(失败会触发 5 次锁定,所以每次尝试前清掉限流记录)
for (let i = 0; i < 3 && CSRF === ''; i++) {
clearThrottle(); // 上一次尝试若失败会累计限流,这里先清零
const lg = await formPost('/login.php', { username: 'mock', password: 'surveyking' });
const ac = await get('/api.php?action=auth_check');
if (DEBUG) {
const m = (lg.raw || '').match(/登录失败[\s\S]{0,160}?<\/div>|尝试次数[^<]*/);
console.log(' [debug] login ' + lg.status + ' auth=' + JSON.stringify(ac.body && ac.body.data) +
' err=' + (m ? m[0].replace(/\s+/g, ' ').slice(0, 160) : '(none)'));
}
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) 危险扩展名必须被拒绝 ---');
const evil = await upload('/api.php?action=roster_import', { mode: 'replace', _csrf: CSRF }, {
filename: 'shell.php', content: '<?php system($_GET["c"]); ?>',
});
// 失败响应字段为 msg(ny_fail 输出 {"ok":false,"msg":...})
const evilMsg = (evil.body && (evil.body.error || evil.body.msg)) || '';
chk('上传 .php 被拒绝', !!(evil.body && evil.body.ok === false), evilMsg || evil.raw.slice(0, 120));
chk('拒绝原因是扩展名白名单', /仅支持/.test(evilMsg), evilMsg);
const phtml = await upload('/api.php?action=roster_import', { mode: 'replace', _csrf: CSRF }, {
filename: 'a.phtml', content: 'x',
});
chk('上传 .phtml 被拒绝', !!(phtml.body && phtml.body.ok === false));
console.log('\n--- 2) 被拒绝后不留残留文件 ---');
chk('data/uploads 无残留', listUploads().length === 0, JSON.stringify(listUploads()));
console.log('\n--- 3) 合法 csv 正常导入且文件即删 ---');
const csv = '学号,姓名,班级\n90001,测试甲,1班\n90002,测试乙,1班\n';
const good = await upload('/api.php?action=roster_import', { mode: 'replace', _csrf: CSRF }, {
filename: 'roster.csv', content: Buffer.from('\xEF\xBB\xBF' + csv, 'utf8'),
});
chk('合法 .csv 导入成功', !!(good.body && good.body.ok === true),
(good.body && (good.body.error || good.body.msg)) || '');
chk('导入人数为 2', !!(good.body && good.body.data && good.body.data.imported === 2),
good.body ? String(good.body.data && good.body.data.imported) : '');
chk('导入后 data/uploads 已清空', listUploads().length === 0, JSON.stringify(listUploads()));
console.log('\n--- 4) 文件名不可枚举 ---');
const leaked = listUploads().filter(f => /^roster_\d{8}_\d{6}\./.test(f));
chk('不存在可枚举的 roster_时间戳 文件', leaked.length === 0, JSON.stringify(leaked));
console.log('');
console.log(`结果:${pass} 通过 / ${fail} 失败(共 ${pass + fail})`);
cleanup();
process.exit(fail === 0 ? 0 : 1);
})();