# 原版 Web 系统「子页面嵌入挂载」最小侵入改造设计 - 日期:2026-06-17 - 状态:**设计稿 v2(已经 opus 双评审 + 代码实测修订,可据以实现)** - 涉及仓库: - **Web 端(被改造方)**:`D:\Git\lanbingtech\commercial-admin`(Vue3 + Vite + Arco,hash 路由) - **客户端(接入方)**:`D:\Git\lanbingtech\geopro`(Qt + `QWebEngineView`) - 需求背景:客户端需把原版 web 系统的**单个子功能页**(如"系统管理"下的组织/用户/角色,以及项目空间下的数据视图等)**裸挂**进客户端窗口,不带原系统的"标题栏 + 左菜单 + 页签"外壳,复用既有页面与鉴权。 - 设计约束(用户已确认): 1. **尽可能少改动原有代码**——既有函数保持行为零变化,优先"新增"而非"修改"。 2. token 注入:客户端用 `QWebEngineProfile` 预置 localStorage(首选)或 `loadStarted` 注入,token **不进 URL**。 3. 挂载范围:**同时支持租户空间页面(`space=2`)与项目空间页面(`space=3`,需 `projectId`)**。 > v2 修订摘要(详见 §10):EmbedLayout 必须写成**懒加载函数**(否则被 `cloneDeep` 拷坏);`generateEmbedRoutes` 必须定义在 **store 闭包内**;**独立 `QWebEngineProfile` 由建议升为强制**,并在生成路由前清理 pinia 持久化残留;token 注入首选 profile 预置。 --- ## 0. 为什么不能直接挂 URL(约束根因,已对照代码) | 阻碍 | 证据 | 含义 | |---|---|---| | 所有业务页面都是 `Layout` 子路由 | `stores/modules/route.js` `transformComponentView`:`'layout'→Layout`;`layout/index.vue` = Asider+Header+Tabs+Main | 任何路径渲染都带整套外壳 | | 业务路由运行时才动态注册 | `router/guard.js` `beforeEach` → `generateRoutes()`/`generateSpaceRoutes()` → `router.addRoute` | 不跑守卫,目标路由不存在,直接访问 404 | | 进入需登录态 + 空间上下文 | `getToken()` 读 `localStorage['token']`;`space==2/3`;项目空间依赖 `projectStore.projectId`(`computed(projectItem.id)`) | 必须先备好 token / projectId 再生成路由 | > 结论:裸挂子页 = 必须同时解决 **①去外壳、②触发动态路由生成、③补登录态/projectId** 三件事。 --- ## 1. 总体方案:新增「embed 引导入口 + EmbedLayout」,叠加而非改造 新增固定路由 `/embed` 作为引导页:读 URL 参数 → 备好上下文 → 调用**新增的** `generateEmbedRoutes`(用 `EmbedLayout` 包裹,无外壳)→ `router.replace` 到目标子页。正常登录链路**完全不经过**这些新增物。 ### 1.1 嵌入 URL 规范(统一入口,参数驱动) hash 路由下统一入口为 `/#/embed`,目标子页靠 query 指定: ``` http:///#/embed?space=<2|3>&projectId=&target= ``` | 参数 | 必填 | 说明 | |---|---|---| | `space` | 是 | `2`=租户空间,`3`=项目空间 | | `projectId` | 仅 `space=3` | 项目空间页面所属项目 id | | `target` | 是 | 叶子菜单路由路径,经 `encodeURIComponent` 编码 | 示例: - 系统管理·用户列表(租户):`http:///#/embed?space=2&target=%2ForganiMange%2FuserList` - 项目空间·数据视图:`http:///#/embed?space=3&projectId=123&target=%2FprojectSpace%2FdataView` > token 不进 URL(由客户端 profile 预置注入,见 §4)。`target` 即 `/organiMange/userList` 这类原叶子菜单路径,`encodeURIComponent` 后 `/`→`%2F`。 ### 1.2 改造性质:一次性框架级,非逐页改造 §2 列的 5 处改动全部是**框架级**(路由层 + 一个引导页 + 一个空壳布局),**与任何具体业务页无关**。改造完成后: - **嵌入任意叶子菜单页 = 只改 URL 的 `target`/`space`/`projectId`**,业务页代码一行不动。 - 不存在"为某个页面单独做嵌入适配"的工作。 唯一两个"非纯 URL"例外且均已收敛、非逐页: - **D 类**(极少数读 `currentSpace` 的页):引导页对 `space=2` **统一**补 `getEnterpriseUserInfoFun`(§3.2),一处兜底覆盖,非逐页。 - **详情页带参**:属行点击详情、**不在叶子菜单范围**(§11)。 --- ## 2. 改动分级(核心:既有逻辑零修改) | 项 | 文件 | 性质 | 是否改既有逻辑 | |---|---|---|---| | ① 新增 `EmbedLayout.vue` | `commercial-admin/src/layout/EmbedLayout.vue` | 全新文件 | 否 | | ② 新增引导页 `embed/index.vue` | `commercial-admin/src/views/embed/index.vue` | 全新文件 | 否 | | ③ 注册 `/embed` 固定路由 | `commercial-admin/src/router/route.js` | 在 `constantRoutes` **插入一项(404 兜底之前)** | 否(不动既有项) | | ④ 新增 `generateEmbedRoutes` | `commercial-admin/src/stores/modules/route.js` | **store 闭包内新增函数** + 给 `formatAsyncRoutes` 加 `export` | 既有函数体零修改 | | ⑤ guard 顶部早返回 | `commercial-admin/src/router/guard.js` | **唯一的"修改"**:`beforeEach` 顶部加 `if(embed) return` | 既有分支原样保留 | | ⑥ 客户端注入 token + 拼 URL + 独立 profile | `geopro` 客户端新增 `QWebEngineView` | 客户端侧新增 | 否 | > 唯一动到既有执行路径的是 ⑤,做成"顶部早返回"。验收基线:**不带 `/embed`、且 `EMBED_MODE` 未点亮时,执行路径与改造前一致**。 --- ## 3. Web 端详细设计 ### 3.1 ① `EmbedLayout.vue`(新文件) 仅保留 `` + ``,**不引入** Asider/Header/Tabs/DkFooter,**也不要 keep-alive**(`cacheList` 由 Tabs 组件填充,embed 无 Tabs 故恒为空,keep-alive 是死代码,去掉更简单——见 §10 M2)。 ```vue ``` > `useI18n`/`arcoLocales` 是全局插件,裸挂可用(评审已核实)。 ### 3.2 ② 引导页 `views/embed/index.vue`(新文件) 无业务渲染(仅 loading/错误占位)。`onMounted` 内按序引导,**关键顺序**:先清理 pinia 持久化残留 → 写 `projectItem.id`(项目空间)→ 生成路由 → replace。 ```js onMounted(async () => { try { sessionStorage.setItem('EMBED_MODE', '1') // 点亮 embed 标志(仅本 profile/tab) const { space, projectId, target } = route.query // token 已由客户端预置/注入 localStorage const sp = Number(space) // ① 清理持久化残留,防止跨会话/复用 profile 时脏读(见 §10 S3/M3) routeStore.$reset() projectStore.$reset() // ② 设置空间标识(廉价、无害,避免子页/外壳读到错误值) routeStore.isProjectSpace = (sp === 3) await userStore.getInfo() // 复用既有;401 时 /auth/user/info 被拦截器排除→仅 reject if (sp === 3) { projectStore.projectItem.id = projectId // 先写!generateEmbedRoutes(3) 读 projectId=computed(id) } else { await appStore.getEnterpriseUserInfoFun() // space=2 默认补企业信息(写 currentSpace),覆盖"企业空间"类叶子页 } await routeStore.generateEmbedRoutes(sp) // 新增函数 router.replace(decodeURIComponent(target)) // 进入目标子页,EmbedLayout 生效 } catch (e) { console.error('[embed] bootstrap failed:', e) // 显示错误占位,不主动跳登录(embed 无登录交互) errorMsg.value = '页面加载失败,请检查登录态或权限' } }) ``` ### 3.3 ③ 注册 `/embed`(`route.js`,插入 404 兜底之前) `constantRoutes` 中 `/:pathMatch(.*)*` 是兜底项,`/embed` 必须**插在它之前**(精确路由优先,避免匹配歧义): ```js export const constantRoutes = [ { path: '/redirect', /* ...既有不动... */ }, { path: '/embed', name: 'Embed', component: () => import('@/views/embed/index.vue'), meta: { hidden: false }, }, { path: '/:pathMatch(.*)*', /* 404 兜底,保持在最后 */ }, { path: '/403', /* ... */ }, ] ``` ### 3.4 ④ `generateEmbedRoutes`(`route.js` **store 闭包内**新增) 复用既有 `formatAsyncRoutes`/`flatMultiLevelRoutes`,**仅把顶层路由的 `component` 由 `Layout` 换成 `EmbedLayout`**(动态路由顶层节点恒为外壳包裹层;`flatMultiLevelRoutes` 只展平 children、不动顶层 component——评审已核实机制成立)。 **两处必须遵守(否则跑不起来,见 §10 S1/必错-1):** 1. `EmbedLayout` 写成**懒加载函数**,与 `Layout` 一致。原因:`generateEmbedRoutes` 内沿用既有 `cloneDeep`,lodash 对**函数**按引用返回、对**组件 options 对象**会深拷贝并拷坏。静态 `import EmbedLayout` 是对象 → 必被拷坏。 2. 函数定义在 `storeSetup()` **闭包内**(与 `generateRoutes`/`generateSpaceRoutes` 并列),否则 `setRoutes`/`router`/`projectStore` 未定义(它们是闭包内符号)。 ```js // 模块顶层:给既有 formatAsyncRoutes 加 export(纯导出,零行为变化) // 并新增懒加载的 EmbedLayout(不要静态 import!) const EmbedLayout = () => import('@/layout/EmbedLayout.vue') // —— 以下定义在 storeSetup() 内部,与 generateRoutes/generateSpaceRoutes 并列 —— const generateEmbedRoutes = (space) => { return new Promise((resolve, reject) => { const p = space === 3 ? getProjectRouteLimts({ projectId: projectStore.projectId }) : getUserRoute() p.then((res) => { try { const tree = space === 3 ? toArrayTree(res.data) : searchTree(res.data, (i) => Number.parseInt(i.clientType) === 2) const asyncRoutes = formatAsyncRoutes(tree) // 既有函数,零修改 asyncRoutes.forEach((r) => { r.component = EmbedLayout }) // ← 去外壳关键一步(顶层换壳) setRoutes(asyncRoutes) const flat = flatMultiLevelRoutes(cloneDeep(asyncRoutes)) // cloneDeep 对函数式组件按引用,安全 for (const route of flat) { if (!isHttp(route.path) && !router.hasRoute(route.name)) router.addRoute(route) } resolve() } catch (e) { reject(e) } }).catch(reject) }) } // return { ...原有, generateEmbedRoutes } ``` > `generateRoutes`/`generateSpaceRoutes`/`transformComponentView` **函数体一行不动**,正常登录仍走它们(带 `Layout`)。 > `router.hasRoute(route.name)` 是对既有 `generateSpaceRoutes` 里 `hasRoute(route)`(误传对象)的修正用法,仅用于新增函数内部,不影响既有。 ### 3.5 ⑤ guard 顶部早返回(`guard.js` 唯一修改) `router.beforeEach` **最顶部**(`NProgress.start()` 之后)加: ```js // —— embed 嵌入模式:放行,鉴权 + 路由生成由 /embed 引导页自管 —— if (to.path === '/embed' || sessionStorage.getItem('EMBED_MODE') === '1') { NProgress.done() return next() } ``` 既有所有分支(登录判断/空间检测/白名单/版本检测)**整段保留**。 **注意(见 §10 S2)**:早返回是 embed 下唯一放行路径——`router.replace(target)` 的二次 `beforeEach` 靠 `EMBED_MODE==='1'` 兜住,跳过 `generateSpaceRoutes`(避免再生成 Layout 版路由覆盖 EmbedLayout 版)。因此 `EMBED_MODE` 的可靠性是硬约束,必须配合独立 profile(§4)与生成前 `$reset`(§3.2)。 --- ## 4. 客户端 ↔ Web「嵌入契约」(geopro 侧) **强制:每个 embed 视图使用独立 `QWebEngineProfile`**,与系统浏览器登录态、与正常登录链路隔离,避免 pinia 持久化(routeStore/projectStore/userStore 均 `persist` 到 localStorage)跨链路污染。 token 注入**首选 profile 预置**(彻底消除时序竞态),`loadStarted` 注入为备选: ```cpp // 首选:独立 profile + 预置脚本,在文档创建早期写 localStorage auto* profile = new QWebEngineProfile(QStringLiteral("geopro-embed"), parent); // 独立 profile QWebEngineScript s; s.setInjectionPoint(QWebEngineScript::DocumentCreation); s.setWorldId(QWebEngineScript::MainWorld); s.setSourceCode(QStringLiteral( "localStorage.setItem('token','%1');" "localStorage.setItem('refleshToken','%2');").arg(token, refleshToken)); profile->scripts()->insert(s); auto* page = new QWebEnginePage(profile, parent); auto* view = new QWebEngineView(parent); view->setPage(page); // 目标页/空间/项目id 走 query(非敏感);hash 路由下 query 在 # 之后 // 租户(系统管理类): http:///#/embed?space=2&target=%2ForganiMange%2FuserList // 项目空间(数据视图): http:///#/embed?space=3&projectId=&target=%2FprojectSpace%2FdataView view->setUrl(QUrl(url)); ``` 约定:`target` 用 `encodeURIComponent` 编码(含 `/`),引导页 `decodeURIComponent` 还原。 --- ## 5. 关键边界与风险(已对照代码确认) 1. **项目空间生成顺序**:`generateEmbedRoutes(3)` → `getProjectRouteLimts({projectId: projectStore.projectId})`,`projectId=computed(projectItem.id)`。**必须先 `projectItem.id=` 再生成**,否则拉空菜单 → `target` 404。`projectItem` 是 reactive,直接赋值即触发 computed(评审已背书)。 2. **target 须在该账号权限内**:后端按角色过滤菜单,无权限页生成后仍无该路由 → 仍 404。属权限问题。 3. **EMBED_MODE 隔离(安全关键)**:用 `sessionStorage`,**严禁持久化**;并**强制独立 profile**。否则普通访问可能误进 embed 分支、跳过鉴权(见 §3.5/§10 S3)。 4. **pinia 持久化残留**:`routeStore`/`projectStore`/`userStore` 均 `persist`(localStorage)。引导页生成前必须 `$reset`,否则复用 profile 时回灌旧 projectId / 旧路由表(§10 M3)。 5. **后端零改动**:复用 `getUserRoute`/`getProjectRouteLimts`,不碰接口。 6. **token 时序**:profile 预置脚本在 DocumentCreation 注入,早于 Vue 初始化与首个守卫,无竞态(优于 `loadStarted`,见 §10 可能-1)。 7. **token 失效行为**:`http.js:70` 仅对**非** `/auth/user/info` 的 401 弹"重新登录"并跳 `/login`。embed 下 token 失效会弹标准 401 框→无外壳的登录页;属可接受边界,由客户端决定是否重新引导。 8. **外壳态依赖**:`isProjectSpace`/`currentSpace` 仅外壳组件与"企业空间"专页消费(已 grep 确认)。普通业务子页裸挂安全;目标若为企业空间专页(`enterpriseManage/enterpriseSpace`、`setting/profile` 等),引导页需补 `appStore.getEnterpriseUserInfoFun()`。 --- ## 6. 非目标(本轮 OUT) - 不改造外壳组件本身;不为子页做独立 Vite 打包入口;不在 web 端做 token 刷新的嵌入式交互;不用"CSS 隐藏外壳"临时方案(外壳仍实例化、有副作用,已否决)。 --- ## 7. 验收标准 1. **回归基线**:不带 `/embed` 且 `EMBED_MODE` 未点亮的所有访问行为与改造前一致;`generateRoutes`/`generateSpaceRoutes`/`transformComponentView`/`formatAsyncRoutes` 函数体未改(仅 `formatAsyncRoutes` 加 `export`)。 2. **租户空间挂载**:`#/embed?space=2&target=%2ForganiMange%2FuserList` 渲染用户列表且**无外壳**,数据正常。 3. **项目空间挂载**:`#/embed?space=3&projectId=&target=%2FprojectSpace%2FdataView` 正常渲染、projectId 正确。 4. **token 不外泄**:URL/历史/日志无 token;localStorage token 由 profile 预置成功。 5. **隔离**:普通浏览器新 tab 访问业务页,`EMBED_MODE` 不存在、守卫照常生效。 --- ## 8. 回滚 新增物删除即回滚;guard 早返回分支删除即恢复。无数据/接口副作用。 --- ## 9. 实现顺序 1. Web:EmbedLayout.vue → embed/index.vue → route.js 注册 + `generateEmbedRoutes`(闭包内、懒加载壳)→ guard 早返回。 2. 浏览器手验 `space=2`(console 预置 `localStorage.token` + `sessionStorage.EMBED_MODE=1` 后访问 `#/embed?...`)。 3. 验 `space=3`(有效 projectId)。 4. 客户端独立 profile + 预置脚本接入,端到端联调。 --- ## 10. 评审修正记录(opus 双评审 + commercial-admin 代码实测) **已采纳为硬性修正(不改跑不起来):** - **S1 / 必错-2(闭包作用域)**:`generateEmbedRoutes` 定义在 `storeSetup()` 内(`route.js:103-183` 区间),否则 `setRoutes`/`router`/`projectStore` ReferenceError。→ 已落到 §3.4。 - **必错-1(cloneDeep 拷坏组件)**:`EmbedLayout` 必须懒加载函数 `() => import()`。lodash `cloneDeep` 对作为对象属性的**函数按引用返回**(故原系统 `Layout=()=>import()` 正常),对**静态导入的组件 options 对象会深拷贝**并破坏 Vue 内部标识。→ 已落到 §3.4。 - **S3(持久化污染 + 隔离)**:`routeStore/projectStore/userStore` 均 `persist`(localStorage)。EmbedLayout 路由会被持久化,复用 profile 时污染正常链路/反之。→ 独立 profile 升为**强制**(§4),生成前 `$reset`(§3.2)。 - **M3(projectId 脏读)**:`dataView` setup 阶段快照式读 `projectId`(`views/projectSpace/dataView/index.vue:132`),叠加 persist 可能回灌旧值。→ `projectStore.$reset()` + 先赋值后生成(§3.2)。 - **token 时序(可能-1)**:`loadStarted` 的 `runJavaScript` 异步排队,不保证早于首个守卫。→ 改用 profile 预置脚本 DocumentCreation 注入为首选(§4/§5.6)。 - **/embed 插入位置(需确认-4)**:插在 `/:pathMatch(.*)*` 之前(§3.3)。 - **keep-alive(M2)**:EmbedLayout 去掉 keep-alive(cacheList 恒空)(§3.1)。 **已核实成立、予以背书(原 spec 正确):** - 顶层 `component` 换 EmbedLayout 去外壳机制成立(`formatAsyncRoutes` 顶层节点 component 即外壳,`flatMultiLevelRoutes` 不动顶层 component)。 - `projectStore.projectItem.id = projectId` 触发 computed,先赋值后生成"必要且充分"。 - `searchTree`/`toArrayTree` 用法与既有 `generateRoutes`/`generateSpaceRoutes` 一致。 - `useI18n`/`arcoLocales` 全局可用。 - 回归基线(仅 `formatAsyncRoutes` 加 `export` + guard 顶部分支)可验证。 **结论**:方向正确、工作量可控;上述硬性修正纳入 v2 后,**可据本 spec 进入实现**。"必错-1/必错-2"是两个不改连跑都跑不起来的点,实现时务必遵守 §3.4 两条约束。 **已核实安全(原列为"现场确认",实为静态可查,已查清):** - 外壳 provide/inject 依赖:`grep` 确认 `src/layout` + `App.vue` **零 `provide()`**;外壳对事件总线**零 emit**。两个首批目标页 `organiMange/userList.vue`、`projectSpace/dataView/index.vue` 自身**既不 `inject()` 也不订阅任何 bus**。现存 `inject()` 仅在 `projectSpace/datasetInfo` 子树(colorLevel/contourLevel/GprHeader),是页面内部父子 provide,与外壳无关。`coustomEventBus`(`utils/event-bus.js`)定义后全仓无消费方。→ **裸挂这两页对外壳上下文依赖为 0,安全**。其他目标页若纳入,按同样三步静态核对(外壳 provide?页面 inject?总线发射方是否在外壳?)即可,无需等运行时。 **实现前必须用真实菜单接口数据核验(唯一未离线证实项):** - **顶层菜单 component 恒为 `'layout'`**(见 §11 E 类):`generateEmbedRoutes` 换壳逻辑 `asyncRoutes.forEach(r => r.component = EmbedLayout)` 假设每个顶层路由都是外壳包裹层。需抓一次真实 `getUserRoute`(space=2)与 `getProjectRouteLimts`(space=3)响应,确认所有顶层节点 `component === 'layout'`(而非直接页面或 `ParentView`)。若存在非 layout 顶层,换壳会渲染空白 → 改为"仅替换 component 原为 'layout' 的顶层节点"。 **仍需留意:** - embed 路由 name 与正常路由同名:独立 profile 下 embed SPA 不会生成 Layout 版路由,无冲突;若未来同一 SPA 既登录又 embed,需给 embed 路由 name 加前缀。 --- ## 11. 覆盖范围:所有叶子菜单可导航页面(已代码实测) **结论:本方案完整覆盖"所有叶子菜单可导航到的页面"。** 因为叶子菜单导航 = 只给路径,进入所需上下文仅 `path + space + projectId`,恰为 embed 契约所提供;且这些页路由与正常登录一致生成,仅顶层外壳由 `Layout` 换为 `EmbedLayout`。 ### 为何成立(实测依据) - **叶子菜单页不带行参数**:靠 `?id=` 进入的详情页(`projectMange/projectConfiguration/details`、`templateManage/details`、`datasetInfo` 等)是从列表页**行点击 `router.push` 带参**进入的(证据:`projectMange/configuration.vue:91`、`projectSpace/templateManage/index.vue:105`),**不属于叶子菜单**,不在本范围内。 - **项目空间叶子页自取数据**:`abnormalBody/List`、`dataMange`、`projectStructure` 等自调 `getGsTreeFun`/`queryProjectGsStruct`(证据:`grep` 命中 `src/views/projectSpace/*`),不依赖前序页面预加载的 store → 可独立裸挂。 - **页内向自身详情下钻仍可用**:叶子页里行点击 push 到详情页时,该详情路由**也已在同空间一次性生成(EmbedLayout 版)**,故详情同样无外壳渲染。 ### 边界(非阻断,已收敛) - **D 类(极少数读外壳态的叶子页)**:如"企业空间"读 `currentSpace`。引导页对 `space=2` 默认补 `appStore.getEnterpriseUserInfoFun()`(§3.2 已落),`isProjectSpace` 亦在引导页设置 → 覆盖。 - **C 类(仅当嵌入页内"跨空间/回首页")**:单个叶子页本身不受影响;只有离开本页、跨租户↔项目空间或回工作台的跳转会脱离 embed 语境。属"离开该叶子页"的范畴,非"该叶子页能否嵌"。 - **E 类(唯一结构性前提)**:换壳假设顶层菜单 component 恒为 `'layout'`。本系统所有业务页现均带外壳,强烈暗示成立;但需用真实菜单接口数据核验(见 §10「实现前必须核验」)。 - **F 类(不适合/无意义)**:login/redirect/error;以及"导航中枢"类(工作台、项目列表,职责即切空间/进项目,单独嵌无意义)。这些通常也不是普通叶子功能菜单。