Skip to content

Commit 742b892

Browse files
committed
feat(shell): add navigation registry for 9 pages
1 parent 4c89c68 commit 742b892

4 files changed

Lines changed: 277 additions & 41 deletions

File tree

‎app/src/App.tsx‎

Lines changed: 24 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -17,29 +17,13 @@ import { listen } from "@tauri-apps/api/event";
1717
import { invoke } from "@tauri-apps/api/core";
1818
import { confirm } from "@tauri-apps/plugin-dialog";
1919
import { getCurrentWindow } from "@tauri-apps/api/window";
20-
// 批 2 包体治理:课堂页(默认页)**保持静态 import**。
21-
// @ai-context: 它是首屏必渲染的页面 —— 改成 lazy 只是把同一批字节挪进动态 chunk,
22-
// 首屏仍要多一次 chunk 往返,且预算读数会失真(控制方 2026-09-12 裁决 2 末条认可该判断)。
23-
import ClassroomPage from "./pages/ClassroomPage";
24-
// 批 2 包体治理:其余 8 个页面从静态 import 改为按页动态 import。
25-
// @ai-context: 「保留挂载(TD-004)」的语义按页保留 —— **访问过的页常驻、永不卸载**;
26-
// 只是「从未访问过的页」不再进入首屏 module graph。
27-
// @ai-context: 不引入路由库(规格 §3 红线 2):入口仍是 useState<Page> + NAV_ITEMS,
28-
// 只是每个页面成为一个独立 chunk。
29-
const NotesPage = lazy(() => import("./pages/NotesPage"));
30-
const SessionsPage = lazy(() => import("./pages/SessionsPage"));
31-
// v0.20.5:行动域页(做——行动中心独立成页,意图分层)
32-
const ActionPage = lazy(() => import("./pages/ActionPage"));
33-
// v0.20.10(批 5,用户问题 6):复习域页(练——复习面独立顶层 Tab;spec §9 二期兑现)
34-
const ReviewPage = lazy(() => import("./pages/ReviewPage"));
35-
// v0.16.0:AI 对话页(纯聊天 + AI 任务对话视图——DSH 交互范式)
36-
const ChatPage = lazy(() => import("./pages/ChatPage"));
37-
// 2026-08-21 用户需求:设置页(课堂助手设置类面板迁出,单页滚动+分组)
38-
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
39-
// v0.13.1:知识体系页(三时钟纪律——体系进周/季度视图,不入每日复习面)
40-
const KnowledgePage = lazy(() => import("./pages/KnowledgePage"));
41-
// v0.18.0:学习目标页(意图层——独立 Tab,零叙事元素)
42-
const GoalsPage = lazy(() => import("./pages/GoalsPage"));
20+
// 批 3 T6:9 个页面组件改由导航注册表提供(**单一真源**,规格 §10 批 3「9 页全走注册表」)。
21+
// @ai-context: 本文件从此**不 import 任何页面模块** —— 「课堂页静态 import + 其余 8 页按页
22+
// lazy import」的包体纪律(批 2 包体治理)整段搬进 `shell/navRegistry.ts`;
23+
// 「从未访问过的页不进首屏 module graph」与「访问过的页常驻、永不卸载(TD-004)」两条语义
24+
// 都逐字保留(保活由下面的 PageSlot + mountedPages 实施,本任务未动它们一个字节)。
25+
// @ai-context: 仍不引入路由库(规格 §3 红线 2):入口依旧是 useState<Page>。
26+
import { ALL_ENTRIES, navComponent, type PageKey } from "./shell/navRegistry";
4327
// REQ-274(v0.19.4):全局 AI 对话面板(丙案——按需唤起 + 内容保活)
4428
// 批 2 包体治理:从静态 import 改为按需 import,并在它之前加一道「首开挂载」闸门。
4529
// @ai-context: 原语义(见文件末尾 dock 渲染处的注释)是「常驻挂载——开合仅切 display,
@@ -67,24 +51,24 @@ import { CaptureStatusProvider, useCaptureControl } from "./hooks/useLiveCapture
6751
import { pauseReasonLabel } from "./hooks/liveCaptureState";
6852
import type { AiTaskState } from "./types";
6953

70-
type Page = "classroom" | "sessions" | "notes" | "action" | "review" | "chat" | "knowledge" | "goals" | "settings";
54+
// 页面键 = 注册表键集(批 3 T6:从前是 9 个字面量的手写联合,现在从注册表派生)
55+
type Page = PageKey;
7156

