Files
Docker/uniapp/youjiala/src/md/page-routing.md
T
2026-10-13 10:47:39 +08:00

335 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 页面路由与短地址规范
适用范围:`src/pages.json`、`src/manifest.json`、`src/router/routes.ts`、`src/router/h5-clean-url.ts`、`src/main.ts`。
本文记录"为什么这么设计"和"新增页面要动哪几处",代码里的注释只解释局部行为,跨文件的约束以本文为准。
后端地址的 `.env` 分层见 [environment.md](./environment.md),跳转失败与未捕获错误的兜底口径见 [error-handling.md](./error-handling.md),权限判定见 [permission.md](./permission.md)。
---
## 1. 目标:H5 地址形态
需求是**对齐旧 WebVue 系统的地址风格**(`https://uat.youjiala.com/dashboard`),同时保住 uni-app 的 `pages/` 目录结构。
| 阶段 | 地址 | 说明 |
| --- | --- | --- |
| 旧 WebVue | `/login`、`/dashboard` | vue-router 4 + `createWebHistory`,可自由定义 path |
| 本项目改造前 | `/#/pages/login/login` | hash 模式 + `pages.json` 的 path 字面即 URL |
| 中间态(被否决) | `/login`,但文件在 `src/login.vue` | 页面文件被迫上移到 `src` 根,放弃 `pages/` 约定 |
| 本项目现状 | `/login`、`/dashboard`,文件仍在 `pages/xxx/xxx.vue` | history 模式 + 运行时追加短地址路由记录 |
三端最终形态:
```
src/pages/login/login.vue pages.json: pages/login/login URL / 和 /login
src/pages/dashboard/dashboard.vue pages.json: pages/dashboard/dashboard URL /dashboard
小程序 / App 端:只认真实路径 pages/login/login、pages/dashboard/dashboard
```
---
## 2. uni-app 的路由模型(与 vue-router 的本质差异)
- **路由表编译期静态注册**:`pages.json` 的 `pages` 数组是唯一来源,没有 `router.addRoute`,没有嵌套路由,没有全局 `beforeEach`。
- **URL 由 path 字面决定**:uni-h5-vite 的 `generatePageRoute` 生成
```text
// uni-h5-vite/dist/pagesJson.js 逐字摘录(引号为原样,非项目代码)
const alias = isEntry ? `\n alias:'/${path}',` : '';
return `{ path:'/${isEntry ? '' : path}',${alias} ...`
```
入口页被特殊映射为 `/`(真实路径记在 `alias` 与 `meta.route`),**其余页面的 URL 就是 `pages.json` 里 `path` 的逐字拷贝**。除入口页外,uni-app 没有任何"URL 重命名"配置项——这是整篇文档所有绕路的根源。
- **导航 API 分端**:`uni.navigateTo / redirectTo / reLaunch / switchTab`,而不是 `router.push`。
- **守卫靠拦截器**:`uni.addInterceptor`,不是路由中间件。
- **产物里的路由表**:`window.__uniRoutes`,由 uni-h5 的 `initRouter(app)` 在插件 `install` 阶段拿去 `createRouter`。
---
## 3. 改动清单
### 3.1 短地址改造(第一轮)
| 文件 | 改动 |
| --- | --- |
| `manifest.json` | `h5.router.mode = "history"`、`h5.router.base = "/"` |
| `pages.json` | `path` 保持带目录(`pages/login/login`);注释指向本文的登记要求 |
| `router/routes.ts` | 新增。`PAGE_PATHS` 映射表 + `toRealPath()` / `toCleanPath()` / `pageUrl()` |
| `router/h5-clean-url.ts` | 新增。往 `window.__uniRoutes` 追加短地址路由记录 |
| `main.ts` | `#ifdef H5` 引入并在模块体求值期调用 `registerCleanUrls()` |
| `common/http.interceptor.ts` | `loginPage = toRealPath("login")`;401 跳登录用 `uni.reLaunch({ url: pageUrl("login") })` |
### 3.2 登录守卫 + 错误页(第二轮,见 §11)
| 文件 | 改动 |
| --- | --- |
| `router/routes.ts` | 扩展:错误页登记、`CLEAN_PATH_OVERRIDES`、`AUTH_FREE_PAGES`、`PAGE_PERMISSIONS`、`resolvePageName()` / `errorPageUrl()` / `normalizePath()` |
| `router/nav-guard.ts` | 新增。`setupNavGuard()` / `ensureCurrentPageAuthorized()` / `setupUnknownPathFallback()` / `goLoginWithRedirect()` |
| `composables/useAuthGuard.ts` | 新增。页面级 `onShow` 复核(补冷启动深链那半边) |
| `components/error-panel/error-panel.vue` | 新增。401/403/404 共用外壳(移植旧 `ErrorPanel.vue`) |
| `pages/error/page40{1,3,4}.vue` | 新增。三个错误页 |
| `pages.json` | 注册 3 个错误页 |
| `App.vue` | `onLaunch` 调 `setupNavGuard()` |
| `main.ts` | H5 块内 `registerCleanUrls()` 之后调 `setupUnknownPathFallback()` |
| `common/http.interceptor.ts` | 非 2xx 分支新增 `statusCode === 403` → 跳 403 页 |
| `locale/lang/{zh-CN,en-US}.json` | 新增 `ErrorPage` 组 30 个键 |
`history` 开关的读取路径:编译层特征开关 `__UNI_FEATURE_ROUTER_MODE__`,取自 `manifest.web || manifest.h5` 的 `router.mode`。
### 3.3 权限评估层(第三轮,见 permission.md)
| 文件 | 改动 |
| --- | --- |
| `permission/permission-kernel.ts` | 新增。纯函数判定内核 + 预建索引(`buildPermissionIndex` / `can` / `canAny` / `canOperate` / `actionsOf` / `grantedPermissions`) |
| `permission/permission-context.ts` | 新增。接 pinia 档案:`PermissionContext` / `usePermission` / `usePagePermission` / `canVisitPage` |
| `permission/permission-kernel.spec.ts` | 新增。59 条断言的可执行校验(esbuild + node 跑,未引测试框架) |
| `router/routes.ts` | `PAGE_PERMISSIONS` 值类型放宽为 `PermissionNames`(`string \| string[]`),注释指向 permission.md |
| `router/nav-guard.ts` | `evaluate()` 补第三步 `canVisitPage(name)` → `error403` |
---
## 4. 短地址机制:追加克隆记录,而不是挂 alias
### 4.1 第一版为什么错(实测抓到的真实故障)
最初实现是给原路由记录挂 `alias: ["/dashboard"]`(数组)。结果症状是**割裂**的:
- 浏览器地址栏直接输入 `/dashboard` → 能进(vue-router 自己支持数组 alias);
- 站内 `uni.reLaunch({ url: "/dashboard" })` → 报 `page \`/dashboard\` is not found`。
原因在 `node_modules/@dcloudio/uni-h5/dist/uni-h5.es.js` 两条硬约束:
```text
// L1281 getRouteOptions(path, alias = false):uni 的导航层查表
return __uniRoutes.find(
(route) => route.path === path || route.alias === path // 字符串全等,数组永不命中
);
// L6010 入口页在导航层被强制改写回根路径
if (routeOptions.meta.isEntry) {
url = url.replace(routeOptions.alias, "/"); // alias 是数组时这行也是错的
}
```
结论:**uni 的导航层不读 vue-router 的 alias 能力**,只认 `path` 精确匹配(alias 仅支持字符串全等);且带 `meta.isEntry` 的记录地址永远回落到 `/`,到不了 `/login`。
### 4.2 现方案
给每个页面**追加一条克隆路由记录**,`path` 用短地址,让 uni 的查表用 `path` 精确命中。
| 字段 | 处理 | 原因 |
| --- | --- | --- |
| `path` | 换成短地址 `/dashboard` | 让 `getRouteOptions` 第一条件命中 |
| `alias` | 删除 | 避免与真实路径互相干扰;也避开 `isEntry` 分支里的 `url.replace(alias, "/")` |
| `meta.isEntry` | 置 `undefined` | 否则地址栏被强改回 `/` |
| `meta.route` | **保持真实路径** | uni 的页面栈、导航栏样式等仍按真实路径查表 |
| `isCleanClone` | 标记 `true` | 二次注册时跳过克隆体,防止无限繁殖 |
### 4.3 时序(为什么能赶上)
`registerCleanUrls()` 在 `main.ts` 模块体求值期调用,而 `initRouter(app)` 要到插件 `install` 阶段才用**同一个数组引用**去 `createRouter`,所以追加必然被读进去。
两个分支都要覆盖,因为 pages 虚拟模块与本模块的求值先后不由我们控制:
- `window.__uniRoutes` 已赋值 → 直接 `applyCleanUrls(数组)`;
- 尚未赋值 → `Object.defineProperty` 拦一次 `set`,在赋值瞬间完成注册。
### 4.4 跨端隔离
`h5-clean-url.ts` 只被 `#ifdef H5` 引入,实测产物命中数:H5 有、mp-weixin 0、app 0。
---
## 5. API 契约(写页面时必须遵守)
`router/routes.ts` 是页面路径的唯一来源:
```ts
export const PAGE_PATHS = {
login: "pages/login/login",
dashboard: "pages/dashboard/dashboard",
error401: "pages/error/page401",
error403: "pages/error/page403",
error404: "pages/error/page404",
} as const;
```
| 函数 | 用途 | 注意 |
| --- | --- | --- |
| `pageUrl(name)` | **跳转用的 URL** | H5 返回短地址,其他端返回真实路径(条件编译) |
| `toRealPath(name)` | **路径比较**、日志 | 永远带前导斜杠的真实路径 |
| `toCleanPath(name)` | 短地址推导 | 优先取 `CLEAN_PATH_OVERRIDES`,否则取路径最后一段 |
| `resolvePageName(url)` | 任意 URL → 页面名 | 认不出返回 `null`,守卫据此判 404;`"/"` 视为入口页 |
| `errorPageUrl(name, from?)` | 错误页地址 | 内部用 `normalizePath(from)` 清洗后拼进 `?from=`,**不做预编码**(见 §11.4) |
| `currentRealPath()` | 当前页真实路径 | 页面栈为空时返回空串 |
| `isAuthFree(name)` | 是否免登录页 | 数据源是 `AUTH_FREE_PAGES` |
硬约束:**`getCurrentPages()` 返回的 `route` 永远是真实路径,不含运行时补的短地址**。所以任何"当前是不是某页"的判断只能用 `toRealPath()`,用 `pageUrl()` 比较在 H5 上会恒不等(`http.interceptor.ts` 的 `goLogin` 就是按这个写的)。
小程序 / App 端导航只能用真实路径——短地址在那些端根本不存在。
---
## 6. 新增页面的完整动作
```
1. src/pages/album/album.vue 文件,目录名必须与文件名同名
2. src/pages.json 追加 { "path": "pages/album/album" }
3. src/router/routes.ts 的 PAGE_PATHS 追加 album: "pages/album/album"
4. src/pages/album/album.vue 受登录保护的页面调一次 useAuthGuard()
5. src/router/routes.ts 的 PAGE_PERMISSIONS 受功能权限控制的页面才登记权限名,见 permission.md §6
```
第 2 步是**绝对必须**:`pages.json` 是编译期唯一注册入口,不写就没有这个页面。
第 3 步的必须程度见下一节。
第 4 步见 §11:站内跳转由全局拦截器兜住,但冷启动深链不经过任何导航 API,页面自己必须复核。
命名约定(第 1 步)不是审美问题,是功能要求:短地址由 `toCleanPath()` 取**最后一段**推出来,`pages/album/album.vue` → `/album`;若写成 `pages/album/index.vue`,短地址就成了 `/index`。
例外:错误页刻意写成 `pages/error/page401.vue`(末段是 `page401`),因为地址要与旧 WebVue 逐字一致的 `/error/page401`,这类情况用 `CLEAN_PATH_OVERRIDES` 指定短地址,而不是把文件改名为 `error/error.vue`。
---
## 7. 不登记 PAGE_PATHS 会怎样
页面**不会坏**,但会失去短地址,并且编译期被拦:
1. `/pages/album/album` 真实路径照常可访问,深链、刷新、站内跳转都正常;
2. 短地址 `/album` 打不进去,控制台输出一条告警(`h5-clean-url.ts`):
```
[h5-clean-url] 页面未登记到 PAGE_PATHS(router/routes.ts),仅真实路径可访问: /pages/album/album
```
3. 只要代码里用 `pageUrl("album")` / `toRealPath("album")`,`PageName = keyof typeof PAGE_PATHS` 会让 TS 在编译期直接报错——这是有意的强制手段,比运行时静默失效应更早暴露。
另外两种会被跳过的情况(都有独立告警,不会静默):
- **末段重名冲突**:`pages/a/user.vue` 与 `pages/b/user.vue` 都想占 `/user`,后到者被跳过并提示"短地址冲突,已跳过"。
- **tabBar 页面**:明确**不注册短地址**(`h5-clean-url.ts` 的 `isTabBar` 分支)。tabBar 的路径判定依赖 `route.path.slice(1)`,克隆记录会干扰 `switchTab` / `preloadPage`。将来把首页做成 tabBar 时,它只能使用真实路径。
---
## 8. 当前没有做的事(已知缺口)
- **权限守卫已落地但登记表是空的**:`PAGE_PERMISSIONS` 尚无条目,路由级 403 因此还没被任何页面真正触发(现在能跳 403 的只有请求层的 HTTP 403)。判定层本身已完成并可用,语义、登记流程与已知缺口见 [permission.md](./permission.md)。
- **history 模式的服务端兜底**:`/login`、`/dashboard` 这类深链**直接刷新依赖服务端把请求 rewrite 回 `index.html`**。dev server(Vite)已实测返回 SPA 壳,但生产环境(如 `uat.youjiala.com` 的 Nginx)需要加 `try_files $uri $uri/ /index.html;` 一条,否则刷新会 404。这一条尚未在生产环境验证。
---
## 9. 验证记录
改造后跑过的验证,后续再动路由可比照执行:
- **产物实锤**:`window.__uniRoutes` 含 `{path:"/",alias:"/pages/login/login"}`、`{path:"/pages/dashboard/dashboard"}`,以及追加的 `{path:"/login"}`、`{path:"/dashboard"}` 克隆记录;全程无 `#`。
- **浏览器 8/8 用例全绿**:`/`、`/login`、`/dashboard` 深链直达 + 整页刷新;`001/123` 登录后 `reLaunch` 落到 `/dashboard`;上一轮失败的"站内跳 `/dashboard` 报 page is not found"已消失。
- **HTTP 层**:`/`、`/login`、`/dashboard`、`/pages/dashboard/dashboard` 均返回含 `<div id="app">` 的 SPA 壳。
- **三端构建**:H5 / mp-weixin / app 均 `EXIT=0`;mp-weixin 的 `app.json` 中 `pages = login, dashboard`(根级 path 在小程序端合法)——注意该项属于中间态验证,现行结构为 `pages/xxx/xxx`。
- **跨端隔离**:`h5-clean-url` 特征串仅在 H5 产物出现。
复查命令(PowerShell):
```powershell
# 三端构建
.\node_modules\.bin\uni.cmd build *> "$env:TEMP\_b1.log"; "H5_EXIT=$LASTEXITCODE"
.\node_modules\.bin\uni.cmd build -p mp-weixin *> "$env:TEMP\_b2.log"; "MP_EXIT=$LASTEXITCODE"
.\node_modules\.bin\uni.cmd build -p app *> "$env:TEMP\_b3.log"; "APP_EXIT=$LASTEXITCODE"
# 产物里的路由表
Select-String -Path ".\dist\build\h5\assets\*.js" -Pattern '__uniRoutes=\['
```
浏览器侧的深链 / 地址栏 / 控制台表现无法用构建日志判定,必须真机或自动化实测。
---
## 10. 可优化项(未实现,需确认后再动)
`PAGE_PATHS` 与 `pages.json` 目前是两处手工同步。可改为编译期自动生成,只保留"目录名 = 文件名"一条约定:
```ts
import pagesJson from "@/pages.json"; // uni-app 支持 JSON 导入
```
代价是失去 `PageName` 的字面量类型(跳转目标名不再是编译期校验),且要额外处理 `subPackages`。取舍未定,暂不动。
---
## 11. 登录守卫与 401/403/404(移植自旧 WebVue)
### 11.1 为什么是"两条链路"而不是一条
旧系统是 vue-router 的 `router.beforeEach`,一个钩子覆盖所有进入方式。uni-app 没有等价物:
- `uni.addInterceptor(api, { invoke })` 只作用在**调用导航 API 的那一次跳转**上(`navigateTo` / `redirectTo` / `reLaunch` / `switchTab`)。
- **冷启动深链不经过任何导航 API**:H5 地址栏直接输入、分享链接打开、小程序"编译进入指定页面",都是 uni 自己解析出页面实例后直接 `onLoad/onShow`,拦截器一次都不会触发。
所以:`router/nav-guard.ts` 的 `setupNavGuard()` 管站内跳转,`composables/useAuthGuard.ts` 的页面级 `onShow` 复核管冷启动,缺一漏一半。
取消导航的机制(源码依据,`@dcloudio/uni-h5/dist/uni-h5.es.js`):
```text
invokeApi -> queue(interceptor.invoke, options)
queue: res === false 时返回 { then(){}, catch(){} } 这样的空 thenable
```
即 `invoke` 返回 `false` 会让原调用被**静默丢弃**,既不会执行跳转,也不会给调用方留下未捕获的 promise rejection——所以守卫拦下来之后不需要调用方配合处理异常。
### 11.2 判定顺序与放行表
`evaluate(url)` 的顺序就是语义:地址不存在(404)→ 未登录(401)→ 无权限(403)。
- `AUTH_FREE_PAGES` = `login` + 三个错误页。**错误页必须免登录**:401 页就是给"没登录/登录失效"的人看的,拦掉它只会让用户看到一个登录框而不知道刚才为什么被踢。
- 登录态只认请求层的 `getJwtInfo()?.AccessToken`(令牌唯一来源,与 `stores/loginProfile` 口径一致)。
- 刻意**不**把已登录用户从 `/login` 弹回首页:403 页的"重新登录"按钮需要先能进入登录页。
- 第三步(403)走 **fail-open**:本地档案缺失或权限清单为空时放行,只有"档案里有清单但目标页的权限名不在其中"才拦。理由与风险边界见 permission.md §5——真正的安全边界在后端每个接口的 `[PermissionOperate]`。
- 跳错误页用 `redirectTo`(保住返回栈,"返回上一页"才有内容),失败再退化成 `reLaunch`。
### 11.3 错误页与外壳组件
真实路径 `pages/error/page401` 经 `CLEAN_PATH_OVERRIDES` 得到短地址 `/error/page401`,与旧 WebVue 及 `WebSitePolicy.Web401/403/404` 逐字一致。三个页面共用 `components/error-panel/error-panel.vue`(对应旧 `ErrorPanel.vue`),它只管排版与 `tone` 三色(blue / amber / violet),文案、图标、按钮由各错误页给:
| 页 | tone | 图标 | 主按钮 | 次按钮 |
| --- | --- | --- | --- | --- |
| 401 | blue | `lock-fill` | 去登录(带 redirect) | 返回上一页 |
| 403 | amber | `warning-fill` | 重新登录 | 返回首页 |
| 404 | violet | `map` | 返回首页 | 返回上一页 |
旧版用 ant-design 图标和 `<a>`,这里换成 `u-icon` + `u-button`(`u-button` 没有 `icon` 属性,故按钮只有文字),尺寸单位从 px 换成 rpx,头图顶部用 `var(--status-bar-height)` 适配 `navigationStyle: custom`。按钮间距写在容器的 `gap` 上——插槽内容属于各页面的作用域,错误页自己的 scoped 样式选不到它。
### 11.4 `?from=` / `?redirect=` 不做预编码
第一版用 `encodeURIComponent` 包了参数值,实测地址栏出现 `?from=%252Fdashboard`——uni 不会先解这一层,而 vue-router 拼 URL 用的是 `encodeURI`(不编码 `/`,但会把 `%` 编成 `%25`),于是被编码两次。现在只做 `normalizePath()` 清洗(剥 query/hash、补前导斜杠),参数值里只剩路径字符,地址栏呈现干净的 `?from=/dashboard`。页面侧仍用 `decodeQueryPath()` 兜底解一次,兼容各端 query 解码时机的差异。
### 11.5 403 的两个触发点
- 请求层:`common/http.interceptor.ts` 的非 2xx 分支里 `statusCode === 403` → `goForbidden()` 跳 403 页并带 `?from=`;已经在 403 页就不再跳,避免"错误页自己发的请求又 403"来回刷。
- 导航层:`evaluate()` 的第三步 `if (!canVisitPage(name)) return "error403"`,数据源是 `PAGE_PERMISSIONS` + `PermissionContext`(判定语义、双失败策略、登记流程全部见 [permission.md](./permission.md))。该步排在 401 之后:未登录时档案通常也是空的,先给 401 才谈得上"去登录"。
`goForbidden()` 在请求层自己实现而没有复用 `nav-guard` 的 `jumpToError`:`nav-guard` 依赖请求层读登录态,反向 import 会形成循环依赖。`canVisitPage()` 由 `nav-guard` import `permission/permission-context` 单向调用,不构成环——`permission-context` 只 import `routes.ts` 的常量与 `resolvePageName`,不 import `nav-guard`。
### 11.6 词条与验证
- 文案全部走语言包:`locale/lang/{zh-CN,en-US}.json` 的 `ErrorPage` 组 30 个键;组件内固定的"可能的原因""出错的地址:"也在里面(`reasonsTitle` / `fromLabel`)。
- 实测(H5 dev + 自动化浏览器):未登录深链 `/dashboard` → 401 页且 `from` 显示正确;401 的"去登录"→ `/login?redirect=/dashboard`;`001/123` 登录后回跳 `/dashboard`;`/no/such/page` → 404 页;404 的"返回首页"放行;清态后直访 `/error/page403` 不被再拦、无死循环;`window.__uniRoutes` 共 10 条(5 真 + 5 短地址克隆),全程零 console error/warn。
- 三端构建 `EXIT=0`;`location.pathname` 仅出现在 H5 产物(`setupUnknownPathFallback` 的 `#ifdef H5` 生效),`page401` 三端产物均有。
### 11.7 `resolvePageName("")` 曾把登录成功后置地址清成空串(已修)
第三轮接权限层做端到端时暴露的既有缺陷,与权限无关,但正是"空地址到底算不算一个页面"这条边界没定死:
```text
无 ?redirect= 的普通登录
-> login.vue onLoad 里 decodeQueryPath(undefined) -> ""
-> resolvePageName("") -> normalizePath("") -> "/" -> 命中入口页分支 -> "login"(真值)
-> if (name) 成立 -> redirectUrl.value = ""(把默认的 /dashboard 覆盖成空串)
-> uni.reLaunch({ url: "" }) -> fail: Missing required args "url"
-> 用户点了登录、接口 200、令牌已写入,却仍然卡在登录页
```
`"/"` 作为 H5 入口页的路由 path 必须能被 `resolvePageName` 认出来(冷启动落在入口页时靠它反查页面名),所以不能动那一条;问题在于 `normalizePath` 把空串也归一成 `"/"`,于是"我压根没打算跳"被误读成"我要跳入口页"。修复分两层:
- `routes.ts` `resolvePageName`:开头对 `!String(url || "").trim()` 直接返回 `null`(源头掐断,空地址永远不算页面)。
- `login.vue` `onLoad`:判定条件从 `if (name)` 收紧为 `if (name && !isAuthFree(name))`——手敲 `?redirect=/` 或 `?redirect=/error/page401` 时不再把落地页设成登录页/错误页自己。
教训:**"`""` 和 `"/"` 等价"这种归一化只该出现在真正需要它的地方**。凡是把字符串地址解析成结构化结果的函数,都要先明确"空输入是错误还是默认值",否则默认值会顺着真值判断悄悄覆盖掉上游的初始化。