新增: 多项目切换 + 业务平台 SSO 单点跳转
核心功能:
1. 多项目切换:header 新增 ProjectDropdown,切项目时自动刷新权限菜单
2. 业务平台 SSO:header 新增「业务平台」按钮,OAuth2 授权码流程无感跳转
3. SSO 回调:/sso-callback 页面接收业务平台跳回的 code,换 IoT token 登录
共享包改动:
- packages/stores:access store 新增 projectId 字段并加入持久化
- packages/effects/layouts:新增 ProjectDropdown 共享组件
apps/web-antd 改动:
- api/request.ts:
· project-id 请求头仅在非 null 时设置(避免 axios 把 null 序列化为字符串 "null")
· X-Client-Id 改读 VITE_APP_CLIENT_ID,允许多个壳应用各自声明
- api/core/sso.ts:ssoCallback 参数走 body,避免 code 出现在浏览器历史/nginx 日志
- api/system/project:新增项目 simple-list API
- constants/sso.ts:集中 IOT_CLIENT_ID/BIZ_CLIENT_ID 等常量;
generateOauthState 用 crypto.randomUUID 生成 state,替代不安全的 Math.random
- store/auth.ts:抽 completeLogin 公共收尾逻辑,新增 ssoLogin 复用
- views/_core/authentication/sso-callback.vue:SSO 回调页;
dev 模式保留时延日志,失败时通过 query 透给登录页
- router/routes/core.ts:/sso-callback 路由 + beforeEnter 守卫
(缺 code 直接拦回登录页,避免死循环)
- layouts/basic.vue:
· 以 ProjectDropdown 替换 TenantDropdown(列表拉取失败兜底隐藏)
· 切项目时调用 fetchUserInfo,避免菜单/权限陈旧
· 新增「业务平台」跳转按钮;state 写 sessionStorage,
生产缺 VITE_BIZ_BASE_URL 时显式报错而非静默回 localhost
· setInterval 在 onUnmounted 中清理
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
38
apps/web-antd/src/api/core/sso.ts
Normal file
38
apps/web-antd/src/api/core/sso.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* SSO 单点登录相关接口(对接 /system/sso/callback)
|
||||
* 用于从业务平台跳转过来时,用 code 换 IoT 平台的 access_token
|
||||
*/
|
||||
export namespace SsoApi {
|
||||
export interface SsoCallbackResult {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 SSO code 换 access_token
|
||||
* client_secret 不在前端,由后端从配置读取
|
||||
*
|
||||
* @param clientId 当前系统的 client_id,IoT 平台固定为 iot(原 iot-client 已改名)
|
||||
* @param code 授权码(从 URL 读取)
|
||||
* @param redirectUri 回调地址(必须与发起授权时一致)
|
||||
* @param state 发起授权时的随机串,从 URL 读出原样回传(CSRF 防护 + 校验一致性)
|
||||
*/
|
||||
export function ssoCallback(
|
||||
clientId: string,
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
state?: string,
|
||||
) {
|
||||
// 放 body 而非 query,避免 code 落入浏览器历史 / nginx access log
|
||||
return requestClient.post<SsoApi.SsoCallbackResult>('/system/sso/callback', {
|
||||
clientId,
|
||||
code,
|
||||
redirectUri,
|
||||
state,
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { createApiEncrypt } from '@vben/utils';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { IOT_CLIENT_ID } from '#/constants/sso';
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
import { refreshTokenApi } from './core';
|
||||
@@ -23,6 +24,12 @@ import { refreshTokenApi } from './core';
|
||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||
const tenantEnable = isTenantEnable();
|
||||
const apiEncrypt = createApiEncrypt(import.meta.env);
|
||||
/**
|
||||
* 前端身份(OAuth2 client_id):后端 /system/auth/login & /refresh-token
|
||||
* 按此值生成 token,并按 platform 过滤菜单(iot → platform=sys)。
|
||||
* 走环境变量是为了让多个 apps 壳(antd/ele/naive/tdesign)各自声明。
|
||||
*/
|
||||
const clientId = import.meta.env.VITE_APP_CLIENT_ID || IOT_CLIENT_ID;
|
||||
|
||||
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||
const client = new RequestClient({
|
||||
@@ -86,6 +93,13 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||
config.headers['visit-tenant-id'] = tenantEnable
|
||||
? accessStore.visitTenantId
|
||||
: undefined;
|
||||
// 添加项目编号(多项目切换)
|
||||
// 仅在有值时设置,避免 axios 把 null 序列化为字符串 "null" 造成后端误判
|
||||
if (accessStore.projectId != null) {
|
||||
config.headers['project-id'] = accessStore.projectId;
|
||||
}
|
||||
// 声明前端身份(见文件顶部 clientId 注释)
|
||||
config.headers['X-Client-Id'] = clientId;
|
||||
|
||||
// 是否 API 加密
|
||||
if ((config.headers || {}).isEncrypt) {
|
||||
@@ -182,6 +196,10 @@ baseRequestClient.addRequestInterceptor({
|
||||
config.headers['visit-tenant-id'] = tenantEnable
|
||||
? accessStore.visitTenantId
|
||||
: undefined;
|
||||
if (accessStore.projectId != null) {
|
||||
config.headers['project-id'] = accessStore.projectId;
|
||||
}
|
||||
config.headers['X-Client-Id'] = clientId;
|
||||
return config;
|
||||
},
|
||||
});
|
||||
|
||||
21
apps/web-antd/src/api/system/project/index.ts
Normal file
21
apps/web-antd/src/api/system/project/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace SystemProjectApi {
|
||||
export interface Project {
|
||||
id?: number;
|
||||
name: string;
|
||||
code: string;
|
||||
status: number;
|
||||
contactName?: string;
|
||||
contactMobile?: string;
|
||||
address?: string;
|
||||
remark?: string;
|
||||
createTime?: Date;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSimpleProjectList() {
|
||||
return requestClient.get<SystemProjectApi.Project[]>(
|
||||
'/system/project/simple-list',
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user