普法应用首页不是把题库、案例、收藏和统计堆在一个长页面里。首页真正的职责是把用户今天能做什么、已有学习状态和下一步入口组织成一条清晰路径,同时让每个数字都能回答“来自哪里”。如果累计数据被包装成今日状态、估算时长被当成真实计时、静态阅读量被写成平台指标,界面再丰富也会失去可信度。

本文基于知律项目 D:\huawei\one19-11 的真实源码,复核 HomePage.etsIndex.etsMockBanks.etsStatService.etsUserDataManager.ets。包名 com.jiaweikang.one19 是本文草稿核验使用的唯一标记。项目面向 HarmonyOS 5.0 及以上 Stage 模型应用,首页使用 ArkUI 声明式组件、AppStorage/StorageLink 状态联动和 router 页面跳转。

源码中可以确认六个法律分区、六个题库、每库 250 道运行时题目、按本地日期选取的每日一题,以及收藏、错题、答题进度和挑战历史等本地数据。同时也存在需要如实说明的边界:todayLearned() 只判断累计答题是否大于 0,不是真正的当日记录;学习分钟由答题数和考试次数估算;题库通过给基础题循环添加情景前缀扩展到 250 题,不能宣传为 1500 道完全独立的真实案例。审计发现的两组不可追溯固定计数已经在本轮从源码移除,替换为累计答题数和本地收藏条数。

普法首页数据编排封面

一、首页先分清四类数据

知律首页的数据可以分成四类:

  1. 目录数据:法律分区、题库、题型和题目;
  2. 每日数据:由当前日期确定的一道题;
  3. 用户数据:答题进度、收藏、错题和挑战历史;
  4. 展示数据:进度百分比、正确率和估算时长。

目录数据来自本地 MockBanks,用户数据来自 Preferences 的回读结果,展示数据由前两者派生。把来源分开后,页面才能避免直接修改持久化模型,也能在数据为空时稳定渲染。

二、首页通过 StorageLink 订阅学习状态

HomePage 没有自行读取 Preferences,而是订阅应用级状态:

@StorageLink('bankProgress') progressList: BankProgress[] = []
@StorageLink('examHistory') examHistory: ExamHistory[] = []
@StorageLink('favoriteRecords') favRecords: FavoriteRecord[] = []
@StorageLink('wrongRecords') wrongRecords: WrongRecord[] = []

数据初始化和持久化由 UserDataManager 负责,首页只消费数组并计算展示值。答题页更新这些 StorageLink 后,返回首页即可触发统计刷新,不需要手工维护多个计数器。

三、统计服务把原始数组压成首页摘要

首页的 myStats() 调用 StatService.summarize

private myStats() {
  return StatService.summarize(
    this.progressList,
    this.examHistory,
    this.favRecords,
    this.wrongRecords
  )
}

服务汇总已答题数、正确数、正确率、挑战次数、收藏数、错题数和已学习题库数。正确率在总答题数为 0 时返回 0,避免首页首次启动出现 NaN%。这类纯计算适合放在 service,而不是散落在每张卡片里。

四、每日一题使用日期确定性选择

dailyQuestion() 先聚合所有题库题目,再把年月日组成整数种子:

const seed =
  today.getFullYear() * 10000 +
  (today.getMonth() + 1) * 100 +
  today.getDate()
const idx = seed % all.length

同一设备在同一天会得到相同索引,第二天自然轮换。这不是随机数持久化,而是确定性映射,优点是实现简单、无需额外存储,也不会因页面重建突然换题。

五、日期种子要明确时区边界

代码使用设备本地 Date,所以“今天”由设备时区和系统时间决定。用户修改日期、跨时区或系统时间异常时,每日题目会随之变化。

如果产品只要求本地每日练习,这个边界可以接受;如果未来需要全体用户看到同一道题,就应由服务端日期或统一时区决定。当前应用没有服务端,不应把本地日期选择宣传成全网统一每日题。

测试时可以给日期选择函数传入固定 Date,分别覆盖月末、年末和闰日,避免用修改真机系统时间的方式污染其他用例。页面重新进入时还要确认日期变化能够刷新 daily 状态,而不是一直保留昨天缓存的题目。

