Files
jiapuapp/docs/家谱项目全量治理实施计划.md
T
2026-07-22 17:31:38 +08:00

93 KiB
Raw Blame History

导航栈统一与大规模世系树实施计划

执行要求: 实施时使用 superpowers:executing-plans 按任务顺序执行,并在每个批次结束后由主代理、Lagrange、Bernoulli 三人复核;宣称批次或阶段完成前必须使用 superpowers:verification-before-completion。步骤使用复选框跟踪。用户已明确授权三人依据证据直接确定技术方案,不再为可从页面、接口和现有合同推出的细节反复请示。

日期:2026-07-22
状态:阶段 0 已完成;导航栈设计与 T01 大规模世系树设计已完成三人终审;业务代码尚未开始迁移

目标: 先以测试先行方式消除全项目导航栈歧义,再在新后端图窗口合同通过后,把 T01 建成可稳定阅读几十代、几百代且连接线连续的大规模世系图。

架构: 导航阶段只新增两个所有者:utils/navigation-routes.js 持有活动路由语义,utils/navigation.js 持有全部 Uni 导航调用;页面按认证、G、T、F、R、N/M 独立迁移。导航阶段完全验收后,T01 阶段先锁定 Apifox 的规范图窗口合同,再按“规范化与校验 → 可见投影 → 布局 → Scene 与空间索引 → 单 Canvas 视口 → 页面交互”实施;两个阶段之间设置不可跨越的硬门禁。

技术栈: UniApp、Vue 3、JavaScript、SCSS、PowerShell、Node.js、OpenAPI 3.0.1、Apifox、MuMu Android、ADB、App-vue renderjs、Canvas 2D。

