乒乓球口袋教练 HarmonyOS 学习应用(01):动作课程模型与专项内容组织
一、专项内容为何先落在课程模型
乒乓球课程不是只靠标题排列的海报集合。发球、接发、相持和战术训练既需要面向用户的标题,也需要稳定的 id、模块归属、子分类和顺序。把这些字段收敛到课程模型,分类页、搜索页和详情页才会对同一门课给出相同结论。
export interface Course { id: string; moduleId: string; subCategory: string; subCategoryLabel: string; title: string; desc: string; thumbKey: string; videoSrc?: string; durationSec: number; order: number; intro: Block[]; keyPoints: Block[]; mistakes: Block[]; training: Block[]; keywords: string[]; externalVideoRefs?: ExternalVideoRef[]; } export interface SubCategory { id: string; label: string; } export class ExternalVideoRef { platform: string = ''; collectionTitle: string = ''; sectionTitle: string = ''; bvid: string = ''; cid: number = 0; page: number = 0; part: string = ''; url: string = ''; durationSec: number = 0; }二、模块与子分类如何限制列表范围
coursesByModule 先按 moduleId 过滤,再按 order 排序;分类页在此基础上叠加 subCategoryId。这样“全部”与某一专项不是两份手写列表,新增课程只需补齐模型字段。页面不应通过标题包含某个词来猜测它属于发球还是接发。
| 事实 | 唯一来源 | 页面职责 |
|---|---|---|
| 正常结果 | 模型或服务的回读 | 渲染可观察状态 |
| 边界结果 | 明确的缺项或失败分支 | 说明原因并保留入口 |
| 重进结果 | 持久化或模型再次解析 | 不复用旧页面变量 |
} export function coursesByModule(moduleId: string): Course[] { return COURSES.filter(c => c.moduleId === moduleId).sort((a, b) => a.order - b.order); } export function findCourse(id: string): Course | undefined { return COURSES.find(c => c.id === id); } export function nextCourse(id: string): Course | undefined { const cur: Course | undefined = findCourse(id); if (cur === undefined) return undefined; const list: Course[] = coursesByModule(cur.moduleId); const idx: number = list.findIndex(c => c.id === id); return idx >= 0 && idx < list.length - 1 ? list[idx + 1] : undefined; } export function prevCourse(id: string): Course | undefined { const cur: Course | undefined = findCourse(id); if (cur === undefined) return undefined; const list: Course[] = coursesByModule(cur.moduleId); const idx: number = list.findIndex(c => c.id === id); return idx > 0 ? list[idx - 1] : undefined; }三、详情页怎样避免标题和课程事实脱节
详情页从路由参数取得 courseId 后重新查找课程,并据此构造标题、章节和视频入口。这个做法让路由只携带稳定标识;页面重建时能重取最新课程,而不是复用上一张卡片遗留的文案或图片。
.backgroundColor(this.palette.bg); } private filtered(): Course[] { const all: Course[] = coursesByModule(this.moduleId); if (this.subCategoryId === 'all') return all; return all.filter(c => c.subCategory === this.subCategoryId); } }四、数据缺项时应该停止在哪里
模型缺项要显式处理。未知 moduleId、找不到 courseId 或没有子分类时,页面应返回空状态或禁用无效入口,而不是用列表第一项作为默认课程。默认回退会让用户进入错误专项,也会让后续学习记录归属错误。
| 风险 | 不能采用的做法 | 当前链路的处理 |
|---|---|---|
| 数据不存在 | 用默认对象继续渲染 | 停在可解释的空或失败状态 |
| 页面重进 | 复用上一次内存字段 | 重新查询模型或服务 |
| 重复动作 | 追加同一业务事实 | 通过稳定 id 或进入标记收敛 |
import { AppSizes, AppText, ColorPalette, LightPalette, CategoryTabs, TabItem, BlockRenderer, PlaceholderImage, AppToast, TopBar, Routes, DetailParams, PlayerParams, Course, Block, findCourse, nextCourse, prevCourse, coursesByModule, FavoriteService, ProgressService, formatDuration, courseCover, courseVideoSrc, CourseVideoService } from 'library1'; const TAB_INTRO: string = 'intro'; const TAB_KEY: string = 'key'; const TAB_MISTAKE: string = 'mistake'; const TAB_TRAIN: string = 'train'; const TXT_DETAIL: string = '\u8bfe\u7a0b\u8be6\u60c5'; const TXT_NOT_FOUND: string = '\u8bfe\u7a0b\u4e0d\u5b58\u5728'; const TXT_SHARE: string = '\u5206\u4eab'; const TXT_SAVED: string = '\u5df2\u6536\u85cf'; const TXT_SAVE: string = '\u6536\u85cf'; const TXT_COPY_OK: string = '\u5df2\u590d\u5236\u5230\u526a\u8d34\u677f'; const TXT_SAVE_OK: string = '\u5df2\u52a0\u5165\u6536\u85cf'; const TXT_UNSAVE_OK: string = '\u5df2\u53d6\u6d88\u6536\u85cf'; const TXT_PLAY_LOCAL: string = '\u70b9\u51fb\u64ad\u653e'; const TXT_NO_VIDEO_PLACEHOLDER: string = '\u672c\u8bfe\u7a0b\u4ee5\u56fe\u6587\u5b66\u4e60\u4e3a\u4e3b\uff0c\u6682\u65e0\u914d\u5957\u89c6\u9891'; const TXT_DOWNLOAD_LOCAL: string = '\u4e0b\u8f7d\u5230\u672c\u5730'; const TXT_DOWNLOADED_LOCAL: string = '\u5df2\u4e0b\u8f7d\u5230\u672c\u5730'; const TXT_DOWNLOADING_LOCAL: string = '\u6b63\u5728\u4fdd\u5b58'; const TXT_DOWNLOAD_OK: string = '\u5df2\u4fdd\u5b58\u5230\u672c\u5730\u5e76\u540c\u6b65\u5230\u76f8\u518c'; const TXT_DOWNLOAD_LOCAL_OK: string = '\u5df2\u4fdd\u5b58\u5230\u672c\u5730';五、从首页到详情的验收路径
验收从首页选择一个模块开始,依次切换子分类、打开一门课程并返回列表。需要观察模块标题、课程数量、选中分类与详情标题是否都来自同一 id;再用一个不存在的 id 验证页面不会展示无关课程。
| 验收阶段 | 操作 | 应观察到的结果 |
|---|---|---|
| 建立事实 | 完成一次与主题直接对应的动作 | 服务或模型能回读该事实 |
| 页面回读 | 进入目标页或结果页 | 标题、数据和状态相互一致 |
| 重进验证 | 返回、重启或切换模块后再次进入 | 结果不依赖上一页残留 |
六、取舍与后续维护
当前实现把业务事实集中到模型与服务,页面只负责呈现和触发操作。这样做会多出类型、查询和回读代码,却能避免同一份统计或记录在多个页面分叉。需要扩展新课程、新资源或新题目时,应先补齐稳定标识与服务合同,再补页面入口。
ArkTS 的响应式状态机制可参考 HarmonyOS ArkTS 状态管理文档。本文的代码和验收围绕同一个原则:可见结果必须能回到对应模型、服务或持久化事实,不以邻近页面和控件文字代替主题结果。
七、围绕事实来源继续演进
乒乓球口袋教练 HarmonyOS 学习应用(01):动作课程模型与专项内容组织 关注的不是给页面再增加一层显示逻辑,而是让“专项内容为何先落在课程模型”始终能回到唯一的业务事实。模型字段、服务返回值和页面展示应当保持单向关系:事实先发生,页面随后回读;页面不保存第二份能够被误认为真实数据的临时副本。
处理“模块与子分类如何限制列表范围”时,新增条件必须先归属到课程模型、资源服务、日志事实或测验结果之一。把条件散落在多个组件里,短期看起来省事,后续就会出现入口不同、重进不同、统计不同的问题。把规则放回稳定入口,页面只根据结果改变可见状态,能让同一结论在不同入口被复查。
“详情页怎样避免标题和课程事实脱节”说明当前实现选择了页面重建后重新查询,而不是把对象和统计快照长期塞进路由参数。读取会增加少量成本,却避免课程改名、资源替换、日志编辑或题库切换后继续显示旧内容。学习应用的结果需要能解释,不能只追求一次渲染恰好看起来正确。
异常分支也应保留语义:模型缺项要显式处理。未知 moduleId、找不到 courseId 或没有子分类时,页面应返回空状态或禁用无效入口,而不是用列表第一项作为默认课程。默认回退会让用户进入错误专项,也会让后续学习记录归属错误。 这要求页面区分数据不存在、动作未完成和资源不可用,并提供返回选择、补充输入、重新下载或再次答题等对应操作;不能把不同原因压缩成同一条模糊的成功或失败提示。
最后执行“从首页到详情的验收路径”时,至少观察一次主题动作、一次目标页回读和一次重进或切换后的回读。三次观察都指向相同模型或服务结果,才说明实现链路没有依赖旧页面残留。 最后执行“从首页到详情的验收路径”时,至少观察一次主题动作、一次目标页回读和一次重进或切换后的回读。三次观察都指向相同模型或服务结果,才说明实现链路没有依赖旧页面残留。
面向维护的检查可以沿三个问题展开:输入对象的稳定标识是什么,哪个服务或模型决定结果,页面如何把该结果显示出来。用模块、子分类、课程顺序与课程 id 组织发球、接发、相持和战术内容,让首页、分类页与详情页共享同一份课程事实。 这三个问题分别对应数据合同、调用边界和可见反馈;只要其中一层被绕过,新增入口就可能产生和原入口不同的事实。
对失败路径也要保留同样的追踪能力。先让服务返回清晰的不可用、缺项或未完成状态,再由页面提供与原因匹配的操作。这样日志、课程、资源和题目发生变化时,回归测试能够看到具体的分歧点,而不是只能面对一张没有信息的空白页面。
这种组织不会承诺所有后续需求都零成本。新增字段需要同步更新模型、服务与验收;但它把变更范围限制在可枚举的位置,避免把同一判断复制到首页、详情、个人页和结果页后再分别修补。
