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

22 KiB
Raw Blame History

原版 Web 系统「子页面嵌入挂载」最小侵入改造设计

  • 日期2026-06-17
  • 状态:设计稿 v2已经 opus 双评审 + 代码实测修订,可据以实现)
  • 涉及仓库:
    • Web 端(被改造方)D:\Git\lanbingtech\commercial-adminVue3 + Vite + Arcohash 路由)
    • 客户端(接入方)D:\Git\lanbingtech\geoproQt + 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'→Layoutlayout/index.vue = Asider+Header+Tabs+Main 任何路径渲染都带整套外壳
业务路由运行时才动态注册 router/guard.js beforeEachgenerateRoutes()/generateSpaceRoutes()router.addRoute 不跑守卫,目标路由不存在,直接访问 404
进入需登录态 + 空间上下文 getToken()localStorage['token']space==2/3;项目空间依赖 projectStore.projectIdcomputed(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 预置注入,见 §4target/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 闭包内新增函数 + 给 formatAsyncRoutesexport 既有函数体零修改
⑤ 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-alivecacheList 由 Tabs 组件填充embed 无 Tabs 故恒为空keep-alive 是死代码,去掉更简单——见 §10 M2

<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。

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 ③ 注册 /embedroute.js,插入 404 兜底之前)

constantRoutes/:pathMatch(.*)* 是兜底项,/embed 必须插在它之前(精确路由优先,避免匹配歧义):

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 ④ generateEmbedRoutesroute.js store 闭包内新增)

复用既有 formatAsyncRoutes/flatMultiLevelRoutes仅把顶层路由的 componentLayout 换成 EmbedLayout(动态路由顶层节点恒为外壳包裹层;flatMultiLevelRoutes 只展平 children、不动顶层 component——评审已核实机制成立

两处必须遵守(否则跑不起来,见 §10 S1/必错-1

  1. EmbedLayout 写成懒加载函数,与 Layout 一致。原因:generateEmbedRoutes 内沿用既有 cloneDeeplodash 对函数按引用返回、对组件 options 对象会深拷贝并拷坏。静态 import EmbedLayout 是对象 → 必被拷坏。
  2. 函数定义在 storeSetup() 闭包内(与 generateRoutes/generateSpaceRoutes 并列),否则 setRoutes/router/projectStore 未定义(它们是闭包内符号)。
// 模块顶层:给既有 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) 是对既有 generateSpaceRouteshasRoute(route)(误传对象)的修正用法,仅用于新增函数内部,不影响既有。

3.5 ⑤ guard 顶部早返回(guard.js 唯一修改)

router.beforeEach 最顶部NProgress.start() 之后)加:

// —— embed 嵌入模式:放行,鉴权 + 路由生成由 /embed 引导页自管 ——
if (to.path === '/embed' || sessionStorage.getItem('EMBED_MODE') === '1') {
  NProgress.done()
  return next()
}

既有所有分支(登录判断/空间检测/白名单/版本检测)整段保留注意(见 §10 S2:早返回是 embed 下唯一放行路径——router.replace(target) 的二次 beforeEachEMBED_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 注入为备选:

// 首选:独立 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));

约定:targetencodeURIComponent 编码(含 /),引导页 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/userStorepersist(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/enterpriseSpacesetting/profile 等),引导页需补 appStore.getEnterpriseUserInfoFun()

6. 非目标(本轮 OUT

  • 不改造外壳组件本身;不为子页做独立 Vite 打包入口;不在 web 端做 token 刷新的嵌入式交互;不用"CSS 隐藏外壳"临时方案(外壳仍实例化、有副作用,已否决)。

7. 验收标准

  1. 回归基线:不带 /embedEMBED_MODE 未点亮的所有访问行为与改造前一致;generateRoutes/generateSpaceRoutes/transformComponentView/formatAsyncRoutes 函数体未改(仅 formatAsyncRoutesexport)。
  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=2console 预置 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/userStorepersist(localStorage)。EmbedLayout 路由会被持久化,复用 profile 时污染正常链路/反之。→ 独立 profile 升为强制§4生成前 $reset§3.2)。
  • M3projectId 脏读)dataView setup 阶段快照式读 projectIdviews/projectSpace/dataView/index.vue:132),叠加 persist 可能回灌旧值。→ projectStore.$reset() + 先赋值后生成§3.2)。
  • token 时序(可能-1loadStartedrunJavaScript 异步排队,不保证早于首个守卫。→ 改用 profile 预置脚本 DocumentCreation 注入为首选§4/§5.6)。
  • /embed 插入位置(需确认-4:插在 /:pathMatch(.*)* 之前§3.3)。
  • keep-aliveM2EmbedLayout 去掉 keep-alivecacheList 恒空§3.1)。

已核实成立、予以背书(原 spec 正确):

  • 顶层 component 换 EmbedLayout 去外壳机制成立(formatAsyncRoutes 顶层节点 component 即外壳,flatMultiLevelRoutes 不动顶层 component
  • projectStore.projectItem.id = projectId 触发 computed先赋值后生成"必要且充分"。
  • searchTree/toArrayTree 用法与既有 generateRoutes/generateSpaceRoutes 一致。
  • useI18n/arcoLocales 全局可用。
  • 回归基线(仅 formatAsyncRoutesexport + guard 顶部分支)可验证。

结论:方向正确、工作量可控;上述硬性修正纳入 v2 后,可据本 spec 进入实现。"必错-1/必错-2"是两个不改连跑都跑不起来的点,实现时务必遵守 §3.4 两条约束。

已核实安全(原列为"现场确认",实为静态可查,已查清):

  • 外壳 provide/inject 依赖:grep 确认 src/layout + App.vue provide();外壳对事件总线零 emit。两个首批目标页 organiMange/userList.vueprojectSpace/dataView/index.vue 自身既不 inject() 也不订阅任何 bus。现存 inject() 仅在 projectSpace/datasetInfo 子树colorLevel/contourLevel/GprHeader是页面内部父子 provide与外壳无关。coustomEventBusutils/event-bus.js)定义后全仓无消费方。→ 裸挂这两页对外壳上下文依赖为 0安全。其他目标页若纳入,按同样三步静态核对(外壳 provide页面 inject总线发射方是否在外壳即可无需等运行时。

实现前必须用真实菜单接口数据核验(唯一未离线证实项):

  • 顶层菜单 component 恒为 'layout'(见 §11 E 类):generateEmbedRoutes 换壳逻辑 asyncRoutes.forEach(r => r.component = EmbedLayout) 假设每个顶层路由都是外壳包裹层。需抓一次真实 getUserRoutespace=2getProjectRouteLimtsspace=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/detailstemplateManage/detailsdatasetInfo 等)是从列表页行点击 router.push 带参进入的(证据:projectMange/configuration.vue:91projectSpace/templateManage/index.vue:105不属于叶子菜单,不在本范围内。
  • 项目空间叶子页自取数据abnormalBody/ListdataMangeprojectStructure 等自调 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以及"导航中枢"类(工作台、项目列表,职责即切空间/进项目,单独嵌无意义)。这些通常也不是普通叶子功能菜单。