Files
iot-device-management-frontend/apps/web-antd/src/store/auth.ts
lzh 680c965a27 新增: 多项目切换 + 业务平台 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>
2026-04-24 11:40:03 +08:00

182 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { AuthPermissionInfo, Recordable, UserInfo } from '@vben/types';
import type { AuthApi } from '#/api';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { LOGIN_PATH } from '@vben/constants';
import { preferences } from '@vben/preferences';
import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
import { notification } from 'ant-design-vue';
import { defineStore } from 'pinia';
import {
getAuthPermissionInfoApi,
loginApi,
logoutApi,
register,
smsLogin,
socialLogin,
} from '#/api';
import { $t } from '#/locales';
export const useAuthStore = defineStore('auth', () => {
const accessStore = useAccessStore();
const userStore = useUserStore();
const router = useRouter();
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
* @param type 登录类型
* @param params 登录表单数据
* @param onSuccess 登录成功后的回调函数
*/
async function authLogin(
type: 'mobile' | 'register' | 'social' | 'username',
params: Recordable<any>,
onSuccess?: () => Promise<void> | void,
) {
let userInfo: null | UserInfo = null;
try {
let loginResult: AuthApi.LoginResult;
loginLoading.value = true;
switch (type) {
case 'mobile': {
loginResult = await smsLogin(params as AuthApi.SmsLoginParams);
break;
}
case 'register': {
loginResult = await register(params as AuthApi.RegisterParams);
break;
}
case 'social': {
loginResult = await socialLogin(params as AuthApi.SocialLoginParams);
break;
}
default: {
loginResult = await loginApi(params);
}
}
const { accessToken, refreshToken } = loginResult;
if (accessToken) {
userInfo = await completeLogin(accessToken, refreshToken, onSuccess);
}
} finally {
loginLoading.value = false;
}
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) {
try {
const accessToken = accessStore.accessToken as string;
if (accessToken) {
await logoutApi(accessToken);
}
} catch {
// 不做任何处理
}
resetAllStores();
accessStore.setLoginExpired(false);
// 回登录页带上当前路由地址
await router.replace({
path: LOGIN_PATH,
query: redirect
? {
redirect: encodeURIComponent(router.currentRoute.value.fullPath),
}
: {},
});
}
async function fetchUserInfo() {
// 加载
let authPermissionInfo: AuthPermissionInfo | null = null;
authPermissionInfo = await getAuthPermissionInfoApi();
// userStore
userStore.setUserInfo(authPermissionInfo.user);
userStore.setUserRoles(authPermissionInfo.roles);
// accessStore
accessStore.setAccessMenus(authPermissionInfo.menus);
accessStore.setAccessCodes(authPermissionInfo.permissions);
return authPermissionInfo;
}
function $reset() {
loginLoading.value = false;
}
return {
$reset,
authLogin,
ssoLogin,
fetchUserInfo,
loginLoading,
logout,
};
});