素材库详情页最容易犯的错误是把“题库介绍、章节列表、练习按钮、题目解析、例句、收藏、笔记”全部塞进同一个页面。这样首屏看起来功能很满用户真正开始练习时却会被信息噪声打断。笔下生辉的源码采用了更稳的分层BankDetailPage.ets负责题库说明、章节进度和练习入口PracticePage.ets承接具体题目、答案解析、例句、收藏、笔记和答题卡。本文基于本地工程D:\huawei\one17-11可复核源码编写主要文件包括entry/src/main/ets/pages/BankDetailPage.ets、entry/src/main/ets/pages/PracticePage.ets、entry/src/main/ets/mock/MockBanks.ets、entry/src/main/ets/common/components/GreenButton.ets与entry/src/main/ets/common/components/TopBar.ets。文章不声称任何公开发布结果也不把 CSDN 草稿或本地源码复查误报为 AppGallery 后台状态。本文解决四个工程问题题库详情页如何接收bankId并支持固定题库页面复用同一个内容组件。题库说明、纠错提示、重点标签和章节列表如何分区避免详情页变成题目页。章节练习、随机练习、限时挑战如何通过路由参数进入同一个PracticePage。例句、解析、收藏、笔记为什么应由练习页承接而不是放在详情页里提前展开。一、先划清详情页和练习页的职责从源码看BankDetailPage.ets并没有直接展示题目选项、答案解析或收藏按钮。它展示的是题库级信息封面、题库简介、纠错提示、重点标签、章节进度、底部练习入口。题目级能力在PracticePage.ets中实现包括analysis、example、收藏、笔记和答题卡。这个边界可以用一张表概括页面负责什么不负责什么BankDetailContent选择题库、展示题库说明、展示章节进度、发起练习逐题答题、收藏当前题、展示答案解析PracticePage加载题目、选择答案、显示解析和例句、收藏、笔记、答题卡题库营销介绍、题库封面介绍MockBanks提供题库、章节、题目、解析、例句等本地数据页面布局和用户交互UserDataManager读取和更新进度、收藏、错题、笔记控制页面展示结构这种拆分对 HarmonyOS 应用尤其重要。ArkUI 页面如果把所有交互都塞在一个build()里很快会出现状态交叉用户在详情页收藏题目但题目还没加载章节进度更新了但详情页和练习页都在算同一个数字返回路径也会变得难以预测。源码选择用路由把详情页和练习页隔开结构更可维护。二、BankDetailContent 同时支持路由参数和固定题库BankDetailContent定义了一个fixedBankId又能从router.getParams()读取bankId。这说明同一套内容组件既可以作为通用BankDetailPage使用也可以被SichuanBankPage、YueBankPage、MinnanBankPage等固定题库页面复用。interface BankDetailParams { bankId: string } Component export struct BankDetailContent { fixedBankId: string State bank: Bank | undefined undefined aboutToAppear(): void { if (this.fixedBankId.length 0) { this.bank getBankById(this.fixedBankId) return } const params router.getParams() as BankDetailParams | undefined if (params params.bankId) { this.bank getBankById(params.bankId) } } }这段代码有两个可复用点第一固定页面优先。只要传入fixedBankId组件就不会再依赖外部路由参数适合把某个题库做成独立入口页。第二通用详情页仍能通过bankId动态加载题库。首页的BankCard可以用router.pushUrl({ url: ..., params: { bankId } })进入详情页。潜在风险也要看清如果bankId不存在bank会保持undefined页面进入空状态。这个行为是合理的但发布前要验证异常参数、空参数、旧路由入口是否都能显示“未找到题库”而不是白屏。三、空状态不是错误页而是详情页的兜底状态源码中build()里先渲染TopBar然后判断this.bank undefined。找不到题库时不会继续访问this.bank!.name、this.bank!.chapters而是展示空图和提示。if (this.bank undefined) { Column({ space: 12 }) { Image($r(app.media.img_empty_default)) .width(120) .height(120) .objectFit(ImageFit.Contain) .opacity(0.6) Text(未找到题库) .fontSize(Sizes.BODY_FONT) .fontColor(Colors.TEXT_HINT) } .layoutWeight(1) .width(100%) .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) }这个兜底对审核和稳定性都很关键。详情页的入口不止一个题库卡片、搜索结果、分类入口、固定题库页面都可能跳转过来。只要有一个入口传错参数页面就可能进入异常状态。用undefined分支兜住至少能保证不会因为空对象访问导致崩溃。如果继续增强可以把空状态补一个“返回首页”或“查看全部题库”按钮。但当前源码已经满足最基本的稳定性要求参数无效时可见、可返回、不会继续渲染题库详情。四、HeroCard 只展示题库身份和进度概览详情页的HeroCard()把题库封面、题库名称、副标题、总题量、已答数量和正确率放在封面图上。它不展示具体题目也不提前展示答案解析。Builder HeroCard() { Stack({ alignContent: Alignment.BottomStart }) { Image(this.bank!.cover) .width(100%) .height(this.useWideLayout() ? 260 : 210) .objectFit(ImageFit.Cover) .borderRadius(Sizes.CARD_RADIUS) Column() {} .width(100%) .height(this.useWideLayout() ? 260 : 210) .borderRadius(Sizes.CARD_RADIUS) .linearGradient({ angle: 0, colors: [[#00000000, 0], [#B3000000, 1]] }) Column({ space: 10 }) { Text(this.bank!.name) Text(this.profile().subtitle) Row({ space: 8 }) { this.HeroTag(共 ${this.bank!.totalCount} 题) this.HeroTag(已答 ${this.bankFinished()} 题) this.HeroTag(正确率 ${Math.round(this.bankAccuracy() * 100)}%) } } } }这里的工程意图很明确先让用户确认“我进的是哪个素材库”再告诉用户“这个库我练到哪里了”。profile().subtitle提供题库定位bankFinished()和bankAccuracy()提供个人状态。这几个统计方法没有写死在 UI 中而是通过UserDataManager.getProgress(...)读取全局进度private bankFinished(): number { const p UserDataManager.getProgress(this.progressList, this.bankId()) return p ? p.finished : 0 } private bankAccuracy(): number { const p UserDataManager.getProgress(this.progressList, this.bankId()) if (!p || p.finished 0) return this.bank ? this.bank.accuracy : 0 return p.correct / p.finished }这样做的好处是详情页和首页可以共享同一份题库进度而不是各自维护展示数字。五、ProfileCard 把说明和纠错提示分开题库详情页需要解释“这个库练什么”但如果只写一段长文案用户很难扫读。源码用ProfileCard()将题库简介和纠错提示分为两个层级。Builder ProfileCard() { Column({ space: 12 }) { Text(题库简介) Text(this.profile().intro) .fontSize(Sizes.BODY_FONT) .fontColor(Colors.TEXT_SECONDARY) .lineHeight(22) Column({ space: 8 }) { Text(纠错提示) .fontSize(Sizes.SMALL_FONT) .fontWeight(FontWeight.Medium) .fontColor(Colors.PRIMARY) Text(this.profile().cultureNote) .fontSize(Sizes.CAPTION_FONT) .fontColor(Colors.TEXT_HINT) .lineHeight(20) } .backgroundColor(Colors.BACKGROUND_ALT) } }intro解决“为什么练这个库”cultureNote解决“练习时要注意什么”。例如错别字、病句、标点、词语误用、网络热词、古诗词这些题库学习重点并不相同用BankDetailProfile分开配置比把文案散落在 UI 里更合适。interface BankDetailProfile { subtitle: string intro: string cultureNote: string focusTags: string[] sceneTags: string[] }后续如果扩展新素材库只需要补一组BankDetailProfile页面结构不用改。这是详情页可复用的关键。六、FocusCard 用标签帮助用户快速判断适用场景FocusCard()展示两组标签“会练到什么”和“常见场景”。这类标签不是装饰它能帮助用户判断当前题库是否符合自己的训练目标。Builder FocusCard() { Column({ space: 14 }) { Text(本库重点) this.TagBlock(你会练到, this.profile().focusTags) this.TagBlock(常见场景, this.profile().sceneTags) } } Builder TagBlock(title: string, tags: string[]) { Column({ space: 8 }) { Text(title) Flex({ wrap: FlexWrap.Wrap }) { ForEach(tags, (tag: string) { Text(tag) .fontSize(Sizes.CAPTION_FONT) .fontColor(Colors.PRIMARY) .backgroundColor(Colors.PRIMARY_LIGHT) }, (tag: string) tag) } } }这里使用FlexWrap.Wrap说明标签数量可以变化页面不用为每个题库单独调整行数。对于中文标签换行比横向滚动更友好尤其是在手机竖屏下。需要注意的是标签文案来自本地配置不是动态推荐。文章只能说它是本地题库 profile 的标签化展示不能说它有 AI 个性化推荐或云端推荐算法。七、章节列表把题库拆成可执行任务题库详情页不能只告诉用户“这个库很重要”还要把题库拆成可以开始的任务。源码通过ChapterSection()遍历this.bank!.chapters每个章节渲染一个ChapterItem。Builder ChapterSection() { Column({ space: 10 }) { Row() { Text(章节练习) Blank() Text(${this.bank!.chapters.length} 章) } ForEach(this.bank!.chapters, (chapter: Chapter) { this.ChapterItem(chapter) }, (chapter: Chapter) chapter.id) } }章节完成数来自chapterProgressListprivate chapterFinished(chapter: Chapter): number { const cp UserDataManager.getChapterProgress( this.chapterProgressList, this.bankId(), chapter.id ) return cp ? cp.finished : 0 } private chapterDone(chapter: Chapter): boolean { return chapter.total 0 this.chapterFinished(chapter) chapter.total }这段逻辑把题库总进度和章节进度分开。题库总进度适合 Hero 和摘要卡片章节进度适合指导下一步练习。两者都来自用户数据而不是写死在Chapter对象里。八、章节按钮根据状态切换“开始、继续、完成”ChapterAction()根据章节完成情况和已做题数量决定按钮文案与样式。Builder ChapterAction(chapter: Chapter) { if (this.chapterDone(chapter)) { Text(完成) .fontColor(Colors.SUCCESS) } else { Text(this.chapterFinished(chapter) 0 ? 继续 : 开始) .fontColor(this.chapterFinished(chapter) 0 ? Color.White : Colors.PRIMARY) .backgroundColor(this.chapterFinished(chapter) 0 ? Colors.PRIMARY : Colors.SURFACE) .borderWidth(this.chapterFinished(chapter) 0 ? 0 : 1) .borderColor(Colors.PRIMARY) .onClick(() { if (this.bank) { router.pushUrl({ url: pages/PracticePage, params: { bankId: this.bank.id, chapterId: chapter.id, mode: chapter } }) } }) } }这个小状态机对用户很有帮助条件展示路由参数未开始开始bankId chapterId modechapter已开始未完成继续bankId chapterId modechapter已完成完成不再触发练习入口源码没有单独记录“继续从第几题开始”它只是用已完成数量改变按钮文案进入练习后仍由PracticePage根据章节加载题目。文章不能把它写成断点续练更准确的说法是“章节进度驱动入口状态”。九、底部动作区把随机练习和限时挑战作为全库入口章节入口适合按章节推进但用户也需要全库练习。源码在底部固定动作区提供两个按钮随机练习和限时挑战。Builder BottomActions() { Row({ space: 12 }) { GreenButton({ text: 随机练习, isOutline: true, onTap: () { router.pushUrl({ url: pages/PracticePage, params: { bankId: this.bank!.id, mode: random } }) } }) GreenButton({ text: 限时挑战, onTap: () { router.pushUrl({ url: pages/PracticePage, params: { bankId: this.bank!.id, mode: exam } }) } }) } .padding({ left: Sizes.PADDING_LARGE, right: Sizes.PADDING_LARGE, top: 10, bottom: this.bottomSafePadding() }) }两个按钮都进入PracticePage差别只在mode。这让练习页可以复用同一套题目渲染、答案解析、收藏和进度更新逻辑。底部 padding 使用bottomSafePadding()确保底部按钮不会贴到系统手势区private bottomSafePadding(): number { return Math.max( Sizes.BOTTOM_NAV_MIN_PADDING, this.getUIContext().px2vp(this.navigationIndicatorHeightPx) ) }这类底部固定操作区是 AppGallery 布局复查重点。发布前要在手势导航、三键导航、小窗、横屏等状态下确认按钮可见且可点。十、宽屏布局把说明和章节分栏BankDetailContent通过currentBreakpoint和pageWidth判断宽屏布局private useWideLayout(): boolean { return this.currentBp lg this.pageWidth 700 } .onAreaChange((oldArea: Area, newArea: Area) { const width Number(newArea.width) if (width 0) { this.pageWidth width } })宽屏时左侧滚动区域展示 Hero、进度摘要和题库简介右侧滚动区域展示重点标签与章节列表if (this.useWideLayout()) { Row({ space: 20 }) { Scroll() { Column({ space: 16 }) { this.HeroCard() this.BankSummaryCard() this.ProfileCard() } } .width(38%) Scroll() { Column({ space: 16 }) { this.FocusCard() this.ChapterSection() } } .layoutWeight(1) } } else { Scroll() { Column({ space: 16 }) { this.HeroCard() this.BankSummaryCard() this.ProfileCard() this.FocusCard() this.ChapterSection() } } }这是一种比较实际的多设备适配手机竖屏顺序阅读宽屏分成“题库说明”和“执行任务”两栏。它没有额外制造复杂导航也没有在平板上简单拉宽手机卡片。十一、PracticePage 承接解析、例句和收藏题目级能力在PracticePage.ets。练习页根据mode、bankId、chapterId选择题目池if (params.chapterId) { this.questions getQuestionsByChapter(params.bankId, params.chapterId) } else { this.questions getQuestions(params.bankId) } if (this.mode exam) { this.questions this.pickHourlyExamQuestions(this.questions, 20) } else if (this.mode random) { this.questions this.pickRandomPracticeQuestions( this.questions, 20, params.startQuestionId || ) }答案解析和例句也在练习页展示if (this.showAnalysis) { Column({ space: 8 }) { Text(答案解析) Text(正确答案${this.currentQ()!.answer}) Text(this.currentQ()!.analysis) .fontSize(Sizes.BODY_FONT) .fontColor(Colors.TEXT_SECONDARY) .lineHeight(22) if (this.currentQ()!.example) { Text(例句${this.currentQ()!.example!}) .fontSize(Sizes.CAPTION_FONT) .fontColor(Colors.TEXT_HINT) .fontStyle(FontStyle.Italic) } } }收藏入口同样在练习页底部工具栏.onClick(() { const q this.currentQ() if (q) { this.favRecords UserDataManager.toggleFavorite( this.favRecords, q.id, q.bankId ) } })因此标题中的“组织例句、解释、收藏和练习入口”应理解为一条完整链路详情页组织到练习入口练习页组织题目级例句、解释和收藏。不能把收藏按钮说成存在于BankDetailPage。十二、MockBanks 提供题目、解析和例句的数据根MockBanks.ets中的题目数据结构包含analysis和可选exampleinterface RawQuestion { type: string stem: string options: string[] answer: number analysis: string example?: string }源码里可以复核到这样的题目样本{ type: typo, stem: 找出句中的错别字今天的天气非常睛朗。, options: [睛朗 → 晴朗, 天气 → 天汽, 非常 → 飞常, 今天 → 今添], answer: 0, analysis: “晴朗”表示天气晴好不能写作“睛朗”。, example: 雨后天空格外晴朗。 }这说明“解析”和“例句”是真实由源码支撑的能力。不过它们不是详情页直接展示而是在用户进入练习并答题后展示。这个交互顺序是合理的详情页负责选择学习任务练习页在答题之后给反馈。如果把解析和例句提前放在详情页用户还没做题就看到答案训练效果会下降当前源码的分层更符合答题应用的基本体验。十三、进度更新发生在练习完成时详情页显示进度练习页更新进度。PracticePage.goNext()在最后一题后更新题库总进度和章节进度const correctCount this.records.filter(r r.correct).length this.progressList UserDataManager.updateProgress( this.progressList, this.bankId, this.records.length, correctCount, this.chapterId ) if (this.chapterId.length 0) { this.chapterProgressList UserDataManager.updateChapterProgress( this.chapterProgressList, this.bankId, this.chapterId, this.records.length, correctCount ) }这让详情页在返回后能读取新的bankProgress和chapterProgress。从工程边界看详情页不负责写进度只负责读进度练习页根据真实答题记录写进度。发布前如果发现详情页进度不更新应先检查PracticePage是否走到了最后一题后的goNext()。records.length和correctCount是否正确。UserDataManager.updateProgress和updateChapterProgress是否持久化并同步到AppStorage。返回详情页后StorageLink是否触发 UI 刷新。十四、可复核清单与常见问题按源码复查06-02主题可以用下面这张表检查项文件通过标准详情页是否支持路由题库BankDetailPage.etsrouter.getParams()读取bankId固定题库页面是否复用详情组件SichuanBankPage.ets等传入fixedBankId题库不存在是否有空状态BankDetailPage.etsbank undefined分支展示空提示题库简介是否独立于 UIBankDetailPage.etsBankDetailProfile配置intro/cultureNote/tags章节进度是否读取用户数据BankDetailPage.etsUserDataManager.getChapterProgress章节入口是否传参完整BankDetailPage.etsbankId/chapterId/modechapter随机练习和限时挑战是否复用练习页BankDetailPage.etsmoderandom/exam解析和例句是否真实存在MockBanks.ets、PracticePage.etsanalysis和example在答题后展示收藏是否在练习页完成PracticePage.etstoggleFavorite更新favoriteRecords底部按钮是否避让安全区BankDetailPage.etsbottomSafePadding()加到底部 padding常见问题定位现象先查位置处理建议从题库卡进入后显示未找到题库BankCard.detailPageUrl()和路由参数确认bank.id和getBankById一致固定题库页展示错库对应固定页面检查fixedBankId是否写错章节按钮始终显示开始chapterProgressList检查练习完成后章节进度是否写回答题后没有解析PracticePage.selectOption()确认showAnalysis被置为 true例句不显示MockBanks数据该题是否存在example字段收藏状态不高亮PracticePage.isCurFav()检查favoriteRecords与当前题 ID底部按钮贴近手势条bottomSafePadding()检查导航栏高度注入和最小 padding总结笔下生辉的素材库详情链路不是一个单页堆功能的实现而是一组职责明确的页面协作BankDetailContent负责题库身份、题库说明、重点标签、章节进度和练习入口PracticePage负责题目选择、答案解析、例句、收藏、笔记、答题卡和进度写回MockBanks提供本地题库、章节、题目和解析数据UserDataManager负责学习状态。这种分层能避免两个问题一是详情页提前泄露题目答案二是题目级状态污染题库级页面。对 HarmonyOS 多设备应用来说它也更容易适配详情页在窄屏顺序滚动宽屏左右分栏底部动作区通过安全区 padding 保持可点。本文唯一工程标记是com.jiaweikang.one17后续如果进入公开发布阶段仍必须以平台真实公开 URL、文章 ID 和发布时间为准。部分内容由AI辅助生成全文已基于本地 HarmonyOS ArkTS 源码人工复核。