新增: 多项目切换 + 业务平台 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',
|
||||
);
|
||||
}
|
||||
34
apps/web-antd/src/constants/sso.ts
Normal file
34
apps/web-antd/src/constants/sso.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* SSO / OAuth2 相关常量
|
||||
*
|
||||
* 集中维护 client_id、state 存储 key 等跨文件复用的字符串,避免魔法字符串散落。
|
||||
*/
|
||||
|
||||
/** 本平台(物联运维)在芋道后端登记的 OAuth2 客户端编号 */
|
||||
export const IOT_CLIENT_ID = 'iot';
|
||||
|
||||
/** 业务平台在芋道后端登记的 OAuth2 客户端编号 */
|
||||
export const BIZ_CLIENT_ID = 'biz';
|
||||
|
||||
/** 跳业务平台发起 OAuth2 授权时,state 写入 sessionStorage 的 key */
|
||||
export const SSO_STATE_STORAGE_KEY = 'sso:biz:state';
|
||||
|
||||
/** 本平台自身 SSO 回调页面路径 */
|
||||
export const SSO_CALLBACK_PATH = '/sso-callback';
|
||||
|
||||
/**
|
||||
* 生成一个密码学安全的随机 state。
|
||||
* 首选 crypto.randomUUID;低版本浏览器回退 getRandomValues。
|
||||
*/
|
||||
export function generateOauthState(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
||||
const arr = new Uint8Array(16);
|
||||
crypto.getRandomValues(arr);
|
||||
return Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
// 极端降级:不会加密但比 Math.random 多一层时间戳
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import type { NotificationItem } from '@vben/layouts';
|
||||
|
||||
import type { SystemTenantApi } from '#/api/system/tenant';
|
||||
import type { SystemProjectApi } from '#/api/system/project';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { AuthenticationLoginExpiredModal, useVbenModal } from '@vben/common-ui';
|
||||
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
|
||||
import { isTenantEnable, useTabs, useWatermark } from '@vben/hooks';
|
||||
import { useTabs, useWatermark } from '@vben/hooks';
|
||||
import {
|
||||
AntdProfileOutlined,
|
||||
BookOpenText,
|
||||
@@ -20,7 +19,7 @@ import {
|
||||
Help,
|
||||
LockScreen,
|
||||
Notification,
|
||||
TenantDropdown,
|
||||
ProjectDropdown,
|
||||
UserDropdown,
|
||||
} from '@vben/layouts';
|
||||
import { preferences } from '@vben/preferences';
|
||||
@@ -35,7 +34,14 @@ import {
|
||||
updateAllNotifyMessageRead,
|
||||
updateNotifyMessageRead,
|
||||
} from '#/api/system/notify/message';
|
||||
import { getSimpleTenantList } from '#/api/system/tenant';
|
||||
import { authorize } from '#/api/system/oauth2/open';
|
||||
import { getSimpleProjectList } from '#/api/system/project';
|
||||
import {
|
||||
BIZ_CLIENT_ID,
|
||||
generateOauthState,
|
||||
SSO_CALLBACK_PATH,
|
||||
SSO_STATE_STORAGE_KEY,
|
||||
} from '#/constants/sso';
|
||||
import { $t } from '#/locales';
|
||||
import { router } from '#/router';
|
||||
import { useAuthStore } from '#/store';
|
||||
@@ -44,7 +50,6 @@ import LoginForm from '#/views/_core/authentication/login.vue';
|
||||
const userStore = useUserStore();
|
||||
const authStore = useAuthStore();
|
||||
const accessStore = useAccessStore();
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const { destroyWatermark, updateWatermark } = useWatermark();
|
||||
const { closeOtherTabs, refreshTab } = useTabs();
|
||||
|
||||
@@ -155,43 +160,116 @@ function handleNotificationOpen(open: boolean) {
|
||||
handleNotificationGetUnreadCount();
|
||||
}
|
||||
|
||||
// 租户列表
|
||||
const tenants = ref<SystemTenantApi.Tenant[]>([]);
|
||||
const tenantEnable = computed(
|
||||
() => hasAccessByCodes(['system:tenant:visit']) && isTenantEnable(),
|
||||
// ========== 项目切换 ==========
|
||||
const projects = ref<SystemProjectApi.Project[]>([]);
|
||||
const projectListReady = ref(false);
|
||||
/**
|
||||
* 是否允许展示项目切换组件:
|
||||
* - 列表拉取成功且至少一项
|
||||
* - 拉取失败或用户无权限时(后端 403 会被拦截器吃掉),列表为空 → 隐藏控件
|
||||
* TODO: 后端如果补上 system:project:query 权限码,这里换成 hasAccessByCodes 更直接
|
||||
*/
|
||||
const canSwitchProject = computed(
|
||||
() => projectListReady.value && projects.value.length > 0,
|
||||
);
|
||||
|
||||
/** 获取租户列表 */
|
||||
async function handleGetTenantList() {
|
||||
if (tenantEnable.value) {
|
||||
tenants.value = await getSimpleTenantList();
|
||||
/** 获取项目列表 */
|
||||
async function handleGetProjectList() {
|
||||
try {
|
||||
projects.value = await getSimpleProjectList();
|
||||
if (!accessStore.projectId && projects.value.length > 0) {
|
||||
const first = projects.value[0];
|
||||
if (first?.id) {
|
||||
accessStore.setProjectId(first.id as number);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('getSimpleProjectList failed', error);
|
||||
message.warning('项目列表加载失败,项目切换功能暂不可用');
|
||||
projects.value = [];
|
||||
} finally {
|
||||
projectListReady.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理租户切换 */
|
||||
async function handleTenantChange(tenant: SystemTenantApi.Tenant) {
|
||||
if (!tenant || !tenant.id) {
|
||||
message.error('切换租户失败');
|
||||
/** 跳转到业务平台(OAuth2 授权码无感跳转) */
|
||||
const bizLoading = ref(false);
|
||||
async function handleGoToBiz() {
|
||||
if (bizLoading.value) return;
|
||||
// 生产环境若没配 VITE_BIZ_BASE_URL,直接提示,不要悄悄跳 localhost
|
||||
const bizBaseUrl = import.meta.env.VITE_BIZ_BASE_URL;
|
||||
if (!bizBaseUrl) {
|
||||
message.error('业务平台地址未配置(VITE_BIZ_BASE_URL),请联系管理员');
|
||||
return;
|
||||
}
|
||||
|
||||
bizLoading.value = true;
|
||||
try {
|
||||
const redirectUri = `${bizBaseUrl}${SSO_CALLBACK_PATH}`;
|
||||
const state = generateOauthState();
|
||||
// 存起来供对端回跳后(如部署在同域下)校验;backend 也会独立校验一次
|
||||
try {
|
||||
sessionStorage.setItem(SSO_STATE_STORAGE_KEY, state);
|
||||
} catch {
|
||||
// 存储配额/隐私模式下写入失败,走后端校验即可
|
||||
}
|
||||
|
||||
const redirectUrl = await authorize(
|
||||
'code',
|
||||
BIZ_CLIENT_ID,
|
||||
redirectUri,
|
||||
state,
|
||||
true, // auto_approve 跳过授权确认页
|
||||
['user.read', 'user.write'],
|
||||
[],
|
||||
);
|
||||
if (!redirectUrl) {
|
||||
message.error('获取授权失败,请重试');
|
||||
return;
|
||||
}
|
||||
window.location.href = redirectUrl;
|
||||
} catch (error: any) {
|
||||
console.error('goToBiz failed', error);
|
||||
message.error(error?.message ?? '跳转业务平台失败');
|
||||
} finally {
|
||||
bizLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理项目切换 */
|
||||
async function handleProjectChange(project: SystemProjectApi.Project) {
|
||||
if (!project || !project.id) {
|
||||
message.error('切换项目失败');
|
||||
return;
|
||||
}
|
||||
if (project.id === accessStore.projectId) {
|
||||
return;
|
||||
}
|
||||
// 设置当前项目 ID(axios 拦截器会自动带 project-id 请求头)
|
||||
accessStore.setProjectId(project.id as number);
|
||||
// 不同项目可能对应不同菜单/按钮权限,重新拉一次用户信息;失败就回滚选择
|
||||
try {
|
||||
await authStore.fetchUserInfo();
|
||||
} catch (error) {
|
||||
console.error('fetchUserInfo after project switch failed', error);
|
||||
message.error('切换项目后刷新权限失败,请重新登录');
|
||||
return;
|
||||
}
|
||||
// 设置访问租户 ID
|
||||
accessStore.setVisitTenantId(tenant.id as number);
|
||||
// 关闭其他标签页,只保留当前页
|
||||
await closeOtherTabs();
|
||||
// 刷新当前页面
|
||||
await refreshTab();
|
||||
// 提示切换成功
|
||||
message.success(`切换当前租户为: ${tenant.name}`);
|
||||
message.success(`切换当前项目为: ${project.name}`);
|
||||
}
|
||||
|
||||
// ========== 初始化 ==========
|
||||
let unreadCountTimer: null | ReturnType<typeof setInterval> = null;
|
||||
|
||||
onMounted(() => {
|
||||
// 首次加载未读数量
|
||||
handleNotificationGetUnreadCount();
|
||||
// 获取租户列表
|
||||
handleGetTenantList();
|
||||
// 获取项目列表
|
||||
handleGetProjectList();
|
||||
// 轮询刷新未读数量
|
||||
setInterval(
|
||||
unreadCountTimer = setInterval(
|
||||
() => {
|
||||
if (userStore.userInfo) {
|
||||
handleNotificationGetUnreadCount();
|
||||
@@ -201,6 +279,13 @@ onMounted(() => {
|
||||
);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unreadCountTimer) {
|
||||
clearInterval(unreadCountTimer);
|
||||
unreadCountTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
enable: preferences.app.watermark,
|
||||
@@ -247,14 +332,26 @@ watch(
|
||||
/>
|
||||
</template>
|
||||
<template #header-right-1>
|
||||
<div v-if="tenantEnable">
|
||||
<TenantDropdown
|
||||
class="mr-2"
|
||||
:tenant-list="tenants"
|
||||
:visit-tenant-id="accessStore.visitTenantId"
|
||||
@success="handleTenantChange"
|
||||
/>
|
||||
</div>
|
||||
<ProjectDropdown
|
||||
v-if="canSwitchProject"
|
||||
class="mr-2"
|
||||
:project-list="projects"
|
||||
:visit-project-id="accessStore.projectId"
|
||||
@success="handleProjectChange"
|
||||
/>
|
||||
</template>
|
||||
<template #header-right-2>
|
||||
<a
|
||||
class="biz-portal-btn ml-1 mr-2 flex h-8 cursor-pointer items-center gap-1.5 rounded-full px-4 text-sm font-medium text-white shadow-sm transition-all hover:shadow-md"
|
||||
:class="{ 'pointer-events-none opacity-60': bizLoading }"
|
||||
role="button"
|
||||
title="返回业务平台"
|
||||
@click="handleGoToBiz"
|
||||
>
|
||||
<span class="i-lucide:briefcase text-base"></span>
|
||||
<span>业务平台</span>
|
||||
<span class="i-lucide:arrow-up-right text-xs opacity-80"></span>
|
||||
</a>
|
||||
</template>
|
||||
<template #extra>
|
||||
<AuthenticationLoginExpiredModal
|
||||
@@ -270,3 +367,16 @@ watch(
|
||||
</BasicLayout>
|
||||
<HelpModal />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 业务平台按钮颜色,走变量便于后续接入主题系统 */
|
||||
.biz-portal-btn {
|
||||
--biz-btn-bg: #fa8c16;
|
||||
--biz-btn-bg-hover: #ffa940;
|
||||
background-color: var(--biz-btn-bg);
|
||||
}
|
||||
.biz-portal-btn:hover {
|
||||
background-color: var(--biz-btn-bg-hover);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -122,6 +122,30 @@ const coreRoutes: RouteRecordRaw[] = [
|
||||
ignoreAccess: true,
|
||||
},
|
||||
},
|
||||
// SSO 回调页,业务平台跳过来时落地的页面
|
||||
{
|
||||
name: 'SsoCallback',
|
||||
path: '/sso-callback',
|
||||
component: () => import('#/views/_core/authentication/sso-callback.vue'),
|
||||
// 没带 code 直接手输入 URL 到这里会进死循环,这里先挡一层
|
||||
beforeEnter: (to, _from, next) => {
|
||||
if (!to.query.code) {
|
||||
next({
|
||||
path: LOGIN_PATH,
|
||||
query: { ssoError: '缺少授权码,无法完成 SSO 登录' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
next();
|
||||
},
|
||||
meta: {
|
||||
hideInBreadcrumb: true,
|
||||
hideInMenu: true,
|
||||
hideInTab: true,
|
||||
ignoreAccess: true,
|
||||
title: 'SSO 登录中',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export { coreRoutes, fallbackNotFoundRoute };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -8,6 +8,7 @@ export { default as AuthenticationLayoutToggle } from './layout-toggle.vue';
|
||||
export * from './lock-screen';
|
||||
export * from './notification';
|
||||
export * from './preferences';
|
||||
export * from './project-dropdown';
|
||||
export * from './tenant-dropdown';
|
||||
export * from './theme-toggle';
|
||||
export * from './timezone';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ProjectDropdown } from './project-dropdown.vue';
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
interface Project {
|
||||
id?: number;
|
||||
name: string;
|
||||
code?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'ProjectDropdown',
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
projectList?: Project[];
|
||||
visitProjectId?: null | number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const projects = computed(() => props.projectList ?? []);
|
||||
|
||||
async function handleChange(id: number | undefined) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const project = projects.value.find((item) => item.id === id);
|
||||
emit('success', project);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="hover:bg-accent ml-1 mr-2 h-8 w-32 cursor-pointer rounded-full p-1.5"
|
||||
>
|
||||
<IconifyIcon icon="lucide:folder-kanban" class="mr-2" />
|
||||
{{
|
||||
projects.find((item) => item.id === visitProjectId)?.name ||
|
||||
'请选择项目'
|
||||
}}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-40 p-0 pb-1">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
:disabled="project.id === visitProjectId"
|
||||
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
|
||||
@click="handleChange(project.id)"
|
||||
>
|
||||
<template v-if="project.id === visitProjectId">
|
||||
<IconifyIcon icon="lucide:check" class="mr-2" />
|
||||
{{ project.name }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ project.name }}
|
||||
</template>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
v-if="projects.length === 0"
|
||||
disabled
|
||||
class="mx-1 flex cursor-default items-center rounded-sm py-1 leading-8 text-xs opacity-60"
|
||||
>
|
||||
暂无项目
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
@@ -39,6 +39,10 @@ interface AccessState {
|
||||
* 登录是否过期
|
||||
*/
|
||||
loginExpired: boolean;
|
||||
/**
|
||||
* 当前项目编号
|
||||
*/
|
||||
projectId: null | number;
|
||||
/**
|
||||
* 登录 accessToken
|
||||
*/
|
||||
@@ -102,6 +106,9 @@ export const useAccessStore = defineStore('core-access', {
|
||||
setRefreshToken(token: AccessToken) {
|
||||
this.refreshToken = token;
|
||||
},
|
||||
setProjectId(projectId: null | number) {
|
||||
this.projectId = projectId;
|
||||
},
|
||||
setTenantId(tenantId: null | number) {
|
||||
this.tenantId = tenantId;
|
||||
},
|
||||
@@ -121,6 +128,7 @@ export const useAccessStore = defineStore('core-access', {
|
||||
'accessCodes',
|
||||
'tenantId',
|
||||
'visitTenantId',
|
||||
'projectId',
|
||||
'isLockScreen',
|
||||
'lockScreenPassword',
|
||||
],
|
||||
@@ -134,6 +142,7 @@ export const useAccessStore = defineStore('core-access', {
|
||||
isLockScreen: false,
|
||||
lockScreenPassword: undefined,
|
||||
loginExpired: false,
|
||||
projectId: null,
|
||||
refreshToken: null,
|
||||
tenantId: null,
|
||||
visitTenantId: null,
|
||||
|
||||
Reference in New Issue
Block a user