【笔下生辉|02】HarmonyOS ArkTS 素材库详情实战:组织例句、解释、收藏和练习入口

素材库详情页最容易犯的错误,是把"题库介绍、章节列表、练习按钮、题目解析、例句、收藏、笔记"全部塞进同一个页面。这样首屏看起来功能很满,用户真正开始练习时却会被信息噪声打断。笔下生辉 的源码采用了更稳的分层:BankDetailPage.ets 负责题库说明、章节进度和练习入口;PracticePage.ets 承接具体题目、答案解析、例句、收藏、笔记和答题卡。

本文基于本地工程 D:\huawei\one17-11 可复核源码编写,主要文件包括 entry/src/main/ets/pages/BankDetailPage.etsentry/src/main/ets/pages/PracticePage.etsentry/src/main/ets/mock/MockBanks.etsentry/src/main/ets/common/components/GreenButton.etsentry/src/main/ets/common/components/TopBar.ets。文章不声称任何公开发布结果,也不把 CSDN 草稿或本地源码复查误报为 AppGallery 后台状态。

本文解决四个工程问题:

  • 题库详情页如何接收 bankId,并支持固定题库页面复用同一个内容组件。
  • 题库说明、纠错提示、重点标签和章节列表如何分区,避免详情页变成题目页。
  • 章节练习、随机练习、限时挑战如何通过路由参数进入同一个 PracticePage
  • 例句、解析、收藏、笔记为什么应由练习页承接,而不是放在详情页里提前展开。

一、先划清详情页和练习页的职责

从源码看,BankDetailPage.ets 并没有直接展示题目选项、答案解析或收藏按钮。它展示的是题库级信息:封面、题库简介、纠错提示、重点标签、章节进度、底部练习入口。题目级能力在 PracticePage.ets 中实现,包括 analysisexample、收藏、笔记和答题卡。

这个边界可以用一张表概括:

页面 负责什么 不负责什么
BankDetailContent 选择题库、展示题库说明、展示章节进度、发起练习 逐题答题、收藏当前题、展示答案解析
PracticePage 加载题目、选择答案、显示解析和例句、收藏、笔记、答题卡 题库营销介绍、题库封面介绍
MockBanks 提供题库、章节、题目、解析、例句等本地数据 页面布局和用户交互
UserDataManager 读取和更新进度、收藏、错题、笔记 控制页面展示结构

这种拆分对 HarmonyOS 应用尤其重要。ArkUI 页面如果把所有交互都塞在一个 build() 里,很快会出现状态交叉:用户在详情页收藏题目,但题目还没加载;章节进度更新了,但详情页和练习页都在算同一个数字;返回路径也会变得难以预测。源码选择用路由把详情页和练习页隔开,结构更可维护。

二、BankDetailContent 同时支持路由参数和固定题库

BankDetailContent 定义了一个 fixedBankId,又能从 router.getParams() 读取 bankId。这说明同一套内容组件既可以作为通用 BankDetailPage 使用,也可以被 SichuanBankPageYueBankPageMinnanBankPage 等固定题库页面复用。

ts 复制代码
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!.namethis.bank!.chapters,而是展示空图和提示。

ts 复制代码
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() 把题库封面、题库名称、副标题、总题量、已答数量和正确率放在封面图上。它不展示具体题目,也不提前展示答案解析。

ts 复制代码
@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(...) 读取全局进度:

ts 复制代码
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() 将题库简介和纠错提示分为两个层级。

ts 复制代码
@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 里更合适。

ts 复制代码
interface BankDetailProfile {
  subtitle: string
  intro: string
  cultureNote: string
  focusTags: string[]
  sceneTags: string[]
}

后续如果扩展新素材库,只需要补一组 BankDetailProfile,页面结构不用改。这是详情页可复用的关键。

六、FocusCard 用标签帮助用户快速判断适用场景

FocusCard() 展示两组标签:"会练到什么"和"常见场景"。这类标签不是装饰,它能帮助用户判断当前题库是否符合自己的训练目标。

ts 复制代码
@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

ts 复制代码
@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)
  }
}

章节完成数来自 chapterProgressList

ts 复制代码
private 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() 根据章节完成情况和已做题数量决定按钮文案与样式。

ts 复制代码
@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 + mode=chapter
已开始未完成 继续 bankId + chapterId + mode=chapter
已完成 完成 不再触发练习入口

源码没有单独记录"继续从第几题开始",它只是用已完成数量改变按钮文案,进入练习后仍由 PracticePage 根据章节加载题目。文章不能把它写成断点续练;更准确的说法是"章节进度驱动入口状态"。

九、底部动作区把随机练习和限时挑战作为全库入口

章节入口适合按章节推进,但用户也需要全库练习。源码在底部固定动作区提供两个按钮:随机练习和限时挑战。

ts 复制代码
@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(),确保底部按钮不会贴到系统手势区:

ts 复制代码
private bottomSafePadding(): number {
  return Math.max(
    Sizes.BOTTOM_NAV_MIN_PADDING,
    this.getUIContext().px2vp(this.navigationIndicatorHeightPx)
  )
}

这类底部固定操作区是 AppGallery 布局复查重点。发布前要在手势导航、三键导航、小窗、横屏等状态下确认按钮可见且可点。

十、宽屏布局把说明和章节分栏

BankDetailContent 通过 currentBreakpointpageWidth 判断宽屏布局:

ts 复制代码
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、进度摘要和题库简介,右侧滚动区域展示重点标签与章节列表:

ts 复制代码
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。练习页根据 modebankIdchapterId 选择题目池:

ts 复制代码
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 || ''
  )
}

答案解析和例句也在练习页展示:

ts 复制代码
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)
    }
  }
}

收藏入口同样在练习页底部工具栏:

ts 复制代码
.onClick(() => {
  const q = this.currentQ()
  if (q) {
    this.favRecords = UserDataManager.toggleFavorite(
      this.favRecords,
      q.id,
      q.bankId
    )
  }
})

因此,标题中的"组织例句、解释、收藏和练习入口"应理解为一条完整链路:详情页组织到练习入口,练习页组织题目级例句、解释和收藏。不能把收藏按钮说成存在于 BankDetailPage

十二、MockBanks 提供题目、解析和例句的数据根

MockBanks.ets 中的题目数据结构包含 analysis 和可选 example

ts 复制代码
interface RawQuestion {
  type: string
  stem: string
  options: string[]
  answer: number
  analysis: string
  example?: string
}

源码里可以复核到这样的题目样本:

ts 复制代码
{
  type: 'typo',
  stem: '找出句中的错别字:今天的天气非常睛朗。',
  options: ['睛朗 → 晴朗', '天气 → 天汽', '非常 → 飞常', '今天 → 今添'],
  answer: 0,
  analysis: '"晴朗"表示天气晴好,不能写作"睛朗"。',
  example: '雨后天空格外晴朗。'
}

这说明"解析"和"例句"是真实由源码支撑的能力。不过它们不是详情页直接展示,而是在用户进入练习并答题后展示。这个交互顺序是合理的:详情页负责选择学习任务,练习页在答题之后给反馈。

如果把解析和例句提前放在详情页,用户还没做题就看到答案,训练效果会下降;当前源码的分层更符合答题应用的基本体验。

十三、进度更新发生在练习完成时

详情页显示进度,练习页更新进度。PracticePage.goNext() 在最后一题后更新题库总进度和章节进度:

ts 复制代码
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
  )
}

这让详情页在返回后能读取新的 bankProgresschapterProgress。从工程边界看,详情页不负责写进度,只负责读进度;练习页根据真实答题记录写进度。

发布前如果发现详情页进度不更新,应先检查:

  • PracticePage 是否走到了最后一题后的 goNext()
  • records.lengthcorrectCount 是否正确。
  • UserDataManager.updateProgressupdateChapterProgress 是否持久化并同步到 AppStorage
  • 返回详情页后 @StorageLink 是否触发 UI 刷新。

十四、可复核清单与常见问题

按源码复查 06-02 主题,可以用下面这张表:

检查项 文件 通过标准
详情页是否支持路由题库 BankDetailPage.ets router.getParams() 读取 bankId
固定题库页面是否复用详情组件 SichuanBankPage.ets 传入 fixedBankId
题库不存在是否有空状态 BankDetailPage.ets bank === undefined 分支展示空提示
题库简介是否独立于 UI BankDetailPage.ets BankDetailProfile 配置 intro/cultureNote/tags
章节进度是否读取用户数据 BankDetailPage.ets UserDataManager.getChapterProgress
章节入口是否传参完整 BankDetailPage.ets bankId/chapterId/mode=chapter
随机练习和限时挑战是否复用练习页 BankDetailPage.ets mode=random/exam
解析和例句是否真实存在 MockBanks.etsPracticePage.ets analysisexample 在答题后展示
收藏是否在练习页完成 PracticePage.ets toggleFavorite 更新 favoriteRecords
底部按钮是否避让安全区 BankDetailPage.ets bottomSafePadding() 加到底部 padding

常见问题定位:

现象 先查位置 处理建议
从题库卡进入后显示未找到题库 BankCard.detailPageUrl() 和路由参数 确认 bank.idgetBankById 一致
固定题库页展示错库 对应固定页面 检查 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 源码人工复核。

相关推荐
独隅1 小时前
DevEco Code 在 Windows/MacOS 双系统上的完整使用指南
ide·人工智能·windows·macos·华为·harmonyos
●VON2 小时前
鸿蒙 PC Markdown 编辑器有界版本历史:沙箱快照、完整性校验与安全恢复
安全·华为·编辑器·harmonyos·鸿蒙
痕忆丶2 小时前
OpenHarmony北向开发基础之 沙箱机制+分布式文件
harmonyos
youtootech13 小时前
HarmonyOS《柚兔学伴》项目实战25-我的页面、Web 嵌入与项目总结
前端·华为·harmonyos
三声三视14 小时前
uni-app 鸿蒙端传参变成 [object Object]?顺着源码追到 ArkTS router 底层才搞明白
人工智能·ai·uni-app·aigc·ai编程·harmonyos
达子66614 小时前
第7章_HarmonyOS 图解 Ability公共事件与通知
华为·harmonyos
爱写代码的阿森14 小时前
鸿蒙三方库 | harmony-utils之KvUtil键值型数据库操作详解
数据库·华为·harmonyos·鸿蒙·huawei
爱写代码的阿森16 小时前
鸿蒙三方库 | harmony-utils之PreferencesUtil用户首选项读写详解
华为·harmonyos·鸿蒙·huawei
达子66617 小时前
第6章_HarmonyOS 图解 Ability任务调度
华为·harmonyos