素材库详情页最容易犯的错误,是把"题库介绍、章节列表、练习按钮、题目解析、例句、收藏、笔记"全部塞进同一个页面。这样首屏看起来功能很满,用户真正开始练习时却会被信息噪声打断。笔下生辉 的源码采用了更稳的分层: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 等固定题库页面复用。
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!.name、this.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 通过 currentBreakpoint 和 pageWidth 判断宽屏布局:
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。练习页根据 mode、bankId、chapterId 选择题目池:
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
)
}
这让详情页在返回后能读取新的 bankProgress 和 chapterProgress。从工程边界看,详情页不负责写进度,只负责读进度;练习页根据真实答题记录写进度。
发布前如果发现详情页进度不更新,应先检查:
PracticePage是否走到了最后一题后的goNext()。records.length和correctCount是否正确。UserDataManager.updateProgress和updateChapterProgress是否持久化并同步到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.ets、PracticePage.ets |
analysis 和 example 在答题后展示 |
| 收藏是否在练习页完成 | PracticePage.ets |
toggleFavorite 更新 favoriteRecords |
| 底部按钮是否避让安全区 | BankDetailPage.ets |
bottomSafePadding() 加到底部 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 源码人工复核。