六、空题库已有降级对象

当所有题库都为空时,dailyQuestion() 返回:

{
  bankId: '',
  bankName: '知律',
  stem: '暂无可用题目',
  options: [],
  answer: 'A',
  analysis: ''
}

点击今日普法时,空 bankId 会把主标签切到题库页,而不是强行进入练习页。这个降级避免 undefined 解包,但界面仍会显示一个没有选项的每日卡片。更完整的状态应禁用“查看答案”,并提供“查看题库”动作。

七、每日卡片不应在一次构建中反复聚合题库

模板中多次调用 this.dailyQuestion() 获取 stem、options、answer、analysis 和 bankName。每次调用都会遍历所有题库并聚合题目。

当前总量为 1500,通常仍可运行,但声明式刷新会放大重复计算。更稳妥的方式是在状态初始化或日期变化时计算一次:

@State daily: DailyQuestion = EMPTY_DAILY

aboutToAppear(): void {
  this.daily = this.buildDailyQuestion(new Date())
}

这样渲染阶段只读取状态,测试也能给 buildDailyQuestion 注入固定日期。

八、每日答案展开是纯页面状态

revealAnswer 使用 @State

Text(this.revealAnswer ? '收起答案' : '查看答案')
  .onClick(() => {
    this.revealAnswer = !this.revealAnswer
  })

它不写入学习进度,也不把“看过答案”算作完成题目。这个边界合理,因为浏览解析不等于作答。但如果产品希望记录每日学习完成,应该在明确动作上写入独立 dailyRecord,而不是借用总答题数猜测。

九、“今日已学”当前不是日期状态

源码实现是:

private todayLearned(): boolean {
  return this.myStats().totalAnswered > 0
}

只要用户历史上答过任何一道题,以后每天都会显示“今日已学”。这与文案语义不一致。正确模型至少需要日期键:

interface DailyLearningRecord {
  dateKey: string
  questionId: string
  answered: boolean
}

首页比较当前 yyyy-MM-dd 与记录 dateKey,才能准确展示今日状态。

十、每日一课入口连接真实练习链路

openDailyFaPu() 不是打开静态文章,而是进入每日题目所属题库的随机练习:

router.pushUrl({
  url: 'pages/PracticePage',
  params: {
    bankId: dq.bankId,
    mode: 'random'
  }
})

因此首页“每日一题”和“今日普法”卡片的语义略有差异:前者在首页展开答案,后者进入一组随机练习。文案和埋点名称应区分,避免用户以为会继续同一道题。

十一、五个快捷入口混合标签切换和路由

快捷入口分别指向法律题库、案例学习、法律分类、我的收藏和错题本。题库、收藏、错题通过修改主标签和子标签状态跳转;案例与分类通过 router push 页面。

这种混合方式是合理的,因为一级导航不需要堆页面栈,二级详情需要保留返回路径。建议为入口定义统一模型,把标题、图标、说明和动作集中管理,减少 Builder 中的重复参数。

统一模型还可以携带无障碍文案、是否启用和目标类型,让空错题、空收藏等状态不必在 Builder 中临时判断。对于 router 入口,模型应只保存明确路由与参数,真正的导航仍由页面方法执行并处理快速重复点击。

十二、入口动作必须保持可达

“法律题库”设置 currentTabIndex = 1,“我的收藏”和“错题本”还要先设置 favoriteTabIndex。回归测试应确认两项状态更新顺序不会导致收藏页先渲染旧子标签。

测试矩阵至少覆盖:

  • 首次启动点击五个入口;
  • 从其他标签返回首页再点击;
  • 错题为空时进入错题本;
  • 收藏为空时进入收藏;
  • 连续快速点击只产生一层二级路由。

入口可点击不等于目标状态正确。

十三、六大法律分区来自本地目录

REGIONS 包含民法、劳动法、消费者权益、婚姻法、网络安全和校园法律。选中 chip 后,selectedRegionId 保存当前分区:

this.selectedRegionId =
  this.selectedRegionId === r.id ? '' : r.id

