新增: 多项目切换 + 业务平台 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:
lzh
2026-04-23 00:08:33 +08:00
parent fd946c132e
commit 680c965a27
12 changed files with 526 additions and 72 deletions

View File

@@ -29,6 +29,41 @@ export const useAuthStore = defineStore('auth', () => {
const loginLoading = ref(false);
/**
* 登录流程的公共收尾:落 token → 拉用户信息 → 跳首页 → 成功通知。
* 所有入口(密码/短信/社交/SSO拿到 token 后都走这里,保持行为一致。
*/
async function completeLogin(
accessToken: string,
refreshToken: string,
onSuccess?: () => Promise<void> | void,
): Promise<null | UserInfo> {
accessStore.setAccessToken(accessToken);
accessStore.setRefreshToken(refreshToken);
const fetchUserInfoResult = await fetchUserInfo();
const userInfo = fetchUserInfoResult.user;
if (accessStore.loginExpired) {
accessStore.setLoginExpired(false);
} else {
onSuccess
? await onSuccess?.()
: await router.push(
userInfo.homePath || preferences.app.defaultHomePath,
);
}
if (userInfo?.nickname) {
notification.success({
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.nickname}`,
duration: 3,
message: $t('authentication.loginSuccess'),
});
}
return userInfo;
}
/**
* 异步处理登录操作
* Asynchronously handle the login process
@@ -41,7 +76,6 @@ export const useAuthStore = defineStore('auth', () => {
params: Recordable<any>,
onSuccess?: () => Promise<void> | void,
) {
// 异步处理用户登录操作并获取 accessToken
let userInfo: null | UserInfo = null;
try {
let loginResult: AuthApi.LoginResult;
@@ -64,47 +98,36 @@ export const useAuthStore = defineStore('auth', () => {
}
}
const { accessToken, refreshToken } = loginResult;
// 如果成功获取到 accessToken
if (accessToken) {
accessStore.setAccessToken(accessToken);
accessStore.setRefreshToken(refreshToken);
// 获取用户信息并存储到 userStore、accessStore 中
// TODO @芋艿:清理掉 accessCodes 相关的逻辑
// const [fetchUserInfoResult, accessCodes] = await Promise.all([
// fetchUserInfo(),
// // getAccessCodesApi(),
// ]);
const fetchUserInfoResult = await fetchUserInfo();
userInfo = fetchUserInfoResult.user;
if (accessStore.loginExpired) {
accessStore.setLoginExpired(false);
} else {
onSuccess
? await onSuccess?.()
: await router.push(
userInfo.homePath || preferences.app.defaultHomePath,
);
}
if (userInfo?.nickname) {
notification.success({
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.nickname}`,
duration: 3,
message: $t('authentication.loginSuccess'),
});
}
userInfo = await completeLogin(accessToken, refreshToken, onSuccess);
}
} finally {
loginLoading.value = false;
}
return { userInfo };
}
return {
userInfo,
};
/**
* SSO 登录:从业务平台跳转过来时,已经拿到 access_token跳过密码校验
*/
async function ssoLogin(
accessToken: string,
refreshToken: string,
onSuccess?: () => Promise<void> | void,
) {
let userInfo: null | UserInfo = null;
try {
loginLoading.value = true;
// SSO 首次进入时store 可能尚未水合出 visitTenantId这里用 tenantId 兜底,
// 避免第一屏接口因缺少 visit-tenant-id 被后端拒绝。
if (accessStore.tenantId && !accessStore.visitTenantId) {
accessStore.setVisitTenantId(accessStore.tenantId);
}
userInfo = await completeLogin(accessToken, refreshToken, onSuccess);
} finally {
loginLoading.value = false;
}
return { userInfo };
}
async function logout(redirect: boolean = true) {
@@ -150,6 +173,7 @@ export const useAuthStore = defineStore('auth', () => {
return {
$reset,
authLogin,
ssoLogin,
fetchUserInfo,
loginLoading,
logout,