全局约束

  • 全程使用中文沟通;当前五份权威文档继续使用中文文件名和中文内容,不新增并行计划入口。
  • 新增或修改的复杂 JavaScript、Vue、SCSS 和测试逻辑必须有详细中文注释,说明当前步骤、输入、状态变化、失败处理与下一步;注释不得掩盖职责混乱的大函数。
  • 代码只实现当前合同需要的最小能力;不得复制路由、参数、图关系、布局或密码规则,不建立“以后也许有用”的抽象。
  • 主代理负责唯一写入;Lagrange 与 Bernoulli 均完整复核页面美观、业务、接口、交互、测试和 MuMu 证据,三人互相补漏,不割裂成三个单项岗位。
  • 不新增第四位代理,不创建新的专家角色。
  • 不执行 Git add、commit、push、restore、checkout、reset 或其他 Git 变更命令。
  • 不改写 APP.openapi.jsonAPP.openapi.yaml;Apifox 是接口唯一源头,两份本地文件只接受用户重新导出覆盖。
  • 不启动、关闭 MuMu,不调整模拟器窗口缩放比例、设备分辨率、方向或系统字体;只使用当前 emulator-5554 / 720×1280 / 320dpi。T01 页面内双指缩放属于必须验证的产品功能,不属于调整模拟器。
  • 浏览器运行时测试可以验证逻辑,但不能替代 MuMu 视觉和真实 Android 返回验收。
  • 每次代码修改后依次运行本任务聚焦合同、tests/project-responsive-layout-contract.ps1tests/compile-audit.ps1,再在 MuMu 复核受影响流程;阶段关闭前运行全部 tests/*.ps1
  • 不重新进行响应式迁移,不对单一尺寸写页面补丁,不压缩字号、行高或控件尺寸掩盖问题。
  • 普通内容高度由内容决定;弹窗只允许视口 max-height 和内部滚动。
  • G01—G10 只有在本阶段测试或 MuMu 提供明确全局回归证据时才改动,且只改与当前阶段直接相关的行。
  • 用户可见页面提示使用“某某某堂侄”等通用表达;fixture 与 mock 中的姓名不做无关清理。
  • 导航阶段允许只迁移 T01 的现有导航调用;导航门禁通过前,不得实施 T01 新图合同、投影、布局、Scene、Canvas 或新接口接入。T01 完成前不实施短信状态机、领域持久化或全局文字层级第二轮。

阶段硬门禁

  1. 导航门禁: 页面和活动组件直接调用五种 Uni 导航 API 的扫描为零,52 条注册路由精确闭合,导航全量合同通过,MuMu 完成进入、返回、取消、完成和重复进入矩阵。
  2. T01 接口门禁: 用户重新导出的 JSON/YAML 同时包含规范 LineageGraphWindow、概览和定位接口,图合同测试通过;当前递归合同不得由客户端兼容。
  3. T01 几何门禁: 103→106、普通节点 4rpx 间隙和 10×12 多父干线三个已知缺陷先红后绿,十条“连线永不断”不变量全部自动验证。
  4. 阶段隔离: 任一门禁未通过时只修复本阶段问题,不提前混入下一阶段代码。

文件职责图

导航阶段新增

  • utils/navigation-routes.js52 条活动路由的唯一语义注册表。
  • utils/navigation.js:唯一 Uni 导航网关、一次性结果和统一返回优先级。
  • tests/navigation-routes-contract.ps1:注册表、pages.json、参数和唯一所有权合同。
  • tests/navigation-gateway-runtime-smoke.js:在伪造 Uni 栈上验证网关算法。
  • tests/navigation-source-scan-contract.ps1:禁止页面和组件直接调用五种 Uni 导航 API。
  • tests/navigation-flow-contract.ps1:锁定各页面使用的语义方法与流程终点。

导航阶段修改

  • components/AppTabbar.vuecomponents/PageHeader.vuecomponents/ModulePage.vue
  • pages/auth/*.vuepages/genealogy/*.vuepages/tree/*.vuepages/family/*.vuepages/records/*.vuepages/notification/*.vuepages/profile/*.vue 中实际包含导航行为的文件。
  • 与上述行为直接对应的现有聚焦合同;旧的 fallbackUrl、原始 URL 和直接 Uni 调用断言必须同轮删除。

T01 阶段新增

  • utils/lineage/normalize.js:只把新图窗口响应规范化为内部图;不兼容旧递归结构。
  • utils/lineage/validate.js:图引用、主入边、版本、边界和 ID 的唯一运行时校验。
  • utils/lineage/project.js:从完整规范图生成当前可见真实节点与虚拟聚合节点,不修改源图。
  • utils/lineage/layout.js:消费可见投影的确定性 family-unit tidy-tree 世界坐标布局。
  • utils/lineage/scene.js:把布局转换为节点、文字、端点和连接器 Scene。
  • utils/lineage/spatial-index.js:视口裁剪、命中测试和路径穿越查询。
  • utils/lineage/camera.js:相机矩阵、LOD、缩放锚点和边界计算。
  • components/LineageViewport.vueApp-vue/H5 renderjs 与 mp-weixin Canvas 适配器;节点与线同画布、同矩阵、同帧。
  • components/LineageAccessibleList.vue:消费同一规范图的可搜索线性阅读模式。
  • tests/lineage-graph-contract-runtime-smoke.jstests/lineage-projection-runtime-smoke.jstests/lineage-layout-runtime-smoke.jstests/lineage-scene-runtime-smoke.jstests/t01-navigation-integration-contract.ps1tests/t01-large-lineage-contract.ps1

T01 阶段修改

  • utils/api.jsdata/mock.jspages/tree/t01-tree-overview.vue
  • pages/tree/t03-member-profile.vuepages/tree/t04-add-relative.vuepages/tree/t06-edit-relationship.vuepages/tree/t07-member-directory.vue 只修改新版本和焦点回流所必需的部分。
  • 现有 tests/t01-*.ps1tests/t01-*.js 中已经被新图合同替代的断言。

每个代码批次的固定验证

powershell -ExecutionPolicy Bypass -File tests/navigation-routes-contract.ps1
powershell -ExecutionPolicy Bypass -File tests/project-responsive-layout-contract.ps1
powershell -ExecutionPolicy Bypass -File tests/compile-audit.ps1

第一行以任务中明确列出的聚焦合同为准;上例是任务 1 的实际命令,其他任务均在各自步骤写出精确文件名。

阶段关闭时运行:

$failed = @()
Get-ChildItem -LiteralPath tests -File -Filter '*.ps1' | Sort-Object Name | ForEach-Object {
  & powershell -ExecutionPolicy Bypass -File $_.FullName
  if ($LASTEXITCODE -ne 0) { $failed += $_.Name }
}
if ($failed.Count -gt 0) { throw "失败合同:$($failed -join ', ')" }

第一阶段:导航栈语义统一

任务 1:建立路由语义注册表

文件:

  • 新建:utils/navigation-routes.js
  • 新建:tests/navigation-routes-contract.ps1
  • 修改:tests/interface-page-mapping-contract.ps1

接口:

  • 输出:ROUTESROOT_ROUTE_KEYSgetRoute(routeKey)getRouteKeyByPath(path)
  • 路由项:{ path, kind, parent, parentParamMap, requiredParams, optionalParams, allowedSources, resultOperations };所有数组和映射均冻结。
  • 路径统一以 /pages/... 表示;ID 参数均为字符串;sourceKey 是网关保留参数,不进入页面业务参数数组。

路由注册表必须完整采用下表,不得从页面中再复制第二份父子关系:

路由键 类型 规范父页 必填参数 可选业务参数 允许来源
A01 认证根页
A04 普通页 A01 A01
A05 普通页 A01 A01
G01 根页 genealogyId
G03 流程页 G01 genealogyId G01
G05 普通页 G01 genealogyId G01,G03,G06,G09
G06 普通页 G01 mode G01,G03,G09
G08 流程页 G06 genealogyId source G05,G06,G09
G09 普通页 G01 status G01,G06,G08
G10 普通页 G01 genealogyId G01,G05,N01,N02
G11 流程页 G05 genealogyId G05
G12 流程页 G05 genealogyId startGeneration,currentGeneration G01,G05
T01 普通页 G05 genealogyId selectedId G01,G05,T04,T06,T07
T03 单实例页 T01 genealogyId,personId T01,T07,R02
T04 流程页 T01 genealogyId personId,mode T01
T05 流程页 T03 genealogyId,personId T03
T06 流程页 T01 genealogyId,personId T01
T07 普通页 T01 genealogyId T01
T08 普通页 T03 genealogyId,personId T03
F01 根页 genealogyId
F02 流程页 F01 F01
F03 普通页 F01 feedId F01
F04 普通页 F01 F01
F05 普通页 F04 articleId F04
F06 流程页 F04 articleId,mode F04,F05
F07 普通页 F01 F01
F08 普通页 F07 albumId F07,F09
F09 流程页 F08 albumId F08
F10 普通页 F01 F01
R01 普通页 F01 F01
R02 流程页 R01 personId,mode R01
R03 普通页 F01 F01
R04 流程页 R03 giftId,mode R03
R05 普通页 F01 F01
R06 普通页 R05 ritualId R05
R07 流程页 R05 ritualId,mode R05,R06
R08 普通页 R02 personId R02,T03
R09 普通页 R02 personId R02,T03
R10 普通页 F01 F01
R11 普通页 F01 F01
N01 普通页 G01 genealogyId G01,M01
N02 普通页 N01 id N01
M01 根页
M02 流程页 M01 M01
M03 普通页 M01 M01
M04 流程页 M03 M03
M05 流程页 M03 M03
M06 普通页 M01 M01
M07 流程页 M06 M06
M08 普通页 M01 M01
M09 普通页 M01 M01
M10 普通页 M01 M01

直接进入后的规范父页参数默认复制父子路由同名的已声明参数;人物页回 T01 的 personId → selectedId 必须由 parentParamMap 显式声明,页面不得自行拼接回退参数。returnTo/finishPage 还必须显式接收目标参数,确保 G03→G05、F09→F08 等目标不在栈内时仍能构造合法 URL。

目标页允许消费的结果操作也只在注册表中定义:A01=password-resetG01=application-reviewed,generation-poems-updatedG05=genealogy-created,genealogy-settings-updated,generation-poems-updated,application-reviewedG09=application-createdT01=relative-created,relationship-updatedT03=member-open-requested,member-updatedF01=feed-createdF04=article-createdF05=article-updatedF08=media-uploadedR01=person-created,person-updated,person-deletedR03=gift-created,gift-updated,gift-deletedR05=ritual-created,ritual-updated,ritual-deletedN01/N02=application-reviewedM01=profile-updatedM03=password-changed,phone-changedM06=feedback-submitted。未列出的目标页 resultOperations 为空,任意其他字符串都必须在运行时拒绝。

  • 步骤 1:先写失败合同

tests/navigation-routes-contract.ps1 必须读取 pages.jsonutils/navigation-routes.js,断言 52 个路径集合和顺序精确相等、四个根语义恰为 A01/G01/F01/M01、父页与来源均存在、参数名不重复、parentParamMap 两端字段都合法、resultOperations 在目标页内唯一且非空字符串,并禁止 previousreturnUrlfallbackUrltargetUrlstatecountsaveResultstepgenealogyName 进入业务参数。

  • 步骤 2:确认红灯原因正确

运行:

powershell -ExecutionPolicy Bypass -File tests/navigation-routes-contract.ps1

预期:因 utils/navigation-routes.js 不存在而失败;不能因 PowerShell 语法或编码失败。

  • 步骤 3:实现最小注册表

注册表导出结构固定如下,52 项内容严格来自上表:

const defineRoute = (route) => Object.freeze({
  ...route,
  parentParamMap: Object.freeze(route.parentParamMap || {}),
  requiredParams: Object.freeze(route.requiredParams || []),
  optionalParams: Object.freeze(route.optionalParams || []),
  allowedSources: Object.freeze(route.allowedSources || []),
  resultOperations: Object.freeze(route.resultOperations || []),
});

export const ROUTES = Object.freeze({
  A01: defineRoute({ path: "/pages/auth/a01-entry", kind: "auth-root", parent: null, resultOperations: ["password-reset"] }),
  A04: defineRoute({ path: "/pages/auth/a04-register", kind: "page", parent: "A01", allowedSources: ["A01"] }),
  A05: defineRoute({ path: "/pages/auth/a05-reset-password", kind: "page", parent: "A01", allowedSources: ["A01"] }),
  G01: defineRoute({ path: "/pages/genealogy/g01-my-genealogies", kind: "root", parent: null, optionalParams: ["genealogyId"], resultOperations: ["application-reviewed", "generation-poems-updated"] }),
  G03: defineRoute({ path: "/pages/genealogy/g03-create-genealogy", kind: "flow", parent: "G01", optionalParams: ["genealogyId"], allowedSources: ["G01"] }),
  G05: defineRoute({ path: "/pages/genealogy/g05-genealogy-overview", kind: "page", parent: "G01", requiredParams: ["genealogyId"], allowedSources: ["G01", "G03", "G06", "G09"], resultOperations: ["genealogy-created", "genealogy-settings-updated", "generation-poems-updated", "application-reviewed"] }),
  G06: defineRoute({ path: "/pages/genealogy/g06-search-genealogies", kind: "page", parent: "G01", optionalParams: ["mode"], allowedSources: ["G01", "G03", "G09"] }),
  G08: defineRoute({ path: "/pages/genealogy/g08-join-application", kind: "flow", parent: "G06", requiredParams: ["genealogyId"], optionalParams: ["source"], allowedSources: ["G05", "G06", "G09"] }),
  G09: defineRoute({ path: "/pages/genealogy/g09-my-applications", kind: "page", parent: "G01", optionalParams: ["status"], allowedSources: ["G01", "G06", "G08"], resultOperations: ["application-created"] }),
  G10: defineRoute({ path: "/pages/genealogy/g10-application-review", kind: "page", parent: "G01", requiredParams: ["genealogyId"], allowedSources: ["G01", "G05", "N01", "N02"] }),
  G11: defineRoute({ path: "/pages/genealogy/g11-genealogy-settings", kind: "flow", parent: "G05", requiredParams: ["genealogyId"], allowedSources: ["G05"] }),
  G12: defineRoute({ path: "/pages/genealogy/g12-generation-poems", kind: "flow", parent: "G05", requiredParams: ["genealogyId"], optionalParams: ["startGeneration", "currentGeneration"], allowedSources: ["G01", "G05"] }),
  T01: defineRoute({ path: "/pages/tree/t01-tree-overview", kind: "page", parent: "G05", requiredParams: ["genealogyId"], optionalParams: ["selectedId"], allowedSources: ["G01", "G05", "T04", "T06", "T07"], resultOperations: ["relative-created", "relationship-updated"] }),
  T03: defineRoute({ path: "/pages/tree/t03-member-profile", kind: "single", parent: "T01", parentParamMap: { selectedId: "personId" }, requiredParams: ["genealogyId", "personId"], allowedSources: ["T01", "T07", "R02"], resultOperations: ["member-open-requested", "member-updated"] }),
  T04: defineRoute({ path: "/pages/tree/t04-add-relative", kind: "flow", parent: "T01", parentParamMap: { selectedId: "personId" }, requiredParams: ["genealogyId"], optionalParams: ["personId", "mode"], allowedSources: ["T01"] }),
  T05: defineRoute({ path: "/pages/tree/t05-edit-member", kind: "flow", parent: "T03", requiredParams: ["genealogyId", "personId"], allowedSources: ["T03"] }),
  T06: defineRoute({ path: "/pages/tree/t06-edit-relationship", kind: "flow", parent: "T01", parentParamMap: { selectedId: "personId" }, requiredParams: ["genealogyId", "personId"], allowedSources: ["T01"] }),
  T07: defineRoute({ path: "/pages/tree/t07-member-directory", kind: "page", parent: "T01", requiredParams: ["genealogyId"], allowedSources: ["T01"] }),
  T08: defineRoute({ path: "/pages/tree/t08-member-states", kind: "page", parent: "T03", requiredParams: ["genealogyId", "personId"], allowedSources: ["T03"] }),
  F01: defineRoute({ path: "/pages/family/f01-family-feed", kind: "root", parent: null, optionalParams: ["genealogyId"], resultOperations: ["feed-created"] }),
  F02: defineRoute({ path: "/pages/family/f02-publish-feed", kind: "flow", parent: "F01", allowedSources: ["F01"] }),
  F03: defineRoute({ path: "/pages/family/f03-feed-detail", kind: "page", parent: "F01", requiredParams: ["feedId"], allowedSources: ["F01"] }),
  F04: defineRoute({ path: "/pages/family/f04-article-list", kind: "page", parent: "F01", allowedSources: ["F01"], resultOperations: ["article-created"] }),
  F05: defineRoute({ path: "/pages/family/f05-article-detail", kind: "page", parent: "F04", requiredParams: ["articleId"], allowedSources: ["F04"], resultOperations: ["article-updated"] }),
  F06: defineRoute({ path: "/pages/family/f06-article-editor", kind: "flow", parent: "F04", optionalParams: ["articleId", "mode"], allowedSources: ["F04", "F05"] }),
  F07: defineRoute({ path: "/pages/family/f07-album-list", kind: "page", parent: "F01", allowedSources: ["F01"] }),
  F08: defineRoute({ path: "/pages/family/f08-album-detail", kind: "page", parent: "F07", requiredParams: ["albumId"], allowedSources: ["F07", "F09"], resultOperations: ["media-uploaded"] }),
  F09: defineRoute({ path: "/pages/family/f09-media-upload", kind: "flow", parent: "F08", requiredParams: ["albumId"], allowedSources: ["F08"] }),
  F10: defineRoute({ path: "/pages/family/f10-video-list", kind: "page", parent: "F01", allowedSources: ["F01"] }),
  R01: defineRoute({ path: "/pages/records/r01-people-list", kind: "page", parent: "F01", allowedSources: ["F01"], resultOperations: ["person-created", "person-updated", "person-deleted"] }),
  R02: defineRoute({ path: "/pages/records/r02-person-detail", kind: "flow", parent: "R01", optionalParams: ["personId", "mode"], allowedSources: ["R01"] }),
  R03: defineRoute({ path: "/pages/records/r03-gift-list", kind: "page", parent: "F01", allowedSources: ["F01"], resultOperations: ["gift-created", "gift-updated", "gift-deleted"] }),
  R04: defineRoute({ path: "/pages/records/r04-gift-editor", kind: "flow", parent: "R03", optionalParams: ["giftId", "mode"], allowedSources: ["R03"] }),
  R05: defineRoute({ path: "/pages/records/r05-ritual-list", kind: "page", parent: "F01", allowedSources: ["F01"], resultOperations: ["ritual-created", "ritual-updated", "ritual-deleted"] }),
  R06: defineRoute({ path: "/pages/records/r06-ritual-detail", kind: "page", parent: "R05", requiredParams: ["ritualId"], allowedSources: ["R05"] }),
  R07: defineRoute({ path: "/pages/records/r07-ritual-editor", kind: "flow", parent: "R05", optionalParams: ["ritualId", "mode"], allowedSources: ["R05", "R06"] }),
  R08: defineRoute({ path: "/pages/records/r08-growth-journal", kind: "page", parent: "R02", requiredParams: ["personId"], allowedSources: ["R02", "T03"] }),
  R09: defineRoute({ path: "/pages/records/r09-life-events", kind: "page", parent: "R02", requiredParams: ["personId"], allowedSources: ["R02", "T03"] }),
  R10: defineRoute({ path: "/pages/records/r10-memo-list", kind: "page", parent: "F01", allowedSources: ["F01"] }),
  R11: defineRoute({ path: "/pages/records/r11-merit-records", kind: "page", parent: "F01", allowedSources: ["F01"] }),
  N01: defineRoute({ path: "/pages/notification/n01-message-center", kind: "page", parent: "G01", optionalParams: ["genealogyId"], allowedSources: ["G01", "M01"], resultOperations: ["application-reviewed"] }),
  N02: defineRoute({ path: "/pages/notification/n02-message-detail", kind: "page", parent: "N01", requiredParams: ["id"], allowedSources: ["N01"], resultOperations: ["application-reviewed"] }),
  M01: defineRoute({ path: "/pages/profile/m01-profile-home", kind: "root", parent: null, resultOperations: ["profile-updated"] }),
  M02: defineRoute({ path: "/pages/profile/m02-edit-profile", kind: "flow", parent: "M01", allowedSources: ["M01"] }),
  M03: defineRoute({ path: "/pages/profile/m03-security-settings", kind: "page", parent: "M01", allowedSources: ["M01"], resultOperations: ["password-changed", "phone-changed"] }),
  M04: defineRoute({ path: "/pages/profile/m04-change-password", kind: "flow", parent: "M03", allowedSources: ["M03"] }),
  M05: defineRoute({ path: "/pages/profile/m05-change-phone", kind: "flow", parent: "M03", allowedSources: ["M03"] }),
  M06: defineRoute({ path: "/pages/profile/m06-help-center", kind: "page", parent: "M01", allowedSources: ["M01"], resultOperations: ["feedback-submitted"] }),
  M07: defineRoute({ path: "/pages/profile/m07-feedback", kind: "flow", parent: "M06", allowedSources: ["M06"] }),
  M08: defineRoute({ path: "/pages/profile/m08-promotion", kind: "page", parent: "M01", allowedSources: ["M01"] }),
  M09: defineRoute({ path: "/pages/profile/m09-vip-orders", kind: "page", parent: "M01", allowedSources: ["M01"] }),
  M10: defineRoute({ path: "/pages/profile/m10-about-settings", kind: "page", parent: "M01", allowedSources: ["M01"] }),
});

export const ROOT_ROUTE_KEYS = Object.freeze(["A01", "G01", "F01", "M01"]);
export const getRoute = (routeKey) => ROUTES[routeKey] || null;
export const getRouteKeyByPath = (path) =>
  Object.keys(ROUTES).find((routeKey) => ROUTES[routeKey].path === `/${String(path).replace(/^\/+/, "")}`) || null;

实现时把代码中的中文注释放在注册表形状和保留参数规则上,不为每一行重复相同注释。

  • 步骤 4:验证绿灯并检查旧合同迁移

运行路由合同、接口页面映射合同、全局响应式合同与编译审计。预期全部通过;tests/interface-page-mapping-contract.ps1 不再把旧代际父键布局或“阶段 1 尚未开始”作为当前真相。

任务 2:建立唯一导航网关

文件:

  • 新建:utils/navigation.js
  • 新建:tests/navigation-gateway-runtime-smoke.js

接口:

  • 消费:任务 1 的 ROUTESROOT_ROUTE_KEYSgetRoute()getRouteKeyByPath()

  • 输出:buildRouteUrlopenPagereplaceStepgoBackreturnTofinishPagegoRootconsumeNavigationResultresolveBackActionrunBackGuard

  • 一次性结果唯一形状:{ operation, entityId?, refresh };字段集合必须精确,operation 必须属于目标路由 resultOperations,多余字段立即抛错。

  • buildRouteUrlresolveBackAction 是同步纯函数;其余导航函数返回 Promise,并通过同一个转场锁保证一次只调用一个 Uni 导航 API。

  • 步骤 1:先写伪栈运行时测试

tests/navigation-gateway-runtime-smoke.js 使用 vm 注入伪 unigetCurrentPages,至少验证:

assert.equal(buildRouteUrl("T03", { genealogyId: "9007199254740993", personId: "p/1" }, "T01"),
  "/pages/tree/t03-member-profile?genealogyId=9007199254740993&personId=p%2F1&sourceKey=T01");
assert.throws(() => buildRouteUrl("T03", { genealogyId: "1", personId: 2 }, "T01"), /personId/);
assert.throws(() => buildRouteUrl("T03", { genealogyId: "1", personId: "2", targetUrl: "/x" }, "T01"), /targetUrl/);
const firstOpen = openPage("T03", { genealogyId: "1", personId: "2" }, "T01");
const repeatedOpen = openPage("T03", { genealogyId: "1", personId: "2" }, "T01");
assert.equal(firstOpen, repeatedOpen); // 同一 tick 复用在途 Promise,只发生一次 navigateTo
assert.deepEqual(consumeNavigationResult("F04"), { operation: "article-created", entityId: "a9", refresh: true });
assert.equal(consumeNavigationResult("F04"), null); // 只消费一次

同时构造目标在栈内、目标不在栈内、栈深 1、根页切换、非法来源、提交中和未保存确认六组场景,并补齐以下反例:当前真实页 G01 伪报 sourceKey=T07 必须失败;外部直接链接即使带合法 sourceKey 也只能按规范父页回退;同一 tick 两次打开同一目标只调用一次 navigateTooperation="anything"、空 entityId、非布尔 refresh 和额外字段都失败。

  • 步骤 2:运行并确认红灯

运行 node tests/navigation-gateway-runtime-smoke.js。预期因 utils/navigation.js 不存在而失败。

  • 步骤 3:实现 URL 和参数校验

实现必须遵守以下实际逻辑:

const navigationResults = new Map();
let navigationInFlight = null;

const assertScalarString = (name, value) => {
  if (typeof value !== "string" || value.length === 0) {
    throw new TypeError(`导航参数 ${name} 必须是非空字符串`);
  }
};

export const buildRouteUrl = (routeKey, params = {}, sourceKey = "") => {
  const route = getRoute(routeKey);
  if (!route) throw new Error(`未知路由键:${routeKey}`);
  const allowed = new Set([...route.requiredParams, ...route.optionalParams]);
  Object.keys(params).forEach((name) => {
    if (!allowed.has(name)) throw new Error(`${routeKey} 不接受导航参数 ${name}`);
    assertScalarString(name, params[name]);
  });
  route.requiredParams.forEach((name) => {
    if (!Object.prototype.hasOwnProperty.call(params, name)) throw new Error(`${routeKey} 缺少导航参数 ${name}`);
  });
  if (sourceKey) {
    if (!route.allowedSources.includes(sourceKey)) throw new Error(`${sourceKey} 不能进入 ${routeKey}`);
  }
  const query = new URLSearchParams({ ...params, ...(sourceKey ? { sourceKey } : {}) }).toString();
  return query ? `${route.path}?${query}` : route.path;
};

buildRouteUrl 只负责纯字符串构造;真正执行导航前,openPage/replaceStep 必须读取当前栈顶路径,并断言 sourceKey === getRouteKeyByPath(当前真实页面)。页面不得通过传入另一个合法来源绕过注册表。栈深为 1 时一律忽略当前 URL 查询中的 sourceKey,只使用注册表 parent/parentParamMapsourceKey 不得成为外部深链的可信合同。

若 Uni 运行环境没有 URLSearchParams,只在本文件实现一个逐键 encodeURIComponent 的 8—12 行小函数,不引入依赖、不复制到页面。

转场锁固定实现为“同一操作复用 Promise、不同操作在转场结束前返回 false”,不能用页面各自的布尔锁:

const runUniNavigation = (key, invoke) => {
  if (navigationInFlight?.key === key) return navigationInFlight.promise;
  if (navigationInFlight) return Promise.resolve(false);
  let resolvePromise;
  let rejectPromise;
  const promise = new Promise((resolve, reject) => {
    resolvePromise = resolve;
    rejectPromise = reject;
  });
  navigationInFlight = { key, promise };
  try {
    invoke({
      success: () => resolvePromise(true),
      fail: (error) => rejectPromise(new Error(error?.errMsg || "页面跳转失败")),
      complete: () => { navigationInFlight = null; },
    });
  } catch (error) {
    navigationInFlight = null;
    rejectPromise(error);
  }
  return promise;
};
  • 步骤 4:实现六种语义导航

实现顺序固定为:先验证目标,再读取当前栈,再决定唯一 Uni 调用。核心伪代码必须逐行落实:

export const openPage = (routeKey, params = {}, sourceKey = "") => {
  assertActualSource(routeKey, sourceKey);
  const url = buildRouteUrl(routeKey, params, sourceKey);
  if (isCurrentTarget(url)) return Promise.resolve(false);
  if (getRoute(routeKey).kind === "single") return activateExistingSinglePage(routeKey, params, url);
  return runUniNavigation(`push:${url}`, (callbacks) => uni.navigateTo({ url, ...callbacks }));
};

export const replaceStep = (routeKey, params = {}, sourceKey = "") => {
  assertActualSource(routeKey, sourceKey);
  const url = buildRouteUrl(routeKey, params, sourceKey);
  return runUniNavigation(`replace:${url}`, (callbacks) => uni.redirectTo({ url, ...callbacks }));
};

export const goRoot = (routeKey, params = {}) => {
  if (!ROOT_ROUTE_KEYS.includes(routeKey)) throw new Error(`${routeKey} 不是根语义`);
  const url = buildRouteUrl(routeKey, params);
  if (isCurrentTarget(url)) return Promise.resolve(false);
  return runUniNavigation(`root:${url}`, (callbacks) => uni.reLaunch({ url, ...callbacks }));
};

goBack() 在栈深大于 1 时只执行受转场锁保护的 navigateBack({ delta: 1 });栈深为 1 且当前是普通页时忽略 URL 中的 sourceKey,只按规范父页、同名参数和 parentParamMap 构造父页参数。某级父页缺必填参数时继续沿注册表父链向上,直到第一个可合法构造的目标;例如缺 genealogyId 的异常 T03 不能伪造 T01/G05,最终回 G01。当前已是 A01/G01/F01/M01 根语义时返回 false,由 Android 系统处理退出,不伪造跨 Tab 历史。

returnTo(routeKey, targetParams = {}, result = null) 从当前页下方按路由键反向寻找最近实例;找到则精确计算 deltatargetParams 不参与匹配,只校验调用方实际提供的字段。只有目标不在栈内时才要求这些参数包含构造目标所需的全部必填项,并据此调用 goRoot 或内部 replace。finishPage(routeKey, targetParams, result) 先验证结果字段和目标页 resultOperations,再沿用同一规则处理目标参数。结果只在目标确定后写入;Uni 调用失败、被转场锁拒绝或目标页消费后都立即删除,不能泄漏给下一次流程。

kind="single" 的 T03 已在同一 genealogyId 栈下方时,openPage 不再压栈:网关写入 T03 允许的 { operation: "member-open-requested", entityId: personId, refresh: false },精确返回已有 T03,由其读取成功后推进页内轨迹;导航失败立即删除结果。测试必须构造 [T01,T03,其他页] 后再次打开 T03,断言原生栈中始终只有一个 T03。若既有 T03 的 genealogyId 不同,返回明确 T03_CONTEXT_CONFLICT 并拒绝压栈,调用方必须先用根语义切换家谱上下文。

  • 步骤 5:实现统一返回优先级
export const resolveBackAction = ({ transientOpen, internalTrail, dirty, submitting }) => {
  if (transientOpen) return "close-transient";
  if (internalTrail) return "pop-internal-trail";
  if (submitting) return "block-submitting";
  if (dirty) return "confirm-discard";
  return "go-back";
};

runBackGuard(context) 只调用上下文中与返回动作同名的一个回调;放弃确认返回 false 时必须留在当前页,返回 true 才调用 goBack()。页面不得另写不同优先级。

需要弹层、内部轨迹或未保存守卫的页面只保留一个 requestBack 函数。页头 @back 调用它,Android 返回固定写法如下;回调先同步返回 true 消费系统返回,再异步执行同一个守卫:

onBackPress(() => {
  void requestBack();
  return true;
});

根页没有浮层时不注册拦截;有浮层的根页只在浮层可见时返回 true 并关闭最上层。

  • 步骤 6:验证网关

运行 Node 冒烟、导航路由合同、全局响应式合同和编译审计。预期全部通过,且网关以外暂时仍有直接调用;任务 3 才启用零调用扫描。

任务 3:迁移共享页头、底栏和模块页

文件:

  • 修改:components/AppTabbar.vue
  • 修改:components/PageHeader.vue
  • 修改:components/ModulePage.vue
  • 修改:tests/shared-interaction-accessibility-contract.ps1
  • 修改:tests/shared-component-document-flow-contract.ps1
  • 新建:tests/navigation-source-scan-contract.ps1

接口:

  • AppTabbar:保留现有展示键 genealogy/family/profile,另给三项加入 routeKey: G01/F01/M01;只调用 goRoot(item.routeKey),活动项无操作,删除每项的 path

  • PageHeader:普通返回调用 goBack()customBack 仍只发出 back 事件给需要守卫的页面;删除 fallbackUrl

  • ModulePage:返回调用 goBack(),不自行判断路由。

  • 步骤 1:先改失败断言

把旧合同中对 uni.navigateBack()uni.reLaunch()fallbackUrl 的正向断言改成反向断言,并要求三个组件导入导航网关。此时测试必须因组件尚未迁移而失败。

  • 步骤 2:迁移三个组件

共享组件只允许出现:

import { goBack, goRoot } from "@/utils/navigation.js";

PageHeader 的返回函数固定为:

const handleBack = () => {
  if (props.customBack) {
    emit("back");
    return;
  }
  goBack();
};

删除所有 fallbackUrl 属性、默认值、模板传递和相关测试;不保留兼容属性。

  • 步骤 3:建立最终零调用扫描,但暂不要求通过

tests/navigation-source-scan-contract.ps1 扫描 componentspagesutils 下全部 .vue/.js,五种 Uni 导航调用和 getCurrentPages 只允许出现在 utils/navigation.js,而 uni.switchTab 因项目没有原生 tabBar 必须全项目为零。扫描同时禁止页面和组件出现业务 /pages/... 路径字符串,资产路径 /static/... 不受影响;测试与文档目录明确排除,不能用过宽白名单。当前预期列出尚未迁移的页面,不能误报测试和文档中的示例。

  • 步骤 4:验证共享批次并在 MuMu 复核

运行三个共享聚焦合同、全局响应式合同和编译审计;在 MuMu 验证 G01/F01/M01 三个自定义 Tab、普通页头返回、带 customBack 的弹层页返回。不得调整模拟器。

任务 4:迁移认证导航

文件:

  • 修改:pages/auth/a01-entry.vue
  • 修改:pages/auth/a04-register.vue
  • 修改:pages/auth/a05-reset-password.vue
  • 修改:pages/auth/a06-auth-status.vue
  • 修改:tests/a04-registration-contract.ps1
  • 修改:tests/a05-reset-password-contract.ps1
  • 修改:tests/a06-auth-status-contract.ps1
  • 修改:tests/active-page-business-ownership-contract.ps1
  • 修改:tests/navigation-flow-contract.ps1

流程合同:

操作 唯一语义
A01 打开注册 openPage("A04", {}, "A01")
A01 打开重设密码 openPage("A05", {}, "A01")
A04 页头返回或“已有账号” returnTo("A01", {})
A05 取消或本地视觉成功态返回 returnTo("A01", {}),不写结果
A05 未来真实重设接口成功 finishPage("A01", {}, { operation: "password-reset", refresh: false })
A01 真实登录成功 goRoot("G01")
A06 封存页进入登录 goRoot("A01")

当前 A01/A04 的行为验证仍是明确的接口占位,导航批次不得把“知道了”伪装成登录或注册成功。OpenAPI 已证明 /genealogy/app/auth/register200 响应复用 LoginResult,并可返回 LoginVo 会话令牌;真实注册接入时必须在保存有效会话后调用 goRoot("G01"),不能先回 A01 再让用户重复登录。

  • 步骤 1:让认证导航合同先失败

把现有合同中的 uni.navigateBackuni.redirectTo 正向断言改为上表语义调用,增加 A04/A05 连续进入不产生重复 A01 的静态断言。另断言 ?state=success、本地计时器和视觉占位弹窗都不得生成 password-reset 导航结果。运行四个认证聚焦合同,预期因页面尚未导入导航网关而失败。

  • 步骤 2:只迁移现有真实导航动作

页面统一导入所需语义函数;A01 的安全验证占位、A04 的接口占位和 A05 的本地成功弹窗保持当前事实。A05 本地成功弹窗的“返回登录”只普通 returnTo,只有后续真实重设接口成功才允许生成并消费一次性结果;不新增假接口、不写未调用完成函数。A01 的行为验证/协议弹层和 A04/A05 的表单共同使用 requestBack;提交中先阻止离开,未保存时再确认放弃,页头与 onBackPress 构造同一 context 并执行同一 runBackGuard

  • 步骤 3:验证认证批次

运行认证聚焦合同、导航网关 Node 冒烟、响应式合同和编译审计。在 MuMu 验证 A01→A04→页头返回、A01→A04→已有账号、A01→A05→取消、A05 本地成功→返回登录,各流程连续执行 3 次;返回后只能有一个 A01,表单取消不得显示成功。

任务 5:迁移 G 系列导航

文件:

  • 修改:pages/genealogy/g01-my-genealogies.vue
  • 修改:pages/genealogy/g03-create-genealogy.vue
  • 修改:pages/genealogy/g05-genealogy-overview.vue
  • 修改:pages/genealogy/g06-search-genealogies.vue
  • 修改:pages/genealogy/g08-join-application.vue
  • 修改:pages/genealogy/g09-my-applications.vue
  • 修改:pages/genealogy/g10-application-review.vue
  • 修改:pages/genealogy/g11-genealogy-settings.vue
  • 修改:pages/genealogy/g12-generation-poems.vue
  • 修改:tests/g01-visual-contract.ps1
  • 修改:tests/g03-create-flow-contract.ps1
  • 修改:tests/g08-g10-application-flow-contract.ps1
  • 修改:tests/g11-g12-settings-poems-contract.ps1
  • 修改:tests/navigation-flow-contract.ps1

流程合同:

  • G01 卡片和快捷入口只使用 openPage;路径对象改为路由键对象,不能继续保存 /pages/... 字符串。

  • G03 内部从创建步骤切换到始祖步骤使用本页状态,不再接收 stepredirectTo 自己;待完善流程只传 genealogyId,由领域数据决定步骤。取消 returnTo("G01", {});始祖完成 finishPage("G05", { genealogyId: entityId }, { operation: "genealogy-created", entityId, refresh: true })

  • G05 返回 returnTo("G01", {}),进入 F01 使用 goRoot("F01", { genealogyId }),其余入口使用 openPage。G05/G08 不再接收 genealogyName,页面按 genealogyId 从现有 fixture 或领域数据取名称。

  • G06 已加入或我创建的结果 goRoot("G01", { genealogyId });公开预览进入 G05;搜索申请进入 G08;审核中或被拒绝进入 G09。

  • G08 搜索申请成功 finishPage("G09", {}, { operation: "application-created", entityId, refresh: true });邀请码直接加入成功 goRoot("G01", { genealogyId })。邀请码合同已定为“直接加入且不生成审核记录”,M08 同批删除“加入后需要管理员审核”旧文案,不写双分支兼容。

  • G09 通过记录进入 G05,被拒绝记录进入 G08,搜索入口进入 G06;返回保留列表现场。

  • G10/G11/G12 保存或审核成功把一次性结果返回实际来源;取消只返回,不产生成功结果。

  • 步骤 1:逐页写失败断言

tests/navigation-flow-contract.ps1 对上表每一类流程检查路由键、sourceKey、结果 operation 和终点;现有 G 系列聚焦合同移除直接 Uni API 断言。先运行并确认失败来自旧导航调用。

  • 步骤 2:迁移 G01、G03、G05

先处理根页、创建流程和总览;每页完成后分别运行该页聚焦合同、响应式合同、编译审计,并在 MuMu 验证进入、返回、取消、完成和连续重复 3 次。G03 不得把同一页面的业务步骤塞回原生栈。

  • 步骤 3:迁移 G06、G08、G09

只传字符串 ID 与业务枚举;删除 previous 原始来源参数,改用受注册表约束的 sourceKey。失败和取消保留输入及当前列表,邀请码和搜索申请不得落到同一个完成终点。

  • 步骤 4:迁移 G10、G11、G12

审核、设置和字辈保存结果使用注册表允许的一次性结果,并显式传入回退目标所需参数;现有 G10 拒绝原因聚焦与无障碍关系不得回归。G12 从 G01 或 G05 进入时,返回实际栈中来源;直接进入才使用规范父页 G05。G01 的切换/添加弹层,G09 的撤回确认,以及 G03、G08、G10、G11、G12 的表单都接入 requestBack/runBackGuard;页头、取消按钮和 onBackPress 使用同一 context,提交中优先于脏表单确认。

  • 步骤 5:完成 G 系列 MuMu 矩阵

至少验证 G01→G03→取消/完成、G01→G06→G08→G09、G05→G10/G11/G12→取消/完成、直接进入 G11/G12 后返回,以及每条流程重复进入。检查旧页面不残留、当前家谱 ID 不串、取消不刷新、完成只刷新一次。

任务 6:迁移 T 系列并实现 T03 单实例轨迹

文件:

  • 修改:pages/tree/t01-tree-overview.vue
  • 修改:pages/tree/t03-member-profile.vue
  • 修改:pages/tree/t04-add-relative.vue
  • 修改:pages/tree/t05-edit-member.vue
  • 修改:pages/tree/t06-edit-relationship.vue
  • 修改:pages/tree/t07-member-directory.vue
  • 修改:pages/tree/t08-member-states.vue
  • 修改:tests/t03-t08-member-flow-contract.ps1
  • 修改:tests/t03-t08-member-flow-runtime-smoke.js
  • 修改:tests/t03-t08-business-specialization-runtime-smoke.js
  • 修改:tests/navigation-flow-contract.ps1

接口:

  • T03 新增页内状态:memberTrail: string[]trailIndex: numberloadMember(personId): Promise<boolean>initializeMemberTrail(initialPersonId)openRelative(personId)popMemberTrail()

  • 原生页面仍只有一个 T03;亲属点击只在 loadMember 成功后追加字符串 personId,失败不改轨迹、不清空当前成员。

  • T04/T06 成功回 T01 时结果 entityId 是需要聚焦的成员 IDT01 在 onShow 消费一次并定位。

  • 步骤 1:先锁定重复 T03 缺陷

运行时测试先断言初始读取失败时轨迹仍为空、初始成功后轨迹恰为 [A],再构造 T01→T03(A)→亲属 B→亲属 C→返回→返回→来源,断言路由变化始终停留在同一个 T03 地址、当前成员依次 A/B/C/B/A、最终才离开 T03;历史 B 失效时删除 B 后继续回 A。另构造伪栈 [T01,T03,其他页] 再打开同一家谱 T03,断言网关回到既有实例且原生栈始终只有一个 T03。旧实现因未初始化 A 且 navigateTo 同一路由而失败。

  • 步骤 2:实现 T03 页内轨迹

初始成员成功读取后必须先执行:

memberTrail.splice(0, memberTrail.length, initialPersonId);
trailIndex.value = 0;

随后亲属切换按以下顺序实现,并写明中文步骤注释:

const openRelative = async (nextPersonId) => {
  if (nextPersonId === personId.value) return;
  const loaded = await loadMember(nextPersonId);
  if (!loaded) return;
  memberTrail.splice(trailIndex.value + 1);
  memberTrail.push(nextPersonId);
  trailIndex.value = memberTrail.length - 1;
};

popMemberTrail() 先递减索引再加载历史成员;历史成员失效时移除该项并继续向前,不能生成新 T03 页面。页头与 onBackPress 必须构造同一 context,通过 runBackGuard 先消费内部轨迹;T04/T05/T06 等表单也以提交中→脏表单的统一优先级接线。

  • 步骤 3:迁移 T01、T04—T08 的终点

T01 打开 T03/T04/T06/T07 使用路由键;T04 保存 finishPage("T01", { genealogyId }, { operation: "relative-created", entityId, refresh: true })T05 保存 finishPage("T03", { genealogyId, personId }, { operation: "member-updated", entityId: personId, refresh: true })T06 保存 finishPage("T01", { genealogyId }, { operation: "relationship-updated", entityId: personId, refresh: true });T07 可返回 T01 定位或打开当前单实例 T03;T08 家谱失效使用 goRoot("G01"),普通返回回 T03。

  • 步骤 4:验证 T 系列

运行 T 聚焦合同、两个运行时冒烟、响应式合同和编译审计。在 MuMu 验证 T03 A→B→C→B→A→来源、T04/T05/T06 的取消与完成、T07 选择、T08 返回和同一流程 20 次重复进入;原生栈不得随亲属浏览增长。

任务 7:迁移 F 系列导航

文件:

  • 修改:pages/family/f01-family-feed.vue
  • 修改:pages/family/f02-publish-feed.vue
  • 修改:pages/family/f03-feed-detail.vue
  • 修改:pages/family/f04-article-list.vue
  • 修改:pages/family/f05-article-detail.vue
  • 修改:pages/family/f06-article-editor.vue
  • 修改:pages/family/f07-album-list.vue
  • 修改:pages/family/f08-album-detail.vue
  • 修改:pages/family/f09-media-upload.vue
  • 修改:pages/family/f10-video-list.vue
  • 修改:tests/f-business-flow-contract.ps1
  • 修改:tests/f05-expired-state-contract.ps1
  • 修改:tests/f06-editor-context-contract.ps1
  • 修改:tests/f08-album-detail-contract.ps1
  • 修改:tests/f09-media-upload-contract.ps1
  • 修改:tests/f10-video-status-contract.ps1
  • 修改:tests/navigation-flow-contract.ps1

流程合同:

流程 取消/返回 完成
F01→F02 F01 finishPage("F01", {}, { operation: "feed-created", entityId, refresh: true })
F01→F03 F01 且恢复现场 评论成功留在 F03,本地插入一次
F01→F04→F05 F05 回 F04F04 回 F01 收藏留在 F05
F04/F05→F06 新建回 F04,编辑回 F05 article-createdarticle-updated,目标页刷新一次
F01→F07→F08→F09 逐级回实际来源 上传完成回 F08 并刷新一次
F01→F10 F01 当前未开放,无伪成功
  • 步骤 1:先复现两个已知错误栈

静态合同和 MuMu 先记录 F01→F03 失效→“返回家族圈”会留下两个 F01,F04→F05→返回会留下重复 F04。把预期改为 returnTo 后确认旧代码失败。

  • 步骤 2:迁移动态与谱文流程

F02 成功态主按钮改为“返回家族圈”并调用 finishPage;F03 的普通、失效和错误状态都回已有 F01;F06 依据 editorMode 精确回 F04 或 F05,不用 redirectTo 伪装返回。

  • 步骤 3:迁移相册与未开放视频

F08 的页头与 onBackPress 都先关闭照片预览,再返回 F07;F09 有选择或说明未保存时确认,上传中优先阻止离开,成功调用 finishPage("F08", { albumId }, { operation: "media-uploaded", entityId: albumId, refresh: true })F10 使用 returnTo("F01", {}),直接进入时网关按规范父页建立 F01。F02/F06/F09 的表单、F03 的评论提交、F07 的新建相册弹层和 F08 的预览都让页头、取消动作与 onBackPress 构造同一 context 并统一进入 runBackGuard

  • 步骤 4:验证 F 系列

运行全部 F 聚焦合同、响应式合同和编译审计;在 MuMu 逐条验证表中流程的进入、返回、取消、完成和重复进入。重点检查 F01/F04 不重复、F08 预览优先关闭、F09 取消不产生上传成功。

任务 8:迁移 R 系列导航

文件:

  • 修改:pages/records/r01-people-list.vue
  • 修改:pages/records/r02-person-detail.vue
  • 修改:pages/records/r03-gift-list.vue
  • 修改:pages/records/r04-gift-editor.vue
  • 修改:pages/records/r05-ritual-list.vue
  • 修改:pages/records/r06-ritual-detail.vue
  • 修改:pages/records/r07-ritual-editor.vue
  • 修改:pages/records/r08-growth-journal.vue
  • 修改:pages/records/r09-life-events.vue
  • 修改:pages/records/r10-memo-list.vue
  • 修改:pages/records/r11-merit-records.vue
  • 修改:tests/r-business-flow-contract.ps1
  • 修改:tests/r02-person-detail-contract.ps1
  • 修改:tests/r02-person-detail-runtime-smoke.js
  • 修改:tests/navigation-flow-contract.ps1

流程合同:

  • R01→R02:查看和新建均使用 openPage;保存或删除回 R01 并刷新一次,取消只返回。

  • R03→R04:新建、编辑、删除分别返回 gift-createdgift-updatedgift-deleted,失败留在 R04。

  • R05→R06/R07:详情返回 R05;编辑成功回 R05,不能用 redirectTo 新建列表。

  • R02/T03→R08/R09:只传 personId,删除把姓名当跨页身份的 personName 查询合同;页面自行按领域数据显示姓名。

  • R10/R11 不引入额外原生页面流程,但新增记录弹层必须先于页面返回关闭。R02/R04/R07 的保存或删除完成都调用 finishPage(目标路由, 目标参数, 类型化结果)R02/R04/R07/R08/R09/R10/R11 的页头、弹层取消按钮与 onBackPress 共享同一 requestBack,提交中优先阻止离开,未保存时再确认放弃。

  • 步骤 1:先写结果和 ID 失败合同

要求页面不再包含 personName=saveResult= 或直接 Uni 导航调用,并精确断言三组列表/详情/编辑终点。旧实现必须先失败。

  • 步骤 2:按三条独立流程迁移

依次迁移人物录、贺礼簿、礼仪活动;每条流程完成后运行自己的聚焦合同和固定验证,不把三个领域一次性机械替换。

  • 步骤 3:验证 R 系列

MuMu 分别验证新建、编辑、删除取消、删除完成、失败重试和连续重复进入;列表滚动、筛选与搜索现场应在返回后保持,完成结果只触发一次刷新。

任务 9:迁移 N/M 系列与安全通知目标

文件:

  • 修改:utils/navigation-routes.js
  • 修改:utils/navigation.js
  • 修改:pages/notification/n01-message-center.vue
  • 修改:pages/notification/n02-message-detail.vue
  • 修改:pages/profile/m01-profile-home.vue
  • 修改:pages/profile/m02-edit-profile.vue
  • 修改:pages/profile/m03-security-settings.vue
  • 修改:pages/profile/m04-change-password.vue
  • 修改:pages/profile/m05-change-phone.vue
  • 修改:pages/profile/m06-help-center.vue
  • 修改:pages/profile/m07-feedback.vue
  • 修改:pages/profile/m08-promotion.vue
  • 修改:pages/profile/m09-vip-orders.vue
  • 修改:pages/profile/m10-about-settings.vue
  • 修改:tests/nm-page-business-contract.ps1
  • 修改:tests/nm-business-runtime-smoke.js
  • 修改:tests/root-pages-runtime-smoke.js
  • 修改:tests/navigation-flow-contract.ps1

接口:

utils/navigation-routes.js 同轮增加唯一通知目标表:

export const NOTICE_TARGETS = Object.freeze({
  GENEALOGY_REVIEW: Object.freeze({ routeKey: "G10", params: ["genealogyId"] }),
  GENEALOGY_HOME: Object.freeze({ routeKey: "G01", params: ["genealogyId"] }),
});

utils/navigation.js 新增 openNoticeTarget(targetType, params, sourceKey = "N02"):未知类型、缺字段、多字段、非字符串 ID 和原始 URL 一律拒绝;G01 使用 goRootG10 使用 openPage

  • 步骤 1:先锁定原始 URL 漏洞

合同要求 N01/N02 数据只含 targetTypetargetParams,源码不得出现 noticeDetail.value.target/pages/... 目标字符串。旧 N02 因持有 target 并直接 navigateTo 而失败。

  • 步骤 2:迁移 N01/N02

N01 打开 N02 固定使用 openPage("N02", { id }, "N01");N02 默认回 N01。目标无权限、过期、字段不足或类型未知时留在 N02 明确说明,不猜测、不回落空页面。

  • 步骤 3:迁移 M 页面、邀请语义与退出

M01 菜单由路由字符串改成路由键;M03/M06 使用 openPage;M08 删除“邀请码加入后需要管理员审核”及其并行分支,只保留已经定案的“邀请码直接加入且不生成审核记录”,聚焦合同同时断言旧文案不存在。M10 确认退出时按顺序执行 session.clear()、关闭确认层、goRoot("A01"),退出取消只关闭弹窗。M02/M04/M05/M07 的表单、M08/M09 的分享或说明弹层、M10 的协议和退出确认都让页头与 onBackPress 共用同一 requestBack/runBackGuard;提交中优先于脏表单,弹层优先于页面返回。

  • 步骤 4:验证 N/M 系列

运行 N/M 聚焦合同、两个运行时冒烟、响应式合同和编译审计;MuMu 验证 G01/M01→N01→N02→业务目标、目标失效、返回、重复进入,以及 M01→M10→取消/确认退出。确认退出后原栈被清理且只有 A01。

任务 10:关闭导航阶段

文件:

  • 修改:tests/navigation-source-scan-contract.ps1

  • 修改:tests/navigation-flow-contract.ps1

  • 修改:docs/家谱项目全量治理设计.md

  • 修改:docs/家谱项目全量治理实施计划.md

  • 修改:docs/项目当前总览.md

  • 修改:docs/接口与页面映射总表.md

  • 步骤 1:执行零调用与闭合合同

运行 tests/navigation-source-scan-contract.ps1。预期 pages、components、utils 中网关外的五种 Uni 导航调用和 getCurrentPages 数量均为 0,唯一允许位置是 utils/navigation.jsswitchTab 全项目为 0;路由注册表与 pages.json52/52

  • 步骤 2:运行全量合同

运行本计划“每个代码批次的固定验证”中的全量 PowerShell 循环,再运行:

node tests/navigation-gateway-runtime-smoke.js
node tests/password-policy-runtime-smoke.js

预期全部退出码为 0;不能删除或放宽与新导航合同冲突的有效业务测试,而应把旧实现断言迁移到新所有者。

  • 步骤 3:执行 NAV-MUMU-01 至 NAV-MUMU-08
  1. 认证注册与重设返回;
  2. G03 创建取消/完成;
  3. G06/G08/G09 加入流程;
  4. T03 A→B→C→B→A→来源;
  5. F02/F03/F04/F05/F06 回流;
  6. F07/F08/F09 与预览优先级;
  7. R01—R07 列表/详情/编辑;
  8. N01/N02 业务目标与 M10 退出。

每组都验证进入、返回、取消、完成、直接进入和重复进入。记录实际行为与栈结果;忽略“3D研究室”等系统水印,不调整 MuMu。

  • 步骤 4:三人终审并更新状态

三人分别检查业务正确性、全局一致性、回归风险、维护成本和 MuMu 可验证性;任何一人提出有证据的问题即继续修复。只有所有导航门禁均通过,才把总览改为“导航栈语义统一已完成”,并开启第二阶段接口门禁。


第二阶段:T01 大规模世系树

任务 11:向后端提交规范图合同并建立接口门禁

文件:

  • 新建:tests/lineage-openapi-contract.ps1
  • 修改:docs/接口与页面映射总表.md
  • 修改:docs/家谱项目全量治理设计.md

当前已证实缺口:

  • 现有 GET /genealogy/app/genealogies/{genealogyId}/lineage/tree 只有 genealogyId,响应是递归 LineagePersonTreeView[]
  • 当前接口没有 /lineage/tree/overview/lineage/persons/{personId}/locator
  • 当前世系 ID 大量使用 integer/int64JavaScript 无法保证超过安全整数后的精确性。
  • 当前递归 children/spouses 无法精确表达多个家庭联合点、收养/继亲/监护、主入边、未加载边界和图版本。
  • T06 所需的关系修改接口缺失;现有新增父母、子女、兄弟姐妹和配偶接口没有统一的 If-Match 树版本并发合同。

新读合同:

GET /genealogy/app/v2/genealogies/{genealogyId}/lineage/tree
GET /genealogy/app/v2/genealogies/{genealogyId}/lineage/tree/overview
GET /genealogy/app/v2/genealogies/{genealogyId}/lineage/persons/{personId}/locator
PATCH /genealogy/app/v2/genealogies/{genealogyId}/lineage/relationships/{relationshipId}

/lineage/tree 查询参数固定为 mode,focusPersonId,ancestorDepth,descendantDepth,boundaryId,cursor,limit,treeVersion,不接受 branchId/generation。两个模式严格互斥:

  • mode=FOCUSfocusPersonId 可选且只能是调用者可见的稳定人物 IDREDACTED opaque ID 不得作为焦点;缺省时服务端按当前账号绑定成员、主始祖的顺序确定,并返回实际焦点。不可见、过期或不存在的焦点统一返回 HTTP 404 LINEAGE_FOCUS_NOT_AVAILABLE,不泄漏成员是否存在。ancestorDepth/descendantDepth 是 0—20 的整数、可选且默认 2;boundaryId/cursor/treeVersion 必须缺席。
  • mode=BOUNDARYboundaryId/cursor/treeVersion 三项必填;focusPersonId/ancestorDepth/descendantDepth 必须缺席。
  • limit 最小 1、默认 200、最大 500;混合、缺项、多余或越界组合返回 HTTP 422 和稳定业务码 LINEAGE_QUERY_INVALID。OpenAPI 同时声明 400/401/403/404/422/429/5xx

响应 data 必须是:

{
  "version": { "schemaVersion": "2.0", "treeVersion": "t-20260722-1", "generatedAt": "2026-07-22T10:00:00+08:00" },
  "state": "EMPTY",
  "genealogyId": "1001",
  "nodes": [],
  "familyUnits": [],
  "edges": [],
  "window": {
    "focusPersonId": null,
    "entryPersonIds": [],
    "scope": { "ancestorDepth": 2, "descendantDepth": 2 },
    "generationRange": null,
    "returnedNodeCount": 0,
    "boundaries": []
  }
}

这是合法的 state=EMPTY 空谱,不是合同错误。下面是 state=POPULATED 响应中的可见人物、匿名人物、家庭和边片段;完整 POPULATED 根必须有非空 nodes、有效焦点、至少一个窗口入口以及非空代际范围。

节点、家庭和边的精确形状:

{
  "node": {
    "id": "p103",
    "generation": 18,
    "displayName": "某某某",
    "sex": "MALE",
    "avatarOssId": null,
    "branchId": "b-main",
    "branchPath": ["b-root", "b-main"],
    "order": 1,
    "visibility": "VISIBLE",
    "entryReason": null
  },
  "redactedNode": {
    "id": "redacted:t-20260722-1:17",
    "generation": 18,
    "displayName": "隐私成员",
    "order": 2,
    "visibility": "REDACTED",
    "entryReason": null
  },
  "familyUnit": {
    "id": "fu-103-1",
    "anchorPersonId": "p103",
    "partnerRelationship": {
      "relationshipId": "rel-partner-103-104",
      "relationshipKind": "PARTNER",
      "relationType": "MARRIAGE",
      "status": "ACTIVE"
    },
    "partners": [
      { "personId": "p103", "partnerRole": "ANCHOR", "order": 1 },
      { "personId": "p104", "partnerRole": "PARTNER", "order": 2 }
    ],
    "order": 1
  },
  "edge": {
    "id": "e-103-106",
    "familyUnitId": "fu-103-1",
    "childId": "p106",
    "lineageParentId": "p103",
    "parentRelations": [
      { "relationshipId": "rel-parent-103-106", "relationshipKind": "PARENT_CHILD", "personId": "p103", "parentRole": "FATHER", "relationType": "BIOLOGICAL" },
      { "relationshipId": "rel-parent-104-106", "relationshipKind": "PARENT_CHILD", "personId": "p104", "parentRole": "MOTHER", "relationType": "BIOLOGICAL" }
    ],
    "primary": true,
    "order": 1
  }
}

LineageGraphWindow 以根字段 state 为 discriminator 使用 oneOf,枚举只允许 EMPTY/POPULATED。EMPTY 分支精确要求 nodes/familyUnits/edges=[],且窗口内 focusPersonId=null、entryPersonIds=[]、generationRange=null、returnedNodeCount=0、boundaries=[]POPULATED 分支要求 nodes 非空、focusPersonId 引用其中一个 VISIBLE 节点、entryPersonIds 非空且全部引用当前 nodes、generationRange 非空,并满足 returnedNodeCount === nodes.length

entryPersonIds 精确等于当前返回窗口中没有 primary 入边的节点集合,不代表全谱始祖;secondary 入边不取消入口身份。入口节点 entryReason 只允许 GENEALOGY_ROOT/WINDOW_CUT/DISCONNECTED_COMPONENT,非入口固定为 null。每个入口在当前窗口恰有零条 primary 入边,每个非入口恰有一条 primary 入边;GENEALOGY_ROOT 表示全谱主森林根,WINDOW_CUT 表示 canonical primary 父边在窗口外,DISCONNECTED_COMPONENT 表示没有可达全谱根的 canonical primary 链。全谱根只由 overview 的可见根/隐私根计数和 locator 的根可见性分支表达。

人物以 visibility 为 discriminator 使用 oneOf,枚举只允许 VISIBLE/REDACTEDVisibleLineagePerson 精确包含 id,generation,displayName,sex,avatarOssId,branchId,branchPath,order,visibility=VISIBLE,entryReason,其 ID 是可用于 FOCUS、locator、搜索和写接口且不得以 redacted: 开头的稳定人物 IDsex 只允许 MALE/FEMALE/UNKNOWNRedactedLineagePerson 只允许 id,generation,displayName,order,visibility=REDACTED,entryReasondisplayName 固定为“隐私成员”,opaque ID 精确使用 redacted:{treeVersion}:{token} 并仅能在该 treeVersion 内作当前图内部引用,不得用于 FOCUS、locator、搜索或写接口,也不得进入任何 focusPersonId/targetPersonId,并且不得返回 sex/avatarOssId/branchId/branchPath 或其他可推断身份字段。两类人物的 generation 都是大于等于 1 的整数,order 是非负整数;可见人物的 avatarOssId 只允许非空字符串或 null,其他实体、关系和引用 ID 必须是非空字符串。

双人家庭的 partnerRelationship 必含稳定 relationshipId、relationshipKind=PARTNER、relationType、statusrelationTypeMARRIAGE/PARTNERSHIP/UNKNOWNstatusACTIVE/ENDED/UNKNOWN。单亲家庭该对象固定为 nullpartners 只有一个 ANCHORpartnerRole 只允许 ANCHOR/PARTNER,多配偶为每段伴侣关系建立不同 FamilyUnit。父子关系固定 relationshipKind=PARENT_CHILDlineageParentId 必须同时出现在该 edge 的 parentRelations 和对应家庭成员中,子女不得同时属于该家庭成员;primary 入边与 entryPersonIds 必须满足上一段的零条/恰一条森林不变量,所有父子关系都检查自环和有向循环。

边界固定形状为 { id, anchorType, anchorId, direction, reason, hiddenCount, cursor }。PERSON/FAMILY_UNIT 的 anchorId 引用对应实体,WINDOW 的 anchorId=nullhiddenCount 只能是非负整数或 nullcursor 仅在 reason=UNLOADED 时为非空字符串,MISSING/REDACTED 时固定为 null。窗口内匿名人物使用 Node.visibility=REDACTED,窗口外隐藏拓扑使用 boundary.reason=REDACTED,同一隐藏对象不得在一个响应中重复表达。

/lineage/tree/overview 只接受必填查询参数 treeVersion 并返回同一快照;版本已变化时返回 409 TREE_VERSION_CHANGEDLineageOverview 精确包含同一 { schemaVersion, treeVersion, generatedAt } 以及 state、genealogyId、genealogyPersonCount、genealogyRootPersonIds、redactedGenealogyRootCount、generationRange、buckets,以 state=EMPTY/POPULATED 使用 oneOf。EMPTY 精确要求 genealogyPersonCount=0、genealogyRootPersonIds=[]、redactedGenealogyRootCount=0、generationRange=null、buckets=[]。POPULATED 要求 genealogyPersonCount>0、非空 generationRange 和 buckets,且 genealogyRootPersonIds.length + redactedGenealogyRootCount >= 1;每个 bucket 固定包含 id、branchId、generation、visibleCount、redactedCount、unloadedCount、focusPersonId,三种 count 都是非负整数且全部 buckets 的三类计数总和等于 genealogyPersonCount

genealogyRootPersonIds 只包含调用者可见、可用于 FOCUS 的稳定人物 ID,隐私根只计入 redactedGenealogyRootCount,绝不返回 opaque ID。bucket 的 focusPersonId 只能是该 bucket 内可见稳定人物 ID,没有可见目标时固定为 null

/locator 返回 { treeVersion, genealogyId, personId, rootVisibility, rootPersonId, pathCompleteness, ancestorPathSegments, generation, branchId }。根可见性与路径完整性独立校验:rootVisibility=VISIBLE 要求可见稳定 rootPersonIdrootVisibility=REDACTED 要求 rootPersonId=nullpathCompleteness=COMPLETE 表示没有隐私缺口,pathCompleteness=REDACTED_GAPS 表示存在一个或多个隐私段,四种组合中除 REDACTED + COMPLETE 因根本身隐私而非法外,其余三种均可出现。

ancestorPathSegmentskind 使用 oneOf。VISIBLE 段精确为 { kind: "VISIBLE", personIds: [...] },personIds 非空且只含可见稳定 IDREDACTED 段精确为 { kind: "REDACTED", hiddenCount }hiddenCount 为正整数或不披露时为 null,不得含人物 ID。相邻段必须交替,末个 VISIBLE 段以目标结束;可见根时首个 VISIBLE 段从 rootPersonId 开始,隐私根时首段为 REDACTEDCOMPLETE 恰好没有 REDACTED 段,REDACTED_GAPS 至少一段。不得返回客户端路由或任何隐私 opaque ID。

所有世系写接口携带 If-Match: <treeVersion>;成功返回 { treeVersion, affectedPersonIds, affectedFamilyUnitIds, affectedRelationshipIds }。关系 PATCH 由 relationshipId 唯一寻址,并以不可变 relationshipKind 为 discriminator 使用 oneOfPARTNER 分支只允许 relationType/statusPARENT_CHILD 分支只允许 relationType/parentRole;每个分支除 relationshipKind 外至少提交一个可修改字段,未提交的可修改字段保持原值,空更新返回 HTTP 422 和稳定业务码 RELATIONSHIP_PATCH_EMPTY。kind 必须与服务端既有关系一致,参与人不可偷换。版本变化返回 HTTP 409 和稳定业务码 TREE_VERSION_CHANGED。旧 v1 树接口保持原合同;当前 App 只消费固定的四条 /genealogy/app/v2/... 新路径,不做双读或运行时探测。

{
  "oneOf": [
    { "relationshipKind": "PARTNER", "relationType": "MARRIAGE", "status": "ACTIVE" },
    { "relationshipKind": "PARENT_CHILD", "relationType": "BIOLOGICAL", "parentRole": "FATHER" }
  ]
}

两个请求分支都设置 additionalProperties: false,并以 minProperties 或等价 anyOf(required) 约束至少一个可修改字段;relationshipKind 必填且只作判别与一致性校验,不能通过 PATCH 改值。测试必须覆盖单字段更新、双字段更新、未提交字段保持原值,以及只有 relationshipKind 的空更新稳定失败为 422 RELATIONSHIP_PATCH_EMPTY

  • 步骤 1:先写严格接口合同并确认当前红灯

tests/lineage-openapi-contract.ps1 读取 APP.openapi.json,逐项断言上述四条固定 /genealogy/app/v2/... 路径、FOCUS/BOUNDARY 互斥参数、overview 必填 treeVersion、响应引用、两个 EMPTY/POPULATED schema、locator 根可见性 oneOfadditionalProperties: false、关系 oneOf/discriminator、完整枚举、稳定关系 ID、字符串 ID、nullable 头像、1/200/500 限制、400/401/403/404/422/429/5xx、409 和 If-Match;同时断言 App 的新 schema 不挂到 v1 树响应。YAML 必须存在相同 v2 路径与 schema 名。OpenAPI 静态合同只负责可表达的结构约束,跨 bucket 计数、根隐私、请求版本与响应版本一致性必须由任务 12 的运行时 validator 冒烟覆盖,不能用关键词或 schema 存在冒充。运行后预期明确报告缺少 overview、locator、relationship patch 和新 schema,而不是泛化为“接口错误”。

  • 步骤 2:把问题单写入唯一映射总表并交给后端

docs/接口与页面映射总表.md 的 T01 专节记录当前值、目标值、受影响页面、请求/响应、错误码、权限、验收步骤和上述 JSON 示例。不得新建第六份 Markdown。

  • 步骤 3:等待用户提供新的双格式导出

本步骤是硬门禁:只接受用户从后端更新后的 Apifox 重新导出的 APP.openapi.jsonAPP.openapi.yaml。不手改本地导出、不用 mock 假装接口已完成、不提前实施任务 12。

  • 步骤 4:验证新合同绿灯

运行 tests/lineage-openapi-contract.ps1。预期 LINEAGE-OPENAPI-CONTRACT PASS;再核对 JSON/YAML 的接口路径、operationId、参数、响应引用和模型字段一致,三人复核后才开启客户端实现。

任务 12:实现新图的规范化与严格校验

文件:

  • 新建:utils/lineage/normalize.js
  • 新建:utils/lineage/validate.js
  • 新建:tests/lineage-graph-contract-runtime-smoke.js
  • 新建:tests/lineage-overview-locator-runtime-smoke.js

接口:

  • normalizeLineageGraphWindow(raw): LineageGraphWindow

  • normalizeLineageOverview(raw): LineageOverview

  • normalizeLineageLocator(raw): LineageLocator

  • validateLineageGraph(graph): { valid: boolean, issues: Array<{ code, path, message }> }

  • validateLineageOverview(overview, { expectedTreeVersion }): { valid, issues }

  • validateLineageLocator(locator, { expectedTreeVersion, expectedPersonId }): { valid, issues }

  • assertLineageGraph(graph): LineageGraphWindow;非法时抛出 LineageGraphError,错误对象保留全部 issues。

  • assertLineageOverviewassertLineageLocator 使用同一错误结构,且分别在概览展示与定位回流前强制调用。

  • 步骤 1:先覆盖有效和无效图

测试至少构造:合法 EMPTY、合法 POPULATED、单亲、多配偶、收养次入边、带 secondary 入边但无 primary 入边的 DISCONNECTED_COMPONENT 入口、WINDOW_CUT 入口、窗口内匿名节点、窗口外隐私边界、未加载边界、WINDOW 空锚点和字符串大 ID。

无效图必须覆盖:EMPTY 仍带焦点或节点、POPULATED 无焦点或无入口;entryPersonIds 引用不存在人物、列表内人物 entryReason=null、列表外人物 entryReason!=null、入口仍有 primary 入边、非入口没有或具有多条 primary 入边;重复 ID、缺引用、自环、任意父子关系循环、反向代际、家庭锚点不等于唯一 ANCHOR、双人家庭缺关系对象或稳定 ID、relationshipKind 与容器不符、非法 state/sex/visibility/entryReason/partnerRole/relationType/status、REDACTED 泄漏身份字段或进入 focusPersonId/targetPersonId、父母不在家庭、子女同时在家庭、UNLOADED 无 cursor、非 UNLOADED 带 cursor、版本不匹配、数字 ID、旧 children/spouses、旧 parentId、旧 partnerIds、旧 rootPersonIds/rootReason 和旧 version.schema/version.tree

概览运行时冒烟必须覆盖合法 EMPTY、可见根 POPULATED、全隐私根 POPULATED;并拒绝 EMPTY 带根/范围/bucket 或非零总量、POPULATED 零总量/空范围/空 bucket/可见根数加隐私根数为零、bucket 三类计数与 genealogyPersonCount 不等、重复 bucket ID、genealogyRootPersonIds 或 bucket focusPersonId 出现 redacted: opaque ID、响应 treeVersion 与请求不一致。locator 必须覆盖“可见根+COMPLETE”“可见根+REDACTED_GAPS”“隐私根+REDACTED_GAPS”三种合法组合,并拒绝“隐私根+COMPLETE”、COMPLETE 含隐私段、REDACTED_GAPS 无隐私段、相邻同类段、首段与根可见性不符、可见根路径不从 rootPersonId 开始、末段不以目标结束、VISIBLE 段含 redacted: ID、REDACTED 段含人物 ID、响应版本或目标人物不匹配。

旧结构必须明确失败:

assert.throws(
  () => normalizeLineageGraphWindow([{ id: 1, children: [] }]),
  /LINEAGE_GRAPH_SHAPE_UNSUPPORTED/
);
  • 步骤 2:运行并确认红灯

运行两个 lineage 合同冒烟。预期因规范化与校验模块尚不存在而失败,且失败分别指向图窗口、概览或 locator,不得只报笼统 shape 错误。

  • 步骤 3:实现无猜测规范化

规范化只复制新合同字段、统一数组顺序和 null,不得出现以下兼容读法:

person.id || person.personId
generationNo || generation
children / spouses 递归展开
fatherId / motherId 推断 parentId
缺失 branch 时默认“主支”
缺失姓名时伪造“族人”

规范化阶段统一复制字段并按 order、字符串 id 作确定性排序;因此规范化最坏复杂度明确为 O(N log N + E),不冒充线性。相同无序输入经过规范化后必须得到字节级一致的规范图。

  • 步骤 4:实现 O(N+E) 校验

图窗口先建立人员、家庭、关系 ID、边和 boundary 的 Map,再检查引用;全部父子关系使用颜色 DFS 或 Kahn 拓扑检查有向循环,不允许只检查 primary 或让每个节点重新扫描全图。概览校验器单次遍历 buckets 完成计数、唯一性、根隐私和目标 ID 检查;locator 校验器按 discriminator 验证根、路径完整性、请求版本和目标人物。错误一次收集完整,页面可以显示诊断状态;生成投影前必须调用 assertLineageGraph,显示概览或应用定位前必须调用各自 assert。

  • 步骤 5:验证新旧边界

运行图窗口、概览/locator、OpenAPI、响应式合同和编译审计。预期新合法响应通过,旧递归、数字 ID、隐私 ID 泄漏、跨字段计数错误与版本错配均稳定失败。

任务 13:生成可见图投影与聚合节点

文件:

  • 新建:utils/lineage/project.js
  • 新建:tests/lineage-projection-runtime-smoke.js

接口:

  • projectLineageGraph(graph, { focusPersonId }): LineageProjection

  • 投影先通过唯一 primary 入边推导 protectedPathPersonIds/protectedPathFamilyUnitIds,它们是计算结果而不是第二份输入合同;不得写回规范图。

  • 输出包含可见真实节点、家庭、父子边、边界和稳定虚拟聚合节点;布局与 Scene 只消费该投影,搜索和线性列表继续消费完整规范图。聚合节点固定包含 direction、hiddenCount、focusable、targetPersonId:有隐藏可见成员时 focusable=true 且 target 是稳定人物 ID,仅含 REDACTED 时 focusable=false、targetPersonId=null

  • 步骤 1:先写 200 子女失败测试

构造祖父家庭 12 名子女、焦点父亲排第 9、焦点位于下一代的用例,先断言投影必须保留第 9 名父亲以及祖父→父亲→焦点的完整 primary 路径。再构造一个家庭 200 名有序子女,断言任何焦点下都最多保留 5 名真实子女,其余最多形成“前 N 人/后 N 人”两个聚合节点;前后计数之和加真实人数必须等于 200。补充混合可见/隐私区间与全隐私区间:前者 target 必须选择最近可见稳定 ID,后者必须 focusable=false、targetPersonId=null,任何 opaque ID 都不能成为跳转目标。聚合 ID 固定由家庭 ID、方向和被折叠 order 区间生成;20 组乱序原始输入必须先经过 normalizeLineageGraphWindow,再断言投影结果字节级一致。

  • 步骤 2:实现不修改源图的 O(N+E) 投影

投影只接受已经规范化且校验通过的图,按家庭一次分组并保持规范图既有顺序,不在投影内再次排序;沿 primary 入边从焦点回溯到窗口入口,得到唯一受保护路径。这样投影阶段保持 O(N+E)。路径上的每个家庭以通向焦点的子女为中心选最多 5 人,其他家庭稳定选前 5 人。前后聚合节点必须包含 direction、hiddenCount、focusable、targetPersonIdBACK/FRONT 在对应方向选择紧邻窗口的隐藏可见成员作为 target,区间仅含隐私成员时 target 固定为 null 且不可点击。点击可聚焦聚合节点后把稳定 targetPersonId 作为新焦点请求窗口,并把旧焦点写入最多 20 条的会话历史;不存在页内无限展开、折叠或 expandedAggregateIds,因此任意时刻每个家庭的真实子女上限始终为 5。

  • 步骤 3:验证完整图与投影职责

断言完整规范图仍含 200 名真实子女,线性列表可访问全部成员;投影只含当前可见真实节点和虚拟聚合节点;连续点击 BACK 聚合会按稳定目标推进焦点且每次仍最多 5 人,回到上一焦点恢复原投影;同一输入与焦点得到同一输出。运行投影、图、响应式合同与编译审计。

任务 14:实现确定性家庭联合点布局

文件:

  • 新建:utils/lineage/layout.js
  • 新建:tests/lineage-layout-runtime-smoke.js
  • 修改:tests/t01-relation-layout-contract.ps1

接口:

  • layoutLineageProjection(projection, options): LineageLayout

  • 输出:nodeRects: Map<string, Rect>familyPoints: Map<string, Point>generationBandsboundsprimaryForest

  • 世界坐标常量唯一放在 layout.jsNODE_WIDTH=168NODE_HEIGHT=86PARTNER_GAP=20SIBLING_GAP=36GENERATION_GAP=124;相机负责适配视口,页面不得转写这些值。

  • 步骤 1:先写三个已知缺陷和压力形状

布局测试必须精确断言:

assert.equal(singleChildX("fu-103-1"), familyPointX("fu-103-1"));
assert.deepEqual(partnerGroupMidpoint("fu-103-1"), familyPoint("fu-103-1"));

并覆盖 6 人、10×12、20×20、5×100、100×3、300×1、单家庭 200 子女投影、缺 12—49 代、乱序输入、单链 500 代纯逻辑和极不平衡树。当前布局必须先失败 103→106 的家庭点错位和宽支系确定性断言;连接器端点与 4rpx 间隙放到下一任务的 Scene 几何合同验证。

  • 步骤 2:实现线性预处理与主森林

generation 一次分组;每个孩子只用唯一 primary 入边参加主布局,次要收养/继亲/监护关系只参加后续连线。anchorPersonId 决定多配偶家庭围绕哪个主世系人物展开,禁止根据数组第一项猜测。

  • 步骤 3:实现自底向上的 tidy-tree

自底向上计算每个主子树与家庭单元所需宽度,自顶向下分配世界坐标。每个家庭联合点位于实际 partner 节点组中点;单子女严格与联合点同轴,多子女跨度中心严格等于联合点。多家庭单元按 order,id 稳定排列,不能复制同一人物节点。

  • 步骤 4:实现代际缺口与边界占位

代际差大于 1 时生成明确 gap band,而不是把不相邻世代压在相邻行;MISSING/REDACTED/UNLOADED 端点都有实际 Rect 和可连接锚点。

  • 步骤 5:验证复杂度与确定性

乱序输入 20 次的 nodeRects/familyPoints/bounds 必须字节级一致;500 节点布局不得出现嵌套全图扫描。运行布局合同、图合同、响应式合同和编译审计。

任务 15:实现 Scene、空间索引和相机

文件:

  • 新建:utils/lineage/scene.js
  • 新建:utils/lineage/spatial-index.js
  • 新建:utils/lineage/camera.js
  • 新建:tests/lineage-scene-runtime-smoke.js

接口:

  • buildLineageScene(projection, layout, { fontMetricsVersion }): Scene

  • Scene 根对象精确为 { sceneVersion, treeVersion, focusPersonId, bounds, items },不接受缺字段或额外字段。

  • createSpatialIndex(scene, cellSize = 256),输出 queryRect(rect)hitTest(point)

  • createCamera(viewport, worldBounds)panCamerazoomCameraAtfitCameraToIdsgetLod(previousLod, scale)

  • 步骤 1:先写十条几何不变量

逐边检查路径首尾锚点、相邻折线端点完全相等、DPR 误差不超过 0.5 物理像素、单子女无孤立零长横梁、Scene edge 可反查传输 edge、主关系无穿节点、主关系交叉为零、视口外端点但路径穿视口仍被查询、四类边界有端点、Scene 原子替换不混版本。每个家庭成员连接器 ID 必须是 family:{familyUnitId}:partner:{personId},每条父子边连接器 ID 必须是 edge:{edgeId}connectorStart(edge) 必须等于家庭联合点,connectorEnd(edge) 必须等于实际子女顶部锚点。选中前后 Rect、布局与锚点完全不变。缺少 sceneVersion 必须失败;相同 sceneVersion 对应不同序列化 payload 也必须按版本碰撞失败,不能静默覆盖。

  • 步骤 2:实现纯数据 Scene

utils/lineage/scene.jssceneVersion 的唯一所有者,按 treeVersion + projectionSignature + LINEAGE_LAYOUT_VERSION + fontMetricsVersion 生成确定性摘要;任何其他层不得拼接或递增该版本。Scene 只含世界坐标、稳定 ID、绘制类型、样式令牌和可访问文本,并可由 JSON.stringify/parse 无损往返;不得包含 Map、函数、空间索引、Vue、DOM、rpx、Canvas context 或平台对象。瞬时 selectedId 不属于 Scene payload,也不参与 sceneVersion;节点、文字、家庭联合点、连接器、聚合节点和代际标尺属于 Scene,选中光晕由 renderjs 使用独立 selectedId 与 Scene 人物 Rect 动态绘制,不改变 Scene payload 或 sceneVersion

  • 步骤 3:实现连接器与空间索引

Scene 只消费任务 13 已生成的真实节点与聚合节点,不重新截断或重排子女。每个 partner 到联合点各有一条可追踪连接器;单亲联合点与锚点重合时可省略孤立零长度笔画,但保留关系反查。每条 ParentChildEdge 只有一条联合点到子女的连接器。统一网格索引按路径包围盒登记连接器,确保穿越视口的长线不会因端点在外而消失。

  • 步骤 4:实现 LOD 与相机

缩放范围 0.30—2.00LOD 为 0.30—0.44 / 0.45—0.74 / 0.75—1.34 / 1.35—2.00,使用 0.05 迟滞。双指以手势中心缩放,搜索定位至少进入 L2;低于 12sp 的次要文字直接隐藏,不压缩到不可读。

  • 步骤 5:验证纯逻辑

运行 Scene 冒烟 20 次,断言命中、裁剪、LOD、相机锚点、聚合跳转目标和版本替换稳定,再运行固定验证。

任务 16:实现视口大小的单 Canvas 组件

文件:

  • 新建:components/LineageViewport.vue
  • 新建:tests/t01-large-lineage-contract.ps1
  • 修改:tests/t01-all-states-visual-contract.ps1

接口:

props: {
  scene: Object,
  initialCamera: Object,
  selectedId: String,
  bottomInset: Number,
}
emits: ["select", "focus", "camera-change", "request-boundary", "performance"]

scene 必须携带任务 15 生成的 scene.sceneVersion;组件没有第二个可独立传值的版本 prop,避免 payload 与版本漂移。

  • 步骤 1:先写组件边界合同

要求组件使用普通 Options API、包含 App-vue/H5 renderjs、Canvas 物理尺寸等于视口乘 DPR、同一绘制入口处理节点与边,并禁止 DOM 人物节点、DOM 拼线、全世界尺寸 Canvas、选中态改变节点宽高和逐帧跨层传输完整 Scene。合同还必须证明只改变 selectedId 时 Scene payload 与 scene.sceneVersion 不变,renderjs 仅在同一 Canvas 重绘光晕。

  • 步骤 2:实现 App-vue/H5 renderjs

renderjs 持有 Scene、空间索引、相机、手势、LOD 和绘制循环;业务层只在完整 scene.sceneVersion 变化时传入可 JSON 序列化的新 Scene。视图层先校验版本与 payload,再重建索引,最后一次性交换 Scene、索引和版本;相同版本但不同 payload 必须拒绝并保留旧 Scene。平移、缩放及惯性期间零跨层通信;只在手势结束、焦点改变或确需边界请求时通过 $ownerInstance.callMethod 回业务层,不做连续节流快照。

  • 步骤 3:实现绘制与原子替换

视口、底部 inset 或 DPR 改变时,先 setTransform(1,0,0,1,0,0) 重设状态,再把 backing store 设为逻辑视口乘 DPR、CSS 尺寸保持逻辑视口。每帧顺序固定为清屏、设置 DPR、应用相机矩阵、查询可见对象、先画连接器、再画联合点和节点、最后画文字与选中外光晕。字体就绪或回退字体变化后重新测量,生成新的 fontMetricsVersion 并请求新 Scene;新 Scene 完整校验并建立新索引后一次替换旧引用,失败继续显示旧 Scene 并上报,不得半图混画。

  • 步骤 4:实现 mp-weixin 适配和降级

mp-weixin 消费同一 Scene 和相机纯函数,通过平台 Canvas API 绘制;如果窗口超过平台实测能力或连续不达性能门槛,组件发出降级事件,由页面切换线性列表。不得为小程序另写关系规则。

  • 步骤 5:验证组件

运行大树组件合同、Scene 冒烟、响应式合同和编译审计;浏览器仅检查运行逻辑和 Canvas 尺寸,不作视觉通过结论。

任务 17:重建 T01 页面产品结构与无障碍列表

文件:

  • 新建:components/LineageAccessibleList.vue
  • 修改:pages/tree/t01-tree-overview.vue
  • 修改:tests/t01-tree-state-contract.ps1
  • 修改:tests/t01-tree-state-runtime-smoke.js
  • 修改:tests/t01-sheet-state-document-flow-contract.ps1

页面状态:

  • loading:保留页面骨架和明确加载反馈。

  • ready-graphCanvas 图模式。

  • ready-list:同一规范图的线性列表模式。

  • empty:仅由合法 state=EMPTY 进入,主操作进入 T04。

  • error:合同、权限、网络、版本冲突分别说明;不得混成空态。

  • 资料抽屉仅 collapsed/half 两档;完整资料进入 T03。

  • 步骤 1:先把旧全量 DOM/Grid 断言改成新结构

合同要求 LineageViewportLineageAccessibleList、搜索、全谱概览、关系图例、两档抽屉、44dp 控件和四类边界;禁止 tree-grid、全量 CSS grid track、多 <view> 拼线和页面内运行时 members

  • 步骤 2:实现初始焦点和窗口请求

客户端提供焦点的顺序固定为路由 selectedId、当前账号绑定成员 ID;两者都没有时省略 focusPersonId,由后端按主始祖确定并在响应中返回实际焦点。初始 mode=FOCUSancestorDepth=2descendantDepth=2。合法 state=EMPTY 直接显示空谱并允许进入 T04;只有 state=POPULATED 仍缺少或引用不到有效 focusPersonId 时才显示合同错误,客户端不从 nodes 数组猜成员。

  • 步骤 3:实现搜索、聚合跳转和焦点历史

单击 VISIBLE 人物节点只选择并打开半屏抽屉;双击、居中操作、搜索结果或可聚焦的“前 N 人/后 N 人”聚合节点才改变焦点并请求新窗口。REDACTED 节点只显示通用隐私说明,不允许双击、居中、进入详情或成为焦点;不可聚焦聚合节点也不发请求。focusPersonId 与聚合 targetPersonId 都只能使用稳定 VISIBLE 人物 ID,不在当前 Scene 追加更多真实子女。会话内保存最多 20 条 { focusPersonId, treeVersion, matrix },只用于“回到上一焦点”,不写本地存储。

  • 步骤 4:实现独立全谱概览

概览以当前窗口 treeVersion 请求 /lineage/tree/overview 的“世代×支系”聚合,不把全家谱所有人物缩成微型图。只有 bucket 提供 VISIBLE 稳定 focusPersonId 时才允许点击并请求局部窗口;隐私桶的目标为 null。收到 409 TREE_VERSION_CHANGED 时保留当前焦点,先刷新窗口,再用新版本重取概览,绝不混合两个快照。

  • 步骤 5:实现抽屉、列表和返回优先级

抽屉吸附完成后更新 bottomInset 并把选中节点移入剩余安全视口。Android 返回依次关闭搜索/概览/抽屉、回退焦点历史、最后 goBack。图和列表共享选中人物、焦点、树版本、已加载边界、焦点历史与搜索结果,不维护页内聚合展开状态。

  • 步骤 6:验证页面状态

运行三个 T01 聚焦合同、两个纯逻辑冒烟、响应式合同和编译审计;检查普通页面内容流、弹层 max-height、内部滚动和触控面积未回归。

任务 18:接入新接口、mock 和跨页变更版本

文件:

  • 修改:utils/api.js
  • 修改:data/mock.js
  • 修改:pages/tree/t01-tree-overview.vue
  • 修改:pages/tree/t03-member-profile.vue
  • 修改:pages/tree/t04-add-relative.vue
  • 修改:pages/tree/t06-edit-relationship.vue
  • 修改:pages/tree/t07-member-directory.vue
  • 修改:tests/lineage-graph-contract-runtime-smoke.js
  • 新建:tests/t01-navigation-integration-contract.ps1

接口:

appApi.getLineageWindow(genealogyId, query)
appApi.getLineageOverview(genealogyId, treeVersion)
appApi.locateLineagePerson(genealogyId, personId, treeVersion)
appApi.searchLineagePeople(genealogyId, keyword, cursor, limit)
appApi.createRelative(genealogyId, personId, payload, treeVersion)
appApi.updateRelationship(genealogyId, relationshipId, payload, treeVersion)
  • 步骤 1:先改 API 合同并确认旧适配失败

测试禁止 toTreeNode.map(toTreeNode)treeMembersid || personId、客户端 x/y 和数字 ID;要求六个新方法、窗口与 overview 各自的查询参数白名单、overview 必填 treeVersionIf-Match 和规范化/校验调用。REDACTED opaque ID 进入 FOCUS、locator、搜索、写接口或聚合 target 必须在客户端边界失败。

  • 步骤 2:原子替换旧 API 与 mock

删除 getTree 的递归数组返回和旧 treeMembersmock 直接提供合法 LineageGraphWindow、overview、locator、人员详情和版本化 mutation result。所有现有消费者同轮迁移,不能保留双读。

  • 步骤 3:实现边界增量与版本冲突

展开 UNLOADED 只携带当前 boundary cursor 和 treeVersion;新窗口校验成功后原子合并或替换。收到 409 TREE_VERSION_CHANGED 时保留当前焦点,提示数据已更新并用 locator 重新定位,不能把新节点配旧边。

  • 步骤 4:实现 T04/T06 完成回流

写成功结果带新 treeVersion 进入领域响应,但导航一次性结果仍只传 operation/entityId/refreshT01 收到后通过 locator 或新窗口响应定位 entityId。取消、写失败和版本冲突都不得生成成功导航结果。

tests/t01-navigation-integration-contract.ps1 只验证 T01 与已经完成的导航网关之间的焦点回流、取消和版本冲突;本阶段只运行既有 tests/navigation-flow-contract.ps1 作为回归门禁,绝不修改它,避免 T01 接口阶段反向改变已关闭的导航合同。

  • 步骤 5:验证接口与回流

运行 OpenAPI、图、布局、Scene、导航与 T01 聚焦合同,再运行响应式合同和编译审计。新有效路径通过,旧递归输入和旧字段明确失败。

任务 19:完成 T01 MuMu、性能与三人终审

文件:

  • 修改:docs/家谱项目全量治理设计.md

  • 修改:docs/家谱项目全量治理实施计划.md

  • 修改:docs/项目当前总览.md

  • 修改:docs/接口与页面映射总表.md

  • 步骤 1:运行全部自动化验证

运行全部 tests/*.ps1、全部 lineage Node 冒烟、导航 Node 冒烟和密码策略冒烟。预期全部退出码为 0Vue 覆盖仍为 66/66100% 100% no-repeat 与 Vue 直接 border-image-slice 扫描仍为 0

  • 步骤 2:执行 T01-MUMU-01 至 T01-MUMU-10
  1. 默认焦点上二代/下二代和 103→106 连线;
  2. 单指平移、双指缩放、按钮缩放和四级 LOD;
  3. 搜索或概览定位第 10、50、100、300 代;500 代单链只执行纯逻辑测试;
  4. 200 子女聚合、前后人数、聚合跳转、返回原焦点和祖先主路径连续性;
  5. 多配偶家庭联合点及对应子女归组;
  6. MISSING、REDACTED、UNLOADED、FAILED 四类端点;
  7. collapsed/half 抽屉、图/列表切换和全谱概览;
  8. T03/T04/T06/T07 的进入、返回、取消、完成和重复进入;
  9. 空谱、无权限、网络失败、版本冲突和异常图;
  10. 60 秒连续操作、20 次聚合跳转与搜索、自动几何探针和同场景逐边视觉探针。

所有视觉证据只来自当前 MuMu;忽略系统水印,不调整设备。

  • 步骤 3:验证性能门槛

  • 单窗口不超过 500 人;

  • dataReady(规范图校验完成)到 interactive(首帧绘制完成且命中索引可用)不超过 800ms;

  • 输入到反馈 p95 不超过 100ms,从视图层触摸时间戳量到下一完成帧;

  • 持续手势帧耗时 p95 不超过 32ms,由 renderjs 的 requestAnimationFrame 记录;

  • 不连续出现两帧超过 100ms

  • 内存先预热 3 轮,再在相同空闲点比较 20 次聚合跳转与返回后的驻留值,相对稳定基线增长不超过 15%。

每项 MuMu 时延至少采样 30 次,并使用 nearest-rank 计算 p95T01 不单独承担全 App 冷启动门槛。自动几何探针与同一场景逐边视觉探针都必须为零违规。

不达标时优先缩小窗口、减少可见对象或降级到线性列表;不得提高门槛、压缩字号或改成全量巨大 Canvas。

  • 步骤 4:三人独立终审

三人分别从真实数据、几何连续性、交互、视觉完整性、接口严谨性、无障碍、性能和维护成本反向质询。只有十条几何不变量、全部性能门槛、MuMu 十组矩阵和自动化合同同时通过,才把 T01 标记完成。

后续独立阶段边界

T01 完成后才依次为以下阶段重新执行三人只读审查、五方案比较、中文设计和中文实施计划;本计划不提前写其业务代码:

  1. 短信验证码完整状态机:发送资格、发送中、倒计时、失败、限流、前后台恢复、到期和重复发送。
  2. 跨页面领域数据持久化:会话、当前家谱、列表现场、写后失效、账号切换和权限变化;不把导航一次性结果升级成领域仓库。
  3. 全局文字层级与无障碍第二轮:字号层级、系统字号放大、焦点顺序、读屏语义、对比度和 44dp 触控目标。

不得在同一实现批次混合以上阶段,也不得借后续阶段返工已经通过且无回归证据的响应式页面。

当前计划完成条件

  • 阶段 0 的五份权威中文文档仍是唯一长期入口,没有新建旧版、备份或平行计划。
  • 导航与 T01 的所有者、旧路径删除条件、测试、MuMu 矩阵和后端接口缺口均已精确写明。
  • 当前只完成设计与实施计划,没有提前修改业务代码或手工改写 OpenAPI 导出。
  • 三人对业务正确性、全局一致性、回归风险、维护成本和 MuMu 可验证性形成一致结论。
  • 下一次实施从任务 1 的失败测试开始,不从页面机械替换开始。