跳转至

优化需求说明(2026-09-16)

适用范围:题库管理(qbank) / 实时场控(admin) / 整卷考试(exam) / Excel·WPS 操作题(excel_wps)。 每条含 功能目标 · 现状基线 · 边界(不做什么)· 验收标准,验收标准均为可观测、可量化条件。


1. /api/admin/questions 服务端分页

功能目标 题目总量超过约 1500 题时,题目列表接口改为服务端分页,避免每次刷新都全量拉取整张题表。

现状基线(实测,600 题 / 40 考生 / Edge) - /api/admin/questions 全量返回 154KB、16ms;目前靠 qFingerprint() 指纹比对跳过 DOM 重建,但请求体仍是全量,8s 一次。 - 线性外推:1500 题 ≈ 385KB、3000 题 ≈ 770KB,单请求已超过 Win7 老机器一次解析的舒适区。

边界 - 阈值 QBIG = 1500(顶层题位数量,不含材料小题):≤1500 题保持现状(全量返回),避免给绝大多数班级引入分页状态机;>1500 才启用分页。 - 只改"取数方式",不改房间上下文、题池(pool_qids)、当前题/已出题高亮语义。 - 不影响 /api/admin/qb_draw/api/admin/pool/api/admin/set_q

实现要点 - GET /api/admin/questions?offset=0&limit=200limit 上限 500),返回新增 total / offset / has_morecurrent/running/mode/limit/pool/room 每次照常返回。 - 计数用 SELECT COUNT(*) FROM questions WHERE room_code=? AND (parent_id IS NULL OR parent_id=0);子题仍在同请求内按页 IN (...) 批量取(禁止 N+1)。 - 前端 refreshQuestions():分页模式下按页追加进 allQs;指纹比对对已加载部分继续生效。

验收标准 1. 1500 题场景:单次响应 ≤60KB、≤40ms(本地 SQLite);3000 题单页 ≤60KB。 2. 首屏(第一页 200 题)渲染完成 ≤1.5s;滚动到底自动续页,无重复、无漏题、无错位。 3. ≤1500 题时请求不带 offset/limit,行为与今天完全一致(回归项)。 4. 「全选」语义在 UI 明示:已选 x / 已加载 y(共 z),并提供「加载全部」;删除按 id 列表分批处理。 5. _e2e_smoke.py 新增断言:题量 >1500 时请求带 offset/limithas_more 正确翻页。


2. 真正的虚拟滚动(只保留视口内 DOM)

功能目标 把"分批渲染(每批 50 题,滚动到底追加)"升级为真虚拟滚动:视口外的 .q-item 从 DOM 中移除,用占位撑起滚动高度。

现状基线 - 600 题全量展开后常驻 9360 节点;最坏(840 个分布块全渲染)23573 节点 / 7MB 堆;滚动 20 次 0.65s(尚可,但节点数随题量线性增长,3000 题 ≈ 5 万节点,Win7 会明显掉帧)。

边界 - 题目高度不定(材料大题随子题数变化),必须用"测量后缓存高度 + 未测量时用估算值兜底 + 测量后校正滚动位置"的动态虚拟滚动;禁止按固定行高硬算(会导致滚动错位/白屏)。 - 虚拟滚动只管题目条目;分布挂载点随条目一起创建/销毁(与第 3 条配合)。 - 不引入第三方虚拟列表库(离线打包体积约束)。

必须兼容的交互 全选 / 批量删除(已改造为 qSel Set 数据态,天然兼容)、勾选回显、当前题与已出题高亮、材料小题「出这小题」、「临时出这题 / 编辑题目 / 删除」按钮、悬浮控制台拖放面板不受影响。

验收标准 1. 600 题列表常驻 DOM ≤1200 节点;3000 题同样 ≤1200 节点(与题量解耦)。 2. 连续滚动 60 次:单帧耗时 P95 <16ms,无白屏、无跳动、无重复渲染。 3. 窗口 resize / 抽屉钉住导致宽度变化时高度自动重算,滚动位置不错位。 4. 删除某题后高度缓存同步失效重建,列表连续性正确。 5. 键盘 PageUp/PageDown、鼠标滚轮、拖动滚动条三种方式均正常。


3. 离屏分布块回收

功能目标 对已离开视口的分布块做回收,从"最坏 840 块 / 7MB"降到"任意时刻 ≤60 块",内存占用与题量完全解耦。

现状基线 - 最坏情况(840 个分布块全部渲染)23573 节点 / 7MB 堆(上一轮实测)。当前只做"进入视口才加载",没有回收

