Просмотр исходного кода

docs(routing): 新增子路径部署路由实战篇 + 修正脚手架/示例中的路由坑

从一次多页看板交付(无构建 Lit、History 干净 URL、部署在 /<env>/<project>/
二级路径、OBS+CDN)沉淀「网页应用」通用经验,写入规则仓。

新增 .claude/rules/routing-best-practices.md(问题→根因→最佳实践):
- 坑1  new URL(path, baseURI) 丢部署前缀(以 / 开头 = 绝对路径,整体替换 base path)
- 坑2  子路径部署弃用 @lit-labs/router(全局 <a> 拦截 + 未剥前缀 pathname 污染路由)
- 坑3  CDN 回退必须 200 返回 index.html,禁 301/302
- 坑4  <base href>/OBS 前缀/importmap 前缀三处同源,脚本硬校验
- 坑5  CDN 刷新假成功:domain_id 对号入座 + refresh_task 是字符串(非 .id)+ 失败退出非 0
- 坑6  index.html 先删后传(sync 按 size+mtime 判 Skip)+ no-cache + 冷边缘偶发 404
- 坑7  只 curl 首页不算验收(模块图爬取 + MIME + 深链接 200 + 真实浏览器)
- 坑8  部署脚本 cd 自锚定 + URL 参数化,勿硬编码换篇 404
- 坑9  内部导航走 navigate()/href(),保留「新标签打开」
- 坑10 卡片内嵌可点击链接 stopPropagation;无数据不编造;进度口径写清

同步修正会被复制传播的模板:
- skills scaffold.mjs:utils/router.js 改为拼 base pathname(原写法丢前缀);
  app-shell 去掉 @lit-labs/router 改 popstate 订阅;移除 router 依赖
- examples/hello-lit:同上(router.js/app-shell/importmap/package.json/README)
- install-rules.mjs:最小规则集补 base 三处同源 + 禁 new URL 丢前缀 + 禁 lit-labs/router
- routing.md/deployment.md/pitfalls.md/CLAUDE.md/README/architecture/http-utils/
  retrospective/SKILL.md:交叉引用与新口径对齐

验证:node --check 全部通过;skill smoke 通过;resolveUrl/currentPath 逻辑实测
(前缀保留、query/hash 保留、脏 URL 兜底、根部署正常)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
liuyuyang 14 часов назад
Родитель
Сommit
952f078f21

+ 2 - 1
.claude/rules/architecture.md

@@ -15,7 +15,8 @@
   - **Utils(纯 ESM 函数)**:无状态共享工具。
 - **响应式**:`@lit-labs/signals`(细粒度信号)。
 - **DI**:`@lit-labs/context`(组件树注入)。
-- **路由**:`@lit-labs/router`,History 干净 URL(支持微信 OAuth 回调,需 CDN 回退规则)。
+- **路由**:自写 `utils/router.js`,History 干净 URL(支持微信 OAuth 回调,需 CDN 回退规则)。
+  子路径部署**禁用** `@lit-labs/router`(全局 `<a>` 拦截 + 未剥前缀 pathname 会污染路由),见 [routing-best-practices.md](./routing-best-practices.md)。
 
 ## 二、分层职责表(越界即返工)
 

+ 39 - 6
.claude/rules/deployment.md

@@ -91,6 +91,35 @@ obsutil sync ./src/ obs://<bucket>/<project>/ \
 
 **CDN 刷新**:上传后调用 CDN refresh(仓库内 `scripts/deploy.sh` 已接入既有刷新脚本)。刷新**范围**由传入的 URL 域名决定。
 
