22 KiB
原版 Web 系统「子页面嵌入挂载」最小侵入改造设计
- 日期:2026-06-17
- 状态:设计稿 v2(已经 opus 双评审 + 代码实测修订,可据以实现)
- 涉及仓库:
- Web 端(被改造方):
D:\Git\lanbingtech\commercial-admin(Vue3 + Vite + Arco,hash 路由) - 客户端(接入方):
D:\Git\lanbingtech\geopro(Qt +QWebEngineView)
- Web 端(被改造方):
- 需求背景:客户端需把原版 web 系统的单个子功能页(如"系统管理"下的组织/用户/角色,以及项目空间下的数据视图等)裸挂进客户端窗口,不带原系统的"标题栏 + 左菜单 + 页签"外壳,复用既有页面与鉴权。
- 设计约束(用户已确认):
- 尽可能少改动原有代码——既有函数保持行为零变化,优先"新增"而非"修改"。
- token 注入:客户端用
QWebEngineProfile预置 localStorage(首选)或loadStarted注入,token 不进 URL。 - 挂载范围:同时支持租户空间页面(
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://<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)。
<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 ③ 注册 /embed(route.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 ④ generateEmbedRoutes(route.js store 闭包内新增)
复用既有 formatAsyncRoutes/flatMultiLevelRoutes,仅把顶层路由的 component 由 Layout 换成 EmbedLayout(动态路由顶层节点恒为外壳包裹层;flatMultiLevelRoutes 只展平 children、不动顶层 component——评审已核实机制成立)。
两处必须遵守(否则跑不起来,见 §10 S1/必错-1):
EmbedLayout写成懒加载函数,与Layout一致。原因:generateEmbedRoutes内沿用既有cloneDeep,lodash 对函数按引用返回、对组件 options 对象会深拷贝并拷坏。静态import EmbedLayout是对象 → 必被拷坏。- 函数定义在
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)是对既有generateSpaceRoutes里hasRoute(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) 的二次 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 注入为备选:
// 首选:独立 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. 关键边界与风险(已对照代码确认)
- 项目空间生成顺序:
generateEmbedRoutes(3)→getProjectRouteLimts({projectId: projectStore.projectId}),projectId=computed(projectItem.id)。必须先projectItem.id=再生成,否则拉空菜单 →target404。projectItem是 reactive,直接赋值即触发 computed(评审已背书)。 - target 须在该账号权限内:后端按角色过滤菜单,无权限页生成后仍无该路由 → 仍 404。属权限问题。
- EMBED_MODE 隔离(安全关键):用
sessionStorage,严禁持久化;并强制独立 profile。否则普通访问可能误进 embed 分支、跳过鉴权(见 §3.5/§10 S3)。 - pinia 持久化残留:
routeStore/projectStore/userStore均persist(localStorage)。引导页生成前必须$reset,否则复用 profile 时回灌旧 projectId / 旧路由表(§10 M3)。 - 后端零改动:复用
getUserRoute/getProjectRouteLimts,不碰接口。 - token 时序:profile 预置脚本在 DocumentCreation 注入,早于 Vue 初始化与首个守卫,无竞态(优于
loadStarted,见 §10 可能-1)。 - token 失效行为:
http.js:70仅对非/auth/user/info的 401 弹"重新登录"并跳/login。embed 下 token 失效会弹标准 401 框→无外壳的登录页;属可接受边界,由客户端决定是否重新引导。 - 外壳态依赖:
isProjectSpace/currentSpace仅外壳组件与"企业空间"专页消费(已 grep 确认)。普通业务子页裸挂安全;目标若为企业空间专页(enterpriseManage/enterpriseSpace、setting/profile等),引导页需补appStore.getEnterpriseUserInfoFun()。
6. 非目标(本轮 OUT)
- 不改造外壳组件本身;不为子页做独立 Vite 打包入口;不在 web 端做 token 刷新的嵌入式交互;不用"CSS 隐藏外壳"临时方案(外壳仍实例化、有副作用,已否决)。
7. 验收标准
- 回归基线:不带
/embed且EMBED_MODE未点亮的所有访问行为与改造前一致;generateRoutes/generateSpaceRoutes/transformComponentView/formatAsyncRoutes函数体未改(仅formatAsyncRoutes加export)。 - 租户空间挂载:
#/embed?space=2&target=%2ForganiMange%2FuserList渲染用户列表且无外壳,数据正常。 - 项目空间挂载:
#/embed?space=3&projectId=<pid>&target=%2FprojectSpace%2FdataView正常渲染、projectId 正确。 - token 不外泄:URL/历史/日志无 token;localStorage token 由 profile 预置成功。
- 隔离:普通浏览器新 tab 访问业务页,
EMBED_MODE不存在、守卫照常生效。
8. 回滚
新增物删除即回滚;guard 早返回分支删除即恢复。无数据/接口副作用。
9. 实现顺序
- Web:EmbedLayout.vue → embed/index.vue → route.js 注册 +
generateEmbedRoutes(闭包内、懒加载壳)→ guard 早返回。 - 浏览器手验
space=2(console 预置localStorage.token+sessionStorage.EMBED_MODE=1后访问#/embed?...)。 - 验
space=3(有效 projectId)。 - 客户端独立 profile + 预置脚本接入,端到端联调。
10. 评审修正记录(opus 双评审 + commercial-admin 代码实测)
已采纳为硬性修正(不改跑不起来):
- S1 / 必错-2(闭包作用域):
generateEmbedRoutes定义在storeSetup()内(route.js:103-183区间),否则setRoutes/router/projectStoreReferenceError。→ 已落到 §3.4。 - 必错-1(cloneDeep 拷坏组件):
EmbedLayout必须懒加载函数() => import()。lodashcloneDeep对作为对象属性的函数按引用返回(故原系统Layout=()=>import()正常),对静态导入的组件 options 对象会深拷贝并破坏 Vue 内部标识。→ 已落到 §3.4。 - S3(持久化污染 + 隔离):
routeStore/projectStore/userStore均persist(localStorage)。EmbedLayout 路由会被持久化,复用 profile 时污染正常链路/反之。→ 独立 profile 升为强制(§4),生成前$reset(§3.2)。 - M3(projectId 脏读):
dataViewsetup 阶段快照式读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;以及"导航中枢"类(工作台、项目列表,职责即切空间/进项目,单独嵌无意义)。这些通常也不是普通叶子功能菜单。