再次点击会取消选择,推荐题库恢复前 3 个。每个 chip 还配置了 accessibilityText,能向读屏说明是否选中。这比只依赖颜色变化更完整。

十四、筛选没有命中时回退热门题库

filteredHotBanks() 在选中分区没有题库时返回 BANKS.slice(0, 3)。当前每个分区都有一个题库,所以回退主要用于目录异常。

从产品语义看,筛选无结果时突然出现其他分区题库可能让用户误解。更清晰的策略是显示空态并允许清除筛选。当前回退应在文章中描述为容错行为,不能说成精确筛选结果。

回归用例可临时构造一个没有题库的 Region,验证界面究竟采用热门回退还是空态,并检查 accessibilityText 是否仍准确。若保留回退,界面至少应标注“暂无该分类,推荐热门题库”,避免结果与选中 chip 相互矛盾。

十五、题库总量由运行时同步得到

六个 Bank 初始 totalCount 都是 0,模块加载时调用 syncCatalogCounts(),为每库生成题目、回填章节数量和分类数量,最后得到 TOTAL_QUESTIONS。

当前 BANK_TARGET_COUNT = 250,所以六个题库合计 1500 道运行时题目。这个数字是代码生成结果,不是后台下载量,也不是 1500 条独立采编记录。

syncCatalogCounts() 还会把题型数量和六个章节的 total 一并回填,因此目录卡片、分类页和章节页共享同一批运行时计数。测试应在模块初始化后再读取这些常量,避免在同步前把初始 0 误判成真实题库规模。

十六、题目扩展策略必须诚实披露

题库先按题干去重得到基础题源,再循环加入:

[
  '',
  '【真实案例】',
  '【实务考点】',
  '【强化训练】',
  '【高频考点】',
  '【情景判断】',
  '【法条精讲】',
  '【维权指引】',
  '【典型场景】',
  '【综合应用】'
]

这些前缀改变展示题干,但基础问题、选项和解析可能相同。因此商店和首页不应把所有扩展项宣传为“1500 个完全不同的真实案例”。更准确的说法是“六类本地法律练习,共生成 1500 个训练条目”。

十七、“真实案例”文案需要来源审查

首页 banner 写着“真实案例·以案学法”,题库前缀也包含“真实案例”。源码中的题目是本地 Mock 数据和概括性法律情景,没有看到案件编号、裁判文书链接或来源字段。

在没有可核验来源时,建议改为“典型情景”或“案例分析”。涉及具体法条、赔偿金额和程序期限的内容还应由法律专业人员复核,并在产品中提示内容仅供普法学习,不替代个案法律意见。

若未来确实引用公开案例,应为每条内容保存来源名称、发布日期、可访问链接和适用法条版本,并在法条修订后重新核对。只有带有这类来源证据的条目,才适合使用“真实案例”作为可审核的内容标签。

十八、学习进度百分比来自累计答题数

全局进度计算:

Math.min(
  100,
  Math.round(totalAnswered * 100 / TOTAL_QUESTIONS)
)

题库进度则使用该题库 finished / totalCount,并限制上限为 1。由于 updateProgress 会累加答题数,重复练习可能让 finished 超过题库总量,界面最终固定显示 100%。这实际上是“累计作答量相对题库规模”,不等于唯一题目覆盖率。

十九、覆盖率需要记录唯一题目 ID

如果产品要表达“已学习多少知识点”,应记录去重后的 questionId 集合;如果只想鼓励练习量,可以保留累计答题数,但文案应改成“练习进度”或“累计进度”。

两种模型不能混用:

  • 累计练习量允许重复题;
  • 内容覆盖率必须按唯一题去重;
  • 完成状态还要定义是否要求答对。

先确定指标语义,再决定存储结构。

唯一题集合还要处理题库版本变化:题目删除、ID 调整或基础题生成前缀变化时,旧记录如何迁移必须有规则。否则一次内容升级就可能让覆盖率突然下降或重复累计,首页数字也无法向用户解释。

二十、正确率是真实本地汇总

StatService 遍历 progressList 累加 finished 和 correct,只有 finished 大于 0 才计算正确率:

