主导航最容易在功能不断增加后变成两套系统:根级页面通过AppStorage改数字,详情页面通过router.pushUrl压栈,首页快捷入口又同时修改主 Tab 和子 Tab。每条路径单独看都能跳转,但当目标索引越界、两个状态更新顺序不一致、路由参数只做类型断言时,用户会遇到“点错题本却先闪到收藏”“返回后不在原页面”“非法参数进入随机练习”等难以复现的问题。
本文基于知律项目D:\huawei\one19-11、包名com.jiaweikang.one19的真实源码,围绕 brief 指向的Index.ets和HomePage.ets,继续核对FavoritePage.ets、SearchPage.ets、CategoryPage.ets、PracticePage.ets、BankDetailPage.ets与TopBar.ets。项目当前已经建立五个根 Tab、首页快捷入口、二级页面路由和通用返回按钮;本文要解决的不是“从零加导航”,而是把现有入口收口成可验证的导航契约。
一、先把页面分成根级和二级
Index直接承载五个根级内容:
if (this.currentIndex === 0) { HomePage() } else if (this.currentIndex === 1) { BankListPage() } else if (this.currentIndex === 2) { ExamTab() } else if (this.currentIndex === 3) { FavoritePage() } else { MinePage() }它们属于同一个主壳,切换时不需要不断压入新路由。题库详情、搜索、分类、练习和设置则是从根级页面进入的二级页面,适合进入 Router 返回栈。
二、当前根级切换和二级跳转方向基本正确
首页“法律题库”直接切 Tab:
this.currentTabIndex = 1“法律分类”进入独立页面:
router.pushUrl({ url: 'pages/CategoryPage' })这体现了合理层级:同级目的地切状态,详情型目的地压栈。问题不在于项目混用了两种机制,而在于这些机制没有共享同一套类型、参数和失败处理。
三、用数字索引表达业务目的地可读性不足
当前索引含义散落在多个文件:
// Index 0 // 首页 1 // 题库 2 // 报考 3 // 收藏 4 // 我的HomePage和MinePage直接写 1、2、3。新增 Tab 或调整顺序时,所有调用点都要一起修改,否则代码仍能编译,却会进入错误页面。
四、先定义稳定的主 Tab 类型
可以用枚举集中表达:
export enum MainTab { HOME = 0, BANK = 1, EXAM = 2, FAVORITE = 3, MINE = 4 }调用改成:
this.currentTabIndex = MainTab.BANK枚举不改变现有渲染结构,只把业务语义从注释提升为编译期可见的契约。
五、越界索引现在会静默进入“我的”
PageContent()的最后一个else无条件渲染MinePage()。因此currentIndex = -1、5或其他异常值时,界面不会报错,而是伪装成正常的“我的”页面。
更稳的入口先规范化:
private normalizeMainTab(value: number): MainTab { if (value < MainTab.HOME || value > MainTab.MINE) { return MainTab.HOME } return value as MainTab }非法状态回首页并记录阶段日志,比吞掉错误更容易定位来源。
六、主 Tab 和收藏子 Tab 是一个复合意图
首页进入“我的收藏”时执行:
this.favoriteTabIndex = 0 this.currentTabIndex = 3进入“错题本”时执行:
this.favoriteTabIndex = 2 this.currentTabIndex = 3这不是两个无关赋值,而是一个完整意图:“进入收藏主 Tab,并选中指定子页”。
七、不同调用点的赋值顺序已经不一致
首页快捷入口先写子 Tab、再写主 Tab;学习成果卡片中却出现:
this.currentTabIndex = 3 this.favoriteTabIndex = 2收藏入口也有同样的反向顺序。响应式刷新时,FavoritePage可能先按旧favoriteTabIndex渲染,再切到目标子页。即使运行时批处理状态使闪动不明显,导航意图也不应依赖赋值顺序。
八、把复合导航收口成一个方法
最小改造可以是:
export enum FavoriteTab { FAVORITES = 0, NOTES = 1, WRONG = 2 } private openFavoriteTab(target: FavoriteTab): void { this.favoriteTabIndex = target this.currentTabIndex = MainTab.FAVORITE }所有首页和“我的”入口统一调用:
this.openFavoriteTab(FavoriteTab.WRONG)这样至少不会继续复制两个状态的写入顺序。
九、更完整的方案是单一导航状态
如果跨组件入口继续增加,可以将主次状态合成对象:
export interface MainNavigationState { mainTab: MainTab favoriteTab: FavoriteTab revision: number }一次替换状态:
this.navigationState = { mainTab: MainTab.FAVORITE, favoriteTab: FavoriteTab.WRONG, revision: this.navigationState.revision + 1 }页面读取同一快照,避免主状态和子状态来自两个更新时刻。这个对象模型是改造建议,当前源码仍使用两个@StorageLink<number>。
十、首页动作只应表达导航意图
当前EntryItem接收回调:
EntryItem( emoji: string, title: string, sub: string, badgeColor: string, onTap: () => void )组件本身只负责点击反馈,这个边界是合理的。进一步可以把回调内容集中到方法:
private onQuickEntry( target: HomeEntryTarget ): void { // 在一个位置完成映射与校验 }Builder 只传目标,不再内联修改多个全局状态。
十一、为首页入口建立封闭联合类型
type HomeEntryTarget = | { kind: 'mainTab'; tab: MainTab } | { kind: 'favorite'; tab: FavoriteTab } | { kind: 'route'; request: RouteRequest }分发时:
private navigate(target: HomeEntryTarget): void { if (target.kind === 'mainTab') { this.currentTabIndex = target.tab return } if (target.kind === 'favorite') { this.openFavoriteTab(target.tab) return } AppRouter.push(target.request) }主导航只接收经过校验的导航意图,不让任意页面拼接路径和魔法数字。
十二、二级路由字符串也散落在页面里
源码出现:
'pages/SearchPage' 'pages/CategoryPage' 'pages/BankDetailPage' 'pages/PracticePage' 'pages/LearningStatsPage' 'pages/SettingsPage'字符串写错只能在运行时暴露。集中常量可以减少重命名遗漏:
export const Routes = { SEARCH: 'pages/SearchPage', CATEGORY: 'pages/CategoryPage', BANK_DETAIL: 'pages/BankDetailPage', PRACTICE: 'pages/PracticePage' } as const路由表还应与main_pages.json一起纳入回归检查。
十三、路由请求应绑定参数类型
题库详情需要bankId,练习页需要bankId和模式,搜索分类需要类型与名称。可以分别定义:
export type PracticeMode = | 'chapter' | 'random' | 'exam' | 'wrong' | 'wrongAnalysis' export interface PracticeRouteParams { bankId: string mode: PracticeMode chapterId?: string }调用端不再传普通string:
AppRouter.openPractice({ bankId, mode: 'random' })十四、类型断言不是运行时校验
PracticePage当前读取:
const params = router.getParams() as PracticeParams | undefinedSearchPage和BankDetailPage也使用相同模式。as PracticeParams只告诉编译器“相信我”,不会检查运行时对象是否有bankId、mode是否属于允许集合。
外部 Want、历史路由或错误调用都可能带来不完整值,因此目标页仍需验证。
十五、练习参数守卫要校验题库和模式
const PRACTICE_MODES: PracticeMode[] = [ 'chapter', 'random', 'exam', 'wrong', 'wrongAnalysis' ] function parsePracticeParams( value: Object | undefined ): PracticeRouteParams | undefined { const raw = value as Partial<PracticeRouteParams> const bankExists = BANKS.some( (bank: Bank) => bank.id === raw.bankId ) const modeValid = PRACTICE_MODES.includes( raw.mode as PracticeMode ) if (!bankExists || !modeValid) return undefined return raw as PracticeRouteParams }校验失败后应显示参数错误空态或安全返回,不应继续启动计时器和练习流程。
十六、当前 PracticePage 即使无参数也会启动计时器
aboutToAppear()在参数分支之后无条件:
this.timerId = setInterval(() => { this.timerSec++ if ( this.mode === 'exam' && this.examRemainSec() <= 0 ) { this.autoSubmitExam() } }, 1000)如果参数缺失,bankId仍为空,页面也会开始计时。参数守卫应早于业务初始化:
const params = parsePracticeParams( router.getParams() ) if (!params) { this.pageState = 'invalidParams' return } this.startPractice(params)十七、分类搜索参数必须来自真实白名单
CategoryPage从CATEGORIES生成卡片并传:
params: { categoryType: cat.type, categoryName: cat.name }但SearchPage只检查params.categoryType是否存在,没有确认它属于CATEGORIES。可以用同一数据源校验:
function findCategory( type: string ): Category | undefined { return CATEGORIES.find( (item: Category) => item.type === type ) }名称应从匹配到的本地记录取得,而不是信任调用方传入的categoryName。
十八、题库详情同样要验证已知 ID
BankDetailPage当前:
if (params && params.bankId) { this.bank = getBankById(params.bankId) }找不到题库时页面已有“未找到题库”空态,这是正确兜底。更清晰的状态可以区分:
type DetailState = | 'loading' | 'content' | 'invalidParams' | 'notFound'这样错误调用与真实数据缺失不会被混成同一种情况。
十九、每日一题入口没有精准定位原题
openDailyFaPu()取得当日题目后,只传:
router.pushUrl({ url: 'pages/PracticePage', params: { bankId: dq.bankId, mode: 'random' } })它没有传questionId,因此“打开今日普法详情”实际进入对应题库的随机练习,不保证展示首页那道题。文章不能把它描述成精准详情跳转。
产品若要求一致,应扩展明确模式:
{ bankId: dq.bankId, mode: 'single', questionId: dq.questionId }同时目标页必须验证问题确实属于该题库。
二十、收藏问题卡片也没有定位到当前题目
FavoritePage.QuestionCard点击后传:
router.pushUrl({ url: 'pages/PracticePage', params: { bankId, mode: 'random' } })卡片虽然持有questionId,路由却没有使用它。用户点击某条收藏或笔记后,可能进入同题库的其他题。统一路由契约时应决定“继续随机练习”还是“打开这道题”,并让文案与行为一致。
二十一、返回路径目前主要依赖 router.back
通用TopBar:
.onClick(() => { router.back() })SearchPage的自定义返回按钮也调用router.back()。只要二级页面由pushUrl进入,返回到原根页面的语义是成立的。
需要测试的边界是:页面如果由外部入口或异常恢复直接打开,路由栈可能没有预期上一级。此时应根据产品入口协议选择关闭、回首页或显示提示,不能假设所有页面都来自同一路径。
二十二、Splash 到首页使用 replaceUrl 保持根栈干净
启动页使用:
router.replaceUrl({ url: 'pages/Index' })这使 Splash 不留在返回栈中。主导航的路径规则可以概括:
| 层级 | 动作 | 预期返回 |
|---|---|---|
| Splash → Index | replaceUrl | 不返回 Splash |
| 主 Tab 切换 | 状态更新 | 仍在主壳 |
| 主壳 → 二级页 | pushUrl | 返回原主壳 |
| 二级页 → 上一级 | back | 恢复原路径 |
每种入口都应有稳定返回语义。
二十三、当前侧边导航分支不可达
BreakpointSystem只会产生sm、md、lg,但Index把三者全部放进底部导航条件:
if ( this.currentBp === 'sm' || this.currentBp === 'md' || this.currentBp === 'lg' ) { // 底部导航 } else { // 侧边导航 }因此所有合法断点都使用底部导航。这个问题已在启动与首页复核中发现,在主导航契约中必须继续作为多设备阻塞项:导航意图可以统一,但壳层仍需按设计断点真正切换。
二十四、底部和侧边导航的语义还没有完全对齐
底部项设置了:
.accessibilityText( `${title}标签${ selected ? ',已选中' : '' }` )侧边项没有同等可访问性描述。修复宽屏分支后,要保证两套视觉容器共享相同的标题、选中状态、点击方法和读屏语义,而不是形成两套导航逻辑。
二十五、收藏 Tab 的徽标数据语义存在冲突
Index的 Tab 标题是“收藏”,图标也是收藏,但徽标读取:
@StorageLink('wrongRecords') wrongRecords: WrongRecord[] = []注释明确写“显示错题数徽标”。这可能是产品设计,也可能是数据源选错。技术层不能擅自改成收藏数;应先确定徽标代表待复习错题还是收藏数量,再同步文案、可访问性文本和数据源。
二十六、首页存在固定“阅读/收藏”数字
DailyFaPu直接显示:
Text('阅读 1256') Text('收藏 865')源码没有平台 PV、阅读量接口或这组收藏统计的真实来源。因此它们不能被当作真实用户数据,也不能在技术文章、发布记录或提报表里沿用。
更诚实的处理有三种:
- 删除数字,只保留内容入口;
- 使用本地可验证的收藏状态;
- 接入真实统计后标注数据口径和时间。
当前文章不伪造 PV、点赞或收藏量。
二十七、“今日已学”实际上判断累计答题
当前方法:
private todayLearned(): boolean { return this.myStats().totalAnswered > 0 }只要历史上答过一道题,以后每天都会显示“今日已学”。这不是导航 bug,却会影响首页入口文案和点击预期。
如果要表达当天完成,应由答题记录保存日期,再按本地日期过滤。没有日期字段时,应改成“已有学习记录”等与真实数据一致的文案。
二十八、ForEach key 不应拼入选中状态
热门分类当前 key:
(r: Region) => `region_chip_${r.id}_${this.selectedRegionId}`推荐题库 key:
(b: Bank) => `hot_${b.id}_${this.selectedRegionId}`切换分类时,所有 key 都变化,框架会把同一条目视为新组件。稳定身份应只使用r.id或b.id,选中状态通过响应属性刷新。
二十九、导航方法需要防重复点击
卡片已经有 pressed 状态,但没有统一的导航中状态。快速连点详情卡片可能连续调用pushUrl。
@State private navigating: boolean = false private async openBank(bankId: string): Promise<void> { if (this.navigating) return this.navigating = true try { await AppRouter.openBank(bankId) } finally { this.navigating = false } }按钮禁用状态和方法幂等要同时存在,避免只靠视觉反馈。
三十、统一 Router 包装层的最小职责
包装层不应隐藏所有页面逻辑,只负责:
export class AppRouter { static openBank(bankId: string): Promise<void> { if (!BANKS.some((bank: Bank) => bank.id === bankId)) { return Promise.reject( new Error('unknown_bank') ) } return router.pushUrl({ url: Routes.BANK_DETAIL, params: { bankId } }) } }它拥有目标路径、调用端参数校验和错误归一化。目标页仍需再次校验,因为运行时输入不能只信任调用端。
三十一、导航失败要给用户可恢复反馈
当前多数router.pushUrl没有等待结果。建议将失败映射为页面可见提示:
try { await AppRouter.openCategory() } catch (error) { promptAction.showToast({ message: '页面暂时无法打开,请重试' }) }日志记录路由名和错误类别即可,不打印用户笔记、题目答案或其他业务隐私。
三十二、验证矩阵要覆盖所有入口组合
至少检查:
- 五个底部 Tab 逐一切换,选中图标和内容一致;
- 首页进入题库、报考、收藏、错题后目标正确;
- 收藏、笔记、错题三个子 Tab 通过不同入口进入;
- 连续快速点击同一入口不会压入重复页面;
- 分类卡片进入搜索页,参数与本地分类一致;
- 题库卡片进入对应详情页;
- 无效
bankId、无效模式和缺失参数进入错误空态; - 二级页面返回后恢复原主 Tab 与子 Tab;
- Splash 进入首页后返回不会再次出现 Splash;
- 599vp、600vp、601vp、840vp、841vp 下导航壳符合设计;
- 系统返回操作与可见返回按钮结果一致;
- 读屏能识别两套导航的选中状态。
三十三、常见问题与修复顺序
| 现象 | 第一检查点 | 当前源码对应点 | 建议修复 |
|---|---|---|---|
| 点错题先闪收藏 | 主/子 Tab 更新顺序 | 多处顺序不一致 | 收口复合意图 |
| 非法索引进入“我的” | PageContent默认分支 | 所有异常落 Mine | 规范化索引 |
| 收藏题点开不是原题 | 是否传questionId | 只传随机模式 | 定义单题路由 |
| 分类名与类型不一致 | 是否信任参数名称 | Search 直接接收 | 从白名单反查 |
| 练习空参数仍计时 | 守卫是否早返回 | 定时器无条件启动 | 先校验后初始化 |
| 返回后路径异常 | push/replace 层级 | 多入口混合 | 明确返回契约 |
| 宽屏仍是底部栏 | 断点条件 | 合法值全命中 | 修正壳层判断 |
| 数据看似很高 | 固定阅读收藏文本 | 1256/865 | 删除或接真实口径 |
先修导航契约,再调整视觉动画,能更快缩小错误范围。
三十四、发布前导航检查表
- 主 Tab 和子 Tab 都有明确类型;
- 所有魔法索引已集中;
- 越界状态有确定回退;
- 首页快捷入口只产生导航意图;
- 根级切换不压入重复路由;
- 二级页面使用受控路由表;
bankId来自已知题库;categoryType来自分类白名单;mode只允许定义集合;- 参数缺失时不启动计时器或业务流程;
- 收藏题与每日题的点击文案和实际目标一致;
- 返回按钮与系统返回行为一致;
- 快速重复点击有幂等保护;
- 手机和宽屏导航语义一致;
- 固定阅读量、收藏量等伪数据已移除或明确为非真实演示;
- 所有公开统计只记录平台真实回读值;
- 主导航在 HarmonyOS 5.0 及以上目标设备完成实机验证。
三十五、结语
知律当前已经形成“主 Tab 用状态、二级页用 Router”的正确骨架,也有通用返回按钮、参数接口和多入口首页。真正需要收紧的是导航意图的表达:数字索引应变成类型,主次 Tab 应作为一个动作更新,路径应进入注册表,参数断言必须升级为运行时守卫。
当首页只表达用户意图、主壳只接收合法状态、路由层只发出受控请求、目标页再次验证参数时,返回路径自然会变得可预测。再移除固定平台数字、修复断点分流并覆盖多入口测试,主导航才能在 HarmonyOS 5.0 及以上手机、平板与 2in1 窗口中保持一致,而不是依赖每个点击回调刚好写对几个数字和字符串。