72-
const NAV_ITEMS: { key: Page; label: string }[] = [
73-
{ key: "classroom", label: "📡 课堂助手" },
74-
{ key: "sessions", label: "🗂 会话" },
75-
{ key: "notes", label: "📝 笔记" },
76-
// v0.20.5:行动域页(做——行动裁决/SOP/练习/问题;独立 Tab 唯一入口)
77-
{ key: "action", label: "✅ 行动" },
78-
// v0.20.10(批 5):复习域页(练——到期感知唯一入口,无被动提醒;
79-
// 顶层 Tab=9 触发 spec §9 导航收纳观察项,收纳设计另行立项)
80-
{ key: "review", label: "🔄 复习" },
81-
// v0.16.0:AI 对话页(纯聊天 + AI 任务对话视图)
82-
{ key: "chat", label: "💬 AI 对话" },
83-
{ key: "knowledge", label: "🧠 体系" },
84-
// v0.18.0:学习目标(意图层——目标=组的容器,N:M 绑定)
85-
{ key: "goals", label: "🎯 目标" },
86-
{ key: "settings", label: "⚙ 设置" },
87-
];
57+
// 9 个页面组件取自注册表:`navComponent` 保留各自**精确的 props 类型**,故下面 9 段
58+
// <PageSlot> JSX 与本任务之前逐字相同,只换了标识符的来源(注册表不得包 wrapper:
59+
// 组件标识必须稳定,否则每次渲染都会重挂载、保活失效)。
60+
const ClassroomPage = navComponent("classroom");
61+
const SessionsPage = navComponent("sessions");
62+
const NotesPage = navComponent("notes");
63+
const ActionPage = navComponent("action");
64+
const ReviewPage = navComponent("review");
65+
const ChatPage = navComponent("chat");
66+
const KnowledgePage = navComponent("knowledge");
67+
const GoalsPage = navComponent("goals");
68+
const SettingsPage = navComponent("settings");
69+
70+
// 顶栏项 = 注册表 map 出来(label 暂用 legacyLabel:T6 零观感变化,T7 切到 e.label 并删该列)
71+
const NAV_ITEMS: { key: Page; label: string }[] = ALL_ENTRIES.map((e) => ({ key: e.key, label: e.legacyLabel }));
8872

