@bams-app/router 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.vscode/extensions.json +3 -0
- package/README.md +174 -0
- package/package.json +29 -0
- package/src/components/Error/image.png +0 -0
- package/src/components/Error/index.vue +21 -0
- package/src/components/Loading/index.vue +48 -0
- package/src/components/LoginMiddleware.vue +152 -0
- package/src/components/NoPermission/index.vue +42 -0
- package/src/components/NotFound/index.vue +53 -0
- package/src/components/SystemError/index.vue +60 -0
- package/src/createRouterInstance.js +24 -0
- package/src/createRoutes.js +96 -0
- package/src/index.js +16 -0
- package/src/registerRoutes.js +195 -0
- package/src/routerController.js +99 -0
- package/vite.config.js +7 -0
package/README.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# @bams-app/router
|
|
2
|
+
|
|
3
|
+
BAMS-Work 平台的路由包,提供完整的路由体系:静态路由表构建、全局权限守卫、菜单驱动的动态路由注册,以及 404 / 403 / 500 错误页与登录中间件。
|
|
4
|
+
|
|
5
|
+
## 目录结构
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
src/
|
|
9
|
+
├── index.js # 包入口(对外导出)
|
|
10
|
+
├── createRouterInstance.js # 独立创建 router 实例(开发调试用)
|
|
11
|
+
├── createRoutes.js # 构建静态路由表
|
|
12
|
+
├── routerController.js # 全局前置守卫(beforeEach)
|
|
13
|
+
├── registerRoutes.js # 动态路由注册与权限判断工具
|
|
14
|
+
└── components/
|
|
15
|
+
├── LoginMiddleware.vue # 登录中间件页面(/auth/login)
|
|
16
|
+
├── NoPermission/index.vue # 403 无权限页(挂载于 work 布局内)
|
|
17
|
+
├── NotFound/index.vue # 404 页面不存在
|
|
18
|
+
├── SystemError/index.vue # 500 系统错误页
|
|
19
|
+
├── Loading/index.vue # 异步组件加载中
|
|
20
|
+
└── Error/index.vue # 业务页面组件加载失败提示
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 快速开始(应用集成)
|
|
24
|
+
|
|
25
|
+
真实应用通过 `@bams-app/base` 接入本包,由 base 完成路由注册与守卫挂载:
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
// apps/bams-app/src/main.js
|
|
29
|
+
import { createApp } from "vue";
|
|
30
|
+
import App from "./App.vue";
|
|
31
|
+
import router from "./router";
|
|
32
|
+
import bamsBase from "@bams-app/base";
|
|
33
|
+
import WorkLayout from "@configurable/startr-layout"; // 菜单布局组件
|
|
34
|
+
|
|
35
|
+
// 注册业务页面组件到 window.ui(权限模型依赖该注册表区分 403/404)
|
|
36
|
+
import * as uiComponents from "@components-entry";
|
|
37
|
+
window.ui = Object.assign(window.ui || {}, uiComponents);
|
|
38
|
+
|
|
39
|
+
const app = createApp(App);
|
|
40
|
+
app.use(bamsBase, { router, WorkLayout });
|
|
41
|
+
app.$mount("#bams-app");
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`@bams-app/base` 内部会调用 `createRoutes(WorkLayout, router.getRoutes(), appConfig)` 生成路由表并逐个 `addRoute`,然后挂载 `router.beforeEach(routerController)`。
|
|
45
|
+
|
|
46
|
+
`createRouterInstance` 仅用于独立开发/调试(守卫在内部默认注释,便于脱离完整应用跑通基础路由)。
|
|
47
|
+
|
|
48
|
+
## 路由表结构
|
|
49
|
+
|
|
50
|
+
由 `createRoutes(workLayout, routes, appConfig)` 构建,`appConfig.defaultHome` 可指定默认首页(缺省 `pageHome`):
|
|
51
|
+
|
|
52
|
+
| 路径 | name | 说明 |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `/auth/login` | `authLogin` | 登录中间件页(匿名可访问) |
|
|
55
|
+
| `/login`、`/register` | `login`、`register` | 占位页,应用可注入同名路由覆盖 |
|
|
56
|
+
| `/:channel?/:projectId/work/` | `work` | 业务主布局路由,`children` 含 `403` 无权限页 |
|
|
57
|
+
| `/:channel?/:projectId/render/` | `render` | 渲染页(复用 `PublicContentView`) |
|
|
58
|
+
| `/:channel?/:projectId/anon/` | `anon` | 公开页(复用 `PublicContentView`,登录白名单) |
|
|
59
|
+
| `/404` | `404` | 页面不存在 |
|
|
60
|
+
| `/500` | `500` | 系统错误 |
|
|
61
|
+
| — | — | 末尾展开应用自定义路由(如 `/`、`/portal`) |
|
|
62
|
+
|
|
63
|
+
> 说明:work 根路径的 `redirect` 必须使用函数而非字符串拼接,否则 vue-router 会把 `:channel?` 中的 `?` 当作查询串分隔符解析,导致重定向目标错误跳 404。
|
|
64
|
+
|
|
65
|
+
## 用户登录流程
|
|
66
|
+
|
|
67
|
+
```mermaid
|
|
68
|
+
flowchart TD
|
|
69
|
+
START([用户访问页面]) --> Q1{URL 携带<br/>token/sessionId?}
|
|
70
|
+
|
|
71
|
+
subgraph AUTH_MID[登录中间件 /auth/login]
|
|
72
|
+
Q1 -- 是 --> AUTH[消费 token 同步会话]
|
|
73
|
+
AUTH --> Q2{会话同步成功?}
|
|
74
|
+
Q2 -- 是 --> REDIRECT[清理登录参数后回跳<br/>getSafeRedirectPath]
|
|
75
|
+
Q2 -- 否 --> LOGIN
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
subgraph ACCOUNT_LOGIN[账号密码登录 /login]
|
|
79
|
+
Q1 -- 否 --> Q3{本地已登录?<br/>checkLogin}
|
|
80
|
+
Q3 -- 否 --> LOGIN[输入账号密码登录]
|
|
81
|
+
LOGIN -- 成功 --> FETCH[拉取用户菜单并缓存<br/>localStorage 持久化]
|
|
82
|
+
Q3 -- 是 --> ROUTE[进入路由守卫分流]
|
|
83
|
+
FETCH --> REDIRECT
|
|
84
|
+
REDIRECT --> ROUTE
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
subgraph GUARD[路由守卫分流]
|
|
88
|
+
ROUTE --> Q4{work 路由?}
|
|
89
|
+
Q4 -- 是 --> Q5{菜单缓存为空?}
|
|
90
|
+
Q5 -- 是 --> LOAD[拉取菜单<br/>getMenuList]
|
|
91
|
+
LOAD -- 失败 --> ERR[/500 系统错误页/]
|
|
92
|
+
LOAD -- 成功 --> REG[幂等注册菜单路由<br/>registerRoutesByMenu]
|
|
93
|
+
Q5 -- 否 --> REG
|
|
94
|
+
REG --> Q6{页面有权限?<br/>hasRoute 通过}
|
|
95
|
+
Q6 -- 是 --> DONE([渲染业务页面])
|
|
96
|
+
Q6 -- 否 --> FORBID[/403 无权限 或 404 不存在<br/>按 window.ui 区分/]
|
|
97
|
+
Q4 -- 否 --> Q7{路由已注册?}
|
|
98
|
+
Q7 -- 是 --> DONE
|
|
99
|
+
Q7 -- 否 --> REG2[动态注册 render/anon 路由]
|
|
100
|
+
REG2 --> DONE
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## 全局守卫流程(routerController)
|
|
105
|
+
|
|
106
|
+
守卫按顺序处理,命中即返回:
|
|
107
|
+
|
|
108
|
+
1. **URL 携带 token**:`to.query.token` 存在且不在 `/auth/login` → 跳转 `/auth/login` 走登录中间件同步会话。
|
|
109
|
+
2. **未登录**:非匿名路由(`login`/`register`/`allowAnonymous`/`/anon/` 前缀)一律跳转 `/login`,携带 `sysCode` 与 `callback_url` 以便登录后回跳。
|
|
110
|
+
3. **work 路由**(`/.../work/...`):
|
|
111
|
+
- 菜单缓存为空时先拉取菜单(失败 → 跳 `/500`,携带 `source: "获取菜单失败"`);
|
|
112
|
+
- 缓存过期(`isMenuStale`)静默刷新,失败沿用旧缓存;
|
|
113
|
+
- 幂等批量注册全部菜单路由(`registerRoutesByMenu`);
|
|
114
|
+
- 当前导航尚未匹配(`to.matched.length === 0`,即首次访问动态页):页面在菜单中或为 `pageHome` 则重导航,否则按权限跳 403/404;
|
|
115
|
+
- 已匹配:`hasRoute` 通过则放行,否则跳 403/404。
|
|
116
|
+
4. **render/anon 等其余路由**:`hasRoute` 通过则放行,否则按 `registerRoutes` 动态注册。
|
|
117
|
+
|
|
118
|
+
## 核心 API
|
|
119
|
+
|
|
120
|
+
包入口 `index.js` 全部导出:
|
|
121
|
+
|
|
122
|
+
| 导出 | 说明 |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `createRoutes(workLayout, routes, appConfig)` | 构建静态路由表 |
|
|
125
|
+
| `routerController` | 全局前置守卫(需 `this` 绑定 router) |
|
|
126
|
+
| `hasRoute(to)` | 判断目标路由是否已注册(`this` 绑定 router) |
|
|
127
|
+
| `registerRoutes(to, from, next)` | 单个动态路由注册(render/anon),含重复注册死循环保护 |
|
|
128
|
+
| `registerRoutesByMenu(router)` | 按菜单幂等批量注册 work 动态路由,并补注 `pageHome` |
|
|
129
|
+
| `toNoPermissionOrNotFound(pageId, fromPath)` | 构造 403(页面存在但无权限)或 404(页面不存在)跳转目标 |
|
|
130
|
+
| `DEFAULT_HOME` | 默认首页 `pageHome` |
|
|
131
|
+
| `createRouterInstance(appConfig)` | 独立创建 router 实例(开发调试用) |
|
|
132
|
+
|
|
133
|
+
## 权限模型
|
|
134
|
+
|
|
135
|
+
权限由两个维度共同决定:
|
|
136
|
+
|
|
137
|
+
| 条件 | 结果 |
|
|
138
|
+
|---|---|
|
|
139
|
+
| 页面组件存在(`window.ui[id]`)**且**在用户菜单中 | 正常访问 |
|
|
140
|
+
| 页面组件存在但不在菜单中 | 403 无权限(`toNoPermissionOrNotFound` → `/work/403`) |
|
|
141
|
+
| 页面组件不存在 | 404 页面不存在(`toNoPermissionOrNotFound` → `/404`) |
|
|
142
|
+
| 在菜单中但组件未实现 | 注册成功,渲染 `Error` 加载失败提示 |
|
|
143
|
+
|
|
144
|
+
- **菜单**:由后端按用户权限返回(`getResourceByUserTree`),无权限菜单不会下发,对应路由不会被注册,权限天然由菜单保证。
|
|
145
|
+
- **window.ui**:前端各业务页面组件的注册表,守卫据此区分"无权限"与"不存在"。
|
|
146
|
+
|
|
147
|
+
## 动态路由注册
|
|
148
|
+
|
|
149
|
+
- 菜单路由:`registerRoutesByMenu` 遍历 `menuStore.menuPathMap`,对每个页面调用 `createRoute(id)` 注册为 work 子路由,幂等(已注册跳过)。
|
|
150
|
+
- `createRoute(id)`:iframe 菜单(`openType === "1"`)使用 `EmptyIframeRoute` 空占位组件(内容由内容区的 `BamsIframe` 渲染),普通菜单使用 `CheckAuthWorkPage(id)`。
|
|
151
|
+
- `CheckAuthWorkPage`:异步加载 `window.ui[routeName]`,配合 `Loading`(延迟 500ms 显示)与 `Error` 加载失败组件;通过 `defer()` 与 `window.__currWorkRoute__` 实现路由级权限确认(`resolve` 渲染业务页,`reject` 显示无权限)。
|
|
152
|
+
|
|
153
|
+
## 错误页体系
|
|
154
|
+
|
|
155
|
+
| 页面 | 触发场景 | 布局 | 展示信息 | 操作 |
|
|
156
|
+
|---|---|---|---|---|
|
|
157
|
+
| 403(`NoPermission`) | 页面组件存在但无权限 | **work 菜单布局内**(保留菜单便于继续导航) | 来源地址 | 返回上一步 |
|
|
158
|
+
| 404(`NotFound`) | 页面组件不存在 / 路由匹配失败 | 顶层独立页 | 来源地址 | 返回上一步 + 回到系统首页/返回首页 |
|
|
159
|
+
| 500(`SystemError`) | 获取菜单失败等系统错误 | 顶层独立页 | 错误来源、错误信息、访问地址 | 返回上一步 + 回到系统首页 |
|
|
160
|
+
|
|
161
|
+
- 所有错误页通过 `from` query 参数展示来源地址,便于排查是哪个地址触发的错误。
|
|
162
|
+
- 403 作为 work 布局的静态子路由,`hasRoute` 命中后守卫直接放行,不会再次进入权限判断造成循环。
|
|
163
|
+
- 顶层 `/403` 已移除,直接访问 `/403` 会落到 404(403 只在 work 上下文内产生)。
|
|
164
|
+
|
|
165
|
+
## 登录中间件(LoginMiddleware)
|
|
166
|
+
|
|
167
|
+
`/auth/login` 页面负责消费 URL 携带的登录参数(`token`/`sessionId`/`accountId` 等),完成会话同步后通过 `getSafeRedirectPath(redirect)` 跳回原地址。该函数会剔除全部登录敏感参数,保证回跳 URL 不再携带 token,避免与守卫的 token 分支构成死循环。
|
|
168
|
+
|
|
169
|
+
## 设计要点与常见问题
|
|
170
|
+
|
|
171
|
+
- **为何 403 放在 work 布局内?** 403 只会在 work 路由的权限判断中触发,放入布局子路由可保留菜单/页签,用户被拒绝访问后仍能自由导航。
|
|
172
|
+
- **重导航为何不会死循环?** 动态路由注册后 `hasRoute` 必为 true,`registerRoutes` 对重复注册有显式保护;403/404/500 均为静态路由,守卫对其直接放行。
|
|
173
|
+
- **`hasRoute` 中的 `/` 特判**:根路径由应用自身路由(如 `redirect: "/portal"`)处理,该特判作为兜底。
|
|
174
|
+
- **历史记录**:错误页跳转使用 `replace: true`,不污染浏览器历史;错误页"回到首页"使用 `window.location.replace`,避免产生多余历史记录。
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bams-app/router",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"dev": "vite",
|
|
9
|
+
"build": "vite build",
|
|
10
|
+
"preview": "vite preview"
|
|
11
|
+
},
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"vue": "^3.2.0",
|
|
14
|
+
"vue-router": "^4.0.0"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@bams-app/api-model": "*",
|
|
18
|
+
"@bams-app/app-layout": "*",
|
|
19
|
+
"@bams-app/store": "*",
|
|
20
|
+
"@bams-app/utils": "*",
|
|
21
|
+
"axios": "^1.13.2"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@vitejs/plugin-vue": "^6.0.1",
|
|
25
|
+
"vite": "^7.2.4",
|
|
26
|
+
"vue": "^3.5.24",
|
|
27
|
+
"vue-router": "4"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="error-container" :style="{ backgroundImage: `url(${bgImg})` }"></div>
|
|
3
|
+
</template>
|
|
4
|
+
|
|
5
|
+
<script setup>
|
|
6
|
+
import bgImg from "./image.png";
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<style scoped>
|
|
10
|
+
.error-container {
|
|
11
|
+
width: 100%;
|
|
12
|
+
height: 100%;
|
|
13
|
+
display: flex;
|
|
14
|
+
justify-content: center;
|
|
15
|
+
align-items: center;
|
|
16
|
+
background-color: var(--bams-page-bg);
|
|
17
|
+
background-repeat: no-repeat;
|
|
18
|
+
background-position: center;
|
|
19
|
+
background-size: contain;
|
|
20
|
+
}
|
|
21
|
+
</style>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="loading-container">
|
|
3
|
+
<div class="loading-spinner"></div>
|
|
4
|
+
<div class="loading-text">加载中</div>
|
|
5
|
+
</div>
|
|
6
|
+
</template>
|
|
7
|
+
|
|
8
|
+
<script setup>
|
|
9
|
+
// Vue3组合式API
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<style scoped>
|
|
13
|
+
.loading-container {
|
|
14
|
+
position: absolute;
|
|
15
|
+
top: 0;
|
|
16
|
+
left: 0;
|
|
17
|
+
width: 100%;
|
|
18
|
+
height: 100%;
|
|
19
|
+
background-color: color-mix(in srgb, var(--bams-surface-bg) 92%, transparent);
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-direction: column;
|
|
22
|
+
justify-content: center;
|
|
23
|
+
align-items: center;
|
|
24
|
+
z-index: 9999;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.loading-spinner {
|
|
28
|
+
width: 50px;
|
|
29
|
+
height: 50px;
|
|
30
|
+
border: 4px solid color-mix(in srgb, var(--bams-border-color) 30%, transparent);
|
|
31
|
+
border-top-color: var(--bams-primary);
|
|
32
|
+
border-radius: 50%;
|
|
33
|
+
animation: spin 1s linear infinite;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@keyframes spin {
|
|
37
|
+
to {
|
|
38
|
+
transform: rotate(360deg);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.loading-text {
|
|
43
|
+
margin-top: 20px;
|
|
44
|
+
color: var(--bams-text-color);
|
|
45
|
+
font-size: 16px;
|
|
46
|
+
font-weight: 500;
|
|
47
|
+
}
|
|
48
|
+
</style>
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="login-middleware">
|
|
3
|
+
<div v-if="loading" class="loading-state">
|
|
4
|
+
<a-spin size="large" tip="正在登录中,请稍候..." />
|
|
5
|
+
</div>
|
|
6
|
+
<div v-else-if="errorMessage" class="error-state">
|
|
7
|
+
<a-result status="error" title="登录失败" :sub-title="errorMessage">
|
|
8
|
+
<template #extra>
|
|
9
|
+
<a-button type="primary" @click="handleGoLogin">去登录</a-button>
|
|
10
|
+
</template>
|
|
11
|
+
</a-result>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
</template>
|
|
15
|
+
|
|
16
|
+
<script setup>
|
|
17
|
+
import { onMounted, ref } from "vue";
|
|
18
|
+
import { useRoute, useRouter } from "vue-router";
|
|
19
|
+
import { useUserStore } from "@bams-app/store";
|
|
20
|
+
import { sysApi } from "@bams-app/api-model";
|
|
21
|
+
import * as utils from "@bams-app/utils";
|
|
22
|
+
import axios from "axios";
|
|
23
|
+
|
|
24
|
+
const route = useRoute();
|
|
25
|
+
const router = useRouter();
|
|
26
|
+
const userStore = useUserStore();
|
|
27
|
+
const loading = ref(true);
|
|
28
|
+
const errorMessage = ref("");
|
|
29
|
+
|
|
30
|
+
const buildCallbackUrl = target => {
|
|
31
|
+
const safeRedirectPath = getSafeRedirectPath(target);
|
|
32
|
+
const redirectUrl = new URL(safeRedirectPath, window.location.origin);
|
|
33
|
+
const { BASE_URL = "/" } = utils.env.getEnv();
|
|
34
|
+
const normalizedBaseUrl = BASE_URL === "/" ? "/" : `/${String(BASE_URL).replace(/^\/+|\/+$/g, "")}/`;
|
|
35
|
+
|
|
36
|
+
if (normalizedBaseUrl !== "/" && !redirectUrl.pathname.startsWith(normalizedBaseUrl)) {
|
|
37
|
+
redirectUrl.pathname = `${normalizedBaseUrl.replace(/\/$/, "")}/${redirectUrl.pathname.replace(/^\/+/, "")}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return redirectUrl.toString();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const handleGoLogin = () => {
|
|
44
|
+
const { redirect } = route.query;
|
|
45
|
+
utils.auth.redirectToLoginPage({
|
|
46
|
+
callbackPath: buildCallbackUrl(redirect),
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 获取安全的跳转路径,移除 URL 中的登录相关参数
|
|
52
|
+
*/
|
|
53
|
+
const getSafeRedirectPath = target => {
|
|
54
|
+
let targetPath = target || "/";
|
|
55
|
+
try {
|
|
56
|
+
const url = new URL(targetPath, window.location.origin);
|
|
57
|
+
const paramsToDelete = ["sessionId", "token", "accountId", "appRoleId", "corpCode", "menuType", "sysCode", "sessionExpire"];
|
|
58
|
+
paramsToDelete.forEach(param => url.searchParams.delete(param));
|
|
59
|
+
// 如果是同域跳转,保留相对路径
|
|
60
|
+
return url.pathname + url.search + url.hash;
|
|
61
|
+
} catch (e) {
|
|
62
|
+
// 如果解析失败,简单处理,只保留路径部分
|
|
63
|
+
return targetPath.split("?")[0];
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
onMounted(async () => {
|
|
68
|
+
const { sessionId, token, menuType, accountId, redirect, sysCode } = route.query;
|
|
69
|
+
|
|
70
|
+
// 1. 如果已经登录(登录组件已在本页建立会话),直接回跳,
|
|
71
|
+
// 避免再次调用 getAccountData,同时避免二次请求失败时把已建立的会话清掉
|
|
72
|
+
// if (userStore.checkLogin() || auth.isLogin()) {
|
|
73
|
+
// router.replace(getSafeRedirectPath(redirect));
|
|
74
|
+
// return;
|
|
75
|
+
// }
|
|
76
|
+
|
|
77
|
+
if (!token) {
|
|
78
|
+
errorMessage.value = "登录参数缺失 (token),请通过正确渠道访问系统。";
|
|
79
|
+
loading.value = false;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
// 调用 getAccountData 接口获取用户信息
|
|
85
|
+
const res = await axios.get("/admin-api/dac/gate/getAccountData?token=" + token, {
|
|
86
|
+
headers: {
|
|
87
|
+
sysCode: sysCode,
|
|
88
|
+
sessionid: sessionId,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (res.data.code != 200) {
|
|
93
|
+
throw new Error(res.data.msg || "获取用户信息失败");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const userData = res.data.data;
|
|
97
|
+
if (!userData || (typeof userData === "object" && !Array.isArray(userData) && Object.keys(userData).length === 0)) {
|
|
98
|
+
throw new Error("用户信息为空,token 已失效");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
userStore.login({
|
|
102
|
+
userId: userData.userId,
|
|
103
|
+
userName: userData.userName,
|
|
104
|
+
phone: userData.phone,
|
|
105
|
+
orgName: userData.orgName,
|
|
106
|
+
orgCode: userData.orgCode,
|
|
107
|
+
token,
|
|
108
|
+
sessionId,
|
|
109
|
+
menuType,
|
|
110
|
+
accountId: userData.accountId || accountId,
|
|
111
|
+
role: "user",
|
|
112
|
+
permissionMap: {},
|
|
113
|
+
data: userData,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// message.success("登录成功");
|
|
117
|
+
|
|
118
|
+
// 登录成功后拉取一次按钮权限表(失败不影响跳转,独立 try/catch 包裹)
|
|
119
|
+
try {
|
|
120
|
+
const permData = await sysApi.getButtonsBySessionld({ sysCode: sysCode }, { sessionId: sessionId });
|
|
121
|
+
userStore.setPermissions(permData.buttons || {});
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error("获取按钮权限失败:", error);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 跳转回目标页面或首页,并移除 URL 中的 sessionId 等登录参数
|
|
127
|
+
router.replace(getSafeRedirectPath(redirect));
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.error("中间件登录失败:", error);
|
|
130
|
+
userStore.logout();
|
|
131
|
+
errorMessage.value = "登录校验失败,请检查会话是否有效。";
|
|
132
|
+
} finally {
|
|
133
|
+
loading.value = false;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
</script>
|
|
137
|
+
|
|
138
|
+
<style scoped>
|
|
139
|
+
.login-middleware {
|
|
140
|
+
height: 100vh;
|
|
141
|
+
display: flex;
|
|
142
|
+
align-items: center;
|
|
143
|
+
justify-content: center;
|
|
144
|
+
background: var(--bams-page-bg);
|
|
145
|
+
}
|
|
146
|
+
.loading-state,
|
|
147
|
+
.error-state {
|
|
148
|
+
width: 100%;
|
|
149
|
+
max-width: 500px;
|
|
150
|
+
text-align: center;
|
|
151
|
+
}
|
|
152
|
+
</style>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="no-permission-container">
|
|
3
|
+
<a-result status="403" title="403" :sub-title="subTitle">
|
|
4
|
+
<template #extra>
|
|
5
|
+
<a-button type="primary" @click="goBack">返回上一步</a-button>
|
|
6
|
+
</template>
|
|
7
|
+
</a-result>
|
|
8
|
+
</div>
|
|
9
|
+
</template>
|
|
10
|
+
|
|
11
|
+
<script setup>
|
|
12
|
+
import { computed } from "vue";
|
|
13
|
+
import { useRoute, useRouter } from "vue-router";
|
|
14
|
+
|
|
15
|
+
const route = useRoute();
|
|
16
|
+
const router = useRouter();
|
|
17
|
+
|
|
18
|
+
const fromPath = computed(() => (typeof route.query.from === "string" ? route.query.from : ""));
|
|
19
|
+
const currentPath = computed(() => fromPath.value || route.fullPath);
|
|
20
|
+
const subTitle = computed(() => `抱歉,您没有权限访问该页面。访问地址:${currentPath.value}`);
|
|
21
|
+
|
|
22
|
+
const goBack = () => {
|
|
23
|
+
if (window.history.length > 1) {
|
|
24
|
+
router.back();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
// 历史记录不足时兜底回到系统首页(403 必然处于 work 布局下)
|
|
28
|
+
const match = currentPath.value.match(/^(\/.*\/work\/).*$/);
|
|
29
|
+
const targetPath = match ? `${match[1]}pageHome` : "/";
|
|
30
|
+
window.location.replace(router.resolve(targetPath).href);
|
|
31
|
+
};
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<style scoped>
|
|
35
|
+
.no-permission-container {
|
|
36
|
+
height: 100%;
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
justify-content: center;
|
|
40
|
+
background-color: var(--bams-page-bg);
|
|
41
|
+
}
|
|
42
|
+
</style>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="not-found-container">
|
|
3
|
+
<a-result status="404" title="404" :sub-title="subTitle">
|
|
4
|
+
<template #extra>
|
|
5
|
+
<a-button @click="goBack">返回上一步</a-button>
|
|
6
|
+
<a-button type="primary" @click="goHome">{{ backButtonText }}</a-button>
|
|
7
|
+
</template>
|
|
8
|
+
</a-result>
|
|
9
|
+
</div>
|
|
10
|
+
</template>
|
|
11
|
+
|
|
12
|
+
<script setup>
|
|
13
|
+
import { computed } from "vue";
|
|
14
|
+
import { useRoute, useRouter } from "vue-router";
|
|
15
|
+
|
|
16
|
+
const route = useRoute();
|
|
17
|
+
const router = useRouter();
|
|
18
|
+
|
|
19
|
+
const fromPath = computed(() => (typeof route.query.from === "string" ? route.query.from : ""));
|
|
20
|
+
const notFoundPath = computed(() => fromPath.value || route.fullPath);
|
|
21
|
+
const subTitle = computed(() => `抱歉,您访问的页面不存在。访问地址:${notFoundPath.value}`);
|
|
22
|
+
const isFromWork = computed(() => fromPath.value.includes("/work/"));
|
|
23
|
+
const backButtonText = computed(() => (isFromWork.value ? "回到系统首页" : "返回首页"));
|
|
24
|
+
|
|
25
|
+
const goHome = () => {
|
|
26
|
+
let targetPath = "/";
|
|
27
|
+
if (isFromWork.value) {
|
|
28
|
+
const match = fromPath.value.match(/^(\/.*\/work\/).*$/);
|
|
29
|
+
if (match) {
|
|
30
|
+
targetPath = `${match[1]}pageHome`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
window.location.replace(router.resolve(targetPath).href);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const goBack = () => {
|
|
37
|
+
if (window.history.length > 1) {
|
|
38
|
+
router.back();
|
|
39
|
+
} else {
|
|
40
|
+
goHome();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<style scoped>
|
|
46
|
+
.not-found-container {
|
|
47
|
+
height: 100%;
|
|
48
|
+
display: flex;
|
|
49
|
+
align-items: center;
|
|
50
|
+
justify-content: center;
|
|
51
|
+
background-color: var(--bams-page-bg);
|
|
52
|
+
}
|
|
53
|
+
</style>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="system-error-container">
|
|
3
|
+
<a-result status="error" title="系统错误" :sub-title="subTitle">
|
|
4
|
+
<template #extra>
|
|
5
|
+
<a-button @click="goBack">返回上一步</a-button>
|
|
6
|
+
<a-button type="primary" @click="goHome">{{ backButtonText }}</a-button>
|
|
7
|
+
</template>
|
|
8
|
+
</a-result>
|
|
9
|
+
</div>
|
|
10
|
+
</template>
|
|
11
|
+
|
|
12
|
+
<script setup>
|
|
13
|
+
import { computed } from "vue";
|
|
14
|
+
import { useRoute, useRouter } from "vue-router";
|
|
15
|
+
|
|
16
|
+
const route = useRoute();
|
|
17
|
+
const router = useRouter();
|
|
18
|
+
|
|
19
|
+
const errorSource = computed(() => (typeof route.query.source === "string" ? route.query.source : ""));
|
|
20
|
+
const errorMessage = computed(() => (typeof route.query.message === "string" ? route.query.message : ""));
|
|
21
|
+
const fromPath = computed(() => (typeof route.query.from === "string" ? route.query.from : ""));
|
|
22
|
+
const subTitle = computed(() => {
|
|
23
|
+
const parts = [];
|
|
24
|
+
if (errorSource.value) parts.push(`错误来源:${errorSource.value}`);
|
|
25
|
+
if (errorMessage.value) parts.push(`错误信息:${errorMessage.value}`);
|
|
26
|
+
if (fromPath.value) parts.push(`访问地址:${fromPath.value}`);
|
|
27
|
+
return parts.length ? parts.join(";") : "页面加载失败,请稍后重试。";
|
|
28
|
+
});
|
|
29
|
+
const isFromWork = computed(() => fromPath.value.includes("/work/"));
|
|
30
|
+
const backButtonText = computed(() => (isFromWork.value ? "回到系统首页" : "返回首页"));
|
|
31
|
+
|
|
32
|
+
const goHome = () => {
|
|
33
|
+
let targetPath = "/";
|
|
34
|
+
if (isFromWork.value) {
|
|
35
|
+
const match = fromPath.value.match(/^(\/.*\/work\/).*$/);
|
|
36
|
+
if (match) {
|
|
37
|
+
targetPath = `${match[1]}pageHome`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
window.location.replace(router.resolve(targetPath).href);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const goBack = () => {
|
|
44
|
+
if (window.history.length > 1) {
|
|
45
|
+
router.back();
|
|
46
|
+
} else {
|
|
47
|
+
goHome();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<style scoped>
|
|
53
|
+
.system-error-container {
|
|
54
|
+
height: 100%;
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
justify-content: center;
|
|
58
|
+
background-color: var(--bams-page-bg);
|
|
59
|
+
}
|
|
60
|
+
</style>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createRouter, createWebHistory } from 'vue-router';
|
|
2
|
+
import createRoutes from './createRoutes.js';
|
|
3
|
+
import routerController from './routerController.js';
|
|
4
|
+
|
|
5
|
+
const createRouterInstance = (appConfig = {}) => {
|
|
6
|
+
const router = createRouter({
|
|
7
|
+
history: createWebHistory(),
|
|
8
|
+
routes: createRoutes([], appConfig)
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
// 处理路由重复导航的问题
|
|
12
|
+
// const originalPush = router.push;
|
|
13
|
+
// router.push = function push(location, onComplete, onAbort) {
|
|
14
|
+
// if (onComplete || onAbort) return originalPush.call(this, location, onComplete, onAbort);
|
|
15
|
+
// return originalPush.call(this, location).catch(err => err);
|
|
16
|
+
// };
|
|
17
|
+
|
|
18
|
+
// 路由守卫
|
|
19
|
+
// router.beforeEach(routerController.bind(router));
|
|
20
|
+
|
|
21
|
+
return router;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default createRouterInstance;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { AppLayoutPublicContentView as PublicContentView } from "@bams-app/app-layout";
|
|
2
|
+
import LoginMiddleware from "./components/LoginMiddleware.vue";
|
|
3
|
+
import NotFound from "./components/NotFound/index.vue";
|
|
4
|
+
import NoPermission from "./components/NoPermission/index.vue";
|
|
5
|
+
import SystemError from "./components/SystemError/index.vue";
|
|
6
|
+
import { h } from "vue";
|
|
7
|
+
import { DEFAULT_HOME } from "./registerRoutes.js";
|
|
8
|
+
|
|
9
|
+
export default function createRoutes(workLayout = undefined, routes = [], appConfig = {}) {
|
|
10
|
+
const { defaultHome = DEFAULT_HOME } = appConfig;
|
|
11
|
+
return [
|
|
12
|
+
{
|
|
13
|
+
path: "/auth/login",
|
|
14
|
+
name: "authLogin",
|
|
15
|
+
component: LoginMiddleware,
|
|
16
|
+
meta: { allowAnonymous: true }
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
path: "/login",
|
|
20
|
+
name: "login",
|
|
21
|
+
// component: () => import(/* webpackChunkName: "login" */ "../login/index.vue")
|
|
22
|
+
component: h("div", "登录页")
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
path: "/register",
|
|
26
|
+
name: "register",
|
|
27
|
+
component: h("div", "注册页")
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "work",
|
|
31
|
+
path: "/:channel?/:projectId/work/",
|
|
32
|
+
// 注意:不能用字符串重定向(如 `/:channel?/:projectId/work/${defaultHome}`),
|
|
33
|
+
// vue-router 会把路径中的 `?`(`:channel?` 的可选参数标记)当作查询串分隔符解析,
|
|
34
|
+
// 导致重定向目标被解析成 "/:channel",匹配不到任何路由而跳 404。
|
|
35
|
+
// 因此必须使用函数重定向,基于当前路由参数拼接目标路径。
|
|
36
|
+
redirect: (to) => {
|
|
37
|
+
const { channel, projectId } = to.params;
|
|
38
|
+
const workHome = defaultHome || DEFAULT_HOME;
|
|
39
|
+
return `/${[channel, projectId, "work", workHome].filter(Boolean).join("/")}`;
|
|
40
|
+
},
|
|
41
|
+
beforeEnter(to, from, next) {
|
|
42
|
+
next();
|
|
43
|
+
},
|
|
44
|
+
props(route) {
|
|
45
|
+
return { appConfig, mode: "prod" };
|
|
46
|
+
},
|
|
47
|
+
component: workLayout,
|
|
48
|
+
// 403 无权限页仅在 work 路由下触发(routerController 权限判断),
|
|
49
|
+
// 作为 work 布局子路由渲染,保留菜单布局便于用户继续导航
|
|
50
|
+
children: [
|
|
51
|
+
{
|
|
52
|
+
path: "403",
|
|
53
|
+
name: "403",
|
|
54
|
+
component: NoPermission
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "render",
|
|
60
|
+
path: "/:channel?/:projectId/render/",
|
|
61
|
+
redirect: `/:channel?/:projectId/render/${defaultHome}`,
|
|
62
|
+
beforeEnter(to, from, next) {
|
|
63
|
+
next();
|
|
64
|
+
},
|
|
65
|
+
props(route) {
|
|
66
|
+
return { appConfig, mode: "prod" };
|
|
67
|
+
},
|
|
68
|
+
// 渲染页复用内容视图组件,与菜单驱动页面保持一致(路由视图 / iframe / 缓存行为一致)
|
|
69
|
+
component: PublicContentView
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "anon",
|
|
73
|
+
path: "/:channel?/:projectId/anon/",
|
|
74
|
+
redirect: `/:channel?/:projectId/anon/${defaultHome}`,
|
|
75
|
+
beforeEnter(to, from, next) {
|
|
76
|
+
next();
|
|
77
|
+
},
|
|
78
|
+
props(route) {
|
|
79
|
+
return { appConfig, mode: "prod" };
|
|
80
|
+
},
|
|
81
|
+
// 公开页复用内容视图组件,与菜单驱动页面保持一致(路由视图 / iframe / 缓存行为一致)
|
|
82
|
+
component: PublicContentView
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
path: "/404",
|
|
86
|
+
name: "404",
|
|
87
|
+
component: NotFound
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
path: "/500",
|
|
91
|
+
name: "500",
|
|
92
|
+
component: SystemError
|
|
93
|
+
},
|
|
94
|
+
...routes
|
|
95
|
+
];
|
|
96
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
|
|
2
|
+
export { default as createRouterInstance } from './createRouterInstance.js';
|
|
3
|
+
export { default as createRoutes } from './createRoutes.js';
|
|
4
|
+
export { default as routerController } from './routerController.js';
|
|
5
|
+
export { hasRoute, registerRoutes, registerRoutesByMenu, toNoPermissionOrNotFound, DEFAULT_HOME } from './registerRoutes.js';
|
|
6
|
+
|
|
7
|
+
const install = function (Vue) {
|
|
8
|
+
if (install.installed) return;
|
|
9
|
+
install.installed = true;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
if (process.env.NODE_ENV !== "development" && typeof window !== "undefined" && window.Vue) {
|
|
13
|
+
install(window.Vue);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default { install };
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { defineComponent, ref, onMounted, onActivated, h, defineAsyncComponent, markRaw } from 'vue';
|
|
2
|
+
import { useRoute } from 'vue-router';
|
|
3
|
+
import * as utils from "@bams-app/utils";
|
|
4
|
+
import Loading from "./components/Loading/index.vue";
|
|
5
|
+
import Error from "./components/Error/index.vue";
|
|
6
|
+
import { useMenuStore } from "@bams-app/store";
|
|
7
|
+
|
|
8
|
+
const reg = /^\/(?:[^\/]+\/){1,2}(work|render|anon)\/([^\/]+)\/?$/;
|
|
9
|
+
|
|
10
|
+
// 默认首页:不在权限菜单中(菜单由后端按用户权限返回),但必须始终注册并可访问,
|
|
11
|
+
// createRoutes 中 work 根路径重定向即指向该页面
|
|
12
|
+
export const DEFAULT_HOME = "pageHome";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 构造无权限(403)或页面不存在(404)的跳转目标:
|
|
16
|
+
* 页面组件存在(window.ui 已注册)但不在用户菜单中 = 无权限(403);组件不存在 = 页面不存在(404)
|
|
17
|
+
* 403 挂在 work 布局下(保留菜单布局),需从来源地址提取 work 前缀拼接跳转路径
|
|
18
|
+
* @param {string} pageId 页面 ID
|
|
19
|
+
* @param {string} fromPath 来源地址
|
|
20
|
+
* @returns {object} 路由跳转目标
|
|
21
|
+
*/
|
|
22
|
+
export function toNoPermissionOrNotFound(pageId, fromPath) {
|
|
23
|
+
if (window.ui && window.ui[pageId]) {
|
|
24
|
+
const match = fromPath.match(/^(\/.*\/work\/).*$/);
|
|
25
|
+
const base = match ? match[1] : "/";
|
|
26
|
+
return { path: `${base}403`, query: { from: fromPath }, replace: true };
|
|
27
|
+
}
|
|
28
|
+
return { path: "/404", query: { from: fromPath }, replace: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 判断当前访问路由是否存在
|
|
33
|
+
* @param {object} to
|
|
34
|
+
* @returns {boolean}
|
|
35
|
+
*/
|
|
36
|
+
export function hasRoute(to) {
|
|
37
|
+
if (!to.name) return false;
|
|
38
|
+
let router = this;
|
|
39
|
+
let find = router.getRoutes().find((item) => to.path === "/" || to.name === item.name);
|
|
40
|
+
return !!find;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 当前路由如果不存在,执行注册路由
|
|
45
|
+
* @param {object} to
|
|
46
|
+
* @param {object} from
|
|
47
|
+
* @param {function} next
|
|
48
|
+
*/
|
|
49
|
+
export function registerRoutes(to, from, next) {
|
|
50
|
+
let router = this;
|
|
51
|
+
const match = to.path.match(reg);
|
|
52
|
+
|
|
53
|
+
if (match) {
|
|
54
|
+
const type = match[1];
|
|
55
|
+
const id = match[2];
|
|
56
|
+
const route = createRoute(id);
|
|
57
|
+
|
|
58
|
+
// 防止重复注册导致的死循环
|
|
59
|
+
if (router.hasRoute(route.name)) {
|
|
60
|
+
console.error(`路由 [${route.name}] 已存在,但路径 [${to.path}] 仍未匹配成功。请检查父路由配置。`);
|
|
61
|
+
next({ path: "/404", query: { from: to.fullPath }, replace: true });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
router.addRoute(type, route);
|
|
66
|
+
next({ ...to, replace: false });
|
|
67
|
+
} else {
|
|
68
|
+
next({ path: "/404", query: { from: to.fullPath }, replace: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 菜单驱动批量注册 work 路由(幂等)
|
|
74
|
+
* work 路由表由权限过滤后的菜单生成:
|
|
75
|
+
* - 菜单是后端按用户权限返回的(getResourceByUserTree),无权限的菜单不会出现,对应路由不会被注册
|
|
76
|
+
* - 已注册的路由跳过,重复调用无副作用
|
|
77
|
+
* @param {object} router
|
|
78
|
+
*/
|
|
79
|
+
export function registerRoutesByMenu(router) {
|
|
80
|
+
const menuStore = useMenuStore();
|
|
81
|
+
const menuPathMap = menuStore.menuPathMap || {};
|
|
82
|
+
Object.keys(menuPathMap).forEach(id => {
|
|
83
|
+
if (router.hasRoute(id)) return;
|
|
84
|
+
router.addRoute("work", createRoute(id));
|
|
85
|
+
});
|
|
86
|
+
// 默认首页不在菜单清单中,需单独注册,否则 work 根路径重定向到 pageHome 会 404
|
|
87
|
+
if (!router.hasRoute(DEFAULT_HOME)) {
|
|
88
|
+
router.addRoute("work", createRoute(DEFAULT_HOME));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// iframe 菜单的路由仅作为跳转占位,内容由内容区(ContentRouteAndIframeView)的 BamsIframe 渲染:
|
|
93
|
+
// 空组件避免 iframe 模式下被隐藏挂载时误加载业务组件或改写 window.__currWorkRoute__
|
|
94
|
+
const EmptyIframeRoute = defineComponent({
|
|
95
|
+
name: "EmptyIframeRoute",
|
|
96
|
+
render: () => null
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 创建一个路由表
|
|
101
|
+
* @param {string} id
|
|
102
|
+
* @returns route
|
|
103
|
+
*/
|
|
104
|
+
export function createRoute(id) {
|
|
105
|
+
const menuStore = useMenuStore();
|
|
106
|
+
const menuItem = menuStore.menuPathMap[id];
|
|
107
|
+
const isIframeMenu = String(menuItem?.openType) === "1";
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
path: id,
|
|
111
|
+
name: id,
|
|
112
|
+
meta: { id: id, isWork: true, isCache: menuItem?.isCache },
|
|
113
|
+
props: (route) => ({ workId: id }),
|
|
114
|
+
// iframe 菜单的内容由内容区渲染,路由组件仅作占位
|
|
115
|
+
component: isIframeMenu ? EmptyIframeRoute : CheckAuthWorkPage(id)
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* route页面权限拦截组件
|
|
121
|
+
* window.__currWorkRoute__ 代表当前正在访问的work route
|
|
122
|
+
* window.__currWorkRoute__.resolve 有权限,显示业务页面
|
|
123
|
+
* window.__currWorkRoute__.reject 无权限,显示无权限提示
|
|
124
|
+
*/
|
|
125
|
+
const CheckAuthWorkPage = function (routeName) {
|
|
126
|
+
return defineComponent({
|
|
127
|
+
name: `CheckAuthWorkPage_${routeName}`,
|
|
128
|
+
inject: ['appConfig'],
|
|
129
|
+
setup(props) {
|
|
130
|
+
const status = ref('pending'); // pending(页面正在打开...) | resolve(有权限) | reject(无权限)
|
|
131
|
+
const route = useRoute();
|
|
132
|
+
const { promise, resolve, reject } = utils.base.defer();
|
|
133
|
+
const ErrorComponent = defineComponent({
|
|
134
|
+
name: `WorkRouteError_${routeName}`,
|
|
135
|
+
render() {
|
|
136
|
+
return h(Error, {
|
|
137
|
+
menuCode: routeName
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
const WorkComponent = markRaw(defineAsyncComponent({
|
|
142
|
+
// 核心:组件加载函数(必须,返回Promise)
|
|
143
|
+
loader: window.ui && window.ui[routeName] || (() => Promise.reject(`组件{${routeName}}不存在`)),
|
|
144
|
+
// 加载中显示的组件(可选)
|
|
145
|
+
loadingComponent: Loading,
|
|
146
|
+
// 加载失败显示的组件(可选)
|
|
147
|
+
errorComponent: ErrorComponent,
|
|
148
|
+
// 延迟多久显示加载中组件(ms,默认200),避免网络快时闪屏
|
|
149
|
+
delay: 500,
|
|
150
|
+
// 加载超时时间(ms,可选),超时会触发errorComponent
|
|
151
|
+
timeout: 20000,
|
|
152
|
+
// 加载失败时是否允许重试(可选,默认false)
|
|
153
|
+
retry: true,
|
|
154
|
+
// 重试的间隔时间(ms,可选,配合retry使用)
|
|
155
|
+
retryDelay: 1000
|
|
156
|
+
}));
|
|
157
|
+
|
|
158
|
+
promise
|
|
159
|
+
.then(() => {
|
|
160
|
+
status.value = "resolve";
|
|
161
|
+
})
|
|
162
|
+
.catch(() => {
|
|
163
|
+
status.value = "reject";
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
onMounted(() => {
|
|
167
|
+
window.__currWorkRoute__ = { resolve, reject };
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
onActivated(() => {
|
|
171
|
+
// window.__currWorkRoute__ = { resolve, reject };
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// setTimeout(() => {
|
|
175
|
+
// window.__currWorkRoute__.resolve();
|
|
176
|
+
// }, 1000);
|
|
177
|
+
|
|
178
|
+
resolve();
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
status,
|
|
182
|
+
route,
|
|
183
|
+
WorkComponent
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
render() {
|
|
187
|
+
if (this.status === "pending") return h(Loading);
|
|
188
|
+
if (this.status === "reject") return h("div", {}, "无权限");
|
|
189
|
+
if (this.status === "resolve") {
|
|
190
|
+
return h(this.WorkComponent);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { hasRoute, registerRoutes, registerRoutesByMenu, toNoPermissionOrNotFound, DEFAULT_HOME } from "./registerRoutes.js";
|
|
2
|
+
import { useUserStore, useMenuStore } from "@bams-app/store";
|
|
3
|
+
import { auth, url } from "@bams-app/utils";
|
|
4
|
+
|
|
5
|
+
const allowList = ["login", "register"];
|
|
6
|
+
const loginRoutePath = "/login";
|
|
7
|
+
// 路径白名单:无需登录即可访问的路径
|
|
8
|
+
const pathWhiteList = ["/anon/"];
|
|
9
|
+
// work 路由前缀:/:channel?/:projectId/work/...
|
|
10
|
+
const workRouteReg = /^\/(?:[^\/]+\/){1,2}work\//;
|
|
11
|
+
|
|
12
|
+
export default async function routerController(to, from, next) {
|
|
13
|
+
const router = this;
|
|
14
|
+
const userStore = useUserStore();
|
|
15
|
+
const menuStore = useMenuStore();
|
|
16
|
+
|
|
17
|
+
// 1. 处理 URL 携带 sessionId/token 的登录中间件跳转
|
|
18
|
+
// 只有在未登录状态下,才跳转到授权页面进行登录同步
|
|
19
|
+
if (to.query.token && to.path !== "/auth/login") {
|
|
20
|
+
next({
|
|
21
|
+
path: "/auth/login",
|
|
22
|
+
query: {
|
|
23
|
+
...to.query,
|
|
24
|
+
redirect: to.fullPath
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 2. 未登录时,仅允许访问匿名路由,其余统一跳转登录页
|
|
31
|
+
const isAllowAnonymousRoute = allowList.includes(to.name) || to.meta?.allowAnonymous || to.path === loginRoutePath || pathWhiteList.some(path => to.path.includes(path));
|
|
32
|
+
if (!userStore.checkLogin() && !isAllowAnonymousRoute) {
|
|
33
|
+
next({
|
|
34
|
+
path: loginRoutePath,
|
|
35
|
+
query: {
|
|
36
|
+
sysCode: auth.getSysCode(),
|
|
37
|
+
callback_url: to.href
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 3. work 路由:基于缓存菜单批量注册,不再逐条动态注册
|
|
44
|
+
// 登录成功后已拉取菜单并缓存(localStorage 持久化),这里直接用缓存数据注册,
|
|
45
|
+
// 权限由菜单天然保证;仅当缓存为空(如缓存被清理)时才兜底拉取一次,
|
|
46
|
+
// 缓存超过有效期(MENU_CACHE_TTL)时静默刷新,失败则沿用旧缓存
|
|
47
|
+
if (workRouteReg.test(to.path)) {
|
|
48
|
+
if (Object.keys(menuStore.menuPathMap).length === 0) {
|
|
49
|
+
try {
|
|
50
|
+
await menuStore.getMenuList();
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error("获取菜单失败,无法注册 work 路由:", error);
|
|
53
|
+
next({
|
|
54
|
+
path: "/500",
|
|
55
|
+
query: { source: "获取菜单失败", from: to.fullPath, message: error?.message || String(error) },
|
|
56
|
+
replace: true
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
} else if (menuStore.isMenuStale) {
|
|
61
|
+
// 有缓存但已过期:刷新一次,失败不影响旧缓存继续使用
|
|
62
|
+
try {
|
|
63
|
+
await menuStore.getMenuList();
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error("刷新菜单失败,使用缓存菜单:", error);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// 幂等批量注册全部菜单路由
|
|
69
|
+
registerRoutesByMenu(router);
|
|
70
|
+
// 路由刚注册,当前导航的 to 对象尚未重新解析(to.name/to.matched 均为空,hasRoute 必为 false),
|
|
71
|
+
// 需重导航一次让 vue-router 匹配新注册的路由;菜单中存在该页面才重导航,否则直接 404,
|
|
72
|
+
// 避免无权限页面在此处反复重导航造成死循环
|
|
73
|
+
if (to.matched.length === 0) {
|
|
74
|
+
const pageId = to.path.split("/").filter(Boolean).pop();
|
|
75
|
+
// 默认首页不在菜单中,但属于内置页面,同样放行重导航
|
|
76
|
+
if (menuStore.menuPathMap[pageId] || pageId === DEFAULT_HOME) {
|
|
77
|
+
next({ ...to, replace: true });
|
|
78
|
+
} else {
|
|
79
|
+
// TODO 临时放开:不在菜单中的 work 路由也允许访问,动态注册后重导航
|
|
80
|
+
// 原逻辑:next(toNoPermissionOrNotFound(pageId, to.fullPath));
|
|
81
|
+
registerRoutes.bind(router)(to, from, next);
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// 已匹配(二次导航或路由已注册):菜单中存在该路由 = 有权限,否则按页面是否存在跳 403/404
|
|
86
|
+
if (hasRoute.bind(router)(to)) {
|
|
87
|
+
next();
|
|
88
|
+
} else {
|
|
89
|
+
const pageId = to.path.split("/").filter(Boolean).pop();
|
|
90
|
+
// TODO 临时放开:不在菜单中的 work 路由直接放行,不再跳 403/404
|
|
91
|
+
// 原逻辑:next(toNoPermissionOrNotFound(pageId, to.fullPath));
|
|
92
|
+
next();
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 4. render/anon 等其余路由:保持原有动态注册逻辑
|
|
98
|
+
hasRoute.bind(router)(to) ? next() : registerRoutes.bind(router)(to, from, next);
|
|
99
|
+
}
|