accuracyPercent: totalAnswered > 0
  ? Math.round(totalCorrect / totalAnswered * 100)
  : 0

该值可追溯到本地答题记录的汇总,属于真实派生指标。测试应覆盖首次启动 0%、全对 100%、部分正确和数据异常时的边界,避免 correct 大于 finished 导致超过 100%。

二十一、学习分钟是估算值而非计时值

当前计算:

return Math.floor(totalAnswered * 0.5) +
  this.examHistory.length * 5

每道题按半分钟、每次考试额外按 5 分钟估算。它没有读取真实会话开始结束时间,不能标注为“准确学习时长”。界面可以写“预计学习时长”,或改为在前后台生命周期和练习会话中记录真实有效时长。

二十二、进度环目前是视觉模拟

首页用两个完整圆形边框叠加,并通过 opacity 表示进度。opacity 只改变整圈透明度,不会形成真实弧长,因此 30% 看起来仍是一个较淡的完整圆。

如果需要准确的环形进度,可以使用标准 Progress 环形样式或 Canvas 绘制弧线。无论采用哪种组件,都要保持数值文本和视觉弧长一致,并测试 0、1、99、100 四个边界。

二十三、学习成果卡片直接读取真实数组长度

错题数量来自 wrongRecords.length,收藏数量来自 favRecords.length。点击卡片会切换到收藏主标签的对应子页。

这些值属于本机用户状态,可以在首页展示,但不能写成平台收藏量。删除收藏或答对错题后,StorageLink 应使首页立即刷新;重启后还要从 Preferences 回读相同数量。

首页数据流六步链路

二十四、首页计数已切换为本地真实口径

初版 DailyFaPu 中存在两组没有数据来源的固定展示值。它们既不是服务端统计,也不是本地持久化结果,因此不能作为真实阅读量或收藏量。修复后的源码直接读取已有本地状态:

Text(`累计答题 ${this.myStats().totalAnswered}`)
Text(`本地收藏 ${this.favRecords.length}`)

修改后页面不再展示无法追溯的 PV 和收藏量,也不会在文章、商店材料或平台提报中把固定展示值当作真实数据。

若未来需要公开阅读量,应建立真实计数来源,并说明数据范围、设备边界与更新时间。在真实平台数据出现前,提报表中的 PV、点赞和收藏字段应留空。

二十五、首页长列表需要考虑计算和重绘成本

HomePage 是一个纵向 Scroll,包含多个复杂 Builder、横向 Scroll、ForEach 和大量阴影。每次 StorageLink 变化可能触发重新计算,dailyQuestion()myStats() 和进度函数又在多处重复调用。

优化顺序应是:

  1. 把每日题缓存成状态;
  2. 一次计算 stats 并复用;
  3. 为 ForEach 提供稳定 key;
  4. 避免在渲染链中重复筛选全部题目;
  5. 用 Profiler 验证,而不是凭感觉重构。

首页首帧和滚动性能比单个函数的理论复杂度更重要。

二十六、稳定 key 要避免把状态写进标识

法律分区 chip 的 key 包含 selectedRegionId

`region_chip_${r.id}_${this.selectedRegionId}`

选中变化时所有 key 都改变,框架可能把整个列表视为新节点。若只需要更新样式,使用稳定的 r.id 更合适。每日选项 key 还拼接文本,文本修改也会导致节点重建。

稳定 key 应代表实体身份,而不是当前视觉状态。

二十七、多设备首页要验证内容密度

Index 根据 breakpoint 选择底部导航或侧边导航,但 HomePage 本身仍以纵向单列为主,只记录 pageWidth,没有在当前源码中使用宽屏分栏。

因此 tablet 和 2in1 上可能出现内容过宽、快捷入口过疏或长页面滚动。多设备优化可以在宽屏把每日题与学习进度并排、分类与案例分区,但必须先验证现有界面,再决定是否调整。

二十八、系统安全区已经纳入主导航

Index 从 AppStorage 获取顶部和底部避让高度:

Math.max(
  Sizes.BOTTOM_NAV_MIN_PADDING,
  px2vp(navigationIndicatorHeightPx)
)