8973
function App() {
9074
// URL per-window 标志早返回(不渲染主导航壳)。批 2b 采集控制单一状态源:

‎app/src/shell/navRegistry.test.ts‎

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* @ai-context 导航注册表守卫 —— 规格 §10 批 3 行「9 页全走注册表」的**机器判据**。
3+
*
4+
* Why 用 vitest 而不是 `scripts/*.mjs`:`scripts/*.mjs` 不在 `line-limits` 扫描域
5+
* (批 2 follow-up #16),而 `app/src/**` 在 ⇒ 守卫自己也被 300 行红线看着。
6+
*
7+
* 判据分五层,每层都能**独立变红**、报错各自点名(每层的红都有对应的变异体实测,见 T6 报告):
8+
* ① 键集:组件表键集 = 注册表键集(9)· 唯一 · `pages/` 下每个非测试 `.tsx` 都被注册
9+
* (枚举目录、不靠手写清单;且注册表**实际 import 的页面模块路径**必须与目录枚举一致);
10+
* ② 接线:每个 key 在 `App.tsx` 里确有 `page === "key"` —— 且 `App.tsx` **不直接 import 任何
11+
* 页面模块**、组件一律经 `navComponent("key")` 取得(这是「全走注册表」的本体);
12+
* ③ 图标:9 个图标名都在 `ui/icons` 注册表里且互不相同(拼错必须报错,不许渲染成空白);
13+
* ④ 取值口:`navEntry` / `isPageKey` 的**正样本 + 阴性样本**;
14+
* ⑤ 不变量:顶栏顺序与 label 逐字冻结(T6 不许有观感变化)· `mountedPages` **只增不减**
15+
* (批 2 裁决 2 的保活硬要求:访问过的页面永不卸载)。
16+
*
17+
* 口径:页面文件枚举 = `pages/*.tsx` 去掉 `*.test.tsx`(今日恰好 9 个)。
18+
* ⇒ 将来加第 10 个页面文件而没登记,① 当场红(这正是「全走注册表」的意思)。
19+
*/
20+
import { readdirSync, readFileSync } from "node:fs";
21+
import { dirname, join } from "node:path";
22+
import { fileURLToPath } from "node:url";
23+
import { describe, expect, it } from "vitest";
24+
import { ALL_ENTRIES, NAV_ENTRIES, PAGE_COMPONENTS, isPageKey, navComponent, navEntry, type PageKey } from "./navRegistry";
25+
import { ICON_PATHS } from "../ui/icons";
26+
27+
const SRC = join(dirname(fileURLToPath(import.meta.url)), "..");
28+
const APP_TSX = join(SRC, "App.tsx");
29+
const REGISTRY_TS = join(SRC, "shell", "navRegistry.ts");
30+
const PAGES = join(SRC, "pages");
31+
const appSource = () => readFileSync(APP_TSX, "utf8");
32+
33+
/** 页面文件(不含测试)—— 从目录枚举,不手写清单 */
34+
function pageFiles(): string[] {
35+
return readdirSync(PAGES)
36+
.filter((f) => f.endsWith(".tsx") && !f.endsWith(".test.tsx"))
37+
.map((f) => f.replace(/\.tsx$/, ""))
38+
.sort();
39+
}
40+
41+
/** 顶栏 8 项的**冻结**顺序与 label(逐字抄自 T6 之前的 `NAV_ITEMS`;T7 切到 `e.label` 时同提交改这里) */
42+
const FROZEN_TOP_BAR: readonly (readonly [PageKey, string])[] = [
43+
["classroom", "📡 课堂助手"],
44+
["sessions", "🗂 会话"],
45+
["notes", "📝 笔记"],
46+
["action", "✅ 行动"],
47+
["review", "🔄 复习"],
48+
["chat", "💬 AI 对话"],
49+
["knowledge", "🧠 体系"],
50+
["goals", "🎯 目标"],
51+
];
52+
53+
describe("导航注册表(规格 §10 批 3「9 页全走注册表」)", () => {
54+
it("① 注册表 9 项、键唯一", () => {
55+
expect(ALL_ENTRIES).toHaveLength(9);
56+
expect(new Set(ALL_ENTRIES.map((e) => e.key)).size).toBe(9);
57+
});
58+
59+
it("① 组件表键集 = 注册表键集(两张表不许各自另立清单,双向都判)", () => {
60+
expect(ALL_ENTRIES.map((e) => e.key).slice().sort()).toEqual(Object.keys(PAGE_COMPONENTS).slice().sort());
61+
});
62+
63+
it("① pages/ 下每个非测试 .tsx 都在注册表里(孤儿页面 = 没走注册表)", () => {
64+
const declared = new Set(ALL_ENTRIES.map((e) => `${e.key[0].toUpperCase()}${e.key.slice(1)}Page`));
65+
const orphans = pageFiles().filter((f) => !declared.has(f));
66+
expect(orphans, `这些页面文件没有登记进注册表:\n${orphans.join("\n")}`).toEqual([]);
67+
});
68+
69+
it("① 注册表实际 import 的页面模块 = pages/ 目录枚举(第二判据:名字对不上也算没接线)", () => {
70+
const referenced = [...readFileSync(REGISTRY_TS, "utf8").matchAll(/["']\.\.\/pages\/([A-Za-z0-9_]+)["']/g)]
71+
.map((m) => m[1])
72+
.sort();
73+
// 枚举一侧非空(今日 9 个)⇒ 抽取器失效会以「差集非空」的形式变红,而不是静默判绿
74+
expect(pageFiles().length).toBe(9);
75+
expect(referenced).toEqual(pageFiles());
76+
});
77+
78+
it("② 每个 key 都在 App.tsx 里被渲染(注册了但没接线 = 点不到)", () => {
79+
const app = appSource();
80+
const missing = ALL_ENTRIES.filter((e) => !app.includes(`page === "${e.key}"`)).map((e) => e.key);
81+
expect(missing, `这些 key 在 App.tsx 里没有对应的 page === 判断:\n${missing.join("\n")}`).toEqual([]);
82+
});
83+
84+
it("② App.tsx 不直接 import 任何页面模块(页面只能经注册表到达)", () => {
85+
const direct = [...appSource().matchAll(/["']\.\.?\/pages\/[A-Za-z0-9_]+["']/g)].map((m) => m[0]);
86+
expect(direct, `App.tsx 仍在直连页面模块:\n${direct.join("\n")}`).toEqual([]);
87+
});
88+
89+
it("② App.tsx 的 9 个页面组件标识符都取自 navComponent(key)", () => {
90+
const app = appSource();
91+
const missing = ALL_ENTRIES.filter((e) => !app.includes(`navComponent("${e.key}")`)).map((e) => e.key);
92+
expect(missing, `这些 key 没有从注册表取组件:\n${missing.join("\n")}`).toEqual([]);
93+
expect(app).toContain('type Page = PageKey;');
94+
});
95+
96+
it("③ 图标名都在 ui/icons 注册表里,且 9 页各用一个(不重复)", () => {
97+
const unknown = ALL_ENTRIES.filter((e) => !(e.icon in ICON_PATHS)).map((e) => `${e.key} → ${e.icon}`);
98+
expect(unknown, `这些图标名没注册:\n${unknown.join("\n")}`).toEqual([]);
99+
expect(new Set(ALL_ENTRIES.map((e) => e.icon)).size).toBe(9);
100+
});
101+
102+
it("④ navEntry / isPageKey:9 个 key 全是正样本,无意义串是阴性样本", () => {
103+
for (const e of ALL_ENTRIES) {
104+
expect(navEntry(e.key)).toBe(e);
105+
expect(isPageKey(e.key)).toBe(true);
106+
// 注册表条目持有的组件必须与取值口**同一个引用**(否则渲染路径与注册表脱钩、且每次渲染可能重挂载)
107+
expect(navComponent(e.key)).toBe(e.Component);
108+
}
109+
expect(isPageKey("not-a-page")).toBe(false);
110+
expect(isPageKey("")).toBe(false);
111+
expect(() => navEntry("not-a-page" as PageKey)).toThrow(/未注册的页面键/);
112+
});
113+
114+
it("⑤ 顶栏 8 项顺序 + label 逐字冻结(T6 零观感变化),设置项只在注册表不在顶栏", () => {
115+
expect(NAV_ENTRIES.map((e) => [e.key, e.legacyLabel])).toEqual(FROZEN_TOP_BAR);
116+
expect(ALL_ENTRIES.map((e) => e.key)).toEqual([...FROZEN_TOP_BAR.map(([k]) => k), "settings"]);
117+
// 注:`NAV_ENTRIES` 的键是 8 个字面量,`e.key === "settings"` 会被 tsc 判为「无重叠比较」
118+
// (TS2367)⇒ 先宽成 string[] 再比(判据不变,仍能红)
119+
expect(NAV_ENTRIES.map((e) => e.key as string)).not.toContain("settings");
120+
expect(ALL_ENTRIES.map((e) => e.key as string)).toContain("settings");
121+
});
122+
123+
it("⑤ 顶栏 label 不再在 App.tsx 里重复(同一事实不再写两遍)", () => {
124+
const app = appSource();
125+
// 判据必须是**引号定界的字面量**,不能用裸 `includes`:App.tsx 的注释里本来就有
126+
// 「笔记」「会话」这些词(批 1 实测 6 例子串误判),裸 includes 会把注释判成重复。
127+
const duplicated = ALL_ENTRIES.filter((e) => new RegExp(`["'\`]${e.legacyLabel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["'\`]`).test(app)).map(
128+
(e) => e.key,
129+
);
130+
expect(duplicated, `这些 label 仍在 App.tsx 里硬编码:\n${duplicated.join("\n")}`).toEqual([]);
131+
});
132+
133+
it("⑤ 保活不变量:mountedPages 只增不减,PageSlot 用 display 门控而非卸载", () => {
134+
const app = appSource();
135+
// 只增的更新式(逐字)—— 换成「按当前页重建集合」即「切走即卸载」,是本仓禁止形态
136+
expect(app).toContain("prev.has(page) ? prev : new Set(prev).add(page)");
137+
expect(app).toContain("if (!mounted) return null;");
138+
expect(app).toContain('display: show ? "block" : "none"');
139+
expect(app).not.toMatch(/mountedPages\.delete\(/);
140+
expect(app).not.toMatch(/setMountedPages\(new Set\(\[page\]\)\)/);
141+
expect(app).not.toMatch(/setMountedPages\(\(\) =>/);
142+
});
143+
});

0 commit comments

Comments
 (0)