新增: 多项目切换 + 业务平台 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:
@@ -0,0 +1,90 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { LOGIN_PATH } from '@vben/constants';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ssoCallback } from '#/api/core/sso';
|
||||
import {
|
||||
IOT_CLIENT_ID,
|
||||
SSO_CALLBACK_PATH,
|
||||
} from '#/constants/sso';
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
/**
|
||||
* IoT 运维平台 SSO 回调页
|
||||
*
|
||||
* 场景:从业务平台跳转过来时,URL 会带上 code 参数:
|
||||
* iot.xxx.com/sso-callback?code=xxx&state=yyy
|
||||
*
|
||||
* 本页的职责:
|
||||
* 1. 从 URL 读取 code
|
||||
* 2. 调 /system/sso/callback 用 code 换 IoT 平台的 access_token
|
||||
* 3. 走 authStore.ssoLogin 完成登录流程(拉用户信息、菜单)
|
||||
* 4. 跳首页
|
||||
*
|
||||
* 注:state 校验由后端完成(授权时存入、回调时比对)。
|
||||
*/
|
||||
defineOptions({ name: 'SsoCallback' });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const errorMsg = ref<string>('');
|
||||
|
||||
async function redirectToLogin(reason: string) {
|
||||
errorMsg.value = reason;
|
||||
message.error(reason);
|
||||
await router.replace({
|
||||
path: LOGIN_PATH,
|
||||
query: { ssoError: reason },
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const code = route.query.code as string | undefined;
|
||||
if (!code) {
|
||||
await redirectToLogin('SSO 登录失败:缺少授权码');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const redirectUri = `${window.location.origin}${SSO_CALLBACK_PATH}`;
|
||||
// state 必须从 URL 原样回传,否则后端会抛 OAUTH2_GRANT_STATE_MISMATCH
|
||||
const state = (route.query.state as string) || undefined;
|
||||
|
||||
const devTiming = import.meta.env.DEV ? performance.now() : 0;
|
||||
const tokenResp = await ssoCallback(IOT_CLIENT_ID, code, redirectUri, state);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(
|
||||
`[SSO] ssoCallback 耗时 ${(performance.now() - devTiming).toFixed(0)}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
await authStore.ssoLogin(tokenResp.access_token, tokenResp.refresh_token);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(
|
||||
`[SSO] 总耗时 ${(performance.now() - devTiming).toFixed(0)}ms`,
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// 真实异常保留 console.error,方便排障
|
||||
console.error('SSO callback failed', error);
|
||||
await redirectToLogin(error?.message ?? 'SSO 登录失败,请重新登录');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen items-center justify-center text-base">
|
||||
<span v-if="errorMsg">{{ errorMsg }}</span>
|
||||
<span v-else>正在登录,请稍候...</span>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user