+> ⚠️ **CDN 刷新的两个假成功陷阱**(实测踩过,详见
+> [routing-best-practices.md 坑 5](./routing-best-practices.md#坑-5cdn-刷新看似成功实则没刷)):
+> 1. **域名对号入座**:每个加速域有各自的 `domain_id` 与凭证;用错只刷了一个域名,
+>    用户访问的另一个域名照旧是旧内容。未知域名应**直接报错**,别静默跳过。
+> 2. **SDK 返回结构**:`refresh_task` 是**字符串 ID**,不是对象。若读成
+>    `result.refresh_task.id` 会恒得 `undefined`,脚本**照常退出 0**(假成功)。
+>    必须对结果做**非空断言**,任一失败则**退出码非 0**。
+>
+> 刷新用 `type: 'directory'`(URL 以 `/` 结尾)一次覆盖目录,比逐个 file 省事且不易漏。
+
+## 三·五、验收:不能只 `curl` 首页
+
+`curl` 首页 200 **只证明 `index.html` 在**;无构建应用白屏的典型原因(某模块 404、MIME 错、
+回退没配、相对路径错一个字符)它一个都不覆盖。分层验收:
+
+1. **静态体检**(部署前):`node --check` 无语法/装饰器残留;相对 import 带 `.js`;
+   相对目标存在;裸标识符都在 importmap;自定义元素标签含连字符。
+2. **模块图爬取**(部署后,HTTP 层):从入口 `app.js` 跟随相对 import 走完整图,
+   断言每个文件 **200 + `Content-Type: text/javascript`**;importmap 每条目标可达;
+   **深链接返回 200 且内容是 `index.html`**(非 302)。
+3. **真实浏览器**(最终验收):点导航 + 刷新深链接,确认不 404、无控制台报错。
+4. **缓存层**:核对 CDN 返回的**内容**是新版(不只是状态码)。
+
+> 把 1–2 步固化成一条命令纳入部署流程。可复用脚本形态见
+> [routing-best-practices.md 坑 7](./routing-best-practices.md#坑-7只-curl-首页不算验收)。
+
+> 🔎 **CDN 冷边缘**:新上传的 key 首次被某边缘节点拉取时**偶发 404**(回源竞争),
+> 重跑一次即消失 —— 验收脚本碰到新 key 的首次 404 不要立刻判故障。
+
 ## 四、History 路由的 CDN 回退(关键)
 
 | 规则 | 匹配 | 动作 |
@@ -130,10 +159,14 @@ OBS=/opt/data/bin/obsutil
 
 ## 六、发布 checklist
 
-- [ ] `<base href>` 与部署前缀一致
-- [ ] importmap 指向 Repose,版本号正确
+- [ ] `<base href>` 与部署前缀一致(**同一变量派生**,脚本里硬校验)
+- [ ] importmap 指向 Repose(**内联**在 index.html),版本号/前缀正确
 - [ ] 依赖已同步到 Repose(`Content-Type: text/javascript` + CORS 放行)
-- [ ] OBS 上传完成,`index.html` 已先删后传
-- [ ] CDN 回退规则:不存在路径 200 返回 index.html
-- [ ] CDN 刷新完成
-- [ ] 直接命中深链接(如 `/users/42`)刷新可用
+- [ ] OBS 上传完成,`index.html` 已先删后传 + `no-cache`
+- [ ] CDN 回退规则:不存在路径 200 返回 index.html,**禁 301/302**
+- [ ] CDN 刷新完成(`type:directory`,**domain_id 对号入座**,读 `refresh_task` 字符串,失败退出非 0)
+- [ ] 直接命中深链接(如 `/users/42`)**刷新**可用(200 且为 index.html)
+- [ ] 静态体检 + 模块图爬取通过;**真实浏览器**点导航/刷新深链接无报错
+
+> 路由 / base / CDN 的完整肺坑与 check 清单见
+> [routing-best-practices.md](./routing-best-practices.md)。

+ 1 - 1
.claude/rules/http-utils.md

@@ -101,7 +101,7 @@ export const debounce = (fn, ms = 200) => {
 
 | 工具 | 用途 |
 |------|------|
-| `utils/router.js` | `navigate()`(基于 `document.baseURI`,尊重 `<base href>`) |
+| `utils/router.js` | `navigate()` / `href()` / `currentPath()`(把应用内路径拼到 base 的 **pathname** 上;见 [routing-best-practices.md](./routing-best-practices.md)) |
 | `utils/dom.js` | 少量原生 DOM 辅助(慎用,优先 Lit 指令) |
 | `utils/validate.js` | 表单校验纯函数 |
 

+ 35 - 6
.claude/rules/pitfalls.md

@@ -34,11 +34,30 @@
 **原因**:CDN 回退用 301/302 重定向。
 **对策**:回退**必须 200** 返回 `index.html`,地址栏不变。见 [deployment.md](./deployment.md)。
 
-## 五、子目录部署缺 `<base href>`
+## 四·一、`new URL(path, baseURI)` 丢部署前缀(子路径部署最高频)
+
+**症状**:子路径部署下(`/dev/myapp/`)导航跳到域名根 `https://cdn/students`,刷新 404;
+根部署时正常。
+**原因**:`new URL` 的相对解析:`input` 以 `/` 开头时是绝对路径,会**整体替换** base 的
+path,**不保留** base 的目录前缀。
+**对策**:把应用内路径**拼到 base 的 pathname 上**再解析;导航一律走 `utils/router.js`。
+见 [routing-best-practices.md 坑 1](./routing-best-practices.md#坑-1任意的-new-urlpath-baseuri-会丢掉部署前缀)。
+
+## 四·二、`@lit-labs/router` 污染子路径部署的路由状态
+
+**症状**:引入后路由偶发跳错/停错页/点内部链接整页刷新,根部署正常、二级路径异常。
+**原因**:它装全局 `<a>` click 拦截器自行 `pushState`;且用**未剥前缀**的 `location.pathname`
+匹配(路由表是应用内路径)。
+**对策**:子路径部署**自写** `utils/router.js`(~60 行);根部署可选。
+见 [routing-best-practices.md 坑 2](./routing-best-practices.md#坑-2lit-labsrouter-在子路径部署下污染路由状态)。
+
+## 五、子目录部署缺 `<base href>`(或三处前缀不一致)
 
 **症状**:`./app.js`、相对 import 全部 404(被解析到域名根)。
-**原因**:子目录部署(`/admin/`)未写 `<base href="/admin/">`。
-**对策**:子目录部署**必须**写对 `<base href>`;`navigate()` 基于 `document.baseURI` 拼 URL。
+**原因**:子目录部署(`/admin/`)未写 `<base href="/admin/">`;或 `<base href>`、
+OBS 上传前缀、importmap 路径前缀**三处不一致**。
+**对策**:三处由**同一变量派生**,部署脚本里 `grep` 硬校验;`navigate()` 基于 base 拼 URL。
+见 [routing-best-practices.md 坑 4](./routing-best-practices.md#坑-4base-href-与实际部署前缀不一致三处必须同源)。
 
 ## 六、相对 import 漏写 `.js` 后缀
 
@@ -78,12 +97,22 @@
 | 用 `.ts` 源码直发 OBS | 浏览器不认 TS;无构建下**源码即 JS** |
 | 直接改 `src/` 后忘记刷 CDN | 看到旧版本;发布流程要含 CDN 刷新 |
 | `index.html` 被 CDN 长缓存 | 回退/更新拿到旧壳;`index.html` 设 `no-cache` |
+| `index.html` 被 `sync` 跳过 | `sync` 按 size+mtime 判 Skip;**先删后传** |
+| CDN 刷新「看似成功」 | `refresh_task` 是字符串 ID,读 `.id` 恒 undefined → 假成功;须非空断言 + 失败退出非 0 |
+| CDN 刷新只刷了一个域名 | domain_id/凭证对号入座;未知域名直接报错 |
+| 只 `curl` 首页算验收 | 首页 200 ≠ 能跑;须模块图爬取 + 真实浏览器 |
 | 自定义元素标签忘带连字符 | `define` 抛错,如 `approot` 应 `app-root` |
+| 在卡片内嵌链接未 `stopPropagation` | 点内层链接触发外层跳转;内层 `@click` 断冒泡 |
+
+> 路由 / base / CDN 的完整肺坑见 [routing-best-practices.md](./routing-best-practices.md)。
 
 ## 十二、排查口诀
 
 1. 看控制台 **MIME / CORS / 404** 三类报错——先分清是「依赖没托管好」还是「业务路径错」。
 2. 打开 Network:模块请求的 **Content-Type** 与 **状态码**。
-3. 深链接刷新 404 → 查 CDN 回退规则。
-4. 相对资源 404 → 查 `<base href>`。
-5. 元素重复注册 → 查 lit 是否多版本。
+3. 深链接刷新 404 → 查 CDN 回退规则(是否 200)。
+4. 相对资源 404 → 查 `<base href>` 与部署前缀是否一致。
+5. 导航跳到域名根 / 前缀丢失 → 查是否用了 `new URL(path, baseURI)`。
+6. 点内部链接整页刷新 / 路由跳错 → 查是否引入 `@lit-labs/router`(子路径部署)或裸 `<a>`。
+7. 线上还是旧版 → 查 CDN 刷新的 domain_id 与 `refresh_task` 返回值(别信「假成功」日志)。
+8. 元素重复注册 → 查 lit 是否多版本。

+ 453 - 0
.claude/rules/routing-best-practices.md

@@ -0,0 +1,453 @@
+# 路由最佳实践与常见坑(子路径部署 / History 模式 / CDN 回退)
+
+> 本篇是 [routing.md](./routing.md) 的**实战补充**:routing.md 讲「怎么做」,本篇讲
+> 「为什么这么做、错了会怎样、如何一眼定位」。
+>
+> 素材来自一次真实的多页看板交付(Lit 无构建、部署在 `/<env>/<project>/` 二级路径、
+> History 干净 URL、走 OBS + CDN)。所有条目都对应一次真实故障。
+>
+> 通篇只讲**网页应用**的通用规律(路由 / base / CDN / 部署 / 验收),不含任何具体业务。
+
+## 阅读方式
+
+每条按 **症状 → 根因 → 最佳实践 / 规避** 组织。排查时按症状检索即可。
+
+- 前缀缺失、深链接刷新 404:见 [坑 1](#坑-1任意的-new-urlpath-baseuri-会丢掉部署前缀)
+- 路由状态被第三方库污染:见 [坑 2](#坑-2lit-labsrouter-在子路径部署下污染路由状态)
+- 发版后看到旧页面:见 [坑 5](#坑-5cdn-刷新看似成功实则没刷)
+- 部署脚本「跑完了但东西没上去/地址不对」:见 [坑 8](#坑-8部署脚本的-static-cd-陷阱与硬编码-url)
+
+---
+
+## 零、一张图记住全部关系
+
+```
+浏览器地址栏  https://<cdn>/<env>/<project>/students/s01
+                         └────────┬─────────┘
+                    部署前缀(必须三处一致)
+                    = OBS 上传前缀
+                    = <base href>
+                    = importmap 里业务/依赖的路径前缀
+
+index.html   <base href="/<env>/<project>/">     ← 相对 import 的解析基准
+utils/router 把「应用内路径 /students/s01」拼到 base 的 pathname 上 → 真实 URL
+CDN 回退      命中不了静态资源 → 200 返回 index.html(禁 302)
+```
+
+**一句话:应用内部只认「不含前缀的应用内路径」(`/students/s01`),
+前缀只在三处出现一次(base href / 上传前缀 / importmap),且三者必须由同一个变量派生。**
+
+---
+
+## 坑 1:任意的 `new URL(path, baseURI)` 会丢掉部署前缀
+
+**症状**
+- 子路径部署(如 `/dev/myapp/`)下,点导航跳到 `https://<cdn>/students`(**域名根**,前缀没了)。
+- 刷新该页 → **404**(OBS 上根目录没有这个文件,CDN 回退也没覆盖到)。
+- 本地开发用根路径部署时一切正常,一上线换二级路径就崩 —— 典型的「环境相关」假象。
+
+**根因**
+
+`new URL(input, base)` 的语义是 **RFC 3986 相对解析**:当 `input` **以 `/` 开头**时,
+它是**绝对路径**,会**整体替换** base 的 path,**不保留 base 的目录前缀**。
+
+```js
+new URL('/students', 'https://cdn/dev/myapp/')
+// → https://cdn/students        ❌ 前缀 /dev/myapp 被丢掉
+new URL('students', 'https://cdn/dev/myapp/')
+// → https://cdn/dev/myapp/students  ✅ 但依赖 base 以 / 结尾,脆弱
+```
+
+而应用内部路径(`/students`)天然带前导 `/`,于是「尊重 `<base href>`」这个直觉写法
+在子路径部署下**必然**丢前缀。
+
+**最佳实践**
+
+先把应用内路径**拼到 base 的 pathname 上**,再交给 URL 解析(此时相对解析不会丢前缀):
+
+```js
+/** base 路径(去尾斜杠),如 '/dev/myapp';根部署时为 '' */
+export function basePath() {
+  return new URL(document.baseURI).pathname.replace(/\/+$/, '');
+}
+
+/** 应用内路径 → 保留部署前缀的绝对 URL(查询串 / hash 原样保留) */
+export function resolveUrl(path) {
+  const base = basePath();
+  const q = path.indexOf('?');
+  const h = path.indexOf('#');
+  let cut = path.length;
+  if (q !== -1) cut = Math.min(cut, q);
+  if (h !== -1) cut = Math.min(cut, h);
+  let p = path.slice(0, cut);
+  const rest = path.slice(cut);          // ?query#hash 原样带过去
+  if (!p.startsWith('/')) p = '/' + p;   // 容错:允许传 'students'
+  return new URL(base + p + rest, location.origin);
+}
+```
+
+**红线(写进 code review 清单)**:业务代码里**禁止**出现 `new URL(path, document.baseURI)`
+用在「导航 / 内部链接」上。一切经 `utils/router.js` 的 `resolveUrl()`。
+
+---
+
+## 坑 2:`@lit-labs/router` 在子路径部署下污染路由状态
+
+**症状**
+- 引入 `@lit-labs/router` 后,子路径部署的应用里路由「偶尔跳错 / 停在错误页 / 点内部链接整页刷新」。
+- 根部署时完全正常,换二级路径后诡异。
+
+**根因(两点叠加)**
+
+1. 它在 `window` 上装了**全局 click 拦截器**,对**同源 `<a>` 自行 `pushState`** ——
+   绕过你自己的导航函数(也就绕过了 base 前缀处理),且与「手动 `<a>` + 原生刷新」的行为预期冲突。
+2. 它在 `hostConnected` / `popstate` 时,用**未经剥离的 `location.pathname`**(带着 `/dev/myapp/`)
+   去匹配路由表 —— 而路由表里的 path 是**应用内路径**(`/students`),于是匹配不上 / 匹配错。
+
+**最佳实践**
+
+- **子路径部署的项目,不要用 `@lit-labs/router`**。自写一个 ~60 行的 `utils/router.js`:
+  - `navigate(path, {replace})`:`pushState` 后**手动派发 `popstate`**(History 原生不触发),
+    让订阅者感知;内部走 `resolveUrl()`(保留前缀)。
+  - `currentPath()`:从 `location.pathname` **剥掉 base 前缀**,返回应用内路径。
+  - `href(path)`:给**真实 `<a>`** 用,返回保留前缀的完整 URL。
+- 要不要界面路由框架?**不需要**。无构建项目里「路由表 + `currentPath()` + `popstate` 订阅」
+  就是最小的、可调试的路由。见 [routing.md 第二节](./routing.md)。
+- 若坚持用第三方 router:必须在 `goto()`/`popstate` 前把 `pathname` 剥前缀,并
+  关掉/绕过它的全局 `<a>` 拦截 —— 成本通常高于自写。
+
+**配套:`currentPath()` 要稳**
+
+```js
+export function currentPath() {
+  const base = basePath();
+  const p = location.pathname;
+  let path = p.startsWith(base) ? p.slice(base.length) : p;  // 脏 URL 退回全路径
+  if (!path.startsWith('/')) path = '/' + path;
+  return path.replace(/\/{2,}/g, '/') || '/';   // 折叠 '//students'
+}
+```
+
+- **剥前缀**:路由表只认应用内路径。
+- **脏数据兜底**:万一 URL 落在前缀之外(历史书签 / 手工输入),**退回到 `/` 开头的全路径**,
+  交给路由表落到 404 页 —— 别抛异常、别白屏。
+- **折叠重复斜杠**:`/dev/myapp//students` 不该变成两条不同路由。
+
+---
+
+## 坑 3:深链接刷新 404 —— CDN 回退不是 200
+
+**症状**
+- 站内点击正常(SPA 前端接管),但**在该 URL 上直接刷新 / 粘贴给别人打开 → 404**。
+- 或:刷新后**地址栏变成了 `/index.html`**,路由参数全丢(这是 302 的特征)。
+- 微信 OAuth 回调失败(`redirect_uri` 与实际落地 URL 不一致)。
+
+**根因**
+
+History 模式下,`/dev/myapp/students/s01` 这个路径在 OBS 上**根本不存在**(OBS 里只有
+`index.html` 和真实文件)。没有回退规则时,CDN 直接 404。
+
+**最佳实践**
+
+CDN 配置**两条回退规则**,顺序不能反:
+
+| 顺序 | 匹配 | 动作 |
+|------|------|------|
+| 规则 1 | 命中真实静态资源(`.js` / `.css` / `.png` …) | 直接返回该文件 |
+| 规则 2 | 其余(路径不存在) | 返回 `index.html`,**状态码 200** |
+
+- **必须 200,禁止 301/302**:302 会由浏览器改写地址栏、丢失 `location.pathname`,
+  路由参数与 OAuth 回调全部破坏。回退「返回内容」而非「重定向」。
+- `index.html` 设 `Cache-Control: no-cache`,保证回退永远拿到最新壳。
+- 部署后**逐条实测深链接**(含一个不存在的路径,验证它会落到 404 页而不是报错)。
+  见 [坑 7 验收清单](#坑-7只-curl-首页不算验收)。
+
+---
+
+## 坑 4:`<base href>` 与实际部署前缀不一致(三处必须同源)
+
+**症状**
+- 所有相对 import / 相对资源(`./app.js`、`./components/x.js`)**全部 404**,
+  被解析到**域名根**而不是项目目录。
+- 或换个环境部署后,importmap 指向的依赖路径 404。
+
+**根因**
+
+`<base href>`、OBS 上传前缀、importmap 里的路径前缀,是**同一个前缀的三个副本**。
+只要有一处不一致(比如手工改了其中一处),就从那一处开始 404。
+
+**最佳实践**
+
+1. **三处同源**:在部署脚本里用**一个变量**派生全部前缀,禁止三处各写各的:
+
+   ```bash
+   OBS_PREFIX="dev/${PROJECT}"       # OBS 上传前缀
+   BASE_HREF="/dev/${PROJECT}/"      # <base href> 期望值
+   # importmap 里的业务/依赖路径 = /${OBS_PREFIX}/...
+   ```
+
+2. **部署脚本里硬校验**(失败即中止,别等到线上白屏):
+
+   ```bash
+   grep -q "<base href=\"${BASE_HREF}\"" src/index.html \
+     && echo "ok base href = ${BASE_HREF}" \
+     || { echo "!! base href 与部署前缀 ${BASE_HREF} 不一致,相对 import 会 404" >&2; exit 1; }
+   ```
+
+3. **importmap 用内联**(`<script type="importmap">{...}</script>`),不要用
+   `<script type="importmap" src="...">` 外链 —— 外链 importmap 浏览器支持面窄,
+   内联是唯一 100% 可靠的形式。内联的那份同时托管一份到依赖仓供他项目复用。
+4. **依赖也带前缀**:若依赖与业务同源(如都在 `/<env>/npm/`),importmap 里依赖路径
+   **必须**写成 `/<env>/npm/lit/vX/index.js`,与 `<base href>` 前缀一致;
+   同源即无 CORS 问题,是省事之选。
+
+> 判定口诀:**根部署(`/`)时它无所谓,子路径部署时它是所有 404 的总开关。**
+
+---
+
+## 坑 5:CDN 刷新「看似成功实则没刷」
+
+**症状**
+- 发布后线上仍是**旧页面**(甚至新旧混着:壳是新的、某个 js 是旧的)。
+- 刷新脚本日志打印 `taskId: undefined` 或干脆只刷了一个域名 —— 但**退出码为 0**,流水线绿灯。
+
+**根因(两类,都要防)**
+
+1. **域名/凭证错配**:脚本用的 domain_id(或 AK)只对某个加速域有效,
+   只刷了 `a.example.com`,没刷 `app.example.com` —— 而用户访问的是后者。
+2. **SDK 返回结构读错**:脚本读 `result.refresh_task.id`,而 SDK 实际返回的
+   `refresh_task` 是**字符串 ID**(不是对象)→ 恒得 `undefined` → 打印
+   `taskId: undefined` 后**照样退出 0**,形成**假成功**。
+
+**最佳实践**
+
+- 刷新 **`type: 'directory'`**(URL 以 `/` 结尾),一次覆盖目录下新增/变更文件,
+  比逐个 file 刷新省事、不易漏。
+- **按域名对号入座**:维护 `host → domain_id` 映射表,URL 里解析出 host 再取对应
+  domain_id + 凭证;**未知域名直接报错退出**,绝不静默跳过。
+- **把假成功变成真失败**:对刷新结果**做非空断言**,任一失败则**退出码非 0**:
+
+  ```js
+  const taskId = r.refresh_task;              // ← 字符串 ID,不是 r.refresh_task.id
+  if (!taskId) { failed++; continue; }        // 没有 taskId = 没提交成功,不得算成功
+  ...
+  process.exit(failed ? 1 : 0);
+  ```
+
+- 部署脚本调用刷新失败时**不要吞掉**(最多提示可手动重试,但要让流水线红)。
+
+---
+
+## 坑 6:`index.html` 被 `sync` 跳过 / 被长缓存
+
+**症状**
+- 发了新版,`index.html` 还是旧的(但新增的文件都在)—— 典型的「壳没更新」。
+- 或者 CDN 回退拿到的 `index.html` 是缓存里的旧壳。
+
+**根因**
+
+- `obsutil sync` 按 **size + mtime** 判定 Skip。`index.html` 名字固定、内容随发布变化,
+  若 size 恰好相同(或 mtime 未变),**会被静默跳过**。
+- CDN 若把 `index.html` 也长缓存,回退/更新都拿到旧壳。
+
+**最佳实践**
+
+- **先删后传**:`obsutil rm <prefix>/index.html` 再 `obsutil cp ./src/index.html ...`,
+  并排除它不参与上一步的 `sync`(`-exclude="index.html"`)。
+- 传 `index.html` 时显式设头:
+  `-meta="Cache-Control:no-cache, no-store, must-revalidate#Content-Type:text/html; charset=utf-8"`。
+- 带内容 hash 的静态资源可长缓存;`index.html` 永远 `no-cache`。
+- ⚠️ **CDN 冷边缘**:新上传的 key 首次被某边缘节点拉取时**偶发 404**(回源竞争),
+  **重跑一次即消失**。验收脚本遇到新 key 的首次 404 不要立刻判定为故障。
+
+---
+
+## 坑 7:只 `curl` 首页不算验收
+
+**症状**
+- 部署脚本 `curl -w '%{http_code}'` 首页 → 200 → 认为发布成功。
+- 用户打开 → **白屏**(某个模块 404 / MIME 错 / 回退没配 / 某个相对路径错一个字符)。
+
+**根因**
+
+`curl` 首页 200 只能证明 `index.html` 在。浏览器加载一个无构建 ESM 应用会走一条**长链路**,
+任一环断裂都白屏,而 `curl` 一个都不覆盖:
+
+```
+index.html → 内联 importmap → 解析裸标识符 → 拉模块(MIME 必须 text/javascript)
+           → 模块内相对 import(必须带 .js)→ 再拉… → 深链接回退(必须 200)
+```
+
+**最佳实践(分层验收,越往下越真)**
+
+1. **静态体检**(部署前,`node --check` + 解析):语法/装饰器残留、相对 import 带 `.js`、
+   相对目标存在、裸标识符都在 importmap、自定义元素标签含连字符。
+2. **模块图爬取**(部署后,HTTP 层):从入口 `app.js` 出发**跟随相对 import 走完整图**,
+   断言每个文件 **200 + MIME 为 `text/javascript`**;再断言 importmap 每条目标可达;
+   再断言**深链接返回 200 且内容是 index.html**(`num_redirects=0`)。
+   —— 这一步比人眼快,且能在 CI 里跑。
+3. **真实浏览器**(最终验收):用 Playwright/真实浏览器**点导航 + 刷新深链接**,
+   确认不 404、无控制台报错。HTTP 层过了不代表渲染没问题(渲染报错、Signal 时机等)。
+4. **缓存层**:核对 CDN 实际返回的**内容**(不只是状态码),确认是新版。
+
+> 原则:**能自动断言的都断言,别靠「我 curl 了一下 200」。**
+> 一个可复用的验收脚本应把上面 1–2 步固化成一条命令,纳入部署流程。
+
+---
+
+## 坑 8:部署脚本的 static cd 陷阱与硬编码 URL
+
+**症状**
+- 脚本在某个目录下「卡住」或后续命令全部相对错位。
+- 文档里的交付链接**换一个项目 / 换一个篇目就 404**(把上一篇的 URL 抄到了新篇)。
+
+**根因**
+
+- **`cd` 相对路径**:脚本用相对路径 `cd`,或在管道/子 shell 里改了 cwd,
+  后续 `obsutil` / 文件路径全部相对错位。
+- **硬编码 URL**:把「某一次的线上地址」写死进文档/配置;换项目后就指向不存在的资源。
+
+**最佳实践**
+
+- 脚本开头用**以脚本自身位置推导的绝对路径**,不依赖调用者的 cwd:
+
+  ```bash
+  set -euo pipefail
+  REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+  cd "$REPO_ROOT"
+  ```
+
+- **参数化**:域名 / 前缀 / 桶都从**变量或参数**取(`PROJECT`、`OBS_PREFIX`、`BASE_HREF`),
+  绝不散落字面量;URL 用这些变量拼出来。
+- **交付物链接**用统一的 URL 构造函数生成(`https://<cdn>/<prefix>/<path>`),
+  不要人肉抄。文档里若必须贴链接,**注明它是哪个项目/前缀**,避免跨项目误用。
+- 顺手加 `--dry` 只打印不执行的开关,方便核对将要跑的命令。
+
+---
+
+## 坑 9:裸 `<a href>` 与 SPA 导航冲突;`href()` 要留出「新标签打开」
+
+**症状**
+- 点内部链接**整页刷新**:多标签 KeepAlive 全丢、状态重置、白屏一瞬。
+- 或:为了 SPA 全部改用 `navigate()`,结果**中键/右键「在新标签打开」失效**。
+
+**根因**
+
+- 原生 `<a href="/x">` 触发**整页导航**,不是 SPA 行为。
+- 但**全部**改成 JS click 又牺牲了浏览器原生能力(新标签、复制链接、无障碍)。
+
+**最佳实践**
+
+两套出口,按需选:
+
+| 场景 | 用什么 | 说明 |
+|------|--------|------|
+| 站内跳转(按钮/菜单项,语义是「切页面」) | `navigate(path)` | `pushState` + 派发 `popstate`,SPA 不刷新 |
+| 真链接(要给用户「新标签打开/复制链接」) | `<a href=${href(path)}>` | `href()` 返回**保留前缀**的完整 URL,中间键/右键正常 |
+| 在卡片/行内点击的**链接**(防止触发外层跳转) | `@click=${e => e.stopPropagation()}` | 见 [坑 10](#坑-10可点击交付链接点它不该触发外层卡片跳转) |
+
+- 内部导航**一律**经 `utils/router.js`,**禁止**裸 `new URL(path, baseURI)`(见坑 1)
+  与第三方全局拦截(见坑 2)。
+- 菜单项若用 `<div>` 模拟链接,记得补 `role="link" tabindex="0"` 与
+  `@keydown` 回车触发,保持键盘可达。
+
+---
+
+## 坑 10:可点击交付链接,点它不该触发外层卡片跳转
+
+**症状**
+- 卡片整体可点(进详情),卡片里还嵌了「打开报告 / 打开仓库」链接 ——
+  点内层链接时**外层跳转也触发了**,或整页被导航走、新标签没打开。
+
+**根因**
+
+事件冒泡:内层 `<a>` 的 `click` 冒泡到外层卡片的 `click` 处理器。
+
+**最佳实践**
+
+- 内层链接 `@click=${(e) => e.stopPropagation()}`,切断冒泡;用**真实 `<a>` + `target="_blank"`**
+  让浏览器开新标签,别用 `window.open` 绕。
+- 交给数据的链接用**统一结构**(如 `{kind, label, url}` 数组),渲染层据 `kind` 决定
+  图标/文案,便于一致地展示「报告 / 看板 / 文档 / 仓库」等多种入口。
+- **进度/状态展示**这类看板常见模式:
+  - **不要编造数据**:字段缺失就**留空**(如还没有的真实 URL 一律空),
+    比填假的强 —— 空会被发现,假的会被当成事实。
+  - 筛选层统一剔除**不该计入统计的条目**(内部人员/测试数据),
+    在服务层做,不要在展示层各自打补丁。
+  - 进度口径**写清楚**(「以输出报告为最终节点」这类),口径变了要在 UI/文档同步标注,
+    避免同一数字两种理解。
+
+---
+
+## 附:内部导航工具(可直接复制的最小实现)
+
+```js
+// utils/router.js —— 子路径部署安全的导航(只用 History API,干净 URL)
+export function basePath() {
+  return new URL(document.baseURI).pathname.replace(/\/+$/, '');
+}
+
+export function resolveUrl(path) {
+  const base = basePath();
+  const q = path.indexOf('?'), h = path.indexOf('#');
+  let cut = path.length;
+  if (q !== -1) cut = Math.min(cut, q);
+  if (h !== -1) cut = Math.min(cut, h);
+  let p = path.slice(0, cut);
+  const rest = path.slice(cut);
+  if (!p.startsWith('/')) p = '/' + p;
+  return new URL(base + p + rest, location.origin);
+}
+
+export function navigate(path, { replace = false } = {}) {
+  const url = resolveUrl(path);
+  if (replace) history.replaceState({}, '', url);
+  else history.pushState({}, '', url);
+  window.dispatchEvent(new PopStateEvent('popstate'));  // History 原生不触发
+}
+
+export function currentPath() {
+  const base = basePath();
+  let path = location.pathname;
+  if (base && path.startsWith(base)) path = path.slice(base.length);
+  if (!path.startsWith('/')) path = '/' + path;
+  return path.replace(/\/{2,}/g, '/') || '/';
+}
+
+export function currentQuery() {
+  return Object.fromEntries(new URLSearchParams(location.search));
+}
+
+export function href(path) { return resolveUrl(path); }   // 供真实 <a href>
+```
+
+路由表 + 订阅(`app-shell` 里):监听 `popstate`,用 `currentPath()` 匹配路由表,
+命中则「打开/激活」对应视图(多标签场景下不要整体重渲染,见
+[tabs-keepalive.md](./tabs-keepalive.md))。
+
+---
+
+## 附:上线前 checklist(路由 / base / CDN 维度)
+
+- [ ] `<base href>` == OBS 上传前缀 == importmap 路径前缀(**同一变量派生**,脚本里有硬校验)
+- [ ] importmap **内联**在 `index.html`,依赖路径带正确的部署前缀
+- [ ] 业务代码**无** `new URL(path, baseURI)` 用于导航;无 `@lit-labs/router`(子路径部署)
+- [ ] `currentPath()` 剥前缀 + 脏 URL 兜底 + 折叠 `//`
+- [ ] CDN 回退:命中真实资源直返;不存在路径 **200** 返回 `index.html`,**禁 301/302**
+- [ ] `index.html` 先删后传 + `no-cache`
+- [ ] CDN 刷新:`type:directory`,**对号入座 domain_id**,读 `refresh_task`(字符串),失败退出非 0
+- [ ] 静态体检(语法/装饰器/`.js` 后缀/importmap 覆盖/标签连字符)通过
+- [ ] 模块图爬取(200 + `text/javascript`)通过
+- [ ] 深链接**刷新** 200 且为 `index.html`(含一个不存在的路径 → 落 404 页)
+- [ ] **真实浏览器**点导航 + 刷新深链接,无 404、无控制台报错
+- [ ] 交付链接 `target=_blank` 可点、不触发外层跳转;无数据不编造,留空
+
+---
+
+## 相关
+
+- [routing.md](./routing.md) —— 路由基本用法(History、守卫、OAuth、多标签交互)
+- [deployment.md](./deployment.md) —— Repose 依赖托管 + OBS/CDN 部署 + 回退规则
+- [pitfalls.md](./pitfalls.md) —— 无构建的通用坑(装饰器 / CORS / MIME / 多版本 lit)
+- [tabs-keepalive.md](./tabs-keepalive.md) —— 多标签 KeepAlive 与路由的配合
+- [project-structure.md](./project-structure.md) —— 子路径部署与 `<base href>`

+ 48 - 9
.claude/rules/routing.md

@@ -1,9 +1,25 @@
-# 路由(@lit-labs/router)
+# 路由(History 干净 URL)
 
 > 使用 History 模式得到**干净 URL**(无 `#`)。这是内部后台 + 微信 OAuth 回调的硬性前提:
 > OAuth 回调 URL 不能带 hash,且必须由 CDN 返回 **200**(不能 302)。
+>
+> ⚠️ **子路径部署(`/<env>/<project>/`)优先自写 `utils/router.js`,不要用 `@lit-labs/router`**:
+> 它装全局 `<a>` click 拦截器,且用**未剥前缀**的 `location.pathname` 匹配路由,会污染路由状态。
+> 根部署可选用它;子路径部署一律自写 —— 最小实现与完整踩坑见
+> [routing-best-practices.md](./routing-best-practices.md)(坑 1、坑 2)。
 
-## 一、基本用法
+## 〇、子路径部署的红线(先看这条)
+
+应用内部只认「**不含前缀的应用内路径**」(`/students/s01`),前缀只在三处出现且必须同源:
+`<base href>` == OBS 上传前缀 == importmap 路径前缀。
+
+- ❌ **禁止** `new URL(path, document.baseURI)` 做导航 —— 当 `path` 以 `/` 开头时
+  会**丢掉 base 的路径前缀**,刷新即 404。正确做法:拼到 base 的 **pathname** 上再解析。
+- `currentPath()` 要**剥掉前缀**并折叠重复斜杠,脏 URL 兜底到 404 页。
+
+完整根因与规避见 [routing-best-practices.md](./routing-best-practices.md)。
+
+## 一、基本用法(@lit-labs/router,仅根部署)
 
 ```js
 // services/router-service.js 或 app.js
@@ -55,19 +71,36 @@ navigate('/users/42');          // pushState
 navigate('/login', { replace: true });
 ```
 
-`utils/router.js` 里封装,基于 `<base href>` 拼前缀:
+`utils/router.js` 里封装,**把应用内路径拼到 base 的 pathname 上**(不能用
+`new URL(path, document.baseURI)`,会丢前缀):
 
 ```js
+export function basePath() {
+  return new URL(document.baseURI).pathname.replace(/\/+$/, '');   // '/dev/myapp' 或 ''
+}
+export function resolveUrl(path) {
+  const base = basePath();
+  const q = path.indexOf('?'), h = path.indexOf('#');
+  let cut = path.length;
+  if (q !== -1) cut = Math.min(cut, q);
+  if (h !== -1) cut = Math.min(cut, h);
+  let p = path.slice(0, cut);
+  const rest = path.slice(cut);              // 查询串 / hash 原样保留
+  if (!p.startsWith('/')) p = '/' + p;
+  return new URL(base + p + rest, location.origin);
+}
 export function navigate(path, { replace = false } = {}) {
-  const url = new URL(path, document.baseURI); // 尊重 <base href>
+  const url = resolveUrl(path);
   if (replace) history.replaceState({}, '', url);
   else history.pushState({}, '', url);
   window.dispatchEvent(new PopStateEvent('popstate'));
 }
 ```
 
-- **基于 `document.baseURI`** 拼 URL,子目录部署(`/admin/`)才不会跳错。
-- 手动 `pushState` 后派发 `popstate`,让 Router 感知。
+- **拼到 base 的 pathname 上**再解析,子目录部署(`/admin/`)才不会丢前缀。
+- 手动 `pushState` 后派发 `popstate`,让订阅者感知(History 原生不触发)。
+- 真链接用 `href(path)`(= `resolveUrl`)供 `<a href>` 使用,保留中键「新标签打开」。
+- 完整实现与坑见 [routing-best-practices.md](./routing-best-practices.md)。
 
 ## 三、CDN 回退规则(History 路由的前提)
 
@@ -118,7 +151,13 @@ new Router(this, [
 
 ## 七、坑
 
-- 忘记 CDN 回退 → 刷新 404。
-- `<base href>` 配错 → 相对 import 404。
-- 用 `<a href="/x">` 原生跳转 → 整页刷新(非 SPA)。内部导航一律 `navigate()` + `popstate`。
+- 忘记 CDN 回退 → 刷新 404。回退**必须 200**,禁 301/302(302 会丢 `pathname`)。
+- `<base href>` 配错 / 与部署前缀不一致 → 相对 import 404。
+- `new URL(path, document.baseURI)` 丢部署前缀 → 子路径深链接 404(**最高频**,见坑 1)。
+- `@lit-labs/router` 在子路径部署下污染路由状态(全局 `<a>` 拦截 + 未剥前缀 pathname)。
+- 用 `<a href="/x">` 原生跳转 → 整页刷新(非 SPA)。内部导航一律 `navigate()` + `popstate`;
+  要保留「新标签打开」的用 `href()`。
+- 只 `curl` 首页 200 就算验收 → 用户白屏。**必须**跑模块图爬取 + 真实浏览器。
 - 混用 hash 路由与 History 路由 → 混乱。**只用 History**。
+
+> 以上每条的**症状 → 根因 → 规避**,逐条见 [routing-best-practices.md](./routing-best-practices.md)。

+ 8 - 5
CLAUDE.md

@@ -19,13 +19,14 @@
 | 3 | [components.md](./.claude/rules/components.md) | LitElement 组件规范、Shadow DOM、属性/事件、Slot | 写组件 |
 | 4 | [services.md](./.claude/rules/services.md) | Service 分层、Signal 状态、单例、可单测 | 写服务 / 状态 |
 | 5 | [context-di.md](./.claude/rules/context-di.md) | `@lit-labs/context` 令牌与 provide/consume | 做依赖注入 |
-| 6 | [routing.md](./.claude/rules/routing.md) | `@lit-labs/router` History 路由、守卫、404 | 做路由 |
+| 6 | [routing.md](./.claude/rules/routing.md) | History 路由、守卫、404(子路径部署自写 `utils/router.js`) | 做路由 |
 | 7 | [tabs-keepalive.md](./.claude/rules/tabs-keepalive.md) | 后台多标签 + KeepAlive(实例常驻) | 做后台壳 |
 | 8 | [http-utils.md](./.claude/rules/http-utils.md) | 原生 Fetch 封装、token 拦截、错误处理、utils | 写请求 / 工具 |
 | 9 | [deployment.md](./.claude/rules/deployment.md) | FMODE Repose 依赖托管 + OBS+CDN 业务部署 + importmap | 上线 / 配依赖 |
 | 10 | [pitfalls.md](./.claude/rules/pitfalls.md) | CORS / 版本冲突 / 302 陷阱 / base href 等坑位 | 排查问题 |
 | 11 | [modules/](./.claude/rules/modules/) | 功能模块 / 通用能力 / 业务模块 划分最佳实践 | 模块拆分 |
-| 12 | [retrospective.md](./docs/retrospective.md) | 对标 Angular zoneless 的技术复盘 | 理解选型理由 |
+| 12 | [routing-best-practices.md](./.claude/rules/routing-best-practices.md) | **子路径部署路由实战**:base 三处同源 / `new URL` 丢前缀 / 弃用 `@lit-labs/router` / CDN 刷新假成功 / 验收不能只 curl | 做路由 / 子路径部署 / 上线验收 |
+| 13 | [retrospective.md](./docs/retrospective.md) | 对标 Angular zoneless 的技术复盘 | 理解选型理由 |
 
 ## 2. 架构一句话
 
@@ -39,7 +40,7 @@
 | 全局状态 | `@lit-labs/signals` + Service 单例 | 跨组件共享 |
 | 组件本地状态 | Lit `@property` / `@state` | 弹窗、输入框 |
 | 依赖注入 | `@lit-labs/context` | 令牌 + provide/consume |
-| 路由 | `@lit-labs/router` | History 干净 URL |
+| 路由 | 自写 `utils/router.js`(History 干净 URL) | **子路径部署禁用 `@lit-labs/router`**(全局 `<a>` 拦截 + 未剥前缀 pathname 会污染路由);根部署可选它 |
 | 请求 | 原生 `fetch` 薄封装 | 不引 axios |
 | 持久化 | `localStorage` 薄封装 | 一次性配置 |
 | 部署 | OBS + CDN | 无构建直传 |
@@ -59,10 +60,12 @@
 
 - **依赖** → 同步 npm 原始 ESM 到 **FMODE Repose**(按版本目录化,开 HTTP/2,`.js` 必须 `text/javascript`,CORS 放行业务域名),全业务共享一份公共 importmap。
 - **业务码** → `obsutil` 同步 `src/` 到 OBS(华为云 `obs://www-fmode.cn/<project>/`,南一端点),`public-read`,CDN 指向 `fmode.cn/<project>/`,最后刷新 CDN。
-- **子目录部署必须写 `<base href="/<project>/">`**,否则相对 JS 404。
+- **子目录部署必须写 `<base href="/<project>/">`**,且 `<base href>` == OBS 上传前缀 == importmap 路径前缀(**同一变量派生 + 脚本硬校验**),否则相对 JS 404。
 - **History 回退**:CDN 配置「不存在路径返回 `index.html` 且状态码 **200**」,**严禁 302/301**(否则地址栏被改写、微信 OAuth 回调失败)。
+- **CDN 刷新**:`type:directory` + domain_id 对号入座;SDK 返回的 `refresh_task` 是**字符串**(不是 `.id`),失败须退出非 0(否则**假成功**)。
+- **验收**:不能只 `curl` 首页 200 —— 须跑模块图爬取(200 + `text/javascript`)+ 深链接 200 + **真实浏览器**。
 
-详见 [deployment.md](./.claude/rules/deployment.md)。
+详见 [deployment.md](./.claude/rules/deployment.md) 与 [routing-best-practices.md](./.claude/rules/routing-best-practices.md)。
 
 ## 6. 命名约定(速记)
 

+ 2 - 1
README.md

@@ -11,6 +11,7 @@
 |--------|------|------|
 | 规则总纲 | [`CLAUDE.md`](./CLAUDE.md) | AI 进入本仓/引用本仓时的第一入口,索引全部规则 |
 | 分层规则 | [`.claude/rules/`](./.claude/rules/) | 架构、目录、组件、服务、DI、路由、多标签、部署、坑位逐篇规范 |
+| **路由实战** | [`.claude/rules/routing-best-practices.md`](./.claude/rules/routing-best-practices.md) | 子路径部署路由最佳实践:base 三处同源 / `new URL` 丢前缀 / 弃用 `@lit-labs/router` / CDN 刷新假成功 / 不能只 curl 验收 |
 | 模块划分 | [`.claude/rules/modules/`](./.claude/rules/modules/) | 功能模块 / 通用能力 / 业务模块 的拆分最佳实践 |
 | 源稿 | [`.claude/_lit-dev-manual.md`](./.claude/_lit-dev-manual.md) | 用户提供的《Lit 原生 ESM 无构建开发最佳实践》原始规范 |
 | 可运行示例 | [`examples/hello-lit/`](./examples/hello-lit/) | 零构建、可直接上 OBS+CDN 的最小多标签后台示例 |
@@ -27,7 +28,7 @@
 - **视图**:Lit 3.x(`LitElement` / `html` / `css` / Shadow DOM)
 - **响应式**:`@lit-labs/signals`(跨组件细粒度信号)
 - **依赖注入**:`@lit-labs/context`(组件树 Context)
-- **路由**:`@lit-labs/router`(History 干净 URL,支持微信 OAuth 回调)
+- **路由**:自写 `utils/router.js`(History 干净 URL,支持微信 OAuth 回调)|子路径部署**禁用** `@lit-labs/router`
 - **分层**:WebComponent(视图) / Service(状态与业务) / Utils(无状态函数)
 - **部署**:FMODE Repose(依赖仓)+ OBS + CDN(业务托管,History 回退 200)
 

+ 1 - 1
docs/retrospective.md

@@ -25,7 +25,7 @@
 | 依赖分发 | 打包进 bundle | Repose 统一托管,一次上传全业务复用 |
 | 组件模型 | 装饰器 + 模板 DSL(框架私有) | **标准 Web Components** + 标准模板字面量 |
 | 样式隔离 | ViewEncapsulation(模拟/Shadow) | **Shadow DOM 原生** |
-| 路由 | `@angular/router` | `@lit-labs/router`(History) |
+| 路由 | `@angular/router` | 自写 `utils/router.js`(History;子路径部署弃用 `@lit-labs/router`) |
 | DI | 框架 DI 容器 | `@lit-labs/context`(轻)或直接 import 单例 |
 | 心智模型 | 框架概念多(模块/组件/指令/管道…) | **浏览器原生 API**,概念少 |
 | 升级成本 | 框架版本强绑定 | 只升级 lit 版本号(改 importmap) |

+ 2 - 2
examples/hello-lit/README.md

@@ -45,7 +45,7 @@ npx --yes serve src
 
 ```bash
 cd examples/hello-lit
-npm install lit @lit-labs/signals @lit-labs/context @lit-labs/router
+npm install lit @lit-labs/signals @lit-labs/context
 # 把 index.html 的 importmap 换成 ./importmap.json,
 # 并把 importmap.json 的路径指向 ./node_modules/...
 npx serve src
@@ -61,7 +61,7 @@ npx serve src
 - **分层**:`page-user-list` 只读 `userService.users`(signal)+ 转发点击;逻辑在 service。
 - **Signal 响应式**:`SignalWatcher` 组件读 signal 自动重渲染。
 - **Context**:`app.js` 用 `provide(authContext, authService)`;`page-home` 用 `ContextConsumer` 消费。
-- **History 路由**:`navigate()` 基于 `document.baseURI`,干净 URL。
+- **History 路由**:`navigate()` 经 `utils/router.js`(把应用内路径拼到 base 的 pathname 上,子路径部署不丢前缀),干净 URL;不用 `@lit-labs/router`。
 - **多标签 KeepAlive**:切到「用户列表」输入筛选词 → 去详情 → 切回,输入**不丢**(实例常驻)。
 - **无装饰器**:全部用 `static properties` + `customElements.define()`。
 

+ 1 - 2
examples/hello-lit/package.json

@@ -11,7 +11,6 @@
   "devDependencies": {
     "lit": "^3.2.1",
     "@lit-labs/signals": "^0.1.3",
-    "@lit-labs/context": "^0.5.1",
-    "@lit-labs/router": "^0.2.0"
+    "@lit-labs/context": "^0.5.1"
   }
 }

+ 5 - 12
examples/hello-lit/src/components/app-shell.js

@@ -1,12 +1,15 @@
 /**
  * components/app-shell.js —— 布局壳:侧边菜单 + 多标签栏 + KeepAlive 内容区。
  *
- * 关键:内容区不用 router.outlet()(每次路由切换会销毁重建),
+ * 关键:内容区不用路由 outlet()(每次路由切换会销毁重建),
  * 而是交给 <tabs-container> 渲染「所有已打开标签」的常驻容器,
  * 路由只负责「打开/激活标签」。
+ *
+ * ⚠️ 不用 @lit-labs/router:它在 window 上装全局 <a> click 拦截器,
+ *   且用**未剥前缀**的 location.pathname 匹配路由 —— 子路径部署下会污染路由状态。
+ *   本壳直接用 utils/router.js 的 currentPath() + popstate 订阅。
  */
 import { LitElement, html, css } from 'lit';
-import { Router } from '@lit-labs/router';
 import { SignalWatcher } from '@lit-labs/signals';
 import { tabsService } from '../services/tabs-service.js';
 import { currentPath } from '../utils/router.js';
@@ -15,13 +18,6 @@ import './side-menu.js';
 import './tabs-bar.js';
 import './tabs-container.js';
 
-/** 路由表:path 模式 → 标签标题(供打开标签时命名) */
-const ROUTES = [
-  { pattern: '/', title: '首页' },
-  { pattern: '/users', title: '用户列表' },
-  { pattern: '/users/:id', title: '用户详情' },
-];
-
 export class AppShell extends SignalWatcher(LitElement) {
   static styles = css`
     :host { display: grid; grid-template-columns: 200px 1fr; min-height: 100vh; }
@@ -29,9 +25,6 @@ export class AppShell extends SignalWatcher(LitElement) {
     main { display: flex; flex-direction: column; min-width: 0; }
   `;
 
-  /** Router 仅用于 history 集成与 popstate 派发;渲染出口不用它 */
-  #router = new Router(this, [{ path: '/*', render: () => html`` }]);
-
   #onPop = () => this.#syncTabWithRoute();
 
   connectedCallback() {

+ 1 - 2
examples/hello-lit/src/importmap.json

@@ -12,7 +12,6 @@
     "@lit/context": "/npm/@lit/context/v1.1.6/index.js",
     "@lit-labs/context": "/npm/@lit/context/v1.1.6/index.js",
     "@lit-labs/signals": "/npm/@lit-labs/signals/v0.3.0/index.js",
-    "signal-polyfill": "/npm/signal-polyfill/v0.2.2/dist/index.js",
-    "@lit-labs/router": "/npm/@lit-labs/router/v0.1.4/index.js"
+    "signal-polyfill": "/npm/signal-polyfill/v0.2.2/dist/index.js"
   }
 }

+ 44 - 5
examples/hello-lit/src/utils/router.js

@@ -1,15 +1,54 @@
 /**
- * utils/router.js —— 基于 document.baseURI 的导航,尊重 <base href>。
+ * utils/router.js —— 子路径部署安全的导航(只用 History API,干净 URL)。
+ *
+ * ⚠️ 不要用 `new URL(path, document.baseURI)` 做导航:
+ *   当 path 以 `/` 开头时,URL 的相对解析会**丢掉 base 的路径前缀**
+ *   (`/hello-lit/students` → `https://cdn/students`),子路径部署下刷新即 404。
+ *   正确做法:把应用内路径拼到 base 的 **pathname** 上再解析。
  */
+
+/** base 路径(去尾斜杠),如 '/hello-lit';根部署时为 '' */
+export function basePath() {
+  return new URL(document.baseURI).pathname.replace(/\/+$/, '');
+}
+
+/** 应用内路径(`/users` 这种)→ 保留部署前缀的绝对 URL(查询串 / hash 原样保留) */
+export function resolveUrl(path) {
+  const base = basePath();
+  const q = path.indexOf('?');
+  const h = path.indexOf('#');
+  let cut = path.length;
+  if (q !== -1) cut = Math.min(cut, q);
+  if (h !== -1) cut = Math.min(cut, h);
+  let p = path.slice(0, cut);
+  const rest = path.slice(cut);            // ?query#hash 原样带过去
+  if (!p.startsWith('/')) p = '/' + p;     // 容错:允许传 'users' 或 '/users'
+  return new URL(base + p + rest, location.origin);
+}
+
+/** 跳转:pushState 后手动派发 popstate,让订阅者感知(History 原生不触发)。 */
 export function navigate(path, { replace = false } = {}) {
-  const url = new URL(path, document.baseURI);
+  const url = resolveUrl(path);
   if (replace) history.replaceState({}, '', url);
   else history.pushState({}, '', url);
   window.dispatchEvent(new PopStateEvent('popstate'));
 }
 
-/** 当前路径(去掉 base 前缀,供路由匹配) */
+/** 当前路径:剥掉部署前缀,返回应用内路径(始终以 / 开头)。 */
 export function currentPath() {
-  const base = new URL(document.baseURI).pathname.replace(/\/$/, '');
-  return location.pathname.replace(base, '') || '/';
+  const base = basePath();
+  let path = location.pathname;
+  if (base && path.startsWith(base)) path = path.slice(base.length);
+  if (!path.startsWith('/')) path = '/' + path;
+  return path.replace(/\/{2,}/g, '/') || '/';   // 折叠 '//users'
+}
+
+/** 路径 → 查询参数对象 */
+export function currentQuery() {
+  return Object.fromEntries(new URLSearchParams(location.search));
+}
+
+/** 应用内绝对链接(供真实 <a href> 使用,保留「新标签打开」能力) */
+export function href(path) {
+  return resolveUrl(path);
 }

+ 1 - 1
skills/fmode-code-lit/README.md

@@ -75,7 +75,7 @@ OBS_BUCKET=obs://<bucket> bash scripts/deploy.sh my-admin
 
 ```
 lit@3.3.3  lit-html@3.3.3  lit-element@4.2.2  @lit/reactive-element@2.1.2
-@lit/context@1.1.6  @lit-labs/signals@0.3.0  signal-polyfill@0.2.2  @lit-labs/router@0.1.4
+@lit/context@1.1.6  @lit-labs/signals@0.3.0  signal-polyfill@0.2.2
 ```
 
 > `@lit-labs/signals` 依赖 `signal-polyfill`(importmap 需映射,入口 `dist/index.js`)。

+ 3 - 3
skills/fmode-code-lit/SKILL.md

@@ -32,7 +32,7 @@ tags: [未来飞马, 智能体技能, 超级技能, 开发级, 前端, Lit, WebC
 
 教 AI 用 **无构建 / 无打包 / 纯原生 ESM + Lit 3.x** 的方式开发前端:源码即产物,浏览器直接跑,
 依赖走 **FMODE Repose** 统一托管,业务码走 **OBS + CDN**。分层(组件 / 服务 / 工具)、
-`@lit-labs/signals` 响应式、`@lit/context` 依赖注入、`@lit-labs/router` History 路由、后台多标签 KeepAlive。
+`@lit-labs/signals` 响应式、`@lit/context` 依赖注入、自写 `utils/router.js` History 路由(子路径部署**禁用** `@lit-labs/router`)、后台多标签 KeepAlive。
 
 > 完整规则见 `fmode-code-lit` **规则仓**(本技能随仓发布)。
 > 本技能负责「**初始化 + 生成代码**」,规则仓负责「**规范细则**」。
@@ -120,14 +120,14 @@ bash scripts/deploy.sh <project>   # obsutil 同步 src/ → OBS → CDN 刷新
 | 全局状态 | `@lit-labs/signals` 的 signal + `SignalWatcher` |
 | 组件本地状态 | `static properties`(`state: true`) |
 | 依赖注入 | `@lit/context` 的 `provide()` + `ContextConsumer`(**非** `@lit-labs/context`,已废弃) |
-| 路由 | `@lit-labs/router`,History 干净 URL |
+| 路由 | 自写 `utils/router.js`,History 干净 URL(子路径部署**禁用** `@lit-labs/router`) |
 | 子目录部署 | 必须 `<base href="/<project>/">` |
 
 ## 依赖版本(2026-10 实测可用)
 
 ```
 lit@3.3.3  lit-html@3.3.3  lit-element@4.2.2  @lit/reactive-element@2.1.2
-@lit/context@1.1.6  @lit-labs/signals@0.3.0  signal-polyfill@0.2.2  @lit-labs/router@0.1.4
+@lit/context@1.1.6  @lit-labs/signals@0.3.0  signal-polyfill@0.2.2
 ```
 
 > 注意:`@lit-labs/signals` 依赖 **`signal-polyfill`**,importmap 必须映射它(入口 `dist/index.js`)。

+ 2 - 2
skills/fmode-code-lit/scripts/install-rules.mjs

@@ -49,7 +49,7 @@ if (srcRules) {
 } else {
   console.log('\n未找到规则仓,写入内置最小规则集。');
   const minimal = {
-    'lit-no-build.md': `# Lit 无构建前端 · 最小规则\n\n1. 源码即产物:不引入任何打包器。\n2. 依赖由 importmap 指向 FMODE Repose,禁止拷进项目。\n3. **禁用装饰器**(@customElement/@property/@state/@consume)——用 static properties + customElements.define + ContextConsumer。\n4. 分层:组件(渲染)→服务(业务/状态)→工具(纯函数);组件禁 fetch,服务禁 DOM。\n5. 相对导入必须带 .js 后缀。\n6. 全局状态用 @lit-labs/signals;组件本地状态用 static properties(state:true)。\n7. 子目录部署必须 <base href=\"/<project>/\">。\n8. CDN History 回退:不存在路径 200 返回 index.html,禁 302。\n`,
+    'lit-no-build.md': `# Lit 无构建前端 · 最小规则\n\n1. 源码即产物:不引入任何打包器。\n2. 依赖由 importmap 指向 FMODE Repose,禁止拷进项目。\n3. **禁用装饰器**(@customElement/@property/@state/@consume)——用 static properties + customElements.define + ContextConsumer。\n4. 分层:组件(渲染)→服务(业务/状态)→工具(纯函数);组件禁 fetch,服务禁 DOM。\n5. 相对导入必须带 .js 后缀。\n6. 全局状态用 @lit-labs/signals;组件本地状态用 static properties(state:true)。\n7. 子目录部署必须 <base href=\"/<project>/\">,且与 OBS 前缀、importmap 前缀三者同源。\n8. 导航一律走 utils/router.js(把应用内路径拼到 base 的 pathname 上);禁止 new URL(path, baseURI),子路径部署会丢前缀。\n9. 子路径部署禁用 @lit-labs/router(全局 <a> 拦截 + 未剥前缀 pathname 会污染路由)。\n10. CDN History 回退:不存在路径 200 返回 index.html,禁 302。\n`,
   };
   for (const [name, content] of Object.entries(minimal)) {
     const d = join(destRules, name);
@@ -60,7 +60,7 @@ if (srcRules) {
 
 // CLAUDE.md 片段(追加式,不覆盖已有正文)
 const claudeMd = join(target, 'CLAUDE.md');
-const snippet = `\n## 前端(Lit 原生 ESM 无构建)\n\n本项目前端遵循 \`.claude/rules/\` 下的 Lit 无构建规则:源码即产物、依赖走 importmap、\n分层(组件/服务/工具)、**禁用装饰器**、@lit-labs/signals 响应式、@lit-labs/router History 路由。\n详见 \`.claude/rules/architecture.md\` 与 \`.claude/rules/components.md\`。\n`;
+const snippet = `\n## 前端(Lit 原生 ESM 无构建)\n\n本项目前端遵循 \`.claude/rules/\` 下的 Lit 无构建规则:源码即产物、依赖走 importmap、\n分层(组件/服务/工具)、**禁用装饰器**、@lit-labs/signals 响应式、自写 utils/router.js History 路由(子路径部署禁用 @lit-labs/router)。\n详见 \`.claude/rules/architecture.md\` 与 \`.claude/rules/components.md\`。\n`;
 if (existsSync(claudeMd)) {
   const cur = readFileSync(claudeMd, 'utf8');
   if (!cur.includes('Lit 原生 ESM 无构建')) { writeFileSync(claudeMd, cur + snippet); console.log(`  append ${claudeMd}`); }

+ 66 - 17
skills/fmode-code-lit/scripts/scaffold.mjs

@@ -32,7 +32,7 @@ function write(rel, content) {
 
 const DEP_VERSIONS = {
   lit: '3.3.3', 'lit-html': '3.3.3', 'lit-element': '4.2.2', '@lit/reactive-element': '2.1.2',
-  '@lit/context': '1.1.6', '@lit-labs/signals': '0.3.0', 'signal-polyfill': '0.2.2', '@lit-labs/router': '0.1.4',
+  '@lit/context': '1.1.6', '@lit-labs/signals': '0.3.0', 'signal-polyfill': '0.2.2',
 };
 
 console.log(`\n生成 Lit 无构建项目:${target}\n项目名:${project}\n`);
@@ -75,7 +75,6 @@ write('src/importmap.json', JSON.stringify({
     '@lit/context': `/npm/@lit/context/v${DEP_VERSIONS['@lit/context']}/index.js`,
     '@lit-labs/signals': `/npm/@lit-labs/signals/v${DEP_VERSIONS['@lit-labs/signals']}/index.js`,
     'signal-polyfill': `/npm/signal-polyfill/v${DEP_VERSIONS['signal-polyfill']}/dist/index.js`,
-    '@lit-labs/router': `/npm/@lit-labs/router/v${DEP_VERSIONS['@lit-labs/router']}/index.js`,
   },
 }, null, 2) + '\n');
 
@@ -167,36 +166,86 @@ export const http = {
 };
 `);
 
-// utils/router.js
+// utils/router.js —— 子路径部署安全(不要用 new URL(path, baseURI),会丢部署前缀)
 write('src/utils/router.js', `/**
- * 基于 document.baseURI 的导航,尊重 <base href>。
+ * utils/router.js —— 子路径部署安全的导航(只用 History API,干净 URL)。
+ *
+ * ⚠️ 不要写 new URL(path, document.baseURI) 做导航:path 以 '/' 开头时,
+ *   相对解析会丢掉 base 的路径前缀(/proj/users -> /users),子路径部署刷新即 404。
+ *   正确做法:把应用内路径拼到 base 的 pathname 上再解析。
+ *   详见规则仓 .claude/rules/routing-best-practices.md
  */
+export function basePath() {
+  return new URL(document.baseURI).pathname.replace(/\\/+$/, '');
+}
+export function resolveUrl(path) {
+  const base = basePath();
+  const q = path.indexOf('?'), h = path.indexOf('#');
+  let cut = path.length;
+  if (q !== -1) cut = Math.min(cut, q);
+  if (h !== -1) cut = Math.min(cut, h);
+  let p = path.slice(0, cut);
+  const rest = path.slice(cut);        // 查询串 / hash 原样保留
+  if (!p.startsWith('/')) p = '/' + p;
+  return new URL(base + p + rest, location.origin);
+}
 export function navigate(path, { replace = false } = {}) {
-  const url = new URL(path, document.baseURI);
+  const url = resolveUrl(path);
   if (replace) history.replaceState({}, '', url); else history.pushState({}, '', url);
-  window.dispatchEvent(new PopStateEvent('popstate'));
+  window.dispatchEvent(new PopStateEvent('popstate'));   // History 原生不触发
 }
 export function currentPath() {
-  const base = new URL(document.baseURI).pathname.replace(/\\/$/, '');
-  return location.pathname.replace(base, '') || '/';
+  const base = basePath();
+  let path = location.pathname;
+  if (base && path.startsWith(base)) path = path.slice(base.length);
+  if (!path.startsWith('/')) path = '/' + path;
+  return path.replace(/\\/{2,}/g, '/') || '/';   // 折叠 '//'
+}
+export function currentQuery() {
+  return Object.fromEntries(new URLSearchParams(location.search));
 }
+export function href(path) { return resolveUrl(path); }   // 供真实 <a href>
 `);
 
-// components/app-shell.js
+// components/app-shell.js —— 不用 @lit-labs/router(子路径部署会污染路由状态)
 write('src/components/app-shell.js', `/**
- * 布局壳:侧边菜单 + 路由出口。多标签版本见规则仓 tabs-keepalive.md。
+ * 布局壳:侧边菜单 + 内容出口。多标签版本见规则仓 tabs-keepalive.md。
+ *
+ * ⚠️ 不用 @lit-labs/router:它装全局 <a> 拦截器且用未剥前缀的 location.pathname
+ *   匹配路由,子路径部署下会污染路由状态。改为订阅 popstate + currentPath()。
  */
 import { LitElement, html, css } from 'lit';
-import { Router } from '@lit-labs/router';
+import { currentPath, navigate } from '../utils/router.js';
 import './page-home.js';
 
+const ROUTES = [{ path: '/', tag: 'page-home', title: '首页' }];
+
 export class AppShell extends LitElement {
   static styles = css\`:host{display:block;padding:16px}\`;
-  #router = new Router(this, [
-    { path: '/', render: () => html\`<page-home></page-home>\` },
-    { path: '/*', render: () => html\`<page-home></page-home>\` },
-  ]);
-  render() { return html\`<main>\${this.#router.outlet()}</main>\`; }
+  static properties = { _path: { state: true } };
+
+  #onPop = () => { this._path = currentPath(); };
+
+  connectedCallback() {
+    super.connectedCallback();
+    this._path = currentPath();
+    window.addEventListener('popstate', this.#onPop);
+  }
+  disconnectedCallback() {
+    window.removeEventListener('popstate', this.#onPop);
+    super.disconnectedCallback();
+  }
+
+  render() {
+    const hit = ROUTES.find((r) => r.path === this._path) || ROUTES[ROUTES.length - 1];
+    return html\`
+      <nav>
+        \${ROUTES.map((r) => html\`<button @click=\${() => navigate(r.path)}>\${r.title}</button>\`)}
+      </nav>
+      <main>\${this.#view(hit)}</main>
+    \`;
+  }
+  #view(r) { return document.createElement(r.tag); }
 }
 customElements.define('app-shell', AppShell);
 `);
@@ -229,7 +278,7 @@ write('package.json', JSON.stringify({
   scripts: { serve: 'npx --yes serve src', deploy: 'bash scripts/deploy.sh ' + project },
   devDependencies: {
     lit: '^' + DEP_VERSIONS.lit, '@lit/context': '^' + DEP_VERSIONS['@lit/context'],
-    '@lit-labs/signals': '^' + DEP_VERSIONS['@lit-labs/signals'], '@lit-labs/router': '^' + DEP_VERSIONS['@lit-labs/router'],
+    '@lit-labs/signals': '^' + DEP_VERSIONS['@lit-labs/signals'],
   },
 }, null, 2) + '\n');