第18次:课程详情页面

本文讲解单节课程内容页的实现,对应文件是 entry/src/main/ets/pages/LessonDetail.ets。如果说模块详情页是目录,那么课程详情页就是正文,这里既要展示讲解内容,也要展示代码示例、关键要点、收藏入口和完成按钮。


这一页在整个流程里的位置

当用户从模块详情点击某一节课时,会进入 LessonDetail。这个页面需要解决的事情非常多:

  • 根据 moduleId + lessonId 查到具体课时
  • 展示课程说明和正文段落
  • 根据不同 section 类型渲染普通文本、提示和警告
  • 通过 CodeBlock 展示代码示例
  • 支持跳转到代码调试器
  • 支持收藏与完成状态切换
  • 在完成后联动进度与徽章服务

ModuleDetail 点击某课时

LessonDetail 读取路由参数

TutorialService.getLessonById

渲染 description + sections

渲染 codeExamples

可跳转 CodePlayground

渲染 keyTakeaways

点击 标记为完成

ProgressService.markLessonComplete

BadgeService.checkAndAwardBadges


一、页面初始化时拿到的不是整本模块,而是单节课

LessonDetailaboutToAppear() 中做的事情很直接:

  • 从路由参数里拿到 moduleIdlessonId
  • 调用 TutorialService.getLessonById(moduleId, lessonId)
  • 同时读取当前收藏状态
  • 再检查当前课时是否已完成

这一步说明一个很重要的设计点:课时详情页不自己遍历全部数据,而是精准查找单条业务对象。这样页面状态会更轻、更清晰,也更适合做细粒度交互。


二、正文内容不是一整块字符串,而是结构化 section

这一页真正值得学的地方,是课程正文的组织方式。项目没有把正文写成一个超长富文本,而是把内容拆成 ContentSection[]。每个 section 至少包含:

  • type
  • title
  • content

在页面层,SectionContent(section) 根据 type 做不同渲染:

  • 普通文本:直接 Text
  • tip:显示高亮提示块
  • warning:显示警告块

这样做有几个明显优势:

  1. 数据和渲染是解耦的。
  2. 同一套数据结构可以给不同页面或组件复用。
  3. 以后要增加新的内容类型,比如“引用”“小结”“练习题”,只需要扩展渲染逻辑。

这就是教程内容型应用非常典型的写法:正文内容结构化,页面负责解释结构。


三、代码示例区为什么单独做成一个板块

当前项目里,课程内容中的代码示例不是混在普通 section 里,而是放在 lesson.content.codeExamples 里单独渲染。它的好处非常明显:

  • 可以统一使用 CodeBlock 组件。
  • 每段代码可以带标题、语言类型和解释说明。
  • 某些代码示例还能带 isEditable,一键跳到 CodePlayground

这就把“阅读代码”和“动手练习”连接起来了。对于学习类应用来说,这一步非常关键,因为只有展示代码不够,最好还能让用户试着改、试着运行。

你会看到页面中为可编辑示例提供了“在调试器中打开”按钮,这个按钮点击后会把:

  • title
  • code
  • language
  • explanation

一起通过路由参数传给 CodePlayground。这是一种很实用的鸿蒙页面协作方式。


四、关键要点区块为什么放在正文后面

KeyTakeawaysSection() 负责展示 keyTakeaways。它没有放在最上面,而是放在正文和代码示例之后,原因很合理:

  • 先理解内容
  • 再看代码
  • 最后做归纳

这和真实学习习惯是一致的。关键要点不是替代正文,而是帮助复盘。UI 上使用编号圆点,也是为了让要点更容易扫读。

这一块设计很适合你以后写知识类应用时借鉴。很多初学者会把“重点”塞进正文里,结果越写越乱。更好的做法就是像当前项目这样:正文负责展开,关键要点负责收束。


五、完成按钮背后的联动非常值得记

CompleteButton() 的点击事件并不只是把按钮颜色改一下。真实逻辑是:

  1. 调用 ProgressService.markLessonComplete(this.lesson.id, this.moduleId)
  2. 把本地 isCompleted 更新成 true
  3. 再读取最新 progress
  4. 调用 BadgeService.checkAndAwardBadges(progress)

这说明“完成学习”这个动作,其实会牵动多个系统:

  • 课程系统
  • 进度系统
  • 徽章系统
  • 首页与个人中心的展示系统

所以你以后看到一个按钮,不要只盯着它的 UI 样式,而要追踪它背后的业务链路。