顶部也使用 topAvoidAreaHeightPx。回归测试应覆盖有手势导航、传统导航栏、横屏和窗口缩放,确认首页内容不会被状态栏或底部导航遮挡。

二十九、无障碍信息是首页的重要能力

快捷入口、分区 chip、今日普法和成果卡片配置了 accessibilityText 与 accessibilityLevel。读屏用户可以听到标题、数量和选中状态。

还需要验证:

  • emoji 是否有可理解的替代文本;
  • “查看答案”展开后焦点是否合理;
  • 分类选中状态是否即时播报;
  • 五个入口触控尺寸是否足够;
  • 正确率和进度是否按自然语言读取。

配置属性不等于真实设备体验已通过。

三十、推荐的数据模型改进

为了让首页所有状态可解释,可以补充:

interface HomeLearningSnapshot {
  dateKey: string
  dailyQuestionId: string
  dailyAnswered: boolean
  uniqueQuestionCount: number
  totalAttemptCount: number
  activeSeconds: number
}

其中 dailyAnswered 解决今日状态,uniqueQuestionCount 表达覆盖率,totalAttemptCount 表达练习量,activeSeconds 表达真实时长。三个指标各自有明确含义,不再互相替代。

一个可直接落地的最小实现,是把“日期键”和“已答题 ID”作为持久化事实,把分钟数继续标注为估算值,直到项目真正接入前后台计时。下面的代码只使用当前工程已经存在的题目 ID、答题记录和本地日期,不引入网络能力:

interface DailyLearningRecord {
  dateKey: string
  answeredQuestionIds: string[]
}

function buildDateKey(now: Date): string {
  const month = `${now.getMonth() + 1}`.padStart(2, '0')
  const day = `${now.getDate()}`.padStart(2, '0')
  return `${now.getFullYear()}${month}${day}`
}

function markAnswered(
  current: DailyLearningRecord,
  questionId: string,
  now: Date
): DailyLearningRecord {
  const dateKey = buildDateKey(now)
  const ids = current.dateKey === dateKey
    ? current.answeredQuestionIds
    : []
  return {
    dateKey,
    answeredQuestionIds: Array.from(new Set([...ids, questionId]))
  }
}

保存时将 DailyLearningRecord 交给现有 UserDataManager,首页通过服务方法读取,不直接操作 Preferences。这样 todayLearned 可以判断“日期键等于今天且 ID 集合非空”,内容覆盖率可以用全局去重 ID 集合除以当前题库 ID 总数。重复回答同一题不会增加覆盖率,但仍可增加累计练习次数,两种指标由此分开。

迁移也要有明确顺序:旧版本第一次启动时保留 progressListexamHistory,新建空的每日记录与唯一题集合;用户下一次答题后再写入新结构。不要从旧累计数量反推具体题目 ID,因为源码没有足够证据完成这种映射。这个选择会让升级后的覆盖率从可验证的零开始,却不会伪造历史明细。

为了让读者复现,可以按以下步骤验证:清空应用数据后启动,确认“今日未学”;进入任一题库回答题目并返回首页,确认当天状态变为“今日已学”;再次回答同一题,确认累计练习量增加而唯一覆盖数不增加;把测试时钟推进到次日,确认每日状态重置且累计数据保留。最后重启应用,四项结果都应与重启前一致。

普法首页四类数据职责

三十一、首页回归测试清单

至少覆盖:

  • 首次启动时进度、正确率、错题、收藏均为 0;
  • 同一天重复进入每日题不变化;
  • 日期变化后每日题按规则轮换;
  • 空题库显示降级状态;
  • 查看答案可展开和收起;
  • 五个快捷入口目标正确;
  • 分类选中、取消和题库筛选正确;
  • 收藏、错题、答题后首页数字即时更新;
  • 重启后本地数据保持;
  • phone、tablet、2in1 无遮挡和溢出;
  • 读屏可识别入口名称和状态。

两组不可追溯固定计数已经从当前源码移除;回归时应确认页面只显示本地累计答题数和本地收藏条数。

