web版页面可嵌入改造方案

This commit is contained in:
gaozheng 2026-06-17 18:09:25 +08:00
parent 3635f295b2
commit a2e16e18e8
1 changed files with 341 additions and 0 deletions

View File

@ -0,0 +1,341 @@
# 原版 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以及"导航中枢"类(工作台、项目列表,职责即切空间/进项目,单独嵌无意义)。这些通常也不是普通叶子功能菜单。