核心功能:
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>
182 lines
5.0 KiB
TypeScript
182 lines
5.0 KiB
TypeScript
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,
|
||
};
|
||
});
|