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

📅 2026/7/26 14:25:28
羽毛球教程 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支持跨模块搜索。intro、keyPoints、mistakes、training采用块模型使详情页可以按文本、列表等不同块类型渲染。字段消费者不采用的替代方案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) .mapTabItem((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[]远端失败可读取最近一次完整缓存缓存也为空才显示整体错误不能把半份响应直接合并进当前数组否则分类计数、前后篇顺序和进度分母会同时失真。