考试结果页是用户最容易相信数字的地方,也是最不应该用占位数据的地方。一个分数如果来自未经校验的路由参数,一条历史如果页面每次出现都重复保存,一张答题网格如果把“提交顺序”误当成“题目顺序”,界面再漂亮也无法成为可信学习记录。
本文基于知律项目D:\huawei\one19-11、包名com.jiaweikang.one19的真实源码,复核ExamResultPage.ets、PracticePage.ets、QuestionUtils、UserDataManager.ets和分数等级配置。当前结果页真实实现了分数卡、正确率、用时、答题网格、考试历史保存、错题解析入口与再考一次;但排名236/1258是静态文本,错因和知识点并不在结果页直接展示,路由载荷也没有校验或会话级去重。文章将围绕这些可复核事实给出修正方案。
一、先确认结果页真正展示了什么
页面包含四组内容:
- 分数卡和由分数映射的印章;
- 正确率、用时和静态排名三宫格;
- 正确、错误、未答三态答题网格;
- “错题解析”和“再考一次”两个入口。
它没有在当前页面展开每道错题的原因,也没有独立知识点模型。错因与题目解析要点击“错题解析”后,由PracticePage的wrongAnalysis模式展示。因此标题中的“展示错因、知识点”应理解为结果到复习的完整链路,不能宣称结果页已经生成结构化知识点报告。
二、结果参数全部来自路由
interface ExamResultParams { bankId: string score: number total: number correct: number durationSec: number records: string }练习页在交卷时把这些值通过replaceUrl传入。结果页没有重新从题目与答案计算,而是直接赋值。这让页面实现简单,但也意味着参数是唯一事实来源,一旦重复、缺失或越界,历史和 UI 会一起出错。
三、aboutToAppear 无条件保存考试历史
aboutToAppear(): void { const params = router.getParams() as ExamResultParams | undefined if (params) { this.bankId = params.bankId this.score = params.score this.total = params.total this.correct = params.correct this.durationSec = params.durationSec try { this.records = JSON.parse(params.records) as AnswerRecord[] } catch (_) {} } this.rankInfo = getRankByScore(this.score) this.examHistory = UserDataManager.addExamHistory( this.examHistory, this.bankId, this.score, this.total, this.correct, this.durationSec ) }即使 params 不存在,方法仍会保存默认的空 bankId、0 分、0 题和 0 秒记录。页面被异常直达时,历史会出现零值条目;从错题解析返回导致页面再次出现时,也存在重复写入同一场考试的可能。
四、历史模型缺少 sessionId
当前ExamHistory只有:
interface ExamHistory { bankId: string score: number total: number correct: number durationSec: number finishedAt: string }它无法判断两条相同分数记录是两次考试,还是同一页面重复保存。finishedAt在写入时重新生成,反而会把重复记录伪装成不同时间。
五、为每场考试增加稳定标识
更可靠的做法是在练习会话开始时生成sessionId,交卷与结果页全程传递:
interface ExamResultPayload { sessionId: string bankId: string questionIds: string[] records: AnswerRecord[] durationSec: number finishReason: 'manual' | 'timeout' }分数、正确数和总题数可以从 payload 重新计算,避免同一事实有多份独立参数。历史保存以 sessionId 去重,同一会话无论页面出现几次都只落库一次。
六、先验证,再更新 ArkUI 状态
interface ResultState { status: 'ready' | 'invalid' payload?: ExamResultPayload message: string } function validatePayload(payload: ExamResultPayload): ResultState { if (!payload.sessionId || !payload.bankId) { return { status: 'invalid', message: '考试标识不完整' } } if (payload.questionIds.length === 0) { return { status: 'invalid', message: '试卷为空' } } const questionSet = new Set(payload.questionIds) const valid = payload.records.every( record => questionSet.has(record.questionId) ) return valid ? { status: 'ready', payload, message: '' } : { status: 'invalid', message: '答案记录与试卷不匹配' } }页面收到 invalid 时显示错误态并禁止保存历史。不要先把数值写入状态,再在渲染阶段逐个修补。
七、分数应由同一份记录计算
当前手动交卷使用Math.round(correctCount / questions.length * 100),超时交卷调用QuestionUtils.calcScore。结果页又接收 score、total、correct 三个值。
统一 payload 后,可以在结果层派生:
const total = payload.questionIds.length const correct = payload.records.filter(r => r.correct).length const score = total > 0 ? Math.round(correct / total * 100) : 0一份原始记录派生全部指标,避免 score 与 correct/total 不一致。
八、正确率已经处理 total 为零
页面计算正确率时使用:
Math.round(this.correct / Math.max(this.total, 1) * 100)它避免除零,但不能证明零题考试是合法结果。零题应该进入错误态,而不是显示 0% 后写入历史。
九、分数等级是本地阈值
getRankByScore按分数映射优秀、良好、及格等本地等级,并返回印章资源。这是本地 UI 分类,不是用户在某个平台中的真实排名。
等级阈值可以在文章中说明,但不能写成权威考试认定或官方资格结果。
十、236/1258 是静态占位数字
结果页直接渲染:
this.MetricItem(`${236}/${1258}`, '排名')源码没有排行榜服务、用户总量、匿名统计或网络请求。这个数字不能当作真实排名,更不能记录为平台数据、PV 或用户规模。发布前应改为“本地等级”或直接移除排名卡。
十一、答题网格假设 records 与题号同序
for (let i = 0; i < this.total; i++) { let state: string = 'empty' if (i < this.records.length) { state = this.records[i].correct ? 'correct' : 'wrong' } result.push({ index: i + 1, state }) }这段代码按 records 数组位置决定题号状态。但练习页允许通过答题卡跳题,records 按提交顺序 push。用户先回答第 5 题时,它会成为 records[0],结果网格却把第 1 题标成已答。
十二、网格必须按 questionId 映射
结果 payload 需要携带试卷顺序questionIds:
private makeAnswerGrid(payload: ExamResultPayload): GridItem[] { const answerMap = new Map<string, AnswerRecord>() for (const record of payload.records) { answerMap.set(record.questionId, record) } return payload.questionIds.map((questionId, index) => { const record = answerMap.get(questionId) return { index: index + 1, state: !record ? 'empty' : (record.correct ? 'correct' : 'wrong') } }) }题号由试卷顺序决定,答案状态由 questionId 查询,提交顺序不再影响结果。
十三、AnswerRecord JSON 需要逐项校验
当前 JSON.parse 成功就断言为AnswerRecord[],并未验证数组、字段类型和 selected 范围。建议检查:
- 外层必须是数组;
- questionId 与 selected 必须是字符串;
- correct 必须是 boolean;
- 同一 questionId 只能出现一次;
- selected 必须属于该题可选键;
- 记录必须属于当前试卷。
解析失败不应默默显示空网格并保存一条完整历史。
十四、考试历史保存要幂等
static addExamHistoryOnce( records: ExamHistory[], next: ExamHistory ): ExamHistory[] { if (records.some(item => item.sessionId === next.sessionId)) { return records } const result = [next, ...records] UserDataManager.persistExamHistory(result) return result }这个方法把防重放在持久化边界,页面重复出现、手动与超时竞争、恢复导航都不会制造第二条记录。
十五、结果页不是最理想的历史写入所有者
更稳的所有权是:练习页统一完成方法计算并保存结果,成功后把 sessionId 导航到结果页;结果页只读取已保存记录。这样页面展示不会产生写副作用。
如果暂时保留结果页写入,也必须先验证 payload,并使用 sessionId 幂等保存。
十六、错题解析入口传的是全部 records
点击“错题解析”把整个 records JSON 传回 PracticePage:
router.pushUrl({ url: 'pages/PracticePage', params: { bankId: this.bankId, mode: 'wrongAnalysis', records: JSON.stringify(this.records) } })PracticePage 再过滤!record.correct,根据 questionId 回查题目并展示解析。所以结果页只是复习入口,不直接拥有错因内容。
十七、知识点当前没有独立模型
Question 只有 type、stem、options、answer、analysis 和 example,没有 knowledgePointIds。结果页无法统计“合同法错 3 题”之类结构化知识点。
如需知识点报告,应为题目增加稳定标签或知识点 ID,并在结果层按 ID 聚合。不能从 analysis 文本临时分词后宣称得到准确知识图谱。
十八、空错题时应禁用解析入口
当前无论是否存在错误记录,按钮都可以进入 wrongAnalysis。PracticePage 对零错题有标题和空态处理,但结果页提前计算wrongCount并禁用按钮会更清晰。
private wrongCount(): number { return this.records.filter(record => !record.correct).length }全对时可将按钮改为“查看答题卡”或直接隐藏。
十九、再考一次使用 replaceUrl
“再考一次”通过 replaceUrl 回到 PracticePage,避免结果页不断堆叠在路由栈中。这是合理的返回路径。
新的考试必须创建新的 sessionId;若复用旧 ID,幂等历史会把新考试误判为重复。
二十、错题解析使用 pushUrl 的返回语义
错题解析用 pushUrl,用户查看后返回结果页。若结果页 aboutToAppear 再次保存历史,就暴露了当前副作用问题。引入幂等后,这条路由结构才安全。
二十一、Preferences 适合轻量历史但要设上限
ExamHistory 以 JSON 数组保存在 Preferences,每次新记录放在最前。长期考试会让数组不断增长。
可以只保留最近 50 或 100 场,或改用关系型存储支持分页与统计。具体阈值应由产品需求决定,但必须避免无限增长。
二十二、保存失败不能吞掉
当前 UserDataManager.persist 捕获异常后静默结束。结果页仍会显示历史已保存的样子,重启却可能丢失。
保存接口应返回成功或失败,失败时给出“结果可查看,但未保存到本机”的状态。不要将 AppStorage 内存变化等同于持久化成功。
二十三、用时也需要范围校验
durationSec 直接格式化显示。负数、超大值或非有限数字都应判为无效。考试倒计时场景下,用时还应限制在 0 到考试时长之间。
这不是为了修饰 UI,而是防止异常载荷进入历史统计。
二十四、状态颜色需要文字语义
网格用绿、红、灰区分正确、错误、未答,图例提供文字说明。对屏幕朗读,每个格子还应设置如“第 3 题,回答错误”的 accessibilityText。
不能只依赖颜色,深浅色模式也要验证正文、图例和印章对比度。
二十五、底部安全区已正确纳入
按钮区 bottom padding 使用导航指示区高度与最小间距的较大值,能避让系统手势区域。长网格放在 Scroll 内,底部按钮保持可达。
仍需验证小窗口、横屏和平板下三宫格不会挤压,静态排名移除后可改为两列指标或自适应布局。
二十六、推荐的结果页状态
type ExamResultViewState = | { status: 'loading' } | { status: 'invalid'; message: string } | { status: 'ready'; result: ValidatedExamResult } | { status: 'save_failed'; result: ValidatedExamResult }ready 才显示完整结果;invalid 不保存历史;save_failed 允许查看本场结果,但明确告知没有落库。
二十七、故障排查表
| 现象 | 优先检查 | 修复方向 |
|---|---|---|
| 历史出现 0 分 0 题 | params 是否缺失 | 无效载荷禁止保存 |
| 返回后历史重复 | aboutToAppear 副作用 | sessionId 幂等 |
| 跳题后网格错位 | records 是否按提交序 | 按 questionIds 映射 |
| 分数与正确率矛盾 | 多份参数是否独立 | 从 records 统一派生 |
| 排名始终相同 | 是否静态 236/1258 | 移除或接真实服务 |
| 全对仍能进错题解析 | wrongCount 是否为 0 | 禁用入口 |
| 重启历史丢失 | Preferences flush 结果 | 展示保存失败 |
| 错题解析找不到题 | questionId 是否稳定 | 内容迁移保持 ID |
二十八、回归测试清单
- 缺少路由参数时显示错误态且不写历史;
- total 为 0、correct 越界、durationSec 非法时拒绝载荷;
- 同一 sessionId 多次出现只保存一条;
- 新一次考试使用新 sessionId;
- 任意顺序答题后网格仍对应正确题号;
- 未答题显示灰色且不计入正确数;
- 分数、正确数、总题数由同一 records 派生;
- 静态排名不再显示为真实排名;
- 全对时错题解析入口禁用或隐藏;
- 错题解析只展示错误记录;
- 再考一次替换结果页且不复用会话 ID;
- Preferences 保存失败有可见提示;
- 历史数量达到上限后按规则裁剪;
- 手机、横屏、平板和 2in1 布局无截断;
- 屏幕朗读能播报每个题号状态。
二十九、源码事实与产品表述边界
可以确认:结果页显示本地分数、正确率、用时和答题网格;分数印章来自本地阈值;考试历史通过 Preferences 保存;错题解析入口把 records 传给 PracticePage;再考一次使用 replaceUrl。
必须谨慎:236/1258是静态数字,不是真实排名;结果页不直接展示错因和结构化知识点;网格当前按提交顺序着色;历史缺少 sessionId 去重;路由参数未校验;无参数时仍可能保存零值历史。这些问题不能用“仅供参考”掩盖,应从数据契约修正。
三十、结语
可信的答题结果页不是把几个数字排成卡片,而是保证整场考试有唯一身份、所有指标来自同一份答案记录、网格能还原真实题号、历史只保存一次、复习入口能追溯到具体题目。
知律当前已经打通结果展示、历史保存和错题解析路由,但还需要移除静态排名、验证结果载荷、按题目顺序映射网格,并为历史增加幂等键。完成这些改造后,HarmonyOS 5.0 及以上的 ArkUI 结果页才能成为可复核的本地学习记录,而不是一次性的视觉总结。
本文部分内容由 AI 辅助整理,所有源码事实、数据口径与能力边界均以知律项目实际代码复核为准;题目中的法律内容仅用于软件工程分析,不构成法律意见。