BadgeService ProgressService LessonDetail 用户 BadgeService ProgressService LessonDetail 用户 点击 标记为完成 markLessonComplete() 进度更新完成 loadProgress() checkAndAwardBadges(progress) 新徽章列表

六、收藏按钮为什么放在标题栏

这个细节其实体现了页面优先级。课时详情页最核心的交互只有两个:

  • 收藏
  • 标记完成

收藏按钮被放在标题栏右侧,是因为它属于“轻量即时操作”,不需要占大面积空间;而“标记完成”放到底部,是因为它属于主要动作,需要更明确、更有存在感。

这是一种很合理的交互分层:

  • 轻操作放头部
  • 主动作放底部
  • 内容放中间

你以后做文章页、笔记页、教程页时,都可以参考这种层次。


七、这一页和上一页的边界一定要分清

很多人写教程时容易把 ModuleDetailLessonDetail 讲混。最简单的区分方式是:

  • ModuleDetail 负责“列目录”
  • LessonDetail 负责“讲正文”

具体来说:

  • 模块页关心的是课时列表、完成状态、收藏状态。
  • 课程页关心的是正文结构、代码示例、关键要点、完成按钮。

如果你把两者边界理清,后面看 CodeBlockCodePlayground 的时候就会发现,代码阅读与调试功能之所以能成立,前提就是 LessonDetail 已经把课时内容组织得足够清楚。


八、自己实操时建议怎么验证

  1. 从任意模块进入一节课程。
  2. 看顶部标题、课程描述、正文 section 是否都能正常显示。
  3. 找到代码示例,确认 CodeBlock 渲染正常。
  4. 点击“在调试器中打开”,观察参数是否正确传到 CodePlayground
  5. 点击收藏,再返回模块详情页,看收藏状态是否同步。
  6. 点击“标记为完成”,再返回首页和个人中心,看学习统计和徽章是否变化。

只要你自己把这条链路走通一次,这个页面的设计思路就会非常牢固。


九、本篇常见坑

1. 把正文内容写死在页面里

页面应该消费 lesson.content.sections,而不是自己拼一堆文字。

2. 代码示例和正文混着写

把代码示例单独抽成 codeExamples,更利于展示和后续调试跳转。

3. 完成按钮只改本地状态

如果不调用 ProgressService,那只是“假完成”,刷新页面数据就丢了。

4. 忽略徽章联动

完成课时之后还要继续检查徽章条件,这样奖励体系才是闭环。


本篇小结

课程详情页是这个项目真正承载教学内容的地方。你应该重点记住这几件事:

  • 它按 moduleId + lessonId 精准定位课时。
  • 它通过 sectionscodeExampleskeyTakeaways 组织正文。
  • 它把收藏、完成、代码调试入口都串到了同一页。

理解了这一页,接下来读 CodeBlockCodePlayground 就会非常自然,因为它们本来就是为这页服务的。


跟着真实源码继续往下看

课时详情页加载课时和收藏状态的真实代码:

aboutToAppear(): void {
  const params = router.getParams() as RouterParams;
  if (params?.moduleId && params?.lessonId) {
    this.moduleId = params.moduleId;
    this.lesson = TutorialService.getLessonById(params.moduleId, params.lessonId);
    this.isBookmarked = BookmarkService.isBookmarked(params.lessonId);
    this.checkCompletion();
  }
}

代码示例区域的真实写法如下:

ForEach(this.lesson.content.codeExamples, (example: CodeExample) => {
  Column() {
    CodeBlock({
      code: example.code,
      language: example.language,
      title: example.title,
      explanation: example.explanation
    })

    if (example.isEditable) {
      Button('🔧 在调试器中打开')
        .onClick(() => {
          router.pushUrl({
            url: 'pages/CodePlayground',
            params: {
              title: example.title,
              code: example.code,
              language: example.language,
              explanation: example.explanation
            }
          });
        })
    }
  }
})

按这个顺序动手

  1. 打开 entry/src/main/ets/pages/LessonDetail.ets
  2. 搜索 codeExamples,看代码示例是如何交给 CodeBlock 的。
  3. 再搜索 markComplete(),确认完成动作如何联动进度和徽章。

课后练习

  1. 画出 LessonDetail -> CodePlayground 的参数传递关系。
  2. 总结 SectionContent() 针对普通文本、tip、warning 三类内容分别采用了什么视觉样式。
  3. 思考如果你要加入“课后练习”板块,应该放进 sections,还是单独做成一个新字段,为什么。
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