拓冰建站拓冰建站
首页 / 资讯中心 / 正文

【口算王|05】HarmonyOS ArkTS 训练结果实战:汇总正确率、用时和继续练习建议

【口算王05】HarmonyOS ArkTS 训练结果实战汇总正确率、用时和继续练习建议证据边界本文的“当前实现”来自本轮对本地口算王工程中ExamResultPage.ets、PracticePage.ets、UserDataManager.ets、QuestionUtils.ets、ThemeConstants.ets、ExamTab.ets与StatService.ets的静态复核。可以确认结算参数、展示指标、历史结构和两个后续入口统一结算对象、会话幂等和个性化建议均属于建议扩展。本轮未执行构建、安装、真机路由、生命周期复现或性能测量。训练结果页看起来只是把几个数字排成卡片实际上它承担着一次练习的“结算边界”上游要保证分数、题数、正确数、用时和答题记录彼此一致下游要把结果写入历史并为错题复盘或再次挑战提供明确入口。任何一个字段含义不清都会让统计越积越偏。本文基于口算王项目本地口算王工程脱敏路径的真实源码重点复核ExamResultPage.ets、PracticePage.ets、UserDataManager.ets、QuestionUtils.ets、ThemeConstants.ets、ExamTab.ets与StatService.ets。文中的当前实现均以本轮静态源码复核为依据工程身份信息已脱敏。先给出结论当前版本已经展示挑战分数、正确率、用时、等级印章、答题状态网格并提供“错题解析”和“再考一次”两个后续动作结果还会写入 Preferences 支撑挑战历史和近五次均分。源码没有根据题型、速度或错误原因生成个性化训练建议结果页显示的236/1258也是静态文字。本文会把现有能力与可落地增强方案分开说明。一、结果页首先要定义指标口径一次挑战结束后PracticePage会向结果页传递interface ExamResultParams { bankId: string score: number total: number correct: number durationSec: number records: string }字段看似直观但工程上必须给出精确定义字段当前含义注意事项bankId本次挑战所属题库用于历史展示和再次挑战score百分制整数分数应与正确数、总题数一致total本次题目总数包含未答题correct正确记录数量只统计已提交且正确的题durationSec当前计时器累计秒数当前是 interval 自增值recordsAnswerRecord[]的 JSON 字符串路由边界需要运行时校验结果页不能把“已答题数”和“总题数”混为一谈。挑战超时可能出现records.length total此时未答题既不能算正确也不应被当作一条有选项的错误记录。二、分数由哪里计算项目中存在两个等价计算位置。自动交卷调用QuestionUtils.calcScore()static calcScore(records: AnswerRecord[], total: number): number { if (total 0) return 0 const correctCount records.filter(r r.correct).length return Math.round(correctCount / total * 100) }手动完成最后一题时PracticePage.goNext()直接计算const correctCount this.records.filter(r r.correct).length const score Math.round( correctCount / this.questions.length * 100 )两段当前逻辑一致但重复实现会增加以后规则漂移的概率。例如产品决定空题扣分或总分改为 120 分如果只改一处自动交卷和手动交卷就会得到不同结果。更稳妥的做法是让所有结算出口只调用一个纯函数。export interface ResultSummary { total: number answered: number correct: number wrong: number unanswered: number score: number accuracyPercent: number } export function summarizeResult( records: AnswerRecord[], total: number ): ResultSummary { const safeTotal Math.max(0, total) const answered Math.min(records.length, safeTotal) const correct records.filter( (record: AnswerRecord) record.correct ).length return { total: safeTotal, answered, correct, wrong: Math.max(0, answered - correct), unanswered: Math.max(0, safeTotal - answered), score: safeTotal 0 ? Math.round(correct / safeTotal * 100) : 0, accuracyPercent: answered 0 ? Math.round(correct / answered * 100) : 0 } }这里同时保留“得分率”和“已答正确率”。当前页面展示的是correct / total标签写“正确率”如果业务希望区分未答与答错最好明确命名避免用户误解。三、结果数据如何进入页面ExamResultPage.aboutToAppear()读取路由参数并恢复页面状态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 (_) {} }as ExamResultParams与as AnswerRecord[]都只是类型断言不会在运行时校验数字范围或数组元素。稳定的结果页应防御score为负数或超过 100correct totaldurationSec不是有限整数records不是数组一条记录缺少questionIdrecords.length total路由参数为空。建议在进入 UI 状态前统一归一化function clampInt(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min return Math.min(max, Math.max(min, Math.floor(value))) } function normalizeResult(input: ExamResultParams): ExamResultParams { const total clampInt(input.total, 0, 10000) const correct clampInt(input.correct, 0, total) return { bankId: input.bankId || , score: clampInt(input.score, 0, 100), total, correct, durationSec: clampInt(input.durationSec, 0, 86400), records: input.records || [] } }进一步可以不信任上游的score与correct而是从通过校验的记录重新派生。这样页面展示、历史持久化和统计服务共享同一个可信结果。四、正确率展示处理了除零结果页的正确率计算为Text(${Math.round( this.correct / Math.max(this.total, 1) * 100 )}%)Math.max(total, 1)避免了零除但它只解决运行时数值问题没有解决空结果的语义。total0时展示0%用户可能理解为“全部答错”实际上更可能是路由参数异常或题库为空。更友好的策略是private accuracyText(): string { if (this.total 0) return -- return ${Math.round(this.correct / this.total * 100)}% }同时显示空态或“本次没有可结算题目”而不是把异常数据包装成正常成绩。五、用时格式化复用了 QuestionUtils结果页没有自行拼接分钟和秒而是调用private formatDuration(sec: number): string { return QuestionUtils.formatTime(sec) }工具实现为static formatTime(sec: number): string { const m Math.floor(sec / 60) const s sec % 60 return ${String(m).padStart(2, 0)}: ${String(s).padStart(2, 0)} }当前设置允许 20、30、45、60 分钟mm:ss足够使用。但调用前仍应保证sec是非负整数。如果未来支持超过 99 分钟或暂停恢复还要明确用时是“墙钟经过时间”还是“有效作答时间”。当前durationSec来自每秒自增的timerSec后台暂停导致的时间偏差已在上一篇限时训练文章中说明。六、等级印章是确定性映射getRankByScore()按分数返回标签、印章资源和颜色export function getRankByScore(score: number): RankInfo { if (score 90) { return { label: 优秀, stamp: $r(app.media.img_result_stamp_excellent), color: Colors.ERROR } } if (score 75) { return { label: 良好, stamp: $r(app.media.img_result_stamp_good), color: Colors.ACCENT } } if (score 60) { return { label: 及格, stamp: $r(app.media.img_result_stamp_pass), color: Colors.SUCCESS } } return { label: 继续努力, stamp: $r(app.media.img_result_stamp_keep_trying), color: Colors.TEXT_HINT } }这是可复核的分级能力90、75、60 是三个阈值。它不是基于年级、题库难度或历史表现的动态评价。结果页当前只渲染rankInfo.stamp标签主要在挑战历史卡片中使用。若要把等级转化为练习建议可以继续基于确定性规则但必须避免把低分描述成能力定论。建议使用“下一步动作”不要使用“你不擅长数学”一类标签。七、答题网格如何表示三种状态makeAnswerGrid()以总题数为边界构造状态private makeAnswerGrid(): GridItem[] { const result: GridItem[] [] 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 }) } return result }这段代码准确表达了当前记录的顺序语义第i条记录对应第i道题。已答正确为绿色已答错误为红色记录之外为未答。边界在于AnswerRecord自己带有questionId但结果网格没有题目序列可用于核对。如果记录乱序、重复或缺题单靠数组下标无法发现。更可靠的结算载荷应同时携带本次试卷题目 ID 顺序interface ExamSessionResult { sessionId: string bankId: string questionIds: string[] records: AnswerRecord[] startedAt: number finishedAt: number }网格遍历questionIds再按questionId查找记录便能稳定区分未答、错答和数据缺失。八、挑战历史保存了哪些字段ExamHistory的真实模型如下export interface ExamHistory { bankId: string score: number total: number correct: number durationSec: number finishedAt: string }结果页出现时执行this.examHistory UserDataManager.addExamHistory( this.examHistory, this.bankId, this.score, this.total, this.correct, this.durationSec )addExamHistory()把新记录放到数组头部并写入 Preferencesconst result: ExamHistory[] [{ bankId, score, total, correct, durationSec, finishedAt: nowStr() }, ...records] UserDataManager.persist(UserDataManager.K_EXAM, result)这样ExamTab可以直接取前十条StatService也可以取数组前五条计算最近均分。数据顺序由“新记录头插”保证。九、aboutToAppear 写历史的幂等风险把持久化放在aboutToAppear()很方便但页面每次出现都会执行。当前ExamHistory没有sessionIdaddExamHistory()也不去重。如果同一个结果页因路由返回、窗口恢复或生命周期变化再次触发出现逻辑就可能重复写入同一场挑战。可以让历史记录带会话键export interface ExamHistoryV2 extends ExamHistory { sessionId: string finishedAtEpoch: number } static addExamHistoryOnce( records: ExamHistoryV2[], next: ExamHistoryV2 ): ExamHistoryV2[] { if (records.some( (item: ExamHistoryV2) item.sessionId next.sessionId )) { return records } const result [next, ...records] UserDataManager.persist(UserDataManager.K_EXAM, result) return result }更进一步结算应在挑战会话服务中只执行一次结果页只展示已经完成的结算对象。页面生命周期不再决定业务数据是否写入。十、近五次均分如何计算StatService.recentAvgScore()取数组头部最近 N 条static recentAvgScore( examHistory: ExamHistory[], count: number 5 ): number { if (examHistory.length 0) return 0 const recent examHistory.slice( 0, Math.min(count, examHistory.length) ) const sum recent.reduce( (value, exam) value exam.score, 0 ) return Math.round(sum / recent.length) }这项统计真实存在于学习统计页。它是最近挑战分数的算术平均不考虑题库难度、题量、年级或用时所以不能直接当作综合能力指数。如果要在结果页显示趋势建议至少保证比较对象可比同一bankId相同题量相同挑战时长同一评分规则版本。否则“比上次提高 10 分”可能只是因为换了更简单的题库。十一、当前继续练习入口是什么结果页底部有两个真实按钮GreenButton({ text: 错题解析, isOutline: true, onTap: () { router.pushUrl({ url: pages/PracticePage, params: { bankId: this.bankId, mode: wrongAnalysis, records: JSON.stringify(this.records) } }) } }) GreenButton({ text: 再考一次, onTap: () { router.replaceUrl({ url: pages/PracticePage, params: { bankId: this.bankId, mode: exam } }) } })这就是当前的“继续练习建议”错了可以查看错题解析也可以重新挑战同一题库。源码没有根据错误题型选择专项练习也没有显示“建议练习乘法口诀 10 分钟”之类的个性化文案。十二、如何生成有依据的下一步建议建议必须来自本次结果而不是只看总分。可以先构造题型统计export interface TypeResult { type: string total: number correct: number accuracy: number } export interface PracticeSuggestion { title: string reason: string targetType?: string actionMode: wrongAnalysis | typePractice | exam }生成规则可以保持透明有错题时首选“先看错题解析”某题型至少出现 3 题且正确率低于 60%建议专项练习未答题超过总题数的 20%建议延长时间或练习节奏正确率高但用时明显增加只提示稳定节奏不下能力结论样本不足时只建议再练一次不生成薄弱项标签。function buildSuggestion( summary: ResultSummary, weakest?: TypeResult ): PracticeSuggestion { if (summary.wrong 0) { return { title: 先复盘本次错题, reason: 本次有 ${summary.wrong} 道错题, actionMode: wrongAnalysis } } if (weakest weakest.total 3 weakest.accuracy 60) { return { title: 练习薄弱题型, reason: ${weakest.type} 正确率为 ${weakest.accuracy}%, targetType: weakest.type, actionMode: typePractice } } return { title: 再完成一组同级挑战, reason: 继续积累可比较记录, actionMode: exam } }规则、阈值和数据来源都能被用户和测试理解这比模糊的“智能推荐”更可靠。十三、静态排名不能作为真实指标结果页第三个指标当前写死this.MetricItem(${236}/${1258}, 排名)源码没有网络请求、本地排名算法或样本集合。因此它不是实时排名也不能写入文章结论、平台宣传或腾讯文档数据。在没有真实数据源时有三个合规选择删除排名换成“已答题数”展示本地历史中的个人最佳明确标为示例但正式产品通常不应保留示例排名。推荐替换为本次可计算指标this.MetricItem( ${this.records.length}/${this.total}, 已答 )每一个展示数字都能从本次结算载荷直接验证。十四、结果页的多设备布局边界页面主体使用Scroll底部按钮区固定并通过private bottomSafePadding(): number { return Math.max( Sizes.BOTTOM_NAV_MIN_PADDING, this.getUIContext().px2vp( this.navigationIndicatorHeightPx ) ) }处理系统底部安全区这是已有的真实适配。答题网格使用可换行的Flex比固定列数更适合不同宽度。仍需重点验证小窗下两个底部按钮是否过窄大字体时三宫格指标是否换行平板宽屏时内容是否过度拉伸状态栏、导航栏在深浅色下是否可读红绿状态是否同时提供文本图例结果内容较长时最后一行网格是否能滚动到按钮上方。宽屏可将“分数卡 指标”和“答题网格”做成双栏但不需要改变结算模型。十五、验证结果页的最小测试矩阵输入场景预期结果20 题全对100 分、正确率 100%、无错题20 题答对 15 题75 分、等级“良好”20 题只答 10 题且全对得分率 50%未答 10 题total0展示空态或--不崩溃路由 records 损坏降级为空数组并提示correct total归一化或拒绝异常数据用时 0 秒格式化为00:00用时 3600 秒格式化为60:00重复进入同一结果历史只新增一次历史超过 10 条挑战页只展示最近 10 条最近记录不足 5 条按实际条数计算均分无真实排名数据不展示虚构排名除了函数测试还要做一次真实路由烟测完成挑战、进入结果页、打开错题解析、返回结果页、再考一次并检查历史数量是否符合预期。十六、常见问题与排查顺序现象可能原因优先检查分数与正确率不同步结算逻辑有多个实现统一summarizeResult()未答题被当成错题只比较correct与total单独计算 unanswered用时跳变interval 计时受生命周期影响改用截止时间模型历史重复aboutToAppear()重复写入增加 sessionId 幂等近五次均分不可信不同题库直接混算按 bankId 和规则版本过滤错题解析题目错位记录与题目平行数组失配按 questionId 绑定排名一直不变页面使用固定值删除或换成真实指标建议与错题无关只根据总分生成汇总题型和未答数据十七、发布前检查清单自动交卷与手动交卷使用同一结算函数score、total、correct与records可相互校验未答题有独立状态空题库和损坏路由参数有降级界面用时经过非负整数归一化等级阈值与ThemeConstants一致挑战历史写入具有sessionId幂等保护Preferences 旧数据有迁移或兼容策略近五次均分只描述为算术平均继续练习建议能够指出数据来源没有把静态236/1258描述成真实排名正误状态不只依赖颜色小窗、平板、大字体和底部安全区完成目检“错题解析”和“再考一次”路由均可返回或继续页面出现、离开、再次出现不会重复结算。十八、结算数据应在离开练习页前冻结当前两条交卷路径都会在PracticePage内计算结果再通过路由传给ExamResultPage。问题不在于路由传参本身而在于分数、正确数、总题数、用时与记录仍是彼此独立的值以后只要某个出口少更新一个字段结果页就会收到一组内部矛盾的数据。更稳的边界是先冻结一次结算快照再让页面展示、历史写入和后续复盘全部消费同一个对象。export interface ExamSettlement { bankId: string total: number answered: number correct: number score: number durationSec: number records: AnswerRecord[] } function createSettlement( bankId: string, questions: Question[], records: AnswerRecord[], durationSec: number ): ExamSettlement { const total questions.length const safeRecords records.slice(0, total) const correct safeRecords.filter((item: AnswerRecord) item.correct).length return { bankId, total, answered: safeRecords.length, correct, score: total 0 ? Math.round(correct / total * 100) : 0, durationSec: Math.max(0, Math.floor(durationSec)), records: safeRecords } }这是建议实现不是当前源码已有类型。它的价值是把“同一次挑战的事实”绑定在一起score不再由调用方随意填写answered与total可以直接区分未答负用时也在边界处被截断。若业务以后改变计分规则只需要修改快照构造函数和对应测试。十九、自动交卷与手动交卷应汇入同一出口源码的自动交卷调用QuestionUtils.calcScore()手动完成最后一题则直接执行同样的除法。今天两者结果相同不代表长期不会漂移。工程上可把触发原因作为参数保留但结算、进度更新和跳转只写一次这样超时、最后一题完成和以后增加的“主动交卷”都遵守相同顺序。type SubmitReason timeout | completed | manual private finishExam(reason: SubmitReason): void { if (this.timerId ! -1) { clearInterval(this.timerId) this.timerId -1 } const result createSettlement( this.bankId, this.questions, this.records, this.timerSec ) this.saveProgressFrom(result) this.openResultPage(result, reason) }验证时不要只比较最终分数还要比较副作用三种触发方式是否都只停止一次计时器、只写一次题库进度、只跳转一次结果页。连续点击也要加入用例防止两个事件在页面切换前重复进入结算出口。当前源码尚未使用SubmitReason或统一finishExam()文中代码只表达可迁移的收口方式。二十、路由恢复不能只依赖类型断言router.getParams() as ExamResultParams只会让编译器接受字段访问不会保证运行时拿到正确数据。结果页还会在params不存在时继续执行等级计算和历史写入因此异常入口不仅可能显示空成绩还可能保存一条空题库、零分的历史。解析层应先返回成功或失败再决定是否进入正常内容状态。interface ParseResult { valid: boolean message: string value?: ExamSettlement } function parseResultParams(params: ExamResultParams | undefined): ParseResult { if (!params || !params.bankId) { return { valid: false, message: 缺少题库信息 } } if (!Number.isFinite(params.total) || params.total 0) { return { valid: false, message: 题目总数无效 } } return { valid: true, message: } }真实项目还要校验records是否为数组、元素字段是否完整、题目 ID 是否重复以及correct能否从记录重新计算。解析失败时应展示错误态和返回按钮不写历史、不显示等级印章也不把异常值伪装成0%。二十一、历史幂等需要会话身份而不是成绩组合用bankId score finishedAt去重并不可靠同一用户可能连续完成两次相同分数的挑战时间字符串也只有分钟精度反过来同一结果页再次出现时nowStr()又会产生新时间依旧无法识别重复。合适的键应在挑战开始时生成并沿结算链路传递。export interface ExamHistoryV2 { sessionId: string bankId: string score: number total: number correct: number durationSec: number finishedAt: string } static addExamHistoryOnce( records: ExamHistoryV2[], next: ExamHistoryV2 ): ExamHistoryV2[] { if (records.some((item: ExamHistoryV2) item.sessionId next.sessionId)) { return records } return [next, ...records] }上线迁移时旧记录没有会话键不能反推一个可靠 ID。可以把旧数据保留为只读历史新挑战写入新结构统计服务同时读取两种版本待产品确认后再清理。当前ExamHistory只有六个字段且addExamHistory()每次直接头插因此这里描述的是风险与演进方案不是已经发生过重复写入的实测结论。二十二、错题解析入口依赖 questionId 闭环结果页把完整records传入wrongAnalysis模式练习页先过滤correctfalse的记录再到当前题库中按questionId找题。这条链路是真实存在的也揭示了复盘完整性的关键记录有 ID 不等于一定能恢复题目。题库升级、题目删除、跨题库记录或重复 ID 都可能让部分错题找不到。输入当前行为建议反馈没有错题得到空题目数组显示“本次无错题”返回结果页records JSON 损坏捕获异常并使用空数组区分“无错题”和“记录损坏”题目 ID 已不存在该记录不会加入题目数组提示可恢复数量与缺失数量重复 questionId可能重复加入同一道题按 ID 去重并保留首次作答因此结果页的“错题解析”按钮在跳转前可以先统计错误记录数复盘页则应返回恢复结果。只有当错误记录和真实题目都能对应时才显示正常的逐题解析否则要给出可理解的空态或部分恢复说明。二十三、继续练习建议要分清现有入口与推荐系统当前结果页只有“错题解析”和“再考一次”这已经构成简单、可解释的后续动作。它们分别使用本次错误记录和原题库 ID不需要额外推断。真正的专项建议则要求题目类型、样本量和路由能力同时存在不能只根据总分拼一段看似智能的文案。动作当前数据依据当前是否可执行错题解析本次 records 中的错误项是进入 wrongAnalysis再考一次本次 bankId是进入 exam薄弱题型练习需把 questionId 关联到题型并统计否属于建议扩展速度训练建议需题目级用时或可比历史否当前只有整场 durationSec推荐规则还要设置最小样本量。一次挑战里某题型只出现一题答错并不能证明稳定薄弱没有题目级用时也不能说某个题型“速度慢”。页面可先把真实按钮做好再逐步增加有证据的推荐而不是用无法复核的结论替代行动入口。二十四、展示层要把异常、空态和可访问性一并验收现有页面已经用文字图例解释绿色、红色和灰色三种答题状态也通过Scroll、换行Flex与底部安全区计算保证基本可达性。仍需验证颜色不是唯一信息网格里的单个格子目前只显示序号用户若无法区分红绿必须结合图例和上下文判断。更完整的方案可以为每格增加可访问性描述如“第 3 题错误”。大字体和小窗下三宫格指标、长时间文本与两个按钮是最容易出问题的区域。建议把验收拆成四组正常结果、部分未答、空或损坏参数、超长显示每组再覆盖浅色、深色、字体放大和底部手势区。平板宽屏可以调整内容最大宽度但不应改变分数口径或历史写入时机。二十五、端到端验证要核对页面之外的副作用结果页的验收不能停在截图。一次完整烟测应记录挑战开始、交卷原因、路由参数、页面显示、历史条数和后续入口。尤其要在打开错题解析后返回结果页观察aboutToAppear()是否再次写入再进入“再考一次”确认使用的仍是原bankId且路由栈没有残留旧结果。完成一场包含正确、错误和未答题的挑战保存预期结算快照逐项比对分数、得分率、用时、等级与网格状态记录进入结果页前后的挑战历史长度进入错题解析核对每个 questionId 与题干、所选答案和正确答案返回后再次检查历史长度排除重复写入点击再考一次核对题库、模式、计时器和返回路径使用损坏参数与空题库重复上述流程确认错误态不会持久化。本轮没有执行这些真机步骤所以不能把清单写成“已通过”。静态源码复核能确认链路和风险位置构建、安装、生命周期次数以及实际交互仍需在设备或模拟器上独立留证。二十六、总结口算王当前训练结果页已经形成清晰的基础闭环PracticePage计算挑战结果ExamResultPage展示分数、正确率、用时、等级印章和答题状态UserDataManager保存挑战历史ExamTab与StatService再消费这些记录。要把它提升为可靠的训练反馈系统关键不是再增加几个卡片而是建立唯一结算模型所有指标从同一份记录派生路由输入经过校验历史写入按会话幂等未答题与答错分开表达建议只引用可解释的题型与错误数据。现有“错题解析”和“再考一次”可以作为默认后续动作个性化专项建议则应在具备真实题型统计后再启用。这样结果页展示的每一个数字和每一句建议都能回到源码与数据中复核。AI 辅助声明本文在人工核对真实工程源码、结算参数、历史模型与后续路由后使用 AI 辅助整理结构、润色表达并生成示意图当前实现、建议扩展和未执行验证均已分别说明。CSDN-SERIES:ALL-163187302
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门