Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

UI / 交互 / Bug 修复(#91)
- dashboard.php:补「← 主系统 / 退出」入口(此前全屏后无法离开);
  修复成员下钻 onclick 里 JSON.stringify 双引号提前闭合属性导致点击报错,
  改为 data-sid 事件委托;补 Esc 关弹窗;后台标签页暂停轮询、切回自动补拉;
  刷新中断原因写入 title;数值字段统一兜底,避免 NaN / undefined%
- draw.php / draw.js:结果表新增「登陆账号」列(导出前可核对,缺失显示 学号 ⚠);
  弹窗只关最上层且支持 Esc;「全部」改为跟随候选池,手工改人数即解除;
  候选池不足时给出明确提示;自定义比例框隐藏时清空,避免旧权重被误读
- app.js / dashboard.php:成绩着色改为按「占本卷满分的百分比」判断,
  修复满分不是 100 时整列误标红
- style.css:数值列右对齐;新增 subbar、draw-stage-sub;
  看板顶栏窄屏下拉与按钮换行,消除横向滚动
2026-09-11 16:23:08 +08:00
nxxy335 f271066266 docs: 支持 DOCS_OUT 自定义输出目录(环境禁止清空 dist 时可输出到新目录) 2026-09-10 23:40:28 +08:00
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
nxxy335 0fd880624f feat: 完善/优化/新增大批次 + 安全加固 + 性能优化
- 修复再次提交不置顶(排序键改用 last_submit_ts)
- 后端结果缓存 + 并发锁(DataCache / ny_build_data / Roster 指纹失效)
- 导出 xlsx 多表:按最新成绩 / 按班级分表 / 可选列
- 名单管理:新增改弹窗、删除多选+二次确认、登陆账号列
- 安全:CSRF token + 登录失败限流(5次锁10分钟)
- 未匹配答卷一键加入名单(unmatched_map)
- 缺考名单增强:复制学号/姓名、按班统计、打印
- 成绩快照 + 跨卷趋势(Snapshot)
- 异常检测线索(短时长高分/高频重考/雷同成绩)
- 前端性能:虚拟滚动、搜索防抖、后台暂停、失败退避
- 测试沉淀:test-new-features.php + run-all.sh
2026-09-10 17:59:45 +08:00
nxxy335 87750b20c4 fix: 修复拉取大量答卷时内存耗尽(128M)导致后台刷新失败
- SurveyKingClient::listAnswers/listProjects 单页上限夹在 20~200,
  无论配置 page_size 多大,单次 json_decode 体量都被限制,避免超大响应撑爆 128M
- raw() 不再随返回值常驻完整 HTTP 正文(仅留前 2000 字符用于诊断),
  降低 json_decode 之外的额外内存占用;每页处理后及时释放引用
- bootstrap.php 在未被 php_admin_value 硬锁时临时将 memory_limit 放宽到 512M
  作为安全兜底(触顶场景:/api.php?action=data 拉取卷王全量答卷)

根因:error.log 中 PHP Fatal error Allowed memory size of 134217728 bytes exhausted
位于 SurveyKingClient.php:180 json_decode(),由上传名单后答卷量增大、
单页响应整体解码超出内存上限引发。
2026-09-10 15:45:39 +08:00
nxxy335 a65ec682ce fix: 修复点击设置报 Cannot set properties of null (setting 'textContent')
根因:#fullScoresHint 是 #fullScoresBox 的子元素,而 renderFullScores()
用 box.innerHTML 渲染试卷列表时会把它销毁,紧接着的
$('#fullScoresHint').textContent = ... 即变成 null.textContent 抛错。

只要 state.projects 非空(连上卷王、有可答卷)点设置必触发;
projects 为空时走 early return 反而不报错,故此前长期潜伏未被发现
(自 8686eb9 设置重构引入)。

修复:
- index.php:把 #fullScoresHint 移出 #fullScoresBox,改为兄弟元素,
  并加注释说明为何不能放回去
- assets/app.js:新增 setFullScoresHint() 带 null 守卫,三个分支统一走它;
  即使将来 DOM 结构再变动也不会让整个设置弹窗打不开
2026-09-10 12:50:39 +08:00
nxxy335 6def49b969 fix: 全量提示改为顶部 toast 飘窗 + 修复卷王 token 失效跳转死循环 + 调整行点击防御
- 所有 showAlert 改为 showToast 顶部飘窗(首屏 / 抽取页统一,含空消息守卫)
- 导出排序新增 成绩升序 / 成绩降序 两种(前后端 + 设置弹窗都支持)
- 设置保存:仅连接信息真变化才 reload projects,避免非连接改动导致持续刷新失败
- 强化历史成绩弹窗排序与兜底(缺 submit_ts 时按 idx 倒序)
- 行点击事件加防御:要求父节点是 #tbody,避免被无关 tr 误触发
- 卷王 token 失效时:先 fetch logout 清会话再跳 login.php,登录页也加 token 探测兜底,根治"跳不过去"死循环
- 移除 index.php / draw.php 中已无用的内联 #alert DOM
- 兼容 config_get 缺 export_sort 时回退 time 的 4 模式归一化
2026-09-10 12:30:00 +08:00
nxxy335 137e2cb665 fix: 更新 dedupe 提示文字(修复后考试次数始终完整展示)
- 设置弹窗「只保留最新一份」的说明文字过时:仍写'考试次数恒为 1、
  历史只有一条、需取消勾选',但 aggregateByStudent 已修复为无论 dedupe
  取值都保留真实次数与全部历史。更新为准确描述当前行为。
- 全面审查未发现其它代码级 bug:表头/表体列序一致(学号/姓名/班级/
  组别/成绩/考试次数/提交时间/时长)、attempts 列确实在成绩后面;
  dashboard 看板成员榜的'刷N次'标签在名字旁是紧凑三列布局的刻意设计。
2026-09-10 11:21:32 +08:00
nxxy335 e031b3858e feat: 新数据高亮每 30 秒自动清除一次
- 新增独立定时器 startHlAutoClear:每 hlAutoClearSec(默认30)秒扫描一次,
  若仍有高亮则清空并重新渲染表格与计数徽标;无高亮时不打扰。
- 既保留'比闪一下停留更久'的可见性,又避免绿色底纹长期堆积失去重点。
- 清除按钮 tooltip 同步说明'每 30 秒自动清除一次'。
- 与手动清除(#btnClearHl)、切换项目重置高亮互不冲突。
2026-09-10 10:55:19 +08:00
nxxy335 0174b2abc2 fix: 考试次数与历史始终展示全部提交(不再被 dedupe 吞成 1 条)
- aggregateByStudent 修复:attempts(真实次数) 与 attempts_list(每次提交明细)
  无论 dedupe 取值都使用全部提交;dedupe 仅决定主明细表代表成绩取最新
  还是最高分。此前 dedupe=true(默认)把 attempts 写成 1、attempts_list 只留
  最新一份,导致考试次数列和点击历史都只显示 1 条,多次刷题数据丢失。
- best_score 改为跨全部尝试的真实最高分,修复历史弹窗标题最高分错误。
- 新增 tests/test-aggregate.php 覆盖:11次/2次/1次提交、dedupe 两种取值、
  重考低分、导出展开全部记录,共 17 项断言全部通过。

数据印证:第二周测试(1).xlsx 共 146 份提交 / 35 人,31 人多次提交
(最多 11 次),修复后考试次数与历史均完整呈现。
2026-09-10 10:41:42 +08:00
nxxy335 14c7fa7177 feat: 成绩明细考试次数/历史成绩/导出排序/高亮常亮
- 成绩明细成绩列后新增「考试次数」列(可排序)
- 点击任意成员弹窗查看历史成绩(修复内联 onclick 找不到 IIFE 内函数、多次刷题下钻完全失效的 bug;仅 1 次也能点开)
- 历史成绩展示方式可切换:默认点击成员弹窗;可选「成绩明细内全部展开」(每次考试一行,标注第几次/最佳)
- 新数据绿色底纹常亮(不再黄闪一下):变更检测纳入 attempts/最近提交时间(重考低分也能发现);高亮跨刷新累积,新增「清除高亮」按钮,切换项目/姓名题时清零
- 导出成绩可选排序:按提交时间(默认)或按学号(同学号内按提交时间降序);导出全部考试记录并始终带「考试次数/第几次」列
- 优化排序:空值恒排末尾、主键相同时按学号次级排序;提交时间列显示「最近一次提交」
- 后端:DataService 补 last_submit_ts/last_submit_at/attempts_list 的 idx 与 is_best;新增 expandAttempts 与 sortForExport;修复 expandAttempts 降序比较器写反
- 设置新增 history_display/export_sort 两项配置(局部保存不触发重连、非法值归一化);缺考导出按学号排序
- 设置里补充「只保留最新一份」与考试次数的关系说明
2026-09-10 10:10:02 +08:00
nxxy335 9ee2aec2f0 feat: 全面打磨平台交互
- 名单导入容错:rowsToStudents 加去重/跳过统计、表头正则扩充、.0 尾巴清理;importFile 返回精确 count
- 看板:修复 .repeat(0) 空状态坏代码;新增实时点 stale 指示(手动/自动刷新失败变红 + 提示刷新中断);
        手动刷新按钮加刷新中…反馈;记住自动刷新开关与间隔(localStorage + ?interval= 深链)
- 主统计页:后台静默刷新连续失败时连接指示转实时刷新中断告警;记住用户手动调整的刷新间隔
- 设置页:新增 config_test 端点 + 独立测试连接按钮(用弹窗内未保存凭据测连接,不落盘);
        保存按钮加保存中…防重提交;登录失败不清旧 token
- 抽取页:重置结果加二次确认;新增复制名单按钮(两列制表符分隔,可粘到微信/Excel)
- 修复看板预先存在 JS 语法错误(少一个右括号导致整页 JS 失效)
2026-09-09 18:53:00 +08:00
nxxy335 c4c3bf7da1 修复"未配置卷王登录账号"导致后台刷新陷入死状态
根因:login.php 仅持久化 username、不持久化 password;token 缓存失效后
自动重登因缺少密码而抛 RuntimeException('未配置卷王登录账号'),前端仅提示
"后台刷新失败,已显示本地缓存"却无法自愈,刷新循环永不恢复。

修复:
- login.php:登录成功时同时持久化 username 与 password,token 失效时平台
  可凭凭据自动重新登录卷王,避免死状态。
- api.php:全局 catch 对鉴权类错误(未配置账号/登录失败)打标 extra.auth=true。
- assets/app.js、dashboard.php:api() 透传 auth 标记;刷新失败若为鉴权错误,
  自动跳转 login.php 重新登录,不再停留在本地缓存死状态。

验证:本地 mock 卷王端到端复现 CASE1(空账号精确复现报错)与 CASE2
(凭据齐备自动重登成功),HTTP 接口确认返回 extra.auth:true 与 ok:true。
2026-09-09 17:55:58 +08:00
nxxy335 d2f0908385 feat: 支持单套试卷同一提交人多次刷卷并展示
- DataService::build 新增 aggregateByStudent 按学号聚合:
  dedupe=true 仅保留最新一份;false 则每学号一行(代表成绩取最高分)
  附 attempts(次数) 与 attempts_list(每次提交明细),修正提交人数被多次提交inflate的问题
- api.php dashboard 透传 attempts/attempts_list;statOf 增加 attempts 求和;export 增加刷题次数列
- dashboard.php 成员榜加"刷N次"徽标,点击下钻弹窗看每次提交(时间/成绩/时长/暂存);hero 显示总提交次数
- 主统计页成绩明细同样加"刷N次"徽标与下钻弹窗
- stats 新增 attempts_total
2026-09-09 15:55:06 +08:00
nxxy335 3952c77afe refactor: 取消 install.php 安装向导 + 下线 pgsql/mysql 支持 + 班级看板独立
- 删除 install.php:所有入口(index/dashboard/draw/login/api)不再跳安装,
  未登录统一跳 login.php;连接信息变更在【设置】里直接修改 data/config.json
- 下线 pgsql/mysql:
  * bootstrap.php 简化为单文件存储路径(ny_load_config/ny_save_config 直接读写 data/config.json),
    删除 ny_db_config、配置迁移、配置双写分支
  * lib/Db.php 改为纯 stub:pdo() 恒返回 null,避免上层误连;migrate/ensureRosterLogin 等为 no-op
  * Roster.php 经 useDb 永远 false,自动走 roster.json
  * api.php 不再返回 db_driver/db_connected/db_error 诊断
- 班级看板独立(dashboard.php 重写):
  * 删除"← 返回总览"按钮 + 整体概况 #overview 区
  * 进入即展示单班看板:6 张汇总卡 + 分组对比/成员榜
  * 顶部 hero 区动态显示当前班级名 + 问卷名
  * 工具栏新增 #classSel 班级选择器(切换班级不离开本页)
  * URL 支持 ?pid=&class=&auto=1|0 分享与刷新恢复
  * 下拉班级选项随数据动态刷新;保留自动刷新 + 全屏 + 缓存
- e2e 测试(test-config-save)加固 cleanup 钩子:
  * 统一 cleanup() 函数接管所有退出路径
  * 解决之前 e2e 留 residue 把 data/config.json 改成 mock 地址的问题(导致本机登录失败)
- php -l 全文件通过;node --check 内嵌 JS 通过;e2e test-config-save 27/27 + test-draw-alloc 29/29 全过
2026-09-09 14:04:59 +08:00
nxxy335 6cd17b9cb2 fix: 修复保存满分后清登录;配置双写;设置显示账号/密码;抽取比例改按人数加权
- 修复:仅当连接信息(base_url/api_prefix/username/password/verify_ssl)真变化时才清 token + 重连;
  保存满分/布局等不应触发登录卷王(bug:之前一律 unlink token.json,导致保存后所有用户都被踢)
- 修复:默认满分不再误写当前卷;改为显式 apply_pid + apply_score 才会改 full_scores
- 配置双写(DB+文件):避免 DB 不可用时静默回退到出厂默认 admin/123456;
  ny_save_config 同时写文件+DB;ny_load_config 在 DB 可用但无记录时从文件自动迁回
- 设置弹窗新增:登录账号可编辑、密码 type=password 不回显、留空表示不修改;
  保存后根据 ping 自动给出成功/失败反馈(错误密码不再静默)
- 抽取比例改为按组人数加权(默认)+ 各组均分 + 自定义比例 + 不分组,最大余额法守恒分配
- 抽出比例 UI:删除 5:5 复选框、改 #balanceMode 下拉 + 按组渲染比例输入框
- Db::lastError() 暴露 DB 错误用于诊断(config_get 返回 db_driver / db_connected / db_error)
- e2e:test-draw-alloc 18→29 测试用例(加权/自定义/守恒 fuzz);
  新增 test-config-save e2e 27 用例全过(连接信息变化检测 + 自动 ping + 密码不回显 + apply_pid)
- mock-server:账户校验从 'admin' 改为 'mock' 接受 e2e 账号
2026-09-09 13:06:47 +08:00
nxxy335 6772aeae11 feat: 抽取页分组均衡5:5 / 重置清累计 / 历史记录 / 排除管理 / 登陆账号字段 / side等高
随机抽取页:
- 分组均衡抽取:有组别(≥2个)时默认按名额均分到组(12/8抽10→5/5),
  其次按班级均分,否则整池随机;可勾选关闭
- 名额均分算法 allocQuotas:floor 均分 + 余数小组优先补齐 +
  超容量退回 + 差额给剩余容量大者
- 重置结果:同时清零累计已抽取数(历史记录仍保留)
- 历史记录:每批次入 localStorage ny_draw_log(上限50),
  重置不清,支持查看详情/恢复结果/删除/清空
- 抽取排除:排除管理弹窗(搜索/清空),结果行可"移出并加入排除"
- 白名单列表下载:登陆账号优先,缺失回退学号

名单登陆账号:
- 模板解析识别"登陆/登录账号/账号/手机号"列,手机号去 .0 尾巴
- Db ny_roster 增 login 列 + 旧表自动升级(三方言)
- roster_save 透传 login;draw_export 使用 login
- 登陆账号仅用于白名单列表下载,其余界面不展示

布局:side 模式下成绩明细/缺考名单 panel 改 flex 撑满,
.table-scroll 取消 320px 限高,两侧等高不再留白

测试:tools/test-draw-alloc.js 18/18 通过(含 1000 组随机 fuzz)
2026-09-09 12:29:49 +08:00
nxxy335 ba0e64ed51 feat: 导出按班级/组别筛选 + 随机抽取页 + 零依赖 XLSX 导出
导出跟随分类筛选
- api.php export / export_absent 新增 cls、grp 参数,按当前筛选过滤 rows
- 缺考口径与页面完全一致:筛选后名单 − 筛选后已提交(按学号匹配)
- 导出文件名附带班级 / 组别(如 成绩明细_高一数学月考_高一(1)班_A组_时间戳.csv)
- app.js 两个导出按钮追加 cls/grp 参数并提示导出范围

随机抽取页 draw.php
- 来源:学号名单 / 某试卷已提交人员;支持班级、组别筛选与抽取人数设置
- 「排除已抽中」累计去重,抽取结果存入 localStorage,刷新后仍可下载
- 抽取动画(滚动定格),结果表格含序号 / 学号 / 姓名 / 班级 / 组别
- 下载「白名单导入模板.xlsx」「白名单列表.xlsx」:两列 姓名 / 登陆账号

XLSX 导出能力
- 新增 lib/XlsxWriter.php:零依赖生成 xlsx,有 zip 扩展走 ZipArchive,
  无则回退手写 ZIP(stored);单元格按共享字符串 + 文本格式(numFmtId=49)
  写入,长学号不会变科学计数法
- 新增 api.php action=draw_export,输出与「白名单列表.xlsx」模板一致的结构

其他
- index.php 顶栏新增「随机抽取」入口;支持 index.php#roster 直接打开名单管理
- tools/test-xlsxwriter.php 自测:18 项全通过(含无 ZipArchive 的手写打包路径)
2026-09-09 11:11:14 +08:00
nxxy335 8686eb99c7 feat: 接入PostgreSQL/MariaDB + 首次安装向导 + 登录页独立 + 设置重构 + 文件夹识别兜底
- 数据库层 lib/Db.php:统一 PDO 支持 pgsql/mysql/sqlite,连接参数存 data/db.json
  (git-ignored),migrate 建 ny_settings/ny_roster 表(三方言兼容 upsert)
- 配置/名单读写:bootstrap ny_load_config/ny_save_config 与 Roster 均改为
  '数据库可用则走 DB、否则回退 data/config.json/roster.json' 的文件降级模式
- 首次安装向导 install.php:选择存储后端(文件/PostgreSQL/MariaDB),测试连接+
  建表+写入卷王连接信息与默认满分;支持 ?force=1 重新配置
- 登录门分离:新增 login.php 独立登录页(仅需密码,账号预填)+logout.php+
  会话(ny_session_start/ny_is_auth/ny_login/ny_logout);index/dashboard 未安装
  跳 install、未登录跳 login;api.php 加会话保护与 auth_check
- 设置重构:删除卷王系统地址/接口前缀/登录账号/登录密码(连接信息由安装向导
  配置);新增布局配置(默认/成绩明细与缺考名单同行)与各试卷独立满分列表(默认
  分数作用于全部);工具栏布局下拉移除
- 文件夹识别兜底:app.js/dashboard.php 双策略 — mode 含 folder 优先,缺省时用
  parent_id 引用反推(应对部分版本无 mode 字段);空文件夹自动隐藏

验证:php -l 全部通过、node --check app.js 通过;SQLite 等价实测 Db 层
(migrate/config upsert/Roster 读写/ny_load_config)与会话辅助函数全部PASS;
e2e(内置服务器)验证路由:未装→install、装完→login、index/dashboard 未登录
跳转、api 401 保护、?force=1 重配、logout 302 均正确。本机无真实 pgsql/mysql
服务器,DB 连接按部署环境实测。
2026-09-09 07:15:56 +08:00
nxxy335 64d0116c0e feat: 文件夹双策略识别 + 布局选项 + 移除提交率卡片 + localStorage缓存层
- 文件夹识别:mode 含 'folder' 优先,缺省时用 parentId 引用反推;
  即使卷王版本无 mode 字段也能正确分组,避免'直接没有文件夹选项'
- 布局选项:工具栏新增 #layoutSel(默认 / 成绩明细与缺考名单同行),
  用 CSS Grid 双模式渲染,窄屏回落单列;偏好写 localStorage
- 移除统计卡片中的提交率卡片(卡片数 9 → 8)
- localStorage 缓存层:问卷列表 / 每个项目数据 / 看板数据 / 班级组别
  筛选 / 布局 / 上次项目 id 全部持久化;进入页面先渲染缓存,
  后台再异步拉新数据并写回缓存;网络失败时仍能用本地缓存查看
- dashboard.php 同步:文件夹识别 / 数据缓存一致更新
2026-09-09 00:08:08 +08:00
nxxy335 7345f9c29b fix: 文件夹试卷可见(用optgroup) + 班级后组别筛选 + 看板全屏锁屏不溢出 2026-09-08 23:31:47 +08:00
nxxy335 8e3ea32567 feat: 看板每组显示考试成员榜(排名/姓名/成绩,成绩降序) + 防溢出 2026-09-08 20:02:34 +08:00
nxxy335 1b7683baab feat: 看板支持A/B组对比下钻 + 全屏 + 删除返回按钮 + 全系统组别(A/B)支持 2026-09-08 19:44:45 +08:00
69 changed files with 17584 additions and 759 deletions
+12
View File
@@ -1,9 +1,21 @@
# 运行时数据:含账号密码、API 令牌、学生名单,禁止入库 # 运行时数据:含账号密码、API 令牌、学生名单,禁止入库
data/ data/
# WorkBuddy 项目数据目录(记忆 / 配置缓存),非源码,禁止入库
.workbuddy/
# e2e 隔离用的临时数据目录(run-all.sh 自动创建,不入库)
.ny-test-data/
# 依赖与系统文件 # 依赖与系统文件
node_modules/ node_modules/
vendor/ vendor/
*.log *.log
# VitePress 文档站构建产物与缓存
docs/.vitepress/dist/
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 秒」)强制单行显示,避免「秒」字换行。
- **成绩明细不再显示每人的「状态」列**:缺考人员统一在「缺考名单」面板查看,成绩明细只保留学号 / 姓名 / 班级 / 成绩 / 提交时间 / 考试时长。 - **成绩明细不再显示每人的「状态」列**:缺考人员统一在「缺考名单」面板查看,成绩明细只保留学号 / 姓名 / 班级 / 成绩 / 提交时间 / 考试时长。
- **新增数据标记**:每次刷新后,相较上一次新出现的提交(按学号判断)整行显示绿色底纹,方便一眼看出谁刚交卷;首次加载不标记。 - **新增数据标记**:每次刷新后,相较上一次新出现的提交(按学号判断)整行显示绿色底纹,方便一眼看出谁刚交卷;首次加载不标记。
+1083 -94
View File
File diff suppressed because it is too large Load Diff
+2054 -257
View File
File diff suppressed because it is too large Load Diff
+987
View File
@@ -0,0 +1,987 @@
/*!
* NY 问卷考试系统数据平台 - 随机抽取页逻辑
*
* 功能:来源切换 / 班级组别筛选 / 分组均衡抽取(5:5) / 排除已抽中 /
* 排除名单管理 / 历史记录(重置不丢失) / 白名单下载(登陆账号优先)
*/
(function () {
'use strict';
const $ = s => document.querySelector(s);
const $$ = s => Array.prototype.slice.call(document.querySelectorAll(s));
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
const LS_RESULT = 'ny_draw_result'; // 当前抽取结果(刷新后仍可下载)
const LS_HISTORY = 'ny_draw_history'; // 累计抽中学号(「重置结果」清零)
const LS_EXCLUDED = 'ny_draw_excluded'; // 手动排除的学号
const LS_LOG = 'ny_draw_log'; // 历史批次(重置不清,上限 50 条)
const LS_SETTINGS = 'ny_draw_settings'; // 来源 / 项目 / 班级 / 组别 / 人数 / 开关
const LOG_MAX = 50;
const state = {
roster: [],
projects: [],
answers: [],
source: 'roster',
picked: [],
history: new Set(), // 累计抽中(重置结果时清零)
excluded: new Set(), // 手动排除
log: [], // 历史批次,新的在前
customRatios: {}, // 自定义比例:{ 组名: 权重 }
drawing: false,
all: false, // 「全部」模式:人数始终跟随当前候选池(手工改输入框即解除)
};
/* ---------------- 接口 ---------------- */
async function api(action, params) {
const q = new URLSearchParams(Object.assign({ action: action }, params || {}));
const r = await fetch('api.php?' + q.toString(), { credentials: 'same-origin' });
let j;
try {
j = await r.json();
} catch (e) {
throw new Error('接口返回异常(HTTP ' + r.status + ')');
}
if (r.status === 401 || (j && j.ok === false && /未登录/.test(j.msg || ''))) {
location.href = 'login.php';
throw new Error('未登录');
}
if (!j || !j.ok) {
throw new Error((j && j.msg) || '请求失败');
}
return j.data;
}
/* ---------------- Toast 顶部飘窗通知(与 app.js 一致) ---------------- */
const TOAST_DURATION = 3500;
const TOAST_LIMIT = 5;
function showToast(msg, type) {
if (msg === '' || msg == null) return;
const el = document.getElementById('toastContainer') || (function () {
const c = document.createElement('div');
c.id = 'toastContainer';
c.style.cssText = 'position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none;max-width:calc(100vw - 32px);width:560px';
document.body.appendChild(c);
return c;
})();
while (el.children.length >= TOAST_LIMIT) el.firstChild.remove();
const t = document.createElement('div');
const cls = type === 'error' ? 'toast-error' : (type === 'warn' ? 'toast-warn' : (type === 'info' ? 'toast-ok' : 'toast'));
t.className = 'toast ' + cls;
t.innerHTML = '<span>' + esc(msg).replace(/\n/g, '<br>') + '</span>';
t.style.cssText = 'pointer-events:auto;padding:10px 16px;border-radius:10px;font-size:13px;line-height:1.55;box-shadow:0 8px 30px rgba(15,23,42,.18);animation:toastIn .25s ease;white-space:pre-wrap;word-break:break-word';
el.appendChild(t);
t.addEventListener('click', () => { t.style.animation = 'toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); });
setTimeout(() => { if (t.parentNode) { t.style.animation = 'toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); } }, TOAST_DURATION);
}
/** 兼容旧 showAlert 调用 → 走 toast(draw.js 内部已有 esc,此处直接复用) */
function showAlert(msg, type) {
if (msg === '' || msg == null) return;
const t = type === 'info' ? 'ok' : (type === 'error' ? 'error' : (type === 'warn' ? 'warn' : ''));
showToast(msg, t);
}
/* ================= 抽取核心(纯函数,供测试) ================= */
/** Fisher-Yates 洗牌(返回新数组) */
function shuffle(arr) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const t = a[i]; a[i] = a[j]; a[j] = t;
}
return a;
}
/**
* 名额分配:
* - sizes 各组容量
* - n 要抽的总人数
* - weights 各组权重(null=均分;数组=按权重,权重<=0 视为 0)
* 算法:最大余额法(largest remainder)—— raw_i = n * w_i / Σw;floor 后把剩余名额
* 按小数部分降序分给还没满的组。均分模式是 weights=null 的特例(每组权重 1),
* 余数时按"组容量小者优先"补齐,保证每组都有代表。
*/
function allocQuotas(sizes, n, weights) {
const g = sizes.length;
const quota = new Array(g).fill(0);
if (g === 0 || n <= 0) return quota;
const total = sizes.reduce((a, b) => a + b, 0);
if (n >= total) return sizes.slice(); // 全量
const base = new Array(g).fill(0);
const frac = new Array(g).fill(0);
if (Array.isArray(weights) && weights.length === g) {
const w = weights.map(x => (Number(x) > 0 ? Number(x) : 0));
const wsum = w.reduce((a, b) => a + b, 0);
if (wsum > 0) {
// 按权重分配:最大余额法
for (let i = 0; i < g; i++) {
const raw = n * w[i] / wsum;
base[i] = Math.floor(raw);
frac[i] = raw - base[i];
}
} else {
// 全 0 权重 → 退化为均分
const b = Math.floor(n / g);
for (let i = 0; i < g; i++) base[i] = b;
const rem = n - b * g;
const bySmall = sizes.map((s, i) => i)
.sort((a, b2) => sizes[a] - sizes[b2] || a - b2);
for (let k = 0; k < rem; k++) base[bySmall[k % g]]++;
}
} else {
// 名额均分:每组 n/g,余数小组优先(保证每组都有代表)
const b = Math.floor(n / g);
for (let i = 0; i < g; i++) base[i] = b;
const rem = n - b * g;
const bySmall = sizes.map((s, i) => i)
.sort((a, b2) => sizes[a] - sizes[b2] || a - b2);
for (let k = 0; k < rem; k++) base[bySmall[k % g]]++;
}
// 余数补齐:frac 大的优先;同 frac 时剩余容量大的优先
let rest = n - base.reduce((a, b) => a + b, 0);
if (rest > 0) {
const order = sizes.map((s, i) => i).sort((a, b) =>
(frac[b] - frac[a]) || ((sizes[b] - base[b]) - (sizes[a] - base[a])) || (a - b));
for (let k = 0; rest > 0 && k < g; k++) {
const i = order[k % g];
if (base[i] < sizes[i]) { base[i]++; rest--; }
}
// 若仍有余数(组容量限制),继续按剩余容量大者分配
while (rest > 0) {
let progress = false;
const byRoom = sizes.map((s, i) => i)
.sort((a, b) => ((sizes[b] - base[b]) - (sizes[a] - base[a])) || (a - b));
for (const i of byRoom) {
if (rest <= 0) break;
if (base[i] < sizes[i]) { base[i]++; rest--; progress = true; }
}
if (!progress) break;
}
}
// 超容量退回(极端比例时兜底)
let overflow = 0;
for (let i = 0; i < g; i++) {
if (base[i] > sizes[i]) { overflow += base[i] - sizes[i]; base[i] = sizes[i]; }
}
while (overflow > 0) {
let progress = false;
const byRoom = sizes.map((s, i) => i)
.sort((a, b) => ((sizes[b] - base[b]) - (sizes[a] - base[a])) || (a - b));
for (const i of byRoom) {
if (overflow <= 0) break;
if (base[i] < sizes[i]) { base[i]++; overflow--; progress = true; }
}
if (!progress) break;
}
return base;
}
/**
* 选择均衡维度:组别(>=2 个非空组)优先,其次班级,否则不均衡
*/
function balanceKeyOf(pool) {
const grps = new Set(pool.map(m => String(m.grp || '').trim()).filter(Boolean));
if (grps.size >= 2) return 'grp';
const clss = new Set(pool.map(m => String(m.cls || '').trim()).filter(Boolean));
if (clss.size >= 2) return 'cls';
return '';
}
/**
* 分组均衡抽取:返回 { picked, detail, sizes, weights }
* weights 数组为各组实际权重(默认加权时=各组人数)
* detail 例:["A 6人", "B 4人"],用于提示与历史记录
*/
function pickBalanced(pool, n, key, weights) {
const label = key === 'grp' ? '(未分组)' : '(未分班)';
const groups = new Map();
pool.forEach(m => {
const k = String(m[key] || '').trim() || label;
if (!groups.has(k)) groups.set(k, []);
groups.get(k).push(m);
});
const gk = Array.from(groups.keys())
.sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true }));
const sizes = gk.map(k => groups.get(k).length);
const w = (Array.isArray(weights) && weights.length === gk.length)
? weights
: null;
const quotas = allocQuotas(sizes, n, w);
const picked = [];
const detail = [];
gk.forEach((k, i) => {
const part = shuffle(groups.get(k)).slice(0, quotas[i]);
picked.push(...part);
if (quotas[i] > 0) detail.push(k + ' ' + quotas[i] + '人');
});
return { picked, detail, sizes, weights: w || sizes.slice() };
}
// 测试钩子(node 端 eval 后通过 window.__nyDrawTest 调用)
if (typeof window !== 'undefined') {
window.__nyDrawTest = { shuffle, allocQuotas, balanceKeyOf, pickBalanced };
}
/* ================= 数据加载 ================= */
async function loadRoster() {
const d = await api('roster');
state.roster = (d && d.students ? d.students : []).map(s => ({
sid: String(s.sid || ''),
name: String(s.name || ''),
cls: String(s.cls || ''),
grp: String(s.grp || ''),
login: String(s.login || ''),
}));
const badge = $('#rosterBadge');
if (badge) badge.textContent = state.roster.length;
}
async function loadProjects() {
try {
state.projects = (await api('projects')) || [];
} catch (e) {
state.projects = [];
}
const sel = $('#projectSel');
if (!sel) return;
const isFolder = p => (p.mode || '').toLowerCase().indexOf('folder') >= 0;
const parentIds = new Set();
state.projects.forEach(p => {
const pid = String(p.parent_id || '0');
if (pid !== '0' && pid !== '') parentIds.add(pid);
});
const folderSet = new Set();
state.projects.forEach(p => { if (isFolder(p) || parentIds.has(p.id)) folderSet.add(p.id); });
const nameOf = id => {
const f = state.projects.filter(p => p.id === id)[0];
return f ? String(f.name || '') : '';
};
const items = state.projects.filter(p => !folderSet.has(p.id));
if (!items.length) {
sel.innerHTML = '<option value="">(无可用的问卷 / 考试)</option>';
return;
}
sel.innerHTML = items.map(p => {
const pid = String(p.parent_id || '0');
const fn = (pid !== '0' && pid !== '' && folderSet.has(pid)) ? nameOf(pid) : '';
const label = fn ? fn + ' / ' + p.name : p.name;
return '<option value="' + esc(p.id) + '">' + esc(label) + '</option>';
}).join('');
}
async function loadAnswers(pid) {
const d = await api('data', { projectId: pid });
const loginOf = {};
state.roster.forEach(s => { if (s.login) loginOf[s.sid] = s.login; });
state.answers = ((d && d.rows) ? d.rows : []).map(r => ({
sid: String(r.sid || ''),
name: String(r.name || ''),
cls: String(r.cls || ''),
grp: String(r.grp || ''),
login: String(loginOf[r.sid] || ''),
}));
}
/* ================= 候选池 ================= */
function baseList() {
return state.source === 'project' ? state.answers : state.roster;
}
function currentPool() {
let list = baseList().slice();
const cls = $('#clsSel').value;
const grp = $('#grpSel').value;
if (cls) list = list.filter(x => (x.cls || '') === cls);
if (grp) list = list.filter(x => (x.grp || '') === grp);
list = list.filter(x => !state.excluded.has(String(x.sid)));
if ($('#excludePicked').checked) {
list = list.filter(x => !state.history.has(String(x.sid)));
}
return list;
}
function uniqSorted(arr) {
const seen = {};
const out = [];
arr.forEach(v => { if (v && !seen[v]) { seen[v] = 1; out.push(v); } });
out.sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true }));
return out;
}
function fillSel(sel, values, allLabel) {
if (!sel) return;
const cur = sel.value;
sel.innerHTML = '<option value="">' + esc(allLabel) + '</option>' +
values.map(v => '<option value="' + esc(v) + '">' + esc(v) + '</option>').join('');
sel.value = (values.indexOf(cur) >= 0) ? cur : '';
}
function populateFilters() {
const list = baseList();
fillSel($('#clsSel'), uniqSorted(list.map(x => x.cls || '')), '全部班级');
fillSel($('#grpSel'), uniqSorted(list.map(x => x.grp || '')), '全部组别');
}
function rangeText() {
const cls = $('#clsSel').value, grp = $('#grpSel').value;
const parts = [];
if (cls) parts.push(cls);
if (grp) parts.push(grp + '组');
return parts.length ? parts.join(' / ') : '全部';
}
function renderStats() {
const pool = currentPool();
// 「全部」模式:候选池变化(改筛选 / 改来源 / 改排除)时人数跟着变
if (state.all) {
const ni = $('#numInput');
if (ni) ni.value = String(Math.max(1, pool.length));
}
$('#dPool').textContent = pool.length;
$('#dPicked').textContent = state.picked.length;
$('#dHistory').textContent = state.history.size;
$('#dExcluded').textContent = state.excluded.size;
$('#dSource').textContent = state.source === 'project' ? '已提交人员' : '学号名单';
$('#drawHint').textContent = '候选 ' + pool.length + ' 人(' + rangeText() + ')';
const lb = $('#logBadge');
if (lb) lb.textContent = state.log.length;
const ec = $('#exclCount');
if (ec) ec.textContent = '已排除 ' + state.excluded.size + ' 人';
}
/**
* 计算当前候选池的分组信息:{ key, gk[], sizes[] }
* key = 'grp' | 'cls' | ''
* gk 按字典序排序后的组名
* sizes 每组成员数
*/
function poolGroups(pool) {
const key = balanceKeyOf(pool);
if (!key) return { key: '', gk: [], sizes: [] };
const label = key === 'grp' ? '(未分组)' : '(未分班)';
const groups = new Map();
pool.forEach(m => {
const k = String(m[key] || '').trim() || label;
if (!groups.has(k)) groups.set(k, 0);
groups.set(k, groups.get(k) + 1);
});
const gk = Array.from(groups.keys())
.sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true }));
return { key, gk, sizes: gk.map(k => groups.get(k)) };
}
/** 把对象 {gk:weight} 读成与当前池分组顺序一致的数组,缺组视为 0 */
function readCustomWeights(gk) {
const src = state.customRatios || {};
return gk.map(k => {
const v = parseFloat(src[k]);
return Number.isFinite(v) && v > 0 ? v : 0;
});
}
/* ================= 抽取 ================= */
async function doDraw() {
if (state.drawing) return;
const pool = currentPool();
if (!pool.length) {
showAlert('当前筛选下没有可抽取的人员(可关闭「排除已抽中」、调整班级 / 组别或排除名单)');
return;
}
// 「全部」模式:人数始终等于当前候选池,切换筛选后不会沿用旧数字
let n = state.all ? pool.length : parseInt($('#numInput').value, 10);
if (!n || n < 1) n = 1;
let capped = false;
if (n > pool.length) { n = pool.length; capped = true; }
state.drawing = true;
const stage = $('#drawStage');
const btn = $('#btnDraw');
btn.disabled = true;
showAlert('');
// 滚动动画:先快速闪动若干次
stage.className = 'draw-stage rolling';
const rounds = Math.min(24, 10 + n);
for (let i = 0; i < rounds; i++) {
const p = pool[Math.floor(Math.random() * pool.length)];
stage.textContent = p.name || p.sid || '—';
await sleep(40 + i * 7);
}
// 真正抽取:均衡(按模式分组) 或整池随机
let picked, detail = [];
const mode = $('#balanceMode').value;
if (mode === 'off') {
picked = shuffle(pool).slice(0, n);
} else {
const g = poolGroups(pool);
if (g.key) {
let weights = null;
if (mode === 'weight') weights = g.sizes.slice(); // 默认按组人数加权
else if (mode === 'custom') weights = readCustomWeights(g.gk);
// mode === 'even' 走 allocQuotas(null) → 均分
const res = pickBalanced(pool, n, g.key, weights);
picked = res.picked; detail = res.detail;
} else {
picked = shuffle(pool).slice(0, n);
}
}
state.picked = picked;
picked.forEach(m => state.history.add(String(m.sid)));
stage.className = 'draw-stage done';
if (n === 1 && picked.length) {
// 只抽 1 人时把学号 / 班级 / 组别一起显示出来,教师不用再去表里找
const p0 = picked[0];
const meta = [p0.sid, p0.cls, p0.grp ? (p0.grp + '组') : ''].filter(Boolean).join(' · ');
stage.innerHTML = '<div>' + esc(p0.name || p0.sid) +
(meta ? '<div class="draw-stage-sub">' + esc(meta) + '</div>' : '') + '</div>';
} else {
stage.textContent = '🎉 已抽取 ' + n + ' 人';
}
renderResult();
renderStats();
pushLog(picked, detail, mode);
saveState();
state.drawing = false;
btn.disabled = false;
const tail = detail.length ? '(' + detail.join(' / ') + ')' : '';
if (capped) {
showAlert('候选池只有 ' + pool.length + ' 人,已按 ' + n + ' 人抽取(可关闭「排除已抽中」或调整筛选扩大候选)', 'warn');
} else {
showAlert('已抽取 ' + n + ' 人' + tail + (n > 1 ? ',可点击右上角按钮下载白名单' : ''), 'info');
}
}
function renderResult() {
const tb = $('#drawBody');
if (!state.picked.length) {
tb.innerHTML = '<tr><td colspan="6" class="empty">尚未抽取</td></tr>';
return;
}
// 抽取结果表不显示登陆账号(仅导出白名单才含该列),故只渲染 5 个数据列 + 操作列
tb.innerHTML = state.picked.map((m, i) => {
return '<tr>' +
'<td class="num">' + (i + 1) + '</td>' +
'<td class="sid">' + esc(m.sid) + '</td>' +
'<td class="name">' + esc(m.name) + '</td>' +
'<td>' + esc(m.cls || '') + '</td>' +
'<td>' + esc(m.grp || '') + '</td>' +
'<td class="act"><button class="btn sm danger" data-drop="' + i + '" title="移出结果并加入排除名单">排除</button></td>' +
'</tr>';
}).join('');
}
/* ================= 历史记录 ================= */
function pushLog(picked, detail, mode) {
const modeTxt = ({ weight: '按人数加权', even: '均分', custom: '自定义比例', off: '不分组' })[mode] || '';
state.log.unshift({
ts: new Date().toLocaleString('zh-CN', { hour12: false }),
source: state.source === 'project' ? '已提交人员' : '学号名单',
range: rangeText(),
balance: (modeTxt ? modeTxt + ':' : '') + (detail || []).join(' / '),
count: picked.length,
members: picked.map(m => ({
sid: m.sid, name: m.name, cls: m.cls || '', grp: m.grp || '', login: m.login || '',
})),
});
if (state.log.length > LOG_MAX) state.log.length = LOG_MAX;
saveLog();
}
function renderLog() {
const box = $('#logList');
if (!box) return;
if (!state.log.length) {
box.innerHTML = '<p class="empty" style="text-align:center;padding:30px 0">暂无历史记录</p>';
return;
}
box.innerHTML = state.log.map((e, i) => {
const rows = (e.members || []).map((m, j) =>
'<tr>' +
'<td class="num">' + (j + 1) + '</td>' +
'<td class="sid">' + esc(m.sid) + '</td>' +
'<td>' + esc(m.name) + '</td>' +
'<td>' + esc(m.cls || '') + '</td>' +
'<td>' + esc(m.grp || '') + '</td>' +
'</tr>').join('');
const meta = [e.ts, e.source, '范围:' + e.range, e.count + ' 人'];
if (e.balance) meta.push('均衡:' + e.balance);
return '<div class="log-item">' +
'<div class="log-head">' +
'<label class="log-summary"><span>' + esc(meta.join(' · ')) + '</span></label>' +
'<span class="log-actions">' +
'<button class="btn sm" data-log-restore="' + i + '">恢复结果</button>' +
'<button class="btn sm danger" data-log-del="' + i + '">删除</button>' +
'</span></div>' +
'<details class="log-detail"><summary>查看成员(' + e.count + ')</summary>' +
'<div class="table-scroll short"><table class="grid compact">' +
'<thead><tr><th style="width:8%;text-align:right">序号</th><th style="width:28%">学号</th><th style="width:26%">姓名</th><th style="width:24%">班级</th><th style="width:14%">组别</th></tr></thead>' +
'<tbody>' + rows + '</tbody></table></div>' +
'</details>' +
'</div>';
}).join('');
}
/* ================= 排除管理 ================= */
let exclQuery = '';
function renderExcl() {
const tb = $('#exclBody');
if (!tb) return;
let list = baseList().slice();
if (exclQuery) {
const q = exclQuery.toLowerCase();
list = list.filter(x =>
String(x.sid).toLowerCase().includes(q) ||
String(x.name).toLowerCase().includes(q) ||
String(x.cls || '').toLowerCase().includes(q));
}
if (!list.length) {
tb.innerHTML = '<tr><td colspan="5" class="empty">' +
(baseList().length ? '没有匹配的人员' : '当前来源暂无人员') + '</td></tr>';
return;
}
tb.innerHTML = list.map(m => {
const on = state.excluded.has(String(m.sid)) ? ' checked' : '';
return '<tr>' +
'<td style="text-align:center"><input type="checkbox" data-excl="' + esc(m.sid) + '"' + on + '></td>' +
'<td class="sid">' + esc(m.sid) + '</td>' +
'<td>' + esc(m.name) + '</td>' +
'<td>' + esc(m.cls || '') + '</td>' +
'<td>' + esc(m.grp || '') + '</td>' +
'</tr>';
}).join('');
}
/* ================= 下载(登陆账号优先) ================= */
function dlMembers() {
return state.picked.map(m => ({
name: m.name,
sid: m.sid,
login: m.login || '',
}));
}
async function download(type) {
if (!state.picked.length) {
showAlert('请先点击「🎲 开始抽取」');
return;
}
const fname = type === 'import' ? '白名单导入模板.xlsx' : '白名单列表.xlsx';
try {
const r = await fetch('api.php?action=draw_export', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: type, members: dlMembers() }),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fname;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 3000);
showAlert('已下载 ' + fname, 'info');
} catch (e) {
showAlert('导出失败:' + e.message);
}
}
/* ================= 本地缓存 ================= */
function saveState() {
try {
localStorage.setItem(LS_RESULT, JSON.stringify(state.picked));
localStorage.setItem(LS_HISTORY, JSON.stringify(Array.from(state.history)));
} catch (e) { /* 忽略 */ }
}
function saveLog() {
try {
localStorage.setItem(LS_LOG, JSON.stringify(state.log));
const lb = $('#logBadge');
if (lb) lb.textContent = state.log.length;
} catch (e) { /* 忽略 */ }
}
function saveExcluded() {
try {
localStorage.setItem(LS_EXCLUDED, JSON.stringify(Array.from(state.excluded)));
} catch (e) { /* 忽略 */ }
}
function saveSettings() {
try {
// 收集自定义比例输入
const cr = {};
$$('#ratioBox input[data-gk]').forEach(inp => {
const k = inp.dataset.gk;
const v = parseFloat(inp.value);
if (k && Number.isFinite(v) && v > 0) cr[k] = v;
});
state.customRatios = cr;
localStorage.setItem(LS_SETTINGS, JSON.stringify({
source: state.source,
projectId: $('#projectSel').value,
cls: $('#clsSel').value,
grp: $('#grpSel').value,
num: $('#numInput').value,
all: state.all,
exclude: $('#excludePicked').checked,
mode: $('#balanceMode').value,
customRatios: cr,
}));
} catch (e) { /* 忽略 */ }
}
function loadState() {
const arr = (k) => {
try {
const v = JSON.parse(localStorage.getItem(k) || '[]');
return Array.isArray(v) ? v : [];
} catch (e) { return []; }
};
state.picked = arr(LS_RESULT);
state.history = new Set(arr(LS_HISTORY).map(String));
state.excluded = new Set(arr(LS_EXCLUDED).map(String));
state.log = arr(LS_LOG);
}
function loadSettings() {
try {
const s = JSON.parse(localStorage.getItem(LS_SETTINGS) || '{}');
if (s && typeof s === 'object') return s;
} catch (e) { /* 忽略 */ }
return {};
}
/* ================= 事件 ================= */
function bind() {
$('#sourceSel').addEventListener('change', async () => {
state.source = $('#sourceSel').value;
$('#projectField').classList.toggle('hidden', state.source !== 'project');
state.answers = [];
state.picked = [];
renderResult();
if (state.source === 'project') {
if (!state.projects.length) await loadProjects();
const pid = $('#projectSel').value;
if (pid) {
try { await loadAnswers(pid); } catch (e) { showAlert(e.message); }
}
}
populateFilters();
renderStats();
renderRatioBox();
if ($('#exclModal').classList.contains('open')) renderExcl();
saveSettings();
});
$('#projectSel').addEventListener('change', async () => {
const pid = $('#projectSel').value;
if (!pid) { state.answers = []; populateFilters(); renderStats(); renderRatioBox(); return; }
showAlert('正在拉取已提交人员…', 'info');
try {
await loadAnswers(pid);
showAlert('');
} catch (e) {
showAlert(e.message);
}
populateFilters();
renderStats();
renderRatioBox();
if ($('#exclModal').classList.contains('open')) renderExcl();
saveSettings();
});
['#clsSel', '#grpSel'].forEach(id => {
$(id).addEventListener('change', () => {
renderStats(); renderRatioBox(); saveSettings();
});
});
$('#numInput').addEventListener('change', saveSettings);
// 手工改人数即解除「全部」模式
$('#numInput').addEventListener('input', () => { state.all = false; });
$('#excludePicked').addEventListener('change', () => { renderStats(); saveSettings(); });
$('#balanceMode').addEventListener('change', () => {
renderRatioBox();
saveSettings();
});
// 班级/组别/来源切换会改变候选池 → 比例输入框也要重画
['#sourceSel', '#projectSel', '#clsSel', '#grpSel'].forEach(id => {
const el = $(id);
if (el) el.addEventListener('change', () => {
// 延迟一帧,等 populateFilters 填好再算
setTimeout(renderRatioBox, 0);
});
});
$$('[data-num]').forEach(b => {
b.addEventListener('click', () => {
const v = b.dataset.num;
if (v === 'all') {
state.all = true;
$('#numInput').value = String(Math.max(1, currentPool().length));
} else {
state.all = false;
$('#numInput').value = v;
}
saveSettings();
});
});
$('#btnDraw').addEventListener('click', doDraw);
// 重置结果:清空本次结果 + 累计已抽取(历史记录保留)
$('#btnReset').addEventListener('click', () => {
if (state.picked.length || state.history.size) {
if (!confirm('确定重置?将清空本次结果与累计已抽取的 ' + state.history.size + ' 人(历史记录仍保留)。')) return;
}
state.picked = [];
state.history = new Set();
renderResult();
renderStats();
saveState();
const stage = $('#drawStage');
stage.className = 'draw-stage';
stage.textContent = '点击「🎲 开始抽取」';
showAlert('已重置结果与累计已抽取(历史记录仍保留)', 'info');
});
// 复制名单:两列「姓名 / 登陆账号」,制表符分隔,便于直接粘贴到 Excel / 微信
$('#btnCopy').addEventListener('click', async () => {
if (!state.picked.length) {
showAlert('请先点击「🎲 开始抽取」再复制');
return;
}
const lines = state.picked.map(m =>
(m.name || '') + '\t' + (m.login || m.sid || ''));
const text = lines.join('\n');
let ok = false;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
ok = true;
}
} catch (e) { /* 降级到 textarea */ }
if (!ok) {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
ok = document.execCommand('copy');
ta.remove();
} catch (e) { ok = false; }
}
showAlert(ok ? ('已复制 ' + state.picked.length + ' 人的姓名 / 登陆账号到剪贴板') : '复制失败,请手动选择结果复制', ok ? 'info' : '');
});
// 清空历史(顶栏)
$('#btnClearLog').addEventListener('click', () => {
if (!state.log.length) return showAlert('暂无历史记录');
if (!confirm('确定清空全部 ' + state.log.length + ' 条历史记录?此操作不可恢复。')) return;
state.log = [];
saveLog();
renderLog();
renderStats();
showAlert('已清空历史记录', 'info');
});
$('#btnDlImport').addEventListener('click', () => download('import'));
$('#btnDlList').addEventListener('click', () => download('list'));
/* --- 结果行内:排除并移除 --- */
$('#drawBody').addEventListener('click', e => {
const btn = e.target.closest('[data-drop]');
if (!btn) return;
const i = parseInt(btn.dataset.drop, 10);
if (isNaN(i) || !state.picked[i]) return;
const m = state.picked[i];
state.excluded.add(String(m.sid));
state.picked.splice(i, 1);
saveExcluded();
saveState();
renderResult();
renderStats();
showAlert('已将「' + (m.name || m.sid) + '」加入排除名单,可点「🚫 排除管理」撤销', 'info');
});
/* --- 排除管理弹窗 --- */
$('#btnExclude').addEventListener('click', () => {
exclQuery = '';
$('#exclSearch').value = '';
renderExcl();
renderStats();
$('#exclModal').classList.add('open');
});
$('#exclSearch').addEventListener('input', () => {
exclQuery = $('#exclSearch').value.trim().toLowerCase();
renderExcl();
});
$('#exclBody').addEventListener('change', e => {
const cb = e.target.closest('[data-excl]');
if (!cb) return;
if (cb.checked) state.excluded.add(String(cb.dataset.excl));
else state.excluded.delete(String(cb.dataset.excl));
saveExcluded();
renderStats();
});
$('#btnClearExcl').addEventListener('click', () => {
if (!state.excluded.size) return;
state.excluded = new Set();
saveExcluded();
renderExcl();
renderStats();
});
/* --- 历史记录弹窗 --- */
$('#btnLog').addEventListener('click', () => {
renderLog();
$('#logModal').classList.add('open');
});
$('#logList').addEventListener('click', e => {
const rst = e.target.closest('[data-log-restore]');
const del = e.target.closest('[data-log-del]');
if (rst) {
const i = parseInt(rst.dataset.logRestore, 10);
const entry = state.log[i];
if (!entry) return;
state.picked = (entry.members || []).map(m => ({
sid: m.sid, name: m.name, cls: m.cls || '', grp: m.grp || '', login: m.login || '',
}));
renderResult();
renderStats();
saveState();
showAlert('已恢复该批次(' + state.picked.length + ' 人),可直接下载白名单', 'info');
}
if (del) {
const i = parseInt(del.dataset.logDel, 10);
if (isNaN(i) || !state.log[i]) return;
if (!confirm('删除这条历史记录?')) return;
state.log.splice(i, 1);
saveLog();
renderLog();
renderStats();
}
});
/* --- 弹窗通用关闭:只关最上层那一个,避免连带关掉下层的弹窗 --- */
document.addEventListener('click', e => {
const isClose = (e.target.matches && e.target.matches('[data-close]')) ||
(e.target.classList && e.target.classList.contains('modal'));
if (!isClose) return;
const open = $$('.modal.open');
if (!open.length) return;
const top = open[open.length - 1];
// 点遮罩时只响应最上层弹窗自身的遮罩
if (e.target.classList && e.target.classList.contains('modal') && e.target !== top) return;
top.classList.remove('open');
});
/* Esc 关闭最上层弹窗 */
document.addEventListener('keydown', e => {
if (e.key !== 'Escape' && e.key !== 'Esc') return;
const open = $$('.modal.open');
if (open.length) open[open.length - 1].classList.remove('open');
});
}
/**
* 渲染"自定义比例"输入框:按当前候选池的分组(组别/班级)列出,每个组一个数字输入框,
* 默认按各组人数预填(即等价"按人数加权"),用户可改。
*/
function renderRatioBox() {
const box = $('#ratioBox');
if (!box) return;
const pool = currentPool();
const g = poolGroups(pool);
const mode = $('#balanceMode').value;
const show = (mode === 'custom') && g.key;
box.classList.toggle('hidden', !show);
// 隐藏时必须清空:否则旧的 data-gk 输入框还会被 saveSettings 读走,
// 下次切成自定义比例时会误用上一批分组的权重
if (!show) { box.innerHTML = ''; return; }
box.innerHTML =
'<p class="hint" style="margin:0 0 6px">按上面分组的顺序填写权重,例:组A=3,组B=1。留空=不计该组。</p>' +
g.gk.map((k, i) => {
const def = (state.customRatios && Number.isFinite(parseFloat(state.customRatios[k])))
? state.customRatios[k]
: g.sizes[i];
return '<div class="row" style="display:flex;gap:8px;align-items:center;margin:4px 0">' +
'<label style="min-width:120px">' + esc(k) + ' <small style="color:var(--text-3)">(' + g.sizes[i] + '人)</small></label>' +
'<input type="number" min="0" step="0.1" data-gk="' + esc(k) + '" value="' + esc(def) + '" placeholder="' + g.sizes[i] + '" style="width:120px">' +
'</div>';
}).join('');
// 改动即时保存
box.querySelectorAll('input[data-gk]').forEach(inp => {
inp.addEventListener('change', saveSettings);
});
}
/* ---------------- 启动 ---------------- */
document.addEventListener('DOMContentLoaded', async function () {
loadState();
const s = loadSettings();
if (s.exclude === false) $('#excludePicked').checked = false;
// 兼容旧版:s.balance === false → 不分组
const mode = s.mode || (s.balance === false ? 'off' : 'weight');
$('#balanceMode').value = mode;
if (s.num) $('#numInput').value = s.num;
state.all = !!s.all;
if (s.source === 'project') {
state.source = 'project';
$('#sourceSel').value = 'project';
$('#projectField').classList.remove('hidden');
}
if (s.customRatios && typeof s.customRatios === 'object') {
state.customRatios = s.customRatios;
}
bind();
renderResult();
renderStats();
// 第一次也要按当前池渲染自定义比例框
renderRatioBox();
try {
await loadRoster();
} catch (e) {
showAlert('名单加载失败:' + e.message);
}
if (state.source === 'project') {
await loadProjects();
const pid = s.projectId || $('#projectSel').value;
if (pid) {
$('#projectSel').value = pid;
try { await loadAnswers(pid); } catch (e) { /* 静默 */ }
}
}
populateFilters();
if (s.cls) $('#clsSel').value = s.cls;
if (s.grp) $('#grpSel').value = s.grp;
renderStats();
});
})();
+445 -36
View File
@@ -6,14 +6,22 @@
--text: #1d2433; --text: #1d2433;
--text-2: #5b6577; --text-2: #5b6577;
--text-3: #8b95a7; --text-3: #8b95a7;
/* 主色阶:--primary 基准、--primary-2 悬停/激活深一档、--primary-3 浅色渐变端 */
--primary: #2f6bff; --primary: #2f6bff;
--primary-2: #2559db;
--primary-3: #5b8bff;
--primary-weak: #eaf0ff; --primary-weak: #eaf0ff;
--brand-2: #6a4bff; /* 品牌渐变收尾(紫)—— logo/面板条/登录条 */
--ok: #12a86a; --ok: #12a86a;
--ok-2: #3ec99a; /* 成功渐变亮端(toast) */
--ok-weak: #e7f7ef; --ok-weak: #e7f7ef;
--warn: #e08c1a; --warn: #e08c1a;
--warn-2: #f0a54a; /* 警告渐变亮端(toast) */
--warn-weak: #fdf3e2; --warn-weak: #fdf3e2;
--danger: #e0483d; --danger: #e0483d;
--danger-2: #ef6c64; /* 危险渐变亮端(toast) */
--danger-weak: #fdecea; --danger-weak: #fdecea;
--ink: #1b2440; /* 登录页深底 */
--shadow: 0 1px 2px rgba(20, 30, 60, .05), 0 8px 24px rgba(20, 30, 60, .06); --shadow: 0 1px 2px rgba(20, 30, 60, .05), 0 8px 24px rgba(20, 30, 60, .06);
--shadow-lg: 0 12px 32px rgba(20, 30, 60, .12); --shadow-lg: 0 12px 32px rgba(20, 30, 60, .12);
--radius: 12px; --radius: 12px;
@@ -53,7 +61,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;
@@ -80,6 +93,8 @@ body {
.conn-err { background: var(--danger-weak); color: var(--danger); } .conn-err { background: var(--danger-weak); color: var(--danger); }
.conn-err i { background: var(--danger); } .conn-err i { background: var(--danger); }
.conn-load i { background: var(--warn); animation: blink 1s infinite; } .conn-load i { background: var(--warn); animation: blink 1s infinite; }
.conn-warn { background: var(--warn-weak, #fff4e0); color: var(--warn, #e08c1a); }
.conn-warn i { background: var(--warn, #e08c1a); animation: blink 1s infinite; }
@keyframes blink { 50% { opacity: .25; } } @keyframes blink { 50% { opacity: .25; } }
/* ---------- 按钮 ---------- */ /* ---------- 按钮 ---------- */
@@ -113,8 +128,9 @@ body {
color: #fff; color: #fff;
} }
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; } .btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn-primary:hover { background: #2559db; border-color: #2559db; } .btn-primary:hover { background: var(--primary-2); border-color: var(--primary-2); }
.btn.sm { padding: 5px 10px; font-size: 12px; } .btn.sm { padding: 5px 10px; font-size: 12px; }
.btn.xs { padding: 3px 8px; font-size: 11px; }
.btn.danger { color: var(--danger); border-color: #f3cfcb; } .btn.danger { color: var(--danger); border-color: #f3cfcb; }
.btn.danger:hover { background: var(--danger-weak); } .btn.danger:hover { background: var(--danger-weak); }
.btn b { .btn b {
@@ -154,6 +170,24 @@ select, input[type=text], input[type=number], input[type=password] {
select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47,107,255,.12); } select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47,107,255,.12); }
.field.btns select { width: 100px; } .field.btns select { width: 100px; }
/* ---------- 副工具栏:导出等次要操作独立成行,避免挤在主工具栏里 ---------- */
.subbar {
display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
margin-top: 10px; padding: 10px 14px;
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow);
}
.subbar-label {
font-size: 12px; font-weight: 700; color: var(--text-3); letter-spacing: .5px;
padding-right: 12px; border-right: 1px solid var(--border);
}
.subbar-item {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--text-2); cursor: pointer; white-space: nowrap;
}
.subbar-item input[type=checkbox] { width: auto; height: auto; margin: 0; cursor: pointer; }
.subbar-item select { width: auto; min-width: 112px; height: 30px; padding: 0 8px; font-size: 12px; }
.switch { position: relative; display: inline-block; width: 40px; height: 22px; flex: none; } .switch { position: relative; display: inline-block; width: 40px; height: 22px; flex: none; }
.switch input { opacity: 0; width: 0; height: 0; } .switch input { opacity: 0; width: 0; height: 0; }
.switch span { .switch span {
@@ -212,6 +246,8 @@ select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px
border: 1px solid var(--border); border-radius: var(--radius); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow); overflow: hidden; box-shadow: var(--shadow); overflow: hidden;
} }
/* 在 .layout 容器内由 gap 控制间距,去掉 panel 自身 margin-top */
.layout > .panel { margin-top: 0; }
.panel-head { .panel-head {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 13px 18px; border-bottom: 1px solid var(--border-2); padding: 13px 18px; border-bottom: 1px solid var(--border-2);
@@ -229,46 +265,123 @@ select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px
.updated { font-size: 12px; color: var(--text-3); display: flex; align-items: center; gap: 6px; } .updated { font-size: 12px; color: var(--text-3); display: flex; align-items: center; gap: 6px; }
.dot { width: 7px; height: 7px; border-radius: 50%; background: #cbd3e2; display: inline-block; } .dot { width: 7px; height: 7px; border-radius: 50%; background: #cbd3e2; display: inline-block; }
.dot.on { background: var(--ok); animation: pulse 1.6s infinite; } .dot.on { background: var(--ok); animation: pulse 1.6s infinite; }
.dot.stale { background: var(--danger); animation: blink 1s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } } @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } } /* ---------- 三 panel 布局容器(默认 / 成绩明细与缺考名单同行) ---------- */
.layout {
display: grid; gap: 18px;
grid-template-columns: 1fr;
margin-top: 18px;
}
.layout > #tablePanel { grid-column: 1; grid-row: 1; }
.layout > #absentPanel { grid-column: 1; grid-row: 2; }
.layout > #unmatchedPanel { grid-column: 1; grid-row: 3; }
/* side:成绩明细 与 缺考名单 同行,未匹配答卷放下面整宽 */
.layout[data-layout="side"] {
grid-template-columns: 1fr 1fr;
}
.layout[data-layout="side"] > #tablePanel { grid-column: 1; grid-row: 1; }
.layout[data-layout="side"] > #absentPanel { grid-column: 2; grid-row: 1; }
.layout[data-layout="side"] > #unmatchedPanel { grid-column: 1 / -1; grid-row: 2; }
/* 窄屏一律回落单列(grid-row 也要还原) */
@media (max-width: 900px) {
.layout, .layout[data-layout="side"] { grid-template-columns: 1fr; }
.layout > #tablePanel, .layout > #absentPanel, .layout > #unmatchedPanel,
.layout[data-layout="side"] > #tablePanel,
.layout[data-layout="side"] > #absentPanel,
.layout[data-layout="side"] > #unmatchedPanel {
grid-column: 1;
}
.layout > #tablePanel, .layout[data-layout="side"] > #tablePanel { grid-row: 1; }
.layout > #absentPanel, .layout[data-layout="side"] > #absentPanel { grid-row: 2; }
.layout > #unmatchedPanel, .layout[data-layout="side"] > #unmatchedPanel { grid-row: 3; }
}
/* ---------- 表格 ---------- */ /* ---------- 表格 ---------- */
.table-scroll { overflow: auto; max-height: 62vh; } .table-scroll { overflow: auto; max-height: 62vh; scrollbar-gutter: stable; }
.table-scroll.short { max-height: 320px; } .table-scroll.short { max-height: 320px; }
.table-scroll.mid { max-height: 380px; } .table-scroll.mid { max-height: 380px; }
table.grid { width: 100%; border-collapse: collapse; font-size: 13px; } table.grid { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; table-layout: fixed; }
table.grid th { table.grid th {
position: sticky; top: 0; z-index: 2; position: sticky; top: 0; z-index: 2;
background: linear-gradient(180deg, #fbfcfe, #f4f7fc); color: var(--text-2); background: linear-gradient(180deg, #fbfcfe, #f4f7fc); color: var(--text-2);
text-align: left; font-weight: 600; font-size: 12px; text-align: left; font-weight: 600; font-size: 12px;
padding: 11px 14px; border-bottom: 1px solid var(--border); padding: 11px 14px;
white-space: nowrap; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
/* 用 box-shadow 画分隔线而非 border:border 在 border-collapse:separate + sticky 下
不会与下方 td 边框产生折叠重绘缝隙(消除吸顶表头在滚动边界的 1px 双线/错位观感) */
background-clip: padding-box;
box-shadow: inset 0 -1px 0 var(--border); box-shadow: inset 0 -1px 0 var(--border);
} }
table.grid th.sortable { cursor: pointer; user-select: none; } table.grid th.sortable { cursor: pointer; user-select: none; }
table.grid th.sortable:hover { color: var(--primary); } table.grid th.sortable:hover { color: var(--primary); }
table.grid th .arrow { color: var(--primary); margin-left: 3px; } table.grid th .arrow { color: var(--primary); margin-left: 3px; }
table.grid td { padding: 10px 14px; border-bottom: 1px solid var(--border-2); } table.grid td { padding: 10px 14px; border-bottom: 1px solid var(--border-2); vertical-align: middle; background-clip: padding-box; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 含按钮的操作列:不能被 ellipsis 的 overflow:hidden 裁掉按钮 */
table.grid td.act { overflow: visible; text-overflow: clip; }
/* 虚拟滚动占位行:不占视觉空间、不响应 hover/条纹、不显示边框 */
table.grid tbody tr.vspacer td { padding: 0; border: 0; background: transparent !important; }
table.grid tbody tr.vspacer:nth-child(even) td { background: transparent !important; }
table.grid tbody tr.vspacer:hover td { background: transparent !important; }
table.grid tbody tr:nth-child(even) td { background: #fbfcff; } table.grid tbody tr:nth-child(even) td { background: #fbfcff; }
table.grid tbody tr:hover td { background: var(--primary-weak); } table.grid tbody tr:hover td { background: var(--primary-weak); }
table.grid.compact td, table.grid.compact th { padding: 7px 12px; } table.grid.compact td, table.grid.compact th { padding: 7px 12px; }
td.empty { text-align: center; color: var(--text-3); padding: 32px 0; } td.empty { text-align: center; color: var(--text-3); padding: 32px 0; }
td.num { font-variant-numeric: tabular-nums; white-space: nowrap; } td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
td.sid { font-weight: 600; color: var(--text-2); font-variant-numeric: tabular-nums; } td.sid { font-weight: 600; color: var(--text-2); font-variant-numeric: tabular-nums; }
/* 数值列右对齐:成绩 / 次数 / 时长纵向扫读时更容易对齐比对 */
table.grid td.num, table.grid td.score { text-align: right; }
/* 表头对齐 = data-k 派生,与单元格的正则保持一致;避免"表头左对齐、数据右对齐"造成的视觉错列 */
table.grid thead th[data-k="score"],
table.grid thead th[data-k="attempts"],
table.grid thead th[data-k="submit_ts"],
table.grid thead th[data-k="duration"] { text-align: right; }
table.grid td.score { font-weight: 700; font-size: 14px; }
table.grid td.name { font-weight: 600; }
td.name { font-weight: 600; } td.name { font-weight: 600; }
td.score { font-weight: 700; font-variant-numeric: tabular-nums; } td.score { font-weight: 700; font-variant-numeric: tabular-nums; }
.score-high { color: var(--ok); } .score-high { color: var(--ok); }
.score-mid { color: var(--warn); } .score-mid { color: var(--warn); }
.score-low { color: var(--danger); } .score-low { color: var(--danger); }
.diff { animation: flash 1.6s ease-out; }
@keyframes flash { from { background: #fff6d9; } to { background: transparent; } }
/* 新增数据绿色底纹(每次刷新后标记,下一轮刷新前持续显示) */ /* 新数据行动画:FLIP 滑动(行移动) + 全新行落入;尊重 prefers-reduced-motion */
@keyframes rowDrop { from { opacity: 0; transform: translateY(-12px); } to { opacity: 1; transform: translateY(0); } }
table.grid tbody tr.row-drop-in { animation: rowDrop .42s ease; }
@media (prefers-reduced-motion: reduce) {
table.grid tbody tr.row-drop-in { animation: none; }
}
/* ---------- Toast 顶部飘窗通知 ---------- */
.toast {
background: linear-gradient(135deg, var(--primary), var(--primary-3));
color: #fff;
}
.toast-ok { background: linear-gradient(135deg, var(--ok), var(--ok-2)); color: #fff; }
.toast-error{ background: linear-gradient(135deg, var(--danger), var(--danger-2)); color: #fff; }
.toast-warn { background: linear-gradient(135deg, var(--warn), var(--warn-2)); color: #fff; }
@keyframes toastIn {
from { opacity: 0; transform: translateY(-12px) scale(.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes toastOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(-8px) scale(.96); }
}
/* 新数据绿色底纹:常亮(不再闪烁),由"清除高亮"或切换项目/姓名题清除 */
table.grid tbody tr.new td { background: #e7f7ee; } table.grid tbody tr.new td { background: #e7f7ee; }
table.grid tbody tr.new:nth-child(even) td { background: #e7f7ee; }
table.grid tbody tr.new:hover td { background: #d6f2e2; } table.grid tbody tr.new:hover td { background: #d6f2e2; }
/* 左侧色条,进一步强调"这条是新的" */
table.grid tbody tr.new td:first-child { box-shadow: inset 3px 0 0 var(--ok); }
/* 考试次数列 */
td.att { font-weight: 700; color: var(--text-2); }
.tag { .tag {
display: inline-block; padding: 1px 8px; border-radius: 999px; display: inline-block; padding: 1px 8px; border-radius: 999px;
@@ -277,6 +390,21 @@ table.grid tbody tr.new:hover td { background: #d6f2e2; }
.tag-ok { background: var(--ok-weak); color: var(--ok); } .tag-ok { background: var(--ok-weak); color: var(--ok); }
.tag-warn { background: var(--warn-weak); color: var(--warn); } .tag-warn { background: var(--warn-weak); color: var(--warn); }
.tag-gray { background: var(--border-2); color: var(--text-3); } .tag-gray { background: var(--border-2); color: var(--text-3); }
.tag-att { background: #e8eefc; color: #3457c9; cursor: pointer; }
.tag-new { background: var(--ok-weak); color: var(--ok); }
.tag-best { background: #fff1dc; color: #b26a06; }
/* 当日最高(历史成绩按天分组后,组内最高分) */
.tag-day { background: var(--primary-weak); color: var(--primary-2); }
/* 历史成绩弹窗:按提交日期分组的分组行 */
tr.att-day > td { background: var(--border-2); font-weight: 600; color: var(--text-2); }
tr.att-day > td .att-day-sub { margin-left: 8px; font-weight: 400; font-size: 12px; color: var(--text-3); }
/* 多次刷题:成员榜可点击行 */
tr.clickable { cursor: pointer; }
tr.clickable:hover td { background: var(--primary-weak); }
/* 绿色常亮行 hover 时保持绿色调,避免看起来"消失" */
table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
.muted { color: var(--text-3); }
/* ---------- 弹窗 ---------- */ /* ---------- 弹窗 ---------- */
.modal { .modal {
@@ -300,6 +428,81 @@ table.grid tbody tr.new:hover td { background: #d6f2e2; }
.x { border: 0; background: none; font-size: 22px; line-height: 1; cursor: pointer; color: var(--text-3); } .x { border: 0; background: none; font-size: 22px; line-height: 1; cursor: pointer; color: var(--text-3); }
.x:hover { color: var(--text); } .x:hover { color: var(--text); }
.modal-body { padding: 18px; overflow: auto; } .modal-body { padding: 18px; overflow: auto; }
/* 弹窗内的表格不做吸顶:弹窗自身是滚动容器,吸顶表头会与内容错层(且贴边观感差) */
.modal-body table.grid th { position: static; }
.modal-body table.grid { box-shadow: inset 0 0 0 1px var(--border-2); border-radius: 8px; }
/* ================= 统一导出 / 打印弹窗 ================= */
.ex-sec {
padding: 12px 14px; margin: 0 0 12px;
border: 1px solid var(--border); border-radius: 10px; background: #fbfcfe;
}
.ex-sec[hidden] { display: none; }
.ex-sec-title {
font-size: 12px; font-weight: 700; color: var(--text-2);
letter-spacing: .3px; margin: 0 0 10px;
display: flex; align-items: center; gap: 8px;
}
.ex-sec-title small { font-weight: 500; color: var(--text-3, #8b93a1); }
/* 导出内容:2×2 卡片单选,一眼看清四种导出各是什么 */
.ex-types { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.ex-type {
display: flex; align-items: flex-start; gap: 9px; padding: 10px 12px;
border: 1px solid var(--border); border-radius: 9px; background: #fff;
cursor: pointer; transition: border-color .15s, box-shadow .15s, background .15s;
}
.ex-type:hover { border-color: #b9c2d0; }
.ex-type:has(input:checked) {
border-color: var(--primary, #2f6fed); background: #f3f7ff;
box-shadow: 0 0 0 1px var(--primary, #2f6fed) inset;
}
.ex-type input { margin-top: 3px; flex: none; }
.ex-type-t { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.ex-type-t b { font-size: 13px; color: var(--text); }
.ex-type-t small { font-size: 11.5px; color: var(--text-3, #8b93a1); line-height: 1.45; }
/* 字段行 */
.ex-row { display: flex; flex-wrap: wrap; gap: 14px 18px; align-items: flex-start; }
.ex-field {
display: flex; flex-direction: column; gap: 6px; min-width: 150px;
font-size: 12px; font-weight: 600; color: var(--text-2);
}
.ex-field select, .ex-field input[type=text], .ex-field input[type=number] {
font-size: 13px; font-weight: 400; color: var(--text);
}
.ex-field-tip {
min-width: 200px; flex: 1 1 240px; font-weight: 400;
display: flex; flex-direction: column; gap: 4px; justify-content: flex-end;
}
.ex-field-grow { flex: 1 1 240px; }
.ex-field-grow input { width: 100%; }
/* 班级 / 组别多选:竖排标签 + 列表,便于 Ctrl 多选 */
#exClsSel, #exGrpSel {
width: 168px; height: 96px; padding: 4px 6px; font-size: 12px;
border: 1px solid var(--border); border-radius: 8px;
background: #fff; color: var(--text); outline: none;
}
#exGrpSel { width: 132px; }
.ex-checks {
display: flex; flex-wrap: wrap; gap: 8px 20px; margin-top: 12px;
font-size: 12.5px; color: var(--text);
}
.ex-checks label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
/* 多考试:勾选列表 */
.ex-pick { margin-top: 12px; }
.ex-pick-head {
display: flex; align-items: center; gap: 10px; margin: 0 0 8px;
font-size: 12.5px; font-weight: 600; color: var(--text-2);
}
.ex-pick-head small { font-weight: 500; color: var(--text-3, #8b93a1); }
.table-scroll.mid { max-height: 208px; overflow: auto; }
/* 合规的旧类名保留(其它地方仍在用 .me-bar 之类的排版习惯) */
.me-bar { display: flex; align-items: center; gap: 16px; margin: 0 0 10px; }
.me-bar .chk { margin: 0; white-space: nowrap; }
.modal-foot { .modal-foot {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 12px 18px; border-top: 1px solid var(--border); background: #fbfcfe; padding: 12px 18px; border-top: 1px solid var(--border); background: #fbfcfe;
@@ -311,6 +514,9 @@ table.grid tbody tr.new:hover td { background: #d6f2e2; }
.fi { display: flex; flex-direction: column; gap: 6px; } .fi { display: flex; flex-direction: column; gap: 6px; }
.fi.full { grid-column: 1 / -1; } .fi.full { grid-column: 1 / -1; }
.fi label { font-size: 12px; font-weight: 600; color: var(--text-2); } .fi label { font-size: 12px; font-weight: 600; color: var(--text-2); }
.fi label .req { color: var(--danger, #e5484d); margin-left: 2px; }
.fi-inline { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; }
.fi-inline .chk { font-weight: 500; font-size: 13px; color: var(--text); }
.hint { margin: 0; font-size: 11px; color: var(--text-3); } .hint { margin: 0; font-size: 11px; color: var(--text-3); }
.fi.checks { flex-direction: row; flex-wrap: wrap; gap: 18px; } .fi.checks { flex-direction: row; flex-wrap: wrap; gap: 18px; }
.fi.checks label { display: flex; align-items: center; gap: 6px; font-weight: 500; font-size: 13px; color: var(--text); } .fi.checks label { display: flex; align-items: center; gap: 6px; font-weight: 500; font-size: 13px; color: var(--text); }
@@ -320,6 +526,7 @@ table.grid tbody tr.new:hover td { background: #d6f2e2; }
background: var(--ok-weak); color: #0d7a4d; border: 1px solid #c8ecda; background: var(--ok-weak); color: #0d7a4d; border: 1px solid #c8ecda;
} }
.ping-result.err { background: var(--danger-weak); color: #a3342c; border-color: #f5d3cf; } .ping-result.err { background: var(--danger-weak); color: #a3342c; border-color: #f5d3cf; }
.ping-result.loading { background: var(--border-2); color: var(--text-2); border-color: var(--border-2); }
/* ---------- 名单 ---------- */ /* ---------- 名单 ---------- */
.upload-zone { .upload-zone {
@@ -373,9 +580,6 @@ table.grid tbody tr.new:hover td { background: #d6f2e2; }
.login-box .fi { margin-bottom: 14px; } .login-box .fi { margin-bottom: 14px; }
.login-actions { display: flex; gap: 10px; margin-top: 6px; } .login-actions { display: flex; gap: 10px; margin-top: 6px; }
.login-actions .btn { flex: 1; } .login-actions .btn { flex: 1; }
.login-msg { margin-top: 12px; font-size: 12px; min-height: 18px; white-space: pre-wrap; }
.login-msg.ok { color: var(--ok); }
.login-msg.err { color: var(--danger); }
/* ---------- 全屏适配 ---------- */ /* ---------- 全屏适配 ---------- */
body.fs-on .wrap { max-width: none; padding-top: 8px; } body.fs-on .wrap { max-width: none; padding-top: 8px; }
@@ -386,32 +590,44 @@ 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; }
.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%; }
.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; }
@@ -420,19 +636,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; }
} }
/* ---------- 数据看板 ---------- */ /* ---------- 数据看板 ---------- */
@@ -442,28 +675,75 @@ 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: 8px; flex-wrap: wrap; }
.dash-controls select { width: 230px; height: 38px; } .dash-controls select { width: 230px; height: 38px; }
/* 看板顶栏的「主系统 / 退出」用描边区分于同排的半透明操作按钮 */
.dash-controls .btn.back {
background: transparent; border-color: rgba(255, 255, 255, .45); font-weight: 600;
}
.dash-controls .btn.back:hover { background: rgba(255, 255, 255, .18); border-color: #fff; }
/* 窄屏:下拉与按钮换行铺开,避免 230px 固定宽把顶栏撑出横向滚动条 */
@media (max-width: 860px) {
.dash-hero { padding: 14px 16px; }
.dash-controls { width: 100%; gap: 8px; }
.dash-controls select { width: auto; flex: 1 1 160px; min-width: 0; }
.dash-controls .btn { flex: 0 0 auto; }
}
.dash-controls .inline { color: #fff; } .dash-controls .inline { color: #fff; }
.dash-controls .btn { background: rgba(255, 255, 255, .14); border-color: transparent; color: #fff; } .dash-controls .btn { background: rgba(255, 255, 255, .14); border-color: transparent; color: #fff; }
.dash-controls .btn:hover { background: rgba(255, 255, 255, .24); } .dash-controls .btn:hover { background: rgba(255, 255, 255, .24); }
/* 单班大屏:顶栏只保留「试卷 / 自动刷新 / 全屏」,班级选择与「← 主系统」隐藏
(退出按钮本形态下不再渲染,这里不再靠 CSS 藏) */
body.dash-class #classSelWrap,
body.dash-class #linkIndex { display: none !important; }
/* 公开(免登录)班级大屏的角标 */
.pub-tag {
display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 12px;
background: rgba(255, 255, 255, .16); border: 1px solid rgba(255, 255, 255, .38); color: #fff;
}
/* 首页汇总卡(5 卡)在该形态下无意义 */
body.dash-home #boardSummary { display: none; }
.dash-hero .brand-cls {
display: inline-block; margin-left: 2px; padding: 2px 10px; border-radius: 999px;
background: rgba(255, 255, 255, .18); font-size: 13px; font-weight: 600;
}
/* ---------- 首页:班级入口卡片 ---------- */
.cls-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(248px, 1fr));
gap: 14px; margin-top: 12px;
}
.cls-card {
min-width: 0; cursor: pointer; user-select: none;
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
padding: 14px 16px 12px; box-shadow: var(--shadow);
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
}
.cls-card:hover { transform: translateY(-3px); border-color: var(--primary); box-shadow: var(--shadow-lg); }
.cls-card:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.cls-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.cls-name { font-size: 16px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cls-go { flex: none; font-size: 12px; font-weight: 700; color: var(--primary); }
.cls-kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 8px; margin-top: 10px; }
.cls-kpi { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.cls-kpi span { font-size: 11px; color: var(--text-3); }
.cls-kpi b { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; }
.cls-kpi.k-ok b { color: var(--ok); }
.cls-kpi.k-warn b { color: var(--warn); }
.cls-bar { height: 8px; border-radius: 999px; background: var(--border-2); overflow: hidden; margin-top: 12px; }
.cls-bar > i { display: block; height: 100%; border-radius: 999px; transition: width .5s ease; }
.cls-sub {
margin-top: 7px; font-size: 11.5px; color: var(--text-3);
display: flex; justify-content: space-between; gap: 8px;
}
@media (max-width: 860px) { .cls-grid { grid-template-columns: 1fr; } }
.sec-title { margin: 22px 0 4px; font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; } .sec-title { margin: 22px 0 4px; font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.sec-title .tag-gray { background: var(--border-2); color: var(--text-3); padding: 1px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; } .sec-title .tag-gray { background: var(--border-2); color: var(--text-3); padding: 1px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.class-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; margin-top: 14px; }
.class-card {
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
padding: 16px 18px; box-shadow: var(--shadow); transition: transform .18s, box-shadow .18s;
}
.class-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-lg); }
.class-card h3 { margin: 0 0 2px; font-size: 16px; display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.class-card .rate-num { font-size: 15px; font-weight: 800; white-space: nowrap; }
.class-card .sub { font-size: 12px; color: var(--text-3); margin-bottom: 12px; }
.bar { height: 9px; border-radius: 999px; background: var(--border-2); overflow: hidden; } .bar { height: 9px; border-radius: 999px; background: var(--border-2); overflow: hidden; }
.bar > i { display: block; height: 100%; border-radius: 999px; transition: width .5s ease; } .bar > i { display: block; height: 100%; border-radius: 999px; transition: width .5s ease; }
.class-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; margin-top: 14px; } .class-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; margin-top: 14px; }
@@ -471,8 +751,137 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
.class-metrics .m span { color: var(--text-3); font-size: 11px; } .class-metrics .m span { color: var(--text-3); font-size: 11px; }
.class-metrics .m b { font-size: 17px; font-weight: 700; } .class-metrics .m b { font-size: 17px; font-weight: 700; }
.metric-ok b { color: var(--ok); } .metric-ok b { color: var(--ok); }
.metric-warn b { color: var(--warn); }
.metric-danger b { color: var(--danger); } .metric-danger b { color: var(--danger); }
.empty { text-align: center; color: var(--text-3); padding: 40px 0; } .empty { text-align: center; color: var(--text-3); padding: 40px 0; }
/* 分组对比:A 在左、B 在右 */
.group-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 14px; }
@media (max-width: 860px) { .group-compare { grid-template-columns: 1fr; } }
.gpanel {
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
padding: 16px 18px; box-shadow: var(--shadow);
}
.gpanel .ghead { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2px; }
.gpanel .ghead strong { font-size: 16px; }
.gpanel .ghead .rate-num { font-size: 15px; font-weight: 800; }
/* 成员榜:排名 / 姓名 / 成绩 */
.mem-title {
margin-top: 16px; margin-bottom: 8px;
font-size: 12px; font-weight: 700; color: var(--text-2);
}
.member-list {
max-height: 300px; overflow: auto;
border: 1px solid var(--border-2); border-radius: 10px; background: #fff;
}
.member-list table.grid { font-size: 12.5px; }
.member-list table.grid th { padding: 8px 10px; }
.member-list table.grid td { padding: 6px 10px; }
.rk {
display: inline-flex; align-items: center; justify-content: center;
min-width: 22px; height: 22px; padding: 0 6px; border-radius: 999px;
background: var(--border-2); color: var(--text-2);
font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums;
}
.rk-1 { background: #fff2d6; color: #b7791f; }
.rk-2 { background: #eef1f6; color: #64748b; }
.rk-3 { background: #fbe9dd; color: #b45309; }
/* 防溢出:网格子项允许收缩,长内容不撑破页面 */
.group-compare, .cards, .gpanel { min-width: 0; }
.gpanel { overflow: hidden; }
.member-list td.name { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 看板全屏:整页不溢出屏幕(外层锁 100vh + overflow:hidden,内部区域自行滚动) */
body.dash.fs-on .wrap {
height: 100vh; display: flex; flex-direction: column; overflow: hidden;
max-width: none; padding: 12px 20px 14px;
}
body.dash.fs-on .dash-hero { padding: 12px 18px; flex: 0 0 auto; border-radius: var(--radius); }
body.dash.fs-on .sec-title { margin: 12px 0 4px; font-size: 14px; }
body.dash.fs-on .cards { gap: 10px; margin-top: 10px; }
body.dash.fs-on .card { padding: 10px 12px; }
body.dash.fs-on .card b { font-size: 20px; }
body.dash.fs-on .group-compare { gap: 12px; }
body.dash.fs-on .gpanel { padding: 12px 14px; }
body.dash.fs-on .member-list { max-height: none; }
body.dash.fs-on .cls-grid { grid-template-columns: repeat(auto-fill, minmax(212px, 1fr)); gap: 10px; }
body.dash.fs-on .cls-card { padding: 10px 12px; }
body.dash.fs-on .cls-kpi b { font-size: 15px; }
/* ================= 随机抽取页 ================= */
.panel-head .actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
/* 抽取人数输入框:在 toolbar 内联按钮组中收窄 */
.field .inline input[type=number] { width: 78px; flex: none; }
.draw-stage {
margin: 18px; padding: 26px 18px;
border: 1px dashed var(--border); border-radius: var(--radius);
background: linear-gradient(180deg, #fbfcff, #f6f8ff);
text-align: center; font-size: 20px; font-weight: 700;
color: var(--text-3); letter-spacing: 1px;
min-height: 86px; display: flex; align-items: center; justify-content: center;
transition: color .2s ease, background .2s ease;
}
.draw-stage.rolling {
color: var(--primary);
background: linear-gradient(180deg, #f2f6ff, #eaf0ff);
animation: drawShake .12s infinite alternate;
}
.draw-stage.done {
color: var(--ok);
background: linear-gradient(180deg, #f2fbf5, #e9f7ee);
font-size: 26px;
}
/* 只抽 1 人时的副行(学号 · 班级 · 组别),字号小于主名字 */
.draw-stage-sub {
margin-top: 8px; font-size: 14px; font-weight: 600;
color: var(--text-2); letter-spacing: 0;
}
@keyframes drawShake {
from { transform: translateY(-1px) scale(.995); }
to { transform: translateY(1px) scale(1.005); }
}
@media (prefers-reduced-motion: reduce) {
.draw-stage.rolling { animation: none; }
}
/* ================= side 布局:两 panel 等高(缺考名单不再留大片空白) ================= */
/* 宽屏 side 模式:panel 变纵向 flex,表格区撑满剩余高度(行高由内容多的一侧决定) */
@media (min-width: 901px) {
.layout[data-layout="side"] > #tablePanel,
.layout[data-layout="side"] > #absentPanel {
display: flex; flex-direction: column;
}
.layout[data-layout="side"] > #tablePanel .table-scroll,
.layout[data-layout="side"] > #absentPanel .table-scroll {
flex: 1 1 auto; min-height: 0; max-height: none;
}
}
/* ================= 随机抽取:历史记录 ================= */
.log-list { display: flex; flex-direction: column; gap: 10px; }
.log-item {
border: 1px solid var(--border-2); border-radius: 10px;
background: #fbfcff; overflow: hidden;
}
.log-head {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 10px 14px; flex-wrap: wrap;
}
.log-summary { font-size: 13px; font-weight: 600; color: var(--text); }
.log-summary small { color: var(--text-3); font-weight: 500; }
.log-actions { display: flex; gap: 8px; flex: none; }
.log-detail summary {
cursor: pointer; user-select: none; padding: 7px 14px;
font-size: 12px; color: var(--primary); border-top: 1px dashed var(--border-2);
list-style: none;
}
.log-detail summary::-webkit-details-marker { display: none; }
.log-detail summary::before { content: '▸ '; }
.log-detail[open] summary::before { content: '▾ '; }
.log-detail[open] summary { border-bottom: 1px solid var(--border-2); }
.log-detail .table-scroll { max-height: 260px; border-radius: 0; }
+446 -8
View File
@@ -9,7 +9,14 @@ const NY_APP_NAME = 'NY问卷考试系统数据平台';
const NY_VERSION = '1.0.0'; const NY_VERSION = '1.0.0';
define('NY_ROOT', str_replace('\\', '/', __DIR__)); define('NY_ROOT', str_replace('\\', '/', __DIR__));
define('NY_DATA', NY_ROOT . '/data'); // 数据目录默认指向项目下的 data/;测试可通过 NY_DATA 环境变量整体重定向到临时目录,
// 避免 e2e 把 mock 服务的连接配置写进生产库(曾因此把生产 base_url 改成 127.0.0.1 导致登录失败)。
$nyData = NY_ROOT . '/data';
$nyDataEnv = getenv('NY_DATA');
if ($nyDataEnv !== false && $nyDataEnv !== '') {
$nyData = rtrim(str_replace('\\', '/', $nyDataEnv), '/');
}
define('NY_DATA', $nyData);
// ---------------- 环境预检 ---------------- // ---------------- 环境预检 ----------------
if (PHP_VERSION_ID < 80000) { if (PHP_VERSION_ID < 80000) {
@@ -36,16 +43,152 @@ if ($missing !== []) {
date_default_timezone_set('Asia/Shanghai'); date_default_timezone_set('Asia/Shanghai');
mb_internal_encoding('UTF-8'); mb_internal_encoding('UTF-8');
// ---------------- 内存上限(数据接口可能处理大量答卷)----------------
// 拉取卷王全量答卷做 json_decode 时极易触顶 128M;在未被禁用 / 未被 php_admin_value 硬锁的前提下
// 临时放宽到 512M。若主机用 php_admin_value 锁定了 memory_limit,下面的 ini_set 会静默失效,
// 需要改主机 php.ini(或 fpm 池配置)才能生效。
(function (): void {
$cur = (string) ini_get('memory_limit');
if ($cur === '' || $cur === '-1') {
return; // 未设置 / 已无限制,无需处理
}
$curBytes = (int) $cur;
$unit = strtoupper(substr($cur, -1));
if ($unit === 'G') {
$curBytes *= 1024 * 1024 * 1024;
} elseif ($unit === 'M') {
$curBytes *= 1024 * 1024;
} elseif ($unit === 'K') {
$curBytes *= 1024;
}
$want = 512 * 1024 * 1024;
if ($curBytes > 0 && $curBytes < $want) {
@ini_set('memory_limit', '512M');
}
})();
// ---------------- 会话 ----------------
function ny_session_start(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
session_name('NYSESS');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'httponly' => true,
'samesite' => 'Lax',
'secure' => $secure,
]);
@session_start();
}
/** 当前是否已登录(本系统会话) */
function ny_is_auth(): bool
{
ny_session_start();
return !empty($_SESSION['ny_auth']);
}
/** 标记登录 */
function ny_login(): void
{
ny_session_start();
$_SESSION['ny_auth'] = true;
}
/** 登出 */
function ny_logout(): void
{
ny_session_start();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
}
// ---------------- CSRF 防护 ----------------
/**
* 会话绑定的 CSRF token:写操作(POST)必须携带,防止跨站请求伪造。
* 前端从 config_get / auth_check 响应里拿到 csrf_token,后续写请求附在
* X-CSRF-Token 请求头或 body 的 _csrf 字段。
*/
function ny_csrf_token(): string
{
ny_session_start();
if (empty($_SESSION['ny_csrf']) || !is_string($_SESSION['ny_csrf'])) {
$_SESSION['ny_csrf'] = bin2hex(random_bytes(16));
}
return $_SESSION['ny_csrf'];
}
/**
* 取请求原始 body(仅读一次并缓存)。
* 写接口既要校验 CSRF(从 body 取 _csrf)又要解析业务 JSON,二者都需读 php://input;
* 大多数 SAPI 下 php://input 可重复读,但个别环境只能读一次,因此这里统一缓存避免第二次读为空。
*/
function ny_request_body(): string
{
if (!isset($GLOBALS['__ny_body'])) {
$GLOBALS['__ny_body'] = (string)@file_get_contents('php://input');
}
return $GLOBALS['__ny_body'];
}
/** 校验 CSRF token(写操作调用)。失败返回 false 并应拒绝请求。 */
function ny_csrf_ok(): bool
{
$given = '';
if (!empty($_SERVER['HTTP_X_CSRF_TOKEN'])) {
$given = (string)$_SERVER['HTTP_X_CSRF_TOKEN'];
} elseif (!empty($_POST['_csrf'])) {
$given = (string)$_POST['_csrf'];
} else {
// JSON body 里的 _csrf(api.php 写接口多走 JSON)
$raw = ny_request_body();
if ($raw !== '') {
$j = json_decode($raw, true);
if (is_array($j) && isset($j['_csrf'])) {
$given = (string)$j['_csrf'];
}
}
}
$token = ny_csrf_token();
return $given !== '' && hash_equals($token, $given);
}
// ---------------- 登录失败限流(已移除) ----------------
// 注:单机内部工具场景下,5 次失败锁 10 分钟的限制常因配置写错/测环境串味而把正常用户挡在门外,
// 且无实际爆破价值(仅内网使用)。应需求已整体移除:登录不再限流,失败只即时提示原因。
/** 判断是否已安装(只看文件 config:base_url 非空即视为已具备连接配置) */
function ny_is_installed(): bool
{
$cfg = ny_load_config();
if (trim((string)($cfg['base_url'] ?? '')) === '') {
return false;
}
return is_file(ny_config_file());
}
// ---------------- 目录初始化 ---------------- // ---------------- 目录初始化 ----------------
foreach (['', '/cache', '/uploads'] as $sub) { foreach (['', '/cache', '/uploads'] as $sub) {
$dir = NY_DATA . $sub; $dir = NY_DATA . $sub;
if (!is_dir($dir)) { if (!is_dir($dir)) {
@mkdir($dir, 0777, true); @mkdir($dir, 0777, true);
} }
} // 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");
}
} }
// ---------------- 默认配置 ---------------- // ---------------- 默认配置 ----------------
@@ -64,7 +207,18 @@ 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',
// 历史成绩展示方式:click=点击成员弹窗查看(默认);inline=成绩明细内直接展开每次考试
'history_display' => 'click',
// 成绩导出排序:time=按提交时间降序(默认);sid=按学号(同学号内按提交时间降序)
'export_sort' => 'time',
// 数据结果缓存秒数:0=每次实时拉取;建议 10~30。
// 拉取卷王全量答卷是重操作(多次 HTTP + 大量解码),缓存可避免多人同时刷新时重复打卷王。
// 名单一变指纹就变会自动失效;点"立即刷新"也会 force=1 跳过缓存。
'cache_ttl' => 15,
]; ];
} }
@@ -73,10 +227,52 @@ function ny_config_file(): string
return NY_DATA . '/config.json'; return NY_DATA . '/config.json';
} }
/**
* 配置来源:db | file | default
* (default 表示既没读到 DB 也没读到文件——此时账号密码是出厂默认值,几乎必然连不上卷王)
*/
function ny_config_source(): string
{
return (string)($GLOBALS['__ny_cfg_source'] ?? 'default');
}
function ny_load_config(): array function ny_load_config(): array
{ {
$GLOBALS['__ny_cfg_source'] = 'default';
$cfg = ny_default_config(); $cfg = ny_default_config();
$saved = ny_read_json(ny_config_file(), null); $file = ny_config_file();
$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)) {
@@ -86,12 +282,251 @@ function ny_load_config(): array
} }
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/'); $cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/'); $cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
// 账号/密码一律转成字符串:历史数据里若存成 null,不改这里就会表现为"未配置卷王登录账号"
$cfg['username'] = trim((string)($cfg['username'] ?? ''));
$cfg['password'] = (string)($cfg['password'] ?? '');
return $cfg; return $cfg;
} }
function ny_save_config(array $cfg): bool function ny_save_config(array $cfg): bool
{ {
return ny_write_json(ny_config_file(), $cfg); // 双写:SQLite 为主(成功即算成功),文件为备份(DB 挂掉时还能读回来)
// 关键:两处都必须做"回读校验"。以前只管写不管是否真落盘,data/ 目录不可写时
// 登录页会照常提示"登录成功",但下一个请求读到的账号仍是空的 → 无限跳登录页。
$GLOBALS['__ny_cfg_save_error'] = '';
$notes = [];
$json = json_encode($cfg, JSON_UNESCAPED_UNICODE);
$ok = false;
if (class_exists('Db') && Db::pdo() !== null) {
try {
if (Db::settingsSet('config', $json)) {
$GLOBALS['__ny_cfg_source'] = 'db';
$ok = true;
} else {
$notes[] = '数据库写入失败:' . (Db::lastError() ?: '未知原因');
}
} catch (Throwable $e) {
$notes[] = '数据库写入异常:' . $e->getMessage();
}
} else {
$notes[] = '数据库不可用(' . (class_exists('Db') ? Db::lastError() : 'Db 类未加载') . ')';
}
$fileOk = ny_write_json(ny_config_file(), $cfg);
if ($fileOk) {
// 回读一致才算真成功
$back = ny_read_json(ny_config_file(), null);
if (!is_array($back) || json_encode($back, JSON_UNESCAPED_UNICODE) !== $json) {
$fileOk = false;
$notes[] = '配置文件回读不一致:' . ny_config_file();
}
} else {
$notes[] = '配置文件写入失败:' . ny_config_file();
}
// 尽量收紧配置文件权限(仅属主可读写),降低明文密码被其它本地用户读取的风险。
// Windows 下 chmod 基本无效(真正的防护是 data/ 目录禁止 Web 访问,见 .htaccess / 部署文档);
// 明文密码仍需保留以便卷王 token 失效时平台自动重新登录,无法改为只存哈希。
if ($fileOk && is_file(ny_config_file())) {
@chmod(ny_config_file(), 0600);
}
if (!$ok) {
$GLOBALS['__ny_cfg_source'] = $fileOk ? 'file' : 'default';
}
if (!$ok && !$fileOk) {
$GLOBALS['__ny_cfg_save_error'] = '配置未落盘:' . implode(';', $notes);
} elseif ($notes !== []) {
// 有一处成功即不影响使用,仅记录降级信息
$GLOBALS['__ny_cfg_save_error'] = implode(';', $notes);
}
return $ok || $fileOk;
}
/** 上一次 ny_save_config 的问题描述(空 = 完全正常) */
function ny_config_save_error(): string
{
return (string)($GLOBALS['__ny_cfg_save_error'] ?? '');
}
/**
* 配置落盘诊断:登录/鉴权失败时优先看这里,用来区分
* 「账号没配」与「账号配了但写不进去 / 读不回来」这两类完全不同的故障。
*/
function ny_config_diag(): array
{
$dir = rtrim((string)NY_DATA, '/\\');
$file = ny_config_file();
$dbOk = class_exists('Db') && Db::pdo() !== null;
$dbWritable = false;
if ($dbOk) {
$probe = '__ny_probe_' . bin2hex(random_bytes(4));
$dbWritable = Db::settingsSet($probe, '1');
Db::settingsDelete($probe);
}
$cfgWritable = false;
$probeFile = $dir . '/.__ny_write_probe';
$fp = @fopen($probeFile, 'wb');
if ($fp) {
$cfgWritable = (fwrite($fp, '1') !== false);
fclose($fp);
@unlink($probeFile);
}
$c = ny_load_config();
return [
'storage' => ny_config_source(),
'data_dir' => $dir,
'dir_writable' => is_dir($dir) && is_writable($dir),
'cfg_file' => $file,
'cfg_writable' => $cfgWritable,
'db_connected' => $dbOk,
'db_writable' => $dbWritable,
'db_error' => (class_exists('Db') && !$dbOk) ? Db::lastError() : '',
'has_username' => trim((string)($c['username'] ?? '')) !== '',
'has_password' => ((string)($c['password'] ?? '')) !== '',
'username' => (string)($c['username'] ?? ''),
'save_error' => ny_config_save_error(),
];
}
/**
* 把接口/表单传入的配置片段合并进已有配置(api.php config_save 用)。
* 抽出来是为了可单测,同时在这里统一守住"账号/密码不能被误清空"。
*
* @return array{config:array,conn_changed:bool}
*/
function ny_apply_config_input(array $old, array $in): array
{
$allowed = ['base_url', 'api_prefix', 'username', 'timeout', 'refresh_interval', 'page_size', 'name_keywords', 'history_display', 'export_sort'];
$new = $old;
foreach ($allowed as $k) {
if (array_key_exists($k, $in)) {
$new[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k];
}
}
// 账号不允许被改空:设置页留空视为"不改",否则一旦保存就会陷入"未配置账号"死循环
if (array_key_exists('username', $in)) {
$u = trim((string)($in['username'] ?? ''));
if ($u === '') {
$new['username'] = (string)($old['username'] ?? '');
}
}
// 密码:保留首尾空格的原貌,空 / 占位符 = 不修改
if (array_key_exists('password', $in)) {
$pwdIn = (string)$in['password'];
if ($pwdIn !== '' && $pwdIn !== '******') {
$new['password'] = $pwdIn;
}
}
foreach (['verify_ssl', 'include_temp_save', 'dedupe'] as $k) {
if (array_key_exists($k, $in)) {
$new[$k] = filter_var($in[$k], FILTER_VALIDATE_BOOL);
}
}
// 布局配置
if (array_key_exists('layout', $in)) {
$new['layout'] = ($in['layout'] === 'side') ? 'side' : 'default';
}
// 历史成绩展示方式:click(点击成员弹窗,默认) / inline(明细内全部展开)
if (array_key_exists('history_display', $in)) {
$new['history_display'] = ($in['history_display'] === 'inline') ? 'inline' : 'click';
}
// 成绩导出排序
if (array_key_exists('export_sort', $in)) {
$v = (string)$in['export_sort'];
$new['export_sort'] = in_array($v, ['time','sid','score_desc','score_asc'], true) ? $v : 'time';
}
if (array_key_exists('pass_rate', $in)) {
$pr = (float)$in['pass_rate'];
$new['pass_rate'] = ($pr >= 1 && $pr <= 100) ? $pr : 60;
}
$new['base_url'] = rtrim((string)$new['base_url'], '/');
$new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/');
$new['timeout'] = (int)$new['timeout'];
$new['refresh_interval'] = (int)$new['refresh_interval'];
if ($new['refresh_interval'] < 3) {
$new['refresh_interval'] = 3;
}
// 默认满分
$fs = (int)($in['full_score'] ?? $new['full_score'] ?? 100);
if ($fs < 1) {
$fs = 100;
}
$new['full_score'] = $fs;
// 账号/密码强制字符串(历史数据里可能存成 null,导致 (string) 后变成空 → 报"未配置账号")
$new['username'] = (string)($new['username'] ?? '');
$new['password'] = (string)($new['password'] ?? '');
// 仅当连接信息真的变了才清 token + 重连;保存满分/布局等不应触发登录卷王
$connKeys = ['base_url', 'api_prefix', 'username', 'password', 'verify_ssl'];
$connChanged = false;
foreach ($connKeys as $k) {
if ((string)($old[$k] ?? '') !== (string)($new[$k] ?? '')) {
$connChanged = true;
break;
}
}
return ['config' => $new, 'conn_changed' => $connChanged];
}
/**
* 解析「本卷满分 + 合格率」:
* 优先取该试卷的独立配置(试卷之间互相隔离),没有则用全局配置。
*
* @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];
}
/**
* PHP 会话目录是否可用(登录状态能不能保持住)。
* 目录不可写时 session_start() 只会告警,登录态存不下来 → 表现为"登录完又被踢回登录页"。
*/
function ny_session_usable(?string &$why = null): bool
{
$dir = (string)@session_save_path();
// save_path 可能是 "N;/path"(多级目录散列),取分号后的真实路径
if (preg_match('/^\d+;(.*)$/', $dir, $m) === 1) {
$dir = $m[1];
}
if ($dir === '') {
$dir = sys_get_temp_dir();
}
if (!is_dir($dir)) {
$why = '会话目录不存在:' . $dir;
return false;
}
if (!is_writable($dir)) {
$why = '会话目录不可写:' . $dir;
return false;
}
return true;
} }
/** 安全的字符串化(避免数组/对象转字符串报错) */ /** 安全的字符串化(避免数组/对象转字符串报错) */
@@ -229,9 +664,12 @@ function ny_format_duration(?int $sec): string
spl_autoload_register(function (string $class): void { spl_autoload_register(function (string $class): void {
$map = [ $map = [
'XlsxReader' => NY_ROOT . '/lib/XlsxReader.php', 'XlsxReader' => NY_ROOT . '/lib/XlsxReader.php',
'XlsxWriter' => NY_ROOT . '/lib/XlsxWriter.php',
'SurveyKingClient' => NY_ROOT . '/lib/SurveyKingClient.php', 'SurveyKingClient' => NY_ROOT . '/lib/SurveyKingClient.php',
'Roster' => NY_ROOT . '/lib/Roster.php', 'Roster' => NY_ROOT . '/lib/Roster.php',
'DataService' => NY_ROOT . '/lib/DataService.php', 'DataService' => NY_ROOT . '/lib/DataService.php',
'DataCache' => NY_ROOT . '/lib/DataCache.php',
'Db' => NY_ROOT . '/lib/Db.php',
]; ];
if (isset($map[$class]) && is_file($map[$class])) { if (isset($map[$class]) && is_file($map[$class])) {
require $map[$class]; require $map[$class];
+727 -103
View File
@@ -1,29 +1,51 @@
<?php <?php
/**
* 对外数据大屏(两种形态,由 URL 决定):
* 1) 无 ?class= → 大屏首页:先选试卷/考试,再以卡片网格列出该卷的班级,点击进入该班大屏;
* 2) 有 ?class=<班级名> → 该班独立大屏:只有试卷选择 / 自动刷新开关 / 全屏,
* 不出现班级选择、「← 主系统」「退出」,并且屏蔽浏览器后退键(退不回首页)。
* URL 仍支持 ?pid= 与 &auto= 便于分享与刷新恢复。
*/
declare(strict_types=1); declare(strict_types=1);
require __DIR__ . '/bootstrap.php'; require __DIR__ . '/bootstrap.php';
$pidParam = trim((string)($_GET['pid'] ?? ''));
$clsParam = trim((string)($_GET['class'] ?? ''));
$isClass = ($clsParam !== '');
/* 公开访问:带 ?class= 的班级大屏不需要登录。该班数据只暴露该班本身——
接口侧会先按班级裁剪再回传,拿不到全卷与其它班级的成绩。
大屏首页仍要求登录——它列出的是某卷下所有班级的入口。 */
$publicView = $isClass && !ny_is_auth();
if (!$isClass && !ny_is_auth()) {
header('Location: login.php');
exit;
}
$pageTitle = $isClass ? ($clsParam . ' · 班级考试大屏') : '数据大屏 · 选择班级';
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>数据看板 · <?= htmlspecialchars(NY_APP_NAME) ?></title> <title><?= htmlspecialchars($pageTitle) ?> · <?= htmlspecialchars(NY_APP_NAME) ?></title>
<link rel="stylesheet" href="assets/style.css"> <link rel="stylesheet" href="assets/style.css">
</head> </head>
<body> <!-- dash-class = 单班大屏(隐藏班级选择/返回/退出 + 锁后退);dash-home = 首页班级入口 -->
<body class="dash <?= $isClass ? 'dash-class' : 'dash-home' ?>">
<div class="wrap"> <div class="wrap">
<div class="dash-hero"> <div class="dash-hero">
<div class="brand"> <div class="brand">
<span class="logo">NY</span> <h1 class="brand-title">NY问卷考试系统数据平台</h1>
<div class="brand-text"> <?php if ($isClass): ?>
<strong>NY问卷考试系统 · 数据看板</strong> <span class="brand-cls" id="heroClass"><?= htmlspecialchars($clsParam) ?></span>
<small>班级整体情况 · 面向班主任与学生</small> <?php endif; ?>
</div>
</div> </div>
<div class="dash-controls"> <div class="dash-controls">
<select id="projectSel"><option value="">请选择问卷 / 考试</option></select> <select id="projectSel" style="min-width:160px"><option value="">请选择问卷 / 考试</option></select>
<span id="classSelWrap"><select id="classSel" style="min-width:140px" disabled><option value="">请先选问卷</option></select></span>
<label class="switch"><input type="checkbox" id="autoRefresh" checked><span></span></label> <label class="switch"><input type="checkbox" id="autoRefresh" checked><span></span></label>
<select id="intervalSel" style="width:auto"> <select id="intervalSel" style="width:auto">
<option value="5">5 秒</option> <option value="5">5 秒</option>
@@ -33,145 +55,747 @@ require __DIR__ . '/bootstrap.php';
<option value="60">60 秒</option> <option value="60">60 秒</option>
</select> </select>
<button class="btn" id="btnRefresh">刷新</button> <button class="btn" id="btnRefresh">刷新</button>
<a class="back" href="index.php">← 返回主系统</a> <button class="btn" id="btnFullscreen" title="切换全屏">⛶ 全屏</button>
<a class="btn back" id="linkIndex" href="index.php" title="返回主系统(成绩明细 / 名单 / 抽取)">← 主系统</a>
<?php if (!$publicView): ?>
<a class="btn back" id="linkLogout" href="logout.php" title="退出登录">退出</a>
<?php endif; ?>
</div> </div>
</div> </div>
<div class="sec-title">整体概况</div> <div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
<section class="cards" id="summary"> <span id="heroSub">选择一个问卷与班级查看考试整体情况</span>
<div class="card accent-blue"><span>名单总人数</span><b id="oTotal">-</b></div> <?php if ($publicView): ?><span class="pub-tag" title="本页面为公开页面,无需登录即可查看">公开 · 无需登录</span><?php endif; ?>
<div class="card ok accent-green"><span>已提交</span><b id="oSubmit">-</b></div> <span class="spacer" style="flex:1"></span>
<div class="card warn accent-orange"><span>缺考</span><b id="oAbsent">-</b></div> 最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
<div class="card accent-purple"><span>提交率</span><b id="oRate">-</b></div> </div>
<div class="card accent-teal"><span>平均分</span><b id="oAvg">-</b></div>
<div class="card accent-indigo"><span>合格率(≥60%满分)</span><b id="oPass">-</b></div> <!-- ================= 看板区:首页=班级入口卡片,单班页=该班看板 ================= -->
<div id="board">
<!-- 5 个汇总卡片(仅单班大屏使用) -->
<section class="cards" id="boardSummary">
<div class="card accent-blue"><span>总人数</span><b id="bTotal">-</b></div>
<div class="card ok accent-green"><span>已提交</span><b id="bSubmit">-</b></div>
<div class="card warn accent-orange"><span>缺考</span><b id="bAbsent">-</b></div>
<div class="card accent-teal"><span>平均分</span><b id="bAvg">-</b></div>
<div class="card accent-indigo"><span>合格率<span id="bPassLabel">(≥60%满分)</span></span><b id="bPass">-</b></div>
</section> </section>
<div class="sec-title">各班整体情况 <span class="tag-gray" id="classCount"></span></div> <div class="sec-title" id="boardTitle">分组对比 / 考试成员</div>
<div class="class-grid" id="classGrid"> <div id="boardBody">
<div class="empty">请选择问卷 / 考试以查看各班数据</div> <div class="empty">请选择问卷与班级查看考试整体情况</div>
</div>
</div> </div>
<div style="margin-top:18px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;"> </div>
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
<!-- 多次刷题明细弹窗 -->
<div class="modal" id="attemptsModal">
<div class="modal-box">
<div class="modal-head"><h3 id="attTitle">提交明细</h3><button class="x" data-close onclick="closeAttempts()">&times;</button></div>
<div class="modal-body" id="attBody"></div>
</div> </div>
</div> </div>
<script> <script>
// 弹窗:点击遮罩或带 data-close 的元素关闭
document.querySelectorAll('.modal').forEach(m => {
m.addEventListener('click', e => {
if (e.target === m || e.target.hasAttribute('data-close')) m.classList.remove('open');
});
});
const $ = s => document.querySelector(s); const $ = s => document.querySelector(s);
const $$ = s => Array.from(document.querySelectorAll(s));
function esc(s){ return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); } function esc(s){ return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
function fmtDur(sec){
if (sec == null) return '-';
if (sec < 60) return sec + ' 秒';
const m = Math.floor(sec / 60), s = sec % 60;
if (m < 60) return m + ' 分 ' + String(s).padStart(2, '0') + ' 秒';
const h = Math.floor(m / 60);
return h + ' 小时 ' + String(m % 60).padStart(2, '0') + ' 分';
}
function rateColor(r){ function rateColor(r){
if (r >= 90) return 'var(--ok)'; if (r >= 90) return 'var(--ok)';
if (r >= 60) return 'var(--warn)'; if (r >= 60) return 'var(--warn)';
return 'var(--danger)'; return 'var(--danger)';
} }
async function api(action, opts){ /* 成绩红绿:一律按管理员在后台设的本卷「满分 + 合格率」判定,取接口下发的 DASH。
opts = opts || {}; ⚠️ DASH 是 let 声明的,不会挂到 window 上——写成 window.DASH 永远拿到 undefined,
结果就是悄悄回退成 100 分 / 60% 的出厂默认值,后台改了合格率也不生效。 */
function scoreGrade(sc){
const d = (typeof DASH !== 'undefined') ? DASH : null;
const full = (d && d.full_score) ? Number(d.full_score) : 100;
const pass = (d && d.pass_rate != null) ? Number(d.pass_rate) : 60;
if (!(full > 0) || sc == null || sc === '') return { cls: '', pct: null, pass: pass, line: 0 };
const pct = Number(sc) / full * 100;
if (!isFinite(pct)) return { cls: '', pct: null, pass: pass, line: 0 };
// 合格线本身高过 90% 时绿线跟着抬上去,免得"合格了却显示成黄色"
const hi = Math.max(90, pass);
return {
cls: pct >= hi ? 'score-high' : (pct >= pass ? 'score-mid' : 'score-low'),
pct: pct,
pass: pass,
line: Math.round(full * pass / 100 * 10) / 10, // 合格线折算成分数, tooltip 用
};
}
/* FLIP 行移动画:新数据行滑到新排名、其它行顺势下移(尊重 prefers-reduced-motion) */
const REDUCED_MOTION = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
function captureFlip(container){
const m = new Map();
if (!container) return m;
container.querySelectorAll('tr[data-sid]').forEach(tr => {
const sid = tr.getAttribute('data-sid');
if (sid) m.set(sid, tr.getBoundingClientRect().top);
});
return m;
}
function playFlip(container, prev){
if (!container || !prev || REDUCED_MOTION) return;
container.querySelectorAll('tr[data-sid]').forEach(tr => {
const sid = tr.getAttribute('data-sid');
if (!sid) return;
const oldTop = prev.get(sid);
if (oldTop === undefined) { tr.classList.add('row-drop-in'); return; }
const dy = oldTop - tr.getBoundingClientRect().top;
if (Math.abs(dy) < 1) return;
tr.style.transition = 'none';
tr.style.transform = 'translateY(' + dy + 'px)';
void tr.offsetWidth;
requestAnimationFrame(() => {
tr.style.transition = 'transform .42s cubic-bezier(.22,.61,.36,1)';
tr.style.transform = '';
});
});
}
/* URL 状态:?pid=&class=&auto=1|0 */
function readUrl(){
const u = new URL(location.href);
return {
pid: u.searchParams.get('pid') || '',
cls: u.searchParams.get('class') || '',
auto: u.searchParams.get('auto'),
};
}
function writeUrl({pid, cls, auto}){
const u = new URL(location.href);
if (pid !== undefined) u.searchParams.set('pid', pid);
if (cls !== undefined) u.searchParams.set('class', cls);
if (auto !== undefined) u.searchParams.set('auto', auto ? '1' : '0');
// 注意:replaceState 不传 state 会把它清成 null,把后退锁的标志位一起抹掉,
// 所以有锁时原样写回(即便如此后退仍会被 popstate 兜住,这里是顺手保持一致)
const st = (history.state && history.state.nyLock) ? { nyLock: 1 } : null;
history.replaceState(st, '', u.pathname + (u.search ? u.search : ''));
}
/* 本地缓存 */
const LS = {
get(k, def){ try { const v = localStorage.getItem(k); return v == null ? def : JSON.parse(v); } catch(e){ return def; } },
set(k, v){ try { localStorage.setItem(k, v); } catch(e){} },
};
const LS_PROJECTS = 'ny_projects';
const LS_BOARD = (pid, cls) => 'ny_board_' + pid + '_' + encodeURIComponent(cls);
const LS_DASH_AUTO = 'ny_dash_auto';
const LS_DASH_INTERVAL= 'ny_dash_interval';
let DASH = null;
let CUR = readUrl();
/* 形态:URL 带 class = 单班大屏(不显示班级选择、锁后退);否则是「首页 → 选班级进入」 */
const IS_CLASS = !!CUR.cls;
/* 当前班级:单班页取自 URL,首页取自下拉 */
function currentClass(){ return IS_CLASS ? (CUR.cls || '') : ($('#classSel') ? $('#classSel').value : ''); }
/* ---------- API ---------- */
async function api(action, params){
let url = 'api.php?action=' + encodeURIComponent(action); let url = 'api.php?action=' + encodeURIComponent(action);
if (opts.params) { if (params) for (const k in params) url += '&' + k + '=' + encodeURIComponent(params[k] || '');
for (const k in opts.params) url += '&' + k + '=' + encodeURIComponent(opts.params[k]); const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
}
const init = { method: 'GET', headers: { 'Accept': 'application/json' } };
try {
const res = await fetch(url, init);
const j = await res.json(); const j = await res.json();
if (j && j.ok) return j.data; if (j && j.ok) return j.data;
throw new Error((j && (j.msg || j.error)) || '请求失败'); const err = new Error((j && (j.msg || j.error)) || '请求失败');
} catch (e) { // 401 = 本系统会话已失效,同样需要重新登录(防死循环闸门见 handleAuthFail)
throw new Error(e.message || '网络错误'); err.auth = !!(j && j.extra && j.extra.auth) || res.status === 401;
err.extra = (j && j.extra) || null;
throw err;
}
/* ---------- 鉴权失败:防反复跳登录页的死循环 ----------
* 若刚登录完(120 秒内)又鉴权失败,说明"重新登录"解决不了问题(多半是凭据没存住),
* 此时不再跳转,直接把服务端原因 + 落盘诊断显示出来,让人能自查。 */
function authJustLoggedIn(){
try {
const t = parseInt(sessionStorage.getItem('ny_login_at') || '0', 10);
return t > 0 && (Date.now() - t) < 120000;
} catch (e) { return false; }
}
function authJumpCount(){
try { return parseInt(sessionStorage.getItem('ny_auth_jumps') || '0', 10) || 0; } catch (e) { return 0; }
}
function showAuthFailureBox(e){
const d = (e.extra && e.extra.diag) || null;
const yn = v => (v ? '是' : '否');
const lines = d ? [
'配置来源:' + (d.storage || '-') + '(配置文件可写=' + yn(d.cfg_writable) + ')',
'数据目录:' + (d.data_dir || '-') + '(可写=' + yn(d.dir_writable) + ')',
'数据库:' + (d.db_connected ? ('已连接(可写=' + yn(d.db_writable) + ')') : ('未连接 ' + (d.db_error || ''))),
'已存账号:' + (d.has_username ? (d.username || '已设置') : '空 ← 这就是「未配置账号」的原因'),
'已存密码:' + (d.has_password ? '已保存' : '空'),
d.save_error ? ('保存异常:' + d.save_error) : '',
].filter(Boolean) : [];
$('#boardBody').innerHTML =
'<div class="empty" style="text-align:left;line-height:1.9">' +
'<b>无法访问卷王</b><br>' + esc(e.message || '') +
(lines.length ? '<pre style="white-space:pre-wrap;margin:10px 0 0;text-align:left;font-size:12px">' + esc(lines.join('\n')) + '</pre>' : '') +
'<div style="margin-top:12px"><a class="btn btn-primary" href="login.php">重新登录</a></div>' +
'</div>';
}
/* ---------- 问卷列表(双策略文件夹识别 + 缓存) ---------- */
function renderProjects(list){
const sel = $('#projectSel');
const keep = sel.value;
sel.innerHTML = '<option value="">请选择问卷 / 考试</option>';
const isFolder = p => (p.mode || '').toLowerCase().includes('folder');
const parentIds = new Set();
list.forEach(p => { const pid = (p.parent_id || '0'); if (pid && pid !== '0' && pid !== '') parentIds.add(pid); });
const folderSet = new Set();
list.forEach(p => { if (isFolder(p) || parentIds.has(p.id)) folderSet.add(p.id); });
const childOf = fid => list.filter(p => !folderSet.has(p.id) && (p.parent_id || '0') === fid);
list.filter(p => !folderSet.has(p.id)).forEach(p => {
const o = document.createElement('option');
o.value = p.id; o.textContent = (p.mode ? '[' + p.mode + '] ' : '') + p.name + '(' + (p.total || 0) + '份)';
sel.appendChild(o);
});
list.filter(p => folderSet.has(p.id)).forEach(f => {
const kids = childOf(f.id);
if (!kids.length) return;
const og = document.createElement('optgroup');
og.label = '📁 ' + f.name;
kids.forEach(p => {
const o = document.createElement('option');
o.value = p.id; o.textContent = (p.mode ? '[' + p.mode + '] ' : '') + p.name + '(' + (p.total || 0) + '份)';
og.appendChild(o);
});
sel.appendChild(og);
});
if (CUR.pid && list.some(p => p.id === CUR.pid)) sel.value = CUR.pid;
else if (keep && list.some(p => p.id === keep)) sel.value = keep;
else {
const last = LS.get('ny_board_last_pid', '');
if (last && list.some(p => p.id === last)) sel.value = last;
} }
} }
async function loadProjects(){ async function loadProjects(){
const cached = LS.get(LS_PROJECTS, []);
if (Array.isArray(cached) && cached.length) renderProjects(cached);
try { try {
const list = await api('projects'); const list = await api('projects');
const sel = $('#projectSel'); LS.set(LS_PROJECTS, JSON.stringify(list || []));
const keep = sel.value; renderProjects(list || []);
sel.innerHTML = '<option value="">请选择问卷 / 考试</option>' + refreshClassSel(); // 项目变了刷新班级列表
list.map(p => '<option value="' + esc(p.id) + '">' + esc(p.name) + '(' + esc(p.mode || '') + ')</option>').join('');
if (keep) sel.value = keep;
} catch (e) { } catch (e) {
$('#classGrid').innerHTML = '<div class="empty">加载问卷列表失败:' + esc(e.message) + '</div>'; if (!cached || !cached.length) {
$('#boardBody').innerHTML = '<div class="empty">加载问卷列表失败:' + esc(e.message) + '</div>';
}
} }
} }
function renderSummary(d){ /* ---------- 班级列表(来自 roster) ---------- */
const o = d.overall || {}; async function refreshClassSel(){
$('#oTotal').textContent = o.roster_total ?? '-'; const sel = $('#classSel');
$('#oSubmit').textContent = o.submitted ?? '-';
$('#oAbsent').textContent = o.absent ?? '-';
$('#oRate').textContent = (o.submit_rate != null ? o.submit_rate : '-') + (o.submit_rate != null ? '%' : '');
$('#oAvg').textContent = o.avg_score != null ? o.avg_score : '-';
$('#oPass').textContent = (o.pass_rate != null ? o.pass_rate : '-') + (o.pass_rate != null ? '%' : '');
}
function renderClasses(d){
const classes = d.classes || [];
$('#classCount').textContent = classes.length ? classes.length + ' 个班级' : '';
if (!classes.length) {
$('#classGrid').innerHTML = '<div class="empty">暂无班级数据(名单未导入班级信息)</div>';
return;
}
$('#classGrid').innerHTML = classes.map(c => {
const rate = c.rate || 0;
const color = rateColor(rate);
const passCls = c.pass_rate >= 60 ? 'metric-ok' : (c.pass_rate > 0 ? 'metric-warn' : '');
return '<div class="class-card">' +
'<h3><span>' + esc(c.class) + '</span><span class="rate-num" style="color:' + color + '">' + rate + '%</span></h3>' +
'<div class="sub">已提交 ' + c.submitted + ' / 总人数 ' + c.total + ' · 缺考 ' + c.absent + '</div>' +
'<div class="bar"><i style="width:' + rate + '%;background:' + color + '"></i></div>' +
'<div class="class-metrics">' +
'<div class="m"><span>平均分</span><b>' + (c.avg != null ? c.avg : '-') + '</b></div>' +
'<div class="m ' + passCls + '"><span>合格率</span><b>' + c.pass_rate + '%</b></div>' +
'<div class="m metric-ok"><span>最高分</span><b>' + (c.max != null ? c.max : '-') + '</b></div>' +
'<div class="m metric-danger"><span>最低分</span><b>' + (c.min != null ? c.min : '-') + '</b></div>' +
'</div>' +
'</div>';
}).join('');
}
async function loadDashboard(){
const pid = $('#projectSel').value; const pid = $('#projectSel').value;
if (!pid) { if (!pid) {
$('#classGrid').innerHTML = '<div class="empty">请选择问卷 / 考试以查看各班数据</div>'; sel.innerHTML = '<option value="">请先选问卷</option>';
sel.disabled = true;
return; return;
} }
// 项目数据来自 api (dashboard),先调用一次拿班级
try { try {
const d = await api('dashboard', { params: { projectId: pid } }); const data = await api('dashboard', dashParams({ projectId: pid }));
renderSummary(d); DASH = data;
renderClasses(d); // 合格率阈值可配(全局或本卷独立),卡片标题实时反映
$('#lastUpdate').textContent = d.updated_at || '-'; const bl = document.getElementById('bPassLabel');
$('#liveDot').classList.add('on'); if (bl) bl.textContent = '(≥' + (data.pass_rate == null ? 60 : data.pass_rate) + '%满分)';
setTimeout(() => $('#liveDot').classList.remove('on'), 900); const classes = (data.classes || []).map(c => c.class);
sel.innerHTML = classes.length
? classes.map(c => '<option value="' + esc(c) + '">' + esc(c) + '</option>').join('')
: '<option value="">暂无班级</option>';
sel.disabled = !classes.length;
if (IS_CLASS) {
/* 单班大屏:班级由 URL 决定,下拉只是隐藏的镜像。
换卷后该班若不在新卷名单里 → 落到第一个班并把 URL 改对(不新增历史,避免影响后退锁)。 */
if (classes.length) {
if (!classes.includes(CUR.cls)) { CUR.cls = classes[0]; writeUrl({ cls: CUR.cls }); }
sel.value = CUR.cls;
}
} else if (CUR.cls && classes.includes(CUR.cls)) {
sel.value = CUR.cls;
} else {
// 默认 class:上次选择 > 第一个
const last = LS.get('ny_board_last_cls_' + pid, '');
if (last && classes.includes(last)) sel.value = last;
else if (classes.length) sel.value = classes[0];
CUR.cls = sel.value;
}
renderBoard();
} catch (e) { } catch (e) {
$('#classGrid').innerHTML = '<div class="empty">数据加载失败:' + esc(e.message) + '</div>'; if (e.auth) {
const jumps = authJumpCount();
if (authJustLoggedIn() || jumps >= 2) { showAuthFailureBox(e); return; }
try { sessionStorage.setItem('ny_auth_jumps', String(jumps + 1)); } catch (err) {}
// 卷王账号未配置或登录失效:跳转登录页自愈
$('#boardBody').innerHTML = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
setTimeout(() => { location.href = 'login.php'; }, 1200);
return;
}
sel.innerHTML = '<option value="">加载失败</option>';
sel.disabled = true;
$('#boardBody').innerHTML = '<div class="empty">加载失败:' + esc(e.message) + '</div>';
} }
} }
function schedule(){ /* ---------- 成员榜(含多次刷题徽标与下钻) ---------- */
if (window.__timer) clearInterval(window.__timer); function membersTable(members){
if (!$('#autoRefresh').checked) return; if (!members || !members.length) {
const sec = parseInt($('#intervalSel').value, 10) || 15; return '<div class="empty" style="padding:18px 0">暂无考试成员</div>';
window.__timer = setInterval(loadDashboard, sec * 1000); }
return '<div class="member-list"><table class="grid compact">' +
'<thead><tr><th style="width:20%;text-align:right">排名</th><th style="width:55%">姓名</th><th style="width:25%" data-k="score">成绩</th></tr></thead><tbody>' +
members.map(m => {
const rk = (m.rank == null ? '' : m.rank);
const badge = rk === 1 ? 'rk rk-1' : (rk === 2 ? 'rk rk-2' : (rk === 3 ? 'rk rk-3' : 'rk'));
const sc = m.score;
// 红绿由后台配置的满分成数 / 合格率决定(见 scoreGrade)
const g = scoreGrade(sc);
const cls = g.cls;
const tip = g.cls ? ' title="占满分 ' + g.pct.toFixed(1) + '% 合格线 ' + g.line + ' 分"' : '';
const att = (m.attempts || 1) > 1;
const tag = att ? ' <span class="tag tag-att" title="查看 ' + m.attempts + ' 次提交明细">刷' + m.attempts + '次</span>' : '';
// 注意:不要把 sid 直接拼进 onclick="...",JSON.stringify 产生的双引号会提前闭合
// HTML 属性(onclick="openAttempts("123")"),点击必然报错。改用 data-sid + 事件委托。
return '<tr' + (att ? ' class="clickable"' : '') + ' data-sid="' + esc(m.sid) + '"' + (att ? ' title="查看提交明细"' : '') + '>' +
'<td class="num"><span class="' + badge + '">' + rk + '</span></td>' +
'<td class="name">' + esc(m.name) + tag + '</td>' +
'<td class="score num ' + cls + '"' + tip + '>' + (sc == null ? '-' : sc) + '</td>' +
'</tr>';
}).join('') +
'</tbody></table></div>';
} }
$('#projectSel').addEventListener('change', loadDashboard); /* ---------- 多次刷题下钻弹窗 ---------- */
$('#btnRefresh').addEventListener('click', loadDashboard); function openAttempts(sid){
$('#autoRefresh').addEventListener('change', schedule); const info = (window.MEMBER_ATT || {})[sid];
$('#intervalSel').addEventListener('change', schedule); if (!info) return;
const list = info.list || [];
const rows = list.map(a => {
// 下钻弹窗里的成绩用同一套红绿口径,避免"列表里是绿的、点开却是白的"
const g = scoreGrade(a.score);
const tmp = a.temp_save ? ' <span class="tag tag-gray">暂存</span>' : '';
const sc = a.score == null ? '<span class="muted">未判分</span>' : esc(a.score);
return '<tr>' +
'<td class="num">' + esc(a.submit_at) + '</td>' +
'<td class="num ' + g.cls + '">' + sc + tmp + '</td>' +
'<td class="num">' + esc(a.duration_txt || '-') + '</td>' +
'</tr>';
}).join('');
$('#attTitle').textContent = info.name + ' · 共 ' + (info.attempts || list.length) + ' 次提交';
$('#attBody').innerHTML = list.length
? '<table class="grid compact att-table"><thead><tr>' +
'<th data-k="submit_ts" style="width:40%">提交时间</th>' +
'<th data-k="score" style="width:30%">成绩</th>' +
'<th data-k="duration" style="width:30%">考试时长</th>' +
'</tr></thead><tbody>' + rows + '</tbody></table>'
: '<div class="empty">无提交明细</div>';
document.querySelector('#attemptsModal').classList.add('open');
}
function closeAttempts(){
const m = document.querySelector('#attemptsModal');
if (m) m.classList.remove('open');
}
// 成员榜下钻:事件委托(#boardBody 常驻,内部表格会被整体重绘)
(function bindMemberDrill(){
const box = document.querySelector('#boardBody');
if (!box) return;
box.addEventListener('click', e => {
const tr = e.target.closest ? e.target.closest('tr[data-sid]') : null;
if (!tr || !box.contains(tr)) return;
openAttempts(tr.getAttribute('data-sid'));
});
})();
// Esc 关闭最上层弹窗
document.addEventListener('keydown', e => {
if (e.key !== 'Escape' && e.key !== 'Esc') return;
const open = Array.prototype.slice.call(document.querySelectorAll('.modal.open'));
if (open.length) open[open.length - 1].classList.remove('open');
});
loadProjects().then(loadDashboard); /* ---------- 首页:班级入口卡片网格 ---------- */
schedule(); function clsCardHtml(c){
const rate = Number(c.rate || 0);
const total = dv(c.total, 0), sub = dv(c.submitted, 0), abs = dv(c.absent, 0);
const pr = Number(c.pass_rate == null ? 0 : c.pass_rate);
const kpi = (label, val, kc) =>
'<div class="cls-kpi ' + (kc || '') + '"><span>' + label + '</span><b>' + val + '</b></div>';
return '<div class="cls-card" data-cls="' + esc(c.class) + '" role="button" tabindex="0" ' +
'title="点击进入「' + esc(c.class) + '」的独立大屏">' +
'<div class="cls-head"><span class="cls-name">' + esc(c.class) + '</span>' +
'<span class="cls-go">进入 ›</span></div>' +
'<div class="cls-kpis">' +
kpi('总人数', total) +
kpi('已提交', sub, 'k-ok') +
kpi('缺考', abs, abs > 0 ? 'k-warn' : '') +
kpi('平均分', dv(c.avg)) +
kpi('合格率', pv(pr), 'k-ok') +
'</div>' +
'<div class="cls-bar"><i style="width:' + rate + '%;background:' + rateColor(rate) + '"></i></div>' +
'<div class="cls-sub"><span>提交率 ' + rate + '%</span>' +
(c.attempts > c.submitted ? '<span>共 ' + dv(c.attempts, 0) + ' 次刷题</span>' : '') +
'</div>' +
'</div>';
}
/** 首页渲染:把该卷的班级列成卡片,点击进入该班独立大屏(带 pid/auto 便于回到同一状态) */
function renderClassCards(){
const pid = $('#projectSel').value;
if (!pid || !DASH) { renderPlaceholderBoard('请先选择一份问卷 / 考试'); return; }
const list = DASH.classes || [];
const bt = document.getElementById('boardTitle');
if (bt) bt.innerHTML = '班级入口 <span class="tag-gray">点击卡片进入该班独立大屏</span>';
const box = $('#boardBody');
if (!list.length) {
box.innerHTML = '<div class="empty">该问卷没有解析出班级,请检查名单的「班级」列</div>';
return;
}
box.innerHTML = '<div class="cls-grid">' + list.map(clsCardHtml).join('') + '</div>';
const _hs = document.getElementById('heroSub');
if (_hs) _hs.textContent = '共 ' + list.length + ' 个班级 · 点击卡片进入该班大屏';
const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); }
}
/** 进入某班独立大屏:整页跳转(不是前端路由,浏览器后退键会被下面的锁拦掉) */
function enterClass(cls){
const pid = $('#projectSel').value;
if (!pid || !cls) return;
const u = new URL(location.href);
u.searchParams.set('pid', pid);
u.searchParams.set('class', cls);
u.searchParams.set('auto', $('#autoRefresh').checked ? '1' : '0');
location.href = u.pathname + u.search;
}
// 卡片点击 / 回车进入(事件委托:卡片网格每次整体重绘,绑在常驻的 #boardBody 上)
(function bindClsCards(){
const box = document.querySelector('#boardBody');
if (!box) return;
const hit = e => (e.target && e.target.closest ? e.target.closest('.cls-card') : null);
box.addEventListener('click', e => {
const card = hit(e);
if (card) enterClass(card.getAttribute('data-cls') || '');
});
box.addEventListener('keydown', e => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const card = hit(e);
if (!card) return;
e.preventDefault();
enterClass(card.getAttribute('data-cls') || '');
});
})();
/* ---------- 看板占位(未选问卷/班级) ---------- */
/* 占位卡片配色:与真实看板卡片(renderBoard)一一对应,保证空态左侧色条一致 */
const PLACEHOLDER_ACCENT = {
'总人数': 'accent-blue', '已提交': 'accent-green', '缺考': 'accent-orange',
'平均分': 'accent-teal', '合格率': 'accent-indigo'
};
function renderPlaceholderBoard(msg){
const m = msg || '选择一个问卷与班级查看考试整体情况';
const cards = [
['总人数','-'],['已提交','-'],['缺考','-'],
['平均分','-'],['合格率','-']
];
$('#boardSummary').innerHTML = cards.map(c =>
// 占位卡片与真实看板卡片顺序、配色一致(accent-* 决定左侧色条),避免空态蓝条与真实卡片不一致
'<div class="card ' + PLACEHOLDER_ACCENT[c[0]] + '"><span>' + c[0] + '</span><b>' + c[1] + '</b></div>').join('');
$('#boardBody').innerHTML = '<div class="empty">' + esc(m) + '</div>';
const bt = document.getElementById('boardTitle');
if (bt) bt.innerHTML = IS_CLASS ? '分组对比 / 考试成员'
: '班级入口 <span class="tag-gray">选好试卷后显示可进入的班级</span>';
// 注意:heroTitle 已随导航栏精简移除,只保留 heroSub(承载当前问卷/班级提示)
const _hs = document.getElementById('heroSub');
if (_hs) _hs.textContent = m;
const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); }
}
/* ---------- 实时状态指示 ---------- */
function markLive(){
const d = $('#liveDot');
if (d) { d.classList.remove('stale'); d.classList.add('on'); }
setTimeout(() => { const e = $('#liveDot'); if (e) e.classList.remove('on'); }, 900);
}
function markStale(msg){
const d = $('#liveDot');
if (d) { d.classList.remove('on'); d.classList.add('stale'); }
const lu = $('#lastUpdate');
// 具体原因放进 title(鼠标悬停可见),正文保持简短
if (lu) { lu.textContent = '刷新中断(仍显示上次数据)'; if (msg) lu.title = String(msg); }
}
function handleAuthFail(e){
const jumps = authJumpCount();
if (authJustLoggedIn() || jumps >= 2) { showAuthFailureBox(e || new Error('鉴权失败')); return; }
try { sessionStorage.setItem('ny_auth_jumps', String(jumps + 1)); } catch (err) {}
$('#boardBody').innerHTML = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
setTimeout(() => { location.href = 'login.php'; }, 1200);
}
/* ---------- 渲染单班看板 ---------- */
/** 数值兜底:后端字段缺失时显示占位符,避免界面出现 NaN / undefined */
function dv(v, dash){ return (v === undefined || v === null || v === '' || Number.isNaN(v)) ? (dash || '-') : v; }
function pv(v){ return dv(v, '-') + (v === undefined || v === null || v === '' ? '' : '%'); }
function renderBoard(){
// 首页形态:不出单班看板,改为渲染班级入口卡片(boardSummary 由 CSS 隐藏)
if (!IS_CLASS) { renderClassCards(); return; }
const pid = $('#projectSel').value;
const cls = currentClass();
CUR.pid = pid; CUR.cls = cls;
writeUrl({ pid, cls });
if (!pid || !cls || !DASH) {
renderPlaceholderBoard();
return;
}
LS.set('ny_board_last_pid', JSON.stringify(pid));
LS.set('ny_board_last_cls_' + pid, JSON.stringify(cls));
const c = (DASH.classes || []).find(x => x.class === cls);
if (!c) {
$('#boardBody').innerHTML = '<div class="empty">该班级无数据(名单中无此班级)</div>';
return;
}
// 顶部提示:当前班级 / 问卷(heroTitle 已移除,只更新 heroSub)
const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || '';
const _hc = document.getElementById('heroClass');
if (_hc) _hc.textContent = cls + ' · 该班独立大屏';
// 构建"学号 -> 多次刷题明细"映射(班级级 members 已含全班所有人的 attempts_list)
const MAP = {};
(c.members || []).forEach(m => {
if ((m.attempts || 1) > 1) {
MAP[m.sid] = { name: m.name, attempts: m.attempts, list: m.attempts_list || [] };
}
});
window.MEMBER_ATT = MAP;
const overall = DASH.overall || {};
const totalAttempts = (overall.attempts_total || 0);
const _hs2 = document.getElementById('heroSub');
if (_hs2) _hs2.textContent = cls + ' · 问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' +
'总 ' + dv(c.total, 0) + ' / 已交 ' + dv(c.submitted, 0) + ' / 缺考 ' + dv(c.absent, 0) +
(totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : '');
// 5 卡汇总(提交率卡片已移除)
$('#boardSummary').innerHTML =
'<div class="card accent-blue"><span>总人数</span><b>' + dv(c.total, 0) + '</b></div>' +
'<div class="card ok accent-green"><span>已提交</span><b>' + dv(c.submitted, 0) + '</b></div>' +
'<div class="card warn accent-orange"><span>缺考</span><b>' + dv(c.absent, 0) + '</b></div>' +
'<div class="card accent-teal"><span>平均分</span><b>' + dv(c.avg) + '</b></div>' +
'<div class="card accent-indigo"><span>合格率(≥' + (DASH && DASH.pass_rate != null ? DASH.pass_rate : 60) + '%满分)</span><b>' + pv(c.pass_rate) + '</b></div>';
const groups = (c.groups || []).filter(g => g.group !== '(未分组)');
const box = $('#boardBody');
if (!groups.length) {
box.innerHTML = '<div class="gpanel">' +
'<div class="ghead"><strong>本班考试成员</strong>' +
'<span class="rate-num">' + dv(c.submitted, 0) + ' 人</span></div>' +
'<div class="sub">该班级未设置分组(A/B),以下按成绩从高到低排列' +
(c.attempts > c.submitted ? ' · 共 ' + c.attempts + ' 次刷题' : '') + '</div>' +
membersTable(c.members) + '</div>';
} else {
box.innerHTML = '<div class="group-compare">' + groups.map(g => {
const gc = rateColor(g.rate || 0);
return '<div class="gpanel">' +
'<div class="ghead"><strong>' + esc(g.group) + ' 组</strong>' +
'<span class="rate-num" style="color:' + gc + '">' + dv(g.rate, 0) + '%</span></div>' +
'<div class="sub">已提交 ' + dv(g.submitted, 0) + ' / 总人数 ' + dv(g.total, 0) + ' · 缺考 ' + dv(g.absent, 0) +
(g.attempts > g.submitted ? ' · 共 ' + g.attempts + ' 次刷题' : '') + '</div>' +
'<div class="bar"><i style="width:' + dv(g.rate, 0) + '%;background:' + gc + '"></i></div>' +
'<div class="class-metrics">' +
'<div class="m"><span>平均分</span><b>' + dv(g.avg) + '</b></div>' +
'<div class="m"><span>合格率</span><b>' + pv(g.pass_rate) + '</b></div>' +
'<div class="m metric-ok"><span>最高分</span><b>' + dv(g.max) + '</b></div>' +
'<div class="m metric-danger"><span>最低分</span><b>' + dv(g.min) + '</b></div>' +
'</div>' +
'<div class="mem-title">考试成员 · 按成绩降序(' + dv(g.submitted, 0) + ' 人)</div>' +
membersTable(g.members) +
'</div>';
}).join('') + '</div>';
}
}
/* ---------- 全屏 ---------- */
function toggleFullscreen(){
const el = document.documentElement;
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
const req = el.requestFullscreen || el.webkitRequestFullscreen;
if (req) {
const p = req.call(el);
// 部分浏览器(尤其 Safari)会返回 Promise,被拒绝时静默吞掉会让人以为"点了没反应"
if (p && typeof p.catch === 'function') {
p.catch(() => { alert('当前浏览器/环境不允许进入全屏,请按 F11 手动切换'); });
}
}
} else {
// 注意:exitFullscreen 挂在 document 上,不是 documentElement 上
// (写成 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(){
const on = !!(document.fullscreenElement || document.webkitFullscreenElement);
document.body.classList.toggle('fs-on', on);
const btn = $('#btnFullscreen');
if (btn) btn.textContent = on ? '⛶ 退出全屏' : '⛶ 全屏';
}
/* ---------- 自动刷新 ---------- */
let timer = null;
function schedule(){
if (timer) { clearInterval(timer); timer = null; }
if (!$('#autoRefresh').checked) return;
const sec = parseInt($('#intervalSel').value, 10) || 15;
timer = setInterval(refreshBoard, sec * 1000);
}
/* 取数参数:公开访问时后端按 class 裁剪并返回单班结果,取数必须带班级;
已登录时这个参数被忽略,留着不影响。 */
function dashParams(extra){
const p = {};
for (const k in (extra || {})) p[k] = extra[k];
p.projectId = p.projectId || $('#projectSel').value || CUR.pid || '';
if (IS_CLASS && CUR.cls) p.class = CUR.cls;
return p;
}
async function refreshBoard(manual){
const pid = $('#projectSel').value;
if (!pid) return;
// 标签页在后台时不做自动轮询(省流量、也避免无人值守时反复请求卷王)
if (!manual && document.hidden) return;
const btn = $('#btnRefresh');
if (manual && btn) { btn.disabled = true; btn.textContent = '刷新中…'; }
try {
const fresh = await api('dashboard', dashParams({ projectId: pid }));
DASH = fresh;
// 项目集合可能换:刷新班级下拉但保留当前选择
const sel = $('#classSel');
const keep = sel.value;
const classes = (fresh.classes || []).map(c => c.class);
if (!classes.includes(keep)) {
sel.innerHTML = classes.length
? classes.map(c => '<option value="' + esc(c) + '">' + esc(c) + '</option>').join('')
: '<option value="">暂无班级</option>';
sel.disabled = !classes.length;
if (classes.length) { sel.value = classes[0]; CUR.cls = classes[0]; writeUrl({ cls: classes[0] }); }
}
// 刷新前捕获各行位置,刷新后做 FLIP:新数据行滑入、其它行下移
const flipPrev = captureFlip($('#boardBody'));
renderBoard();
playFlip($('#boardBody'), flipPrev);
$('#lastUpdate').textContent = fresh.updated_at || '-';
markLive();
} catch (e) {
if (e.auth) { handleAuthFail(e); return; }
// 静默续期:保留旧数据,但把实时点变红提示中断
markStale(e.message);
} finally {
if (manual && btn) { btn.disabled = false; btn.textContent = '刷新'; }
}
}
/* ---------- 后退锁定:单班大屏不允许退回首屏 / 主系统 ----------
* 做法:加载后立刻 pushState 一条同页历史,之后每次 popstate(无论用户按后退还是前进)
* 都再推回一条,页面永远留在当前 URL 上——浏览器后退键因此无效。
* 只锁 IS_CLASS 形态:首页仍可正常后退(例如退回登录前的主系统页)。 */
let historyLocked = false;
function lockHistory(){
if (!IS_CLASS || historyLocked) return;
historyLocked = true;
const push = () => { try { history.pushState({ nyLock: 1 }, '', location.href); } catch (e) {} };
push();
window.addEventListener('popstate', function onPop(e){
// 落回自己推入的同页条目时不再重复占位,避免连按后退无限堆积历史
if (e.state && e.state.nyLock) return;
push();
});
}
/* ---------- 事件 ---------- */
$('#projectSel').addEventListener('change', () => { CUR.pid = $('#projectSel').value; CUR.cls = ''; refreshClassSel(); schedule(); });
$('#classSel').addEventListener('change', () => { CUR.cls = $('#classSel').value; renderBoard(); });
$('#btnRefresh').addEventListener('click', () => refreshBoard(true));
$('#autoRefresh').addEventListener('change', () => {
writeUrl({ auto: $('#autoRefresh').checked });
LS.set(LS_DASH_AUTO, $('#autoRefresh').checked);
schedule();
});
$('#intervalSel').addEventListener('change', () => {
LS.set(LS_DASH_INTERVAL, parseInt($('#intervalSel').value, 10) || 15);
schedule();
});
$('#btnFullscreen').addEventListener('click', toggleFullscreen);
document.addEventListener('fullscreenchange', onFsChange);
document.addEventListener('webkitfullscreenchange', onFsChange);
/* 切回前台:立刻补一次数据并重排定时器;切到后台:停掉轮询 */
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (timer) { clearInterval(timer); timer = null; }
} else {
refreshBoard();
schedule();
}
});
/* ---------- 偏好恢复(自动刷新开关 + 间隔,含 ?interval= 深链) ---------- */
function restoreDashPrefs(){
const qs = new URLSearchParams(location.search);
if (qs.get('interval')) {
$('#intervalSel').value = qs.get('interval');
LS.set(LS_DASH_INTERVAL, parseInt($('#intervalSel').value, 10) || 15);
} else {
const iv = LS.get(LS_DASH_INTERVAL, 0);
if (iv) {
const opts = Array.prototype.slice.call($('#intervalSel').options).map(o => parseInt(o.value, 10));
if (opts.includes(iv)) $('#intervalSel').value = String(iv);
}
}
if (CUR.auto !== '0') {
const auto = LS.get(LS_DASH_AUTO, null);
if (auto !== null) $('#autoRefresh').checked = !!auto;
}
}
/* ---------- 启动 ---------- */
restoreDashPrefs();
loadProjects().then(() => {
if (CUR.pid) {
$('#projectSel').value = CUR.pid;
refreshClassSel().then(() => { renderBoard(); schedule(); });
} else if (IS_CLASS && $('#projectSel').value) {
// 公开链接可能不带 pid:自动落到该卷第一份考试,别让访客对着空页面
CUR.pid = $('#projectSel').value;
refreshClassSel().then(() => { renderBoard(); schedule(); });
} else {
refreshClassSel();
}
// 首页文案指向"选考试 → 进班级",避免顶栏停在旧的单班提示上
if (!IS_CLASS) {
const _hs = document.getElementById('heroSub');
if (_hs) _hs.textContent = '先选择一份问卷 / 考试,再点击班级卡片进入该班大屏';
}
});
lockHistory();
// auto 参数
if (CUR.auto === '0') $('#autoRefresh').checked = false;
</script> </script>
</body> </body>
</html> </html>
+132
View File
@@ -0,0 +1,132 @@
import { defineConfig } from 'vitepress'
// 站点部署在域名的 /ny-exam-platform 子路径下,故 base 默认为该路径。
// 本地预览时用 DOCS_BASE=/ 覆盖:DOCS_BASE=/ npm run docs:build
const base = process.env.DOCS_BASE || '/ny-exam-platform/'
// 输出目录。默认 .vitepress/dist;若需要保留旧产物(或环境禁止删除目录),
// 可用 DOCS_OUT=.vitepress/dist2 npm run docs:build 输出到新目录。
const outDir = process.env.DOCS_OUT || '.vitepress/dist'
export default defineConfig({
base,
outDir,
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/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: '切换到深色模式'
}
})
+133
View File
@@ -0,0 +1,133 @@
# 接口一览
所有接口统一走 `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`、
`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`、`class` | 按班级 / 组别聚合的看板数据。`class` 非空且未登录时,服务端只回这一个班(并标记 `public`) |
`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[] 未匹配答卷
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 | 方法 | 参数 | 说明 |
| --- | --- | --- | --- |
| `export` | GET | `projectId`、`nameQid`、`cls`、`grp`、`mode=latest\|all`、`scoreMode=latest\|best`、`split=cls`、`sort`、`cols`、`skipTemp=1`、`noAbsent=1` | 成绩明细 `.xlsx`(`all` 时每人每次考试一行) |
| `export_absent` | GET | `projectId`、`nameQid`、`cls`、`grp` | 缺考名单 `.xlsx` |
| `export_multi` | GET | `projectIds`(逗号分隔)、`nameQid`、`mode=block\|summary\|both`、`wide=1`、`passTimes`、`markBest=0`、`passRow=0`、`scoreMode=latest\|highest`、`cls` / `grp`(可多值)、`withCls` / `withGrp`、`includeTemp`、`format=json` | 「每次考试」块表 / 宽表 / 成绩对比 `.xlsx`(`format=json` 时返回装配结果供前端打印) |
| `draw_export` | GET | `type=import\|list` | 白名单导入模板 / 白名单列表 |
`sort` 取值:`time` / `sid` / `score_desc` / `score_asc`。
### 导出新参数
| 参数 | 适用接口 | 取值 | 说明 |
| --- | --- | --- | --- |
| `mode=all` | `export` | 缺省 | 每次考试一行(同一学号考 N 次就有 N 行);`mode=latest` 为每人一行最新成绩 |
| `scoreMode=best` | `export` | 缺省 | 每人一行的代表成绩改取**最高成绩**(默认 `latest` = 最近一次真实提交);`mode=all` 不生效 |
| `noAbsent=1` | `export`、`export_multi` | 缺省 | 不附缺考名单工作表(`export_multi` 口径:所选考试全都没交的人) |
| `skipTemp=1` | `export` | 缺省 | `mode=all` 时排除暂存草稿;`includeTemp=1` 可强制保留 |
| `mode=block` | `export_multi` | **默认** | 两行表头的「每次考试」块表:一列「第几次」被所有考试共用(逐行 1、2、3…,不合并),每场考试占 **1 列**(第 1 行考试名 + 第 2 行「成绩」小标题),学生块末尾一行「合格次数」,学号·姓名纵向合并,没考的考试合并成「未考」(旧值 `merged` / `attempts` 等价) |
| `wide=1` | `export_multi` | — | 只传 1 个 `projectId` 时改用宽表:每人一行、第几次直接做列名(`学号 / 姓名 / 第1次…第N次 / [合格次数]`),由 `ny_assemble_attempt_wide_sheet()` 生成 |
| `passTimes` | `export_multi` | 1 | 「合格次数」达标阈值:该场达到合格线的次数 ≥ 此值才标绿(默认 1) |
| `markBest=0` | `export_multi` | 开 | `=0` 取消「最高分标红」 |
| `passRow=0` | `export_multi` | 开 | `=0` 不输出「合格次数」行(也就没有绿底) |
| `mode=summary` | `export_multi` | — | 成绩对比:一行一个学生、一列一场考试(旧值 `matrix` 等价) |
| `mode=both` | `export_multi` | — | 第一张块表 + 第二张成绩对比表 |
| `format=json` | `export_multi` | — | 不下载文件,`ny_ok` 返回 `sheets`(`rows` / `merges` / `widths` / `header_rows` / `cell_styles`)+ `meta`(考试名、场次、班级 / 组别、列开关、`pass_line` / `pass_times` / `mark_best` / `pass_row` / `wide`),供「多考试打印」渲染 |
| `cls` / `grp` | `export_multi` | 新增 | 支持多选(逗号分隔),空 = 全部;与单考试 `export` 的筛选口径一致 |
| `withCls` / `withGrp` | `export_multi` | 新增 | 强制出 / 不出班级、组别列;缺省由 `ny_multi_col_flags()` 判定:选 ≥2 个班级(组别)才出,只选 1 个班级且未选组别两列都不出 |
| `includeTemp=1` | `export_multi` | 缺省 | 不传 = 块表默认跳过暂存草稿(保留时标注 `第N次(草稿)`) |
块表由 `ny_assemble_block_sheet()` 生成,返回 `rows`(两行表头 + 学生块)/ `merges` / `widths` / `header_rows=2`
/`cell_styles`;宽表由 `ny_assemble_attempt_wide_sheet()` 生成,参数同上。
合格线按**每场各自**的 `ny_resolve_score_cfg($pid, $c)` 解析(本卷满分 × 合格率),不是全场统一一条线。
`cell_styles` 是 `行,列 → red | green`(行与列均 **1-based**),只有两类标记:
- `red` —— 该场该生成绩 = 该场最高分(同分只标最早达成的那次);
- `green` —— 该场该生成绩达到合格线的次数 ≥ `passTimes`(未达标不标记)。
`XlsxWriter::renderMulti()` 支持 `merges`(1-based 闭区间 `[r1,c1,r2,c2]`)、`header_rows`(多行表头同样式)
与 `cell_styles`(单元格级红字 / 绿底);被合并的非首格写成「空值 + 同样式」以保证合并区域四周边框完整,
并跟随左上角首格的标记,避免绿底只覆盖半个格子。
前端 `sheetToHtml()` 把同一份 `merges` 换算成 `colspan` / `rowspan`、把 `cell_styles` 换算成红字 / 绿底,
所以**打印与导出的 Excel 用同一套判定**。
列显示规则 `ny_multi_col_flags()` 与排序比较函数同样放在 `lib/multi_export.php`(纯函数,可单测)。
## 前端调用约定
```js
// 读
const d = await api('data', { params: { projectId, force: 1 } })
// 写:api() 会自动注入 _csrf
await api('roster_save', { json: { students } })
```
## 相关
- [架构与目录](./architecture.md)
- [测试](./testing.md)
+108
View File
@@ -0,0 +1,108 @@
# 架构与目录
## 技术栈
- **后端**:PHP 8(过程式 + 少量静态类),零 Composer 依赖
- **前端**:原生 HTML / CSS / JavaScript(无框架、无构建步骤)
- **存储**:SQLite(PDO),缺失 `pdo_sqlite` 时自动降级为 JSON 文件
- **外部依赖**:仅卷王(SurveyKing)开放 API
## 目录结构
```
ny-exam-platform/
├─ index.php 主系统页面(服务端渲染骨架 + 前端挂载)
├─ dashboard.php 数据大屏:首页(班级入口)+ ?class= 单班大屏(锁后退)
├─ 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 防惊群)
│ ├─ 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/ 导入临时目录(解析后即删)
├─ 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)
+112
View File
@@ -0,0 +1,112 @@
# 数据存储(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/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)
+142
View File
@@ -0,0 +1,142 @@
# 测试
回归测试分两类:**PHP 自测**(纯逻辑断言)与 **Node e2e**(起 mock 服务 + PHP 内置服务器,走真实 HTTP)。
## 一键运行
```bash
bash tests/run-all.sh
```
脚本会依次执行:
| 文件 | 类型 | 覆盖 |
| --- | --- | --- |
| `tests/test-aggregate.php` | PHP | 聚合、去重、考试次数、排序 |
| `tests/test-new-features.php` | PHP | 再次提交置顶排序、XLSX 多表、名单导入列识别 |
| `tests/test-config-persist.php` | PHP | 配置双写兜底(DB / `config.json`) |
| `tools/test-xlsxwriter.php` | PHP | XLSX 写入与 ZIP 打包、合并单元格、单元格级红字 / 绿底 |
| `tests/test-multi-export.php` | PHP | 多考试块表与单场宽表的装配(合并区间、合格线、标红标绿、班级 / 组别列) |
| `tools/test-config-save.js` | Node e2e | 配置保存、连接变化检测、每卷配置隔离、CSRF |
| `tools/test-upload-guard.js` | Node e2e | 导入文件后缀白名单 |
| `tools/test-whitelist.js` | Node e2e | 白名单导入 / 名单识别 |
| `tools/test-login-loop.js` | Node e2e | 未配置账号 → 登录 → 已登录状态回流 |
| `tools/test-dashboard-nav.js` | Node e2e | 数据大屏两种形态:`dash-home` / `dash-class` 骨架、顶栏控件收敛、后退锁定、班级卡片渲染 |
| `tools/test-draw-alloc.js` | Node e2e | 随机抽取名额分配(加权 / 自定义 / 守恒 fuzz) |
| `tools/test-block-print.js` | Node(不需要服务) | 从 `assets/app.js` 抽取 `sheetToHtml` 校验合并区间换算与红 / 绿标记 |
> `tools/test-block-print.js`、`tools/make-samples.js`、`tools/test-dashboard-nav.js`、`tools/make-dash-sample.js`
> 都会**直接从生产源码里抽取函数在真数据上执行**,而不是另抄一份渲染逻辑——改了这些函数,这几处会立刻暴露漂移。
::: warning 端口占用会让 e2e「假通过 / 假失败」
每个 e2e 段用固定端口(`test-config-save.js` 18081、`test-whitelist.js` 18082、`test-upload-guard.js` 18083 等)。
若上一次跑测试时中断,PHP 子进程可能变成**孤儿服务**继续占着端口:新一次运行时它的 `waitPort` 会照常通过
(打到的是旧服务),于是登录直接失败、报错信息指向错误的连接地址。遇到"服务已就绪但登录失败",先查端口:
```bash
netstat -ano | grep LISTENING | grep -E ":180[0-9][0-9]|:199[0-9][0-9]"
taskkill //PID <pid> //F
```
:::
## 生成导出版式样例
想直观看新版式(以及验证打印与导出一致),可生成可打开的样例:
```bash
php tools/make-samples.php # 出两版 xlsx + sheet-data.json
node tools/make-samples.js # 用生产 sheetToHtml 渲染打印预览 HTML
node tools/make-dash-sample.js # 渲染数据大屏首页预览(含班级入口卡片)
```
产物在 `docs/samples/`:打印预览 `preview-multi-block.html` / `preview-single-wide.html`
(+ 对应 `.xlsx`),大屏首页预览 `dash-home-preview.html`。两者都自起 mock 服务与临时数据目录,
不会读写项目 `data/`。
可通过环境变量覆盖运行时路径:
```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,需手工核对
```
:::
## 写新测试时注意
| 陷阱 | 说明 |
| --- | --- |
| 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/` 为空
- [ ] 工具栏「导出」只一个按钮 → 弹窗里四种内容都能出表,学号不是科学计数法
- [ ] 成绩明细「每人成绩」切 最高 / 最新 → 成绩列跟着变,文件名带 `_最高成绩` / `_最新成绩`
- [ ] 每次考试导出 → 最高分红字、合格次数达标绿底、未达标留白、整场未考合并成「未考」;每场考试只占 1 列,「第几次」逐行 1/2/3 不被合并
- [ ] 多考试打印 → 版式与导出的 Excel 一致,「合格次数」标签没被合并吞掉
- [ ] 打印预览 → 默认成绩降序,没有教师签字栏、没有站点域名页脚;**绿底 / 红字真的印出来了**
(打印样式带 `print-color-adjust: exact`,不需要在对话框里勾「背景图形」)
- [ ] 导出弹窗「附加内容 · 附缺考名单」→ 每次考试 / 多考试对比也出「缺考名单」工作表
- [ ] 在每卷配置里改满分成数 / 合格率 → 数据大屏成员榜的红绿跟着变
- [ ] 数据大屏首页 → 选试卷后列出班级卡片,点击进入单班大屏
- [ ] 单班大屏 → 顶栏只有试卷 / 自动刷新 / 全屏,没有班级选择与退出;按浏览器后退键**退不回去**
- [ ] 复制单班大屏链接在**无痕窗口**打开 → 直接看到该班数据(免登录),顶栏有「公开 · 无需登录」角标;
看不到其它班级的成绩;大屏首页仍要求登录
- [ ] 数据大屏全屏进入 / 退出都正常
- [ ] 随机抽取 → 名额守恒
## 相关
- [架构与目录](./architecture.md)
- [接口一览](./api.md)
+107
View File
@@ -0,0 +1,107 @@
# 数据大屏
数据大屏(`dashboard.php`)是**面向投屏 / 大屏**的汇总视图,适合考试进行中实时监控提交进度。
它有两种形态,由 URL 决定:
| 形态 | 打开方式 | 长什么样 |
| --- | --- | --- |
| **首页(班级入口)** | 从主页面顶部导航 **📊 数据大屏** 打开,或直接访问 `dashboard.php` | 先选试卷 / 考试,下方以卡片列出该卷的所有班级;点卡片进入该班大屏 |
| **单班大屏** | 点首页的班级卡片(等价于 `dashboard.php?pid=<问卷ID>&class=<班级名>`) | 只展示这一个班:5 张汇总卡 + 分组对比 / 成员榜 |
首页是"入口",不是概览页——它不堆指标,只做一件事:让你**一次看到有哪些班,点进去看单个班**。
## 单班大屏是公开的,可以直接发链接
`dashboard.php?pid=<问卷ID>&class=<班级名>` **不需要登录**,任何人打开都能看到这一个班的数据
(顶栏带「公开 · 无需登录」角标)。适合直接把链接发到班级群让孩子或家长自己看。
::: danger 公开的是"这一个班",不是全卷
接口在**服务端**先按 `class` 把名单和答卷裁剪掉,再回传数据:公开链接拿不到全卷汇总,
也拿不到其它班级的成绩。想看别的班,打开那个班自己的链接即可;不带 `class` 参数的
大屏首页仍然要求登录。
:::
::: warning 单班大屏没有回头路
单班大屏**不显示**班级下拉、「← 主系统」和「退出」,页面内部也不存在回到首页的链接;
浏览器**后退键同样被屏蔽**(页面加载后立刻 `pushState` 占位,任何 `popstate` 都再推回去)。
想回主系统或换班,请在地址栏改 URL,或关闭标签页重新从首页进入。
:::
## 顶部控件
| 控件 | 首页 | 单班大屏 |
| --- | :-: | :-: |
| 问卷 / 考试 | ✅ | ✅ 切换后若该班不在新卷名单里,会自动落到第一个班 |
| 班级 | ✅ | ❌ 由 URL 固定,不提供切换 |
| 自动刷新开关 + 间隔 | ✅ | ✅ |
| 刷新 | ✅ | ✅ |
| ⛶ 全屏 | ✅ | ✅ |
| ← 主系统 | ✅ | ❌ 由 CSS 隐藏 |
| 退出 | ✅ | ❌ 页面里不再渲染 |
::: tip 后台标签页会自动暂停
切换到其他标签页后看板会**停止轮询**,切回来时立即补拉一次数据并重新开始计时——
投屏时长开也不会在无人观看时反复请求卷王。自动刷新失败时,「最后更新」会显示
**刷新中断(仍显示上次数据)**,鼠标悬停可看到具体原因。
:::
## 首页:班级入口卡片
每张卡片给出该班的速览,点整张卡片(或聚焦后按回车)进入该班大屏:
`总人数 / 已提交 / 缺考 / 平均分 / 合格率` + 一条提交率进度条;没交过卷的班也能一眼看到缺考数。
::: tip 首页也在自动刷新
首页同样受自动刷新开关控制(默认 15 秒),卡片上的数字会跟着变,适合把整层楼/整个年级的
提交进度投在一块屏上。
:::
## 汇总卡片(单班大屏)
`总人数 / 已提交 / 缺考 / 平均分 / 合格率(≥X%满分)`
合格率卡片的阈值标签会随 [每卷配置](./project-config.md) 实时变化。
## 成绩的红绿怎么定的
成员榜(以及「刷 N 次」下钻弹窗)里的成绩颜色**完全按 [每卷配置](./project-config.md) 里
管理员设的满分与合格率**判定,不是写死的:
| 占满分的比例 | 颜色 |
| --- | --- |
| ≥ 90%(合格线本身高过 90% 时则以合格线为准) | 🟢 绿 |
| ≥ 合格率 × 满分(合格但未达优秀) | 🟡 黄 |
| < 合格率 × 满分(不及格) | 🔴 红 |
例如把本卷满分改成 150、合格率改成 80%,那么 100 分只占 66.7% → 判红,130 分(86.7%)判黄,
145 分(96.7%)判绿。鼠标悬停在分数上会显示「占满分 xx% 合格线 xx 分」。
## 班级 / 组别下钻
(即单班大屏的主体部分)
看板主体按**班级**分块,每块内再按**组别**对比,指标与统计口径完全相同:
总人数、已提交、缺考、提交率、平均分、合格率、最高分、最低分、平均用时。
点击考过多次的成员(带「刷 N 次」标签)可查看其**每一次提交**(时间、成绩、时长),最佳成绩有标记。
按 `Esc` 或点击弹窗外部可关闭。
> 未分班的人归入「(未分班)」,未分组的人归入「(未分组)」,不会丢失。
## 常见问题
### 全屏按钮点了没反应
- 浏览器要求全屏必须由**用户手势**触发,脚本自动调用 `requestFullscreen()` 会被拒绝;
- 退出全屏调用的是 `document.exitFullscreen()`(注意不是 `document.documentElement`),
平台已修正过这一点;
- 若页面被嵌在 `<iframe>` 中,需要给 iframe 加 `allowfullscreen` 属性。
### 数据不更新
确认自动刷新开关打开;若结果缓存时间较长,点 **刷新** 会带 `force=1` 跳过缓存。
## 相关
- [成绩明细与统计口径](./scores.md)
- [系统设置](./settings.md)
+78
View File
@@ -0,0 +1,78 @@
# 随机抽取
随机抽取(`draw.php`)用于从名单中抽人——抽查、抽考、抽签、抽取上台展示等。
从主页面顶部导航 **🎲 随机抽取** 打开(新标签页)。
## 抽取来源
| 来源 | 说明 |
| --- | --- |
| 学号名单(全部) | 从完整名单中抽 |
| 某试卷已提交人员 | 只从交过卷的人里抽(需要先选试卷) |
可再用 **班级 / 组别** 下拉缩小候选池。
## 抽取人数
输入框直接填,或用快捷按钮 `1` / `5` / `10` / `全部`。
## 分组比例
决定有分组时名额如何分配:
| 模式 | 说明 |
| --- | --- |
| 按人数加权(默认) | 人多的组分到更多名额,采用**最大余额法**,保证总数守恒 |
| 各组均分(5:5) | 名额均分到组(不是按人数加权),余数给排在前面的小组 |
| 自定义比例 | 为每个组手动指定比例 |
| 不分组 | 忽略组别,整池随机 |
::: tip 5:5 的含义
「各组均分」是**名额均分到组**,不等于每组抽到的人数与其人数成正比。
如果你想"人多的班多抽几个",请用默认的**按人数加权**。
:::
## 去重与排除
| 功能 | 说明 |
| --- | --- |
| 排除已抽中 | 本次会话内已抽中的人不再被抽到;**重置结果**后清零 |
| 排除管理 | 手动勾选不参与抽取的人(如请假、已抽过);支持清空排除 |
## 结果区
顶部卡片:`候选人数 / 本次抽取 / 累计已抽取 / 已排除 / 抽取来源`。
结果表格:`序号 / 学号 / 姓名 / 班级 / 组别 / 登陆账号 / 操作`。
「登陆账号」列让你在导出前就能核对:显示为 `学号 ⚠` 表示名单里没填这一列,
导出时会自动回退用学号——**这会导致学生登不进考试**,请先回主系统「名单管理」补全。
::: tip 「全部」按钮会跟随候选池
点 **全部** 后抽取人数进入跟随模式:切换班级 / 组别 / 来源时人数自动更新为当前
候选池大小。手工改动人数输入框即解除该模式。
:::
| 按钮 | 说明 |
| --- | --- |
| 📜 历史 | 查看历次抽取批次。**重置结果不会清空历史** |
| 📋 复制名单 | 复制姓名 / 登陆账号,可直接粘贴到微信或 Excel |
| ⬇ 白名单导入模板 | 导出为卷王白名单导入模板 `.xlsx` |
| ⬇ 白名单列表 | 导出为白名单列表 `.xlsx` |
| 重置结果 | 清空本次结果与累计已抽取(历史保留) |
| 清空历史 | 清空全部历史记录 |
## 登陆账号说明
名单中的**登陆账号**列在这里既用于导出白名单,也会直接显示在结果表格中(便于导出前核对)。
若名单里没有这一列,抽取结果里会用 `学号 ⚠` 标出,导出时回退用学号。
::: warning 模板文件名
卷王模板中该列表头为「登陆账号」(不是"登录账号"),导出时已按此命名。
:::
## 相关
- [名单管理](./roster.md)
- [系统设置](./settings.md)
+197
View File
@@ -0,0 +1,197 @@
# 导出与打印
工具栏下方只有一个 **导出** 按钮,所有导出与打印的选项都收在这个弹窗里,不再散落多个小按钮。
选项在你打开弹窗前就配好了会**自动记住**(存本地),下次打开就是上次的设置。
> 生成的是 `.xlsx`:零依赖,仓库内自行实现 XLSX 写入,没有 `zip` 扩展也能用。
## 导出弹窗
| 分区 | 内容 |
| --- | --- |
| **导出内容** | 成绩明细 / 每次考试 / 缺考名单 / 多考试对比(四选一,选中后下方分区跟着变) |
| **统计范围** | 班级 / 组别多选(不选 = 全部,按住 `Ctrl` / `⌘` 多选) |
| **成绩口径与列** | 每人成绩(最新 / 最高)、排序、出表方式、按班级分工作表、附缺考名单 |
| **多考试对比** | 表格形态、对比成绩、勾选要对比的考试 |
| **附加内容** | 附缺考名单(成绩明细 / 每次考试 / 多考试对比 都出工作表) |
| **标记与合格次数** | 合格次数阈值、最高分标红、输出「合格次数」行 |
| **打印设置** | 纸张方向、打印标题 |
- 分区随「导出内容」显隐:选 **缺考名单** 时只保留范围与打印设置;
选 **多考试对比** 时才出现考试勾选表;
- **附加内容** 与 **标记与合格次数** 是三类成绩表共用的,不随导出内容消失;
「按班级分工作表」只有成绩明细支持(置灰不可勾选);
- 弹窗底部有**一句话汇总**(导什么、哪些考试、多大范围),导错了一眼能看出来;
- 打印走隐藏 iframe 调起,**不会被浏览器弹窗拦截挡掉**,也不会多开标签页。
### 成绩明细:每人最新成绩 / 最高成绩
**默认按每人最新成绩显示**(最近一次真实提交),可选 **最高成绩**(该生在这些考试里的最高分)。
| 选项 | 效果 |
| --- | --- |
| 每人成绩 | `最新成绩`(默认)/ `最高成绩` —— 每人一行时的代表成绩 |
| 排序 | `按提交时间` / `按学号` / `按成绩降序` / `按成绩升序` |
| 出表方式 | `每人一行(汇总)`(默认)/ `每次考试一行(明细)`:明细模式保留每位学生的历史提交 |
| 附缺考名单 | 默认开;只导成绩时取消勾选(接口 `noAbsent=1`)。**每次考试 / 多考试对比 也支持**,口径为「名单里有、所选考试全都没交」 |
| 按班级分工作表 | 一个班级一个工作表(xlsx 多工作表) |
默认列:`学号 / 姓名 / 班级 / 组别 / 成绩 / 最高分 / 考试次数 / 最近提交时间 / 考试时长 / 考试时长(秒)`。
## 「每次考试」块表(多场对比,主推格式)
勾选多场考试,选 **两行表头** 形态。**一列「第几次」被所有考试共用**(逐行写 1、2、3…,不合并),
每场考试占 **1 列**,学生块末尾多一行 **合格次数**。
| 学号 | 姓名 | 第几次 | 第一次月考·数学 | 第一次月考·英语 |
| --- | --- | --- | --- | --- |
| | | | 成绩 | 成绩 |
| (合并) | (合并) | 1 | 112 | 130 |
| (合并) | (合并) | 2 | 100 | 129 |
| (合并) | (合并) | 3 | **136** | **133** |
| (合并) | (合并) | **合格次数** | 3 | 3 |
| 356002 | 李四 | 1 | (合并)**未考** | 128 |
| (合并) | (合并) | **合格次数** | | 1 |
规则:
- **学生块高度** = 该生在所有勾选考试里最多的那次次数 + 1(末行是合格次数);
- **学号 / 姓名**按学生块纵向合并;**第几次**逐行写各自的次序号(1、2、3…),**不合并**
——合并会把 2、3 吞掉只剩 1;
- **每场考试占 1 列**:第 1 行是考试名、第 2 行是 `成绩` 小标题;
- **没考的考试**(整场没有该生记录)→ 该列 × 学生块高合并成一个 **`未考`** 格子;
- **排序**:学生按学号升序(出现班级 / 组别列时按 班级 → 组别 → 学号);
- **暂存草稿**:默认不导出、也不占「第几次」编号。
### 单场考试的宽表版式
只导出 **一场** 考试时,改用更清楚的宽表:每人一行,第几次直接做列名。
| 学号 | 姓名 | 第1次 | 第2次 | 第3次 | 合格次数 |
| --- | --- | --- | --- | --- | --- |
| 356001 | 张三 | 112 | 100 | **136** | 3 |
| 356002 | 李四 | **128** | | | 1 |
导出与打印用的是同一套判定,所以单场和「多场块表」的标记完全一样。
## 红 / 绿标记
| 标记 | 条件 | 效果 |
| --- | --- | --- |
| **最高分标红** | 该场该生成绩 = 该场最高分 | 红字加粗(同分只标最早达成的那次) |
| **合格次数标绿** | 该场该生达到合格线的次数 ≥ 阈值 | 绿底加粗 |
| 合格次数未达标 | 未达阈值 | **仍显示实际次数**(含 0 次),只不标绿 |
- 合格线 = 该场**本卷满分 × 合格率**(每个试卷各自的配置,不是全场统一一条线);
- 阈值在弹窗里可改(默认 1 次);关掉「最高分标红」或「合格次数行」就只出表不标记;
- 整场没考的不参与合格次数判定,直接是 `未考`;
- 学号等长数字列统一按**文本格式**写入,不会被 Excel 转成科学计数法,也不会被当公式执行;
- 打印与导出的 Excel 用同一份服务端装配结果(红字 / 绿底也来自同一份 `cell_styles`),
不存在「导出一个样、打印另一个样」。
## 班级 / 组别列什么时候出现
| 选择情况 | 班级列 | 组别列 |
| --- | --- | --- |
| 只选 1 个班级、未选组别 | 不出 | 不出 |
| 选 ≥2 个班级 | **出** | 未选组别时按数据判断(数据跨多组才出) |
| 只选 1 个班级但选 ≥2 个组别 | 不出 | **出** |
| 什么都不选(= 全部) | 数据跨 ≥2 个班级才出 | 数据跨 ≥2 个组别才出 |
- 出列时**连续相同班级 / 组别的单元格会纵向合并**(顺序按 班级 → 组别 → 学号),一个班只占一格;
- 也可以用接口参数 `withCls=1` / `withGrp=1` 强制出列(`=0` 强制不出)。
### 其它形态
| 表格形态 | 产出 |
| --- | --- |
| 两行表头(每次考试 + 合格次数) | 上面那套块表(**默认**) |
| 成绩对比(每人一行) | 一行一个学生、一列一场考试,成绩取「最新」或「最高」 |
| 两者都出 | 第一张块表 + 第二张成绩对比表 |
### 文件名
```
成绩明细_<试卷名>[_<班级>][_<组别>][_最新成绩|_最高成绩]_<YYYYMMDD_HHMMSS>.xlsx
每次考试_<试卷名>[_<班级>][_<组别>]_<YYYYMMDD_HHMMSS>.xlsx
缺考名单_<试卷名>[_<班级>][_<组别>]_<YYYYMMDD_HHMMSS>.xlsx
多考试成绩对比_<N>场[_<班级…>][_<组别…>]_<最新成绩|最高分>_<YYYYMMDD_HHMMSS>.xlsx
每次考试与成绩对比_<N>场[_<班级…>][_<组别…>]_<YYYYMMDD_HHMMSS>.xlsx # 两者都出
```
### 跟随筛选
导出**完全跟随页面当前的班级 / 组别筛选**,缺考口径也与页面一致
(筛选后名单 − 筛选后已提交,按学号匹配),不会出现「页面上 30 人、导出 50 人」的情况。
## 打印
| 选项 | 说明 |
| --- | --- |
| 打印标题 | 默认按考试名自动生成,可改成学校 / 考试正式名称 |
| 纸张方向 | 多考试打印默认 **横向**(列多不折行),可改纵向 |
| 成绩明细的分组对比 | 按班级或组别分段,段首给出小计(人数 / 平均 / 最高 / 最低 / 合格人数与合格率) |
| 成绩明细的显示选项 | 显示名次、显示「是否合格」列、显示考试次数、附缺考名单 |
- 表头在**每一页自动重复**,行不会被切断到两页;两行表头都放进 `<thead>`;
- 页眉含范围(班级 / 组别)、打印时间、人数、平均分、最高 / 最低、合格人数与合格线;
- 页脚含**教师 / 教研组长签字栏与日期**,可直接签字归档。
成绩明细面板右上角的**打印**与导出弹窗里的打印设置是同一套(打印标题、方向、分组对比都在弹窗里改)。
::: tip 绿底 / 红字要能印出来
打印样式里写了 `print-color-adjust: exact`,**不需要**在打印对话框里手动勾选「背景图形」,
Chrome / Edge 会照常输出绿底与红字。若仍看不到底纹,检查是否换用了不支持该属性的浏览器。
:::
::: tip 缺考名单单独成页
勾选「附缺考名单」后,缺考名单工作表会在打印时**另起一页**(不会挤在成绩表底部),便于分开归档。
:::
## 直接调用导出接口
需要对接其它系统时,可以带参数直接 GET(需已登录):
```
# 成绩明细(每人一行最新 / 最高)
api.php?action=export&projectId=xxx&cls=1班,2班&grp=A组&mode=latest&scoreMode=best&sort=score_desc
# 「每次考试」块表(多场)
api.php?action=export_multi&projectIds=a,b&mode=block&passTimes=1
# 单场宽表版式(每人一行,第几次做列名)
api.php?action=export_multi&projectIds=a&mode=block&wide=1
# 多考试:成绩对比 / 两者都出
api.php?action=export_multi&projectIds=a,b&mode=summary&scoreMode=highest
api.php?action=export_multi&projectIds=a,b&mode=both
# 「多考试打印」用的数据(不下载文件,返回 rows / merges / header_rows / cell_styles)
api.php?action=export_multi&projectIds=a,b&mode=block&format=json
# 缺考名单
api.php?action=export_absent&projectId=xxx
```
| 参数 | 取值 | 说明 |
| --- | --- | --- |
| `projectId` | 试卷 ID | 必填(`export_multi` 用 `projectIds`,逗号分隔,可只传一场) |
| `cls` / `grp` | 班级 / 组别 | 与页面筛选一致;`export_multi` 支持**多选**(逗号分隔),空 = 全部 |
| `mode` | `latest` / `all` | `export` 的成绩明细:每人一行(默认,取最新)/ 每次考试一行 |
| `scoreMode` | `latest` / `best` | 每人一行的代表成绩:最新(默认)/ 最高 |
| `mode`(`export_multi`) | `block` / `summary` / `both` | 每次考试块表(默认)/ 成绩对比 / 两者都出;旧值仍兼容:`matrix`→`summary`、`merged`·`attempts`→`block` |
| `wide` | `1` | 单场 `block` 时改用宽表版式(每人一行、第几次做列名) |
| `passTimes` | 正整数 | 合格次数达标阈值(默认 1) |
| `markBest` | `0` | `=0` 取消「最高分标红」 |
| `passRow` | `0` | `=0` 不输出「合格次数」行(也就没有绿底) |
| `format` | `json` | 不下载文件,返回 `sheets`(`rows` / `merges` / `header_rows` / `cell_styles`)供打印渲染 |
| `withCls` / `withGrp` | `1` / `0` | 强制出 / 不出班级、组别列;缺省按上表规则自动判断 |
| `noAbsent` | `1` | 成绩明细不附缺考名单 |
| `skipTemp` | `1` | `mode=all` 时排除暂存草稿 |
| `includeTemp` | `1` | 每次考试块表保留暂存草稿(标注为 `第N次(草稿)`) |
| `sort` | `time` / `sid` / `score_desc` / `score_asc` | 成绩明细排序(块表恒按 班级 → 组别 → 学号 / 学号) |
| `split` | `cls` | 按班级拆分多工作表 |
| `cols` | 逗号分隔列名 | 从默认列中挑选(仅 `export`) |
`format=json` 返回的 `cell_styles` 是 `行,列 → red | green`,行与列都是 **1-based**;
前端据此加红字 / 绿底,合并区间内的非首格继承左上角首格的标记。
## 相关
- [成绩明细与统计口径](./scores.md)
- [接口一览](../dev/api.md)
+112
View File
@@ -0,0 +1,112 @@
# 常见问题
## 连接与登录
**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:登录一直失败**
登录失败只会在登录页即时提示原因(账号/密码错、地址不可达等),不会因多次失败被锁。
若登录页连接信息显示成 `127.0.0.1` 等错误地址,用 `php tools/fix-config.php` 修正即可。
**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)。
+132
View File
@@ -0,0 +1,132 @@
# 环境要求与部署
## 环境要求
| 项目 | 要求 | 说明 |
| --- | --- | --- |
| 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/` | 可写 | 卷王令牌缓存、结果缓存 |
```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)
+60
View File
@@ -0,0 +1,60 @@
# 平台简介
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)
+99
View File
@@ -0,0 +1,99 @@
# 成绩明细与统计口径
这一页说明**每个数字到底是怎么算出来的**,以及表格里的每一列是什么含义。
## 统计卡片
| 卡片 | 计算方式 |
| --- | --- |
| 名单总人数 | 名单条目数;被当前试卷设为**免考**的人不计入 |
| 已提交 | 匹配到名单、且提交了答卷的人数(去重后的代表成绩行数) |
| 缺考 | `max(0, 名单总人数 − 已提交)` |
| 提交率 | `已提交 ÷ 名单总人数 × 100%`——考试进行中最该盯的指标 |
| 提交率 | `已提交 ÷ 名单总人数 × 100%` |
| 平均分 | 所有**有成绩**的答卷成绩平均值;无成绩者不计入分母 |
| 最高分 / 最低分 | 同上,取自所有有成绩的答卷 |
| 合格率 | `成绩 ≥ 满分 × 合格率阈值` 的人数 ÷ **已提交人数**。阈值全局默认 60%,可在 [试卷设置](./project-config.md) 针对单卷覆盖 |
| 平均用时 | 所有**有时长**的答卷时长平均值(秒),格式化为「X 分 Y 秒」 |
::: tip 卡片标题会跟着配置变
合格率卡片的标题会实时显示当前阈值,例如「合格率 (≥70%满分)」,避免你误以为还是 60%。
:::
## 班级 / 组别联动
选择顶部 **班级** 下拉后:
- 全部统计卡片只统计该班级;
- 成绩明细只显示该班级;
- 缺考名单只显示该班级,标题右侧计数同步变化;
- 切换班级是**本地即时重算**,不会额外请求服务器。
再选 **组别** 可做二次切片。
## 成绩明细列
| 列 | 说明 |
| --- | --- |
| 学号 / 姓名 / 班级 / 组别 | 来自名单;未匹配到的答卷不会进入本表 |
| 成绩 | 卷王返回的 `examScore`;该问卷不是考试模式时为 `-` |
| 考试次数 | 该学生**全部提交次数**,与「去重」开关无关 |
| 提交时间 | **最近一次**提交时间(重考低分也会让这行按最新时间排序到最上面) |
| 考试时长 | 最近一次答卷的时长;后台手动录入等没有起止时间的答卷为 `-` |
点击任意一行可查看该学生的**每一次提交**(时间、成绩、时长),最佳成绩有标记。
### 成绩颜色
成绩按**占本卷满分的百分比**着色,而不是写死的 90 / 60 分:
| 颜色 | 条件 |
| --- | --- |
| 绿色 | ≥ 满分的 90% |
| 橙色 | ≥ 合格率线(默认满分的 60%,可在[每卷配置](./project-config.md)里改) |
| 红色 | 低于合格率线 |
这样把满分改成 50 分或 20 分制时,不会因为绝对分数偏低而整列误标红。
数据看板的成员榜使用同一套规则。
## 排序与搜索
- 点击表头即可排序,支持学号 / 姓名 / 班级 / 组别 / 成绩 / 考试次数 / 提交时间 / 时长;
- 默认排序键是**最近提交时间**,所以刚交卷的人一定在最上面;
- 搜索框支持学号 / 姓名模糊匹配,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)
+60
View File
@@ -0,0 +1,60 @@
---
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: 零依赖生成 .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

+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<title>数据大屏首页(预览)</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="../../assets/style.css">
<style>
/* 预览页专属:补上 hero 里样式类所需的小件(原页面由 dashboard.php 内联给出) */
.brand-text{display:flex;flex-direction:column;gap:2px}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--border-2);vertical-align:1px}
.dot.on{background:var(--ok)}
.dot.stale{background:var(--danger)}
.spacer{flex:1}
.preview-tip{margin:12px 0 0;padding:8px 12px;border:1px dashed var(--border);border-radius:var(--radius);
font-size:12px;color:var(--text-3);background:var(--panel-weak,#f7f8fc)}
</style></head>
<body class="dash dash-home">
<div class="wrap">
<div class="dash-hero">
<div class="brand"><h1 class="brand-title">NY问卷考试系统数据平台</h1></div>
<div class="dash-controls">
<select id="projectSel"><option value="p1">高一数学月考(31 份)</option></select>
<span id="classSelWrap"><select id="classSel" disabled><option value="">请先选问卷</option></select></span>
<label class="switch"><input type="checkbox" id="autoRefresh" checked><span></span></label>
<select id="intervalSel"><option value="15" selected>15 秒</option></select>
<button class="btn" id="btnRefresh">刷新</button>
<button class="btn" id="btnFullscreen">⛶ 全屏</button>
<a class="btn back" id="linkIndex" href="index.php">← 主系统</a>
<a class="btn back" id="linkLogout" href="logout.php">退出</a>
</div>
</div>
<div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
<span id="heroSub">共 4 个班级 · 点击卡片进入该班大屏</span>
<span class="spacer"></span>
最后更新:<span id="lastUpdate">2026-09-27 20:57:25</span><i id="liveDot" class="dot on"></i>
</div>
<div class="preview-tip">静态预览:这张页面由生产环境 dashboard.php 的 <code>clsCardHtml()</code> 渲染,
数据取自 mock 卷王。真实站点上点卡片会整页跳转到 <code>dashboard.php?pid=…&amp;class=…</code>,
进入该班独立大屏(那里不再有班级选择、返回主系统与退出,浏览器后退键也被锁住)。</div>
<div id="board">
<section class="cards" id="boardSummary">
<div class="card accent-blue"><span>总人数</span><b>0</b></div>
<div class="card ok accent-green"><span>已提交</span><b>0</b></div>
<div class="card warn accent-orange"><span>缺考</span><b>0</b></div>
<div class="card accent-teal"><span>平均分</span><b>-</b></div>
<div class="card accent-indigo"><span>合格率</span><b>-</b></div>
</section>
<div class="sec-title" id="boardTitle">班级入口 <span class="tag-gray">点击卡片进入该班独立大屏</span></div>
<div id="boardBody"><div class="cls-grid"><div class="cls-card" data-cls="高一(1)班" role="button" tabindex="0" title="点击进入「高一(1)班」的独立大屏"><div class="cls-head"><span class="cls-name">高一(1)班</span><span class="cls-go">进入 ›</span></div><div class="cls-kpis"><div class="cls-kpi "><span>总人数</span><b>2</b></div><div class="cls-kpi k-ok"><span>已提交</span><b>2</b></div><div class="cls-kpi "><span>缺考</span><b>0</b></div><div class="cls-kpi "><span>平均分</span><b>81</b></div><div class="cls-kpi k-ok"><span>合格率</span><b>100%</b></div></div><div class="cls-bar"><i style="width:100%;background:var(--ok)"></i></div><div class="cls-sub"><span>提交率 100%</span></div></div><div class="cls-card" data-cls="高一(2)班" role="button" tabindex="0" title="点击进入「高一(2)班」的独立大屏"><div class="cls-head"><span class="cls-name">高一(2)班</span><span class="cls-go">进入 ›</span></div><div class="cls-kpis"><div class="cls-kpi "><span>总人数</span><b>1</b></div><div class="cls-kpi k-ok"><span>已提交</span><b>1</b></div><div class="cls-kpi "><span>缺考</span><b>0</b></div><div class="cls-kpi "><span>平均分</span><b>93</b></div><div class="cls-kpi k-ok"><span>合格率</span><b>100%</b></div></div><div class="cls-bar"><i style="width:100%;background:var(--ok)"></i></div><div class="cls-sub"><span>提交率 100%</span></div></div><div class="cls-card" data-cls="高一(3)班" role="button" tabindex="0" title="点击进入「高一(3)班」的独立大屏"><div class="cls-head"><span class="cls-name">高一(3)班</span><span class="cls-go">进入 ›</span></div><div class="cls-kpis"><div class="cls-kpi "><span>总人数</span><b>1</b></div><div class="cls-kpi k-ok"><span>已提交</span><b>1</b></div><div class="cls-kpi "><span>缺考</span><b>0</b></div><div class="cls-kpi "><span>平均分</span><b>90</b></div><div class="cls-kpi k-ok"><span>合格率</span><b>100%</b></div></div><div class="cls-bar"><i style="width:100%;background:var(--ok)"></i></div><div class="cls-sub"><span>提交率 100%</span></div></div><div class="cls-card" data-cls="高一(4)班" role="button" tabindex="0" title="点击进入「高一(4)班」的独立大屏"><div class="cls-head"><span class="cls-name">高一(4)班</span><span class="cls-go">进入 ›</span></div><div class="cls-kpis"><div class="cls-kpi "><span>总人数</span><b>1</b></div><div class="cls-kpi k-ok"><span>已提交</span><b>1</b></div><div class="cls-kpi "><span>缺考</span><b>0</b></div><div class="cls-kpi "><span>平均分</span><b>76</b></div><div class="cls-kpi k-ok"><span>合格率</span><b>100%</b></div></div><div class="cls-bar"><i style="width:100%;background:var(--ok)"></i></div><div class="cls-sub"><span>提交率 100%</span></div></div></div></div>
</div>
</div>
<script>/* 预览页:把"全屏/刷新"按钮做成 plainly 不可点,仅示意版式 */
document.addEventListener('DOMContentLoaded', function () {
["#btnFullscreen", "#btnRefresh", "#projectSel", "#classSelWrap"].forEach(function (s) {
var el = document.querySelector(s); if (el) el.setAttribute("disabled", "disabled");
});
});</script>
</body></html>
Binary file not shown.
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<title>多考试导出 · 每次考试(两行表头)(预览)</title><style>body{font-family:"Microsoft YaHei","PingFang SC",sans-serif;color:#222;background:#f7f7f7;margin:0;padding:24px}.page{background:#fff;border:1px solid #e3e3e3;border-radius:6px;padding:20px 22px;margin:0 auto 20px;max-width:1100px}.page h2{font-size:16px;margin:0 0 4px}.page .sub{font-size:12px;color:#777;margin:0 0 14px}table.bt{border-collapse:collapse;border:1px solid #999;width:100%;background:#fff;font-size:11px}table.bt td,table.bt th{border:1px solid #999;text-align:center;padding:3px 5px}table.bt th,table.bt thead td{background:#eaeaea;font-weight:700}table.bt thead tr:first-child td{background:#eaeaea}table.bt td.absent{color:#888}table.bt td.max{color:#d0021b;font-weight:700}table.bt td.pass{background:#63c384;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}html,body,.pass,.absent,.max{-webkit-print-color-adjust:exact;print-color-adjust:exact}.legend{margin:10px 0 0;font-size:12px;color:#555}.legend i{display:inline-block;width:11px;height:11px;vertical-align:-1px;margin:0 4px 0 12px}.legend .r{color:#d0021b;font-weight:700}.legend .g{background:#63c384;font-weight:700}.sign{margin-top:16px;font-size:12px}</style></head><body>
<div class="page"><h2>多考试导出 · 每次考试(两行表头)</h2><p class="sub">合格线 110 分,达 1 次标绿</p>
<table class="bt"><thead><tr><td rowspan="2">学号</td><td rowspan="2">姓名</td><td rowspan="2">第几次</td><td>第一次月考·数学</td><td>第一次月考·英语</td></tr><tr><td>成绩</td><td>成绩</td></tr></thead><tbody><tr><td rowspan="5">356001</td><td rowspan="5">张三</td><td>1</td><td>112</td><td class="max">112</td></tr><tr><td>2</td><td>100</td><td>99</td></tr><tr><td>3</td><td class="max">136</td><td></td></tr><tr><td>4</td><td>127</td><td></td></tr><tr><td>合格次数</td><td class="pass">3</td><td class="pass">1</td></tr><tr><td rowspan="4">356002</td><td rowspan="4">李四</td><td>1</td><td rowspan="3" class="absent">未考</td><td>130</td></tr><tr><td>2</td><td>129</td></tr><tr><td>3</td><td class="max">133</td></tr><tr><td>合格次数</td><td></td><td class="pass">3</td></tr><tr><td rowspan="2">356003</td><td rowspan="2">王五</td><td>1</td><td class="max">95</td><td class="absent">未考</td></tr><tr><td>合格次数</td><td>0</td><td></td></tr></tbody></table>
<p class="legend">标记说明:<span class="r">最高分</span> 标红、<i class="r">红字</i>;<span class="g">合格次数</span> 始终显示(含 0 次),达 1 次标绿、<i class="g">绿底</i>;未达标显示次数、不标绿。
<p class="sign">教师签字:______________  教研组长签字:______________  日期:____________</p>
</div></body></html>
+7
View File
@@ -0,0 +1,7 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<title>单场导出 · 每次考试(每人一行的宽表)(预览)</title><style>body{font-family:"Microsoft YaHei","PingFang SC",sans-serif;color:#222;background:#f7f7f7;margin:0;padding:24px}.page{background:#fff;border:1px solid #e3e3e3;border-radius:6px;padding:20px 22px;margin:0 auto 20px;max-width:1100px}.page h2{font-size:16px;margin:0 0 4px}.page .sub{font-size:12px;color:#777;margin:0 0 14px}table.bt{border-collapse:collapse;border:1px solid #999;width:100%;background:#fff;font-size:11px}table.bt td,table.bt th{border:1px solid #999;text-align:center;padding:3px 5px}table.bt th,table.bt thead td{background:#eaeaea;font-weight:700}table.bt thead tr:first-child td{background:#eaeaea}table.bt td.absent{color:#888}table.bt td.max{color:#d0021b;font-weight:700}table.bt td.pass{background:#63c384;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}html,body,.pass,.absent,.max{-webkit-print-color-adjust:exact;print-color-adjust:exact}.legend{margin:10px 0 0;font-size:12px;color:#555}.legend i{display:inline-block;width:11px;height:11px;vertical-align:-1px;margin:0 4px 0 12px}.legend .r{color:#d0021b;font-weight:700}.legend .g{background:#63c384;font-weight:700}.sign{margin-top:16px;font-size:12px}</style></head><body>
<div class="page"><h2>单场导出 · 每次考试(每人一行的宽表)</h2><p class="sub">第一次月考·数学 · 合格线 110 分,达 1 次标绿</p>
<table class="bt"><thead><tr><td>学号</td><td>姓名</td><td>第1次</td><td>第2次</td><td>第3次</td><td>第4次</td><td>合格次数</td></tr></thead><tbody><tr><td>356001</td><td>张三</td><td>112</td><td>100</td><td class="max">136</td><td>127</td><td class="pass">3</td></tr><tr><td>356003</td><td>王五</td><td class="max">95</td><td></td><td></td><td></td><td>0</td></tr></tbody></table>
<p class="legend">标记说明:<span class="r">最高分</span> 标红、<i class="r">红字</i>;<span class="g">合格次数</span> 始终显示(含 0 次),达 1 次标绿、<i class="g">绿底</i>;未达标显示次数、不标绿。
<p class="sign">教师签字:______________  教研组长签字:______________  日期:____________</p>
</div></body></html>
+222
View File
@@ -0,0 +1,222 @@
{
"block": {
"title": "多考试导出 · 每次考试(两行表头)",
"sheet": {
"rows": [
[
"学号",
"姓名",
"第几次",
"第一次月考·数学",
"第一次月考·英语"
],
[
"",
"",
"",
"成绩",
"成绩"
],
[
"356001",
"张三",
"1",
"112",
"112"
],
[
"",
"",
"2",
"100",
"99"
],
[
"",
"",
"3",
"136",
""
],
[
"",
"",
"4",
"127",
""
],
[
"",
"",
"合格次数",
"3",
"1"
],
[
"356002",
"李四",
"1",
"未考",
"130"
],
[
"",
"",
"2",
"",
"129"
],
[
"",
"",
"3",
"",
"133"
],
[
"",
"",
"合格次数",
"",
"3"
],
[
"356003",
"王五",
"1",
"95",
"未考"
],
[
"",
"",
"合格次数",
"0",
""
]
],
"merges": [
[
1,
1,
2,
1
],
[
1,
2,
2,
2
],
[
1,
3,
2,
3
],
[
3,
1,
7,
1
],
[
3,
2,
7,
2
],
[
8,
4,
10,
4
],
[
8,
1,
11,
1
],
[
8,
2,
11,
2
],
[
12,
1,
13,
1
],
[
12,
2,
13,
2
]
],
"header_rows": 2,
"cell_styles": {
"3,5": "red",
"5,4": "red",
"7,4": "green",
"7,5": "green",
"10,5": "red",
"11,5": "green",
"12,4": "red"
}
},
"meta": {
"pass_line": 110,
"pass_times": 1
}
},
"wide": {
"title": "单场导出 · 每次考试(每人一行的宽表)",
"sheet": {
"rows": [
[
"学号",
"姓名",
"第1次",
"第2次",
"第3次",
"第4次",
"合格次数"
],
[
"356001",
"张三",
"112",
"100",
"136",
"127",
"3"
],
[
"356003",
"王五",
"95",
"",
"",
"",
"0"
]
],
"merges": [],
"header_rows": 1,
"cell_styles": {
"2,5": "red",
"2,7": "green",
"3,3": "red"
}
},
"meta": {
"pass_line": 110,
"pass_times": 1,
"exam_name": "第一次月考·数学"
}
}
}
+155
View File
@@ -0,0 +1,155 @@
# 部署加固清单
按 **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` | 名单(文件备份) |
## 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` 清会话再跳转登录页,避免出现跳转死循环。
## 登录失败处理
登录失败(卷王账号/密码错、地址不可达等)只会在登录页即时提示原因,**不做失败次数限流**——
单机内网工具场景下,限流常因配置写错或测试串味把正常用户挡在门外,且无实际防爆破价值。
若怀疑连接配置被测试污染导致连不上真实卷王,用 `php tools/fix-config.php` 一键修正连接地址。
## 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` /
`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/`
自动生成 `.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)
+168
View File
@@ -0,0 +1,168 @@
<?php
/**
* NY 问卷考试系统数据平台 - 随机抽取
* 从名单 / 已提交人员中随机抽取(支持按人数加权/均分/自定义比例/不分组、排除名单、历史记录),
* 导出为卷王白名单(两列:姓名 / 登陆账号)
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if (!ny_is_auth()) {
header('Location: login.php');
exit;
}
$rosterCount = Roster::count();
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>随机抽取 · <?= htmlspecialchars(NY_APP_NAME) ?></title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<h1 class="brand-title">NY问卷考试系统数据平台</h1>
</div>
<div class="top-right">
<a class="btn" href="index.php#roster" title="前往主系统管理学号名单">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></a>
<button class="btn" id="btnClearLog" title="清空全部历史记录(重置结果不影响历史记录)">清空历史</button>
<a class="btn" href="index.php">← 返回主系统</a>
<a class="btn" href="logout.php" title="退出登录">退出</a>
</div>
</header>
<main class="wrap">
<section class="toolbar">
<div class="field">
<label>抽取来源</label>
<select id="sourceSel">
<option value="roster">学号名单(全部)</option>
<option value="project">某试卷已提交人员</option>
</select>
</div>
<div class="field grow hidden" id="projectField">
<label>问卷 / 考试</label>
<select id="projectSel"><option value="">加载中…</option></select>
</div>
<div class="field">
<label>班级</label>
<select id="clsSel"><option value="">全部班级</option></select>
</div>
<div class="field">
<label>组别</label>
<select id="grpSel"><option value="">全部组别</option></select>
</div>
<div class="field">
<label>抽取人数</label>
<div class="inline">
<input type="number" id="numInput" min="1" step="1" value="1">
<button class="btn sm" data-num="1">1</button>
<button class="btn sm" data-num="5">5</button>
<button class="btn sm" data-num="10">10</button>
<button class="btn sm" data-num="all">全部</button>
</div>
</div>
<div class="field btns">
<label class="chk" title="决定有分组时如何分配名额"><span style="color:var(--text-2);font-size:12px">分组比例</span>
<select id="balanceMode" style="height:30px;border-radius:8px;border:1px solid var(--border);padding:0 8px;background:#fff;margin-left:4px">
<option value="weight">按人数加权(默认)</option>
<option value="even">各组均分(5:5)</option>
<option value="custom">自定义比例</option>
<option value="off">不分组(整池随机)</option>
</select>
</label>
<label class="chk" title="再次抽取时不与本次会话已抽中的人重复;「重置结果」后清零"><input type="checkbox" id="excludePicked" checked> 排除已抽中</label>
<button class="btn" id="btnExclude" title="勾选不参与抽取的人">🚫 排除管理</button>
<button class="btn btn-primary" id="btnDraw">🎲 开始抽取</button>
<button class="btn" id="btnReset" title="清空本次结果与累计已抽取(历史记录保留)">重置结果</button>
</div>
<div class="field full hidden" id="ratioBox" style="grid-column:1/-1"></div>
</section>
<section class="cards" id="statCards">
<div class="card accent-blue"><span>候选人数</span><b id="dPool">-</b></div>
<div class="card accent-green"><span>本次抽取</span><b id="dPicked">-</b></div>
<div class="card warn accent-orange"><span>累计已抽取</span><b id="dHistory">-</b></div>
<div class="card accent-red"><span>已排除</span><b id="dExcluded">-</b></div>
<div class="card accent-gray"><span>抽取来源</span><b id="dSource">学号名单</b></div>
</section>
<section class="panel">
<div class="panel-head">
<h2>抽取结果 <small id="drawHint"></small></h2>
<div class="actions">
<button class="btn" id="btnLog" title="查看历史抽取批次(重置结果后仍保留)">📜 历史 <b id="logBadge">0</b></button>
<button class="btn" id="btnCopy" title="复制姓名 / 登陆账号到剪贴板(可直接粘贴到微信 / Excel)">📋 复制名单</button>
<button class="btn" id="btnDlImport" title="导出为「白名单导入模板.xlsx」">⬇ 白名单导入模板</button>
<button class="btn btn-primary" id="btnDlList" title="导出为「白名单列表.xlsx」">⬇ 白名单列表</button>
</div>
</div>
<div class="draw-stage" id="drawStage">点击「🎲 开始抽取」</div>
<div class="table-scroll">
<table class="grid">
<thead>
<tr>
<th style="width:8%">序号</th>
<th style="width:22%">学号</th>
<th style="width:18%">姓名</th>
<th style="width:24%">班级</th>
<th style="width:18%">组别</th>
<th style="width:10%">操作</th>
</tr>
</thead>
<tbody id="drawBody"><tr><td colspan="6" class="empty">尚未抽取</td></tr></tbody>
</table>
</div>
<p class="hint">抽取结果表不显示登陆账号;「⬇ 白名单导入模板」「⬇ 白名单列表」与「📋 复制名单」均导出两列「<b>姓名</b> / <b>登陆账号</b>」,登陆账号取名单中导入的「登陆账号」列。<b>若名单未填登陆账号,导出时会自动回退用学号</b>,请先到主系统「名单管理」补全,否则学生无法登录考试。</p>
</section>
</main>
<!-- ================= 排除管理 ================= -->
<div class="modal" id="exclModal">
<div class="modal-box wide">
<div class="modal-head"><h3>抽取排除管理</h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<div class="roster-bar">
<input type="text" id="exclSearch" placeholder="搜索学号 / 姓名 / 班级">
<span class="spacer"></span>
<span class="hint" id="exclCount">已排除 0 人</span>
<button class="btn sm danger" id="btnClearExcl">清空排除</button>
</div>
<div class="table-scroll mid">
<table class="grid compact">
<thead><tr><th style="width:10%;text-align:center">排除</th><th style="width:24%">学号</th><th style="width:22%">姓名</th><th style="width:26%">班级</th><th style="width:18%">组别</th></tr></thead>
<tbody id="exclBody"><tr><td colspan="5" class="empty">加载中…</td></tr></tbody>
</table>
</div>
<p class="hint">勾选后这些人不再参与抽取(勾选即时生效);仅影响随机抽取,不影响其他功能。</p>
</div>
<div class="modal-foot"><span class="spacer"></span><button class="btn btn-primary" data-close>完成</button></div>
</div>
</div>
<!-- ================= 历史记录 ================= -->
<div class="modal" id="logModal">
<div class="modal-box wide">
<div class="modal-head"><h3>抽取历史记录</h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<div id="logList" class="log-list"><p class="empty" style="text-align:center;padding:30px 0">暂无历史记录</p></div>
<p class="hint">每次抽取自动记一笔;「重置结果」不会清除历史记录,可随时恢复某一批次重新下载。</p>
</div>
<div class="modal-foot">
<span class="spacer"></span>
<button class="btn btn-primary" data-close>关闭</button>
</div>
</div>
</div>
<script src="assets/draw.js"></script>
</body>
</html>
+475 -81
View File
@@ -2,6 +2,11 @@
declare(strict_types=1); declare(strict_types=1);
require __DIR__ . '/bootstrap.php'; require __DIR__ . '/bootstrap.php';
if (!ny_is_auth()) {
header('Location: login.php');
exit;
}
$cfg = ny_load_config(); $cfg = ny_load_config();
$rosterCount = Roster::count(); $rosterCount = Roster::count();
?> ?>
@@ -17,15 +22,12 @@ $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>
<a class="btn" id="btnDashboard" href="dashboard.php" target="_blank" rel="noopener" title="在新标签页打开数据看板">📊 数据看板</a> <a class="btn" id="btnDashboard" href="dashboard.php" target="_blank" rel="noopener" title="在新标签页打开数据看板">📊 数据看板</a>
<a class="btn" id="btnDraw" href="draw.php" target="_blank" rel="noopener" title="在新标签页打开随机抽取">🎲 随机抽取</a>
<button class="btn" id="btnFullscreen" title="切换全屏">⛶ 全屏</button> <button class="btn" id="btnFullscreen" title="切换全屏">⛶ 全屏</button>
<button class="btn" id="btnRoster">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></button> <button class="btn" id="btnRoster">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></button>
<button class="btn btn-primary" id="btnSettings">设置</button> <button class="btn btn-primary" id="btnSettings">设置</button>
@@ -33,29 +35,6 @@ $rosterCount = Roster::count();
</div> </div>
</header> </header>
<!-- ================= 登录门 ================= -->
<div class="login-gate" id="loginGate">
<div class="login-box">
<div class="login-brand">
<span class="logo">NY</span>
<div class="brand-text">
<strong>NY问卷考试系统数据平台</strong>
<small>实时数据统计</small>
</div>
</div>
<p class="login-tip">请输入系统连接信息,完成连接测试后登录。密码需每次输入,避免依赖一次配置直接查看数据。</p>
<div class="fi"><label>系统网址</label><input type="text" id="lgBase" placeholder="https://exam.nxxy335.top"></div>
<div class="fi"><label>API 路由</label><input type="text" id="lgPrefix" placeholder="/api"></div>
<div class="fi"><label>账号</label><input type="text" id="lgUser" placeholder="admin"></div>
<div class="fi"><label>密码</label><input type="password" id="lgPwd" placeholder="请输入密码" autocomplete="current-password"></div>
<div class="login-actions">
<button class="btn" id="lgTest">测试连接</button>
<button class="btn btn-primary" id="lgLogin" disabled>登录</button>
</div>
<div class="login-msg" id="lgMsg"></div>
</div>
</div>
<main class="wrap"> <main class="wrap">
<section class="toolbar"> <section class="toolbar">
@@ -71,6 +50,10 @@ $rosterCount = Roster::count();
<label>班级</label> <label>班级</label>
<select id="clsSel"><option value="">全部班级</option></select> <select id="clsSel"><option value="">全部班级</option></select>
</div> </div>
<div class="field">
<label>组别</label>
<select id="grpSel"><option value="">全部组别</option></select>
</div>
<div class="field"> <div class="field">
<label>自动刷新</label> <label>自动刷新</label>
<div class="inline"> <div class="inline">
@@ -87,18 +70,20 @@ $rosterCount = Roster::count();
</div> </div>
<div class="field btns"> <div class="field btns">
<button class="btn btn-primary" id="btnRefresh">立即刷新</button> <button class="btn btn-primary" id="btnRefresh">立即刷新</button>
<button class="btn btn-primary" id="btnProjCfg" title="为当前选中的试卷单独设置满分 / 合格率 / 免考名单">试卷设置</button>
</div> </div>
<div class="field grow-2"> <div class="field grow-2">
<label>搜索</label> <label>搜索</label>
<input type="text" id="kw" placeholder="输入学号或姓名筛选"> <input type="text" id="kw" placeholder="输入学号或姓名筛选">
</div> </div>
<div class="field btns">
<button class="btn" id="btnExport">导出成绩</button>
<button class="btn" id="btnExportAbsent">导出缺考</button>
</div>
</section> </section>
<div id="alert" class="alert hidden"></div> <section class="subbar">
<span class="subbar-label">导出</span>
<span class="hint" id="exportBarHint">成绩明细 / 每次考试 / 缺考名单 / 多考试对比,都在「导出」里配置</span>
<span class="spacer"></span>
<button class="btn btn-primary" id="btnExportOpen" title="导出与打印:选好导出内容、范围与标记方式,再导出 Excel 或直接打印">导出</button>
</section>
<section class="cards" id="statCards"> <section class="cards" id="statCards">
<div class="card accent-blue"><span>名单总人数</span><b id="sTotal">-</b></div> <div class="card accent-blue"><span>名单总人数</span><b id="sTotal">-</b></div>
@@ -108,48 +93,61 @@ $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>
<section class="panel"> <div class="layout" id="dataLayout" data-layout="default">
<section class="panel" id="tablePanel">
<div class="panel-head"> <div class="panel-head">
<h2>成绩明细 <small id="tableHint"></small></h2> <h2>成绩明细 <small id="tableHint"></small></h2>
<div class="updated">最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i></div> <div class="updated">
<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>
</div>
</div> </div>
<div class="table-scroll"> <div class="table-scroll">
<table class="grid" id="dataTable"> <table class="grid" id="dataTable">
<thead> <thead>
<tr> <tr>
<th class="sortable" data-k="sid">学号</th> <th class="sortable" data-k="sid" style="width:16%">学号</th>
<th class="sortable" data-k="name">姓名</th> <th class="sortable" data-k="name" style="width:12%">姓名</th>
<th class="sortable" data-k="cls">班级</th> <th class="sortable" data-k="cls" style="width:10%">班级</th>
<th class="sortable" data-k="score">成绩</th> <th class="sortable" data-k="grp" style="width:7%">组别</th>
<th class="sortable" data-k="submit_ts">提交时间</th> <th class="sortable" data-k="score" style="width:10%">成绩</th>
<th class="sortable" data-k="duration">考试时长</th> <th class="sortable" data-k="attempts" style="width:11%" title="该生提交过的次数,点击表头可排序">考试次数</th>
<th class="sortable" data-k="submit_ts" style="width:22%" title="最近一次提交时间">提交时间</th>
<th class="sortable" data-k="duration" style="width:12%">考试时长</th>
</tr> </tr>
</thead> </thead>
<tbody id="tbody"><tr><td colspan="6" class="empty">请选择问卷 / 考试</td></tr></tbody> <tbody id="tbody"><tr><td colspan="8" class="empty">请选择问卷 / 考试</td></tr></tbody>
</table> </table>
</div> </div>
</section> </section>
<div class="grid-2"> <section class="panel" id="absentPanel">
<section class="panel"> <div class="panel-head">
<div class="panel-head"><h2>缺考名单 <small id="absentHint"></small></h2></div> <h2>缺考名单 <small id="absentHint"></small></h2>
<span class="spacer"></span>
<button class="btn sm" id="btnCopySids" title="复制全部缺考学号(逗号分隔)">复制学号</button>
<button class="btn sm" id="btnCopyNames" title="复制全部缺考姓名">复制姓名</button>
<button class="btn sm" id="btnPrintAbsent" title="打印缺考名单">打印</button>
</div>
<div class="table-scroll short"> <div class="table-scroll short">
<table class="grid compact"> <table class="grid compact" id="absentTable">
<thead><tr><th>学号</th><th>姓名</th><th>班级</th></tr></thead> <thead><tr><th style="width:25%">学号</th><th style="width:30%">姓名</th><th style="width:25%">班级</th><th style="width:20%">组别</th></tr></thead>
<tbody id="absentBody"><tr><td colspan="3" class="empty">—</td></tr></tbody> <tbody id="absentBody"><tr><td colspan="4" class="empty">—</td></tr></tbody>
</table> </table>
</div> </div>
</section> </section>
<section class="panel">
<div class="panel-head"><h2>未匹配答卷 <small>姓名不在名单中</small></h2></div> <section class="panel" id="unmatchedPanel">
<div class="panel-head"><h2>未匹配答卷 <small id="unmatchedHint">姓名不在名单中</small></h2></div>
<div class="table-scroll short"> <div class="table-scroll short">
<table class="grid compact"> <table class="grid compact">
<thead><tr><th>姓名</th><th>成绩</th><th>提交时间</th></tr></thead> <thead><tr><th style="width:25%">姓名</th><th style="width:18%" data-k="score">成绩</th><th style="width:35%" data-k="submit_ts">提交时间</th><th style="width:22%">操作</th></tr></thead>
<tbody id="unmatchedBody"><tr><td colspan="3" class="empty">—</td></tr></tbody> <tbody id="unmatchedBody"><tr><td colspan="4" class="empty">—</td></tr></tbody>
</table> </table>
</div> </div>
</section> </section>
@@ -163,26 +161,12 @@ $rosterCount = Roster::count();
<div class="modal-body"> <div class="modal-body">
<div class="form-grid"> <div class="form-grid">
<div class="fi full"> <div class="fi full">
<label>卷王系统地址</label> <label>布局</label>
<input type="text" id="cfgBase" placeholder="http://127.0.0.1:1991"> <div class="fi-inline">
<p class="hint">例如 http://192.168.1.10:1991,结尾不要带 /</p> <label class="chk"><input type="radio" name="cfgLayout" value="default" id="layoutDef"> 默认(缺考+未匹配同行)</label>
<label class="chk"><input type="radio" name="cfgLayout" value="side" id="layoutSide"> 成绩明细与缺考名单同行</label>
</div> </div>
<div class="fi"> <p class="hint">主界面三个面板(成绩明细 / 缺考名单 / 未匹配答卷)的排版方式</p>
<label>接口前缀</label>
<input type="text" id="cfgPrefix" placeholder="/api">
<p class="hint">社区版默认 /api(企业版可填 /admin-api)</p>
</div>
<div class="fi">
<label>每页拉取条数</label>
<input type="number" id="cfgPageSize" min="20" max="1000">
</div>
<div class="fi">
<label>登录账号</label>
<input type="text" id="cfgUser">
</div>
<div class="fi">
<label>登录密码</label>
<input type="password" id="cfgPwd" placeholder="留空表示不修改">
</div> </div>
<div class="fi"> <div class="fi">
<label>默认刷新间隔(秒)</label> <label>默认刷新间隔(秒)</label>
@@ -193,11 +177,39 @@ $rosterCount = Roster::count();
<input type="number" id="cfgTimeout" min="3" max="120"> <input type="number" id="cfgTimeout" min="3" max="120">
</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="cfgPageSize" min="20" max="200">
<p class="hint" id="cfgFullHint">未选择试卷时,作为所有试卷的默认值</p> <p class="hint">实际生效上限 200,超出会自动夹到 200(防止单页内存耗尽)</p>
</div> </div>
<div class="fi"> <div class="fi">
<label>全局默认满分</label>
<input type="number" id="cfgFull" min="1" max="1000">
<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 class="fi">
<label>历史成绩展示方式</label>
<select id="cfgHistoryDisplay">
<option value="click">点击成员弹窗查看(默认)</option>
<option value="inline">成绩明细内全部展开</option>
</select>
<p class="hint">「全部展开」时每次考试单独一行,直接显示在成绩明细中</p>
</div>
<div class="fi">
<label>成绩导出排序</label>
<select id="cfgExportSort">
<option value="time">按提交时间(默认)</option>
<option value="sid">按学号(同学号按提交时间)</option>
<option value="score_desc">按成绩降序</option>
<option value="score_asc">按成绩升序</option>
</select>
<p class="hint">导出包含每次考试记录,始终带「考试次数」列</p>
</div>
<div class="fi full">
<label>姓名题关键词</label> <label>姓名题关键词</label>
<input type="text" id="cfgKeywords"> <input type="text" id="cfgKeywords">
<p class="hint">英文逗号分隔,用于自动识别"姓名"题目</p> <p class="hint">英文逗号分隔,用于自动识别"姓名"题目</p>
@@ -207,13 +219,43 @@ $rosterCount = Roster::count();
<label><input type="checkbox" id="cfgTemp"> 包含"暂存"未提交的答卷</label> <label><input type="checkbox" id="cfgTemp"> 包含"暂存"未提交的答卷</label>
<label><input type="checkbox" id="cfgSsl"> 校验 HTTPS 证书</label> <label><input type="checkbox" id="cfgSsl"> 校验 HTTPS 证书</label>
</div> </div>
<p class="hint" style="margin-top:-4px">
<b>「只保留最新一份」说明</b>:勾选后主明细表的「成绩」列取该生最新一次提交的分数,
取消勾选则取最高分。<b>无论是否勾选,「考试次数」与「历史成绩」始终展示全部提交记录</b>。
</p>
<div class="fi full" style="margin-top:6px">
<div style="display:flex;align-items:center;gap:8px">
<label style="margin:0">🔌 卷王系统连接</label>
<span class="hint" id="cfgConnInfo" style="font-size:12px"></span>
</div>
</div>
<div class="fi full">
<label>系统地址</label>
<input type="text" id="cfgBase" placeholder="https://exam.nxxy335.top" spellcheck="false">
<p class="hint">卷王后台地址,结尾不要带 <code>/</code>;修改后保存会自动重连</p>
</div>
<div class="fi">
<label>接口前缀</label>
<input type="text" id="cfgPrefix" placeholder="/api" spellcheck="false">
<p class="hint">社区版 <code>/api</code>;企业版可能是 <code>/admin-api</code></p>
</div>
<div class="fi">
<label>登录账号</label>
<input type="text" id="cfgUser" autocomplete="username" placeholder="登录卷王的账号">
</div>
<div class="fi">
<label>登录密码</label>
<input type="password" id="cfgPass" autocomplete="new-password" placeholder="已保存 · 留空表示不修改">
<p class="hint">为安全,已保存的密码不回显;如需修改请直接输入新密码后保存</p>
</div>
</div> </div>
<div id="pingResult" class="ping-result hidden"></div> <div id="pingResult" class="ping-result hidden"></div>
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
<button class="btn" id="btnPing">测试连接</button> <span class="hint" style="align-self:center">修改地址 / 前缀 / 账号 / 密码后保存会自动重新连接</span>
<button class="btn" id="btnDiag">详细诊断</button>
<span class="spacer"></span> <span class="spacer"></span>
<button class="btn" id="btnTestConn">测试连接</button>
<button class="btn" data-close>取消</button> <button class="btn" data-close>取消</button>
<button class="btn btn-primary" id="btnSaveCfg">保存</button> <button class="btn btn-primary" id="btnSaveCfg">保存</button>
</div> </div>
@@ -229,23 +271,25 @@ $rosterCount = Roster::count();
<input type="file" id="rosterFile" accept=".xlsx,.xlsm,.csv,.txt" hidden> <input type="file" id="rosterFile" accept=".xlsx,.xlsm,.csv,.txt" hidden>
<div class="uz-inner"> <div class="uz-inner">
<strong>点击或拖拽文件到此上传</strong> <strong>点击或拖拽文件到此上传</strong>
<span>支持 .xlsx / .csv,需包含「学号」「姓名」两列(「班级」可选)</span> <span>支持 .xlsx / .csv,需包含「学号」「姓名」两列(「班级」「组别」可选,组别如 A/B)</span>
</div> </div>
</div> </div>
<div class="roster-bar"> <div class="roster-bar">
<button class="btn sm" id="btnTpl" title="下载名单导入模板(CSV,含「登陆账号」列)">⬇ 下载模板</button>
<div class="inline"> <div class="inline">
<label class="chk"><input type="radio" name="impMode" value="replace" checked> 覆盖</label> <label class="chk"><input type="radio" name="impMode" value="replace"> 覆盖</label>
<label class="chk"><input type="radio" name="impMode" value="append"> 追加</label> <label class="chk"><input type="radio" name="impMode" value="append" checked> 追加</label>
</div> </div>
<input type="text" id="rosterSearch" placeholder="搜索学号 / 姓名"> <input type="text" id="rosterSearch" placeholder="搜索学号 / 姓名">
<span class="spacer"></span> <span class="spacer"></span>
<button class="btn sm" id="btnAddRow">+ 新增</button> <button class="btn sm" id="btnAddRow">+ 新增</button>
<button class="btn sm danger" id="btnDelSel" hidden>删除选中</button>
<button class="btn sm danger" id="btnClearRoster">清空</button> <button class="btn sm danger" id="btnClearRoster">清空</button>
<button class="btn sm btn-primary" id="btnSaveRoster">保存名单</button> <button class="btn sm btn-primary" id="btnSaveRoster">保存名单</button>
</div> </div>
<div class="table-scroll mid"> <div class="table-scroll mid">
<table class="grid compact" id="rosterTable"> <table class="grid compact" id="rosterTable">
<thead><tr><th style="width:32%">学号</th><th style="width:26%">姓名</th><th>班级</th><th style="width:70px">操作</th></tr></thead> <thead><tr><th style="width:6%"><input type="checkbox" id="rosterCheckAll" title="全选"></th><th style="width:26%">学号</th><th style="width:22%">姓名</th><th style="width:26%">班级</th><th style="width:20%">组别</th></tr></thead>
<tbody id="rosterBody"></tbody> <tbody id="rosterBody"></tbody>
</table> </table>
</div> </div>
@@ -258,6 +302,356 @@ $rosterCount = Roster::count();
</div> </div>
</div> </div>
<!-- 多次刷题明细弹窗 -->
<div class="modal" id="attemptsModal">
<div class="modal-box">
<div class="modal-head"><h3 id="attTitle">提交明细</h3><button class="x" data-close>&times;</button></div>
<div class="modal-body" id="attBody"></div>
</div>
</div>
<!-- 新增学生弹窗 -->
<div class="modal" id="addStudentModal">
<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>学号 <b class="req">*</b></label>
<input type="text" id="addSid" placeholder="必填">
</div>
<div class="fi">
<label>姓名 <b class="req">*</b></label>
<input type="text" id="addName" placeholder="必填">
</div>
<div class="fi">
<label>班级</label>
<input type="text" id="addCls" placeholder="可选">
</div>
<div class="fi">
<label>组别</label>
<input type="text" id="addGrp" placeholder="如 A / B,可选">
</div>
<div class="fi" hidden>
<label>登陆账号</label>
<input type="text" id="addLogin" placeholder="可选,用于抽取页登录">
</div>
<p class="hint" id="addStudentHint"></p>
</div>
<div class="modal-foot">
<span class="spacer"></span>
<button class="btn" data-close>取消</button>
<button class="btn btn-primary" id="btnAddStudentOk">添加</button>
</div>
</div>
</div>
<!-- 未匹配答卷映射到名单 -->
<div class="modal" id="mapUnmatchedModal">
<div class="modal-box">
<div class="modal-head"><h3>加入名单</h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<p class="hint" style="margin:0 0 12px">答卷姓名:<b id="mapUmName"></b>(不在名单中)</p>
<div class="fi">
<label>学号 <b class="req">*</b></label>
<input type="text" id="mapUmSid" placeholder="输入该生的学号">
</div>
<div class="fi">
<label>班级</label>
<input type="text" id="mapUmCls" placeholder="可选">
</div>
<div class="fi">
<label>组别</label>
<input type="text" id="mapUmGrp" placeholder="可选">
</div>
<p class="hint" id="mapUmHint"></p>
</div>
<div class="modal-foot">
<span class="spacer"></span>
<button class="btn" data-close>取消</button>
<button class="btn btn-primary" id="btnMapUmOk">加入名单</button>
</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>
<input type="text" id="ptTitle" placeholder="默认:试卷名 + 成绩明细">
</div>
<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">
<label>纸张方向</label>
<select id="ptOrient">
<option value="portrait" selected>纵向(A4,列少时清晰)</option>
<option value="landscape">横向(A4,列多时不易折行)</option>
</select>
</div>
<div class="fi">
<label>打印范围</label>
<select id="ptScope">
<option value="latest" selected>每人一行(汇总)</option>
<option value="all">每次考试一行(含历史提交)</option>
</select>
<p class="hint">「每次考试一行」会把每位学生的每次提交各打一行,完整留存历史。</p>
</div>
<div class="fi">
<label>每人成绩口径</label>
<select id="ptRowScore" title="每人一行时的代表成绩;选「每次考试一行」时本项不生效">
<option value="latest" selected>最新成绩(最近一次提交)</option>
<option value="best">最高成绩(该生最高分)</option>
</select>
<p class="hint">与「导出 → 成绩明细」的「每人成绩」一致,默认最新成绩。</p>
</div>
<div class="fi checks">
<label><input type="checkbox" id="ptRank" checked> 显示名次(全表连续编号)</label>
<label><input type="checkbox" id="ptPass"> 显示「是否合格」列(默认不显示)</label>
<label><input type="checkbox" id="ptAttempts" checked> 显示考试次数</label>
<label><input type="checkbox" id="ptAbsent" title="成绩表后另起一页附缺考名单,含备注列供手写"> 附缺考名单(另起一页)</label>
</div>
<p class="hint">打印内容<b>不受页面当前筛选外的影响</b>,始终按当前班级/组别筛选结果输出;表头会在每页自动重复,末尾附签字栏。</p>
<p class="hint">要打印<b>多场考试对比</b>或<b>「每次考试」表</b>(最高分标红、合格次数达标绿底)?用工具栏的 <b>导出</b> 弹窗,选「多考试对比」或「每次考试」后点「打印」。</p>
<p class="hint" id="ptHint"></p>
</div>
<div class="modal-foot">
<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="exportModal">
<div class="modal-box wide">
<div class="modal-head"><h3>导出 / 打印</h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<!-- 1. 导出内容 -->
<div class="ex-sec">
<div class="ex-sec-title">导出内容</div>
<div class="ex-types">
<label class="ex-type" data-for="detail">
<input type="radio" name="exType" value="detail" checked>
<span class="ex-type-t">
<b>成绩明细</b>
<small>每人一行:学号 / 姓名 / 班级 / 组别 / 成绩 / 次数 / 时间 / 时长</small>
</span>
</label>
<label class="ex-type" data-for="attempts">
<input type="radio" name="exType" value="attempts">
<span class="ex-type-t">
<b>每次考试</b>
<small>按截图版式:一行一次考试,最高分标红、合格次数标绿</small>
</span>
</label>
<label class="ex-type" data-for="absent">
<input type="radio" name="exType" value="absent">
<span class="ex-type-t">
<b>缺考名单</b>
<small>学号 / 姓名 / 班级 / 组别(跟随下方筛选范围)</small>
</span>
</label>
<label class="ex-type" data-for="multi">
<input type="radio" name="exType" value="multi">
<span class="ex-type-t">
<b>多考试对比</b>
<small>勾选多场考试,横向对比:每场一列,最高分标红、合格次数标绿</small>
</span>
</label>
</div>
</div>
<!-- 2. 统计范围(班级 / 组别) -->
<div class="ex-sec" id="exScopeSec">
<div class="ex-sec-title">统计范围 <small id="exScopeSum"></small></div>
<div class="ex-row">
<label class="ex-field">班级
<select id="exClsSel" multiple size="4" title="按住 Ctrl / ⌘ 可多选;不选 = 全部班级"></select>
</label>
<label class="ex-field">组别
<select id="exGrpSel" multiple size="4" title="按住 Ctrl / ⌘ 可多选;不选 = 全部组别"></select>
</label>
<div class="ex-field ex-field-tip">
<span class="hint" id="exScopeHint">不选 = 全部(Ctrl / ⌘ 多选)</span>
<span class="hint">选多个班级 / 组别时,导出会自动补出对应列并按相同值合并单元格。</span>
</div>
</div>
</div>
<!-- 3. 明细列与标记(成绩明细 / 每次考试 用) -->
<div class="ex-sec" id="exDetailSec">
<div class="ex-sec-title">成绩口径与列</div>
<div class="ex-row">
<label class="ex-field">每人成绩
<select id="exRowScore" title="每人一行时的代表成绩:最新 = 最近一次真实提交;最高 = 该生最高分">
<option value="latest" selected>最新成绩</option>
<option value="best">最高成绩</option>
</select>
</label>
<label class="ex-field">排序
<select id="exSort">
<option value="time">按提交时间</option>
<option value="sid">按学号</option>
<option value="score_desc">按成绩降序</option>
<option value="score_asc">按成绩升序</option>
</select>
</label>
<label class="ex-field">出表方式
<select id="exDetailMode">
<option value="latest" selected>每人一行(汇总)</option>
<option value="all">每次考试一行(明细)</option>
</select>
</label>
</div>
<div class="ex-checks" id="exDetailChecks">
<label><input type="checkbox" id="exSplit"> 按班级分工作表</label>
</div>
<p class="hint" id="exDetailHint">每人一行时可用「每人成绩」切换最新 / 最高;「每次考试一行」会保留每位学生的历史提交。</p>
</div>
<!-- 3.5 附加工作表(成绩明细 / 每次考试 / 多考试对比 共用) -->
<div class="ex-sec" id="exExtraSec">
<div class="ex-sec-title">附加内容</div>
<div class="ex-checks">
<label><input type="checkbox" id="exWithAbsent" checked> 附缺考名单(名单里有、所选考试全都没交的人)</label>
</div>
</div>
<!-- 4. 标记与合格次数(成绩明细 / 每次考试 / 多考试对比 共用) -->
<div class="ex-sec" id="exMarkSec">
<div class="ex-sec-title">标记与合格次数</div>
<div class="ex-row">
<label class="ex-field">合格次数阈值
<input type="number" id="exPassTimes" min="1" max="20" step="1" value="1"
title="同一场考试里「达到合格线」的次数 ≥ 此值才标绿(默认 1 次)">
</label>
<div class="ex-field ex-field-tip">
<span class="hint">合格线 = 该场考试满分 × 合格率(在「试卷设置」里按卷配置,缺省用全局)</span>
<span class="hint">例:阈值填 2,则该场考到合格线 ≥ 2 次才把「合格次数」标绿。</span>
</div>
</div>
<div class="ex-checks">
<label><input type="checkbox" id="exMarkBest" checked> 最高分标红</label>
<label><input type="checkbox" id="exPassRow" checked> 输出「合格次数」行(达标绿底)</label>
</div>
</div>
<!-- 5. 多考试内容 -->
<div class="ex-sec" id="exMultiSec" hidden>
<div class="ex-sec-title">多考试对比</div>
<div class="ex-row">
<label class="ex-field">表格形态
<select id="exMultiMode">
<option value="block" selected>两行表头(每次考试 + 合格次数)</option>
<option value="summary">成绩对比(每人一行)</option>
<option value="both">两者都出</option>
</select>
</label>
<label class="ex-field">对比成绩
<select id="exMultiScore">
<option value="latest" selected>最新成绩</option>
<option value="highest">最高成绩</option>
</select>
</label>
</div>
<div class="ex-pick">
<div class="ex-pick-head">
<span>勾选要对比的考试 <small id="exPickCount"></small></span>
<span class="spacer"></span>
<button class="btn sm" id="btnExPickAll" type="button">全选</button>
<button class="btn sm" id="btnExPickNone" type="button">清空</button>
</div>
<div class="table-scroll mid">
<table class="grid compact" id="exExamTable">
<thead><tr><th style="width:8%"><input type="checkbox" id="exCheckAll" title="全选"></th><th style="width:8%">顺序</th><th>考试名称</th></tr></thead>
<tbody id="exExamBody"><tr><td colspan="3" class="empty">(暂无可导出的考试,请先在右上角刷新问卷列表)</td></tr></tbody>
</table>
</div>
</div>
</div>
<!-- 6. 打印设置 -->
<div class="ex-sec" id="exPrintSec" hidden>
<div class="ex-sec-title">打印设置</div>
<div class="ex-row">
<label class="ex-field">纸张方向
<select id="exOrient" title="多考试打印默认横向(列多,横向不易折行)">
<option value="landscape" selected>横向(A4)</option>
<option value="portrait">纵向(A4)</option>
</select>
</label>
<label class="ex-field ex-field-grow">打印标题
<input type="text" id="exPrintTitle" placeholder="留空 = 用考试名自动生成">
</label>
</div>
</div>
<p class="hint" id="exSummaryLine" style="margin-top:4px"></p>
</div>
<div class="modal-foot">
<span class="spacer"></span>
<button class="btn" data-close>取消</button>
<button class="btn" id="btnExPrint">打印</button>
<button class="btn btn-primary" id="btnExExport">导出 Excel</button>
</div>
</div>
</div>
<script> <script>
window.NY = { window.NY = {
appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>, appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>,
+106
View File
@@ -0,0 +1,106 @@
<?php
/**
* 数据结果缓存(短期 TTL)
*
* 目的:拉取卷王全量答卷是重操作(多次 HTTP + 大量 json_decode),
* 多个老师同时查看同一套卷、或页面按 5~15 秒自动刷新时,会重复打卷王。
* 这里把 DataService::build() 的结果按「项目 + 配置 + 名单指纹」缓存一小段时间。
*
* 失效保证:名单变化(Roster::fingerprint)、连接/去重等配置变化都会改变 key。
* 另外可显式 force=1 跳过缓存(前端"立即刷新"按钮使用)。
*/
declare(strict_types=1);
final class DataCache
{
private static function dir(): string
{
return NY_DATA . '/cache';
}
/** 由若干要素拼成缓存 key */
public static function key(array $parts): string
{
return md5(implode("\x1f", array_map(static fn($v) => (string)$v, $parts)));
}
private static function path(string $key): string
{
return self::dir() . '/data_' . $key . '.json';
}
private static function lockPath(string $key): string
{
return self::dir() . '/data_' . $key . '.lock';
}
/**
* 读取缓存;不存在 / 过期 / 损坏都返回 null
* @return array{at:int,result:array}|null
*/
public static function read(string $key, int $ttl): ?array
{
$f = self::path($key);
if (!is_file($f)) {
return null;
}
$raw = @file_get_contents($f);
if ($raw === false || $raw === '') {
return null;
}
$d = json_decode($raw, true);
if (!is_array($d) || !isset($d['at']) || !is_array($d['result'])) {
return null;
}
if ($ttl > 0 && (time() - (int)$d['at']) > $ttl) {
return null;
}
return ['at' => (int)$d['at'], 'result' => $d['result']];
}
public static function write(string $key, array $result): bool
{
if (!is_dir(self::dir())) {
@mkdir(self::dir(), 0775, true);
}
$tmp = self::path($key) . '.tmp';
$ok = @file_put_contents($tmp, json_encode(
['at' => time(), 'result' => $result],
JSON_UNESCAPED_UNICODE
), LOCK_EX);
if ($ok === false) {
return false;
}
return @rename($tmp, self::path($key));
}
/**
* 获取构建锁(防止并发惊群:同一 key 只有一个请求真正去拉卷王)。
*
* @return resource|null 拿到锁返回句柄(需配合 releaseLock 释放);拿不到返回 null
*/
public static function acquireLock(string $key)
{
if (!is_dir(self::dir())) {
@mkdir(self::dir(), 0775, true);
}
$fh = @fopen(self::lockPath($key), 'c');
if ($fh === false) {
return null;
}
if (!flock($fh, LOCK_EX | LOCK_NB)) {
fclose($fh);
return null; // 已有其它请求在构建
}
return $fh;
}
public static function releaseLock($fh): void
{
if (is_resource($fh)) {
@flock($fh, LOCK_UN);
@fclose($fh);
}
}
}
+424 -31
View File
@@ -30,9 +30,15 @@ class DataService
return $out; return $out;
} }
// children 在根或子节点 // 不同卷王版本题目可能挂在 children / questions / list 下,统一兼容
$self = $node; $self = $node;
$children = (isset($node['children']) && is_array($node['children'])) ? $node['children'] : null; $children = null;
foreach (['children', 'questions', 'list', 'items'] as $ck) {
if (isset($node[$ck]) && is_array($node[$ck])) {
$children = $node[$ck];
break;
}
}
$id = isset($self['id']) ? (string)$self['id'] : ''; $id = isset($self['id']) ? (string)$self['id'] : '';
$title = isset($self['title']) ? self::cleanTitle((string)$self['title']) : ''; $title = isset($self['title']) ? self::cleanTitle((string)$self['title']) : '';
@@ -79,23 +85,75 @@ 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);
$students = Roster::all(); // 每卷独立配置(满分 / 合格率 / 免考名单):试卷之间互相隔离
// 姓名 -> 学号 / 班级(规范化) $pCfg = self::projectConfig($projectId);
$studentInfo = []; $exempt = [];
foreach ($students as $s) { foreach ((array)($pCfg['exempt'] ?? []) as $sid) {
$k = ny_norm_name((string)$s['name']); $sid = trim((string)$sid);
if ($k !== '') { if ($sid !== '') {
$studentInfo[$k] = [ $exempt[$sid] = true;
'sid' => (string)$s['sid'],
'cls' => (string)($s['cls'] ?? ''),
];
} }
} }
// 基础名单(全名单):用于答卷匹配,不受白名单影响
$rosterAll = Roster::all();
if ($exempt !== []) {
$rosterAll = array_values(array_filter(
$rosterAll,
static fn(array $s): bool => !isset($exempt[trim((string)($s['sid'] ?? ''))])
));
}
// 注:$students(应考名单)与 $studentInfo 在解析出 $survey(含白名单)后统一构建,见下方
$project = $client->getProject($projectId); $project = $client->getProject($projectId);
$survey = $project['survey'] ?? null; $survey = $project['survey'] ?? null;
if (is_string($survey) && $survey !== '') { if (is_string($survey) && $survey !== '') {
@@ -104,6 +162,75 @@ class DataService
$survey = $decoded; $survey = $decoded;
} }
} }
// 卷内「考试白名单 / 通讯录名单」:决定「应考名单」(缺/待考名单来源)。
// 真实来源 = 卷王项目「参与者(受访者)」(t_project_partner, type=RESPONDENT_SYS_USER/IMP_USER),
// 由 SurveyKingClient::getWhitelist -> getRespondentPartners(/api/project/partner/list) 读取;
// 未配置受访者名单时「按现在的方案显示」(使用全名单)。
$paperWhitelist = [];
try {
$paperWhitelist = $client->getWhitelist($projectId, $survey);
} catch (Throwable $e) {
$paperWhitelist = [];
}
$useWhitelist = ($paperWhitelist !== []);
$whitelistTotal = 0;
if ($useWhitelist) {
// 以学号 / 登陆账号 / 姓名(大小写不敏感)把白名单成员匹配回名单,
// 命中的取名单里的姓名/班级/组别;未命中的以白名单值作占位,避免漏统计。
$rosterMap = [];
foreach ($rosterAll as $s) {
$sid = strtolower(trim((string)($s['sid'] ?? '')));
$login = strtolower(trim((string)($s['login'] ?? '')));
$name = strtolower(trim((string)($s['name'] ?? '')));
if ($sid) { $rosterMap['sid:' . $sid] = $s; }
if ($login) { $rosterMap['login:' . $login] = $s; }
if ($name) { $rosterMap['name:' . $name] = $s; }
}
$students = [];
$seen = [];
foreach ($paperWhitelist as $v) {
$key = strtolower(trim((string)$v));
$m = $rosterMap['sid:' . $key] ?? $rosterMap['login:' . $key] ?? $rosterMap['name:' . $key] ?? null;
if ($m !== null) {
$sid = trim((string)($m['sid'] ?? ''));
if ($sid !== '' && isset($seen[$sid])) {
continue;
}
if ($sid !== '') { $seen[$sid] = true; }
$students[] = $m;
} else {
if (isset($seen['wk:' . $key])) {
continue;
}
$seen['wk:' . $key] = true;
$students[] = ['sid' => $v, 'name' => $v, 'cls' => '', 'grp' => '', 'login' => ''];
}
}
$whitelistTotal = count($students);
} else {
$students = $rosterAll;
}
// 姓名 -> 学号 / 班级(规范化):全名单 + 白名单独有成员都纳入,
// 保证所有提交(含白名单外提交)都能匹配到姓名。
$studentInfo = [];
$addInfo = static function (array $s) use (&$studentInfo): void {
$k = ny_norm_name((string)($s['name'] ?? ''));
if ($k !== '') {
$studentInfo[$k] = [
'sid' => (string)$s['sid'],
'cls' => (string)($s['cls'] ?? ''),
'grp' => (string)($s['grp'] ?? ''),
];
}
};
foreach ($rosterAll as $s) { $addInfo($s); }
if ($useWhitelist) {
foreach ($students as $s) { $addInfo($s); }
}
$questions = self::flattenQuestions($survey); $questions = self::flattenQuestions($survey);
$keywords = (string)($cfg['name_keywords'] ?? '姓名'); $keywords = (string)($cfg['name_keywords'] ?? '姓名');
@@ -127,9 +254,8 @@ class DataService
$includeTemp = !empty($cfg['include_temp_save']); $includeTemp = !empty($cfg['include_temp_save']);
$dedupe = !empty($cfg['dedupe']); $dedupe = !empty($cfg['dedupe']);
$rows = []; $rawRows = []; // 已匹配学号的原始 row(每条 = 一次提交)
$unmatched = []; $unmatched = [];
$seenSid = [];
foreach ($answers as $a) { foreach ($answers as $a) {
$tempSave = isset($a['tempSave']) ? (int)$a['tempSave'] : 1; $tempSave = isset($a['tempSave']) ? (int)$a['tempSave'] : 1;
@@ -156,7 +282,7 @@ class DataService
continue; continue;
} }
$v = ny_flatten_value($answerMap[$qid]); $v = ny_flatten_value($answerMap[$qid]);
if ($v !== '' && isset($nameMap[ny_norm_name($v)])) { if ($v !== '' && isset($studentInfo[ny_norm_name($v)])) {
$name = $v; $name = $v;
break; break;
} }
@@ -165,7 +291,7 @@ class DataService
if ($name === '' && $questions === []) { if ($name === '' && $questions === []) {
foreach ($answerMap as $v) { foreach ($answerMap as $v) {
$s = ny_flatten_value($v); $s = ny_flatten_value($v);
if ($s !== '' && isset($nameMap[ny_norm_name($s)])) { if ($s !== '' && isset($studentInfo[ny_norm_name($s)])) {
$name = $s; $name = $s;
break; break;
} }
@@ -190,8 +316,8 @@ class DataService
$ts = ny_to_timestamp($a['updateAt'] ?? null); $ts = ny_to_timestamp($a['updateAt'] ?? null);
} }
// 考试时长 // 考试时长(metaInfo 可能缺失,先整体判空避免每答案两条 Undefined key 警告)
$ai = $a['metaInfo']['answerInfo'] ?? []; $ai = ($a['metaInfo'] ?? [])['answerInfo'] ?? [];
$start = is_array($ai) ? (int)($ai['startTime'] ?? 0) : 0; $start = is_array($ai) ? (int)($ai['startTime'] ?? 0) : 0;
$end = is_array($ai) ? (int)($ai['endTime'] ?? 0) : 0; $end = is_array($ai) ? (int)($ai['endTime'] ?? 0) : 0;
$dur = null; $dur = null;
@@ -203,11 +329,13 @@ class DataService
$info = $studentInfo[$key] ?? null; $info = $studentInfo[$key] ?? null;
$sid = $info['sid'] ?? ''; $sid = $info['sid'] ?? '';
$cls = $info['cls'] ?? ''; $cls = $info['cls'] ?? '';
$grp = $info['grp'] ?? '';
$row = [ $row = [
'sid' => $sid, 'sid' => $sid,
'name' => $name, 'name' => $name,
'cls' => $cls, 'cls' => $cls,
'grp' => $grp,
'score' => $score, 'score' => $score,
'submit_ts' => $ts, 'submit_ts' => $ts,
'submit_at' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : '-', 'submit_at' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : '-',
@@ -223,22 +351,22 @@ class DataService
continue; continue;
} }
if ($dedupe && isset($seenSid[$sid])) { $rawRows[] = $row;
$idx = $seenSid[$sid];
// 保留提交时间最新的一份
if ($ts >= ($rows[$idx]['submit_ts'] ?? 0)) {
$rows[$idx] = $row;
$seenSid[$sid] = $idx;
}
continue;
}
$seenSid[$sid] = count($rows);
$rows[] = $row;
} }
// 按学号聚合:dedupe=true 仅保留最新一份;false 则每名一行(代表=最高分)
// 并附带 attempts(次数) / attempts_list(每次提交明细),供看板展示"多次刷题"
$agg = self::aggregateByStudent($rawRows, $dedupe);
$rows = $agg['rows'];
$attemptsTotal = $agg['attempts_total'];
// 成绩明细默认按提交时间降序(最新在前),学号作为次级排序 // 成绩明细默认按提交时间降序(最新在前),学号作为次级排序
usort($rows, static function (array $x, array $y): int { // 重要:排序键必须用「最近一次提交时间 last_submit_ts」,而不是代表行的 submit_ts。
$d = ($y['submit_ts'] ?? 0) <=> ($x['submit_ts'] ?? 0); // 否则当 dedupe=false(代表成绩=最高分)时,学生重考且分数更低,代表行仍是最高分那次,
// submit_ts 不变 → 该行会停在原来的位置只高亮,不会置顶,教师登记时看不到新提交。
$sortTs = static fn(array $r): int => (int)($r['last_submit_ts'] ?? 0) ?: (int)($r['submit_ts'] ?? 0);
usort($rows, static function (array $x, array $y) use ($sortTs): int {
$d = $sortTs($y) <=> $sortTs($x);
if ($d !== 0) { if ($d !== 0) {
return $d; return $d;
} }
@@ -262,6 +390,7 @@ class DataService
'sid' => (string)$s['sid'], 'sid' => (string)$s['sid'],
'name' => (string)$s['name'], 'name' => (string)$s['name'],
'cls' => (string)($s['cls'] ?? ''), 'cls' => (string)($s['cls'] ?? ''),
'grp' => (string)($s['grp'] ?? ''),
]; ];
} }
} }
@@ -288,6 +417,7 @@ class DataService
'min_score' => $scores !== [] ? min($scores) : null, 'min_score' => $scores !== [] ? min($scores) : null,
'avg_duration' => $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null, 'avg_duration' => $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null,
'answer_total' => count($answers), 'answer_total' => count($answers),
'attempts_total'=> $attemptsTotal,
]; ];
return [ return [
@@ -305,7 +435,270 @@ class DataService
'stats' => $stats, 'stats' => $stats,
'roster' => $students, 'roster' => $students,
'roster_count' => count($students), 'roster_count' => count($students),
'whitelist_driven' => $useWhitelist,
'whitelist_total' => $whitelistTotal,
// 本卷独立配置(前端据此回显满分/合格率/免考);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'),
]; ];
} }
/**
* 把聚合后的学生行展开为"每次考试一行",用于导出全部考试记录 / 明细内全部展示。
* 每行携带该次考试的成绩、提交时间、时长,以及该学生的总考试次数 attempts 与第几次 idx。
*
* @param array $rows 聚合后的行(每行含 attempts_list)
* @param bool $desc 是否按提交时间降序(新 -> 旧)
* @return array
*/
public static function expandAttempts(array $rows, bool $desc = true): array
{
$out = [];
foreach ($rows as $r) {
$list = (array)($r['attempts_list'] ?? []);
if ($list === []) {
// 没有明细时退回代表行本身
$one = $r;
$one['idx'] = 1;
$one['is_best'] = true;
$one['day'] = self::dayKeyOf((int)($r['submit_ts'] ?? 0), (string)($r['submit_at'] ?? ''));
$one['day_best'] = true;
$one['day_count'] = 1;
$out[] = $one;
continue;
}
foreach ($list as $a) {
$out[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
'grp' => (string)($r['grp'] ?? ''),
'score' => $a['score'] ?? null,
'submit_ts' => (int)($a['submit_ts'] ?? 0),
'submit_at' => (string)($a['submit_at'] ?? '-'),
'duration' => $a['duration'] ?? null,
'duration_txt' => (string)($a['duration_txt'] ?? '-'),
'answer_id' => (string)($a['answer_id'] ?? ''),
'temp_save' => !empty($a['temp_save']),
'attempts' => (int)($r['attempts'] ?? 1),
'best_score' => $r['best_score'] ?? null,
'idx' => (int)($a['idx'] ?? 1),
'is_best' => !empty($a['is_best']),
'day' => (string)($a['day'] ?? ''),
'day_best' => !empty($a['day_best']),
'day_count' => (int)($a['day_count'] ?? 1),
'last_submit_ts' => (int)($r['last_submit_ts'] ?? 0),
'last_submit_at' => (string)($r['last_submit_at'] ?? '-'),
];
}
}
usort($out, static function (array $x, array $y) use ($desc): int {
// 注意:x<=>y 是升序,降序要用 y<=>x
$d = ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0));
if (!$desc) {
$d = -$d;
}
if ($d !== 0) {
return $d;
}
// 同时间:学号升序
$a = (string)$x['sid'];
$b = (string)$y['sid'];
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
});
return $out;
}
/**
* 导出/展示用的排序:
* - time(默认):按提交时间降序,同时间按学号升序
* - sid:按学号升序,同一学号内多次考试再按提交时间降序
*
* @param array $rows expandAttempts 展开后的行
* @param string $mode 'time' | 'sid'
*/
public static function sortForExport(array $rows, string $mode = 'time'): array
{
$out = $rows;
if ($mode === 'sid') {
usort($out, static function ($x, $y): int {
$a = (string)($x['sid'] ?? '');
$b = (string)($y['sid'] ?? '');
if (is_numeric($a) && is_numeric($b)) {
$d = $a <=> $b;
} else {
$d = strnatcmp($a, $b);
}
if ($d !== 0) {
return $d;
}
return ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0));
});
return $out;
}
usort($out, static function ($x, $y): int {
$d = ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0));
if ($d !== 0) {
return $d;
}
$a = (string)($x['sid'] ?? '');
$b = (string)($y['sid'] ?? '');
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
});
return $out;
}
/**
* 取某次提交的「年月日」分组键。
*
* 关键:一律用服务端本地时区 date('Y-m-d', $ts) 计算,**不能用 UTC**(gmdate / JS toISOString),
* 否则零点前后提交的答卷会被算到前一天/后一天(东八区会整体错一天)。
* 无有效时间戳(createAt 缺失导致 ny_to_timestamp 返回 0)时,退回从显示串取前 10 位;
* 仍取不到则返回空串,上层归为「未知时间」分组并排在最后。
*
* @param int $ts 提交时间戳(秒),0 表示未知
* @param string $submitAt 展示用时间串(形如 2026-09-08 09:41:20)
*/
public static function dayKeyOf(int $ts, string $submitAt = ''): string
{
if ($ts > 0) {
return date('Y-m-d', $ts);
}
if ($submitAt !== '' && $submitAt !== '-' && preg_match('/^(\d{4}-\d{2}-\d{2})/', $submitAt, $m) === 1) {
return $m[1];
}
return '';
}
/**
* 按学号聚合一次提交的答卷记录,解决"同人多次刷卷"在看板中被当成多个人的问题。
*
* 关键语义(修复"考试次数=1 / 历史只有一条"的 bug):
* - attempts(真实考试次数) 与 attempts_list(每次提交明细) **始终等于全部提交**,与 dedupe 无关;
* 这样"考试次数列"与"点击查看历史"永远展示完整数据。
* - dedupe 仅决定主明细表"代表成绩"那一行取哪次考试的成绩:
* dedupe=true → 取提交时间最新的一份(默认,看板更干净)
* dedupe=false → 取最高分(同分时取最早达到该分数的那次)
*
* @param array $rows 已匹配学号的原始 row(每条 = 一次提交)
* @return array{rows:array, attempts_total:int}
*/
public static function aggregateByStudent(array $rows, bool $dedupe): array
{
$bySid = [];
foreach ($rows as $r) {
$bySid[(string)($r['sid'] ?? '')][] = $r;
}
$agg = [];
$attemptsTotal = 0;
foreach ($bySid as $sid => $list) {
$attemptsTotal += count($list);
// 升序:旧 -> 新
$sorted = $list;
usort($sorted, static function (array $x, array $y): int {
return ($x['submit_ts'] ?? 0) <=> ($y['submit_ts'] ?? 0);
});
// 最佳成绩(用于标记每次考试是否为最高分)
$maxScore = null;
foreach ($sorted as $a) {
if ($a['score'] !== null && ($maxScore === null || $a['score'] > $maxScore)) {
$maxScore = $a['score'];
}
}
// 最近一次提交(升序排序后的最后一份)
$lastSubmitTs = (int)($sorted[count($sorted) - 1]['submit_ts'] ?? 0);
// 按「提交时间年月日」分组统计:每天的最高分与提交次数。
// 历史弹窗据此按天分类,并在每个分类内标记当日最高分。
$dayMax = []; // day => 当天最高分
$dayCount = []; // day => 当天提交次数(含未判分)
foreach ($sorted as $a) {
$day = self::dayKeyOf((int)($a['submit_ts'] ?? 0), (string)($a['submit_at'] ?? ''));
$dayCount[$day] = ($dayCount[$day] ?? 0) + 1;
if ($a['score'] !== null && (!array_key_exists($day, $dayMax) || $a['score'] > $dayMax[$day])) {
$dayMax[$day] = $a['score'];
}
}
// 标记是否已给"最佳"打过标(同分时只标最早达到该分数的那次)
$bestMarked = false;
// 每天是否已标记过"当日最高"(同分时同样只标最早达到的那次,与全局 is_best 一致)
$dayMarked = [];
$idx = 0;
$attemptsList = array_map(
static function (array $a) use (&$idx, $maxScore, &$bestMarked, &$dayMarked, $dayMax, $dayCount): array {
$idx++;
$isBest = (!$bestMarked && $a['score'] !== null && $maxScore !== null && (float)$a['score'] === (float)$maxScore);
if ($isBest) {
$bestMarked = true;
}
$day = self::dayKeyOf((int)($a['submit_ts'] ?? 0), (string)($a['submit_at'] ?? ''));
$dayBest = ($a['score'] !== null && array_key_exists($day, $dayMax)
&& (float)$a['score'] === (float)$dayMax[$day] && empty($dayMarked[$day]));
if ($dayBest) {
$dayMarked[$day] = true;
}
return [
'idx' => $idx,
'is_best' => $isBest,
'submit_at' => $a['submit_at'],
'submit_ts' => $a['submit_ts'],
'score' => $a['score'],
'duration' => $a['duration'],
'duration_txt' => $a['duration_txt'],
'answer_id' => $a['answer_id'],
'temp_save' => $a['temp_save'],
// 按天分类用:day 为空串表示时间未知
'day' => $day,
'day_best' => $dayBest,
'day_count' => (int)($dayCount[$day] ?? 1),
];
},
$sorted
);
// 代表成绩选择(仅影响主明细表那一行的成绩,不影响考试次数 / 历史明细)
if ($dedupe) {
// 代表成绩 = 提交时间最新的一份(默认,看板更干净)
$row = $sorted[count($sorted) - 1];
} else {
// 代表成绩 = 最高分(null 成绩不参与比较,全部为 null 时回退为最近一份)
$best = null;
$bestScore = null;
foreach ($sorted as $a) {
if ($a['score'] !== null && ($bestScore === null || $a['score'] > $bestScore)) {
$bestScore = $a['score'];
$best = $a;
}
}
if ($best === null) {
$best = $sorted[count($sorted) - 1];
}
$row = $best;
}
// 关键修复:考试次数与历史明细始终使用真实全部提交,与去重无关
// (之前 dedupe=true 时把 attempts 写成 1、attempts_list 只留最新一份,导致"考试次数"和
// 历史弹窗都只显示 1 条,多次刷题的数据被吞掉)
$row['attempts'] = count($list);
$row['best_score'] = $maxScore; // 真实最高分(跨全部尝试),用于历史弹窗标题展示
$row['attempts_list'] = $attemptsList;
// 最近一次提交时间(与"代表成绩"可能是不同的一次考试):
// 用于"按提交时间排序"和"新提交高亮",保证重考(哪怕分数更低)也能被发现
$row['last_submit_ts'] = $lastSubmitTs;
$row['last_submit_at'] = $lastSubmitTs > 0 ? date('Y-m-d H:i:s', $lastSubmitTs) : '-';
$agg[] = $row;
}
return ['rows' => $agg, 'attempts_total' => $attemptsTotal];
}
} }
+387
View File
@@ -0,0 +1,387 @@
<?php
/**
* 数据库适配层 —— SQLite 单文件后端(零配置、无需额外服务)
*
* 设计要点:
* 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);
class Db
{
private static ?PDO $pdo = null;
private static string $err = '';
private static bool $initTried = false;
public static function prefix(): string
{
return 'ny_';
}
/** 数据文件路径 */
public static function dbFile(): string
{
return NY_DATA . '/ny.db';
}
/** 是否具备启用条件(有 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;
}
/** 建立/复用连接;失败返回 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();
}
}
// ---------------- 设置 ----------------
/**
* SQLite 用 ON CONFLICT 做 upsert(其它方言见历史版本)
*
* 返回是否真的写成功:以前这里 void + 内部吞异常,导致"配置没落盘但上层以为成功"
* (典型后果:登录成功→下一请求仍报"未配置账号"→反复跳登录页的死循环)。
* 因此改为回读校验后返回 bool,并把失败原因放进 self::lastError()。
*/
public static function settingsSet(string $k, string $v): bool
{
$pdo = self::pdo();
if (!$pdo) {
return false;
}
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();
return false;
}
// 回读校验:磁盘满 / 目录不可写 / 只读挂载时 execute 可能不报错但实际未落盘
try {
$p = self::prefix();
$st = $pdo->prepare("SELECT v FROM {$p}settings WHERE k = ?");
$st->execute([$k]);
$got = $st->fetchColumn();
if ($got === false || (string)$got !== $v) {
self::$err = '写入后回读不一致(可能磁盘已满或数据库只读)';
return false;
}
} catch (Throwable $e) {
self::$err = $e->getMessage();
return false;
}
return true;
}
public static function settingsDelete(string $k): void
{
$pdo = self::pdo();
if (!$pdo) {
return;
}
try {
$p = self::prefix();
$st = $pdo->prepare("DELETE FROM {$p}settings WHERE k = ?");
$st->execute([$k]);
} catch (Throwable $e) {
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
{
$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 [];
}
}
/**
* 全量替换名单(roster_save 语义)
* @return bool 成功 true;DB 不可用或事务失败 false(失败原因见 lastError)
*/
public static function rosterReplace(array $students): bool
{
$pdo = self::pdo();
if (!$pdo) {
self::$err = '数据库未连接';
return false;
}
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();
return true;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
self::$err = $e->getMessage();
return false;
}
}
public static function rosterCount(): int
{
$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 单卷配置(缺省返回空结构) */
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();
}
}
}
+144 -34
View File
@@ -13,9 +13,57 @@ class Roster
return NY_DATA . '/roster.json'; return NY_DATA . '/roster.json';
} }
/** 数据库是否可作为名单后端 */
private static function useDb(): bool
{
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()) {
self::migrateFileToDbOnce();
return Db::rosterAll();
}
$d = ny_read_json(self::file(), null); $d = ny_read_json(self::file(), null);
if (!is_array($d) || !isset($d['students']) || !is_array($d['students'])) { if (!is_array($d) || !isset($d['students']) || !is_array($d['students'])) {
return []; return [];
@@ -29,6 +77,8 @@ class Roster
'sid' => trim((string)($s['sid'] ?? '')), 'sid' => trim((string)($s['sid'] ?? '')),
'name' => trim((string)($s['name'] ?? '')), 'name' => trim((string)($s['name'] ?? '')),
'cls' => trim((string)($s['cls'] ?? '')), 'cls' => trim((string)($s['cls'] ?? '')),
'grp' => trim((string)($s['grp'] ?? '')),
'login' => trim((string)($s['login'] ?? '')),
]; ];
} }
return $out; return $out;
@@ -38,8 +88,8 @@ class Roster
{ {
$d = ny_read_json(self::file(), null); $d = ny_read_json(self::file(), null);
return [ return [
'count' => count(self::all()), 'count' => self::count(),
'updated_at' => is_array($d) ? (string)($d['updated_at'] ?? '') : '', 'updated_at' => self::useDb() ? date('Y-m-d H:i:s') : (is_array($d) ? (string)($d['updated_at'] ?? '') : ''),
]; ];
} }
@@ -56,8 +106,28 @@ class Roster
'sid' => $sid, 'sid' => $sid,
'name' => $name, 'name' => $name,
'cls' => trim((string)($s['cls'] ?? '')), 'cls' => trim((string)($s['cls'] ?? '')),
'grp' => trim((string)($s['grp'] ?? '')),
'login' => trim((string)($s['login'] ?? '')),
]; ];
} }
// 按学号去重(同 sid 取最后一条):roster 表 sid 为主键,重复 sid 会触发约束冲突导致整批回滚。
// 去重避免「保存成功但名单实际没变」的静默丢失。
$bySid = [];
$noSid = [];
foreach ($clean as $s) {
if ((string)($s['sid'] ?? '') !== '') {
$bySid[$s['sid']] = $s;
} else {
$noSid[] = $s;
}
}
$clean = array_values($bySid);
foreach ($noSid as $s) {
$clean[] = $s;
}
if (self::useDb()) {
return Db::rosterReplace($clean);
}
return ny_write_json(self::file(), [ return ny_write_json(self::file(), [
'students' => $clean, 'students' => $clean,
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
@@ -66,44 +136,50 @@ class Roster
public static function clear(): bool public static function clear(): bool
{ {
if (self::useDb()) {
Db::rosterReplace([]);
return true;
}
return ny_write_json(self::file(), ['students' => [], 'updated_at' => date('Y-m-d H:i:s')]); return ny_write_json(self::file(), ['students' => [], 'updated_at' => date('Y-m-d H:i:s')]);
} }
public static function count(): int public static function count(): int
{ {
if (self::useDb()) {
return Db::rosterCount();
}
return count(self::all()); return count(self::all());
} }
/** /**
* 首次使用时,若名单为空且存在 导入模板.xlsx,自动导入 * 名单指纹:名单内容一变就变,用于数据缓存 key。
* 只取「人数 + 文件修改时间 + 内容的轻量哈希」,避免每次全量序列化。
*/ */
public static function ensureSeeded(): bool public static function fingerprint(): string
{ {
if (self::count() > 0) { if (self::useDb()) {
return false; // 人数 + 内容哈希:名单内容变了指纹才变(只靠人数会在"换人"时误命中缓存)
$n = self::count();
$hash = md5(json_encode(self::all(), JSON_UNESCAPED_UNICODE));
return 'db:' . $n . ':' . $hash;
} }
$candidates = [ $f = self::file();
dirname(NY_ROOT) . '/导入模板.xlsx', $mt = is_file($f) ? (string)filemtime($f) : '0';
NY_ROOT . '/导入模板.xlsx', $sz = is_file($f) ? (string)filesize($f) : '0';
NY_DATA . '/导入模板.xlsx', $d = ny_read_json($f, null);
]; $hash = is_array($d) ? md5(json_encode($d['students'] ?? [], JSON_UNESCAPED_UNICODE)) : '0';
foreach ($candidates as $f) { return $mt . ':' . $sz . ':' . $hash;
if (is_file($f)) {
try {
$res = self::importFile($f, 'xlsx');
return $res['count'] > 0;
} catch (Throwable $e) {
// 忽略
}
}
}
return false;
} }
/** /**
* 解析上传文件 -> 学生数组 * 解析上传文件 -> 学生数组
* @return array{count:int,students:array} * @return array{count:int,students:array}
*/ */
/**
* 解析上传文件 -> 学生数组
* @return array{count:int,skipped:int,duplicates:int,headers:array,students:array}
*/
public static function importFile(string $file, string $ext = ''): array public static function importFile(string $file, string $ext = ''): array
{ {
if ($ext === '') { if ($ext === '') {
@@ -124,9 +200,17 @@ class Roster
} }
if ($rows === []) { if ($rows === []) {
throw new RuntimeException('文件为空或无法解析'); throw new RuntimeException('文件为空或无法解析(请确认是 xlsx/csv 且包含数据行)');
} }
return ['count' => count($rows), 'students' => self::rowsToStudents($rows)]; $meta = [];
$students = self::rowsToStudents($rows, $meta);
return [
'count' => count($students),
'skipped' => $meta['skipped'],
'duplicates' => $meta['duplicates'],
'headers' => $meta['headers'],
'students' => $students,
];
} }
public static function parseCsv(string $file): array public static function parseCsv(string $file): array
@@ -169,21 +253,27 @@ class Roster
} }
/** /**
* 识别 学号/姓名/班级 列 * 识别 学号/姓名/班级/组别/登陆账号 列
* @return array<int,array{sid:string,name:string,cls:string}> * 登陆账号仅用于随机抽取页导出白名单,界面任何位置都不显示
* @param array $rows
* @param array $meta 输出:['skipped'=>int,'duplicates'=>int,'headers'=>array]
* @return array<int,array{sid:string,name:string,cls:string,grp:string,login:string}>
*/ */
public static function rowsToStudents(array $rows): array public static function rowsToStudents(array $rows, array &$meta = []): array
{ {
$meta = ['skipped' => 0, 'duplicates' => 0, 'headers' => []];
$sidCol = -1; $sidCol = -1;
$nameCol = -1; $nameCol = -1;
$clsCol = -1; $clsCol = -1;
$grpCol = -1;
$loginCol = -1;
$headerIdx = -1; $headerIdx = -1;
// 找表头 // 找表头:同一行需同时命中「学号类」与「姓名类」关键词(容错全角空格/间隔)
foreach ($rows as $i => $row) { foreach ($rows as $i => $row) {
$joined = implode('|', array_map('strval', $row)); $joined = implode('|', array_map('strval', $row));
if (preg_match('/学\s*号|编\s*号|序\s*号|no\.?|id/i', $joined) if (preg_match('/学\s*号|编\s*号|序\s*号|学\s*员\s*号|号\s*码|no\.?|id/i', $joined)
&& preg_match('/姓\s*名|名\s*字|学生|成员/i', $joined)) { && preg_match('/姓\s*名|名\s*字|学\s*生\s*姓\s*名|姓\s*氏|成\s*员/i', $joined)) {
$headerIdx = $i; $headerIdx = $i;
break; break;
} }
@@ -193,16 +283,25 @@ class Roster
$start = $headerIdx + 1; $start = $headerIdx + 1;
foreach ($rows[$headerIdx] as $j => $cell) { foreach ($rows[$headerIdx] as $j => $cell) {
$c = (string)$cell; $c = (string)$cell;
if ($sidCol < 0 && preg_match('/学\s*号|编\s*号|序\s*号|^\s*no\.?\s*$|^\s*id\s*$/i', $c)) { if ($sidCol < 0 && preg_match('/学\s*号|编\s*号|序\s*号|学\s*员\s*号|号\s*码|^\s*no\.?\s*$|^\s*id\s*$/i', $c)) {
$sidCol = $j; $sidCol = $j;
} }
if ($nameCol < 0 && preg_match('/姓\s*名|名\s*字|学生姓名/i', $c)) { if ($nameCol < 0 && preg_match('/姓\s*名|名\s*字|学\s*生\s*姓\s*名|姓\s*氏/i', $c)) {
$nameCol = $j; $nameCol = $j;
} }
if ($clsCol < 0 && preg_match('/班\s*级|班级|class/i', $c)) { if ($clsCol < 0 && preg_match('/班\s*级|班级|class/i', $c)) {
$clsCol = $j; $clsCol = $j;
} }
if ($grpCol < 0 && preg_match('/组\s*别|分\s*组|组\s*次|^\s*组\s*$|group/i', $c)) {
$grpCol = $j;
} }
if ($loginCol < 0 && preg_match('/登\s*[陆录]\s*账\s*号|登\s*[陆录]\s*账号|账\s*号|手\s*机\s*号|手\s*机/i', $c)) {
$loginCol = $j;
}
}
$meta['headers'] = [
'sid' => $sidCol, 'name' => $nameCol, 'cls' => $clsCol, 'grp' => $grpCol, 'login' => $loginCol,
];
} }
if ($sidCol < 0) { if ($sidCol < 0) {
$sidCol = 0; $sidCol = 0;
@@ -212,17 +311,28 @@ class Roster
} }
$out = []; $out = [];
$seen = [];
for ($i = $start; $i < count($rows); $i++) { for ($i = $start; $i < count($rows); $i++) {
$row = $rows[$i]; $row = $rows[$i];
$sid = trim((string)($row[$sidCol] ?? '')); $sid = trim((string)($row[$sidCol] ?? ''));
$name = trim((string)($row[$nameCol] ?? '')); $name = trim((string)($row[$nameCol] ?? ''));
if ($sid === '' && $name === '') { if ($sid === '' && $name === '') {
$meta['skipped']++;
continue; continue;
} }
// 学号去掉 .0 尾巴(Excel 数字读取) // 学号/登陆账号去掉 Excel 数字尾巴(如 2024012001.0 / 13912687810.0)
$sid = preg_replace('/\.0+$/', '', $sid) ?? $sid; $sid = preg_replace('/\.0+$/', '', $sid) ?? $sid;
$cls = $clsCol >= 0 ? trim((string)($row[$clsCol] ?? '')) : ''; $cls = $clsCol >= 0 ? trim((string)($row[$clsCol] ?? '')) : '';
$out[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls]; $grp = $grpCol >= 0 ? trim((string)($row[$grpCol] ?? '')) : '';
$login = $loginCol >= 0 ? trim((string)($row[$loginCol] ?? '')) : '';
$login = preg_replace('/\.0+$/', '', $login) ?? $login;
// 同一条学号多次出现:保留首次,其余记重复跳过(避免看板/统计被重复计入)
if ($sid !== '' && isset($seen[$sid])) {
$meta['duplicates']++;
continue;
}
$seen[$sid] = true;
$out[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login];
} }
return $out; return $out;
} }
+184 -4
View File
@@ -187,7 +187,10 @@ class SurveyKingClient
$msg = (string)($decoded['msg'] ?? $decoded['message'] ?? ''); $msg = (string)($decoded['msg'] ?? $decoded['message'] ?? '');
} }
return ['status' => $status, 'headers' => $h, 'data' => $data, 'raw' => $rawBody, 'code' => $code, 'msg' => $msg]; // 仅保留前若干字符用于错误诊断;完整正文不再随返回值常驻内存,
// 避免大答卷响应在 json_decode 之外再额外占用一倍内存。
$rawDiag = mb_strcut($rawBody, 0, 2000);
return ['status' => $status, 'headers' => $h, 'data' => $data, 'raw' => $rawDiag, 'code' => $code, 'msg' => $msg];
} }
/** /**
@@ -448,7 +451,7 @@ class SurveyKingClient
/** 问卷 / 考试列表 */ /** 问卷 / 考试列表 */
public function listProjects(): array public function listProjects(): array
{ {
$size = max(20, (int)($this->cfg['page_size'] ?? 200)); $size = min(200, max(20, (int)($this->cfg['page_size'] ?? 100)));
$all = []; $all = [];
for ($page = 1; $page <= 50; $page++) { for ($page = 1; $page <= 50; $page++) {
$r = $this->request('/project/list', ['current' => $page, 'pageSize' => $size]); $r = $this->request('/project/list', ['current' => $page, 'pageSize' => $size]);
@@ -480,10 +483,183 @@ class SurveyKingClient
return is_array($r['data']) ? $r['data'] : []; return is_array($r['data']) ? $r['data'] : [];
} }
/**
* 取「应考名单」来源(待考 / 缺考名单由此驱动)。
*
* 卷王真实模型(社区版 v1.13 与开源版一致):
* 限制谁可以答卷的是项目「参与者(受访者)」—— t_project_partner 中
* type = RESPONDENT_SYS_USER(2) / RESPONDENT_IMP_USER(3) 的记录,
* 经 /api/project/partner/list 返回,每条带 status(0未访问 / 1已访问未答 / 2已答题)。
* 这才是「考试白名单 / 通讯录名单」的真实载体。
*
* 旧代码误把 surveySetting.whiteLists(按「题目选项」白名单,限制某题可选项)当成应考名单,
* 字段形状根本不对,导致线上从未触发。这里以真实模型为准,whiteLists 仅作极老版本的兜底。
*
* @param string $projectId 问卷 ID
* @param array|null $survey 已解析的 survey(仅极老版本白名单兜底用,新模型不依赖它)
* @return array<int,string> 应考成员标识:优先姓名(便于与本地名单按姓名匹配),其次登陆账号 / 学号
*/
public function getWhitelist(string $projectId, ?array $survey = null): array
{
// 1) 真实模型:项目「受访者」参与者 = 应考名单
$partners = [];
try {
$partners = $this->getRespondentPartners($projectId);
} catch (Throwable $e) {
$this->log[] = 'getRespondentPartners 失败(回退白名单兜底):' . $e->getMessage();
}
if ($partners !== []) {
$out = [];
foreach ($partners as $p) {
if (!is_array($p)) {
continue;
}
$type = (int)($p['type'] ?? 0);
if ($type !== 2 && $type !== 3) {
continue; // 仅取受访者(系统用户 / 导入用户),排除 OWNER / COLLABORATOR
}
$name = trim((string)($p['userName'] ?? ''));
$userId = trim((string)($p['userId'] ?? ''));
$user = $p['user'] ?? null;
$login = '';
if (is_array($user)) {
if ($name === '') {
$name = trim((string)($user['name'] ?? ''));
}
$login = trim((string)($user['login'] ?? $user['account'] ?? $user['username'] ?? ''));
if ($userId === '') {
$userId = trim((string)($user['id'] ?? ''));
}
}
// 与本地名单 / 答卷姓名匹配的标识:姓名 > 登陆账号 > userId
$key = $name !== '' ? $name : ($login !== '' ? $login : $userId);
if ($key !== '') {
$out[] = $key;
}
}
// 去重(大小写不敏感),保留首次出现顺序
$seen = [];
$res = [];
foreach ($out as $item) {
$k = strtolower($item);
if (!isset($seen[$k])) {
$seen[$k] = true;
$res[] = $item;
}
}
if ($res !== []) {
return $res;
}
}
// 2) 兜底:极老 / 定制版本可能把应考名单放在 surveySetting.whiteLists(实为题目选项白名单)
if (is_array($survey)) {
$setting = null;
if (isset($survey['surveySetting']) && is_array($survey['surveySetting'])) {
$setting = $survey['surveySetting'];
} elseif (isset($survey['survey']) && is_array($survey['survey']) && isset($survey['survey']['surveySetting'])) {
$setting = $survey['survey']['surveySetting'];
} else {
// 顶层直接是白名单结构
foreach (['whiteLists', 'whiteList', 'whitelist'] as $wk) {
if (isset($survey[$wk]) && (is_array($survey[$wk]) || is_string($survey[$wk]))) {
$setting = $survey;
break;
}
}
}
if (is_array($setting)) {
return self::parseWhitelist($setting);
}
}
return [];
}
/**
* 取项目的「受访者」参与者列表(应考名单真实来源)。
* GET /api/project/partner/list?projectId=...&types=2,3(RESPONDENT_SYS_USER / RESPONDENT_IMP_USER),自动翻页。
* @return array<int,array{id:string,type:int,userName:string,userId:string,status:int,user?:array}>
*/
public function getRespondentPartners(string $projectId): array
{
$size = 200;
$all = [];
for ($page = 1; $page <= 50; $page++) {
$r = $this->request('/project/partner/list', [
'projectId' => $projectId,
'types' => '2,3',
'current' => $page,
'pageSize' => $size,
]);
$data = $r['data'];
$list = is_array($data) ? ($data['list'] ?? $data) : [];
if (!is_array($list) || $list === []) {
break;
}
foreach ($list as $p) {
if (is_array($p)) {
$all[] = $p;
}
}
$total = is_array($data) ? (int)($data['total'] ?? 0) : 0;
if ($total > 0 && count($all) >= $total) {
break;
}
if (count($list) < $size) {
break;
}
}
return $all;
}
/**
* 从 surveySetting 中解析白名单成员(去重、保留首次出现顺序)。
* @return array<int,string>
*/
public static function parseWhitelist(array $setting): array
{
$raw = $setting['whiteLists'] ?? $setting['whiteList'] ?? $setting['whitelist'] ?? null;
if ($raw === null || $raw === [] || $raw === '') {
return [];
}
$out = [];
$walk = static function ($node) use (&$out, &$walk): void {
if (is_array($node)) {
// 列表或对象都逐值递归(兼容 { 题ID: [值] })
foreach ($node as $v) {
$walk($v);
}
return;
}
if (is_string($node) && trim($node) !== '') {
$out[] = trim($node);
} elseif (is_scalar($node) && $node !== '' && $node !== null) {
$out[] = (string)$node;
}
};
$walk($raw);
// 去重(大小写不敏感),保留首次出现顺序
$seen = [];
$res = [];
foreach ($out as $item) {
$key = strtolower($item);
if (!isset($seen[$key])) {
$seen[$key] = true;
$res[] = $item;
}
}
return $res;
}
/** 答卷列表(自动翻页取全量) */ /** 答卷列表(自动翻页取全量) */
public function listAnswers(string $projectId): array public function listAnswers(string $projectId): array
{ {
$size = max(20, (int)($this->cfg['page_size'] ?? 200)); // 单页上限夹在 20~200:无论配置 page_size 多大,单次 json_decode 的体量都被限制住,
// 避免一份超大响应直接撑爆 128M。100 页 × 200 = 最多 2 万份,足以覆盖学校级考试。
$size = min(200, max(20, (int)($this->cfg['page_size'] ?? 100)));
$all = []; $all = [];
for ($page = 1; $page <= 100; $page++) { for ($page = 1; $page <= 100; $page++) {
$r = $this->request('/answer/list', [ $r = $this->request('/answer/list', [
@@ -502,10 +678,14 @@ class SurveyKingClient
} }
} }
$total = is_array($data) ? (int)($data['total'] ?? 0) : 0; $total = is_array($data) ? (int)($data['total'] ?? 0) : 0;
// 及时释放本页引用,降低峰值内存占用
$r = null;
$data = null;
$list = null;
if ($total > 0 && count($all) >= $total) { if ($total > 0 && count($all) >= $total) {
break; break;
} }
if (count($list) < $size) { if (count($all) < $size) {
break; break;
} }
} }
+577
View File
@@ -0,0 +1,577 @@
<?php
/**
* NY 问卷考试系统数据平台 - 零依赖 XLSX 生成
*
* 仅用 PHP 内置函数生成最小可用的 .xlsx 文件:
* - 有 zip 扩展时走 ZipArchive;无 zip 扩展时回退到"手写 ZIP(stored)"实现,保证任何环境都能导出
* - 单元格统一按「共享字符串 + 文本格式(numFmtId=49)」写入,学号/账号等长数字不会被转成科学计数法
* - 表头行(默认第 1 行,可用 header_rows 指定多行)加粗 + 居中 + 边框
* - 支持合并单元格(merges),被合并的非首格写「空值 + 同样的样式」,保证合并区域四周边框完整
* - 支持单元格级样式(cell_styles):按 [行,列] 指定「红字 / 绿底」等标记,
* 用于"每场最高分标红、合格次数达标标绿"
*/
declare(strict_types=1);
final class XlsxWriter
{
/** @var array<string,int> 共享字符串 => 索引 */
private array $strIndex = [];
/** @var array<int,string> 索引 => 共享字符串 */
private array $strings = [];
/** @var array<int,int> 索引 => 出现次数(sst 的 count 用,区别于 uniqueCount) */
private array $strCount = [];
/** @var array<int,array{name:string,rows:array,widths:array,merges:array}> 工作表(按输出顺序) */
private array $sheets = [];
// 样式索引(见 stylesXml)
private const S_DEFAULT = 0; // 常规(无边框)
private const S_HEADER = 1; // 表头:加粗 + 居中 + 细边框
private const S_TEXT = 2; // 数据:文本格式(numFmtId=49)+ 细边框
private const S_RED = 3; // 数据 + 红字(最高分标记)
private const S_GREEN = 4; // 数据 + 绿底(合格次数达标标记)
private const S_HEADER_GREEN = 5; // 表头样式 + 绿底(合格次数行的表头格)
/**
* 语义化标记 => 样式索引。调用方只管语义('red' / 'green' / 'header_green'),
* 具体配色编号由本类统一维护,避免外部硬编码样式下标。
*/
private const MARK_STYLES = [
'red' => self::S_RED,
'green' => self::S_GREEN,
'header_green' => self::S_HEADER_GREEN,
];
/**
* 生成单表 xlsx 二进制内容(兼容旧调用)
*
* @param array $rows 二维表,第一行视为表头;每个元素会被强制转成字符串
* @param array $opts ['widths' => [列宽,...], 'title' => 工作表名]
* @return string xlsx 文件二进制
*/
public static function render(array $rows, array $opts = []): string
{
return self::renderMulti([[
'name' => (string)($opts['title'] ?? 'Sheet1'),
'rows' => $rows,
'widths' => array_values((array)($opts['widths'] ?? [])),
]]);
}
/**
* 生成多工作表 xlsx
*
* 说明:所有单元格都写成「共享字符串 + 文本格式(numFmtId=49)」,
* 因此既不会出现长学号被转成科学计数法,也不会把以 = + - @ 开头的内容
* 当成公式执行(天然规避 CSV 公式注入问题)。
*
* @param array $sheets 每个元素:['name'=>工作表名, 'rows'=>二维表, 'widths'=>[列宽...], 'merges'=>[[r1,c1,r2,c2],...], 'header_rows'=>int,
* 'cell_styles'=>['r,c'=>'red'|'green'|'header_green', ...]]
* merges 为 1-based 闭区间(含表头行),如 [[2,1,4,1]] = A2:A4 纵向合并
* header_rows 表头行数(默认 1;两行表头的交叉表用 2)
* cell_styles 的键为 1-based 的 "行,列",值为语义标记名(未知标记静默忽略)
* @return string xlsx 文件二进制
*/
public static function renderMulti(array $sheets): string
{
$w = new self();
foreach ($sheets as $sh) {
if (!is_array($sh)) {
continue;
}
$rows = array_values((array)($sh['rows'] ?? []));
if ($rows === []) {
continue; // 空表不输出,避免 Excel 提示修复
}
$w->sheets[] = [
'name' => self::safeSheetName((string)($sh['name'] ?? ''), count($w->sheets) + 1, $w->sheets),
'rows' => $rows,
'widths' => array_values((array)($sh['widths'] ?? [])),
'merges' => self::normalizeMerges((array)($sh['merges'] ?? []), count($rows), self::maxCols($rows)),
'header_rows' => max(1, (int)($sh['header_rows'] ?? 1)),
'cell_styles' => self::normalizeCellStyles((array)($sh['cell_styles'] ?? [])),
];
}
if ($w->sheets === []) {
// 保底:至少输出一张空表
$w->sheets[] = ['name' => 'Sheet1', 'rows' => [['']], 'widths' => [], 'merges' => [], 'header_rows' => 1, 'cell_styles' => []];
}
return $w->build();
}
/**
* 归一化单元格样式表:键 "行,列"(1-based)=> 样式索引;未知标记直接丢弃。
*
* @param array $raw ['3,5' => 'red', [4,5] => 'green', ...](两种写法都接受)
* @return array<string,int>
*/
private static function normalizeCellStyles(array $raw): array
{
$out = [];
foreach ($raw as $k => $mark) {
if (is_array($k) && count($k) >= 2) {
$key = ((int)$k[0]) . ',' . ((int)$k[1]);
} else {
$key = (string)$k;
if (!preg_match('/^\s*(\d+)\s*,\s*(\d+)\s*$/', $key, $m)) {
continue;
}
$key = ((int)$m[1]) . ',' . ((int)$m[2]);
}
$idx = self::MARK_STYLES[(string)$mark] ?? null;
if ($idx === null) {
continue; // 未知标记:不写样式,不报错
}
[$r, $c] = array_map('intval', explode(',', $key));
if ($r < 1 || $c < 1) {
continue;
}
$out[$key] = $idx;
}
return $out;
}
/** 二维表最大列数(至少 1) */
private static function maxCols(array $rows): int
{
$max = 1;
foreach ($rows as $r) {
$max = max($max, count((array)$r));
}
return $max;
}
/**
* 归一化合并区间:过滤非法 / 退化(单格)区间,并按行列排序,保证 Excel 不报修复提示。
*
* @param array $raw [[r1,c1,r2,c2], ...] 1-based
* @return array<int,array{0:int,1:int,2:int,3:int}>
*/
private static function normalizeMerges(array $raw, int $maxRow, int $maxCol): array
{
$out = [];
foreach ($raw as $m) {
if (!is_array($m) || count($m) < 4) {
continue;
}
$r1 = (int)$m[0]; $c1 = (int)$m[1]; $r2 = (int)$m[2]; $c2 = (int)$m[3];
if ($r1 > $r2) { [$r1, $r2] = [$r2, $r1]; }
if ($c1 > $c2) { [$c1, $c2] = [$c2, $c1]; }
if ($r1 < 1 || $c1 < 1 || $r2 > $maxRow || $c2 > $maxCol) {
continue; // 越界丢弃
}
if ($r1 === $r2 && $c1 === $c2) {
continue; // 单格无需合并
}
$out[] = [$r1, $c1, $r2, $c2];
}
usort($out, static function (array $a, array $b): int {
return [$a[0], $a[1], $a[2], $a[3]] <=> [$b[0], $b[1], $b[2], $b[3]];
});
return $out;
}
/* ---------------- 内部:构建 ---------------- */
private function build(): string
{
$files = [
'[Content_Types].xml' => '', // 占位,下面按表数量生成
];
$sheetXmls = [];
$names = [];
foreach ($this->sheets as $i => $sh) {
$n = $i + 1;
$maxCol = 1;
foreach ($sh['rows'] as $r) {
$maxCol = max($maxCol, count((array)$r));
}
$maxRow = max(1, count($sh['rows']));
// 登记共享字符串并生成单元格索引
$cells = [];
foreach (array_values($sh['rows']) as $ri => $row) {
$r = (int)$ri + 1;
foreach (array_values((array)$row) as $ci => $val) {
$c = (int)$ci + 1;
$cells[$r][$c] = $this->intern((string)($val ?? ''));
}
}
$sheetXmls['xl/worksheets/sheet' . $n . '.xml'] = $this->sheetXml(
$cells,
$maxRow,
$maxCol,
$sh['widths'],
$sh['merges'] ?? [],
(int)($sh['header_rows'] ?? 1),
(array)($sh['cell_styles'] ?? [])
);
$names[] = $sh['name'];
}
$count = count($this->sheets);
$files = [
'[Content_Types].xml' => $this->contentTypesXml($count),
'_rels/.rels' => $this->rootRelsXml(),
'docProps/app.xml' => $this->appXml(),
'docProps/core.xml' => $this->coreXml($names[0] ?? 'Sheet1'),
'xl/workbook.xml' => $this->workbookXml($names),
'xl/_rels/workbook.xml.rels' => $this->workbookRelsXml($count),
'xl/styles.xml' => $this->stylesXml(),
'xl/sharedStrings.xml' => $this->sharedStringsXml(),
] + $sheetXmls;
return $this->zip($files);
}
/**
* 工作表名安全化:Excel 限制 31 字符、禁止 []:*?/\、不可重复、不可为空
*/
private static function safeSheetName(string $name, int $seq, array $existing): string
{
$bad = ['\\', '/', '?', '*', '[', ']', ':'];
$n = trim(str_replace($bad, '_', $name));
if ($n === '') {
$n = 'Sheet' . $seq;
}
if (function_exists('mb_substr')) {
$n = mb_substr($n, 0, 31, 'UTF-8');
} else {
$n = substr($n, 0, 31);
}
// 去重
$base = $n;
$i = 2;
$taken = [];
foreach ($existing as $e) {
$taken[strtolower($e['name'])] = true;
}
while (isset($taken[strtolower($n)])) {
$suffix = '(' . $i . ')';
$n = (function_exists('mb_substr') ? mb_substr($base, 0, 31 - strlen($suffix), 'UTF-8') : substr($base, 0, 31 - strlen($suffix))) . $suffix;
$i++;
}
return $n;
}
private function intern(string $s): int
{
if (!array_key_exists($s, $this->strIndex)) {
$i = count($this->strings);
$this->strIndex[$s] = $i;
$this->strings[$i] = $s;
$this->strCount[$i] = 0;
}
$i = $this->strIndex[$s];
$this->strCount[$i] = ($this->strCount[$i] ?? 0) + 1;
return $i;
}
private function sheetXml(array $cells, int $maxRow, int $maxCol, array $widths = [], array $merges = [], int $headerRows = 1, array $cellStyles = []): string
{
$dim = 'A1:' . self::colName($maxCol) . $maxRow;
$cols = '';
for ($c = 1; $c <= $maxCol; $c++) {
$w = $widths[$c - 1] ?? 16;
$cols .= '<col min="' . $c . '" max="' . $c . '" width="' . $w . '" customWidth="1"/>';
}
// 合并区域的非首格不能带值(否则 Excel 打开会提示"修复"),
// 但仍要写成「空值 + 带样式的格子」,否则合并区域的边框会在 Excel 里缺边。
$swallowed = [];
foreach ($merges as $m) {
for ($r = $m[0]; $r <= $m[2]; $r++) {
for ($c = $m[1]; $c <= $m[3]; $c++) {
if ($r === $m[0] && $c === $m[1]) {
continue;
}
$swallowed[$r][$c] = true;
}
}
}
$data = '';
for ($r = 1; $r <= $maxRow; $r++) {
$baseStyle = $r <= $headerRows ? self::S_HEADER : self::S_TEXT;
$line = '';
$has = false;
for ($c = 1; $c <= $maxCol; $c++) {
$sw = isset($swallowed[$r][$c]);
if (!isset($cells[$r][$c]) && !$sw) {
continue;
}
$has = true;
$ref = self::colName($c) . $r;
// 单元格级标记(红字 / 绿底)优先;合并区间跟随左上角首格的样式,
// 否则"合并成一格但只有左边半截有底色"会很难看。
$mark = $cellStyles[$r . ',' . $c] ?? null;
if ($mark === null && $sw) {
foreach ($merges as $m) {
if ($r >= $m[0] && $r <= $m[2] && $c >= $m[1] && $c <= $m[3]) {
$mark = $cellStyles[$m[0] . ',' . $m[1]] ?? null;
break;
}
}
}
$style = ($mark !== null) ? (int)$mark : $baseStyle;
if ($sw) {
$line .= '<c r="' . $ref . '" s="' . $style . '"/>';
} else {
$line .= '<c r="' . $ref . '" s="' . $style . '" t="s"><v>' . $cells[$r][$c] . '</v></c>';
}
}
if (!$has) {
continue;
}
$data .= '<row r="' . $r . '" spans="1:' . $maxCol . '">' . $line . '</row>';
}
$mergeXml = '';
if ($merges !== []) {
$mergeXml = '<mergeCells count="' . count($merges) . '">';
foreach ($merges as $m) {
$mergeXml .= '<mergeCell ref="' . self::colName($m[1]) . $m[0] . ':' . self::colName($m[3]) . $m[2] . '"/>';
}
$mergeXml .= '</mergeCells>';
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
'<dimension ref="' . $dim . '"/>' .
'<sheetViews><sheetView workbookViewId="0"/></sheetViews>' .
'<sheetFormatPr defaultRowHeight="13.5"/>' .
($cols !== '' ? '<cols>' . $cols . '</cols>' : '') .
'<sheetData>' . $data . '</sheetData>' .
$mergeXml .
'</worksheet>';
}
private function contentTypesXml(int $sheetCount): string
{
$overrides = '';
for ($i = 1; $i <= max(1, $sheetCount); $i++) {
$overrides .= '<Override PartName="/xl/worksheets/sheet' . $i . '.xml" ' .
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' .
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' .
'<Default Extension="xml" ContentType="application/xml"/>' .
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' .
$overrides .
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' .
'<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>' .
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' .
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>' .
'</Types>';
}
private function rootRelsXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' .
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' .
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>' .
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>' .
'</Relationships>';
}
/** @param array<int,string> $names 工作表名(已安全化) */
private function workbookXml(array $names): string
{
$sheets = '';
foreach (array_values($names) as $i => $nm) {
$n = (int)$i + 1;
$sheets .= '<sheet name="' . self::xml((string)$nm) . '" sheetId="' . $n . '" r:id="rId' . $n . '"/>';
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' .
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' .
'<sheets>' . $sheets . '</sheets>' .
'</workbook>';
}
private function workbookRelsXml(int $sheetCount): string
{
$rels = '';
for ($i = 1; $i <= max(1, $sheetCount); $i++) {
$rels .= '<Relationship Id="rId' . $i . '" ' .
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" ' .
'Target="worksheets/sheet' . $i . '.xml"/>';
}
$stylesId = max(1, $sheetCount) + 1;
$sstId = max(1, $sheetCount) + 2;
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' .
$rels .
'<Relationship Id="rId' . $stylesId . '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' .
'<Relationship Id="rId' . $sstId . '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>' .
'</Relationships>';
}
private function stylesXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
// numFmts: 49 = @(文本)
'<numFmts count="1"><numFmt numFmtId="49" formatCode="@"/></numFmts>' .
// 字体:0 常规 / 1 加粗(表头)/ 2 红色(最高分)/ 3 加粗绿字
'<fonts count="3">' .
'<font><sz val="11"/><color theme="1"/><name val="宋体"/><charset val="134"/></font>' .
'<font><b/><sz val="11"/><color theme="1"/><name val="宋体"/><charset val="134"/></font>' .
'<font><sz val="11"/><color rgb="FFD0021B"/><name val="宋体"/><charset val="134"/></font>' .
'</fonts>' .
// 填充:0 none / 1 gray125(Excel 约定占位)/ 2 合格绿底 / 3 表头绿底
// 绿色沿用导出截图里的浅绿 (#63C384 系):既醒目又不至于盖住黑色文字
'<fills count="4">' .
'<fill><patternFill patternType="none"/></fill>' .
'<fill><patternFill patternType="gray125"/></fill>' .
'<fill><patternFill patternType="solid"><fgColor rgb="FF63C384"/><bgColor indexed="64"/></patternFill></fill>' .
'<fill><patternFill patternType="solid"><fgColor rgb="FF63C384"/><bgColor indexed="64"/></patternFill></fill>' .
'</fills>' .
// 0 = 无边框,1 = 细灰边框(表格统一描边,打印/截图都清晰)
'<borders count="2">' .
'<border><left/><right/><top/><bottom/><diagonal/></border>' .
'<border>' .
'<left style="thin"><color rgb="FF808080"/></left>' .
'<right style="thin"><color rgb="FF808080"/></right>' .
'<top style="thin"><color rgb="FF808080"/></top>' .
'<bottom style="thin"><color rgb="FF808080"/></bottom>' .
'<diagonal/></border>' .
'</borders>' .
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' .
'<cellXfs count="6">' .
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>' . // 0 常规
'<xf numFmtId="0" fontId="1" fillId="0" borderId="1" xfId="0" applyFont="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 1 表头
'<xf numFmtId="49" fontId="0" fillId="0" borderId="1" xfId="0" applyNumberFormat="1" applyBorder="1"><alignment vertical="center"/></xf>' . // 2 文本
'<xf numFmtId="49" fontId="2" fillId="0" borderId="1" xfId="0" applyNumberFormat="1" applyFont="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 3 红字(最高分)
'<xf numFmtId="49" fontId="0" fillId="2" borderId="1" xfId="0" applyNumberFormat="1" applyFill="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 4 绿底(合格次数达标)
'<xf numFmtId="0" fontId="1" fillId="3" borderId="1" xfId="0" applyFont="1" applyFill="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 5 表头绿底
'</cellXfs>' .
'<cellStyles count="1"><cellStyle name="常规" xfId="0" builtinId="0"/></cellStyles>' .
'</styleSheet>';
}
private function sharedStringsXml(): string
{
$unique = count($this->strings);
$total = array_sum($this->strCount);
$body = '';
foreach ($this->strings as $s) {
$body .= '<si><t xml:space="preserve">' . self::xml((string)$s) . '</t></si>';
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="' . $total .
'" uniqueCount="' . $unique . '">' . $body . '</sst>';
}
private function coreXml(string $title): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" ' .
'xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" ' .
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">' .
'<dc:title>' . self::xml($title) . '</dc:title>' .
'<dc:creator>NY问卷考试系统数据平台</dc:creator>' .
'<cp:lastModifiedBy>NY问卷考试系统数据平台</cp:lastModifiedBy>' .
'<dcterms:created xsi:type="dcterms:W3CDTF">' . gmdate('Y-m-d\TH:i:s\Z') . '</dcterms:created>' .
'<dcterms:modified xsi:type="dcterms:W3CDTF">' . gmdate('Y-m-d\TH:i:s\Z') . '</dcterms:modified>' .
'</cp:coreProperties>';
}
private function appXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" ' .
'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">' .
'<Application>NY Exam Platform</Application></Properties>';
}
/* ---------------- 内部:打包 ---------------- */
/** @param array<string,string> $files 相对路径 => 内容 */
private function zip(array $files): string
{
if (class_exists('ZipArchive')) {
$tmp = tempnam(sys_get_temp_dir(), 'nyx');
if ($tmp !== false) {
$zip = new ZipArchive();
if ($zip->open($tmp, ZipArchive::OVERWRITE) === true) {
foreach ($files as $name => $content) {
$zip->addFromString($name, $content);
}
$zip->close();
$bin = (string)@file_get_contents($tmp);
@unlink($tmp);
if ($bin !== '') {
return $bin;
}
}
@unlink($tmp);
}
}
return self::packManual($files);
}
/**
* 无 zip 扩展时的手写打包(stored,不压缩)
* 单独公开,便于在没有 ZipArchive 的环境下自检
*
* @param array<string,string> $files 相对路径 => 内容
*/
public static function packManual(array $files): string
{
$local = '';
$central = '';
$offset = 0;
$time = self::dosTime();
$date = self::dosDate();
foreach ($files as $name => $content) {
$nameBin = (string)$name;
$data = (string)$content;
$crc = crc32($data);
$len = strlen($data);
$local .= "\x50\x4b\x03\x04" . pack('vvvvvVVVvv', 20, 0x0800, 0, $time, $date, $crc, $len, $len, strlen($nameBin), 0) . $nameBin . $data;
$central .= "\x50\x4b\x01\x02" . pack('vvvvvvVVVvvvvvVV', 20, 20, 0x0800, 0, $time, $date, $crc, $len, $len, strlen($nameBin), 0, 0, 0, 0, 0, $offset) . $nameBin;
$offset += 30 + strlen($nameBin) + $len;
}
return $local . $central .
"\x50\x4b\x05\x06" . pack('vvvvVVv', 0, 0, count($files), count($files), strlen($central), $offset, 0);
}
private static function dosTime(): int
{
$t = getdate();
return ($t['hours'] << 11) | ($t['minutes'] << 5) | (intdiv($t['seconds'], 2) & 0x1f);
}
private static function dosDate(): int
{
$t = getdate();
return (($t['year'] - 1980) << 9) | ($t['mon'] << 5) | $t['mday'];
}
/* ---------------- 工具 ---------------- */
private static function xml(string $s): string
{
return htmlspecialchars($s, ENT_XML1 | ENT_COMPAT, 'UTF-8');
}
/** 列序号(1基) => A / B / ... / AA */
private static function colName(int $n): string
{
$s = '';
while ($n > 0) {
$m = ($n - 1) % 26;
$s = chr(65 + $m) . $s;
$n = intdiv($n - 1, 26);
}
return $s ?: 'A';
}
}
+853
View File
@@ -0,0 +1,853 @@
<?php
/**
* 多考试导出:成绩对比矩阵 + 「每次考试」块表的纯逻辑(不依赖卷王客户端,便于单测)
*
* 两种版式:
* 1) 成绩对比(ny_assemble_multi_exam_matrix):一行一个学生、一列一场考试,只取最新 / 最高分
* 2) 每次考试块表(ny_assemble_block_sheet):两行表头,一行一次考试,学号 / 姓名 / 班级 / 组别
* 纵向合并单元格,没考的考试合并成「未考」一格(见需求截图版式)
*
* 说明:本文件刻意只用 PHP 7.0+ 语法(不用箭头函数 / 数字分隔符),
* 便于在只有 PHP 7.x 的机器上直接跑回归测试。
*/
declare(strict_types=1);
/**
* 取某个学生在某场考试里的"单元格成绩"。
*
* - highest:取真实最高分(跨全部尝试,含重考),即聚合行的 best_score
* - latest :取 attempts_list 中「最近一次非暂存提交」的成绩;
* attempts_list 全为暂存草稿时退回最后一份;列表缺失时退回代表行 score
*
* 与单考试"按最新成绩"导出口径完全一致(跳过 temp_save 草稿,避免导出的"最新成绩"是草稿分)。
*
* @return float|null 未提交 / 无成绩返回 null
*/
function ny_exam_cell_score(array $r, string $mode): ?float
{
if ($mode === 'highest') {
$v = $r['best_score'] ?? null;
return ($v === null || $v === '') ? null : (float)$v;
}
// 最近一次非暂存提交
$list = array_values((array)($r['attempts_list'] ?? []));
$last = null;
for ($i = count($list) - 1; $i >= 0; $i--) {
if (empty($list[$i]['temp_save'])) {
$last = $list[$i];
break;
}
}
if ($last === null && $list !== []) {
$last = $list[count($list) - 1]; // 全是暂存时退回最后一份
}
if ($last === null) {
$v = $r['score'] ?? null; // 无明细:退回代表行
return ($v === null || $v === '') ? null : (float)$v;
}
$v = $last['score'] ?? null;
return ($v === null || $v === '') ? null : (float)$v;
}
/**
* 把多场考试已构建好的结果,组装成"成绩对比"二维矩阵。
*
* @param array $exams 关联数组 pid => ['name'=>string, 'rows'=>array](保持插入顺序即考试展示顺序)
* @param string $scoreMode 'latest' | 'highest'
* @return array{head:array,rows:array,exam_order:array,exam_names:array}
*/
function ny_assemble_multi_exam_matrix(array $exams, string $scoreMode): array
{
$examOrder = [];
$examNames = [];
$scoreByExam = []; // pid => [sid => float|null]
$nameBySid = []; // sid => 姓名(首个非空)
foreach ($exams as $pid => $ex) {
$examOrder[] = $pid;
$examNames[$pid] = trim((string)($ex['name'] ?? '')) !== '' ? (string)$ex['name'] : (string)$pid;
$map = [];
foreach ((array)($ex['rows'] ?? []) as $r) {
$sid = trim((string)($r['sid'] ?? ''));
if ($sid === '') {
continue;
}
if (!isset($nameBySid[$sid]) && trim((string)($r['name'] ?? '')) !== '') {
$nameBySid[$sid] = (string)$r['name'];
}
$map[$sid] = ny_exam_cell_score($r, $scoreMode);
}
$scoreByExam[$pid] = $map;
}
// 学生并集(在任一选中考试里有提交即纳入)
$sidSet = [];
foreach ($scoreByExam as $map) {
foreach ($map as $sid => $_) {
$sidSet[$sid] = true;
}
}
$sids = array_keys($sidSet);
// 默认按学号排序(数值优先,否则自然序)
usort($sids, static function ($a, $b): int {
return ny_sid_cmp((string)$a, (string)$b);
});
$head = ['学号', '姓名'];
foreach ($examOrder as $pid) {
$head[] = $examNames[$pid];
}
$rows = [];
foreach ($sids as $sid) {
$sid = (string)$sid;
$line = [$sid, $nameBySid[$sid] ?? ''];
foreach ($examOrder as $pid) {
$sc = $scoreByExam[$pid][$sid] ?? null;
$line[] = ($sc === null) ? '' : $sc;
}
$rows[] = $line;
}
return ['head' => $head, 'rows' => $rows, 'exam_order' => $examOrder, 'exam_names' => $examNames];
}
/* ==================== 排序 / 列显示规则(纯逻辑,可单测) ==================== */
/** 学号比较:纯数字按数值比,否则自然序 */
function ny_sid_cmp(string $a, string $b): int
{
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
}
/** 文本比较:空值恒排末尾(班级 / 组别排序用,避免"未分班"插在中间) */
function ny_text_cmp(string $a, string $b): int
{
if ($a === $b) {
return 0;
}
if ($a === '') {
return 1;
}
if ($b === '') {
return -1;
}
return strnatcmp($a, $b);
}
/**
* 多考试导出「是否补出班级 / 组别列」的判定(按需求)。
*
* 规则:
* - 选了 ≥2 个班级 → 出「班级」列;只选 1 个班级 → 不出(全班一样,纯噪音)
* - 选了 ≥2 个组别 → 出「组别」列;只选 1 个组别 → 不出
* - 未选班级(= 全部)时按实际数据判断:数据确实跨多个班级才出列
* - 「只选了 1 个班级且未选组别」→ 班级 / 组别两列都不出
*
* @param array $clsPick 已选班级([] = 全部)
* @param array $grpPick 已选组别([] = 全部)
* @param array $seenCls 实际数据里出现的班级
* @param array $seenGrp 实际数据里出现的组别
* @return array{with_cls:bool,with_grp:bool}
*/
function ny_multi_col_flags(array $clsPick, array $grpPick, array $seenCls, array $seenGrp): array
{
$nCls = count($clsPick);
$nGrp = count($grpPick);
$withCls = ($nCls > 1) || ($nCls === 0 && count($seenCls) > 1);
$withGrp = ($nGrp > 1) || ($nGrp === 0 && $nCls !== 1 && count($seenGrp) > 1);
return ['with_cls' => $withCls, 'with_grp' => $withGrp];
}
/* ==================== 「每次考试」块表(两行表头 + 合并单元格) ==================== */
/**
* 取某个学生在某场考试里的「每次考试」列表(按提交时间升序 = 第1次、第2次…)。
*
* - skipTemp 时草稿不计入、也不占"第几次"编号(导出后 1..N 连续)
* - `is_best` 在**被导出的那些次**里比较(跳过草稿时,草稿分不再抢走「是否最佳」标记,
* 否则会出现整场没有一次被标「是」的怪表);同分只标最早达成的那次
* - 保留草稿时草稿条目的 `draft` 为 true,调用方可据此标注「(草稿)」
*
* @return array<int,array{idx:int,score:string,ts:int,is_best:bool,draft:bool}>
*/
function ny_exam_attempt_list(array $r, bool $skipTemp = false): array
{
$list = array_values((array)($r['attempts_list'] ?? []));
if ($list === []) {
// 无明细:代表行本身算一次考试
$list = [[
'idx' => 1,
'score' => $r['score'] ?? null,
'submit_ts' => (int)($r['submit_ts'] ?? 0),
'temp_save' => false,
]];
}
// 统一按提交时间升序(旧 -> 新)
usort($list, static function (array $x, array $y): int {
return ((int)($x['submit_ts'] ?? 0)) <=> ((int)($y['submit_ts'] ?? 0));
});
// 先过滤,再在被导出的那些次里找最高分
$kept = [];
foreach ($list as $a) {
if ($skipTemp && !empty($a['temp_save'])) {
continue;
}
$kept[] = $a;
}
$bestVal = null;
$bestAtIdx = -1;
foreach ($kept as $i => $a) {
$v = $a['score'] ?? null;
if ($v === null || $v === '') {
continue;
}
if ($bestVal === null || (float)$v > $bestVal) {
$bestVal = (float)$v;
$bestAtIdx = $i;
}
}
$out = [];
foreach ($kept as $i => $a) {
$score = $a['score'] ?? null;
$out[] = [
'idx' => $i + 1,
'score' => ($score === null || $score === '') ? '' : (string)$score,
'ts' => (int)($a['submit_ts'] ?? 0),
'is_best' => ($i === $bestAtIdx),
'draft' => !empty($a['temp_save']),
];
}
return $out;
}
/**
* 组装「每次考试」块表(两行表头 / 合并单元格 / 未考占位)——与需求截图一致。
*
* 版式(对应需求截图):
* ┌──────┬──────┬────────┬────────┬────────┬────────┐
* │ 学号 │ 姓名 │ 第几次 │考试名1 │考试名2 │考试名3 │ ← 第 1 行:考试名
* ├──────┼──────┼────────┼────────┼────────┼────────┤
* │ │ │ │ 成绩 │ 成绩 │ 成绩 │ ← 第 2 行(前导列纵向贯穿两行)
* ├──────┼──────┼────────┼────────┼────────┼────────┤
* │24030…│ 张三 │ 1 │ 127 │ 118 │ 134 │
* │(合并)│(合并)│ 2 │ 132 │ 121 │ 136 │ ← 最高分用红字
* │ │ │ … │ … │ … │ … │
* │ │ │合格次数│ 2 │ 0 │ 1 │ ← 达标绿底,未达标不标底
* └──────┴──────┴────────┴────────┴────────┴────────┘
*
* 约定:
* - 一名学生占 max(各场有效次数) + 1 行(+1 是末尾的「合格次数」汇总行)
* - 学号 / 姓名 两列按学生块纵向合并;「第几次」逐行写各自次序号(1、2、3…),**不合并**
* (合并会把 2、3 吞掉只留 1,见截图反馈)
* - 每场考试占 **1 列**:第 1 行考试名、第 2 行「成绩」小标题
* (历史上每场占 2 列,第 2 列是遗留的空位,表现为"每场右边多一根空白列",已去掉)
* - 只在该生某场考试完全没考时写「未考」并纵向合并该列的学生块;考了但次数少 → 留空
* - 「合格次数」= 该场成绩 ≥ 合格线的次数;始终显示(含 0 次),只有 ≥ 阈值才标绿底
* - 每场成绩等于该场最高分的那次用红字标出(同分只标最早达成的一次)
*
* @param array $exams 关联数组 pid => ['name'=>string, 'rows'=>array](保持插入顺序 = 考试列顺序)
* @param array $opts [
* 'skip_temp' => bool, 'with_cls' => bool, 'with_grp' => bool, 'absent_label' => string,
* 'pass_line' => float|null, 合格线分数(null = 不判定合格,不出现合格次数行)
* 'pass_times' => int, 至少合格几次才算通过(默认 1)
* 'mark_best' => bool, 是否把每场最高分标红(默认 true)
* 'pass_row' => bool, 是否输出「合格次数」汇总行(默认 true,且需 pass_line 有效)
* ]
* @return array{rows:array,merges:array,widths:array,header_rows:int,exam_order:array,exam_names:array,exam_col:array,cell_styles:array,students:int,total_rows:int,with_cls:bool,with_grp:bool,pass_row:bool,cols:array}
*/
function ny_assemble_block_sheet(array $exams, array $opts = []): array
{
// 默认跳过暂存草稿(草稿不是有效成绩);显式传 skip_temp=false 可保留
$skipTemp = array_key_exists('skip_temp', $opts) ? !empty($opts['skip_temp']) : true;
$withCls = !empty($opts['with_cls']);
$withGrp = !empty($opts['with_grp']);
$absent = (string)($opts['absent_label'] ?? '未考');
$markBest = array_key_exists('mark_best', $opts) ? !empty($opts['mark_best']) : true;
// 合格线:null / 非正数 => 不判定合格(不输出合格次数行)
$passLine = $opts['pass_line'] ?? null;
$passLine = ($passLine === null || $passLine === '') ? null : (float)$passLine;
if ($passLine !== null && $passLine <= 0) {
$passLine = null;
}
$passTimes = (int)($opts['pass_times'] ?? 1);
if ($passTimes < 1) {
$passTimes = 1;
}
$wantPassRow = array_key_exists('pass_row', $opts) ? !empty($opts['pass_row']) : true;
$passRow = ($wantPassRow && $passLine !== null);
$examOrder = [];
$examNames = [];
$stu = []; // sid => ['name','cls','grp','exams' => [pid => 每次考试列表]]
foreach ($exams as $pid => $ex) {
$examOrder[] = $pid;
$examNames[$pid] = trim((string)($ex['name'] ?? '')) !== '' ? (string)$ex['name'] : (string)$pid;
foreach ((array)($ex['rows'] ?? []) as $r) {
$sid = trim((string)($r['sid'] ?? ''));
if ($sid === '') {
continue;
}
if (!isset($stu[$sid])) {
$stu[$sid] = ['name' => '', 'cls' => '', 'grp' => '', 'exams' => []];
}
// 同一学生在多场考试里字段一致,取首个非空即可
if ($stu[$sid]['name'] === '' && trim((string)($r['name'] ?? '')) !== '') {
$stu[$sid]['name'] = (string)$r['name'];
}
if ($stu[$sid]['cls'] === '' && trim((string)($r['cls'] ?? '')) !== '') {
$stu[$sid]['cls'] = (string)$r['cls'];
}
if ($stu[$sid]['grp'] === '' && trim((string)($r['grp'] ?? '')) !== '') {
$stu[$sid]['grp'] = (string)$r['grp'];
}
$stu[$sid]['exams'][$pid] = ny_exam_attempt_list($r, $skipTemp);
}
}
// 学生顺序:有班级 / 组别列时按 班级 → 组别 → 学号(同班同组自然相邻,合并单元格才不会断开)
$sids = array_keys($stu);
usort($sids, static function ($a, $b) use ($stu, $withCls, $withGrp): int {
$a = (string)$a; // PHP 会把纯数字学号键转成 int,这里统一回字符串
$b = (string)$b;
if ($withCls) {
$d = ny_text_cmp($stu[$a]['cls'], $stu[$b]['cls']);
if ($d !== 0) {
return $d;
}
}
if ($withGrp) {
$d = ny_text_cmp($stu[$a]['grp'], $stu[$b]['grp']);
if ($d !== 0) {
return $d;
}
}
return ny_sid_cmp($a, $b);
});
/* ---------------- 列布局 ----------------
前导列:班级? / 组别? / 学号 / 姓名 / 第几次(各考试共用一列)
考试区:每场 1 列(第 1 行考试名、第 2 行「成绩」小标题) */
$colCls = $withCls ? 1 : 0;
$colGrp = $withGrp ? ($withCls ? 2 : 1) : 0;
$colSid = 1 + ($withCls ? 1 : 0) + ($withGrp ? 1 : 0);
$colName = $colSid + 1;
$colIdx = $colName + 1; // 「第几次」(所有考试共用)
$colExam0 = $colIdx + 1;
$h1 = [];
$h2 = [];
if ($withCls) { $h1[] = '班级'; $h2[] = ''; }
if ($withGrp) { $h1[] = '组别'; $h2[] = ''; }
$h1[] = '学号';
$h2[] = '';
$h1[] = '姓名';
$h2[] = '';
$h1[] = '第几次';
$h2[] = '';
$merges = [];
// 表头两行的纵向合并:班级 / 组别 / 学号 / 姓名 / 第几次
foreach ([$colCls, $colGrp, $colSid, $colName, $colIdx] as $c) {
if ($c > 0) {
$merges[] = [1, $c, 2, $c];
}
}
// 每场考试占 1 列:第 1 行考试名、第 2 行「成绩」小标题(无需横向合并)
$examCol = [];
$c = $colExam0;
foreach ($examOrder as $pid) {
$examCol[$pid] = $c;
$h1[] = $examNames[$pid];
$h2[] = '成绩';
$c += 1;
}
$rows = [$h1, $h2];
$rn = 2; // 当前行号(两行表头已占 1~2)
$total = 0;
$blocks = []; // 学生块:['start','end','cls','grp']
// 单元格标记:'行,列' => 'red' | 'green' | 'header_green'
$marks = [];
foreach ($sids as $sid) {
$sid = (string)$sid;
$s = $stu[$sid];
$lists = [];
$maxN = 0;
foreach ($examOrder as $pid) {
$l = $s['exams'][$pid] ?? [];
$lists[$pid] = $l;
if (count($l) > $maxN) {
$maxN = count($l);
}
}
if ($maxN < 1) {
continue; // 该生所有考试都没有有效提交(例如只有草稿被跳过)
}
// 每场最高分(用于标红;只在被导出的那些次里比较,与列表口径一致)
$bestScore = [];
foreach ($examOrder as $pid) {
$bv = null;
foreach ($lists[$pid] as $a) {
$sv = $a['score'];
if ($sv === '' || $sv === null) {
continue;
}
if ($bv === null || (float)$sv > $bv) {
$bv = (float)$sv;
}
}
$bestScore[$pid] = $bv;
}
$start = $rn + 1;
for ($i = 0; $i < $maxN; $i++) {
$rn++;
$line = [];
if ($withCls) {
$line[] = ($i === 0) ? $s['cls'] : '';
}
if ($withGrp) {
$line[] = ($i === 0) ? $s['grp'] : '';
}
$line[] = ($i === 0) ? $sid : '';
$line[] = ($i === 0) ? $s['name'] : '';
$line[] = (string)($i + 1); // 第几次:所有考试共用一列
foreach ($examOrder as $pid) {
$a = $lists[$pid][$i] ?? null;
if ($a === null) {
$line[] = ''; // 该生这场没考到第 i 次 → 留空
continue;
}
$line[] = $a['score'];
// 最高分标红:只标一处(同分只标最早达成的那一行)
if ($markBest && $a['score'] !== '' && $bestScore[$pid] !== null
&& (float)$a['score'] === $bestScore[$pid] && $a['is_best']) {
$marks[$rn . ',' . $examCol[$pid]] = 'red';
}
}
$rows[] = $line;
$total++;
}
$end = $rn;
if ($end > $start) {
$merges[] = [$start, $colSid, $end, $colSid];
$merges[] = [$start, $colName, $end, $colName];
// 「第几次」不合并:每行是不同的次序号(合并只剩第一行的 1)
}
// 完全没考的考试:该场该列 × 学生块高 → 合并成「未考」
foreach ($examOrder as $pid) {
if ($lists[$pid] !== []) {
continue;
}
$cc = $examCol[$pid];
$rows[$start - 1][$cc - 1] = $absent;
if ($end > $start) { // 单行块无需合并(1×1 合并是非法区间,Excel 会提示修复)
$merges[] = [$start, $cc, $end, $cc];
}
}
$blocks[] = ['start' => $start, 'end' => $end, 'cls' => $s['cls'], 'grp' => $s['grp']];
}
/* ---------------- 末尾「合格次数」汇总行 ----------------
每个学生块末尾追加一行「合格次数」:每场考试列出该生达到合格线的次数;
次数始终显示(含 0 次),达到阈值(默认 1 次)才标绿底。
追加行会改变所有行号,因此这里不走"插入",而是带着合格次数行整体重建一遍
(学生块高 = maxN + 1),保证合并区间 / 标红位置的坐标一次算对。 */
if ($passRow) {
$rows = [$h1, $h2];
$merges = [];
$marks = [];
$rn = 2;
$total = 0;
$newBlocks = [];
foreach ([$colCls, $colGrp, $colSid, $colName, $colIdx] as $cc) {
if ($cc > 0) {
$merges[] = [1, $cc, 2, $cc];
}
}
// 考试列每场 1 列,表头两行各写「考试名 / 成绩」,无需合并
foreach ($sids as $sid) {
$sid = (string)$sid;
$s = $stu[$sid];
$lists = [];
$maxN = 0;
foreach ($examOrder as $pid) {
$l = $s['exams'][$pid] ?? [];
$lists[$pid] = $l;
if (count($l) > $maxN) {
$maxN = count($l);
}
}
if ($maxN < 1) {
continue;
}
$bestScore = [];
foreach ($examOrder as $pid) {
$bv = null;
foreach ($lists[$pid] as $a) {
$sv = $a['score'];
if ($sv === '' || $sv === null) {
continue;
}
if ($bv === null || (float)$sv > $bv) {
$bv = (float)$sv;
}
}
$bestScore[$pid] = $bv;
}
$start = $rn + 1;
for ($i = 0; $i < $maxN; $i++) {
$rn++;
$line = [];
if ($withCls) { $line[] = ($i === 0) ? $s['cls'] : ''; }
if ($withGrp) { $line[] = ($i === 0) ? $s['grp'] : ''; }
$line[] = ($i === 0) ? $sid : '';
$line[] = ($i === 0) ? $s['name'] : '';
$line[] = (string)($i + 1); // 第几次:逐行写各自次序号,不合并
foreach ($examOrder as $pid) {
$a = $lists[$pid][$i] ?? null;
if ($a === null) {
$line[] = '';
continue;
}
$line[] = $a['score'];
if ($markBest && $a['score'] !== '' && $bestScore[$pid] !== null
&& (float)$a['score'] === $bestScore[$pid] && $a['is_best']) {
$marks[$rn . ',' . $examCol[$pid]] = 'red';
}
}
$rows[] = $line;
$total++;
}
// 完全没考的考试:该场该列 × 学生块高 → 合并成「未考」
foreach ($examOrder as $pid) {
if ($lists[$pid] !== []) {
continue;
}
$cx = $examCol[$pid];
$rows[$start - 1][$cx - 1] = $absent;
if ($rn > $start) { // 单行块无需合并(1×1 合并是非法区间)
$merges[] = [$start, $cx, $rn, $cx];
}
}
// 合格次数行(每个学生块一行)
$rn++;
$passLineRow = [];
if ($withCls) { $passLineRow[] = ''; }
if ($withGrp) { $passLineRow[] = ''; }
$passLineRow[] = '';
$passLineRow[] = '';
$passLineRow[] = '合格次数'; // 落在「第几次」列,与截图一致
foreach ($examOrder as $pid) {
$list = $lists[$pid];
if ($list === []) {
// 整场未考:与「未考」合并格保持一致,不再单独判断合格次数
$passLineRow[] = '';
continue;
}
$cnt = 0;
foreach ($list as $a) {
if ($a['score'] !== '' && $a['score'] !== null && (float)$a['score'] >= $passLine) {
$cnt++;
}
}
/* 合格次数始终显示(含 0 次);只有达到阈值才标绿底。
之前写 '' 会把「考了但没达标」的人整格隐藏,用户要看全部人次。 */
$passLineRow[] = (string)$cnt;
if ($cnt >= $passTimes) {
$marks[$rn . ',' . $examCol[$pid]] = 'green'; // 达标:绿底
}
}
$rows[] = $passLineRow;
$total++;
/* 学号 / 姓名 纵向合并到「合格次数」行(这两列该行本就是空的,合并正确);
「第几次」列逐行是 1、2、3…,不合并——末行「合格次数」标签也写在这一列。 */
$merges[] = [$start, $colSid, $rn, $colSid];
$merges[] = [$start, $colName, $rn, $colName];
$newBlocks[] = ['start' => $start, 'end' => $rn, 'cls' => $s['cls'], 'grp' => $s['grp']];
}
$blocks = $newBlocks;
}
// 班级 / 组别:连续相同值的相邻学生块合并成一格
foreach ([['cls', $colCls], ['grp', $colGrp]] as $spec) {
$key = $spec[0];
$col = (int)$spec[1];
if ($col < 1) {
continue;
}
$n = count($blocks);
$i = 0;
while ($i < $n) {
$val = $blocks[$i][$key];
$j = $i;
while ($j + 1 < $n && $blocks[$j + 1][$key] === $val) {
$j++;
}
if ($val !== '' && $j > $i) {
$merges[] = [$blocks[$i]['start'], $col, $blocks[$j]['end'], $col];
}
$i = $j + 1;
}
}
// 合并区间去重(重复区间会让 Excel 打开时提示"修复")
$seen = [];
$uniq = [];
foreach ($merges as $m) {
$k = $m[0] . ',' . $m[1] . ',' . $m[2] . ',' . $m[3];
if (isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$uniq[] = $m;
}
$merges = $uniq;
/* 列宽:够用就好,宽了会把人挤到屏幕外(截图里学号列两侧大片空白就是这么来的)。
- 学号按实际内容估宽(10 位数字约 11,超过 12 位才放宽),不再一律 20
- 姓名同样按最长姓名估宽
- 每场考试只占 1 列,列宽要装得下该列表头(考试名),中文按 2 个半角宽算 */
$maxSidLen = 2;
$maxNameLen = 2;
foreach ($stu as $s) {
// 名单里可能缺 sid / name 的条目(脏数据):估宽只能兜住,不能因此报 Undefined key
$sidLen = mb_strlen((string)($s['sid'] ?? ''), 'UTF-8');
$nameLen = mb_strlen((string)($s['name'] ?? ''), 'UTF-8');
if ($sidLen > $maxSidLen) {
$maxSidLen = $sidLen;
}
if ($nameLen > $maxNameLen) {
$maxNameLen = $nameLen;
}
}
// Excel 列宽单位≈半角字符数;中文按 2 个半角宽算,再留 2 的余量
$sidW = min(18, max(10, $maxSidLen + 2));
$nameW = min(20, max(9, $maxNameLen * 2 + 2));
// 考试列:取最长的考试名估一个统一宽度
$examW = 8;
foreach ($examOrder as $pid) {
$nm = (string)$examNames[$pid];
$len = mb_strlen($nm, 'UTF-8');
$w = 0;
for ($k = 0; $k < $len; $k++) {
$w += (strlen((string)mb_substr($nm, $k, 1, 'UTF-8')) > 1) ? 2 : 1;
}
$w += 2; // 左右留白
if ($w > $examW) {
$examW = $w;
}
}
$examW = min(18, max(8, $examW));
$widths = [];
if ($withCls) {
$widths[] = 14; // 班级名通常较长(如「计算机2401」)
}
if ($withGrp) {
$widths[] = 9;
}
$widths[] = $sidW; // 学号
$widths[] = $nameW; // 姓名
$widths[] = 10; // 第几次(同时承载「合格次数」标签)
foreach ($examOrder as $pid) {
$widths[] = $examW; // 每场考试 1 列(列宽装得下表头考试名)
}
return [
'rows' => $rows,
'merges' => $merges,
'widths' => $widths,
'header_rows' => 2,
'exam_order' => $examOrder,
'exam_names' => $examNames,
'exam_col' => $examCol,
'cell_styles' => $marks,
'students' => count($blocks),
'total_rows' => $total,
'with_cls' => $withCls,
'with_grp' => $withGrp,
'pass_row' => $passRow,
'pass_line' => $passLine,
'pass_times' => $passTimes,
'cols' => [
'cls' => $colCls, 'grp' => $colGrp, 'sid' => $colSid,
'name' => $colName, 'idx' => $colIdx, 'exam0' => $colExam0,
],
];
}
/**
* 组装「单场考试的每次考试」宽表——对应需求截图二。
*
* 版式:
* ┌──────┬──────┬──────┬──────┬─────┬────────┐
* │ 学号 │ 姓名 │ 第1次│ 第2次│ ... │合格次数│
* ├──────┼──────┼──────┼──────┼─────┼────────┤
* │24030…│ 张三 │ 127 │ 132 │ 143 │ 2 │ ← 最高分红字、合格次数达标绿底
* └──────┴──────┴──────┴──────┴─────┴────────┘
*
* 每人一行;列数 = 2 + 该场最多考试次数 + 1(合格次数)。
* 某生次数少于最大值时留空。仅在"不带考试耗时 / 提交时间"时使用本版式,
* 带了耗时 / 时间就会失去宽表的意义(每人一行放不下每次的耗时),此时退回明细表。
*
* @param array $exam 单场考试 ['name'=>string, 'rows'=>array]
* @param array $opts ['skip_temp'=>bool, 'pass_line'=>float|null, 'pass_times'=>int, 'mark_best'=>bool, 'pass_row'=>bool]
* @return array{rows:array,merges:array,widths:array,header_rows:int,cell_styles:array,students:int,total_rows:int,attempt_cols:int,pass_row:bool}
*/
function ny_assemble_attempt_wide_sheet(array $exam, array $opts = []): array
{
$skipTemp = array_key_exists('skip_temp', $opts) ? !empty($opts['skip_temp']) : true;
$markBest = array_key_exists('mark_best', $opts) ? !empty($opts['mark_best']) : true;
$passLine = $opts['pass_line'] ?? null;
$passLine = ($passLine === null || $passLine === '') ? null : (float)$passLine;
if ($passLine !== null && $passLine <= 0) {
$passLine = null;
}
$passTimes = max(1, (int)($opts['pass_times'] ?? 1));
$wantPassRow = array_key_exists('pass_row', $opts) ? !empty($opts['pass_row']) : true;
$passRow = ($wantPassRow && $passLine !== null);
$stu = [];
foreach ((array)($exam['rows'] ?? []) as $r) {
$sid = trim((string)($r['sid'] ?? ''));
if ($sid === '') {
continue;
}
if (!isset($stu[$sid])) {
$stu[$sid] = [
'sid' => $sid,
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
'grp' => (string)($r['grp'] ?? ''),
'list' => ny_exam_attempt_list($r, $skipTemp),
];
}
}
$sids = array_keys($stu);
usort($sids, static function ($a, $b) use ($stu): int {
$a = (string)$a;
$b = (string)$b;
$d = ny_text_cmp($stu[$a]['cls'], $stu[$b]['cls']);
if ($d !== 0) {
return $d;
}
$d = ny_text_cmp($stu[$a]['grp'], $stu[$b]['grp']);
if ($d !== 0) {
return $d;
}
return ny_sid_cmp($a, $b);
});
$maxN = 0;
foreach ($sids as $sid) {
$n = count($stu[$sid]['list']);
if ($n > $maxN) {
$maxN = $n;
}
}
if ($maxN < 1) {
$maxN = 1;
}
$head = ['学号', '姓名'];
for ($i = 1; $i <= $maxN; $i++) {
$head[] = '第' . $i . '次';
}
if ($passRow) {
$head[] = '合格次数';
}
$rows = [$head];
$marks = [];
$rn = 1;
$colPass = 2 + $maxN + 1; // 合格次数列(1-based)
foreach ($sids as $sid) {
$sid = (string)$sid;
$s = $stu[$sid];
$list = $s['list'];
$rn++;
$line = [$sid, $s['name']];
$best = null;
foreach ($list as $a) {
if ($a['score'] === '' || $a['score'] === null) {
continue;
}
$v = (float)$a['score'];
if ($best === null || $v > $best) {
$best = $v;
}
}
for ($i = 0; $i < $maxN; $i++) {
$a = $list[$i] ?? null;
if ($a === null) {
$line[] = '';
continue;
}
$line[] = $a['score'];
if ($markBest && $a['score'] !== '' && $best !== null
&& (float)$a['score'] === $best && $a['is_best']) {
$marks[$rn . ',' . (3 + $i)] = 'red'; // 第1次在第 3 列
}
}
if ($passRow) {
$cnt = 0;
foreach ($list as $a) {
if ($a['score'] !== '' && $a['score'] !== null && (float)$a['score'] >= $passLine) {
$cnt++;
}
}
// 合格次数始终显示(含 0 次);只有达到阈值才标绿底
$line[] = (string)$cnt;
if ($cnt >= $passTimes) {
$marks[$rn . ',' . $colPass] = 'green';
}
}
$rows[] = $line;
}
$widths = [20, 14];
for ($i = 0; $i < $maxN; $i++) {
$widths[] = 9;
}
if ($passRow) {
$widths[] = 11;
}
return [
'rows' => $rows,
'merges' => [],
'widths' => $widths,
'header_rows' => 1,
'cell_styles' => $marks,
'students' => count($sids),
'total_rows' => count($sids),
'attempt_cols' => $maxN,
'pass_row' => $passRow,
'pass_line' => $passLine,
'pass_times' => $passTimes,
];
}
+144
View File
@@ -0,0 +1,144 @@
<?php
/**
* 独立登录页:连接信息(系统地址/接口前缀/账号)已在 data/config.json 中预置,
* 本页只需输入密码(账号可改)完成卷王登录,成功后进入主系统。
* 连接信息有变请在【设置】中修改。
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
// 先加载配置:下面已登录分支要用 $cfg 构造 SurveyKingClient 探活,必须在前面就准备好
$cfg = ny_load_config();
$notice = ''; // 非阻断提示(例如"上次会话已失效,请重新登录")
if (ny_is_auth()) {
// 已登录但卷王 token 可能已过期:用当前 token 探一次卷王,能通即视为有效,否则清掉本地会话强制重新登录
try {
$client = new SurveyKingClient($cfg);
$client->ping();
} catch (Throwable $e) {
// 卷王 token 已失效或配置错误 → 清会话 + 强制显示登录页(避免 login.php 把人踢回 index.php 形成死循环)
// 把失败原因带出来,避免用户只看到登录框却不知道为什么被踢回来
$notice = '已退出之前的登录(' . $e->getMessage() . ')';
$_SESSION = [];
@session_destroy();
}
if (ny_is_auth()) {
header('Location: index.php');
exit;
}
}
$err = '';
$ok = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$user = trim((string)($_POST['username'] ?? $cfg['username']));
$pwd = (string)($_POST['password'] ?? '');
// 允许用户在本页调整账号;密码必须输入
if ($user === '' || $pwd === '') {
$err = '请输入账号与密码。';
} else {
// 用当前配置的连接信息 + 本页输入的账密做一次卷王登录
$test = $cfg;
$test['username'] = $user;
$test['password'] = $pwd;
try {
$client = new SurveyKingClient($test);
$client->getToken(true);
// 登录成功:把账号与密码写回配置(本地单用户工具,持久化密码以便 token
// 失效时平台能自动重新登录卷王,避免后台刷新陷入"未配置账号"的死状态)
$cfg['username'] = $user;
$cfg['password'] = $pwd;
$saved = ny_save_config($cfg);
// 关键:必须回读确认真的存住了。以前这里不看返回值,data 目录不可写时
// 会显示"登录成功",但下一个请求读到的账号仍是空的 → 无限跳回登录页。
$back = ny_load_config();
$persistOk = $saved
&& trim((string)($back['username'] ?? '')) === $user
&& (string)($back['password'] ?? '') === $pwd;
if (!$persistOk) {
$d = ny_config_diag();
$err = "登录卷王成功,但凭据没能保存到本机配置,继续进入会立刻又提示“未配置账号”。\n"
. "请检查 data 目录写权限后重试:\n"
. "· 配置来源:" . $d['storage'] . "(可写=" . ($d['cfg_writable'] ? '是' : '否') . ")\n"
. "· 数据目录:" . $d['data_dir'] . "(可写=" . ($d['dir_writable'] ? '是' : '否') . ")\n"
. "· 数据库:" . ($d['db_connected'] ? '已连接(可写=' . ($d['db_writable'] ? '是' : '否') . ')' : '未连接 ' . $d['db_error']) . "\n"
. "· 保存错误:" . ($d['save_error'] !== '' ? $d['save_error'] : '(无)');
} elseif (!ny_session_usable($sessWhy)) {
// 会话存不住时同样会"登录成功→立刻被踢回登录页",必须在这一步拦住
$err = "凭据已保存,但 PHP 会话不可用,登录状态无法保持:\n" . $sessWhy
. "\n请在 php.ini 中把 session.save_path 指向一个可写目录后重试。";
} else {
ny_login();
$ok = true;
}
} catch (Throwable $e) {
$err = '登录失败:' . $e->getMessage();
}
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登录 · <?= htmlspecialchars(NY_APP_NAME) ?></title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<div class="login-gate<?= $ok ? ' hidden' : '' ?>">
<div class="login-box">
<div class="login-brand">
<span class="logo">NY</span>
<div class="brand-text">
<strong><?= htmlspecialchars(NY_APP_NAME) ?></strong>
<small>实时数据统计</small>
</div>
</div>
<p class="login-tip">系统连接信息已配置(<?= htmlspecialchars(parse_url($cfg['base_url'], PHP_URL_HOST)) ?>/<?= htmlspecialchars(trim($cfg['api_prefix'], '/')) ?>)。请输入账号密码完成登录。</p>
<?php if (trim((string)$cfg['username']) === ''): ?>
<div class="alert" style="white-space:pre-wrap">⚠️ 本机尚未保存卷王账号(配置来源:<?= htmlspecialchars(ny_config_source()) ?>)。
如果每次登录成功都回到本页,多半是 data 目录不可写导致账号没存住——
请让 data/ 目录(<?= htmlspecialchars(NY_DATA) ?>)对 PHP 进程可写后重试。</div>
<?php endif; ?>
<?php if ($err !== ''): ?>
<div class="alert" style="white-space:pre-wrap"><?= htmlspecialchars($err) ?></div>
<?php elseif ($notice !== ''): ?>
<div class="alert" style="white-space:pre-wrap"><?= htmlspecialchars($notice) ?></div>
<?php endif; ?>
<form method="post">
<div class="fi"><label>账号</label><input type="text" name="username" value="<?= htmlspecialchars($cfg['username']) ?>" autocomplete="username"></div>
<div class="fi"><label>密码</label><input type="password" name="password" placeholder="请输入密码" autocomplete="current-password" autofocus></div>
<div class="login-actions">
<button class="btn btn-primary" type="submit">登录</button>
</div>
</form>
</div>
</div>
<?php if ($ok): ?>
<div class="login-gate">
<div class="login-box" style="text-align:center">
<div class="login-brand" style="justify-content:center"><span class="logo">NY</span>
<div class="brand-text"><strong>登录成功</strong><small>正在进入数据平台…</small></div>
</div>
<script>
// 记录"本次登录成功"的时间戳:前端据此判断"刚登录完又报鉴权失败",
// 这种情况不再自动跳回登录页(否则会形成死循环),而是把真实错误显示出来。
try { sessionStorage.setItem('ny_login_at', String(Date.now())); sessionStorage.removeItem('ny_auth_jumps'); } catch (e) {}
setTimeout(function(){ location.href='index.php'; }, 600);
</script>
</div>
</div>
<?php endif; ?>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
<?php
/**
* 退出登录:清除本系统会话并跳回登录页。
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
ny_logout();
header('Location: login.php');
exit;
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# 一键跑完所有回归测试(PHP 单元自测 + Node e2e)。
# 用法:bash tests/run-all.sh
set -e
cd "$(dirname "$0")/.."
# 运行期路径:优先用环境变量,其次用本机工作区路径,最后退回 PATH 里的 php / node
# (工作区 node 的版本目录会随升级变化,写死路径会直接 No such file or directory)
pick() {
local cand="$1" fallback="$2"
if [ -n "$cand" ] && [ -x "$cand" ]; then echo "$cand"; return; fi
command -v "$fallback" 2>/dev/null || echo "$fallback"
}
# 本项目用 PHP 7.4+ 语法(箭头函数 / 类型化属性),必须是 PHP 8;
# 工作区里已备好便携版 8.2.34(其 php.ini 已开启 pdo_sqlite / zip / mbstring),
# 想换别的解释器就设 PHP_BIN=... 覆盖
PHP="$(pick "${PHP_BIN:-C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe}" php)"
NODE="$(pick "${NODE_BIN:-}" node)"
INI="${PHP_INI:-}"
echo "PHP = $PHP"
echo "NODE = $NODE"
echo "================ PHP 自测 ================"
for f in tests/test-aggregate.php tests/test-new-features.php tests/test-config-persist.php tools/test-xlsxwriter.php tests/test-multi-export.php; do
echo ""
echo ">>> $f"
"$PHP" $INI "$f"
done
echo ""
echo "================ Node e2e(需 mock-server + PHP 内置服务器) ================"
# e2e 会把 mock 连接配置写进数据目录;用 NY_DATA 把这两个会改写配置的测试整体指向临时目录,
# 彻底避免污染生产 data/ny.db(曾因此把登录页连接信息写成 127.0.0.1)。
NY_DATA_TMP="$(pwd)/.ny-test-data"
mkdir -p "$NY_DATA_TMP"
# 说明:test-login-loop.js 会清空数据目录里的账密来复现"未配置账号"现场,
# 即使不传 NY_DATA 它也会自己建临时目录,绝不回退到项目 data/。
# 这几段依赖 mock-server(dashboard-nav 自带名单,另 4 段需本机 data/roster.json)。
# 逐段容错,避免一个坏掉的环境把后面的测试整段带走;失败照常记账,末尾统一汇总退出码。
e2e_fail=0
for f in tools/test-config-save.js tools/test-upload-guard.js tools/test-whitelist.js tools/test-login-loop.js tools/test-dashboard-nav.js; do
echo ""
echo ">>> $f (NY_DATA=$NY_DATA_TMP)"
if ! NY_DATA="$NY_DATA_TMP" "$NODE" "$f"; then e2e_fail=$((e2e_fail + 1)); fi
done
# test-draw-alloc.js 不读写连接配置,沿用项目 data/ 即可
echo ""
echo ">>> tools/test-draw-alloc.js"
"$NODE" "tools/test-draw-alloc.js" || e2e_fail=$((e2e_fail + 1))
# 多考试打印渲染(纯前端函数,不需要服务器):从 app.js 抽取 sheetToHtml 校验合并单元格换算
echo ""
echo ">>> tools/test-block-print.js"
"$NODE" "tools/test-block-print.js" || e2e_fail=$((e2e_fail + 1))
echo ""
if [ "$e2e_fail" -gt 0 ]; then
echo "全部测试跑完,但有 $e2e_fail 个 Node 段失败(多为 mock-server 缺 data/roster.json 导致的环境问题)"
exit 1
fi
echo "全部测试完成。"
+163
View File
@@ -0,0 +1,163 @@
<?php
/**
* 验证 aggregateByStudent 的"考试次数 / 历史明细"修复:
* 多次提交的学生在 dedupe=true(默认)与 dedupe=false 两种情况下,
* attempts 都应等于真实提交次数、attempts_list 都应包含全部提交;
* dedupe 仅影响"代表成绩"那一行取最新或最高分。
*/
require __DIR__ . '/../bootstrap.php';
$fail = 0;
$pass = 0;
function check(string $name, bool $cond, string $detail = ''): void
{
global $fail, $pass;
if ($cond) {
$pass++;
echo " ✅ {$name}\n";
} else {
$fail++;
echo " ❌ {$name} {$detail}\n";
}
}
/**
* 构造一个学生的多次提交(模拟 第二周测试 中"陈苏豫 11次"这类数据)
* 提交时间递增(旧 -> 新),分数有高有低。
*/
function makeRows(string $sid, array $scoresByTime): array
{
$rows = [];
$t = 1000; // 递增时间戳
foreach ($scoresByTime as $sc) {
$t += 600;
$rows[] = [
'sid' => $sid,
'name' => '测试生',
'cls' => '1班',
'grp' => 'A',
'score' => $sc,
'submit_ts' => $t,
'submit_at' => date('Y-m-d H:i:s', $t),
'duration' => 120,
'duration_txt'=> '2分0秒',
'answer_id' => 'ans-' . $t,
'matched' => true,
'temp_save' => false,
];
}
return $rows;
}
echo "== dedupe=true(默认:代表成绩取最新一份)==\n";
// 11 次提交:分数各不相同,最新一份是 84.0,最高是 98.0
$scores = [84.0, 98.0, 96.0, 96.0, 96.0, 94.0, 94.0, 96.0, 90.0, 88.0, 98.0];
$rows = makeRows('S001', $scores);
$agg = DataService::aggregateByStudent($rows, true);
check('聚合后只有 1 行(一人一行)', count($agg['rows']) === 1);
$r = $agg['rows'][0];
check('attempts = 11(真实次数,而非被吞成 1)', ($r['attempts'] ?? 0) === 11, 'got ' . var_export($r['attempts'] ?? null, true));
check('attempts_list 含 11 条(历史可展开全部)', count($r['attempts_list'] ?? []) === 11, 'got ' . count($r['attempts_list'] ?? []));
check('best_score = 98.0(跨全部尝试的真实最高分)', (float)($r['best_score'] ?? -1) === 98.0, 'got ' . var_export($r['best_score'] ?? null, true));
// 代表成绩 = 最新一份(数组中最后一个时间 = 98.0,因为 scores 数组最后一个是 98.0;注意最新提交对应最后构造的 98.0)
check('代表成绩取最新提交 = 98.0', (float)($r['score'] ?? -1) === 98.0, 'got ' . var_export($r['score'] ?? null, true));
check('attempts_list 项数 idx 连续 1..11', (int)($r['attempts_list'][10]['idx'] ?? 0) === 11);
check('attempts_list 至少一条 is_best(最高分标记)', count(array_filter($r['attempts_list'], fn($a) => !empty($a['is_best']))) >= 1);
echo "\n== dedupe=false(代表成绩取最高分)==\n";
$agg2 = DataService::aggregateByStudent($rows, false);
$r2 = $agg2['rows'][0];
check('attempts = 11(与 dedupe 无关,始终真实)', ($r2['attempts'] ?? 0) === 11, 'got ' . var_export($r2['attempts'] ?? null, true));
check('attempts_list 含 11 条', count($r2['attempts_list'] ?? []) === 11, 'got ' . count($r2['attempts_list'] ?? []));
check('代表成绩取最高分 = 98.0', (float)($r2['score'] ?? -1) === 98.0, 'got ' . var_export($r2['score'] ?? null, true));
check('best_score = 98.0', (float)($r2['best_score'] ?? -1) === 98.0, 'got ' . var_export($r2['best_score'] ?? null, true));
echo "\n== 重考低分也应被统计(历史含低分,且因提交时间更新能被发现)==\n";
// 先考 90,后重考 70(更低):考试次数必须=2,最新提交=70
$rows3 = makeRows('S002', [90.0, 70.0]);
$agg3 = DataService::aggregateByStudent($rows3, true);
$r3 = $agg3['rows'][0];
check('attempts = 2', ($r3['attempts'] ?? 0) === 2, 'got ' . var_export($r3['attempts'] ?? null, true));
check('attempts_list = 2 条(含低分重考)', count($r3['attempts_list'] ?? []) === 2);
check('代表成绩取最新 = 70.0', (float)($r3['score'] ?? -1) === 70.0, 'got ' . var_export($r3['score'] ?? null, true));
check('last_submit_ts 指向最新提交(重考可被高亮发现)', ($r3['last_submit_ts'] ?? 0) === $rows3[1]['submit_ts']);
echo "\n== 仅 1 次提交不受影响 ==\n";
$rows4 = makeRows('S003', [85.0]);
$agg4 = DataService::aggregateByStudent($rows4, true);
$r4 = $agg4['rows'][0];
check('单次提交 attempts = 1', ($r4['attempts'] ?? 0) === 1);
check('单次提交 attempts_list = 1', count($r4['attempts_list'] ?? []) === 1);
echo "\n== 按提交时间年月日分组:每个分类内标记当日最高 ==\n";
/** 按 [时间戳, 分数] 造行;ts=0 表示时间未知 */
function makeRowsAt(string $sid, array $items): array
{
$rows = [];
foreach ($items as $it) {
[$ts, $sc] = $it;
$rows[] = [
'sid' => $sid,
'name' => '测试生',
'cls' => '1班',
'grp' => 'A',
'score' => $sc,
'submit_ts' => $ts,
'submit_at' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : '-',
'duration' => 120,
'duration_txt' => '2分0秒',
'answer_id' => 'ans-' . $ts,
'matched' => true,
'temp_save' => false,
];
}
return $rows;
}
// 09-08 三次(70/95/88),09-09 两次(60/90)
$rows5 = makeRowsAt('S005', [
[strtotime('2026-09-08 09:00:00'), 70.0],
[strtotime('2026-09-08 11:00:00'), 95.0],
[strtotime('2026-09-08 15:00:00'), 88.0],
[strtotime('2026-09-09 10:00:00'), 60.0],
[strtotime('2026-09-09 14:00:00'), 90.0],
]);
$agg5 = DataService::aggregateByStudent($rows5, true);
$r5 = $agg5['rows'][0];
$al5 = $r5['attempts_list'];
check('5 次提交 attempts_list = 5', count($al5) === 5);
check('前 3 条归属 2026-09-08', ($al5[0]['day'] ?? '') === '2026-09-08' && ($al5[2]['day'] ?? '') === '2026-09-08',
'got ' . ($al5[0]['day'] ?? '?'));
check('后 2 条归属 2026-09-09', ($al5[3]['day'] ?? '') === '2026-09-09' && ($al5[4]['day'] ?? '') === '2026-09-09',
'got ' . ($al5[3]['day'] ?? '?'));
check('09-08 当天次数 = 3', ($al5[0]['day_count'] ?? 0) === 3, 'got ' . ($al5[0]['day_count'] ?? '?'));
check('09-09 当天次数 = 2', ($al5[3]['day_count'] ?? 0) === 2, 'got ' . ($al5[3]['day_count'] ?? '?'));
$dayBestIdx = array_values(array_filter($al5, fn($a) => !empty($a['day_best'])));
check('每个分类各标记 1 个当日最高(共 2 个)', count($dayBestIdx) === 2, 'got ' . count($dayBestIdx));
check('09-08 当日最高 = 95(第 2 次)', (int)($dayBestIdx[0]['idx'] ?? 0) === 2 && (float)($dayBestIdx[0]['score'] ?? -1) === 95.0,
'got idx=' . ($dayBestIdx[0]['idx'] ?? '?') . ' score=' . ($dayBestIdx[0]['score'] ?? '?'));
check('09-09 当日最高 = 90(第 5 次)', (int)($dayBestIdx[1]['idx'] ?? 0) === 5 && (float)($dayBestIdx[1]['score'] ?? -1) === 90.0,
'got idx=' . ($dayBestIdx[1]['idx'] ?? '?') . ' score=' . ($dayBestIdx[1]['score'] ?? '?'));
check('全局最佳仍是 95(第 2 次)', (int)($al5[1]['is_best'] ?? 0) === 1 || !empty($al5[1]['is_best']));
echo "\n== 时间未知(submit_ts=0)归入「未知时间」分组 ==\n";
$rows6 = makeRowsAt('S006', [[0, 50.0], [0, 80.0]]);
$agg6 = DataService::aggregateByStudent($rows6, true);
$al6 = $agg6['rows'][0]['attempts_list'];
check('时间未知时 day = 空串', ($al6[0]['day'] ?? 'x') === '' && ($al6[1]['day'] ?? 'x') === '',
'got ' . var_export($al6[0]['day'] ?? null, true));
check('未知时间组次数 = 2', ($al6[0]['day_count'] ?? 0) === 2, 'got ' . ($al6[0]['day_count'] ?? '?'));
check('未知时间组内仍标记最高(80)', !empty($al6[1]['day_best']) && (float)($al6[1]['score'] ?? -1) === 80.0);
echo "\n== 日期键时区边界(不能用 UTC,否则零点前后错一天)==\n";
check('00:30 仍算当天(东八区不会退到前一天)',
DataService::dayKeyOf((int)strtotime('2026-09-08 00:30:00')) === '2026-09-08',
'got ' . DataService::dayKeyOf((int)strtotime('2026-09-08 00:30:00')));
check('23:30 仍算当天',
DataService::dayKeyOf((int)strtotime('2026-09-08 23:30:00')) === '2026-09-08',
'got ' . DataService::dayKeyOf((int)strtotime('2026-09-08 23:30:00')));
check('无时间戳时从展示串取日期', DataService::dayKeyOf(0, '2026-09-08 09:41:20') === '2026-09-08');
check('无时间戳且展示串为 - 时返回空串', DataService::dayKeyOf(0, '-') === '');
echo "\n========================================\n";
echo "通过 {$pass} · 失败 {$fail}\n";
exit($fail === 0 ? 0 : 1);
+125
View File
@@ -0,0 +1,125 @@
<?php
/**
* 配置落盘 / 鉴权死循环 回归测试
*
* 背景:登录页"登录成功"后立刻又报"未配置卷王登录账号"并反复跳回登录页。
* 根因是 ny_save_config 只写不校验(Db::settingsSet 内部吞异常且返回 void),
* data/ 目录不可写时上层以为保存成功,下一个请求读到的账号仍是空的。
*
* 覆盖:
* 1) ny_save_config 必须回读校验并真实反映落盘结果
* 2) ny_load_config 把 null 账号强制成空字符串(历史脏数据不再伪装成"未配置")
* 3) ny_apply_config_input:账号/密码不许被设置页误清空,连接信息才触发重连
* 4) ny_config_diag 能给出可自查的诊断字段
*/
declare(strict_types=1);
// 必须赶在 bootstrap 之前把数据目录重定向到临时目录,避免污染生产 data/ny.db
$tmpDir = sys_get_temp_dir() . '/ny-cfgtest-' . getmypid() . '-' . bin2hex(random_bytes(3));
putenv('NY_DATA=' . $tmpDir);
$_ENV['NY_DATA'] = $tmpDir;
require __DIR__ . '/../bootstrap.php';
register_shutdown_function(static function () use ($tmpDir): void {
// 清理临时数据目录(Windows 下先删文件再删目录)
if (!is_dir($tmpDir)) {
return;
}
foreach (glob($tmpDir . '/*') ?: [] as $f) {
@unlink($f);
}
@rmdir($tmpDir);
});
$pass = 0;
$fail = 0;
function check(string $name, bool $cond, string $detail = ''): void
{
global $pass, $fail;
if ($cond) { $pass++; echo " ✅ {$name}\n"; }
else { $fail++; echo " ❌ {$name} {$detail}\n"; }
}
echo "\n== 1) 保存后回读(模拟登录页写凭据) ==\n";
$cfg0 = ny_default_config();
$cfg0['username'] = 'teacher01';
$cfg0['password'] = 'P@ss w0rd';
$saved = ny_save_config($cfg0);
check('ny_save_config 返回 true', $saved === true, 'save_error=' . ny_config_save_error());
$back = ny_load_config();
check('回读账号一致', ($back['username'] ?? '') === 'teacher01', json_encode($back['username'] ?? null));
check('回读密码一致(含空格原样保留)', ($back['password'] ?? '') === 'P@ss w0rd', json_encode($back['password'] ?? null));
check('配置来源为 db 或 file', in_array(ny_config_source(), ['db', 'file'], true), ny_config_source());
check('无保存错误提示', ny_config_save_error() === '', ny_config_save_error());
echo "\n== 2) 脏数据兜底:账号被存成 null ==\n";
$dirty = $cfg0;
$dirty['username'] = null;
ny_write_json(ny_config_file(), $dirty);
// 强制走文件分支读取:把 DB 里的记录清掉
if (class_exists('Db') && Db::pdo() !== null) {
Db::settingsDelete('config');
}
$dirtyCfg = ny_load_config();
check('null 账号 → 空字符串而不是 null', $dirtyCfg['username'] === '', var_export($dirtyCfg['username'], true));
check('账号为字符串类型', is_string($dirtyCfg['username']));
echo "\n== 3) 设置保存:账号/密码不被误清空 ==\n";
$old = ny_default_config();
$old['username'] = 'admin';
$old['password'] = 'secret';
$r = ny_apply_config_input($old, ['username' => '']);
check('账号传空 → 保留原账号(防止陷入未配置死循环)', $r['config']['username'] === 'admin', $r['config']['username']);
check('账号传空 → 不算连接信息变化', $r['conn_changed'] === false);
$r = ny_apply_config_input($old, ['username' => null]);
check('账号传 null → 保留原账号', $r['config']['username'] === 'admin', var_export($r['config']['username'], true));
$r = ny_apply_config_input($old, ['password' => '******']);
check('密码占位符 ****** → 不修改', $r['config']['password'] === 'secret');
$r = ny_apply_config_input($old, ['password' => '']);
check('密码传空 → 不修改', $r['config']['password'] === 'secret');
$r = ny_apply_config_input($old, ['password' => 'newpwd']);
check('密码传新值 → 更新', $r['config']['password'] === 'newpwd');
check('密码变化 → 标记连接信息已变', $r['conn_changed'] === true);
$r = ny_apply_config_input($old, ['username' => 'admin2']);
check('账号变化 → 标记连接信息已变', $r['conn_changed'] === true);
$r = ny_apply_config_input($old, ['full_score' => 90, 'page_size' => 50]);
check('仅改满分/分页 → 不触发重连', $r['conn_changed'] === false);
check('满分已写入', (int)$r['config']['full_score'] === 90);
check('未传的字段保持原值', (int)$r['config']['page_size'] === 50 && $r['config']['username'] === 'admin');
$r = ny_apply_config_input($old, ['refresh_interval' => 1]);
check('刷新间隔最小值收敛到 3', (int)$r['config']['refresh_interval'] === 3);
$r = ny_apply_config_input($old, ['base_url' => 'https://exam.nxxy335.top/']);
check('base_url 去掉末尾斜杠', $r['config']['base_url'] === 'https://exam.nxxy335.top', $r['config']['base_url']);
echo "\n== 4) 落盘诊断 ==\n";
$d = ny_config_diag();
check('诊断含 storage', isset($d['storage']));
check('诊断含目录可写标记', array_key_exists('dir_writable', $d));
check('诊断含配置文件可写标记', array_key_exists('cfg_writable', $d));
check('诊断含数据库可写标记', array_key_exists('db_writable', $d));
check('诊断含是否已存账号', array_key_exists('has_username', $d));
check('临时目录可写时 cfg_writable=true', $d['cfg_writable'] === true, json_encode($d, JSON_UNESCAPED_UNICODE));
echo "\n== 5) 数据库写入回读校验 ==\n";
if (class_exists('Db') && Db::pdo() !== null) {
check('Db::settingsSet 成功返回 true', Db::settingsSet('__ny_test_key', 'hello') === true);
check('Db::settingsGet 读回一致', Db::settingsGet('__ny_test_key', '') === 'hello');
Db::settingsDelete('__ny_test_key');
check('Db::settingsDelete 生效', Db::settingsGet('__ny_test_key', 'x') === 'x');
} else {
echo " ⚠️ 无 pdo_sqlite,跳过数据库回读用例\n";
}
echo "\n----------------------------------------\n";
echo "通过 {$pass} 项,失败 {$fail} 项\n";
exit($fail === 0 ? 0 : 1);
+440
View File
@@ -0,0 +1,440 @@
<?php
/**
* 多考试对比导出(成绩交叉矩阵)回归测试
*
* 覆盖 ny_exam_cell_score / ny_assemble_multi_exam_matrix:
* 1) 矩阵结构:第一行 = 考试名称,前两列 = 学号 / 姓名
* 2) 默认按学号升序
* 3) 最新成绩 = attempts_list 中最近一次「非暂存」提交;最高成绩 = 跨全部尝试的最高分
* 4) 草稿(temp_save)不计入"最新成绩",但计入"最高成绩"
* 5) 重考分数更低时,最新≠最高
* 6) 未考的考试列为空;学生并集(任一考试有提交即纳入)
* 7) 同一学号跨考试姓名以首个非空为准
*/
require __DIR__ . '/../lib/multi_export.php';
$fail = 0;
$pass = 0;
function check(string $name, bool $cond, string $detail = ''): void
{
global $fail, $pass;
if ($cond) {
$pass++;
echo " ✅ {$name}\n";
} else {
$fail++;
echo " ❌ {$name} {$detail}\n";
}
}
/**
* 构造一名学生的聚合行(与 DataService::aggregateByStudent 产出的结构保持一致)。
* 分数按时间升序给出(旧 -> 新),tempMarks 标记对应提交是否为草稿。
*/
function stuRow(string $sid, string $name, array $scoresAsc, array $tempMarks = [], ?float $best = null): array
{
$list = [];
$t = 1000000; // 不用数字分隔符,保证 PHP 7.3+ 都能跑回归测试
foreach ($scoresAsc as $i => $sc) {
$t += 600;
$list[] = [
'idx' => $i + 1,
'submit_ts' => $t,
'score' => $sc,
'temp_save' => !empty($tempMarks[$i]),
'duration' => 120,
];
}
return [
'sid' => $sid,
'name' => $name,
'cls' => '1班',
'grp' => 'A',
'score' => end($scoresAsc),
'best_score' => $best ?? (float)max($scoresAsc),
'attempts' => count($scoresAsc),
'attempts_list'=> $list,
];
}
/* ---------------- 场景 ---------------- */
$examMath = [
'name' => '数学期中',
'rows' => [
// 张三:第一次 90,重考降到 80(最新≠最高);草稿 95 只算最高
stuRow('2021001', '张三', [90.0, 80.0], [false, false], 90.0),
stuRow('2021002', '李四', [70.0]),
// 草稿分:time1 真实 60,time2 草稿 99 → 最新应取 60,最高取 99
stuRow('2021004', '赵六', [60.0, 99.0], [false, true], 99.0),
],
];
$examEnglish = [
'name' => '英语期中',
'rows' => [
stuRow('2021001', '张三', [85.0]),
stuRow('2021003', '王五', [60.0]),
],
];
echo "== 最新成绩矩阵 ==\n";
$m = ny_assemble_multi_exam_matrix(['math' => $examMath, 'eng' => $examEnglish], 'latest');
check('表头第一列是学号', ($m['head'][0] ?? '') === '学号', json_encode($m['head']));
check('表头第二列是姓名', ($m['head'][1] ?? '') === '姓名');
check('表头第三列起是考试名称', ($m['head'][2] ?? '') === '数学期中' && ($m['head'][3] ?? '') === '英语期中', json_encode($m['head']));
check('考试列顺序保持选择顺序', $m['exam_order'] === ['math', 'eng']);
// 学号排序:2021001,2021002,2021003,2021004
// 注意:PHP 会把数字字符串键自动转成 int('2021001' -> 2021001),排序结果本身正确,
// 这里用 strval 归一后比较排序顺序(显示层 XlsxWriter 会再转回字符串,无影响)。
$sids = array_map('strval', array_column($m['rows'], 0));
check('按学号升序', $sids === ['2021001', '2021002', '2021003', '2021004'], json_encode($sids));
// 张三:数学最新=80(重考降分,跳过草稿),英语最新=85
$zhang = $m['rows'][0];
check('张三行:姓名正确', $zhang[1] === '张三');
check('张三-数学最新=80(重考降分)', (string)$zhang[2] === '80', (string)$zhang[2]);
check('张三-英语最新=85', (string)$zhang[3] === '85', (string)$zhang[3]);
// 李四只在数学:英语为空
$li = $m['rows'][1];
check('李四-数学=70', (string)$li[2] === '70', (string)$li[2]);
check('李四-英语为空', $li[3] === '', var_export($li[3], true));
// 王五只在英语:数学为空
$wang = $m['rows'][2];
check('王五-数学为空', $wang[2] === '', var_export($wang[2], true));
check('王五-英语=60', (string)$wang[3] === '60', (string)$wang[3]);
// 赵六:数学最新跳过草稿=60
$zhao = $m['rows'][3];
check('赵六-数学最新跳过草稿=60', (string)$zhao[2] === '60', (string)$zhao[2]);
echo "== 最高成绩矩阵 ==\n";
$mh = ny_assemble_multi_exam_matrix(['math' => $examMath, 'eng' => $examEnglish], 'highest');
$zhangH = $mh['rows'][0];
check('张三-数学最高=90(含重考)', (string)$zhangH[2] === '90', (string)$zhangH[2]);
check('张三-英语最高=85', (string)$zhangH[3] === '85', (string)$zhangH[3]);
$zhaoH = $mh['rows'][3];
check('赵六-数学最高含草稿=99', (string)$zhaoH[2] === '99', (string)$zhaoH[2]);
echo "== 跨考试姓名 / 空考试 ==\n";
// 仅一场考试时仍可正常产出;姓名取首个非空
$single = ny_assemble_multi_exam_matrix(['math' => $examMath], 'latest');
check('单场考试表头正确', $single['head'] === ['学号', '姓名', '数学期中']);
check('单场考试行数=3', count($single['rows']) === 3, (string)count($single['rows']));
echo "== ny_exam_cell_score 单测 ==\n";
$r0 = stuRow('X', 'Y', [10.0, 20.0]); // latest=20, highest=20
check('latest=20', ny_exam_cell_score($r0, 'latest') === 20.0);
check('highest=20', ny_exam_cell_score($r0, 'highest') === 20.0);
$r1 = stuRow('X', 'Y', [20.0, 10.0]); // 重考降分
check('latest 取最近=10', ny_exam_cell_score($r1, 'latest') === 10.0);
check('highest 取最高=20', ny_exam_cell_score($r1, 'highest') === 20.0);
$r2 = stuRow('X', 'Y', [50.0, 88.0], [false, true]); // 草稿
check('latest 跳过草稿=50', ny_exam_cell_score($r2, 'latest') === 50.0);
check('highest 含草稿=88', ny_exam_cell_score($r2, 'highest') === 88.0);
$r3 = ['sid' => 'X', 'name' => 'Y', 'score' => 77.0, 'best_score' => 77.0]; // 无明细
check('无明细 latest 退回代表行=77', ny_exam_cell_score($r3, 'latest') === 77.0);
check('无明细 highest=77', ny_exam_cell_score($r3, 'highest') === 77.0);
/* ---------------- 「每次考试」块表(两行表头 / 合并单元格 / 未考) ---------------- */
/**
* 行内容比较:数据行是「参差不齐」的(某场次数少就没有尾巴上的格子),
* 短的一侧按空串补齐到等长再逐格比,避免因为"少几个空格子"误判。
*/
function rowEq(array $a, array $b): bool
{
$n = max(count($a), count($b));
for ($i = 0; $i < $n; $i++) {
$x = isset($a[$i]) ? (string)$a[$i] : '';
$y = isset($b[$i]) ? (string)$b[$i] : '';
if ($x !== $y) {
return false;
}
}
return true;
}
/** merges 转成 "A2:A4" 形式,便于与期望值比较(不依赖生成顺序) */function mergeRefs(array $merges): array
{
$col = static function (int $n): string {
$s = '';
while ($n > 0) {
$m = ($n - 1) % 26;
$s = chr(65 + $m) . $s;
$n = (int)(($n - $m) / 26);
}
return $s;
};
$out = [];
foreach ($merges as $m) {
$out[] = $col($m[1]) . $m[0] . ':' . $col($m[3]) . $m[2];
}
sort($out);
return $out;
}
/** 构造带班级 / 组别的学生行 */
function stuRowCG(string $sid, string $name, string $cls, string $grp, array $scoresAsc, array $tempMarks = [], ?float $best = null): array
{
$r = stuRow($sid, $name, $scoresAsc, $tempMarks, $best);
$r['cls'] = $cls;
$r['grp'] = $grp;
return $r;
}
// 版式场景完全对齐需求截图:张三 4 次数学 / 2 次英语;李四只有英语(数学「未考」)
$shotMath = ['name' => '数学', 'rows' => [
stuRow('356001', '张三', [112.0, 100.0, 136.0, 127.0], [], 136.0),
]];
$shotEng = ['name' => '英语', 'rows' => [
stuRow('356001', '张三', [112.0, 100.0], [], 112.0),
stuRow('356002', '李四', [130.0, 129.0, 133.0], [], 133.0),
]];
echo "== 每次考试块表:两行表头 / 共享第几次 / 未考(截图一版式) ==\n";
$bs = ny_assemble_block_sheet(['m' => $shotMath, 'e' => $shotEng]);
$rows = $bs['rows'];
check('表头第 1 行:学号 / 姓名 / 第几次 / 考试名(每场 1 列)',
$rows[0] === ['学号', '姓名', '第几次', '数学', '英语'],
json_encode($rows[0], JSON_UNESCAPED_UNICODE));
check('表头第 2 行:只有「成绩」小标题',
$rows[1] === ['', '', '', '成绩', '成绩'],
json_encode($rows[1], JSON_UNESCAPED_UNICODE));
check('header_rows = 2', $bs['header_rows'] === 2);
/* 每场考试固定占 1 列(历史上每场占 2 列,第 2 列是遗留空位,
表现为"每场右边多出一根空白列");「第几次」逐行写各自次序号,不纵向合并
(合并会把 2、3 吞掉,只剩首行的 1)。 */
check('张三块首行:第几次=1,两场成绩(每场 1 列)',
rowEq($rows[2], ['356001', '张三', '1', '112', '112']),
json_encode($rows[2], JSON_UNESCAPED_UNICODE));
check('张三第 2 行:第几次=2,数学 100 / 英语 100',
rowEq($rows[3], ['', '', '2', '100', '100']),
json_encode($rows[3], JSON_UNESCAPED_UNICODE));
/* 严格校验:3 个前导列 + 每场 1 列,所有行宽必须完全一致。
rowEq 会把短的一侧按空串补齐,宽松比较测不出「多一根空白列」这种回归。 */
check('每场考试只占 1 列:所有行宽一致 = 3 + 考试数',
count($rows) === 9 && !array_filter(array_map('count', $rows), static fn($n) => $n !== 5),
'列数序列 = ' . json_encode(array_map('count', $rows)));
check('张三第 3 行:第几次=3,数学 136 / 英语空',
$rows[4] === ['', '', '3', '136', ''],
json_encode($rows[4], JSON_UNESCAPED_UNICODE));
check('张三第 4 行:第几次=4,数学 127 / 英语空',
$rows[5] === ['', '', '4', '127', ''],
json_encode($rows[5], JSON_UNESCAPED_UNICODE));
check('李四块首行:数学未考 + 英语 130',
rowEq($rows[6], ['356002', '李四', '1', '未考', '130']),
json_encode($rows[6], JSON_UNESCAPED_UNICODE));
check('李四第 2 / 3 行:第几次 2、3(未考列在合并区内留空)',
$rows[7] === ['', '', '2', '', '129'] && $rows[8] === ['', '', '3', '', '133'],
json_encode([$rows[7], $rows[8]], JSON_UNESCAPED_UNICODE));
check('「第几次」逐行递增,不是被合并压成的一串 1',
array_map(static fn($r) => (string)$r[2], array_slice($rows, 2)) === ['1', '2', '3', '4', '1', '2', '3'],
json_encode(array_map(static fn($r) => $r[2], array_slice($rows, 2)), JSON_UNESCAPED_UNICODE));
check('总行数 = 9(2 行表头 + 张三 4 + 李四 3)', count($rows) === 9, (string)count($rows));
check('total_rows / students 统计正确', $bs['total_rows'] === 7 && $bs['students'] === 2,
$bs['total_rows'] . '/' . $bs['students']);
check('未启用合格行时 pass_row=false', $bs['pass_row'] === false, var_export($bs['pass_row'], true));
$refs = mergeRefs($bs['merges']);
check('合并区间:表头纵向 + 学号姓名纵向 + 未考单列纵向',
$refs === ['A1:A2', 'A3:A6', 'A7:A9', 'B1:B2', 'B3:B6', 'B7:B9', 'C1:C2', 'D7:D9'],
json_encode($refs));
check('「第几次」列没有任何纵向合并(表头 C1:C2 除外)',
!array_filter($bs['merges'], static fn($m) => (int)$m[0] > 1 && (int)$m[1] === 3 && (int)$m[3] === 3),
json_encode($bs['merges']));
check('表头第 1 行没有任何横向合并(考试名各占 1 列)',
!array_filter($bs['merges'], static fn($m) => (int)$m[0] === 1 && (int)$m[2] === 1 && $m[1] !== $m[3]),
json_encode($bs['merges']));
/* 列宽够用就好:考试列要装得下表头考试名(中文按 2 个半角宽算)。 */
check('列宽 = 学号 10 / 姓名 9 / 第几次 10 / 每场考试 1 列(按考试名估宽)',
$bs['widths'] === [10, 9, 10, 8, 8], json_encode($bs['widths']));
echo "== 每次考试块表:最高分标红 / 合格次数标绿 ==\n";
// 合格线 110:
// 张三数学 [112,100,136,127] → 112/136/127 合格 = 3
// 张三英语 [112,100] → 112 合格 = 1
// 李四英语 [130,129,133] → 130/129/133 合格 = 3
$bp = ny_assemble_block_sheet(['m' => $shotMath, 'e' => $shotEng], ['pass_line' => 110]);
$pp = $bp['rows'];
check('启用合格行后 header_rows 仍为 2', $bp['header_rows'] === 2);
check('pass_row=true 且 pass_line 回传', $bp['pass_row'] === true && $bp['pass_line'] === 110.0,
var_export($bp['pass_line'], true));
// 张三块:4 行成绩 + 1 行合格次数(第 7 行)
check('张三合格次数行:数学 3 次 / 英语 1 次',
$pp[6] === ['', '', '合格次数', '3', '1'],
json_encode($pp[6], JSON_UNESCAPED_UNICODE));
// 李四块:3 行成绩 + 1 行合格次数(第 11 行)
check('李四合格次数行:数学未考留空 / 英语 3 次',
$pp[10] === ['', '', '合格次数', '', '3'],
json_encode($pp[10], JSON_UNESCAPED_UNICODE));
check('总行数 = 11(2 + (4+1) + (3+1))', count($pp) === 11, (string)count($pp));
check('total_rows 含合格次数行 = 9', $bp['total_rows'] === 9, (string)$bp['total_rows']);
$pm = $bp['cell_styles'];
// 张三数学最高分 136 在第 5 行第 4 列 → 红字
check('张三数学最高分 136 标红', ($pm['5,4'] ?? '') === 'red', json_encode($pm));
// 张三英语最高分 112 在第 3 行第 5 列 → 红字
check('张三英语最高分 112 标红', ($pm['3,5'] ?? '') === 'red', json_encode($pm));
// 李四英语最高分 133 在第 10 行第 5 列 → 红字
check('李四英语最高分 133 标红', ($pm['10,5'] ?? '') === 'red', json_encode($pm));
// 张三合格次数(第 7 行):数学 3 达标绿底、英语 1 达标绿底
check('张三数学合格次数达标绿底', ($pm['7,4'] ?? '') === 'green', json_encode($pm));
check('张三英语合格次数达标绿底', ($pm['7,5'] ?? '') === 'green', json_encode($pm));
// 李四:数学未考 → 不标绿;英语 3 次达标绿底
check('李四数学未考不标绿', !isset($pm['11,4']), json_encode($pm));
check('李四英语合格次数达标绿底', ($pm['11,5'] ?? '') === 'green', json_encode($pm));
// 未达标(阈值 4 次)→ 仍显示实际次数(张三数学 3 / 英语 1),但不标绿
$bh = ny_assemble_block_sheet(['m' => $shotMath, 'e' => $shotEng], ['pass_line' => 110, 'pass_times' => 4]);
$hp = $bh['rows'];
check('阈值 4 次时张三仍显示实际合格次数(不隐藏)',
$hp[6] === ['', '', '合格次数', '3', '1'],
json_encode($hp[6], JSON_UNESCAPED_UNICODE));
check('阈值 4 次时不达标不标绿',
!isset($bh['cell_styles']['7,4']) && !isset($bh['cell_styles']['7,5']),
json_encode($bh['cell_styles']));
echo "== 每次考试块表:草稿 / 多班级组别列 / 空考试 ==\n";
// 赵六:数学第 2 次是草稿 → 默认跳过(不占编号)
$draftMath = ['name' => '数学', 'rows' => [
stuRow('D1', '赵六', [60.0, 99.0], [false, true], 99.0),
]];
$ds = ny_assemble_block_sheet(['m' => $draftMath]);
check('默认跳过草稿:只剩 1 次',
rowEq($ds['rows'][2], ['D1', '赵六', '1', '60']),
json_encode($ds['rows'][2], JSON_UNESCAPED_UNICODE));
check('跳过草稿后无多余行', count($ds['rows']) === 3, (string)count($ds['rows']));
$di = ny_assemble_block_sheet(['m' => $draftMath], ['skip_temp' => false]);
check('保留草稿:2 次且草稿分参与',
rowEq($di['rows'][3], ['', '', '2', '99']),
json_encode($di['rows'][3], JSON_UNESCAPED_UNICODE));
// 只有草稿的学生:跳过草稿后整场无有效成绩 → 不出现在表里
$onlyDraft = ['name' => '数学', 'rows' => [
stuRow('X1', '只交草稿', [88.0], [true], 88.0),
stuRow('X2', '正常', [70.0], [], 70.0),
]];
$od = ny_assemble_block_sheet(['m' => $onlyDraft]);
check('全草稿学生被剔除(只剩正常那位)', $od['students'] === 1, (string)$od['students']);
check('全草稿学生未写「未考」占位', rowEq($od['rows'][2], ['X2', '正常', '1', '70']),
json_encode($od['rows'][2], JSON_UNESCAPED_UNICODE));
// 多班级 / 多组别:按 班级 → 组别 → 学号 排序,连续相同班级 / 组别合并
$multi = ['name' => '数学', 'rows' => [
stuRowCG('S3', '王五', '1班', 'B', [60.0]),
stuRowCG('S4', '赵六', '2班', 'B', [50.0]),
stuRowCG('S1', '张三', '1班', 'A', [90.0, 80.0]),
stuRowCG('S2', '李四', '1班', 'A', [70.0]),
]];
$mc = ny_assemble_block_sheet(['m' => $multi], ['with_cls' => true, 'with_grp' => true]);
check('多班级表头 = 班级/组别/学号/姓名/第几次/考试名',
$mc['rows'][0] === ['班级', '组别', '学号', '姓名', '第几次', '数学'],
json_encode($mc['rows'][0], JSON_UNESCAPED_UNICODE));
check('按 班级 → 组别 → 学号 排序',
[$mc['rows'][2][2], $mc['rows'][4][2], $mc['rows'][5][2], $mc['rows'][6][2]]
=== ['S1', 'S2', 'S3', 'S4'],
json_encode([$mc['rows'][2][2], $mc['rows'][4][2], $mc['rows'][5][2], $mc['rows'][6][2]]));
check('班级 / 组别只写在学生块首行',
rowEq($mc['rows'][2], ['1班', 'A', 'S1', '张三', '1', '90']),
json_encode($mc['rows'][2], JSON_UNESCAPED_UNICODE));
$mcRefs = mergeRefs($mc['merges']);
check('相同班级 / 相同组别的单元格合并(1班 S1~S3 归一格,A 组 S1~S2 归一格)',
in_array('A3:A6', $mcRefs, true) && in_array('B3:B5', $mcRefs, true),
json_encode($mcRefs));
check('班级列位移后学号 / 姓名合并列正确(C / D 列)',
in_array('C3:C4', $mcRefs, true) && in_array('D3:D4', $mcRefs, true),
json_encode($mcRefs));
// 空考试:只出两行表头
$bEmpty = ny_assemble_block_sheet(['none' => ['name' => '无人考试', 'rows' => []]]);
check('空考试只有两行表头', count($bEmpty['rows']) === 2 && $bEmpty['students'] === 0);
check('空考试仅剩表头合并(考试名不再横向合并)',
mergeRefs($bEmpty['merges']) === ['A1:A2', 'B1:B2', 'C1:C2'],
json_encode(mergeRefs($bEmpty['merges'])));
// 考试列顺序跟随传入顺序
$bOrder = ny_assemble_block_sheet(['e' => $shotEng, 'm' => $shotMath]);
check('考试列顺序跟随传入顺序(英语在前)',
$bOrder['rows'][0][3] === '英语' && $bOrder['rows'][0][4] === '数学',
json_encode($bOrder['rows'][0], JSON_UNESCAPED_UNICODE));
echo "== 单场「每次考试」宽表(截图二版式) ==\n";
$w = ny_assemble_attempt_wide_sheet($shotEng, ['pass_line' => 110]);
check('表头 = 学号/姓名/第1次…第N次/合格次数',
$w['rows'][0] === ['学号', '姓名', '第1次', '第2次', '第3次', '合格次数'],
json_encode($w['rows'][0], JSON_UNESCAPED_UNICODE));
check('每人一行,共 2 人', count($w['rows']) === 3 && $w['students'] === 2, (string)count($w['rows']));
// 张三英语 [112,100]:只有 2 次,第 3 列留空;≥110 → 112 合格 = 1
check('张三行:112 / 100 / 空,合格 1 次',
$w['rows'][1] === ['356001', '张三', '112', '100', '', '1'],
json_encode($w['rows'][1], JSON_UNESCAPED_UNICODE));
// 李四英语 [130,129,133]:全 ≥110 = 3 次合格
check('李四行:130 / 129 / 133,合格 3 次',
$w['rows'][2] === ['356002', '李四', '130', '129', '133', '3'],
json_encode($w['rows'][2], JSON_UNESCAPED_UNICODE));
check('attempt_cols=3', $w['attempt_cols'] === 3, (string)$w['attempt_cols']);
check('最高分 112(张三第 3 列)标红', ($w['cell_styles']['2,3'] ?? '') === 'red', json_encode($w['cell_styles']));
check('最高分 133(李四第 5 列)标红', ($w['cell_styles']['3,5'] ?? '') === 'red', json_encode($w['cell_styles']));
check('张三合格次数 1 次达标绿底', ($w['cell_styles']['2,6'] ?? '') === 'green', json_encode($w['cell_styles']));
check('李四合格次数 3 次达标绿底', ($w['cell_styles']['3,6'] ?? '') === 'green', json_encode($w['cell_styles']));
// 阈值 4 次 → 都不到阈值,仍显示实际次数(1 / 3),但不标绿
$w4 = ny_assemble_attempt_wide_sheet($shotEng, ['pass_line' => 110, 'pass_times' => 4]);
check('阈值 4 次时仍显示实际合格次数(不隐藏)',
$w4['rows'][1][5] === '1' && $w4['rows'][2][5] === '3',
json_encode([$w4['rows'][1][5], $w4['rows'][2][5]], JSON_UNESCAPED_UNICODE));
check('阈值 4 次时不标绿',
!isset($w4['cell_styles']['2,6']) && !isset($w4['cell_styles']['3,6']),
json_encode($w4['cell_styles']));
// 关闭标红 / 关掉合格行
$wOff = ny_assemble_attempt_wide_sheet($shotEng, ['pass_line' => 110, 'mark_best' => false, 'pass_row' => false]);
check('mark_best=false 不标红', $wOff['cell_styles'] === [], json_encode($wOff['cell_styles']));
check('pass_row=false 表头无合格次数', !in_array('合格次数', $wOff['rows'][0], true),
json_encode($wOff['rows'][0], JSON_UNESCAPED_UNICODE));
/** 检查合并区间两两不重叠(区间重叠会让 Excel 打开时报"需要修复") */
function overlapsOf(array $merges): array
{
$bad = [];
$n = count($merges);
for ($i = 0; $i < $n; $i++) {
for ($j = $i + 1; $j < $n; $j++) {
$a = $merges[$i];
$b = $merges[$j];
if ($a[0] <= $b[2] && $b[0] <= $a[2] && $a[1] <= $b[3] && $b[1] <= $a[3]) {
$bad[] = json_encode($a) . '×' . json_encode($b);
}
}
}
return $bad;
}
echo "== 班级 / 组别列的显示规则 ==\n";
$f1 = ny_multi_col_flags(['1班'], [], ['1班'], ['A', 'B']);
check('只选 1 个班级且未选组别 → 班级 / 组别列都不出',
$f1['with_cls'] === false && $f1['with_grp'] === false, json_encode($f1));
$f2 = ny_multi_col_flags(['1班', '2班'], ['A', 'B'], ['1班', '2班'], ['A', 'B']);
check('选了多班级 + 多组别 → 两列都出',
$f2['with_cls'] === true && $f2['with_grp'] === true, json_encode($f2));
$f3 = ny_multi_col_flags(['1班'], ['A', 'B'], ['1班'], ['A', 'B']);
check('只选 1 个班级但选了多组别 → 只出组别列',
$f3['with_cls'] === false && $f3['with_grp'] === true, json_encode($f3));
$f4 = ny_multi_col_flags([], [], ['1班'], ['A']);
check('未筛选且数据只有 1 个班 / 1 个组 → 两列都不出',
$f4['with_cls'] === false && $f4['with_grp'] === false, json_encode($f4));
$f5 = ny_multi_col_flags([], [], ['1班', '2班', '3班'], ['A', 'B']);
check('未筛选且数据跨 3 个班 → 按数据补出班级 / 组别列',
$f5['with_cls'] === true && $f5['with_grp'] === true, json_encode($f5));
$f6 = ny_multi_col_flags(['1班', '2班'], ['A'], ['1班', '2班'], ['A']);
check('多班级 + 只选 1 个组别 → 只出班级列',
$f6['with_cls'] === true && $f6['with_grp'] === false, json_encode($f6));
echo "== 合并区间不重叠(防 Excel 报修复) ==\n";
check('截图版式:无重叠', overlapsOf($bs['merges']) === [], json_encode(overlapsOf($bs['merges'])));
check('多班级多组别:无重叠', overlapsOf($mc['merges']) === [], json_encode(overlapsOf($mc['merges'])));
check('单场导出(带班级组别列):无重叠',
overlapsOf(ny_assemble_block_sheet(['m' => $multi], ['with_cls' => true])['merges']) === []);
check('草稿模式:无重叠',
overlapsOf(ny_assemble_block_sheet(['m' => $draftMath], ['skip_temp' => false])['merges']) === []);
echo "\n通过 {$pass} 项,失败 {$fail} 项\n";
exit($fail === 0 ? 0 : 1);
+85
View File
@@ -0,0 +1,85 @@
<?php
/**
* 新功能回归测试:
* 1) 成绩明细排序:再次提交应置顶(last_submit_ts 排序)
* 2) XlsxWriter 多工作表 renderMulti(分表 + 名字去重/截断)
* 3) 名单导入列识别 rowsToStudents(表头识别 / 去重 / Excel 数字尾巴)
*/
declare(strict_types=1);
require __DIR__ . '/../bootstrap.php';
$pass = 0;
$fail = 0;
function check(string $name, bool $cond, string $detail = ''): void
{
global $pass, $fail;
if ($cond) { $pass++; echo " ✅ {$name}\n"; }
else { $fail++; echo " ❌ {$name} {$detail}\n"; }
}
/* ============ 3) 排序:再次提交置顶 ============ */
echo "\n== 排序(last_submit_ts 置顶)==\n";
// 甲:代表成绩=最高分(98,早提交);最近一次提交=重考 70(晚提交)→ last_submit_ts 更晚,应排在乙前
$aggRows = [
['sid' => 'S01', 'name' => '甲', 'score' => 98.0, 'submit_ts' => 1000, 'last_submit_ts' => 5000, 'last_submit_at' => '2026-09-10 12:00:00'],
['sid' => 'S02', 'name' => '乙', 'score' => 90.0, 'submit_ts' => 2000, 'last_submit_ts' => 2000, 'last_submit_at' => '2026-09-10 10:00:00'],
];
$sortTs = static fn(array $r): int => (int)($r['last_submit_ts'] ?? 0) ?: (int)($r['submit_ts'] ?? 0);
usort($aggRows, static function (array $x, array $y) use ($sortTs): int {
$d = $sortTs($y) <=> $sortTs($x);
return $d !== 0 ? $d : strcmp((string)($x['sid'] ?? ''), (string)($y['sid'] ?? ''));
});
check('重考后的甲(last_submit_ts=5000)排在乙前', $aggRows[0]['sid'] === 'S01', json_encode($aggRows, JSON_UNESCAPED_UNICODE));
/* ============ 2) XlsxWriter 多工作表 ============ */
echo "\n== XlsxWriter 多工作表 ==\n";
$sheets = [
['name' => '1班', 'rows' => [['学号', '姓名'], ['S01', '甲']], 'widths' => [16, 14]],
['name' => '2班', 'rows' => [['学号', '姓名'], ['S02', '乙']], 'widths' => [16, 14]],
['name' => '成绩明细', 'rows' => [['学号', '成绩'], ['S03', 90]], 'widths' => [16, 11]],
['name' => '成绩明细', 'rows' => [['学号', '成绩'], ['S04', 80]], 'widths' => [16, 11]], // 重名 → 应去重
['name' => str_repeat('长', 40), 'rows' => [['X'], [1]], 'widths' => [10]], // 超长名 → 截断
];
$bin = XlsxWriter::renderMulti($sheets);
check('renderMulti 产出 PK ZIP', substr($bin, 0, 2) === 'PK');
$names = [];
if (class_exists('ZipArchive')) {
$tmp = __DIR__ . '/_multi.xlsx';
file_put_contents($tmp, $bin);
$z = new ZipArchive();
if ($z->open($tmp) === true) {
for ($i = 0; $i < $z->numFiles; $i++) {
$n = $z->getNameIndex($i);
if (strpos($n, 'xl/worksheets/sheet') === 0) {
$names[] = $n;
}
}
$z->close();
}
@unlink($tmp);
}
check('产出 5 个工作表文件', count($names) === 5, 'got ' . count($names) . ': ' . json_encode($names));
check('sheet 文件名连续 sheet1..sheet5', in_array('xl/worksheets/sheet5.xml', $names, true));
/* ============ 3) 名单导入列识别 ============ */
echo "\n== 名单导入列识别 ==\n";
$meta = [];
$parsed = Roster::rowsToStudents([
['学号', '姓名', '班级', '组别', '登陆账号'],
['2024012001.0', '张三', '1班', 'A', 'stu001'],
['2024012002', '李四', '1班', 'B', 'stu002'],
['2024012001', '张三重复', '1班', 'A', 'stu001'], // 学号重复 → 跳过
['', ''], // 空行 → 跳过
], $meta);
check('识别到 2 人(重复学号与空行被跳过)', count($parsed) === 2, json_encode($parsed, JSON_UNESCAPED_UNICODE));
check('学号去掉 Excel 数字尾巴 .0', ($parsed[0]['sid'] ?? '') === '2024012001', (string)($parsed[0]['sid'] ?? ''));
check('识别班级 / 组别 / 登陆账号列',
($parsed[0]['cls'] ?? '') === '1班' && ($parsed[0]['grp'] ?? '') === 'A' && ($parsed[0]['login'] ?? '') === 'stu001',
json_encode($parsed[0] ?? [], JSON_UNESCAPED_UNICODE));
check('重复学号记入 duplicates', (int)($meta['duplicates'] ?? 0) === 1, (string)($meta['duplicates'] ?? ''));
check('空行记入 skipped', (int)($meta['skipped'] ?? 0) === 1, (string)($meta['skipped'] ?? ''));
echo "\n========================================\n";
echo "通过 {$pass} · 失败 {$fail}\n";
exit($fail === 0 ? 0 : 1);
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env node
/**
* 文档站本地预览服务
*
* 站点是按 /ny-exam-platform 子路径构建的(base: '/ny-exam-platform/'),
* 直接把 dist 目录当根来跑静态服务会导致所有资源 404。
* 这里做一层前缀重写:把 /ny-exam-platform/<path> 映射到 dist 目录下的 <path>,
* 根路径 / 自动跳转到 /ny-exam-platform/。
*
* 用法:
* node tools/docs-preview.js [端口] [dist目录]
* node tools/docs-preview.js 5199 docs/.vitepress/dist-final
*/
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = parseInt(process.argv[2], 10) || 5199;
const DIST = path.resolve(process.argv[3] || path.join(__dirname, '..', 'docs', '.vitepress', 'dist-final'));
const BASE = (process.env.DOCS_BASE || '/ny-exam-platform').replace(/\/+$/, '');
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.txt': 'text/plain; charset=utf-8',
'.map': 'application/json; charset=utf-8',
};
if (!fs.existsSync(DIST)) {
console.error('[docs-preview] 找不到构建产物:' + DIST);
console.error('请先构建:DOCS_OUT=.vitepress/dist-final npx vitepress build .');
process.exit(1);
}
/** 把 URL 中的前缀剥掉,得到 dist 内的相对路径 */
function toRel(urlPath) {
let p = decodeURIComponent(urlPath.split('?')[0].split('#')[0]);
if (p === BASE || p === BASE + '/') return '/index.html';
if (p.startsWith(BASE + '/')) p = p.slice(BASE.length);
return p;
}
/** 依次尝试:原样 / .html / 目录下 index.html,模拟静态托管的行为 */
function resolveFile(rel) {
const clean = path.normalize(rel).replace(/^([/\\])+/, '');
const full = path.join(DIST, clean);
if (full.indexOf(DIST) !== 0) return null; // 防目录穿越
const candidates = [full, full + '.html', path.join(full, 'index.html')];
for (const c of candidates) {
try {
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
} catch (e) { /* 忽略 */ }
}
return null;
}
const server = http.createServer((req, res) => {
const urlPath = req.url || '/';
// 根路径 / 直接跳到子路径首页
if (urlPath === '/' || urlPath === '') {
res.writeHead(302, { Location: BASE + '/' });
res.end();
return;
}
const rel = toRel(urlPath);
const file = resolveFile(rel);
if (!file) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('404 Not Found: ' + urlPath);
return;
}
const ext = path.extname(file).toLowerCase();
res.writeHead(200, {
'Content-Type': MIME[ext] || 'application/octet-stream',
'Cache-Control': 'no-cache',
});
fs.createReadStream(file).pipe(res);
});
server.listen(PORT, '127.0.0.1', () => {
console.log('[docs-preview] 文档站已启动');
console.log(' 访问地址:http://127.0.0.1:' + PORT + BASE + '/');
console.log(' 构建目录:' + DIST);
});
server.on('error', e => {
console.error('[docs-preview] 启动失败:' + e.message);
process.exit(1);
});
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* 修复 / 重置系统连接配置(终端一次性工具)
*
* 背景:
* 连接配置同时存在于 data/ny.db(SQLite,加载时优先)与 data/config.json(文件备份)。
* ny_load_config() 以 DB 为准,因此一旦部署实例的 ny.db 被 e2e 测试串味或误改写成
* 127.0.0.1 / mock 地址,登录页就会显示「系统连接信息已配置(127.0.0.1/api)」且连不上真实卷王。
* 本工具通过 ny_save_config 双写,一次性把两处都纠正回正确的生产地址。
*
* 用法:
* php tools/fix-config.php [--base-url=https://exam.nxxy335.top] [--api-prefix=/api] [--keep-creds]
*
* 默认行为:
* - base_url 修正为 https://exam.nxxy335.top(可用 --base-url 覆盖)
* - api_prefix 修正为 /api
* - 账号/密码清空(绝不臆造真实卷王凭据),需在登录页重新输入真实账号
* - 加 --keep-creds 则保留现有账号密码不动
*
* 测试隔离:可用 NY_DATA=/tmp/xxx 指向临时目录,不影响生产库。
*/
declare(strict_types=1);
require __DIR__ . '/../bootstrap.php';
$args = array_slice($argv, 1);
$baseUrl = 'https://exam.nxxy335.top';
$apiPrefix = '/api';
$keepCreds = false;
foreach ($args as $a) {
if (strncmp($a, '--base-url=', 11) === 0) {
$baseUrl = substr($a, 11);
} elseif (strncmp($a, '--api-prefix=', 13) === 0) {
$apiPrefix = substr($a, 13);
} elseif ($a === '--keep-creds') {
$keepCreds = true;
} elseif ($a === '--help' || $a === '-h') {
echo "用法: php tools/fix-config.php [--base-url=URL] [--api-prefix=/api] [--keep-creds]\n";
exit(0);
}
}
$cfg = ny_load_config();
$before = (string)($cfg['base_url'] ?? '');
$cfg['base_url'] = rtrim(trim($baseUrl), '/');
$cfg['api_prefix'] = '/' . ltrim(trim($apiPrefix), '/');
if (!$keepCreds) {
$cfg['username'] = '';
$cfg['password'] = '';
}
$ok = ny_save_config($cfg);
if (!$ok) {
fwrite(STDERR, "保存失败:DB 与文件均不可写?请检查 " . NY_DATA . " 目录权限。\n");
exit(1);
}
$host = (string) parse_url($cfg['base_url'], PHP_URL_HOST);
echo "修复前 base_url = " . ($before === '' ? '(空)' : $before) . "\n";
echo "修复后 base_url = " . $cfg['base_url'] . "\n";
echo "api_prefix = " . $cfg['api_prefix'] . "\n";
echo "登录页将显示 = 系统连接信息已配置(" . $host . $cfg['api_prefix'] . ")\n";
echo "账号/密码 = " . ($keepCreds ? '保持不变' : '已清空(请在登录页重新输入真实卷王账号)') . "\n";
echo "配置来源 = " . ny_config_source() . "\n";
echo "OK\n";
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env node
/**
* 生成数据大屏首页的可打开预览:docs/samples/dash-home-preview.html
*
* 关键点与 make-samples.js 一致——卡片**直接调用 dashboard.php 里生产环境的 clsCardHtml()**,
* 不是另写一份渲染逻辑,所以预览长什么样,线上首页就长什么样,不会两边漂移。
* 数据取自本机 mock-server(tools/mock-server.js)+ PHP 内置服务器,名单与答卷都是真跑出来的。
*
* 用法:node tools/make-dash-sample.js
*/
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const http = require('http');
const { spawn } = require('child_process');
const ROOT = path.join(__dirname, '..');
const PORT = 18095;
const INI = process.env.PHP_INI || '';
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
/* ---------- 临时数据目录:绝不动项目 data/ ---------- */
let p = process.env.NY_DATA || path.join(os.tmpdir(), 'ny-dash-sample-' + process.pid);
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2);
const DATA = path.resolve(p);
process.env.NY_DATA = DATA;
fs.mkdirSync(DATA, { recursive: true });
fs.writeFileSync(path.join(DATA, 'config.json'), JSON.stringify({
base_url: 'http://127.0.0.1:19911',
api_prefix: '/api',
username: '', password: '',
verify_ssl: false, timeout: 10,
refresh_interval: 15, page_size: 50,
dedupe: true, include_temp_save: false,
name_keywords: '姓名,名字',
full_score: 100, layout: 'default',
}, null, 2));
/* 名单:mock 的「卷内参与者」只放行 90001-90005,应考名单会被收敛到这几人,
所以把 5 人分散到 4 个班,其余人做无答卷的填充(让卡片同时能看到缺考数)。 */
const STUDENTS = [];
[['90001', '高一(1)班'], ['90002', '高一(2)班'], ['90003', '高一(3)班'],
['90004', '高一(4)班'], ['90005', '高一(1)班']].forEach(([sid, cls]) =>
STUDENTS.push({ sid, name: sid, cls, grp: 'A组', login: '' }));
['高一(1)班', '高一(2)班', '高一(3)班', '高一(4)班'].forEach((cls, ci) => {
for (let i = 1; i <= 11; i++) {
const sid = String(9200 + ci * 100 + i);
STUDENTS.push({ sid, name: sid, cls, grp: i % 2 ? 'A组' : 'B组', login: '' });
}
});
fs.writeFileSync(path.join(DATA, 'roster.json'), JSON.stringify({ students: STUDENTS, updated_at: '2026-01-01 00:00:00' }));
let mock = null, srv = null, cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
try { srv && srv.kill('SIGKILL'); } catch (e) {}
try { mock && mock.kill('SIGKILL'); } catch (e) {}
try { fs.rmSync(DATA, { recursive: true, force: true }); } catch (e) {}
}
process.on('exit', cleanup);
process.on('uncaughtException', e => { console.error('FATAL', e); cleanup(); process.exit(2); });
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'ignore' });
srv = spawn(PHP, INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]), { cwd: ROOT, stdio: 'ignore' });
/* ---------- 极简 HTTP ---------- */
const jar = [];
function collect(res) {
for (const sc of (res.headers['set-cookie'] || [])) {
const pair = sc.split(';')[0];
const i = jar.findIndex(x => x.startsWith(pair.split('=')[0] + '='));
if (i >= 0) jar[i] = pair; else jar.push(pair);
}
}
const cookie = () => (jar.length ? jar.join('; ') : '');
function req(method, p) {
return new Promise((resolve, reject) => {
const h = { 'Accept': 'application/json' };
if (cookie()) h['Cookie'] = cookie();
const r = http.request({ host: '127.0.0.1', port: PORT, method, path: p, headers: h }, res => {
const bufs = [];
res.on('data', c => bufs.push(c));
res.on('end', () => { collect(res); resolve({ raw: Buffer.concat(bufs).toString('utf8') }); });
});
r.on('error', reject);
r.end();
});
}
// 登录必须走表单编码(login.php 读 $_POST),只发一个空 POST 是登不上的
function formPost(p, fields) {
return new Promise((resolve, reject) => {
const body = new URLSearchParams(fields).toString();
const h = { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body) };
if (cookie()) h['Cookie'] = cookie();
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path: p, headers: h }, res => {
const bufs = [];
res.on('data', c => bufs.push(c));
res.on('end', () => { collect(res); resolve({ raw: Buffer.concat(bufs).toString('utf8') }); });
});
r.on('error', reject);
r.write(body); r.end();
});
}
const delay = ms => new Promise(r => setTimeout(r, ms));
/* ---------- 抽出生产环境的卡片渲染函数 ---------- */
const dashSrc = fs.readFileSync(path.join(ROOT, 'dashboard.php'), 'utf8');
const m = dashSrc.match(/function clsCardHtml\(c\)\{[\s\S]*?\n\}/);
if (!m) { console.error('未能从 dashboard.php 抽取 clsCardHtml'); cleanup(); process.exit(1); }
const esc = s => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const dv = (v, d) => (v === undefined || v === null || v === '' ? (d === undefined ? '-' : d) : v);
const pv = v => dv(v, '-') + (v === undefined || v === null || v === '' ? '' : '%');
const rateColor = r => (r >= 90 ? 'var(--ok)' : (r >= 60 ? 'var(--warn)' : 'var(--danger)'));
const clsCardHtml = new Function('esc', 'dv', 'pv', 'rateColor', m[0] + ' return clsCardHtml;')(esc, dv, pv, rateColor);
(async () => {
await delay(2200);
await formPost('/login.php', { username: 'mock', password: 'surveyking' }); // 顺带把账密写进临时配置
const pr = await req('GET', '/api.php?action=projects');
const projects = ((JSON.parse(pr.raw).data) || []).filter(x => x.id && x.id !== 'p2');
if (!projects.length) { console.error('mock 没返回可用问卷,原始响应:\n' + pr.raw.slice(0, 800)); cleanup(); process.exit(1); }
const project = projects[0];
const dr = await req('GET', '/api.php?action=dashboard&projectId=' + encodeURIComponent(project.id));
const data = JSON.parse(dr.raw).data || {};
const classes = data.classes || [];
if (!classes.length) { console.error('dashboard 没返回班级,名单是否为空?'); cleanup(); process.exit(1); }
const cards = classes.map(clsCardHtml).join('');
const projectName = project.name || project.id;
const html = [
'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">',
'<title>数据大屏首页(预览)</title>',
'<meta name="viewport" content="width=device-width, initial-scale=1">',
'<link rel="stylesheet" href="../../assets/style.css">',
'<style>',
'/* 预览页专属:补上 hero 里样式类所需的小件(原页面由 dashboard.php 内联给出) */',
'.brand-text{display:flex;flex-direction:column;gap:2px}',
'.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--border-2);vertical-align:1px}',
'.dot.on{background:var(--ok)}',
'.dot.stale{background:var(--danger)}',
'.spacer{flex:1}',
'.preview-tip{margin:12px 0 0;padding:8px 12px;border:1px dashed var(--border);border-radius:var(--radius);',
' font-size:12px;color:var(--text-3);background:var(--panel-weak,#f7f8fc)}',
'</style></head>',
'<body class="dash dash-home">',
'<div class="wrap">',
' <div class="dash-hero">',
' <div class="brand"><h1 class="brand-title">NY问卷考试系统数据平台</h1></div>',
' <div class="dash-controls">',
' <select id="projectSel"><option value="' + esc(project.id) + '">' + esc(project.name) +
'(' + project.total + ' 份)</option></select>',
' <span id="classSelWrap"><select id="classSel" disabled><option value="">请先选问卷</option></select></span>',
' <label class="switch"><input type="checkbox" id="autoRefresh" checked><span></span></label>',
' <select id="intervalSel"><option value="15" selected>15 秒</option></select>',
' <button class="btn" id="btnRefresh">刷新</button>',
' <button class="btn" id="btnFullscreen">⛶ 全屏</button>',
' <a class="btn back" id="linkIndex" href="index.php">← 主系统</a>',
' <a class="btn back" id="linkLogout" href="logout.php">退出</a>',
' </div>',
' </div>',
' <div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;flex-wrap:wrap;">',
' <span id="heroSub">共 ' + classes.length + ' 个班级 · 点击卡片进入该班大屏</span>',
' <span class="spacer"></span>',
' 最后更新:<span id="lastUpdate">' + esc(data.updated_at || '-') + '</span><i id="liveDot" class="dot on"></i>',
' </div>',
' <div class="preview-tip">静态预览:这张页面由生产环境 dashboard.php 的 <code>clsCardHtml()</code> 渲染,',
' 数据取自 mock 卷王。真实站点上点卡片会整页跳转到 <code>dashboard.php?pid=…&amp;class=…</code>,',
' 进入该班独立大屏(那里不再有班级选择、返回主系统与退出,浏览器后退键也被锁住)。</div>',
' <div id="board">',
' <section class="cards" id="boardSummary">',
' <div class="card accent-blue"><span>总人数</span><b>0</b></div>',
' <div class="card ok accent-green"><span>已提交</span><b>0</b></div>',
' <div class="card warn accent-orange"><span>缺考</span><b>0</b></div>',
' <div class="card accent-teal"><span>平均分</span><b>-</b></div>',
' <div class="card accent-indigo"><span>合格率</span><b>-</b></div>',
' </section>',
' <div class="sec-title" id="boardTitle">班级入口 <span class="tag-gray">点击卡片进入该班独立大屏</span></div>',
' <div id="boardBody"><div class="cls-grid">' + cards + '</div></div>',
' </div>',
'</div>',
'<script>/* 预览页:把"全屏/刷新"按钮做成 plainly 不可点,仅示意版式 */',
'document.addEventListener(\'DOMContentLoaded\', function () {',
' ["#btnFullscreen", "#btnRefresh", "#projectSel", "#classSelWrap"].forEach(function (s) {',
' var el = document.querySelector(s); if (el) el.setAttribute("disabled", "disabled");',
' });',
'});</script>',
'</body></html>',
].join('\n');
const out = path.join(ROOT, 'docs', 'samples', 'dash-home-preview.html');
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, html, 'utf8');
console.log('已生成 ' + path.relative(ROOT, out));
console.log(' 问卷:' + projectName + ' 班级:' + classes.map(c => c.class).join('、'));
cleanup();
process.exit(0);
})();
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env node
/**
* 把 tools/make-samples.php 产出的数据渲染成可打开的样例页面。
*
* 关键点:页面里的表格**直接调用 assets/app.js 里生产环境的 sheetToHtml()**——
* 不是另写一份渲染逻辑,所以预览长什么样,打印就长什么样,不会两边漂移。
* 配色 / 字号沿用 printDoc() 里打印用的那套样式。
*
* 用法:node tools/make-samples.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const data = JSON.parse(fs.readFileSync(path.join(ROOT, 'docs', 'samples', 'sheet-data.json'), 'utf8'));
/* ---- 抽出生产环境的 sheetToHtml ---- */
const appSrc = fs.readFileSync(path.join(ROOT, 'assets', 'app.js'), 'utf8');
const m = appSrc.match(/function sheetToHtml\(sheet\)\s*\{[\s\S]*?\n \}/);
if (!m) {
console.error('未能从 assets/app.js 抽取 sheetToHtml');
process.exit(1);
}
const esc = s => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// eslint-disable-next-line no-new-func
const sheetToHtml = new Function('esc', 'return ' + m[0])(esc);
/* ---- 沿用 printDoc() 里的打印配色 ---- */
const PRINT_CSS = [
'body{font-family:"Microsoft YaHei","PingFang SC",sans-serif;color:#222;background:#f7f7f7;margin:0;padding:24px}',
'.page{background:#fff;border:1px solid #e3e3e3;border-radius:6px;padding:20px 22px;margin:0 auto 20px;max-width:1100px}',
'.page h2{font-size:16px;margin:0 0 4px}',
'.page .sub{font-size:12px;color:#777;margin:0 0 14px}',
// 与 printDoc() 里的打印样式保持一致(表头渲染成 <td>,所以底色写 thead td)
'table.bt{border-collapse:collapse;border:1px solid #999;width:100%;background:#fff;font-size:11px}',
'table.bt td,table.bt th{border:1px solid #999;text-align:center;padding:3px 5px}',
'table.bt th,table.bt thead td{background:#eaeaea;font-weight:700}',
'table.bt thead tr:first-child td{background:#eaeaea}',
'table.bt td.absent{color:#888}',
'table.bt td.max{color:#d0021b;font-weight:700}',
'table.bt td.pass{background:#63c384;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}',
'html,body,.pass,.absent,.max{-webkit-print-color-adjust:exact;print-color-adjust:exact}',
'.legend{margin:10px 0 0;font-size:12px;color:#555}',
'.legend i{display:inline-block;width:11px;height:11px;vertical-align:-1px;margin:0 4px 0 12px}',
'.legend .r{color:#d0021b;font-weight:700}',
'.legend .g{background:#63c384;font-weight:700}',
'.sign{margin-top:16px;font-size:12px}',
].join('');
function render(item) {
const s = item.sheet;
const table = sheetToHtml(s);
const meta = item.meta || {};
const title = esc(item.title);
const sub = meta.exam_name
? esc(meta.exam_name) + ' · 合格线 ' + meta.pass_line + ' 分,达 ' + meta.pass_times + ' 次标绿'
: '合格线 ' + meta.pass_line + ' 分,达 ' + meta.pass_times + ' 次标绿';
return [
'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">',
'<title>' + title + '(预览)</title><style>' + PRINT_CSS + '</style></head><body>',
'<div class="page"><h2>' + title + '</h2><p class="sub">' + sub + '</p>',
table,
'<p class="legend">标记说明:<span class="r">最高分</span> 标红、'
+ '<i class="r">红字</i>;<span class="g">合格次数</span> 始终显示(含 0 次),'
+ '达 ' + meta.pass_times + ' 次标绿、<i class="g">绿底</i>;未达标显示次数、不标绿。',
'<p class="sign">教师签字:______________  教研组长签字:______________  日期:____________</p>',
'</div></body></html>',
].join('\n');
}
const outDir = path.join(ROOT, 'docs', 'samples');
fs.writeFileSync(path.join(outDir, 'preview-multi-block.html'), render(data.block));
fs.writeFileSync(path.join(outDir, 'preview-single-wide.html'), render(data.wide));
console.log('已生成:');
console.log(' docs/samples/preview-multi-block.html (多考试块表)');
console.log(' docs/samples/preview-single-wide.html (单场宽表)');
+127
View File
@@ -0,0 +1,127 @@
<?php
/**
* 生成「导出 / 打印」新版式的可打开样例:
* 1) docs/samples/export-multi-block.xlsx —— 多考试块表(两行表头 + 合格次数 + 红绿标记)
* 2) docs/samples/export-single-wide.xlsx —— 单场宽表(每人一行、第几次做列名)
* 3) docs/samples/sheet-data.json —— 供 make-samples.js 渲染预览 HTML
*
* 数据是一次性写死的示例数据(与 tests/test-multi-export.php 的截图场景同一套口径),
* 不是真实导出结果,仅用于看版式。
*
* 运行:php tools/make-samples.php
*/
declare(strict_types=1);
require __DIR__ . '/../bootstrap.php';
require __DIR__ . '/../lib/multi_export.php';
/** 构造一名学生的若干次考试记录(成绩按时间升序) */
function sample_stu(string $sid, string $name, float $best, array $scoresAsc, array $tempMarks = []): array
{
$list = [];
$t = 1000000;
foreach ($scoresAsc as $i => $sc) {
$t += 600;
$list[] = [
'idx' => $i + 1,
'submit_ts' => $t,
'score' => $sc,
'temp_save' => !empty($tempMarks[$i]),
'duration' => 120,
];
}
return [
'sid' => $sid,
'name' => $name,
'cls' => '1班',
'grp' => 'A',
'score' => end($scoresAsc),
'best_score' => $best,
'attempts' => count($scoresAsc),
'attempts_list' => $list,
];
}
/* ---- 示例数据:与需求截图同口径(合格线 110,阈值 1 次) ---- */
$exams = [
// 「未考」的口径是「整场考试里根本没有这个学号」——不是给个空成绩,
// 所以李四整场从数学的 rows 里去掉(写在 rows 里只会渲染成空白单元格)。
'm' => ['name' => '第一次月考·数学', 'rows' => [
sample_stu('356001', '张三', 136.0, [112.0, 100.0, 136.0, 127.0]),
sample_stu('356003', '王五', 95.0, [95.0]), // 未达合格线 → 不标绿
]],
'e' => ['name' => '第一次月考·英语', 'rows' => [
sample_stu('356001', '张三', 112.0, [112.0, 99.0]),
sample_stu('356002', '李四', 133.0, [130.0, 129.0, 133.0]),
]],
];
$opts = [
'pass_line' => 110, // 本卷满分 130 × 合格率 85%
'pass_times' => 1, // 达标阈值
'mark_best' => true, // 最高分标红
'pass_row' => true, // 输出「合格次数」行
];
/* ---- 1) 多考试块表 ---- */
$block = ny_assemble_block_sheet($exams, $opts);
$blockBin = XlsxWriter::renderMulti([[
'name' => '每次考试',
'rows' => $block['rows'],
'widths' => $block['widths'] ?? [],
'merges' => $block['merges'],
'header_rows' => $block['header_rows'],
'cell_styles' => $block['cell_styles'] ?? [],
]]);
$blockPath = __DIR__ . '/../docs/samples/export-multi-block.xlsx';
file_put_contents($blockPath, $blockBin);
/* ---- 2) 单场宽表(只导出一场考试时的版式) ---- */
$wideRes = ny_assemble_attempt_wide_sheet($exams['m'], $opts);
$wideBin = XlsxWriter::renderMulti([[
'name' => '每次考试',
'rows' => $wideRes['rows'],
'widths' => $wideRes['widths'] ?? [],
'merges' => $wideRes['merges'],
'header_rows' => $wideRes['header_rows'],
'cell_styles' => $wideRes['cell_styles'] ?? [],
]]);
$widePath = __DIR__ . '/../docs/samples/export-single-wide.xlsx';
file_put_contents($widePath, $wideBin);
/* ---- 3) 交给 Node 渲染预览 HTML(用 app.js 里的 sheetToHtml,避免两份实现漂移) ---- */
$data = [
'block' => [
'title' => '多考试导出 · 每次考试(两行表头)',
'sheet' => [
'rows' => $block['rows'],
'merges' => $block['merges'],
'header_rows' => $block['header_rows'],
'cell_styles' => $block['cell_styles'] ?? [],
],
'meta' => [
'pass_line' => $opts['pass_line'],
'pass_times' => $opts['pass_times'],
],
],
'wide' => [
'title' => '单场导出 · 每次考试(每人一行的宽表)',
'sheet' => [
'rows' => $wideRes['rows'],
'merges' => $wideRes['merges'],
'header_rows' => $wideRes['header_rows'],
'cell_styles' => $wideRes['cell_styles'] ?? [],
],
'meta' => [
'pass_line' => $opts['pass_line'],
'pass_times' => $opts['pass_times'],
'exam_name' => $exams['m']['name'],
],
],
];
file_put_contents(__DIR__ . '/../docs/samples/sheet-data.json', json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
echo '块表:' . count($block['rows']) . ' 行 / ' . count($block['merges']) . ' 处合并 / '
. count($block['cell_styles'] ?? []) . " 处标记 → " . realpath($blockPath) . PHP_EOL;
echo '宽表:' . count($wideRes['rows']) . ' 行 / ' . count($wideRes['merges']) . ' 处合并 / '
. count($wideRes['cell_styles'] ?? []) . " 处标记 → " . realpath($widePath) . PHP_EOL;
echo '预览数据:' . realpath(__DIR__ . '/../docs/samples/sheet-data.json') . PHP_EOL;
+46 -3
View File
@@ -13,7 +13,30 @@ const PUB_B64 = Buffer.from(publicKey).toString('base64');
const PASSWORD = 'surveyking'; const PASSWORD = 'surveyking';
let TOKEN = ''; let TOKEN = '';
const roster = JSON.parse(fs.readFileSync(require('path').join(__dirname, '..', 'data', 'roster.json'), 'utf8')).students; /* 名单:优先用 data/roster.json(真实名单,答卷也按它生成)。
没有该文件时用内置样例名单兜底,免得整个 e2e 因为缺文件连不上——
mock 只关心名单的条数与姓名,真实字段由 demoRoster() 补齐。 */
function demoRoster() {
const out = [];
const classes = ['高一(1)班', '高一(2)班'];
const groups = ['A组', 'B组'];
for (let i = 1; i <= 40; i++) {
// 名字必须与下方 PARTNERS 的 userName 一致,否则答卷匹配不到名单
const id = String(90000 + i);
out.push({ sid: id, name: id, cls: classes[i % 2], grp: groups[i % 2] });
}
return out;
}
function loadRoster() {
const p = require('path').join(__dirname, '..', 'data', 'roster.json');
try {
const list = JSON.parse(fs.readFileSync(p, 'utf8')).students;
if (Array.isArray(list) && list.length) return { list, from: 'data/roster.json' };
} catch (e) { /* 没有名单文件,走内置兜底 */ }
return { list: demoRoster(), from: '内置样例名单(data/roster.json 不存在)' };
}
const ROSTER = loadRoster();
const roster = ROSTER.list;
// 构造答卷:前 30 人提交,其中 2 人姓名有空格/错别字 // 构造答卷:前 30 人提交,其中 2 人姓名有空格/错别字
const answers = roster.slice(0, 30).map((s, i) => { const answers = roster.slice(0, 30).map((s, i) => {
@@ -43,6 +66,7 @@ answers.push({
const SURVEY = { const SURVEY = {
id: 'root', title: '高一数学月考', type: 'survey', id: 'root', title: '高一数学月考', type: 'survey',
surveySetting: {},
children: [ children: [
{ id: 'pg1', type: 'pagination', title: '基本信息', children: [ { id: 'pg1', type: 'pagination', title: '基本信息', children: [
{ id: 'q1', type: 'text', title: '<p>姓名</p>' }, { id: 'q1', type: 'text', title: '<p>姓名</p>' },
@@ -59,6 +83,19 @@ const PROJECTS = [
{ id: 'p2', name: '课后满意度调查', mode: 'survey', status: 1, total: 5 }, { id: 'p2', name: '课后满意度调查', mode: 'survey', status: 1, total: 5 },
]; ];
// p1 的「受访者」参与者 = 应考名单(真实卷王模型:t_project_partner type=RESPONDENT_IMP_USER=3)
// status: 0未访问 / 1已访问未答 / 2已答题
const PARTNERS = {
p1: [
{ id: 'pp1', type: 3, userName: '90001', userId: '90001', status: 0 },
{ id: 'pp2', type: 3, userName: '90002', userId: '90002', status: 0 },
{ id: 'pp3', type: 3, userName: '90003', userId: '90003', status: 0 },
{ id: 'pp4', type: 3, userName: '90004', userId: '90004', status: 0 },
{ id: 'pp5', type: 3, userName: '90005', userId: '90005', status: 0 },
],
p2: [],
};
function send(res, code, obj, headers) { function send(res, code, obj, headers) {
res.writeHead(code, Object.assign({ 'Content-Type': 'application/json; charset=utf-8' }, headers || {})); res.writeHead(code, Object.assign({ 'Content-Type': 'application/json; charset=utf-8' }, headers || {}));
res.end(JSON.stringify(obj)); res.end(JSON.stringify(obj));
@@ -100,7 +137,7 @@ const server = http.createServer((req, res) => {
Buffer.from(pwd, 'base64') Buffer.from(pwd, 'base64')
).toString('utf8'); ).toString('utf8');
} catch (e) { plain = '<<解密失败>>'; } } catch (e) { plain = '<<解密失败>>'; }
if (j.username !== 'admin' || plain !== PASSWORD) { if (j.username !== 'mock' || plain !== PASSWORD) {
return send(res, 200, { code: 500, data: null, msg: '账号或密码错误(收到明文=' + plain + ')' }); return send(res, 200, { code: 500, data: null, msg: '账号或密码错误(收到明文=' + plain + ')' });
} }
TOKEN = 'mock.jwt.token.' + Date.now(); TOKEN = 'mock.jwt.token.' + Date.now();
@@ -128,11 +165,17 @@ const server = http.createServer((req, res) => {
const slice = all.slice((cur - 1) * size, cur * size); const slice = all.slice((cur - 1) * size, cur * size);
return send(res, 200, { code: 0, data: { total: all.length, list: slice } }); return send(res, 200, { code: 0, data: { total: all.length, list: slice } });
} }
if (p === '/api/project/partner/list') {
const pid = u.searchParams.get('projectId');
const list = PARTNERS[pid] || [];
return send(res, 200, { code: 0, data: { total: list.length, list } });
}
return send(res, 200, { code: 404, data: null, msg: 'no route ' + p }); return send(res, 200, { code: 404, data: null, msg: 'no route ' + p });
}); });
}); });
server.listen(19911, '127.0.0.1', () => { server.listen(19911, '127.0.0.1', () => {
console.log('mock 卷王已启动 http://127.0.0.1:19911 密码=' + PASSWORD); console.log('mock 卷王已启动 http://127.0.0.1:19911 密码=' + PASSWORD
+ '\n名单来源:' + ROSTER.from + '(' + roster.length + ' 人)');
}); });
+150
View File
@@ -0,0 +1,150 @@
/* 多考试打印渲染回归测试
*
* 「多考试打印」的版式由 assets/app.js 的 sheetToHtml() 负责:把服务端装配好的
* 二维表 + merges(1-based 闭区间)+ cell_styles(red=最高分红字 / green=合格绿底)
* 翻成带 colspan / rowspan 的 HTML。
* 本测试直接从 app.js 抽取该函数源码执行(不复制一份实现,避免两边漂移),
* 用与 tests/test-multi-export.php 同一套版式校验渲染结果。
*
* 运行:node tools/test-block-print.js
*/
const fs = require('fs');
const path = require('path');
let pass = 0, fail = 0;
function chk(name, ok, extra) {
if (ok) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
}
/* ---------- 1) 从生产代码里抽取 sheetToHtml ---------- */
const appPath = path.join(__dirname, '..', 'assets', 'app.js');
const appSrc = fs.readFileSync(appPath, 'utf8');
const m = appSrc.match(/function sheetToHtml\(sheet\)\s*\{[\s\S]*?\n \}/);
chk('能从 assets/app.js 抽到 sheetToHtml', !!m);
if (!m) {
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
process.exit(1);
}
const PRINT_CSS = (appSrc.match(/function printDoc\(title, bodyHtml, opts\)\s*\{[\s\S]*?\n \}/) || [''])[0];
const esc = s => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// eslint-disable-next-line no-new-func
const sheetToHtml = new Function('esc', 'return ' + m[0])(esc);
/* ---------- 2) 真实装配结果(截图版式:共享「第几次」列 + 合格次数行) ----------
列:1 学号 | 2 姓名 | 3 第几次 | 4-5 数学 | 6-7 英语
合格线 110,阈值 1 次。 */
const FIXTURE = {
header_rows: 2,
rows: [
['学号', '姓名', '第几次', '数学', '', '英语', ''],
['', '', '', '成绩', '', '成绩', ''],
// 张三:数学 3 次(112/100/136),英语 1 次(112)
['356001', '张三', '1', '112', '', '112', ''],
['', '', '2', '100', '', '', ''],
['', '', '3', '136', '', '', ''],
['', '', '合格次数', '3', '', '1', ''],
// 李四:数学整场未考,英语 3 次(130/129/133)
['356002', '李四', '1', '未考', '', '130', ''],
['', '', '2', '', '', '129', ''],
['', '', '3', '', '', '133', ''],
['', '', '合格次数', '', '', '3', ''],
// 王五:数学 1 次(88,未达合格线),英语整场未考
['356003', '王五', '1', '88', '', '未考', ''],
['', '', '合格次数', '0', '', '', ''],
],
merges: [
[1, 1, 2, 1], [1, 2, 2, 2], [1, 3, 2, 3], [1, 4, 1, 5], [1, 6, 1, 7], // 表头
[3, 1, 6, 1], [3, 2, 6, 2], [3, 3, 5, 3], // 张三:学号/姓名并入末行,「第几次」只到末次考试
[7, 1, 10, 1], [7, 2, 10, 2], [7, 4, 10, 5], // 李四:学号/姓名并入末行;数学整场「未考」跨 2 列 × 4 行
[11, 6, 11, 7], // 王五:英语整场「未考」跨 2 列
],
cell_styles: {
'5,4': 'red', // 张三 数学最高分 136(1-based 行 5) → 红字
'9,6': 'red', // 李四 英语最高分 133(1-based 行 9) → 红字
'6,4': 'green', // 张三 数学合格次数 3 → 绿底
'6,6': 'green', // 张三 英语合格次数 1 → 绿底
'10,6': 'green', // 李四 英语合格次数 3 → 绿底
},
};
const html = sheetToHtml(FIXTURE);
const thead = (html.match(/<thead>([\s\S]*?)<\/thead>/) || [])[1] || '';
const tbody = (html.match(/<tbody>([\s\S]*?)<\/tbody>/) || [])[1] || '';
const count = (s, needle) => s.split(needle).length - 1;
/* ---------- 3) 断言 ---------- */
chk('表头 2 行进 <thead>(多页打印自动重复)', count(thead, '<tr>') === 2 && count(tbody, '<tr>') === 10,
'thead=' + count(thead, '<tr>') + ' tbody=' + count(tbody, '<tr>'));
chk('学号表头纵向合并 2 行', thead.indexOf('<td rowspan="2">学号</td>') >= 0);
chk('姓名表头纵向合并 2 行', thead.indexOf('<td rowspan="2">姓名</td>') >= 0);
chk('第几次表头纵向合并 2 行', thead.indexOf('<td rowspan="2">第几次</td>') >= 0);
chk('考试名称横跨 2 列',
thead.indexOf('<td colspan="2">数学</td>') >= 0 && thead.indexOf('<td colspan="2">英语</td>') >= 0);
chk('第 2 行表头是「成绩」小标题', thead.indexOf('<td>成绩</td>') >= 0);
chk('张三学号纵向合并 4 行(3 次考试 + 合格次数行)', tbody.indexOf('<td rowspan="4">356001</td>') >= 0);
chk('李四学号纵向合并 4 行', tbody.indexOf('<td rowspan="4">356002</td>') >= 0);
chk('王五学号不合并(只有 1 次考试)', tbody.indexOf('<td>356003</td>') >= 0);
chk('「合格次数」标签落在「第几次」列且 3 处都在', count(tbody, '<td>合格次数</td>') === 3,
'实际 ' + count(tbody, '<td>合格次数</td>'));
chk('未考合并 2 列 × 4 行(李四·数学)',
tbody.indexOf('<td rowspan="4" colspan="2" class="absent">未考</td>') >= 0);
chk('未考合并 2 列 × 1 行(王五·英语)',
tbody.indexOf('<td colspan="2" class="absent">未考</td>') >= 0);
chk('最高分红字 2 处(张三 136 / 李四 133)', count(tbody, 'class="max">') === 2,
'实际 ' + count(tbody, 'class="max">'));
chk('合格次数绿底 3 处(张三 2 场 + 李四 1 场)', count(tbody, 'class="pass">') === 3,
'实际 ' + count(tbody, 'class="pass">'));
chk('王五数学 0 次未达标 → 不标绿', tbody.indexOf('>0</td>') >= 0 && tbody.indexOf('class="pass">0') < 0);
/* td 数守恒:总格位数 − 被合并吞掉的格位。合并一旦算错(重叠 / 漏格)这里就会炸 */
const covered = new Set();
FIXTURE.merges.forEach(mg => {
for (let r = mg[0] - 1; r <= mg[2] - 1; r++) {
for (let c = mg[1] - 1; c <= mg[3] - 1; c++) {
if (r === mg[0] - 1 && c === mg[1] - 1) continue;
covered.add(r + '|' + c);
}
}
});
const capacity = FIXTURE.rows.reduce((s, row) => s + row.length, 0);
const eaten = FIXTURE.rows.reduce((s, row, r) =>
s + row.filter((_, c) => covered.has(r + '|' + c)).length, 0);
chk('td 数守恒(' + capacity + ' − ' + eaten + ' = ' + (capacity - eaten) + ')',
count(html, '<td') === capacity - eaten, '实际 ' + count(html, '<td'));
/* 标记只落在 cell_styles 指定的格子上:合并首格带上 class 且 rowspan/colspan 完整,
同行未参与合并的格子不受牵连,越界键被忽略 */
const markHtml = sheetToHtml({
rows: [['a', 'b', 'c'], ['1', '2', '3']],
merges: [[1, 1, 2, 2]],
cell_styles: { '1,1': 'green', '99,99': 'red' },
header_rows: 1,
});
chk('合并首格带标记:class 正确且 rowspan/colspan 完整',
markHtml.indexOf('<td rowspan="2" colspan="2" class="pass">a</td>') >= 0, markHtml);
chk('同行未合并格不受相邻合并影响', markHtml.indexOf('<td>c</td>') >= 0);
chk('越界标记键被忽略', markHtml.indexOf('class="max"') < 0);
chk('打印 CSS 含块表样式(居中 / 未考灰 / 最高分红 / 合格绿)',
PRINT_CSS.indexOf('table.bt') >= 0 && PRINT_CSS.indexOf('td.absent') >= 0
&& PRINT_CSS.indexOf('td.max') >= 0 && PRINT_CSS.indexOf('td.pass') >= 0);
/* 绿底印不印得出来,取决于 print-color-adjust 有没有写上——浏览器默认关闭"背景图形" */
chk('打印 CSS 声明了 print-color-adjust:exact(否则底纹不会被打印)',
/print-color-adjust:\s*exact/.test(PRINT_CSS) && PRINT_CSS.indexOf('-webkit-print-color-adjust') >= 0);
chk('绿底单元格自己也声明了 print-color-adjust',
/td\.pass\{[^}]*print-color-adjust:\s*exact/.test(PRINT_CSS));
/* 缺考名单等附加工作表需要另起一页,否则会挤在成绩表底部 */
chk('打印 CSS 让缺考名单(h3.grp.page-break)另起一页',
/h3\.grp\.page-break\{[^}]*page-break-before:\s*always/.test(PRINT_CSS));
/* 表头渲染成 <td>,底色必须写 thead td,写成 th 是死规则、两行表头会变白 */
chk('表头底色命中实际渲染的 <td>(thead td,不是死规则 th)',
/table\.bt thead td\{background:/.test(PRINT_CSS));
chk('HTML 已转义',
sheetToHtml({ rows: [['<b>x</b>']], merges: [], header_rows: 1 }).indexOf('&lt;b&gt;') >= 0);
console.log('');
console.log('结果:' + pass + ' 通过,' + fail + ' 失败');
module.exports = { sheetToHtml: sheetToHtml, PRINT_CSS: PRINT_CSS };
process.exit(fail === 0 ? 0 : 1);
+343
View File
@@ -0,0 +1,343 @@
// e2e: config_save 仅连接信息变化才清 token;各试卷独立满分修复;密码不回显
// 运行:node tools/test-config-save.js
const http = require('http');
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const PORT = 18081;
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
// 1) 备份 / 准备文件存储模式
// 数据目录默认用项目下的 data/,但优先读 NY_DATA 环境变量(bootstrap.php 同样支持):
// 测试统一把 NY_DATA 指向临时目录,彻底避免把 mock 配置写进生产库(曾导致生产 base_url 被改成 127.0.0.1)。
// Git Bash 风格路径 /c/Users/... 在 Windows 上 path.resolve 会误判成 C:\c\...,需还原成 C:\Users\...;
// 规范化后写回 process.env.NY_DATA,让子进程(PHP 内置服务器)也拿到正确的 Windows 绝对路径。
function resolveDataDir() {
let p = process.env.NY_DATA || path.join(ROOT, 'data');
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2);
p = path.resolve(p);
process.env.NY_DATA = p;
return p;
}
const DATA = resolveDataDir();
fs.mkdirSync(DATA, { recursive: true });
const cfgFile = path.join(DATA, 'config.json');
const cfgBak = cfgFile + '.bak';
const origCfg = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, 'utf8') : null;
const tokenFile = path.join(DATA, 'cache', 'token.json');
const tokenBak = tokenFile + '.bak';
const hasToken = fs.existsSync(tokenFile);
const tokenSnap = hasToken ? fs.readFileSync(tokenFile, 'utf8') : null;
if (hasToken) fs.renameSync(tokenFile, tokenBak);
// SQLite 优先:若不先移走 data/ny.db,里面的旧配置会盖掉本测试写入的 config.json,
// 导致登录用的是上一次遗留的账号密码(表现为"登录成功但 auth_check=false")。
const dbFile = path.join(DATA, 'ny.db');
const dbBak = dbFile + '.bak';
const hasDb = fs.existsSync(dbFile);
if (hasDb) { try { fs.renameSync(dbFile, dbBak); } catch (e) {} }
fs.writeFileSync(cfgFile, JSON.stringify({
base_url: 'http://127.0.0.1:19911',
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,
full_scores: {},
layout: 'default',
}, null, 2));
// 统一收尾钩子:恢复原 config + token + kill 子进程。任何退出路径都会触发
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
try { srv && srv.kill('SIGKILL'); } catch(e){}
try { mock && mock.kill('SIGKILL'); } catch(e){}
try {
if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg);
else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile);
} catch(e) { console.error('cleanup cfg failed:', e.message); }
try {
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
} 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); }
}
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(1); });
process.on('uncaughtException', (e) => { console.error('FATAL', e); cleanup(); process.exit(2); });
// 2) 启动 mock 卷王
let mock = null;
let srv = null;
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'pipe' });
mock.stdout.on('data', d => process.stdout.write('[mock] ' + d));
mock.stderr.on('data', d => process.stderr.write('[mock-err] ' + d));
// 3) 启动 PHP 内置服务器(-t 让所有请求走 index.php 路由)
const phpArgs = INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]);
srv = spawn(PHP, phpArgs, { cwd: ROOT, stdio: 'pipe' });
srv.stdout.on('data', d => process.stdout.write('[php] ' + d));
srv.stderr.on('data', d => process.stderr.write('[php-err] ' + d));
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 : '')); }
}
function req(method, path, body, headers) {
return new Promise((resolve, reject) => {
// 写操作自动附带 CSRF token(后端对所有写 action 强制校验)
if (body && CSRF && !body._csrf) body = Object.assign({}, body, { _csrf: CSRF });
const data = body ? JSON.stringify(body) : '';
const h = Object.assign({
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
}, headers || {});
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
const r = http.request({ host: '127.0.0.1', port: PORT, method, path, headers: h }, res => {
let chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
let j = null;
try { j = JSON.parse(text); } catch (e) {}
// 收集 Set-Cookie
if (res.headers['set-cookie']) {
for (const sc of res.headers['set-cookie']) {
const pair = sc.split(';')[0];
const name = pair.split('=')[0];
const idx = cookieJar.findIndex(x => x.startsWith(name + '='));
if (idx >= 0) cookieJar[idx] = pair; else cookieJar.push(pair);
}
}
// 跟随 302(不自动跟随 action=login 等会导致副作用的)
if (res.statusCode === 302 && res.headers.location && !h['__followed'] && !/login\.php/.test(res.headers.location)) {
h['__followed'] = 1;
console.log(' [debug] follow', res.statusCode, '→', res.headers.location);
return resolve(req('GET', res.headers.location, null, h));
}
resolve({ status: res.statusCode, body: j, raw: text, setCookie: res.headers['set-cookie'], loc: res.headers.location });
});
});
r.on('error', reject);
if (data) r.write(data);
r.end();
});
}
const cookieJar = [];
let CSRF = ''; // 从 config_get 取到的会话 CSRF token,POST 写请求自动附带
function formPost(path, fields) {
return new Promise((resolve, reject) => {
const body = new URLSearchParams(fields).toString();
const h = {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body),
};
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path, headers: h }, res => {
let chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
if (res.headers['set-cookie']) {
for (const sc of res.headers['set-cookie']) {
const pair = sc.split(';')[0];
const name = pair.split('=')[0];
const idx = cookieJar.findIndex(x => x.startsWith(name + '='));
if (idx >= 0) cookieJar[idx] = pair; else cookieJar.push(pair);
}
}
resolve({ status: res.statusCode, body: text, loc: res.headers.location });
});
});
r.on('error', reject);
r.write(body); r.end();
});
}
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
async function login() {
// 走 login.php 的表单登录拿会话
return await formPost('/login.php', { username: 'mock', password: 'surveyking' });
}
(async () => {
// 等服务起来
await delay(2000);
// 用 inline 脚本快速验证 login 是否能成功(保留 srv 不退出)
await new Promise(r => setTimeout(r, 200));
const http = require('http');
const testCookie = await new Promise((resolve) => {
const body = new URLSearchParams({username:'mock',password:'surveyking'}).toString();
http.request({host:'127.0.0.1',port:PORT,method:'POST',path:'/login.php',headers:{'Content-Type':'application/x-www-form-urlencoded','Content-Length':Buffer.byteLength(body)}},res=>{
let c=[]; res.on('data',d=>c.push(d)); res.on('end',()=>{
const text = Buffer.concat(c).toString();
const cookie = (res.headers['set-cookie']||[]).map(c=>c.split(';')[0]).join('; ');
console.log(' [inline] login status', res.statusCode, 'set-cookie', JSON.stringify(res.headers['set-cookie']));
if (text.indexOf('登录失败') >= 0) {
const m = text.match(/登录失败[^<]+/);
console.log(' [inline] body 错误:', m ? m[0] : '(unknown)');
} else {
console.log(' [inline] body end:', text.slice(Math.max(0,text.length-200)));
}
// 拿同一 cookie 调 auth_check
http.request({host:'127.0.0.1',port:PORT,method:'GET',path:'/api.php?action=auth_check',headers:{Cookie:cookie}}, rs=>{
let cc=[]; rs.on('data',d=>cc.push(d)); rs.on('end',()=>{
console.log(' [inline] auth_check', rs.statusCode, Buffer.concat(cc).toString());
resolve(cookie);
});
}).end();
});
}).end(body);
});
if (testCookie && testCookie.indexOf('NYSESS=') >= 0) {
// 同步 jar
cookieJar.length = 0;
testCookie.split('; ').forEach(c => cookieJar.push(c));
}
console.log('\n--- 1) 首次登录应成功 ---');
// 登录 formPost 不会跟随 302(直接保留 body),但要求 NYSESS cookie 出现
const lg = await formPost('/login.php', { username: 'mock', password: 'surveyking' });
console.log(' [debug] login loc:', lg.loc, 'body:', (lg.body||'').slice(0, 200));
chk('login.php 200/302', lg.status === 302 || lg.status === 200, 'status=' + lg.status);
chk('session cookie 已获得', cookieJar.length > 0, 'jar=' + JSON.stringify(cookieJar));
// 复查 auth(直接走 Session ID 路径)
const ac = await req('GET', '/api.php?action=auth_check&_t=' + Date.now());
console.log(' [debug] auth_check:', ac.status, ac.raw);
chk('auth_check 通过', ac.body && ac.body.data && ac.body.data.auth === true, JSON.stringify(ac.body));
if (!(ac.body && ac.body.data && ac.body.data.auth)) {
console.log(' [fatal] 未登录,停止测试');
mock.kill('SIGTERM'); srv.kill('SIGTERM');
if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg); else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile);
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
process.exit(1);
}
// 之后用 ping 触发 SurveyKing 登录
const p1 = await req('GET', '/api.php?action=ping&_t=' + Date.now()); console.log(' [debug] ping raw:', p1.status, 'loc=' + p1.loc, (p1.raw||'').slice(0, 200));
chk('ping 200', p1.status === 200, 'status=' + p1.status + ' body=' + (p1.raw||'').slice(0, 200));
chk('ping.ok', p1.body && p1.body.ok === true, JSON.stringify(p1.body));
await delay(100);
chk('token 已缓存', fs.existsSync(tokenFile));
console.log('\n--- 2) config_get 应返回 storage + 诊断 ---');
const cg1 = await req('GET', '/api.php?action=config_get');
chk('config_get 200', cg1.status === 200);
chk('返回 storage 字段', cg1.body && cg1.body.data && typeof cg1.body.data.storage === 'string',
'storage=' + (cg1.body && cg1.body.data && cg1.body.data.storage));
chk('不返回明文密码', cg1.body && cg1.body.data && cg1.body.data.password === undefined);
chk('has_password=true', cg1.body && cg1.body.data && cg1.body.data.has_password === true);
// 取 CSRF token,后续写请求自动附带
CSRF = (cg1.body && cg1.body.data && cg1.body.data.csrf_token) || '';
chk('config_get 返回 csrf_token', CSRF !== '', 'csrf=' + CSRF);
console.log('\n--- 3) config_save 不改连接信息:不应清 token / 不应触发 ping ---');
const before = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
const cs1 = await req('POST', '/api.php?action=config_save', {
full_score: 95,
});
chk('config_save 200', cs1.status === 200);
chk('conn_changed=false', cs1.body && cs1.body.data && cs1.body.data.conn_changed === false);
chk('ping 字段不存在', cs1.body && cs1.body.data && cs1.body.data.ping === undefined);
const after = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
chk('token 保持未变', before === after, 'changed=' + (before !== after));
console.log('\n--- 4) 试卷独立配置(满分/合格率/免考)已从设置移出,改走 project_config ---');
// 每卷满分已不在 config_save 里;改为 project_config_save(试卷间隔离)
const pc1 = await req('POST', '/api.php?action=project_config_save&projectId=p1', {
full_score: 150, pass_rate: 70, exempt: 'S1,S2',
});
chk('project_config_save 200', pc1.status === 200, 'status=' + pc1.status + ' ' + JSON.stringify(pc1.body));
chk('生效满分=150', pc1.body && pc1.body.data && pc1.body.data.effective
&& pc1.body.data.effective.full_score === 150,
JSON.stringify(pc1.body && pc1.body.data && pc1.body.data.effective));
chk('生效合格率=70', pc1.body && pc1.body.data && pc1.body.data.effective
&& Number(pc1.body.data.effective.pass_rate) === 70,
JSON.stringify(pc1.body && pc1.body.data && pc1.body.data.effective));
// 隔离性:p2 未配置 → 跟随全局(注:上面的 config_save 已把全局满分改成 95,
// 所以这里不能写死 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('清除后 p1 回到全局满分(=' + gFs + ')', pc4.body && pc4.body.data && pc4.body.data.effective
&& pc4.body.data.effective.full_score === gFs,
JSON.stringify(pc4.body && pc4.body.data && pc4.body.data.effective) + ' global=' + gFs);
console.log('\n--- 5) 修改连接信息:应清 token + 重新登录(ping)---');
const tokBefore = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
// 最先修改密码为不同值:触发 conn_changed=true + ping.ok=false
const cs5 = await req('POST', '/api.php?action=config_save', {
password: 'temp-wrong-password',
});
chk('修改密码后 conn_changed=true', cs5.body && cs5.body.data && cs5.body.data.conn_changed === true);
chk('返回 ping.ok=false', cs5.body && cs5.body.data && cs5.body.data.ping && cs5.body.data.ping.ok === false,
JSON.stringify(cs5.body && cs5.body.data && cs5.body.data.ping));
await delay(200);
const tokAfter = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
chk('修改同 token 被清', tokBefore !== '' && tokAfter === '', 'before=' + (tokBefore !== '') + ' after=' + (tokAfter !== ''));
// 第二步:改回正确密码,应 trigger conn_changed=true + ping.ok=true
const cs5b = await req('POST', '/api.php?action=config_save', {
password: 'surveyking',
});
chk('改回正确密码同 conn_changed=true', cs5b.body && cs5b.body.data && cs5b.body.data.conn_changed === true);
chk('改回后 ping.ok=true', cs5b.body && cs5b.body.data && cs5b.body.data.ping && cs5b.body.data.ping.ok === true,
JSON.stringify(cs5b.body && cs5b.body.data && cs5b.body.data.ping));
await delay(200);
chk('登录成功同 token 已生成', fs.existsSync(tokenFile), 'token exists=' + fs.existsSync(tokenFile));
console.log('\n--- 6) config_get 永远不带明文密码 ---');
const cg2 = await req('GET', '/api.php?action=config_get');
chk('密码不存在', cg2.body && cg2.body.data && cg2.body.data.password === undefined);
chk('has_password 标志', cg2.body && cg2.body.data && cg2.body.data.has_password === true);
console.log('\n--- 7) 修改为错误密码:应自动 ping 失败并保留弹窗提示 ---');
const cs6 = await req('POST', '/api.php?action=config_save', {
username: 'mock',
password: 'wrong-password',
});
chk('conn_changed=true', cs6.body && cs6.body.data && cs6.body.data.conn_changed === true);
chk('ping.ok=false', cs6.body && cs6.body.data && cs6.body.data.ping && cs6.body.data.ping.ok === false,
JSON.stringify(cs6.body && cs6.body.data && cs6.body.data.ping));
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
// 清理 + 退出(统一走 cleanup 钩子,保证 config/token 一定被还原)
cleanup();
setTimeout(() => process.exit(fail === 0 ? 0 : 1), 200);
})().catch(e => { console.error('FATAL', e); cleanup(); process.exit(2); });
+296
View File
@@ -0,0 +1,296 @@
// e2e: 数据大屏两种形态 —— 首页班级入口 + 单班独立大屏(含后退锁定 / 顶栏控件收敛)
//
// 覆盖点:
// 1) 无 ?class= → dash-home:仍显示班级下拉,顶栏出现"选择考试 → 点卡片进入"文案
// 2) ?class=X → dash-class:顶栏隐藏班级选择 / ← 主系统,页面内不出现回首页的链接
// 3) 后退锁定:lockHistory() 只在 IS_CLASS 时挂,首页不受影响
// 4) 卡片渲染:直接把 dashboard.php 里的 clsCardHtml 抽出来跑,断言卡片结构与跳转参数
// 5) 公开访问:带 ?class= 的班级大屏免登录,且接口只回该班数据(其它班级不泄漏)
//
// 运行:node tools/test-dashboard-nav.js
const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const PORT = 18091;
const INI = process.env.PHP_INI || '';
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
const MOCK = { user: 'mock', pass: 'surveyking' };
// NY_DATA 指向临时目录:名单/配置/缓存一律写临时目录,绝不碰项目 data/
let p = process.env.NY_DATA || path.join(os.tmpdir(), 'ny-dash-nav-' + process.pid);
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2);
const DATA = path.resolve(p);
process.env.NY_DATA = DATA;
fs.mkdirSync(DATA, { recursive: true });
const cfgFile = path.join(DATA, 'config.json');
// 名单:5 人有答卷(与 mock 的 PARTNERS 90001-90005 对齐),再补 30 名无答卷者凑出 3 个班
const ROSTER = { students: [], updated_at: '2026-01-01 00:00:00' };
const back = [
['90001', '高一(1)班', 'A组'], ['90002', '高一(1)班', 'A组'], ['90003', '高一(1)班', 'B组'],
['90004', '高一(2)班', 'A组'], ['90005', '高一(2)班', 'B组'],
];
back.forEach(([sid, cls, grp]) => ROSTER.students.push({ sid, name: sid, cls, grp, login: '' }));
['高一(1)班', '高一(2)班', '高一(3)班'].forEach((cls, ci) => {
for (let i = 1; i <= 10; i++) {
const sid = String(9100 + ci * 100 + i);
ROSTER.students.push({ sid, name: sid, cls, grp: i % 2 ? 'A组' : 'B组', login: '' });
}
});
try { fs.unlinkSync(cfgFile); } catch (e) {}
// 起始配置:连的是本机 mock,账号留空(登录时再写入,与 test-login-loop 同口径)
fs.writeFileSync(cfgFile, JSON.stringify({
base_url: 'http://127.0.0.1:19911',
api_prefix: '/api',
username: '',
password: '',
verify_ssl: false,
timeout: 10,
refresh_interval: 15,
page_size: 50,
dedupe: true,
include_temp_save: false,
name_keywords: '姓名,名字',
full_score: 100,
layout: 'default',
}, null, 2));
fs.writeFileSync(path.join(DATA, 'roster.json'), JSON.stringify(ROSTER));
let cleanedUp = false, mock = null, srv = null;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
try { srv && srv.kill('SIGKILL'); } catch (e) {}
try { mock && mock.kill('SIGKILL'); } catch (e) {}
try { fs.rmSync(DATA, { recursive: true, force: true }); } catch (e) {}
}
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(1); });
process.on('uncaughtException', e => { console.error('FATAL', e); cleanup(); process.exit(2); });
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'ignore' });
srv = spawn(PHP, INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]), { cwd: ROOT, stdio: 'ignore' });
let pass = 0, fail = 0;
function chk(name, cond, extra) {
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
}
const cookieJar = [];
function collect(res) {
for (const sc of (res.headers['set-cookie'] || [])) {
const pair = sc.split(';')[0];
const name = pair.split('=')[0];
const i = cookieJar.findIndex(x => x.startsWith(name + '='));
if (i >= 0) cookieJar[i] = pair; else cookieJar.push(pair);
}
}
const jarHeader = () => (cookieJar.length ? cookieJar.map(c => c.replace(/;.*$/, '')).join('; ') : '');
// jar 省略 → 用当前登录会话;传 [] → 完全不带 Cookie 的匿名请求(验公开访问)
function get(p, jar) {
return new Promise((resolve, reject) => {
const h = { 'Accept': 'text/html,application/json' };
const ck = jar === undefined ? jarHeader()
: (jar || []).map(c => c.replace(/;.*$/, '')).join('; ');
if (ck) h['Cookie'] = ck;
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'GET', path: p, headers: h }, res => {
const bufs = [];
res.on('data', c => bufs.push(c));
res.on('end', () => {
// 匿名请求不回灌 Cookie:否则会用它自己的新会话覆盖掉登录会话
if (jar === undefined) collect(res);
resolve({ status: res.statusCode, raw: Buffer.concat(bufs).toString('utf8'), loc: res.headers.location });
});
});
r.on('error', reject);
r.end();
});
}
function post(p, fields) {
return new Promise((resolve, reject) => {
const data = new URLSearchParams(fields).toString();
const h = { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(data) };
if (jarHeader()) h['Cookie'] = jarHeader();
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path: p, headers: h }, res => {
const bufs = [];
res.on('data', c => bufs.push(c));
res.on('end', () => { collect(res); resolve({ status: res.statusCode, raw: Buffer.concat(bufs).toString('utf8') }); });
});
r.on('error', reject);
r.write(data); r.end();
});
}
const delay = ms => new Promise(r => setTimeout(r, ms));
const dashSrc = fs.readFileSync(path.join(ROOT, 'dashboard.php'), 'utf8');
const cssSrc = fs.readFileSync(path.join(ROOT, 'assets', 'style.css'), 'utf8');
(async () => {
await delay(2200);
console.log('\n--- 0) 登录 ---');
await post('/login.php', { username: MOCK.user, password: MOCK.pass });
const ac = await get('/api.php?action=auth_check');
chk('已登录', /"auth"\s*:\s*true/.test(ac.raw), ac.raw.slice(0, 120));
console.log('\n--- 1) 首页形态(无 ?class=)---');
const home = await get('/dashboard.php');
chk('首页返回 200', home.status === 200, 'status=' + home.status);
chk('body 带 dash-home、不带 dash-class',
/<body class="dash dash-home">/.test(home.raw) && !/dash dash-class/.test(home.raw),
(home.raw.match(/<body class="[^"]*"/) || [''])[0]);
chk('首页仍显示班级下拉(机构形态不变)', /id="classSelWrap"/.test(home.raw));
chk('首页保留「← 主系统 / 退出」入口(仅单班页隐藏)',
/id="linkIndex" href="index\.php"/.test(home.raw) && /id="linkLogout" href="logout\.php"/.test(home.raw));
chk('首页首屏提示为"先选考试再点班级"', /先选择一份问卷 \/ 考试,再点击班级卡片进入该班大屏/.test(home.raw));
chk('首页把 renderBoard 分流到 renderClassCards',
/if \(!IS_CLASS\) \{ renderClassCards\(\); return; \}/.test(dashSrc));
chk('首页自动刷新照旧可用(autoRefresh/intervalSel 都在)',
/id="autoRefresh"/.test(home.raw) && /id="intervalSel"/.test(home.raw) && /id="btnFullscreen"/.test(home.raw));
console.log('\n--- 2) 单班大屏形态(?pid=p1&class=高一(1)班)---');
const one = await get('/dashboard.php?pid=p1&class=' + encodeURIComponent('高一(1)班'));
chk('单班页返回 200', one.status === 200, 'status=' + one.status);
chk('body 带 dash-class、不带 dash-home',
/<body class="dash dash-class">/.test(one.raw) && !/dash dash-home/.test(one.raw),
(one.raw.match(/<body class="[^"]*"/) || [''])[0]);
chk('顶栏显示当前班级徽标', /id="heroClass"[^>]*>高一\(1\)班/.test(one.raw),
(one.raw.match(/id="heroClass"[^>]*/) || [''])[0]);
chk('页面内没有任何指向大屏首页的链接', !/href="dashboard\.php"/.test(one.raw));
chk('lockHistory() 在启动时被调起', /lockHistory\(\);/.test(dashSrc));
chk('后退锁定只在单班形态生效', /if \(!IS_CLASS \|\| historyLocked\) return;/.test(dashSrc));
chk('lockHistory 用 pushState 把本页重新推回栈顶',
/history\.pushState\(\{ nyLock: 1 \}, '', location\.href\)/.test(dashSrc) &&
/popstate/.test(dashSrc));
chk('首页形态不锁后退(IS_CLASS 为假时不 push)', !/if \(IS_CLASS(?![\s\S]{0,40}return;\})[\s\S]{0,40}pushState/.test(
dashSrc.slice(dashSrc.indexOf('function lockHistory'), dashSrc.indexOf('function lockHistory') + 400)));
console.log('\n--- 3) 顶栏控件收敛(CSS 隐藏规则)---');
chk('隐藏班级选择', /body\.dash-class #classSelWrap,\s*\n?\s*body\.dash-class #linkIndex \{ display: none !important; \}/.test(cssSrc));
chk('隐藏「← 主系统」', /body\.dash-class #linkIndex/.test(cssSrc));
chk('已不再靠 CSS 藏退出按钮(该形态下根本不渲染)', !/#linkLogout/.test(cssSrc));
chk('首页隐藏 5 张汇总卡', /body\.dash-home #boardSummary \{ display: none; \}/.test(cssSrc));
chk('单班页仍保留试卷/自动刷新/全屏三个控件',
/id="projectSel"/.test(one.raw) && /id="autoRefresh"/.test(one.raw) && /id="btnFullscreen"/.test(one.raw));
chk('单班页的「← 主系统」靠 CSS 隐藏',
/id="linkIndex"/.test(one.raw) && /display: none !important/.test(
cssSrc.slice(cssSrc.indexOf('body.dash-class #classSelWrap'), cssSrc.indexOf('body.dash-home #boardSummary'))));
chk('单班页(已登录)仍渲染退出按钮,由 CSS 隐藏', /id="linkLogout"/.test(one.raw));
console.log('\n--- 4) 班级卡片渲染(把 dashboard.php 里的 clsCardHtml 抽出来实跑)---');
const m = dashSrc.match(/function clsCardHtml\(c\)\{[\s\S]*?\n\}/);
chk('能抽出 clsCardHtml 函数体', !!m);
if (m) {
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const dv = (v, d) => (v === undefined || v === null || v === '' ? (d === undefined ? '-' : d) : v);
const pv = v => dv(v, '-') + (v === undefined || v === null || v === '' ? '' : '%');
const rateColor = r => (r >= 90 ? '#12a86a' : (r >= 60 ? '#e08c1a' : '#e0483d'));
const fn = new Function('esc', 'dv', 'pv', 'rateColor', m[0] + ' return clsCardHtml;')(esc, dv, pv, rateColor);
const html = fn({
class: '高一(1)班', total: 20, submitted: 3, absent: 17, rate: 15,
attempts: 5, avg: 78.5, pass_rate: 66.7,
});
chk('卡片带上班级名与进入入口', /data-cls="高一\(1\)班"/.test(html) && /cls-go/.test(html));
chk('卡片是按钮语义(role/tabindex)', /role="button"/.test(html) && /tabindex="0"/.test(html));
chk('5 项指标齐全', (html.match(/cls-kpi /g) || []).length === 5, '实际 ' + (html.match(/cls-kpi /g) || []).length + ' 项');
chk('提交率进度条按 rate 取值', /cls-bar"><i style="width:15%/.test(html), (html.match(/width:\d+%/) || [''])[0]);
chk('多次刷题徽标透传', /共 5 次刷题/.test(html));
chk('无成绩时的缺考数照常显示', /<span>缺考<\/span><b>17<\/b>/.test(html));
}
console.log('\n--- 5) 进入班级的跳转 ---');
const e = dashSrc.match(/function enterClass\(cls\)\{[\s\S]*?\n\}/);
chk('能抽出 enterClass 函数体', !!e);
if (e) {
chk('跳转时带上 pid + class + auto',
/searchParams\.set\('pid'/.test(e[0]) && /searchParams\.set\('class'/.test(e[0]) && /searchParams\.set\('auto'/.test(e[0]));
chk('是整页跳转(location.href,不是前端路由)', /location\.href = u\.pathname \+ u\.search;/.test(e[0]));
chk('卡片点击/回车都走事件委托(网格重绘不掉监听)',
/box\.addEventListener\('click'/.test(dashSrc) && /box\.addEventListener\('keydown'/.test(dashSrc) &&
/closest\('\.cls-card'\)/.test(dashSrc));
}
chk('首页卡片容器类名与样式一致(cls-grid)',
/<div class="cls-grid">/.test(dashSrc) && /\.cls-grid \{/.test(cssSrc));
console.log('\n--- 5.5) 成绩红绿口径(必须跟随后台配置的满分成数 / 合格率)---');
const sgSrc = dashSrc.match(/function scoreGrade\(sc\)\{[\s\S]*?\n\}/);
chk('能从 dashboard.php 抽出 scoreGrade', !!sgSrc);
if (sgSrc) {
// scoreGrade 里读的是脚本作用域的 DASH(let 声明,不在 window 上),
// 这里把 DASH 作为形参传进去,还原同一条作用域链
// eslint-disable-next-line no-new-func
const mk = new Function('DASH', sgSrc[0] + '; return scoreGrade;');
const g100 = mk({ full_score: 100, pass_rate: 60 });
const g150 = mk({ full_score: 150, pass_rate: 80 });
const gCur = mk({ full_score: 100, pass_rate: 95 });
chk('默认口径:95 绿 / 70 黄 / 50 红',
g100(95).cls === 'score-high' && g100(70).cls === 'score-mid' && g100(50).cls === 'score-low',
[95, 70, 50].map(v => v + '→' + g100(v).cls).join(' '));
// 后台把合格率改成 80、满分改成 150:100 分只占 66.7%,必须判红,不能再按 100 分制当成优秀
chk('后台改「满分 150 / 合格率 80%」后,100 分判红(不再按 100 分制当优秀)',
g150(100).cls === 'score-low', '100→' + g150(100).cls);
chk('同一配置下 130 分(86.7%)判黄、145 分(96.7%)判绿',
g150(130).cls === 'score-mid' && g150(145).cls === 'score-high',
[130, 145].map(v => v + '→' + g150(v).cls).join(' '));
// 合格率 85%:88 分合格但没到 90 分优秀线 → 黄
chk('合格率 85% 时 88 分判黄(合格但未达优秀线,绿线仍是 90)',
mk({ full_score: 100, pass_rate: 85 })(88).cls === 'score-mid',
'88→' + mk({ full_score: 100, pass_rate: 85 })(88).cls);
// 合格率 95%:合格线本身高过 90 分,绿线跟着抬到 95 —— 92/94 都不及格判红,96 判绿
chk('合格线高过 90% 时绿线跟着抬(合格率 95% → 92/94 判红、96 判绿)',
gCur(92).cls === 'score-low' && gCur(94).cls === 'score-low' && gCur(96).cls === 'score-high',
[92, 94, 96].map(v => v + '→' + gCur(v).cls).join(' '));
chk('缺成绩/越界分数不套样式',
g100(null).cls === '' && g100(undefined).cls === '' && g100('abc').cls === '');
chk('合格线按满分折算后回传(150×80% = 120)', g150(100).line === 120, 'line=' + g150(100).line);
chk('scoreGrade 不再通过 window.DASH 取值(let 声明取不到 window,只能读自由变量 DASH)',
!/window\.DASH/.test(sgSrc[0]));
chk('membersTable 也不再走 window.DASH',
!/window\.DASH/.test(dashSrc.slice(dashSrc.indexOf('function membersTable'), dashSrc.indexOf('function membersTable') + 1600)));
}
console.log('\n--- 6) 公开访问:班级大屏免登录 ---');
const CLS2 = encodeURIComponent('高一(2)班');
const anonPage = await get('/dashboard.php?pid=p1&class=' + CLS2, []);
chk('未登录访问班级大屏返回 200(不再被弹到登录页)',
anonPage.status === 200 && !anonPage.loc, 'status=' + anonPage.status + ' loc=' + (anonPage.loc || ''));
chk('匿名页同样是 dash-class 形态', /<body class="dash dash-class">/.test(anonPage.raw));
chk('公开页不再渲染退出按钮', !/id="linkLogout"/.test(anonPage.raw));
chk('公开页渲染「公开 · 无需登录」角标', /class="pub-tag"/.test(anonPage.raw));
chk('取数带 class(公开访问后端要按班级裁剪,两处调用都不能漏)',
(dashSrc.match(/api\('dashboard',\s*dashParams\(/g) || []).length === 2,
'实际 ' + (dashSrc.match(/api\('dashboard',\s*dashParams\(/g) || []).length + ' 处');
chk('dashParams 在单班形态下补充 class 参数',
/if \(IS_CLASS && CUR\.cls\) p\.class = CUR\.cls;/.test(dashSrc));
const anonApi = await get('/api.php?action=dashboard&projectId=p1&class=' + CLS2, []);
const aj = JSON.parse(anonApi.raw);
chk('未登录调用 dashboard 接口成功', aj.ok === true, anonApi.raw.slice(0, 120));
chk('classes 只回指定班级',
Array.isArray(aj.data.classes) && aj.data.classes.length === 1 && aj.data.classes[0].class === '高一(2)班',
'返回 ' + (aj.data.classes || []).map(x => x.class).join('/'));
chk('别班数据没有泄漏', !JSON.stringify(aj.data.classes).includes('高一(1)班'));
chk('overall 已换成该班汇总且键名兼容',
aj.data.overall && typeof aj.data.overall.attempts_total === 'number',
'attempts_total=' + (aj.data.overall || {}).attempts_total);
chk('overall 不含全卷名单总数', aj.data.overall && aj.data.overall.roster_total === aj.data.classes[0].total,
'roster_total=' + (aj.data.overall || {}).roster_total + ' class.total=' + (aj.data.classes[0] || {}).total);
chk('响应带 public 标记', aj.data.public === true);
const anonNoCls = await get('/api.php?action=dashboard&projectId=p1', []);
chk('未登录且不指定 class → 拒绝(不能拿全卷数据)',
JSON.parse(anonNoCls.raw).ok === false, anonNoCls.raw.slice(0, 120));
const homeAnon = await get('/dashboard.php', []);
chk('未登录访问大屏首页仍然跳转登录页',
/login\.php/.test(homeAnon.loc || ''), 'loc=' + (homeAnon.loc || ''));
console.log('\n通过 ' + pass + ' 项,失败 ' + fail + ' 项');
cleanup();
process.exit(fail ? 1 : 0);
})();
+166
View File
@@ -0,0 +1,166 @@
// 测试随机抽取页的均衡分配算法(通过 stub 环境加载 assets/draw.js)
// 运行:node tools/test-draw-alloc.js
const fs = require('fs');
const path = require('path');
// stub 浏览器环境:draw.js 顶部只定义函数,尾部 document.addEventListener
global.window = {};
global.document = { addEventListener() {} };
global.localStorage = { getItem: () => null, setItem: () => {} };
global.location = {};
eval(fs.readFileSync(path.join(__dirname, '..', 'assets', 'draw.js'), 'utf8'));
const T = global.window.__nyDrawTest;
if (!T) { console.error('FAIL 未找到 __nyDrawTest 测试钩子'); process.exit(1); }
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 : '')); }
}
function sum(a) { return a.reduce((x, y) => x + y, 0); }
function eqArr(a, b) { return JSON.stringify(a) === JSON.stringify(b); }
/* ==================== 均分模式(weights = null) ==================== */
// A/B = 12/8 抽 10 → 各 5
chk('均分 12/8 抽10 → 5/5', eqArr(T.allocQuotas([12, 8], 10, null), [5, 5]),
JSON.stringify(T.allocQuotas([12, 8], 10, null)));
// 抽 1 人:余数小组优先
chk('均分 抽1人 → 小组优先', eqArr(T.allocQuotas([12, 8], 1, null), [0, 1]),
JSON.stringify(T.allocQuotas([12, 8], 1, null)));
// 12/8 抽 3 → 1/2
chk('均分 12/8 抽3 → 1/2', eqArr(T.allocQuotas([12, 8], 3, null), [1, 2]),
JSON.stringify(T.allocQuotas([12, 8], 3, null)));
// 容量不足:A=1, B=19, 抽 10 → 1/9
chk('均分 容量不足差额再分配', eqArr(T.allocQuotas([1, 19], 10, null), [1, 9]),
JSON.stringify(T.allocQuotas([1, 19], 10, null)));
// 全抽
chk('均分 全部抽取 = 各组全量', eqArr(T.allocQuotas([12, 8], 20, null), [12, 8]),
JSON.stringify(T.allocQuotas([12, 8], 20, null)));
// 超额
chk('均分 超额取全量', eqArr(T.allocQuotas([3, 4], 99, null), [3, 4]),
JSON.stringify(T.allocQuotas([3, 4], 99, null)));
// 20/20 抽 10
chk('均分 20/20 抽10 → 5/5', eqArr(T.allocQuotas([20, 20], 10, null), [5, 5]),
JSON.stringify(T.allocQuotas([20, 20], 10, null)));
// 三组 10/10/10 抽 8
chk('均分 三组 10/10/10 抽8', (() => {
const q = T.allocQuotas([10, 10, 10], 8, null);
return sum(q) === 8 && Math.max(...q) - Math.min(...q) <= 1;
})(), JSON.stringify(T.allocQuotas([10, 10, 10], 8, null)));
// 空数组
chk('空数组返回空', eqArr(T.allocQuotas([], 5, null), []));
/* ==================== 加权模式(按人数权重) ==================== */
// 12/8 抽 10 → 6/4(按人数加权)
chk('加权 12/8 抽10 → 6/4', eqArr(T.allocQuotas([12, 8], 10, [12, 8]), [6, 4]),
JSON.stringify(T.allocQuotas([12, 8], 10, [12, 8])));
// 12/8 抽 3 → 2/1(raw=[1.8,1.2], base=[1,1], 余数 .8 → A)
chk('加权 12/8 抽3 → 2/1', eqArr(T.allocQuotas([12, 8], 3, [12, 8]), [2, 1]),
JSON.stringify(T.allocQuotas([12, 8], 3, [12, 8])));
// 加权抽 1 → 按权重
chk('加权 12/8 抽1 → 1/0', eqArr(T.allocQuotas([12, 8], 1, [12, 8]), [1, 0]),
JSON.stringify(T.allocQuotas([12, 8], 1, [12, 8])));
// 自定义比例 6:4
chk('自定义 6:4 抽10 → 6/4', eqArr(T.allocQuotas([12, 8], 10, [6, 4]), [6, 4]),
JSON.stringify(T.allocQuotas([12, 8], 10, [6, 4])));
// 自定义 1:1 等价均分
chk('自定义 1:1 = 均分', eqArr(T.allocQuotas([12, 8], 10, [1, 1]), [5, 5]),
JSON.stringify(T.allocQuotas([12, 8], 10, [1, 1])));
// 极端比例 9:1 抽 5
chk('自定义 9:1 抽5', (() => {
const q = T.allocQuotas([12, 8], 5, [9, 1]);
return sum(q) === 5 && q[0] >= 4 && q[1] >= 0 && q[0] <= 12 && q[1] <= 8;
})(), JSON.stringify(T.allocQuotas([12, 8], 5, [9, 1])));
// 容量约束 [1,19] 抽 10:按权重 1:19 → raw=[0.5,9.5] → base=[0,9] + frac 相同时
// 给剩余容量大的组补余数 → A=0, B=10(A 容量只有 1,本来也只可能 0 或 1)
chk('加权 容量约束 [1,19] 抽10', (() => {
const q = T.allocQuotas([1, 19], 10, [1, 19]);
return sum(q) === 10 && q[0] === 0 && q[1] === 10;
})(), JSON.stringify(T.allocQuotas([1, 19], 10, [1, 19])));
// 全 0 权重 → 退化为均分
chk('全0权重 → 均分', eqArr(T.allocQuotas([12, 8], 10, [0, 0]), [5, 5]),
JSON.stringify(T.allocQuotas([12, 8], 10, [0, 0])));
// null 权重 → 均分
chk('null权重 → 均分', eqArr(T.allocQuotas([12, 8], 10, null), [5, 5]),
JSON.stringify(T.allocQuotas([12, 8], 10, null)));
/* ==================== 守恒 + 不超容量 + fuzz ==================== */
let fuzzOk = true;
let fuzzBad = '';
for (let t = 0; t < 1000; t++) {
const g = 1 + Math.floor(Math.random() * 5);
const sizes = Array.from({ length: g }, () => Math.floor(Math.random() * 30));
const n = Math.floor(Math.random() * 60);
// 随机权重
const weights = Array.from({ length: g }, () => Math.random() * 10);
// 50% 概率用 null 走均分
const w = Math.random() < 0.5 ? weights : null;
const q = T.allocQuotas(sizes, n, w);
const total = sizes.reduce((a, b) => a + b, 0);
const expect = Math.min(n, total);
if (sum(q) !== expect) { fuzzOk = false; fuzzBad = `sum=${sum(q)} expect=${expect} sizes=${sizes} n=${n} w=${w}`; break; }
let bad = false;
for (let i = 0; i < g; i++) if (q[i] < 0 || q[i] > sizes[i]) bad = true;
// 加权模式下"任意两组差 ≤ 1"不一定成立(按权重 9:1 抽 5 应是 5/0)
// 但均分模式下应成立
if (w === null) {
const allNotFull = q.every((v, i) => v < sizes[i]);
if (allNotFull && Math.max(...q) - Math.min(...q) > 1) bad = true;
}
if (bad) { fuzzOk = false; fuzzBad = `sizes=${sizes} n=${n} w=${w} q=${q}`; break; }
}
chk('1000 组随机 fuzz:守恒+不超容量+(均分时)均衡', fuzzOk, fuzzBad);
/* ==================== balanceKeyOf ==================== */
chk('有 A/B 组别 → 按 grp', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'B', cls: 'C1' }]) === 'grp');
chk('单组别 → 按班级', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'A', cls: 'C2' }]) === 'cls');
chk('无组别多班级 → 按班级', T.balanceKeyOf([{ grp: '', cls: 'C1' }, { grp: '', cls: 'C2' }]) === 'cls');
chk('都相同 → 不均衡', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'A', cls: 'C1' }]) === '');
/* ==================== pickBalanced ==================== */
{
// 12A + 8B,加权(weights=sizes)抽 10 → 应 6A/4B
const pool = [];
for (let i = 1; i <= 12; i++) pool.push({ sid: 'A' + i, name: 'A' + i, grp: 'A', cls: 'C' });
for (let i = 1; i <= 8; i++) pool.push({ sid: 'B' + i, name: 'B' + i, grp: 'B', cls: 'C' });
const r = T.pickBalanced(pool, 10, 'grp', [12, 8]);
const byGrp = { A: 0, B: 0 };
r.picked.forEach(m => byGrp[m.grp]++);
chk('加权 pickBalanced 12A/8B 抽10 → 6A/4B', byGrp.A === 6 && byGrp.B === 4,
JSON.stringify(byGrp) + ' detail=' + r.detail.join(','));
chk('pickBalanced 详情文案', r.detail.join('/') === 'A 6人/B 4人', r.detail.join('/'));
chk('pickBalanced 结果不重复', new Set(r.picked.map(m => m.sid)).size === 10);
}
{
// 均分(weights=null)抽 10 → 5A/5B
const pool = [];
for (let i = 1; i <= 12; i++) pool.push({ sid: 'A' + i, grp: 'A', cls: 'C' });
for (let i = 1; i <= 8; i++) pool.push({ sid: 'B' + i, grp: 'B', cls: 'C' });
const r = T.pickBalanced(pool, 10, 'grp', null);
const byGrp = { A: 0, B: 0 };
r.picked.forEach(m => byGrp[m.grp]++);
chk('均分 pickBalanced 12A/8B 抽10 → 5A/5B', byGrp.A === 5 && byGrp.B === 5,
JSON.stringify(byGrp) + ' detail=' + r.detail.join(','));
}
{
// 自定义比例 9:1 抽 5
const pool = [];
for (let i = 1; i <= 12; i++) pool.push({ sid: 'A' + i, grp: 'A' });
for (let i = 1; i <= 8; i++) pool.push({ sid: 'B' + i, grp: 'B' });
const r = T.pickBalanced(pool, 5, 'grp', [9, 1]);
const byGrp = { A: 0, B: 0 };
r.picked.forEach(m => byGrp[m.grp]++);
chk('自定义 9:1 抽5 → 全部 A', byGrp.A === 5 && byGrp.B === 0,
JSON.stringify(byGrp));
}
{
// 无维度(key='')走不到
const pool = [{ sid: 'x1', grp: '', cls: '' }, { sid: 'x2', grp: '', cls: '' }];
const r = T.pickBalanced(pool, 2, T.balanceKeyOf(pool) || 'grp', null);
chk('无维度也能抽取', r.picked.length === 2, JSON.stringify(r.picked.map(m => m.sid)));
}
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
process.exit(fail === 0 ? 0 : 1);
+186
View File
@@ -0,0 +1,186 @@
// e2e: 登录后立刻又报"未配置账号"、反复跳登录页的死循环
//
// 复现前提:本机配置里账号为空(或没存住)。旧代码 login.php 不看 ny_save_config 结果,
// 登录成功页照常显示"登录成功",但下一个请求 ny_load_config() 读到的账号仍是空的
// → 抛"未配置卷王登录账号" → 前端 auth 打标 → 跳登录页 → 死循环。
//
// 本测试锁死三件事:
// 1) 登录后凭据必须真的落盘(config_get 能读回账号、has_password=true)
// 2) 登录后立刻拉数据必须成功(不再出现"未配置账号")
// 3) 真出现鉴权失败时,响应必须带 extra.auth + extra.diag(前端据此停止自动跳转并给出可自查信息)
//
// 运行:node tools/test-login-loop.js
const http = require('http');
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const PORT = 18087;
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
const MOCK = { user: 'mock', pass: 'surveyking' };
// NY_DATA 指向临时目录:Git Bash 风格 /c/... 需还原成 C:\...,并写回 env 给 PHP 子进程。
// 注意:绝不能回退到项目下的 data/ —— 本测试会清空账密并重建库,落到真实目录等于破坏本地配置
// (曾因默认回退到 data/ 把 ny.db 删掉)。未显式指定时一律新建临时目录。
const os = require('os');
function resolveDataDir() {
let p = process.env.NY_DATA || path.join(os.tmpdir(), 'ny-login-loop-' + process.pid);
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2);
p = path.resolve(p);
process.env.NY_DATA = p;
return p;
}
const DATA = resolveDataDir();
fs.mkdirSync(DATA, { recursive: true });
const cfgFile = path.join(DATA, 'config.json');
const tokenFile = path.join(DATA, 'cache', 'token.json');
const dbFile = path.join(DATA, 'ny.db');
const origCfg = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, 'utf8') : null;
const hasDb = fs.existsSync(dbFile);
if (hasDb) { try { fs.unlinkSync(dbFile); } catch (e) {} }
try { fs.unlinkSync(tokenFile); } catch (e) {}
// 关键:模拟"账号没配/没存住"的现场——连接信息是对的,账号密码是空的
fs.writeFileSync(cfgFile, JSON.stringify({
base_url: 'http://127.0.0.1:19911',
api_prefix: '/api',
username: '',
password: '',
verify_ssl: false,
timeout: 10,
refresh_interval: 15,
page_size: 50,
dedupe: true,
include_temp_save: false,
name_keywords: '姓名,名字',
full_score: 100,
layout: 'default',
}, null, 2));
let cleanedUp = false, mock = null, srv = null;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
try { srv && srv.kill('SIGKILL'); } catch (e) {}
try { mock && mock.kill('SIGKILL'); } catch (e) {}
try {
if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg);
else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile);
} catch (e) {}
try { if (fs.existsSync(dbFile)) fs.unlinkSync(dbFile); } catch (e) {}
try { if (fs.existsSync(tokenFile)) fs.unlinkSync(tokenFile); } catch (e) {}
}
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(1); });
process.on('uncaughtException', e => { console.error('FATAL', e); cleanup(); process.exit(2); });
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'pipe' });
srv = spawn(PHP, INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]), { cwd: ROOT, stdio: 'pipe' });
let pass = 0, fail = 0;
function chk(name, cond, extra) {
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
}
const cookieJar = [];
let CSRF = '';
const jarHeader = () => (cookieJar.length ? cookieJar.map(c => c.replace(/;.*$/, '')).join('; ') : '');
function collect(res) {
for (const sc of (res.headers['set-cookie'] || [])) {
const pair = sc.split(';')[0];
const name = pair.split('=')[0];
const i = cookieJar.findIndex(x => x.startsWith(name + '='));
if (i >= 0) cookieJar[i] = pair; else cookieJar.push(pair);
}
}
function req(method, p, body) {
return new Promise((resolve, reject) => {
if (body && CSRF && !body._csrf) body = Object.assign({}, body, { _csrf: CSRF });
const data = body ? JSON.stringify(body) : '';
const h = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) };
if (jarHeader()) h['Cookie'] = jarHeader();
const r = http.request({ host: '127.0.0.1', port: PORT, method, path: p, headers: h }, res => {
const bufs = [];
res.on('data', c => bufs.push(c));
res.on('end', () => {
collect(res);
const raw = Buffer.concat(bufs).toString('utf8');
let j = null; try { j = JSON.parse(raw); } catch (e) {}
resolve({ status: res.statusCode, body: j, raw, loc: res.headers.location });
});
});
r.on('error', reject);
if (data) r.write(data);
r.end();
});
}
function formPost(p, fields) {
return new Promise((resolve, reject) => {
const data = new URLSearchParams(fields).toString();
const h = { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(data) };
if (jarHeader()) h['Cookie'] = jarHeader();
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path: p, headers: h }, res => {
const bufs = [];
res.on('data', c => bufs.push(c));
res.on('end', () => { collect(res); resolve({ status: res.statusCode, raw: Buffer.concat(bufs).toString('utf8'), loc: res.headers.location }); });
});
r.on('error', reject);
r.write(data); r.end();
});
}
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
await delay(2200);
console.log('\n--- 1) 登录:凭据必须真的落盘(登录前本机账号为空)---');
const lg = await formPost('/login.php', { username: MOCK.user, password: MOCK.pass });
chk('login.php 返回 200/302', lg.status === 200 || lg.status === 302, 'status=' + lg.status);
chk('登录页未出现"没能保存到本机配置"', lg.raw.indexOf('没能保存到本机配置') < 0, (lg.raw.match(/没能保存到本机配置[^<]*/) || [''])[0].slice(0, 160));
chk('登录页未出现"登录失败"', lg.raw.indexOf('登录失败') < 0, (lg.raw.match(/登录失败[^<]*/) || [''])[0].slice(0, 160));
chk('拿到会话 cookie', cookieJar.some(c => c.indexOf('NYSESS=') === 0), JSON.stringify(cookieJar));
chk('成功页写入 ny_login_at(前端防重复跳转依据)', lg.raw.indexOf('ny_login_at') >= 0);
const ac = await req('GET', '/api.php?action=auth_check');
chk('auth_check 已登录', ac.body && ac.body.data && ac.body.data.auth === true, ac.raw);
if (!(ac.body && ac.body.data && ac.body.data.auth)) { cleanup(); process.exit(1); }
CSRF = (ac.body && ac.body.data && ac.body.data.csrf_token) || CSRF;
const g1 = await req('GET', '/api.php?action=config_get');
CSRF = (g1.body && g1.body.data && g1.body.data.csrf_token) || CSRF;
chk('账号已回读到配置', g1.body && g1.body.data && g1.body.data.username === MOCK.user, JSON.stringify(g1.body && g1.body.data && g1.body.data.username));
chk('has_password=true(密码已存)', g1.body && g1.body.data && g1.body.data.has_password === true);
chk('响应不含明文密码', !('password' in ((g1.body && g1.body.data) || {})));
console.log('\n--- 2) 登录后立刻拉数据:不应再出现"未配置账号" ---');
const d1 = await req('GET', '/api.php?action=data&projectId=p1&force=1');
chk('data 成功', d1.body && d1.body.ok === true, (d1.raw || '').slice(0, 200));
chk('data 未报未配置账号', (d1.raw || '').indexOf('未配置卷王登录账号') < 0, (d1.raw || '').slice(0, 160));
chk('data 未被标记为鉴权失败', !(d1.body && d1.body.extra && d1.body.extra.auth), JSON.stringify(d1.body && d1.body.extra));
console.log('\n--- 3) 鉴权真失败时:必须带 auth 标记 + 可自查的诊断 ---');
const sv = await req('POST', '/api.php?action=config_save', { password: 'definitely-wrong' });
chk('改错密码已保存', sv.body && sv.body.ok === true, (sv.raw || '').slice(0, 160));
const d2 = await req('GET', '/api.php?action=data&projectId=p1&force=1');
chk('此时 data 失败', d2.body && d2.body.ok === false, (d2.raw || '').slice(0, 160));
chk('extra.auth=true', !!(d2.body && d2.body.extra && d2.body.extra.auth), JSON.stringify(d2.body && d2.body.extra));
const diag = d2.body && d2.body.extra && d2.body.extra.diag;
chk('extra.diag 存在', !!diag, JSON.stringify(diag));
if (diag) {
chk('diag.has_username=true(账号其实存住了)', diag.has_username === true);
chk('diag 含目录/文件/数据库可写标记', 'dir_writable' in diag && 'cfg_writable' in diag && 'db_writable' in diag);
chk('diag 含配置来源', typeof diag.storage === 'string' && diag.storage !== '');
}
console.log('\n--- 4) 改回正确密码后应恢复 ---');
const sv2 = await req('POST', '/api.php?action=config_save', { password: MOCK.pass });
chk('恢复密码已保存', sv2.body && sv2.body.ok === true);
const d3 = await req('GET', '/api.php?action=data&projectId=p1&force=1');
chk('data 恢复正常', d3.body && d3.body.ok === true, (d3.raw || '').slice(0, 160));
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
cleanup();
process.exit(fail === 0 ? 0 : 1);
})();
+262
View File
@@ -0,0 +1,262 @@
// 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/8.2.34/php.exe';
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
const DEBUG = !!process.env.NY_DEBUG;
// 数据目录优先读 NY_DATA 环境变量(与 bootstrap.php 一致),让测试整体指向临时目录,
// 不污染生产库(曾因写 mock 配置把生产 base_url 改成 127.0.0.1)。
// Git Bash 风格 /c/Users/... 在 Windows 上 path.resolve 会误判成 C:\c\...,需还原;并把规范化后的
// Windows 绝对路径写回 process.env.NY_DATA,让子进程(PHP 内置服务器)也拿到正确路径。
function resolveDataDir() {
let p = process.env.NY_DATA || path.join(ROOT, 'data');
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2);
p = path.resolve(p);
process.env.NY_DATA = p;
return p;
}
const DATA = resolveDataDir();
fs.mkdirSync(DATA, { recursive: true });
const UPLOADS = path.join(DATA, 'uploads');
// ---------- 备份 / 还原运行时数据 ----------
// 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); }
}
}
const cfgFile = path.join(DATA, 'config.json');
fs.writeFileSync(cfgFile, JSON.stringify({
base_url: 'http://127.0.0.1:' + MOCK_PORT,
api_prefix: '/api',
username: 'mock',
password: 'surveyking',
verify_ssl: false,
timeout: 10,
refresh_interval: 15,
page_size: 50,
include_temp_save: false,
dedupe: true,
name_keywords: '姓名,名字',
full_score: 100,
pass_rate: 60,
layout: 'default',
}, null, 2));
let mock = null, srv = null, cleanedUp = false;
function killAll() {
for (const p of [srv, mock]) {
try { p && p.kill('SIGKILL'); } catch (e) {}
}
}
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
killAll();
// 丢弃本次测试产生的运行时数据,换回备份
for (const f of ['ny.db', 'ny.db-wal', 'ny.db-shm', 'config.json', 'cache/token.json']) {
const p = path.join(DATA, f);
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch (e) {}
}
// 统一用「覆盖写回 + 尽力删除备份」的方式还原,避免删除被拦导致还原失败
for (const [p, bak] of moved.concat(copied)) {
try { if (fs.existsSync(bak)) fs.copyFileSync(bak, p); } catch (e) {}
try { if (fs.existsSync(bak)) fs.unlinkSync(bak); } catch (e) {}
}
}
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(1); });
process.on('uncaughtException', (e) => { console.error('FATAL', e); cleanup(); process.exit(2); });
// ---------- 启动依赖服务 ----------
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
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);
}
// 登录(最多重试 3 次)
for (let i = 0; i < 3 && CSRF === ''; i++) {
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);
})();
+213
View File
@@ -0,0 +1,213 @@
// e2e: 卷内「考试白名单 / 通讯录名单」自动驱动缺/待考名单(DataService::build)
// 运行:node tools/test-whitelist.js (NY_DEBUG=1 可打印调试信息)
//
// 真实卷王模型:应考名单 = 项目「参与者(受访者)」(t_project_partner, type=RESPONDENT_SYS_USER/IMP_USER),
// 经 /api/project/partner/list 返回;不再是旧代码误读的 surveySetting.whiteLists(题目选项白名单)。
// 覆盖:
// 1) p1 配置了受访者名单(Project Partners)→ 应考名单恰好为受访者,缺考仅来自受访者(不含名单外成员)
// 2) p2 未配置受访者 → 按现状(全名单)显示,名单外成员仍在应考/缺考中
// 3) 已移除的 roster_import_whitelist 接口不再可用(按钮已删除,改为自动驱动)
// 4) 上传防护不被破坏(导入后 data/uploads 即清)
const http = require('http');
const net = require('net');
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const PHP_PORT = 18082;
const MOCK_PORT = 19911;
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
const DEBUG = !!process.env.NY_DEBUG;
function resolveDataDir() {
let p = process.env.NY_DATA || path.join(ROOT, 'data');
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + p.slice(2);
p = path.resolve(p);
process.env.NY_DATA = p;
return p;
}
const DATA = resolveDataDir();
fs.mkdirSync(DATA, { recursive: true });
const UPLOADS = path.join(DATA, 'uploads');
const moved = [], copied = [];
for (const f of ['ny.db', 'cache/token.json']) {
const p = path.join(DATA, f);
if (fs.existsSync(p)) {
const bak = p + '.nybak';
try { fs.renameSync(p, bak); moved.push([p, bak]); } catch (e) {}
}
}
for (const f of ['config.json', 'roster.json', 'project_config.json']) {
const p = path.join(DATA, f);
if (fs.existsSync(p)) {
const bak = p + '.nybak';
try { fs.copyFileSync(p, bak); copied.push([p, bak]); } catch (e) {}
}
}
const cfgFile = path.join(DATA, 'config.json');
fs.writeFileSync(cfgFile, JSON.stringify({
base_url: 'http://127.0.0.1:' + MOCK_PORT,
api_prefix: '/api',
username: 'mock',
password: 'surveyking',
verify_ssl: false,
timeout: 10,
refresh_interval: 15,
page_size: 50,
include_temp_save: false,
dedupe: true,
name_keywords: '姓名,名字',
full_score: 100,
pass_rate: 60,
layout: 'default',
}, null, 2));
let mock = null, srv = null, cleanedUp = false;
function killAll() { for (const p of [srv, mock]) { try { p && p.kill('SIGKILL'); } catch (e) {} } }
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
killAll();
for (const f of ['ny.db', 'ny.db-wal', 'ny.db-shm', 'config.json', 'cache/token.json']) {
const p = path.join(DATA, f);
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch (e) {}
}
for (const [p, bak] of moved.concat(copied)) {
try { if (fs.existsSync(bak)) fs.copyFileSync(bak, p); } catch (e) {}
try { if (fs.existsSync(bak)) fs.unlinkSync(bak); } catch (e) {}
}
}
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(1); });
process.on('uncaughtException', (e) => { console.error('FATAL', e); cleanup(); process.exit(2); });
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
const phpArgs = INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PHP_PORT, '-t', ROOT]);
srv = spawn(PHP, phpArgs, { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
function waitPort(port, timeoutMs) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve) => {
(function attempt() {
const s = net.connect({ host: '127.0.0.1', port });
const done = (ok) => { s.destroy(); resolve(ok); };
s.setTimeout(800, () => done(false));
s.on('connect', () => done(true));
s.on('error', () => { if (Date.now() > deadline) return done(false); setTimeout(attempt, 300); });
s.on('timeout', () => { if (Date.now() > deadline) return done(false); setTimeout(attempt, 300); });
})();
});
}
const cookieJar = [];
let CSRF = '';
function baseHeaders() {
const h = {};
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
return h;
}
function absorb(res) {
for (const sc of res.headers['set-cookie'] || []) {
const pair = sc.split(';')[0];
const name = pair.split('=')[0];
const i = cookieJar.findIndex(x => x.startsWith(name + '='));
if (i >= 0) cookieJar[i] = pair; else cookieJar.push(pair);
}
}
function request(method, urlPath, headers, bodyBuf) {
return new Promise((resolve, reject) => {
const h = Object.assign({}, headers || {});
if (bodyBuf) h['Content-Length'] = bodyBuf.length;
const r = http.request({ host: '127.0.0.1', port: PHP_PORT, method, path: urlPath, headers: h }, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => { absorb(res); const raw = Buffer.concat(chunks).toString('utf8'); let j = null; try { j = JSON.parse(raw); } catch (e) {} resolve({ status: res.statusCode, body: j, raw }); });
});
r.on('error', reject);
if (bodyBuf) r.write(bodyBuf);
r.end();
});
}
const get = (p) => request('GET', p, baseHeaders());
function formPost(p, fields) {
const body = Buffer.from(new URLSearchParams(fields).toString());
return request('POST', p, Object.assign({ 'Content-Type': 'application/x-www-form-urlencoded' }, baseHeaders()), body);
}
function upload(urlPath, fields, file) {
const boundary = '----nyUpload' + Math.random().toString(16).slice(2);
const parts = [];
for (const [k, v] of Object.entries(fields)) parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${file.filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`));
parts.push(Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content));
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
return request('POST', urlPath, Object.assign({ 'Content-Type': 'multipart/form-data; boundary=' + boundary }, baseHeaders()), Buffer.concat(parts));
}
let pass = 0, fail = 0;
function chk(name, cond, extra) {
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
}
(async () => {
const mockUp = await waitPort(MOCK_PORT, 15000);
const phpUp = await waitPort(PHP_PORT, 15000);
chk('mock 卷王服务已就绪', mockUp);
chk('PHP 内置服务器已就绪', phpUp);
if (!mockUp || !phpUp) { console.log(' 服务未就绪,终止测试'); cleanup(); process.exit(1); }
for (let i = 0; i < 3 && CSRF === ''; i++) {
await formPost('/login.php', { username: 'mock', password: 'surveyking' });
const ac = await get('/api.php?action=auth_check');
if (ac.body && ac.body.data && ac.body.data.auth === true) {
const cg = await get('/api.php?action=config_get');
CSRF = (cg.body && cg.body.data && cg.body.data.csrf_token) || '';
}
}
chk('已登录并取到 CSRF token', CSRF !== '');
if (CSRF === '') { console.log(' 登录失败,终止测试'); cleanup(); process.exit(1); }
console.log('\n--- 1) 先导入一份名单(含白名单内 90001-90005 + 一个白名单外 90099) ---');
const csv = '学号,姓名,班级\n90001,甲,1班\n90002,乙,1班\n90003,丙,1班\n90004,丁,1班\n90005,戊,1班\n90099,外,1班\n';
const imp = await upload('/api.php?action=roster_import', { mode: 'replace', _csrf: CSRF }, {
filename: 'roster.csv', content: Buffer.from('\xEF\xBB\xBF' + csv, 'utf8'),
});
chk('名单导入成功', !!(imp.body && imp.body.ok === true), (imp.body && (imp.body.error || imp.body.msg)) || '');
chk('导入人数为 6', !!(imp.body && imp.body.data && imp.body.data.imported === 6), imp.body ? String(imp.body.data && imp.body.data.imported) : '');
console.log('\n--- 2) p1 卷内白名单驱动应考名单 ---');
const d1 = await get('/api.php?action=data&projectId=p1');
chk('data(p1) 成功', !!(d1.body && d1.body.ok === true), (d1.body && (d1.body.error || d1.body.msg)) || d1.raw.slice(0, 120));
const d1d = (d1.body && d1.body.data) || {};
chk('p1 标记为白名单驱动', d1d.whitelist_driven === true, 'whitelist_driven=' + d1d.whitelist_driven);
chk('p1 白名单应考 5 人', d1d.whitelist_total === 5, 'whitelist_total=' + d1d.whitelist_total);
const r1 = (d1d.roster || []).map(s => String(s.sid)).sort();
chk('p1 应考名单恰好 90001-90005', JSON.stringify(r1) === JSON.stringify(['90001', '90002', '90003', '90004', '90005']), JSON.stringify(r1));
const a1 = (d1d.absent || []).map(s => String(s.sid)).sort();
chk('p1 缺考仅来自白名单(不含 90099)', !a1.includes('90099'), JSON.stringify(a1));
chk('p1 缺考是白名单子集', a1.every(s => r1.includes(s)), JSON.stringify(a1));
console.log('\n--- 3) p2 未设置白名单 → 按现状(全名单)---');
const d2 = await get('/api.php?action=data&projectId=p2');
chk('data(p2) 成功', !!(d2.body && d2.body.ok === true), (d2.body && (d2.body.error || d2.body.msg)) || '');
const d2d = (d2.body && d2.body.data) || {};
chk('p2 非白名单驱动', d2d.whitelist_driven === false, 'whitelist_driven=' + d2d.whitelist_driven);
const r2 = (d2d.roster || []).map(s => String(s.sid)).sort();
chk('p2 含白名单外成员 90099', r2.includes('90099'), JSON.stringify(r2));
console.log('\n--- 4) 已移除的 roster_import_whitelist 接口不再可用 ---');
const removed = await formPost('/api.php?action=roster_import_whitelist', { projectId: 'p1', mode: 'replace', _csrf: CSRF });
chk('roster_import_whitelist 已停用', !!(removed.body && removed.body.ok === false), (removed.body && (removed.body.error || removed.body.msg)) || '');
console.log('\n--- 5) 上传防护仍有效(导入后 data/uploads 即清) ---');
const left = fs.existsSync(UPLOADS) ? fs.readdirSync(UPLOADS).filter(f => f !== '.htaccess' && f !== 'index.html') : [];
chk('data/uploads 无残留', left.length === 0, JSON.stringify(left));
console.log('');
console.log(`结果:${pass} 通过 / ${fail} 失败(共 ${pass + fail})`);
cleanup();
process.exit(fail === 0 ? 0 : 1);
})();
+266
View File
@@ -0,0 +1,266 @@
<?php
/**
* 自测:XlsxWriter 生成 → XlsxReader 读回,验证闭环与内容正确
* 运行:php tools/test-xlsxwriter.php
*/
declare(strict_types=1);
require __DIR__ . '/../bootstrap.php';
$out = __DIR__ . '/_xlsxwriter_out.xlsx';
$rows = [
['姓名', '登陆账号'],
['张三', '2024010101'],
['李四', '2024010102001234'], // 超长学号:必须原样保留,不能变科学计数法
['王小明', 'A001'],
['Bob "引号" & <标签>', '123456789012345678'],
];
$bin = XlsxWriter::render($rows, ['widths' => [16, 24], 'title' => '白名单']);
file_put_contents($out, $bin);
echo "生成字节数: " . strlen($bin) . PHP_EOL;
// 1) ZIP 头必须是 PK
$pass = 0; $fail = 0;
function chk(string $name, bool $ok, string $extra = ''): void
{
global $pass, $fail;
if ($ok) { $pass++; echo " PASS $name" . ($extra ? " — $extra" : '') . PHP_EOL; }
else { $fail++; echo " FAIL $name" . ($extra ? " — $extra" : '') . PHP_EOL; }
}
chk('ZIP 魔数 PK', substr($bin, 0, 2) === 'PK');
// 用 ZipArchive 解开校验包内结构(ZIP 内容已压缩,不能直接在二进制里搜字符串)
$parts = [];
if (class_exists('ZipArchive')) {
$z = new ZipArchive();
if ($z->open($out) === true) {
for ($i = 0; $i < $z->numFiles; $i++) {
$n = $z->getNameIndex($i);
$parts[$n] = $z->getFromIndex($i);
}
$z->close();
}
}
chk('包内含 xl/workbook.xml', isset($parts['xl/workbook.xml']));
chk('包内含 xl/worksheets/sheet1.xml', isset($parts['xl/worksheets/sheet1.xml']));
chk('包内含 xl/sharedStrings.xml', isset($parts['xl/sharedStrings.xml']));
chk('Content_Types 声明 workbook 主部件',
strpos((string)($parts['[Content_Types].xml'] ?? ''), 'spreadsheetml.sheet.main+xml') !== false);
chk('文本格式 numFmtId=49 已写入',
strpos((string)($parts['xl/styles.xml'] ?? ''), 'numFmtId="49"') !== false);
chk('学号以共享字符串写入(非纯数字单元格)',
strpos((string)($parts['xl/sharedStrings.xml'] ?? ''), '2024010102001234') !== false);
chk('列名(A/B)正确',
strpos((string)($parts['xl/worksheets/sheet1.xml'] ?? ''), 'r="A1"') !== false
&& strpos((string)($parts['xl/worksheets/sheet1.xml'] ?? ''), 'r="B1"') !== false);
// 2) 用自带的 XlsxReader 读回
$back = XlsxReader::read($out);
chk('XlsxReader 成功解析', is_array($back) && count($back) > 0, '行数=' . count($back));
chk('行数一致', count($back) === count($rows), '期望 ' . count($rows) . ',实际 ' . count($back));
$first = $back[0] ?? [];
chk('表头 = 姓名/登陆账号',
($first[0] ?? '') === '姓名' && ($first[1] ?? '') === '登陆账号',
json_encode($first, JSON_UNESCAPED_UNICODE));
chk('普通行正确', ($back[1][0] ?? '') === '张三' && ($back[1][1] ?? '') === '2024010101',
json_encode($back[1] ?? [], JSON_UNESCAPED_UNICODE));
chk('超长学号不被转科学计数法', ($back[2][1] ?? '') === '2024010102001234',
'实际=' . ($back[2][1] ?? 'NULL'));
chk('18 位纯数字学号原样保留', ($back[4][1] ?? '') === '123456789012345678',
'实际=' . ($back[4][1] ?? 'NULL'));
chk('特殊字符转义正确', ($back[4][0] ?? '') === 'Bob "引号" & <标签>',
'实际=' . ($back[4][0] ?? 'NULL'));
// 3) 无 ZipArchive 时的手写打包路径(stored):单独生成再用 XlsxReader 读回
$manualOut = __DIR__ . '/_xlsxwriter_manual.xlsx';
$manual = XlsxWriter::packManual($parts !== [] ? $parts : []);
if ($parts !== []) {
file_put_contents($manualOut, $manual);
chk('手写 ZIP 魔数 PK', substr($manual, 0, 2) === 'PK');
$mBack = [];
try {
$mBack = XlsxReader::read($manualOut);
} catch (Throwable $e) {
$mBack = [];
}
chk('手写打包产物可被解析', count($mBack) === count($rows), '行数=' . count($mBack));
chk('手写打包内容一致',
($mBack[1][0] ?? '') === '张三' && ($mBack[4][1] ?? '') === '123456789012345678',
json_encode($mBack[1] ?? [], JSON_UNESCAPED_UNICODE));
@unlink($manualOut);
}
// 4) 合并单元格(「每次考试」导出用):写入 mergeCells,且被合并的非首格必须留空
$mergedRows = [
['学号', '姓名', '考试名称', '第几次', '成绩', '最高分'],
['2021001', '张三', '数学期中', '第1次', '90', '是'],
['', '', '', '第2次', '80', ''], // 学号/姓名/考试名称被合并占用
['', '', '英语期中', '第1次', '85', '是'],
];
$mOut = __DIR__ . '/_xlsxwriter_merge.xlsx';
$mBin = XlsxWriter::renderMulti([[
'name' => '每次考试',
'rows' => $mergedRows,
'widths' => [20, 14, 26, 10, 10, 8],
// A2:A3、B2:B3 纵向合并;C2:C2 为退化区间(应被丢弃);越界区间也应被丢弃
'merges' => [[2, 1, 3, 1], [2, 2, 3, 2], [2, 3, 2, 3], [2, 6, 99, 6]],
]]);
file_put_contents($mOut, $mBin);
chk('合并表 ZIP 魔数 PK', substr($mBin, 0, 2) === 'PK');
// 取 sheet XML:优先 ZipArchive;没有 zip 扩展时借 XlsxReader 的纯 PHP 解包(stored/deflate 都能读)
$mParts = [];
if (class_exists('ZipArchive')) {
$mz = new ZipArchive();
if ($mz->open($mOut) === true) {
for ($i = 0; $i < $mz->numFiles; $i++) {
$n = $mz->getNameIndex($i);
$mParts[$n] = $mz->getFromIndex($i);
}
$mz->close();
}
}
if ($mParts === []) {
try {
$rm = new ReflectionMethod('XlsxReader', 'readZip');
$rm->setAccessible(true);
$mParts = (array)$rm->invoke(null, $mOut);
} catch (Throwable $e) {
$mParts = [];
}
}
$sheetXml = (string)($mParts['xl/worksheets/sheet1.xml'] ?? '');
if ($sheetXml !== '') {
chk('写入 mergeCells 且计数为 2(退化/越界区间被丢弃)',
strpos($sheetXml, '<mergeCells count="2">') !== false, '未找到 mergeCells count=2');
chk('合并区间 A2:A3 / B2:B3 正确',
strpos($sheetXml, '<mergeCell ref="A2:A3"/>') !== false
&& strpos($sheetXml, '<mergeCell ref="B2:B3"/>') !== false);
chk('mergeCells 位于 sheetData 之后',
strpos($sheetXml, '</sheetData><mergeCells') !== false);
chk('被合并的非首格只带样式、不带值(A3)',
strpos($sheetXml, '<c r="A3" s="2"/>') !== false, '未找到 <c r="A3" s="2"/>');
chk('区块首格仍然保留(A2 / B2 在)',
strpos($sheetXml, 'r="A2"') !== false && strpos($sheetXml, 'r="B2"') !== false);
chk('英语块第 4 行考试名称未被吞(C4 在)', strpos($sheetXml, 'r="C4"') !== false);
chk('样式表已写入细边框', strpos((string)($mParts['xl/styles.xml'] ?? ''), '<left style="thin">') !== false);
} else {
chk('(跳过)无 ZipArchive,合并单元格 XML 未校验', true);
}
$mBack = XlsxReader::read($mOut);
chk('含合并表可被 XlsxReader 解析', count($mBack) === count($mergedRows), '行数=' . count($mBack));
chk('合并表首行内容正确', ($mBack[1][1] ?? '') === '张三' && ($mBack[3][2] ?? '') === '英语期中',
json_encode($mBack[1] ?? [], JSON_UNESCAPED_UNICODE));
@unlink($mOut);
/* 5) 两行表头 + 交叉合并(「多考试导出」块表用):header_rows=2 时两行都按表头样式输出,
并用 cell_styles 标记「最高分红字 / 合格次数绿底」 */
$blockRows = [
['学号', '姓名', '第几次', '数学', '', '英语', ''],
['', '', '', '成绩', '', '成绩', ''],
['356001', '张三', '1', '112', '112'],
['', '', '2', '100', '100'],
['', '', '3', '136', '133'],
['', '', '合格次数', '3', '', '2', ''],
['356002', '李四', '1', '未考', '', '130'],
];
$bOut = __DIR__ . '/_xlsxwriter_block.xlsx';
$bBin = XlsxWriter::renderMulti([[
'name' => '每次考试',
'rows' => $blockRows,
'widths' => [20, 14, 9, 10, 11, 10, 11],
'merges' => [
[1, 1, 2, 1], [1, 2, 2, 2], [1, 3, 2, 3], [1, 4, 1, 5], [1, 6, 1, 7], // 表头
[3, 1, 6, 1], [3, 2, 6, 2], [3, 3, 5, 3], // 张三 学号/姓名/第几次
// 「第几次」不并入合格次数行,
// 否则 C6 的「合格次数」标签会被吞掉
[7, 4, 7, 5], // 李四 数学「未考」跨 2 列
],
'header_rows' => 2,
'cell_styles' => [
'5,4' => 'red', // 张三数学最高分 136 → 红字
'6,4' => 'green', // 张三数学合格次数 → 绿底
'6,6' => 'green', // 张三英语合格次数 → 绿底
'8,2' => 'nope', // 未知标记应被静默忽略
],
]]);
file_put_contents($bOut, $bBin);
chk('两行表头表 ZIP 魔数 PK', substr($bBin, 0, 2) === 'PK');
$bParts = [];
if (class_exists('ZipArchive')) {
$bz = new ZipArchive();
if ($bz->open($bOut) === true) {
for ($i = 0; $i < $bz->numFiles; $i++) {
$bn = $bz->getNameIndex($i);
$bParts[$bn] = $bz->getFromIndex($i);
}
$bz->close();
}
}
$bXml = (string)($bParts['xl/worksheets/sheet1.xml'] ?? '');
$bStyles = (string)($bParts['xl/styles.xml'] ?? '');
if ($bXml !== '') {
chk('第 2 行表头也用表头样式(s="1")',
strpos($bXml, '<c r="A2" s="1"/>') !== false && strpos($bXml, 'r="D2" s="1"') !== false);
chk('数据行用文本样式(s="2")', strpos($bXml, 'r="A3" s="2"') !== false);
chk('表头合并区间齐备',
strpos($bXml, '<mergeCell ref="A1:A2"/>') !== false
&& strpos($bXml, '<mergeCell ref="D1:E1"/>') !== false
&& strpos($bXml, '<mergeCell ref="F1:G1"/>') !== false
&& strpos($bXml, '<mergeCell ref="D7:E7"/>') !== false);
chk('mergeCells 计数 = 9', strpos($bXml, '<mergeCells count="9">') !== false);
chk('「第几次」不并入合格次数行(C6 仍带值)',
strpos($bXml, '<c r="C6" s="2" t="s"><v>16</v></c>') !== false);
// ---- 单元格级样式:最高分红字(s="3")/ 合格次数绿底(s="4") ----
chk('最高分标红用红字样式(D5 s="3")', strpos($bXml, '<c r="D5" s="3"') !== false);
chk('合格次数达标用绿底样式(D6 s="4")', strpos($bXml, '<c r="D6" s="4"') !== false);
chk('合格次数达标用绿底样式(F6 s="4")', strpos($bXml, '<c r="F6" s="4"') !== false);
chk('未标记格仍用普通文本样式(D3 s="2")', strpos($bXml, '<c r="D3" s="2"') !== false);
// 样式表里必须真的存在红字字体与绿色填充,否则 Excel 打开是一片黑白
chk('styles 含红色字体(FFD0021B)', strpos($bStyles, 'FFD0021B') !== false);
chk('styles 含绿色填充(FF63C384)', strpos($bStyles, 'FF63C384') !== false);
chk('cellXfs 数量 = 6', strpos($bStyles, '<cellXfs count="6">') !== false);
// XML 必须合法:结构错一个字符 Excel 就会提示"修复",用 DOM 解析一遍最省事
if (class_exists('DOMDocument')) {
$okXml = true;
foreach (['sheet1.xml' => $bXml, 'styles.xml' => $bStyles] as $part => $xml) {
$doc = new DOMDocument();
if ($xml === '' || !@$doc->loadXML($xml)) {
$okXml = false;
chk('XML 可解析:' . $part, false, '解析失败');
}
}
if ($okXml) {
chk('XML 可解析(sheet / styles)', true);
}
}
} else {
chk('(跳过)无 ZipArchive,两行表头 XML 未校验', true);
}
$bBack = XlsxReader::read($bOut);
chk('两行表头表可被读回', count($bBack) === count($blockRows), '行数=' . count($bBack));
chk('两行表头表内容正确',
($bBack[2][0] ?? '') === '356001'
&& ($bBack[5][2] ?? '') === '合格次数' // 合格次数标签不被合并吞掉
&& ($bBack[6][3] ?? '') === '未考'
&& ($bBack[4][3] ?? '') === '136' && ($bBack[4][4] ?? '') === '133',
json_encode($bBack[6] ?? [], JSON_UNESCAPED_UNICODE));
@unlink($bOut);
@unlink($out);
echo PHP_EOL . "结果:$pass 通过,$fail 失败" . PHP_EOL;
exit($fail === 0 ? 0 : 1);