geopro/docs/superpowers/specs/2026-06-17-web-embed-subpag...

342 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 原版 Web 系统「子页面嵌入挂载」最小侵入改造设计
- 日期2026-06-17
- 状态:**设计稿 v2已经 opus 双评审 + 代码实测修订,可据以实现)**
- 涉及仓库:
- **Web 端(被改造方)**`D:\Git\lanbingtech\commercial-admin`Vue3 + Vite + Arcohash 路由)
- **客户端(接入方)**`D:\Git\lanbingtech\geopro`Qt + `QWebEngineView`
- 需求背景:客户端需把原版 web 系统的**单个子功能页**(如"系统管理"下的组织/用户/角色,以及项目空间下的数据视图等)**裸挂**进客户端窗口,不带原系统的"标题栏 + 左菜单 + 页签"外壳,复用既有页面与鉴权。
- 设计约束(用户已确认):
1. **尽可能少改动原有代码**——既有函数保持行为零变化,优先"新增"而非"修改"。
2. token 注入:客户端用 `QWebEngineProfile` 预置 localStorage首选`loadStarted` 注入token **不进 URL**
3. 挂载范围:**同时支持租户空间页面(`space=2`)与项目空间页面(`space=3`,需 `projectId`**。
> v2 修订摘要(详见 §10EmbedLayout 必须写成**懒加载函数**(否则被 `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://<host>/#/embed?space=<2|3>&projectId=<pid>&target=<encodeURIComponent(叶子页路径)>
```
| 参数 | 必填 | 说明 |
|---|---|---|
| `space` | 是 | `2`=租户空间,`3`=项目空间 |
| `projectId` | 仅 `space=3` | 项目空间页面所属项目 id |
| `target` | 是 | 叶子菜单路由路径,经 `encodeURIComponent` 编码 |
示例:
- 系统管理·用户列表(租户):`http://<host>/#/embed?space=2&target=%2ForganiMange%2FuserList`
- 项目空间·数据视图:`http://<host>/#/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`(新文件)
仅保留 `<a-config-provider>` + `<router-view>`**不引入** Asider/Header/Tabs/DkFooter**也不要 keep-alive**`cacheList` 由 Tabs 组件填充embed 无 Tabs 故恒为空keep-alive 是死代码,去掉更简单——见 §10 M2
```vue
<template>
<a-config-provider :locale="arcoLocale">
<a-layout class="embed-main">
<router-view v-slot="{ Component, route }">
<component :is="Component" v-if="Component" :key="route.path" />
</router-view>
</a-layout>
</a-config-provider>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { arcoLocales } from '@/plugins/locales/i18n.js'
defineOptions({ name: 'EmbedLayout' })
const { locale } = useI18n()
const arcoLocale = computed(() => arcoLocales[locale.value])
</script>
<style scoped>.embed-main{width:100%;height:100%;overflow:hidden}</style>
```
> `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://<host>/#/embed?space=2&target=%2ForganiMange%2FuserList
// 项目空间(数据视图): http://<host>/#/embed?space=3&projectId=<pid>&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=<pid>&target=%2FprojectSpace%2FdataView` 正常渲染、projectId 正确。
4. **token 不外泄**URL/历史/日志无 tokenlocalStorage token 由 profile 预置成功。
5. **隔离**:普通浏览器新 tab 访问业务页,`EMBED_MODE` 不存在、守卫照常生效。
---
## 8. 回滚
新增物删除即回滚guard 早返回分支删除即恢复。无数据/接口副作用。
---
## 9. 实现顺序
1. WebEmbedLayout.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。
- **必错-1cloneDeep 拷坏组件)**`EmbedLayout` 必须懒加载函数 `() => import()`。lodash `cloneDeep` 对作为对象属性的**函数按引用返回**(故原系统 `Layout=()=>import()` 正常),对**静态导入的组件 options 对象会深拷贝**并破坏 Vue 内部标识。→ 已落到 §3.4。
- **S3持久化污染 + 隔离)**`routeStore/projectStore/userStore` 均 `persist`(localStorage)。EmbedLayout 路由会被持久化,复用 profile 时污染正常链路/反之。→ 独立 profile 升为**强制**§4生成前 `$reset`§3.2)。
- **M3projectId 脏读)**`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-aliveM2**EmbedLayout 去掉 keep-alivecacheList 恒空§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以及"导航中枢"类(工作台、项目列表,职责即切空间/进项目,单独嵌无意义)。这些通常也不是普通叶子功能菜单。