边界 - 回收范围:只回收 DOM 与名单缓存distCache(每题一份计数小对象)保留,避免来回滚动重复请求。 - 不回收:当前悬停的块、正在请求中的题、当前正在出的题。 - 回收后重新进入视口必须"无感重建"(数据仍在 distCache,直接重绘,不额外发请求)。

实现要点 - 回收阈值:离屏超过 2 屏rootMargin 之外再留 2×视口高)即清空 innerHTML + 移除 .on。 - LRU 上限:.q-dist.on 同时存在 ≤60 块,超出按最近最少使用淘汰。 - 名单缓存 nameCache 改 LRU,上限 30 题nameReq 同步清理。 - 复用现有 IntersectionObserver(观察 .q-item,因 .q-dist 空态是 display:none,IO 不会回调)。

验收标准 1. 任意滚动位置下 .q-dist.on ≤60;滚完 600 题后堆 ≤15MB、节点数 ≤1200+60×12。 2. 离屏再回屏:分布自动重建,数值与此前一致(不重新请求 dist_batch,可用请求计数断言)。 3. 悬停中的块不会被回收(浮层不闪断)。 4. 已结算 → distClear() 仍能把全部块清零(第 3 条不得破坏该行为)。


4. 极低配设备自适应刷新间隔(2s → 3~5s)

功能目标 机房设备性能极低时,把"视口内分布刷新"的轮询间隔从 2s 放宽到 3~5s,保证页面不卡。

现状基线 - 当前 setInterval(distTick, 2000);WS 在线时另有 8s 兜底刷新。分布刷新只请求视口内题目(约 5~8 题/次,7ms / 10KB),本身很轻,主要成本在低配机的 DOM 重绘。

触发条件(满足任一即降频,取 3~5s 中的自适应值) 1. 顶层题位 >1500(列表规模信号); 2. 运行期自检:单次 renderQChunk(50 题)>80ms,或 dist_batch 请求 P95 >300ms,或连续 3 帧单帧 >50ms; 3. 手动开关「低配模式」(localStorage,场控设置里可切); 4. 服务端/URL 下发 lowPerf=1(机房统一配置)。

边界 - 只降"分布刷新";倒计时、考试截止自动交卷、交卷进度、当前题切换等由 WS 事件驱动,必须保持实时,不降频。 - 手动点「刷新」/ 切换题目 / 结算后仍立即刷新一次。 - 恢复条件:连续 5 次自检指标正常 → 自动回到 2s;手动开关优先级最高。

验收标准 1. 满足触发条件后,dist_batch 请求间隔实测落在 3000~5000ms;未触发时仍为 2000ms。 2. 低配模式下,学生提交后教师端当前题分布延迟 ≤8s(WS 事件仍即时)。 3. 可在「⚙ 答题设置」中手动开关并持久化;刷新页面后保持。 4. 低配模式下 600 题滚动 60 次,单帧 P95 <16ms


5. 废旧老接口核查与清理(本条已执行)

功能目标 不能只新增接口;被取代/无调用方的接口必须删除,并给出处理结果。

