学习应用里的“等级进度”经常被写成一个静态展示页:写几个段位名,再放一条进度条。问题是用户完成练习后,等级页必须能从真实练习结果推导状态:累计答了多少题,当前在哪个段位,距离下一段位还差多少,最高段位时进度条如何处理,列表里哪些段位已达成、哪个是当前、哪些尚未解锁。只靠固定文案,页面很快就会和用户数据脱节。
本文基于句匠源码D:\huawei\one18-11\entry\src\main\ets\pages\LevelProgressPage.ets,同时复核PracticePage.ets中写入bankProgress的链路,说明 HarmonyOS 5.0+ ArkTS 页面如何把练习结果转成等级展示。源码里真正作为“经验值”的是累计答题数finished,不是正确率、积分或服务端经验。正文唯一复核标记:com.jiaweikang.one18。
这篇文章会按源码边界解决几个问题:
- 练习完成后
BankProgress.finished如何累加。 - 等级页如何汇总所有题库已答题数作为经验值。
TIERS阈值表如何决定当前段位和下一段位。progressRatio()如何处理普通段位和最高段位。- 段位列表如何区分当前、已达成和未达成状态。
一、先说明经验值边界:源码用累计答题数,不用正确率加权
句匠的等级页使用bankProgress,而不是单独的levelExp字段。页面里只读取finished:
@StorageLink('bankProgress') progressList: BankProgress[] = [] private totalAnswered(): number { let sum = 0 for (const p of this.progressList) { sum += p.finished } return sum }这意味着当前源码的等级经验规则很明确:用户累计答题越多,段位越高。correct会被进度系统保存,但等级页没有用正确率或答对题数计算段位。
这个边界必须讲清楚。它适合英语练习应用的轻量激励:鼓励持续练习,而不是把等级变成严肃考试评价。如果产品后续要按正确率、难度、题型权重计算经验,需要新增独立经验计算规则,而不是把finished描述成已经支持复杂经验系统。
二、练习结果先写入 bankProgress
等级页本身不修改练习结果。进度来源在PracticePage.ets。练习结束时,页面会统计本次答题记录:
const correctCount = this.records.filter(r => r.correct).length this.progressList = UserDataManager.updateProgress( this.progressList, this.bankId, this.records.length, correctCount, this.chapterId )考试模式自动提交时也会走同类逻辑:
const correctCount = QuestionUtils.correctCount(this.records) const score = QuestionUtils.calcScore(this.records, this.questions.length) this.progressList = UserDataManager.updateProgress( this.progressList, this.bankId, this.records.length, correctCount, '' )这段链路说明等级页的数据不是凭空生成的。records.length是本次练习完成题数,correctCount是答对题数,二者被写入progressList。等级页后续读取的是同一份@StorageLink('bankProgress')。
三、BankProgress 的数据结构决定了等级页能算什么
librarya中的BankProgress结构包含题库 ID、已答题数、答对题数、最后章节和更新时间:
export interface BankProgress { bankId: string finished: number correct: number lastChapterId: string updatedAt: string }更新方法会按题库累加:
static updateProgress( records: BankProgress[], bankId: string, addFinished: number, addCorrect: number, chapterId: string ): BankProgress[] { const idx = records.findIndex(r => r.bankId === bankId) let next: BankProgress[] if (idx >= 0) { const old = records[idx] const updated: BankProgress = { bankId, finished: old.finished + addFinished, correct: old.correct + addCorrect, lastChapterId: chapterId, updatedAt: nowStr(), } next = [...records] next[idx] = updated } else { next = [{ bankId, finished: addFinished, correct: addCorrect, lastChapterId: chapterId, updatedAt: nowStr() }, ...records] } UserDataManager.persist(UserDataManager.K_PROGRESS, next) return next }这里有两个重要工程点:
- 老题库用新对象替换,避免直接改旧对象后 UI 不刷新。
- 新题库放到数组开头,进度列表能按最近学习的题库靠前展示。
等级页只汇总finished,所以它能稳定回答“总共练了多少题”。但它不能从当前结构直接判断“哪一天升级”“某次练习贡献了多少经验”“奖励是否领取”。这些都不是现有源码能力。
四、段位阈值表是等级规则的唯一来源
等级规则在TIERS常量里:
interface Tier { name: string enName: string threshold: number benefit: string } const TIERS: Tier[] = [ { name: '新手', enName: 'Novice', threshold: 0, benefit: '解锁基础题库与每日打卡' }, { name: '学徒', enName: 'Apprentice', threshold: 50, benefit: '解锁拼写纠错专题 + 错题本回顾' }, { name: '学者', enName: 'Scholar', threshold: 200, benefit: '解锁时态训练 + 学习统计图表' }, { name: '专家', enName: 'Expert', threshold: 500, benefit: '解锁介词搭配大全 + 排行榜进入资格' }, { name: '大师', enName: 'Master', threshold: 1000, benefit: '解锁全部题库 + 英伦大师徽章' }, { name: '宗师', enName: 'Grandmaster', threshold: 2500, benefit: '解锁专属皮肤 + 终身成就墙' } ]阈值表的优势是规则透明:总答题数达到 50 进入学徒,达到 200 进入学者。UI、进度条和列表状态都从同一张表推导,不需要在多个地方重复写数字。
但这里也有边界:benefit当前用于展示“权益”文案,不代表源码已经实现对应功能的开关判断。比如“解锁专属皮肤”只是段位说明文本,不能把它误写成已有皮肤系统。
五、当前段位用最后一个满足阈值的项
当前段位由currentTierIndex()计算:
private currentTierIndex(): number { const total = this.totalAnswered() let idx = 0 for (let i = 0; i < TIERS.length; i++) { if (total >= TIERS[i].threshold) idx = i } return idx }这段逻辑遍历所有段位,只要累计答题数大于等于阈值,就把当前索引更新为该段位。最终返回的是“最后一个满足条件的段位”。
示例规则如下:
| 累计答题数 | 命中阈值 | 当前段位 |
|---|---|---|
| 0 | 0 | 新手 |
| 49 | 0 | 新手 |
| 50 | 0、50 | 学徒 |
| 260 | 0、50、200 | 学者 |
| 2500 | 全部阈值 | 宗师 |
这种写法对阈值数组顺序有要求:TIERS必须按 threshold 从小到大排列。如果后续运营调整段位表,应该先检查阈值顺序,否则高段位可能被低阈值项覆盖。
六、下一段位允许不存在
等级进度页必须处理最高段位。源码通过nextTier()返回Tier | undefined:
private nextTier(): Tier | undefined { const i = this.currentTierIndex() return i < TIERS.length - 1 ? TIERS[i + 1] : undefined }这比直接访问TIERS[current + 1]更稳。最高段位时没有下一段位,进度卡片会显示“已达最高段位”:
if (this.nextTier()) { Text(`距 ${this.nextTier()!.name} 还差 ${Math.max(0, this.nextTier()!.threshold - this.totalAnswered())} 题`) .fontSize(Sizes.SMALL_FONT) .fontColor(Colors.TEXT_HINT) } else { Text('已达最高段位') .fontSize(Sizes.SMALL_FONT) .fontColor(Colors.ACCENT) }这里的Math.max(0, ...)也有保护作用。即使数据刚好超过下一段位阈值,显示差值也不会出现负数。正常情况下当前段位已经更新,不会出现“距下一段位 -20 题”,但 UI 层做一次兜底可以避免异常数据直接暴露给用户。
七、进度条比例必须按当前区间计算
进度条不是总答题数除以最高段位阈值,而是按当前段位到下一段位的区间计算:
private progressRatio(): number { const total = this.totalAnswered() const cur = TIERS[this.currentTierIndex()] const nxt = this.nextTier() if (!nxt) return 1 if (nxt.threshold === cur.threshold) return 1 return Math.min(1, (total - cur.threshold) / (nxt.threshold - cur.threshold)) }这段计算解决了三个边界:
- 最高段位没有下一段位,进度固定为 1。
- 下一阈值等于当前阈值时避免除以 0。
- 普通段位用当前区间比例,并通过
Math.min(1, ...)限制最大值。
例如用户答了 120 题,当前段位是学徒,下一段位是学者:
(120 - 50) / (200 - 50) = 70 / 150 ≈ 0.47这比120 / 2500更符合用户感知。用户不是“距离最高段位还有 95%”,而是“当前段位距离下一段位接近一半”。
八、进度条只负责展示,不写回数据
ProgressCard()里同时展示总答题数、下一段位差值和进度条:
@Builder ProgressCard() { Column({ space: 12 }) { Row() { Text(`已答 ${this.totalAnswered()} 题`) .fontSize(Sizes.BODY_FONT) .fontColor(Colors.TEXT_PRIMARY) .fontWeight(FontWeight.Medium) Blank() if (this.nextTier()) { Text(`距 ${this.nextTier()!.name} 还差 ${Math.max(0, this.nextTier()!.threshold - this.totalAnswered())} 题`) .fontSize(Sizes.SMALL_FONT) .fontColor(Colors.TEXT_HINT) } else { Text('已达最高段位') .fontSize(Sizes.SMALL_FONT) .fontColor(Colors.ACCENT) } } Stack({ alignContent: Alignment.Start }) { Column() .width('100%') .height(8) .borderRadius(4) .backgroundColor(Colors.BACKGROUND_ALT) Column() .width(`${this.progressRatio() * 100}%`) .height(8) .borderRadius(4) .linearGradient({ angle: 90, colors: [[Colors.PRIMARY, 0], [Colors.ACCENT, 1]] }) } } }这张卡片没有任何持久化操作。它是纯展示层:读取progressList,计算totalAnswered()和progressRatio(),渲染。这样能避免等级页误改练习数据。真正的数据写入只在练习完成时发生。
九、HeroCard 展示当前段位,列表展示全局段位关系
顶部 Hero 卡片直接读取当前段位:
Text(TIERS[this.currentTierIndex()].name) .fontSize(26) .fontWeight(FontWeight.Bold) .fontColor(Color.White) Text(TIERS[this.currentTierIndex()].enName) .fontSize(Sizes.CAPTION_FONT) .fontColor('#E6FFFFFF')列表则遍历所有段位:
ForEach(TIERS, (t: Tier, idx: number) => { this.TierRow(t, idx) }, (t: Tier) => t.name)这个布局分工比较清楚:Hero 告诉用户“我现在是什么”,TierList 告诉用户“全局阶梯是什么”。如果只显示进度条,用户不知道后面有几个等级;如果只显示列表,当前状态不够突出。
十、TierRow 用索引关系区分三种状态
段位行的状态由idx和currentTierIndex()比较得出:
if (idx === this.currentTierIndex()) { Text('当前') .fontSize(Sizes.SMALL_FONT) .fontColor(Color.White) .padding({ left: 8, right: 8, top: 3, bottom: 3 }) .borderRadius(10) .backgroundColor(Colors.PRIMARY) } else if (idx < this.currentTierIndex()) { Text('已达成') .fontSize(Sizes.SMALL_FONT) .fontColor(Colors.SUCCESS) } else { Text(`${t.threshold} 题`) .fontSize(Sizes.SMALL_FONT) .fontColor(Colors.TEXT_HINT) }背景色也使用同样的判断:
.backgroundColor(idx === this.currentTierIndex() ? Colors.PRIMARY_LIGHT : Colors.BACKGROUND_ALT)这里没有保存每个段位的状态数组,状态完全从当前索引推导。只要currentTierIndex()正确,所有行都会同步变化。对这种等级阶梯页面,这是最简单且可维护的做法。
十一、为什么正确率没有参与等级
源码的BankProgress同时保存finished和correct,但等级页只读取finished。这不是遗漏,而是当前业务规则的选择。句匠作为学习练习应用,用段位鼓励“持续答题”,而不是直接惩罚错误。
如果要把正确率引入等级,可以考虑三种方案:
| 方案 | 计算方式 | 风险 |
|---|---|---|
| 按答题数 | finished累计 | 用户刷题即可升级,质量不区分 |
| 按答对数 | correct累计 | 初学者挫败感较强 |
| 按加权经验 | 难度、题型、正确率综合 | 需要独立经验流水,当前源码不支持 |
当前源码采用第一种。文章不能把它写成“根据正确率升级”,因为LevelProgressPage没有这样的计算。更准确的说法是:练习结果写入了答题数和正确数,等级页把累计答题数作为升级经验。
十二、底部安全区仍然是等级页的一部分
等级页也是 Scroll 长页,底部有段位列表。源码用同样的安全区模式:
@StorageLink('navigationIndicatorHeightPx') navigationIndicatorHeightPx: number = 0 private bottomSafePadding(): number { return Math.max( Sizes.BOTTOM_NAV_MIN_PADDING, this.getUIContext().px2vp(this.navigationIndicatorHeightPx) ) }页面末尾加空白:
Blank().height(this.bottomSafePadding())这个细节对 AppGallery 布局检查很实际。段位列表最后一项如果贴到底部手势区,用户在小屏、横屏或 2in1 小窗口中会难以阅读或触达。等级页虽然不包含复杂输入,但仍然要保证底部内容可见。
十三、按源码做验证
可以用下面的表复核等级页:
| 验证项 | 输入数据 | 预期 |
|---|---|---|
| 空进度 | progressList = [] | 当前段位为新手,已答 0 题 |
| 单题库进度 | 一个题库finished = 60 | 当前段位为学徒 |
| 多题库汇总 | 两个题库finished = 30 + 180 | 总答题 210,当前段位为学者 |
| 下一段位差值 | 总答题 120 | 距学者还差 80 题 |
| 区间进度 | 总答题 120 | 学徒到学者区间约 47% |
| 最高段位 | 总答题 2500 以上 | 显示已达最高段位,进度为 100% |
| 已达成行 | 当前段位之后的行 | 只显示阈值,不显示已达成 |
如果页面显示异常,先不要查 UI 样式,优先打印或查看progressList。等级页所有核心展示都依赖它。
常见问题与处理
| 现象 | 优先排查 | 建议处理 |
|---|---|---|
| 完成练习后等级不变 | PracticePage是否调用updateProgress() | 练习结束入口必须写入progressList |
| 总答题数不对 | progressList中多个题库是否重复累加 | 按bankId更新旧记录,不要每次新增同一题库 |
| 进度条超过 100% | progressRatio()是否使用Math.min(1, ...) | 比例计算必须限制上限 |
| 最高段位仍显示差值 | nextTier()是否返回 undefined | 最高段位显示“已达最高段位” |
| 当前段位行不高亮 | currentTierIndex()计算错误 | 检查 TIERS 是否按阈值升序排列 |
| 已答正确率影响升级预期 | 业务规则理解偏差 | 当前源码按finished,不是按correct |
| 最后一行被挡住 | 底部未加安全区 | 保留bottomSafePadding()和末尾 Blank |
小结
句匠等级进度页的核心是“展示推导”,不是“数据生产”。练习页通过UserDataManager.updateProgress()把本次答题数和答对数写入bankProgress;等级页只读取progressList,把所有finished汇总成经验值,再通过TIERS阈值表推导当前段位、下一段位差值、进度条比例和列表状态。
这条链路的边界很清楚:它没有独立经验流水、没有按正确率加权、没有服务端等级结算,也没有真正的权益解锁开关。对于本地学习应用,这种实现足够轻、可复核、状态一致。后续如果要做更复杂的升级系统,建议先把经验规则抽成独立服务,再把等级页保持为纯展示层。