羽毛球教程 HarmonyOS 学习应用(02):课程模型与分类筛选

📅 2026/7/26 14:26:10 👁️ 阅读次数 📝 编程学习
羽毛球教程 HarmonyOS 学习应用(02):课程模型与分类筛选

一、两级分类解决什么问题

羽毛球知识既有基础规则、技巧分析、装备详解等大模块,也有发球、击球、步伐、防守等模块内子分类。若页面只维护一串展示文案,课程详情、学习进度、收藏和测验很快会失去共同的业务标识。

应用采用“模块 → 子分类 → 课程”三级结构。模块负责首页入口与主题,子分类负责页面筛选,课程对象承载详情内容。界面上的“已学”标签则来自进度状态,不写进课程静态数据。

二、课程对象保存可复用的业务字段

课程模型同时服务列表、详情、搜索、进度和前后篇跳转,因此字段使用稳定 id,而不是依赖数组位置或标题。

export interface Course { id: string moduleId: string subCategory: string subCategoryLabel: string title: string desc: string thumbKey: string durationSec: number order: number intro: Block[] keyPoints: Block[] mistakes: Block[] training: Block[] keywords: string[] } export function coursesByModule(moduleId: string): Course[] { return COURSES .filter(course => course.moduleId === moduleId) .sort((a, b) => a.order - b.order) }

moduleId建立大类关系,subCategory用于页面筛选,order决定模块内顺序,keywords支持跨模块搜索。introkeyPointsmistakestraining采用块模型,使详情页可以按文本、列表等不同块类型渲染。

字段消费者不采用的替代方案
id路由、收藏、进度不能用标题,标题可能调整
moduleId首页与分类页不能从文件名推断
subCategory子分类筛选不能用中文标签作主键
order列表与前后篇不能依赖录入顺序
keywords搜索服务不能只搜索标题

三、模块元数据与课程内容分开维护

八个学习模块有自己的标题、图标、渐变色和启用状态。模块卡片不需要读取整份课程正文,只读取轻量元数据。

export interface LearnModule { id: string title: string subtitle: string iconKey: string enabled: boolean gradientStart: string gradientEnd: string } export function findModule(id: string): LearnModule | undefined { return MODULES.find(m => m.id === id) }

这种分离让首页只关心模块导航,课程详情只关心内容对象。模块暂时不可用时可通过enabled显示禁用态,而不需要删除课程数据。

四、筛选状态由页面直接观察

分类页从路由读取moduleId,并用两个@State字段保存当前模块和子分类。点击标签只更新subCategoryId,列表随状态重新计算。

@State moduleId: string = 'skill_analysis' @State subCategoryId: string = 'all' @State moduleTitle: string = '' private filtered(): Course[] { const all: Course[] = coursesByModule(this.moduleId) if (this.subCategoryId === 'all') { return all } return all.filter(c => c.subCategory === this.subCategoryId) }

筛选结果是静态课程数据的投影,不需要另存一份可变列表。这样连续切换“发球 → 击球 → 全部”时不会累积旧结果,也不会把筛选后的数组误写回课程源。

筛选字段与组件刷新之间的关系遵循华为官方 @State 状态管理说明:页面改变可观察状态后,只让直接依赖它的标签和列表重新渲染。

五、CategoryTabs 的高亮必须绑定 selectedId

子分类组件接收selectedId和回调,文字颜色、字重与指示条都在build()中直接读取当前值。

CategoryTabs({ items: subCategoriesForModule(this.moduleId) .map<TabItem>((s: SubCategory) => ({ id: s.id, label: s.label })), selectedId: this.subCategoryId, onSelect: (id: string) => this.subCategoryId = id })

如果把标签行封装进捕获旧值的 Builder,父页面虽然改变了筛选结果,指示条却可能仍停在原标签。当前实现把每个标签放在组件主构建路径中,使@Prop selectedId的变化能直接驱动高亮刷新。

六、ForEach 键同时考虑课程身份与已学状态

课程列表还要显示“已学/未学”。页面使用课程 id 加完成状态生成键,使进度变化时对应卡片能重新渲染。

ForEach(this.filtered(), (course: Course) => { ListItem() { CourseCard({ course, onTap: () => router.pushUrl({ url: Routes.DETAIL, params: { courseId: course.id } }) }) } }, (course: Course) => course.id + '_' + (this.studied.indexOf(course.id) >= 0 ? 'done' : 'todo') )

单独使用数组下标会把位置当身份,筛选后同一位置可能变成另一门课程;只使用课程 id 又可能让旧的子树继续显示之前的完成标记。把业务身份和影响卡片展示的状态合并到键中,刷新范围更明确。

七、空结果和测验入口属于列表状态

筛选结果为空时,页面显示“该分类下还没有章节”,并建议切换分类;不会继续渲染空白卡片。模块存在题库时,列表尾部再追加测验入口,并显示历史最高分。

场景页面结果恢复入口
路由没有模块参数使用默认技巧分析正常展示
模块 id 未找到标题降级为“学习模块”返回上页
子分类无课程显示空状态切换其他分类
课程 id 失效详情页拒绝渲染返回课程列表
模块无题库不显示测验入口继续浏览课程

八、运行界面的验证路径

现有运行界面显示“技巧分析”模块、全部/发球/击球/步伐/防守五个标签,以及课程卡片的已学状态。围绕这张界面可以完成以下验证:

1. 进入技巧分析,默认“全部”高亮且课程按order排列。 2. 点击“发球”,只保留短发球等发球课程。 3. 点击“步伐”,课程集合变化且标签高亮同步。 4. 完成一门课程后返回,卡片由“未学”刷新为“已学”。 5. 选中无内容分类时出现可理解的空状态,不留下整页空白。

数据验收还要覆盖模型质量。每个模块 id 必须能在模块元数据中找到,每个课程的子分类必须属于对应模块,order在模块内应保持可预测,正确的缩略图键与详情内容不能缺失。若课程标题调整,既有收藏和进度仍应通过 id 找到同一对象;若某个课程被移除,收藏、历史和进度页要忽略失效 id,而不是渲染空卡片。对这类静态数据,可在构建前增加一致性脚本,集中报告重复 id、未知模块和未知子分类。

九、模型稳定后页面才能持续扩展

课程数量增加时,页面不需要再添加新的条件分支;录入课程对象、补齐子分类元数据即可进入现有筛选链路。稳定 id 也让收藏、进度、搜索和测验能够引用同一门课程,而不是各自维护一套标题映射。

当课程改为远端下发时,这套模型仍可保留,但需要在数据源外增加版本、缓存和失败回退。页面只接收已经归一化的Course[]:远端失败可读取最近一次完整缓存,缓存也为空才显示整体错误;不能把半份响应直接合并进当前数组,否则分类计数、前后篇顺序和进度分母会同时失真。