三十二、源码事实与产品表述边界

可以确认的事实:

  • 六大法律分区;
  • 六个本地题库;
  • 每库生成 250 个训练条目;
  • 每日题按本地日期稳定选择;
  • 收藏、错题、进度和考试历史来自本地状态;
  • 正确率由真实本地累计数据计算;
  • 首页入口可切标签或进入二级页面。

必须谨慎表述:

  • 今日已学目前不是按日期记录;
  • 学习分钟是估算;
  • 阅读和收藏静态数字已移除,当前展示累计答题与本地收藏;
  • “真实案例”缺少来源字段;
  • 1500 条包含基础题的前缀扩展;
  • 全局进度不是唯一题目覆盖率。

三十三、结语

一个可信的普法首页,需要把目录、每日内容、用户状态和展示指标分别建模。知律当前已经具备按日选题、StorageLink 状态回流、法律分类筛选和本地学习汇总的主体结构,静态运营数字也已从源码移除。后续仍需修正今日完成语义、学习时长和唯一题目覆盖率口径。

技术实现的关键不是让首页显示更多,而是让每个数字、标签和入口都能追溯到真实数据与明确动作。做到这一点后,多设备布局、性能优化和无障碍增强才有可靠基础。

本文部分内容由 AI 辅助整理,所有源码事实、数据口径和能力边界均以知律项目实际代码复核为准。

三十四、2026-07-27 数据口径静态复核

本轮复核固定在应用版本 1.0.0targetSdkVersion 6.0.2(22)compatibleSdkVersion 6.0.1(21),目标文件是 entry/src/main/ets/views/HomePage.ets。为了避免把页面中的展示数字误写成平台运营数据,使用下面的静态检查同时核对占位文本、真实本地数组和“今日已学”判断。

$source = Get-Content -Raw -Encoding UTF8 `
  'entry/src/main/ets/views/HomePage.ets'

[pscustomobject]@{
  staticReadingPlaceholder =
    ([regex]::Matches($source, '阅读\\s+\\d+')).Count
  staticFavoritePlaceholder =
    ([regex]::Matches($source, '收藏\\s+\\d+')).Count
  usesFavoriteRecords =
    $source.Contains('this.favRecords.length')
  usesWrongRecords =
    $source.Contains('this.wrongRecords.length')
  dailyStateUsesTotalAnswered =
    $source.Contains('return this.myStats().totalAnswered > 0')
  hasDateScopedRecord =
    $source.Contains('DailyLearningRecord')
} | ConvertTo-Json

在上述版本源码上执行,实际输出如下:

{
  "staticReadingPlaceholder": 0,
  "staticFavoritePlaceholder": 0,
  "usesFavoriteRecords": true,
  "usesWrongRecords": true,
  "dailyStateUsesTotalAnswered": true,
  "hasDateScopedRecord": false
}

结果说明本轮治理已经生效:staticReadingPlaceholderstaticFavoritePlaceholder 都为 0;favRecords.lengthwrongRecords.length 继续来自本地状态。todayLearned() 仍读取累计答题数,而不是日期范围内的记录;源码中也没有 DailyLearningRecord。因此静态运营数字进入通过项,而“今日已学”的日期语义仍列为后续改造,不会据此编造用户活跃度。

最小修正不需要引入联网统计。第一步已经完成:DailyFaPu() 改为显示累计答题数和本地收藏条数;第二步仍是在服务层新增按日期保存的答题 ID 集合,让“今日已学”只由当天记录决定;第三步把学习分钟继续标注为估算值,直到工程真正记录学习时长。本文只把第一步写成已实现能力。

这里同样区分构建复核与真机测试:修改后的工程执行 hvigorw.bat --no-daemon assembleHap,实际结果为 BUILD SUCCESSFUL in 20 s 177 msCompileArkTSPackageHapSignHap 均完成;但没有执行安装、答题、跨日、重启和多设备真机回归,因此不宣称这些交互已通过。文章给出的日期迁移结构和回归步骤是后续改造的验收合同。

唯一复核标记:AGC18-HMOS-09-01-METRIC-AUDIT-V2

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