优化需求说明(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=200(limit 上限 500),返回新增 total / offset / has_more;current/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/limit 且 has_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/me 的 portal 字段读取 |
- 路由总数: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.py51/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.js 的 gradeFile() 已返回 groups(小题级),且 score = groups 的 awarded 之和。
- 但 static/index.js:720 与 static/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_batch 的 excel_wps 分支改读 subs,输出 [{i, title, n, n_pass}](每个小题做对人数);dist_names 的 steps 改为按小题返回做对名单。
- 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 = Σ 小题.awarded,score_max = Σ 小题.max。
- counts 语义改为小题维度(pass/fail/unknown/total 指小题数),步骤计数移到 step_counts 供排查。
- 同步到编辑题目场景:/api/admin/q_edit 对 excel_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 == viewByQuestion 的 step_max(一致)。
2. 编辑界面没有可编辑的分值输入框(对 excel_wps 题型)。
3. 未编排 qgroups 时提示:"该考点 JSON 未编排小题,将按步骤计分,建议用本地判分工具重新编排"。
4. 修改编排(manualMax / 启用状态)后重新保存,学生端新提交即按新分值,历史提交不回溯。
9. 学生端 / 教师端适配¶
9.1 学生端:必须显式提交后才进入判分(2026-09-16 修订,覆盖原"自动判分"口径)¶
状态:已实现(2026-09-16;按用户最终口径:不做二次确认,点「提交文件」直接判分入库)
功能目标:选择文件 ≠ 提交。学生必须主动点击「提交」并通过二次确认后,系统才进入判分与上传流程,防止误选文件被直接判分。
现状(缺陷):static/excel-wps.js 的 mountStudentUI 只有 <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.js 的 exShowDetail()、exam_control.js 的 ecShowDetail() 直接渲染 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[].title,examIO.serializeQGroups() 原样持久化。
- 教师把"这一步要做什么"写成一句人话要求填进标题即可(例:"把标题设为黑体 16 号并居中")。
- 默认值:单个操作步骤 = 该步骤标题;合并多个步骤 = 组合小题(N 个操作、M 个考点),教师应改为可读要求。
- 前端(学生端 + 教师端)展示的就是这个 title;report.js 亦已按 g.title + "全对才给分"渲染,口径一致。
边界
- 步骤数据(detail[]:地址 / 期望 / 实际 / 步骤分)仅用于后台判分与问题排查,学生端与教师端界面一律不展示。
- 未公布解析时(reveal_policy=never / 未到公布时机):只显示总分与"明细待公布"。
验收标准
1. 结果区按小题逐条展示:第 n 小题|名称|描述(要求)|✓/✕/?|得分 x/y,末尾给出总分与得分率。
2. 学生端看不到任何单元格地址(Sheet1!A1)、期望值/实际值对比、逐步骤得分条目。
3. 展示文本取自 qgroups[].title(教师双击编辑过即显示其撰写的"要求"),不含逐格地址;若教师未改名,显示默认的"组合小题(N 个操作、M 个考点)"或步骤标题。
4. 学生端小题的对错与得分,与教师端明细、后端存储三者一致(同第 7 条验收 1)。
5. 未公布时只显示总分,不显示任何小题明细。
优先级与依赖建议¶
- P0(数据口径):第 6、7、9 条 —— 小题模型已在判分引擎就绪,只差"上传
subs"这一环,属正确性问题,优先做。 - 计分口径以
EG.grouper.viewByQuestion()的awarded为准:小题内步骤全对才给满分,任一错/待重算即 0 分(award()已实现,前端不得另立一套算法)。 - "小题名称/描述"不新增字段,直接复用
qgroups[].title(编排区双击可编辑)。 - 建议顺序:6(小题加载)→ 7(小题计分)→ 9.3(学生端展示)→ 9.2(教师端展示)→ 9.1(显式提交)。
- 9.1(显式提交)是独立的交互改动,不依赖上面的数据口径,可并行。
- P0(已交付):第 5 条接口清理已完成。
- P1(性能):第 3 条(离屏回收,改动小、收益确定)→ 第 1 条(接口分页)→ 第 4 条(自适应降频)。
- P2(性能):第 2 条虚拟滚动,改造面最大(涉及高度测量与全选/删除交互),建议在第 3 条稳定后再做。
回归要求:每条完成后跑 python _e2e_smoke.py(当前 51 项)并按需补充断言;涉及 Excel/WPS 的改动需另跑一份"3 小题 × 多步骤"的构造用例(全对 / 部分对 / 全错)。