核查方法(可复用) 1. 正则提取全部路由定义(@app.get/post/...@router.*),注意路由器前缀(如 /api/sessions/api/qbank)需拼接后再比对; 2. 归一化 {param} 后在 templates/**static/**(排除 vendor/lib)、各后端模块中检索; 3. 必须按边界匹配(如 /api/admin/dist(?![_a-z])),否则 /api/admin/dist 会被 dist_batch 误判为"有引用"。

处理结果(已完成) | 接口 | 结论 | 说明 | |---|---|---| | GET /api/admin/dist | 已删除 | 被 dist_batch + dist_names 取代(只统计当前题、且把全部名单塞进响应);连带删除私有函数 _dist_of() | | POST /api/admin/add_q | 已删除 | 旧"手动新增单选题(仅 A-D)",无前端入口;新增题目统一走抽题 /api/admin/qb_draw | | POST /api/admin/q_ana | 已删除 | 旧"单独编辑解析",无调用方;统一走 /api/admin/q_edit | | GET /api/portal | 已删除 | 兼容遗留,前端只从 /api/room/meportal 字段读取 |

  • 路由总数:128 → 124
  • 剩余 10 条"疑似无引用"经前缀展开确认全部是使用中的误报/api/sessions/{sid}/{paper,submit,close,reveal}/api/users/{uid}/{sessions/{sid},pwd,unlock}/api/import|qbank/batches/{bid}/{pwd,fails}/api/qbank/papers/{pid}/items
  • 回归:python -m py_compile main.py 通过;_e2e_smoke.py 51/51 通过

验收标准(后续约束) 1. 新增接口时,必须在同一次改动内删除被其取代的旧接口,并在提交说明中列出。 2. 每季度跑一次路由引用扫描,输出"零引用路由清单",清单为空或每条都有书面保留理由。 3. 删除前确认:前端零引用 + 文档/README 零引用 + 无外部工具依赖(tools/exepack/、打包脚本)。


6. 操作题:小题加载修正(步骤 → 小题)

状态:已实现(2026-09-16)

功能目标 Excel/WPS 操作题的分布与明细,从"按步骤展示"改为"按小题展示":一个小题包含多个步骤(考点),只有该小题内全部步骤都做对,该小题才得分

现状基线(关键事实) - 判分引擎已具备小题模型EG.grouper.viewByQuestion(result, qgroups)qgroups = [{title, ids:[rule_id...], manualMax?, enabled?}]award(items, max) = items.some(d => d.status !== "pass") ? 0 : max(全对才给满分)。 - static/excel-wps.jsgradeFile() 已返回 groups(小题级),且 score = groups 的 awarded 之和。 - 但 static/index.js:720static/exam.js:185 只上传 detail(逐考点/步骤),未上传 groups → 后端只有步骤数据 → 教师端分布(上一轮 _excel_steps)与明细都按步骤展示,与需求不符。

边界 - 后端不做二次判分(沿用"前端判分、后端只存"架构),只接收并存储前端回传的小题结果。 - detail(逐步骤)保留入库,仅作为排查/下钻数据,不再用于计分与分布统计。 - qgroups 缺失时降级:每个步骤视为一个小题,并在教师端标注"未编排小题"。 - enabled=false 的小题不计分、不参与分布统计。

实现要点 - 学生端提交新增字段 subs(JSON 数组):[{title, status, max, awarded, pass, fail, unknown, rule_ids}]。 - excel_submission / excel_live_sub 增加 subs TEXT 列(幂等 ALTER TABLE)。 - dist_batchexcel_wps 分支改读 subs,输出 [{i, title, n, n_pass}](每个小题做对人数);dist_namessteps 改为按小题返回做对名单。 - main.py_excel_steps() 随之改为 _excel_subs()(解析 subs;旧数据无 subs 时回退解析 detail)。

验收标准 1. 构造 3 小题(分别含 2/3/1 个步骤)的题:全部步骤对 → 3 小题均得分;某小题内 1 个步骤错 → 该小题 0 分,其余小题不受影响。 2. 教师端题目列表:WPS 题下方每个小题一条"做对 X/N 人 · P%"进度条(不再是每个步骤一条)。 3. 悬停小题条 → 显示"做对该小题"的学生名单(含 得分/满分)。 4. score_max = 启用小题满分之和;score = 小题得分之和。 5. 旧数据(只有 detail)展示时降级为"步骤=小题"并提示"历史数据未编排小题",不报错、不丢分。


7. 计分规则统一为"按小题积分"(不再按步骤积分)

状态:已实现(2026-09-16)

功能目标 全链路(实时答题 / 整卷考试 / 统计报告 / 场控)以小题得分之和为唯一分数来源,移除"按步骤累计"的计分口径。

现状基线 - 前端 gradeFile() 已是小题分(step_total = Σ 小题 awarded);但上传与展示仍是步骤口径detail、逐步骤明细、counts 按步骤统计),教师端报告与分布也按步骤。

边界 - 不改动单选/多选/判断/填空/材料题的计分。 - 不改变"前端判分、后端只存"架构(后端仍不重算)。 - 组卷中 WPS 题的"题位分值"是折算权重points_got = 题位分值 × score/score_max),不属于"按步骤积分",保持不动。

实现要点 - 统一:score = Σ 小题.awardedscore_max = Σ 小题.max。 - counts 语义改为小题维度(pass/fail/unknown/total 指小题数),步骤计数移到 step_counts 供排查。 - 同步到编辑题目场景/api/admin/q_editexcel_wps 不接受/不展示任何"步骤分值"字段(见第 8 条)。

验收标准 1. 同一份作答:学生端显示分数 == 提交回执分数 == /api/excel/detail == 报告/场控分数,误差为 0。 2. 构造"6 个步骤对 5 个"用例:若这 6 步同属 1 个小题 → 得分 0(不是 5/6 分);若分属 3 个小题、其中 1 小题含失败步骤 → 得分 = 其余 2 小题满分之和。 3. 报告页、场控页、学生端三处"小题数/通过小题数"一致。


8. WPS 操作题:编辑态不再单独计算得分

功能目标 编辑 WPS 操作题时不再单独计算分值:考点细则(考点 JSON 的 qgroups 编排)已经编好得分规则,分值以其为唯一来源。

现状基线 - 题库编辑弹窗对 excel_wps 只上传三件套(模板 / 考点 JSON / 判分 JS),payload 不含分值字段;但本地判分工具与编排区仍在按步骤展示/累计分值,容易与"小题编排"产生两套分值口径。

边界 - 题库编辑弹窗(#qm-*)对 excel_wps保留"三件套上传"与"小题编排"入口;移除任何手工分值输入与"按步骤自动算分"的展示。 - 组卷里的"题位分值"是折算权重,不在本条范围内(不删)。 - 考点 JSON 未编排 qgroups 时不报错,降级为按步骤,仅提示。

验收标准 1. 上传含 qgroups 的考点 JSON 后,编辑界面显示"N 个小题 · 卷面满分 X 分",且 X == viewByQuestionstep_max(一致)。 2. 编辑界面没有可编辑的分值输入框(对 excel_wps 题型)。 3. 未编排 qgroups 时提示:"该考点 JSON 未编排小题,将按步骤计分,建议用本地判分工具重新编排"。 4. 修改编排(manualMax / 启用状态)后重新保存,学生端新提交即按新分值,历史提交不回溯。


9. 学生端 / 教师端适配

9.1 学生端:必须显式提交后才进入判分(2026-09-16 修订,覆盖原"自动判分"口径)

状态:已实现(2026-09-16;按用户最终口径:不做二次确认,点「提交文件」直接判分入库)

功能目标选择文件 ≠ 提交。学生必须主动点击「提交」并通过二次确认后,系统才进入判分与上传流程,防止误选文件被直接判分。

现状(缺陷)static/excel-wps.jsmountStudentUI 只有 <input type="file">,其 change 事件直接触发 handle(f)gradeFile()opts.onGraded() 上传;没有显式提交按钮,也没有任何确认步骤——学生一旦误选文件就被判分并计入成绩。

范围:学生作答端(实时答题 static/index.js、整卷考试 static/exam.js 的 WPS 题)。教师端预览/试判如需判分,同样走显式提交。

具体要求 1. 选择/拖入文件后只暂存:展示文件名、大小、修改时间,提供「重新选择」「移除」;不判分、不上传、不写入任何提交记录。 2. 提供明确的「提交」按钮(未选文件时禁用)。 3. 点击「提交」→ 二次确认(用项目统一弹窗 appConfirm不使用原生 confirm()),确认文案必须回显所选文件名(例:将提交《张三-工资表.xlsx》进行判分,提交后不可更换,确认提交?)。 4. 确认后才执行:本地判分(加载引擎→解析→重算→比对)→ 上传 → 回写成绩。 5. 提交成功后按钮变为「重新提交」(可覆盖,按差额补分);已收题/已截止/已公布后锁定并给出原因。

边界 - 重复提交沿用"覆盖 + 只补差额"(score_added);提交中禁用按钮防重复点击。 - 受 reveal_policy / reveal_ana 约束:未公布时只显示总分,不展示小题明细。 - 二次确认只做一次(同一次提交流程内),不要对"重新提交"重复弹窗打扰。

验收标准 1. 选入文件后未点提交:服务端无任何提交记录、无分数变化,页面停留在"待提交"状态(展示文件名)。 2. 点「提交」弹出确认框且含文件名;点"取消"→ 不判分不上传,文件仍保留可改选。 3. 点"确认"→ 依次显示四阶段进度(加载引擎/解析/重算/比对上传)→ 提示"已提交,得分 x/y"。 4. 误选错误文件后可在提交前"移除/重新选择",不会产生任何历史成绩。 5. 提交中重复点击无副作用(按钮禁用);失败给出可读错误(格式错误、缺少考点 JSON、网络失败)并可「重试」。 6. 同一学生连续提交 3 次:分数不重复累加,仅补差额;服务端只保留最后一份文件。

9.2 教师端:返回并按小题展示(不展示步骤)

状态:已实现(2026-09-16)

功能目标/api/excel/stats/{sid}/api/excel/detail/{sub_id}、场控页与统计报告的 WPS 明细,只返回并展示小题(名称 + 描述 + 对错 + 得分);步骤信息只服务于后台判分逻辑,不出现在教师端任何界面

现状admin_stats.jsexShowDetail()exam_control.jsecShowDetail() 直接渲染 detail[]Sheet1!A1 + desc + 期望/实际 + 步骤分),逐格步骤全部暴露给教师。

验收标准 1. detail 接口返回 subs[](小题名称 / 描述 / 状态 / 满分 / 得分),counts 为小题维度;默认不再下发逐步骤 detail(保留内部存储与排查用途,仅在显式"技术问题排查"入口下才展示)。 2. 场控页"Excel / WPS 操作题"面板、统计报告页:按小题列出学生按钮,点开显示"小题名称 / 描述 → ✓✕? → 得分 x/y"。 3. 教师端界面上不出现任何 Sheet!A1 形式的单元格地址、期望值/实际值对比、逐步骤得分。 4. 导出 CSV 按小题列(或按小题展开),不含步骤行。

9.3 学生端按小题展示(名称 + 描述,不展示步骤)

状态:已实现(2026-09-16)

功能目标:学生端结果按小题逐条展示——小题名称 / 小题描述(要求) / 做对没有 / 得分多少;不展示每一个步骤的具体内容。

现状renderResult 平铺 res.detail(逐格步骤,最多 400 条,含地址与期望/实际),学生既看不懂也容易对答案产生困惑。

"小题名称 / 小题描述"的字段定义(2026-09-16 用户定稿:不新增字段) - 二者同为 qgroups[].title,即"步骤组合(小题)"的标题——在小题编排区(excelgrade/web/index.html 第 2 步「小题编排 / 合并所选小题」)双击标题即可编辑EG.qEditor 存回 qgroups[].titleexamIO.serializeQGroups() 原样持久化。 - 教师把"这一步要做什么"写成一句人话要求填进标题即可(例:"把标题设为黑体 16 号并居中")。 - 默认值:单个操作步骤 = 该步骤标题;合并多个步骤 = 组合小题(N 个操作、M 个考点),教师应改为可读要求。 - 前端(学生端 + 教师端)展示的就是这个 titlereport.js 亦已按 g.title + "全对才给分"渲染,口径一致。

边界 - 步骤数据(detail[]:地址 / 期望 / 实际 / 步骤分)仅用于后台判分与问题排查,学生端与教师端界面一律不展示。 - 未公布解析时(reveal_policy=never / 未到公布时机):只显示总分与"明细待公布"。

验收标准 1. 结果区按小题逐条展示:第 n 小题|名称|描述(要求)|✓/✕/?|得分 x/y,末尾给出总分与得分率。 2. 学生端看不到任何单元格地址(Sheet1!A1)、期望值/实际值对比、逐步骤得分条目。 3. 展示文本取自 qgroups[].title(教师双击编辑过即显示其撰写的"要求"),不含逐格地址;若教师未改名,显示默认的"组合小题(N 个操作、M 个考点)"或步骤标题。 4. 学生端小题的对错与得分,与教师端明细、后端存储三者一致(同第 7 条验收 1)。 5. 未公布时只显示总分,不显示任何小题明细。


优先级与依赖建议

  1. P0(数据口径):第 6、7、9 条 —— 小题模型已在判分引擎就绪,只差"上传 subs"这一环,属正确性问题,优先做。
  2. 计分口径以 EG.grouper.viewByQuestion()awarded 为准:小题内步骤全对才给满分,任一错/待重算即 0 分award() 已实现,前端不得另立一套算法)。
  3. "小题名称/描述"不新增字段,直接复用 qgroups[].title(编排区双击可编辑)。
  4. 建议顺序:6(小题加载)→ 7(小题计分)→ 9.3(学生端展示)→ 9.2(教师端展示)→ 9.1(显式提交)。
  5. 9.1(显式提交)是独立的交互改动,不依赖上面的数据口径,可并行。
  6. P0(已交付):第 5 条接口清理已完成。
  7. P1(性能):第 3 条(离屏回收,改动小、收益确定)→ 第 1 条(接口分页)→ 第 4 条(自适应降频)。
  8. P2(性能):第 2 条虚拟滚动,改造面最大(涉及高度测量与全选/删除交互),建议在第 3 条稳定后再做。

回归要求:每条完成后跑 python _e2e_smoke.py(当前 51 项)并按需补充断言;涉及 Excel/WPS 的改动需另跑一份"3 小题 × 多步骤"的构造用例(全对 / 部分对 / 全错)。