feat(@vben/web-antd): 顶栏项目切换器 + 物联运维平台 SSO 无感跳转

- 顶栏 TenantDropdown 替换为 ProjectDropdown(新建 widget)
  - 进入时拉 /system/project/simple-list;仅当本地 projectId 不在列表时
    才回退到首项,避免静默改写用户选择
  - 空列表不渲染,避免出现永远空下拉
- 新增"物联运维"按钮,走 OAuth2 authorization code 流程跳 IoT 前端
  - state 使用 crypto.randomUUID() / getRandomValues() 生成(CSRF 防护)
  - VITE_IOT_BASE_URL 未配置时按钮隐藏,不再硬编码兜底 URL
  - 使用原生 <button disabled> 替代 <a role="button">,修复可访问性
- 新增 /sso-callback 回调页 + /system/sso/callback API
  - 挂载后立即 history.replaceState 清 code/state,避免二次 exchange
  - API 层做 snake_case → camelCase 映射,统一前端风格
  - 文档化 redirectUri 必须与 OAuth2 客户端 redirectUris 白名单一致
- authStore 新增 ssoLogin,与 authLogin 抽取共用 postAuthSuccess
  - token 为空直接抛错,fetchUserInfo 失败回滚 token 避免 401 循环

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-22 23:43:44 +08:00
parent 72ed0eb5aa
commit 37acdcf394
10 changed files with 395 additions and 76 deletions

View File

@@ -19,3 +19,6 @@ VITE_INJECT_APP_LOADING=true
VITE_APP_DEFAULT_USERNAME=admin
# 默认登录密码
VITE_APP_DEFAULT_PASSWORD=admin123
# 物联运维平台前端地址(用于业务平台 -> IoT 平台 SSO 跳转)
VITE_IOT_BASE_URL=http://127.0.0.1:5667

View File

@@ -21,3 +21,6 @@ VITE_INJECT_APP_LOADING=true
# 打包后是否生成dist.zip
VITE_ARCHIVER=true
# 物联运维平台前端地址SSO 跳转目标),生产部署时按实际域名修改
VITE_IOT_BASE_URL=https://iot.example.com

View File

@@ -0,0 +1,52 @@
import { requestClient } from '#/api/request';
/**
* SSO 单点登录相关接口(对接 /system/sso/callback
* 用于从其他子系统(如 IoT 运维平台)跳转回来时,用 code 换 access_token
*/
export namespace SsoApi {
/** 后端 OAuth2 标准响应snake_case仅在 API 层内部使用 */
export interface SsoCallbackRawResult {
access_token: string;
refresh_token: string;
expires_in: number;
token_type: string;
}
/** 前端统一使用 camelCase */
export interface SsoCallbackResult {
accessToken: string;
refreshToken: string;
expiresIn: number;
tokenType: string;
}
}
/**
* 用 SSO code 换 access_token。
* client_secret 不在前端,由后端从配置读取。
*
* @param clientId 当前系统的 client_id业务平台 = 'biz',物联运维 = 'iot'
* @param code 授权码(从 URL 读取)
* @param redirectUri 回调地址(必须与发起授权时一致,且需在 OAuth2 客户端的 redirectUris 白名单内)
* @param state 发起授权时的随机串,从 URL 读出原样回传(后端校验一致性 + CSRF 防护)
*/
export async function ssoCallback(
clientId: string,
code: string,
redirectUri: string,
state?: string,
): Promise<SsoApi.SsoCallbackResult> {
const raw = await requestClient.post<SsoApi.SsoCallbackRawResult>(
'/system/sso/callback',
null,
{
params: { clientId, code, redirectUri, state },
},
);
return {
accessToken: raw.access_token,
refreshToken: raw.refresh_token,
expiresIn: raw.expires_in,
tokenType: raw.token_type,
};
}

View File

@@ -1,20 +1,19 @@
<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, onUnmounted, ref, watch } from 'vue';
import { useAccess } from '@vben/access';
import { AuthenticationLoginExpiredModal, useVbenModal } from '@vben/common-ui';
import { isTenantEnable, useTabs, useWatermark } from '@vben/hooks';
import { useTabs, useWatermark } from '@vben/hooks';
import { AntdProfileOutlined, CircleHelp } from '@vben/icons';
import {
BasicLayout,
Help,
LockScreen,
Notification,
TenantDropdown,
ProjectDropdown,
UserDropdown,
} from '@vben/layouts';
import { preferences } from '@vben/preferences';
@@ -29,7 +28,8 @@ 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 { $t } from '#/locales';
import { router } from '#/router';
import { useAuthStore } from '#/store';
@@ -38,7 +38,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();
@@ -149,33 +148,83 @@ function handleNotificationOpen(open: boolean) {
handleNotificationGetUnreadCount();
}
// 租户列表
const tenants = ref<SystemTenantApi.Tenant[]>([]);
const tenantEnable = computed(
() => hasAccessByCodes(['system:tenant:visit']) && isTenantEnable(),
);
// 项目列表
const projects = ref<SystemProjectApi.Project[]>([]);
/** 获取租户列表 */
async function handleGetTenantList() {
if (tenantEnable.value) {
tenants.value = await getSimpleTenantList();
/** 获取项目列表 */
async function handleGetProjectList() {
projects.value = await getSimpleProjectList();
// 仅在当前 projectId 不在返回列表中(未选 / 已删除 / 无权限)时,回退到第一个
const currentId = accessStore.projectId;
const currentInList =
currentId !== null && projects.value.some((p) => p.id === currentId);
if (!currentInList) {
const firstId = projects.value[0]?.id ?? null;
accessStore.setProjectId(firstId as null | number);
}
}
/** 处理租户切换 */
async function handleTenantChange(tenant: SystemTenantApi.Tenant) {
if (!tenant || !tenant.id) {
message.error('切换租户失败');
/** 跳转到物联运维平台OAuth2 授权码无感跳转) */
const iotLoading = ref(false);
// env 未配置时按钮直接不渲染,避免把用户带到无效域名
const iotBaseUrl = import.meta.env.VITE_IOT_BASE_URL as string | undefined;
/** 生成加密安全的 state 值用于 CSRF 防护 */
function generateState(): string {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return crypto.randomUUID();
}
// 老浏览器兜底:仍使用 getRandomValues强于 Math.random
const buf = new Uint8Array(16);
crypto.getRandomValues(buf);
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('');
}
async function handleGoToIot() {
if (iotLoading.value || !iotBaseUrl) return;
iotLoading.value = true;
try {
// 注意redirectUri 必须与 OAuth2 客户端 redirectUris 白名单完全一致,否则后端拒发 code
const redirectUri = `${iotBaseUrl}/sso-callback`;
const state = generateState();
const redirectUrl = await authorize(
'code',
'iot', // 物联运维平台的 OAuth2 客户端编号
redirectUri,
state,
true, // auto_approve 跳过授权确认页
['user.read', 'user.write'],
[],
);
if (!redirectUrl) {
message.error('获取授权失败,请重试');
return;
}
window.location.href = redirectUrl;
} catch (error: any) {
message.error(error?.message ?? '跳转物联运维平台失败');
} finally {
iotLoading.value = false;
}
}
/** 处理项目切换 */
async function handleProjectChange(project: SystemProjectApi.Project) {
if (!project || !project.id) {
message.error('切换项目失败');
return;
}
// 设置访问租户 ID
accessStore.setVisitTenantId(tenant.id as number);
if (project.id === accessStore.projectId) {
return;
}
// 设置当前项目 IDaxios 拦截器会自动带 project-id 请求头)
accessStore.setProjectId(project.id as number);
// 关闭其他标签页,只保留当前页
await closeOtherTabs();
// 刷新当前页面
// 刷新当前页面,重新拉数据
await refreshTab();
// 提示切换成功
message.success(`切换当前租户为: ${tenant.name}`);
message.success(`切换当前项目为: ${project.name}`);
}
// ========== 初始化 ==========
@@ -184,8 +233,8 @@ let notifyTimer: null | ReturnType<typeof setInterval> = null;
onMounted(() => {
// 首次加载未读数量
handleNotificationGetUnreadCount();
// 获取租户列表
handleGetTenantList();
// 获取项目列表
handleGetProjectList();
// 轮询刷新未读数量
notifyTimer = setInterval(
() => {
@@ -250,14 +299,27 @@ 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="projects.length > 0"
class="mr-2"
:project-list="projects"
:visit-project-id="accessStore.projectId"
@success="handleProjectChange"
/>
</template>
<template #header-right-2>
<button
v-if="iotBaseUrl"
type="button"
class="ml-1 mr-2 flex h-8 cursor-pointer items-center gap-1.5 rounded-full bg-[#1677FF] px-4 text-sm font-medium text-white shadow-sm transition-all hover:bg-[#4096FF] hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60"
:disabled="iotLoading"
title="进入物联运维平台"
@click="handleGoToIot"
>
<span class="i-lucide:cpu text-base"></span>
<span>物联运维</span>
<span class="i-lucide:arrow-up-right text-xs opacity-80"></span>
</button>
</template>
<template #extra>
<AuthenticationLoginExpiredModal

View File

@@ -109,6 +109,18 @@ const coreRoutes: RouteRecordRaw[] = [
},
],
},
{
name: 'SsoCallback',
path: '/sso-callback',
component: () => import('#/views/_core/authentication/sso-callback.vue'),
meta: {
hideInBreadcrumb: true,
hideInMenu: true,
hideInTab: true,
ignoreAccess: true,
title: 'SSO 登录中',
},
},
];
export { coreRoutes, fallbackNotFoundRoute };

View File

@@ -29,6 +29,42 @@ export const useAuthStore = defineStore('auth', () => {
const loginLoading = ref(false);
/**
* token 写入成功后的共用流程:拉用户信息、跳首页、弹欢迎提示。
* authLogin / ssoLogin 共用以避免逻辑漂移。
*/
async function postAuthSuccess(
onSuccess?: () => Promise<void> | void,
): Promise<null | UserInfo> {
// 登录成功后,如果未设置访问租户,默认使用登录时选择的租户
if (accessStore.tenantId && !accessStore.visitTenantId) {
accessStore.setVisitTenantId(accessStore.tenantId);
}
// TODO @芋艿:清理掉 accessCodes 相关的逻辑
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 +77,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,52 +99,48 @@ export const useAuthStore = defineStore('auth', () => {
}
}
const { accessToken, refreshToken } = loginResult;
// 如果成功获取到 accessToken
if (accessToken) {
accessStore.setAccessToken(accessToken);
accessStore.setRefreshToken(refreshToken);
// 登录成功后,如果未设置访问租户,默认使用登录时选择的租户
if (accessStore.tenantId && !accessStore.visitTenantId) {
accessStore.setVisitTenantId(accessStore.tenantId);
}
// 获取用户信息并存储到 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'),
});
}
if (!accessToken) {
return { userInfo: null };
}
accessStore.setAccessToken(accessToken);
accessStore.setRefreshToken(refreshToken);
userInfo = await postAuthSuccess(onSuccess);
} finally {
loginLoading.value = false;
}
return {
userInfo,
};
return { userInfo };
}
/**
* SSO 登录:从其他平台跳转过来时,已经拿到 access_token跳过密码校验。
* 失败token 空、后续拉用户信息异常)会回滚 token避免留下空 token 导致刷新循环。
*/
async function ssoLogin(
accessToken: string,
refreshToken: string,
onSuccess?: () => Promise<void> | void,
) {
if (!accessToken || !refreshToken) {
throw new Error('SSO 登录失败:缺少 accessToken / refreshToken');
}
let userInfo: null | UserInfo = null;
try {
loginLoading.value = true;
accessStore.setAccessToken(accessToken);
accessStore.setRefreshToken(refreshToken);
try {
userInfo = await postAuthSuccess(onSuccess);
} catch (error) {
// 回滚 token避免 Bearer null/空 token 陷入 401 循环
accessStore.setAccessToken(null);
accessStore.setRefreshToken(null);
throw error;
}
} finally {
loginLoading.value = false;
}
return { userInfo };
}
async function logout(redirect: boolean = true) {
@@ -155,6 +186,7 @@ export const useAuthStore = defineStore('auth', () => {
return {
$reset,
authLogin,
ssoLogin,
fetchUserInfo,
loginLoading,
logout,

View File

@@ -0,0 +1,62 @@
<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 { CLIENT_ID } from '#/api/request';
import { useAuthStore } from '#/store';
/**
* SSO 回调页
*
* 场景:从其他子系统(如 IoT 运维平台跳转回来时URL 会带上 code 参数:
* biz.xxx.com/sso-callback?code=xxx&state=yyy
*
* 约束window.location.origin + '/sso-callback' 必须与发起授权时传的 redirect_uri 完全一致,
* 且已登记在后端 OAuth2 客户端的 redirectUris 白名单中,否则 yudao 会拒发 / 换 token。
*/
defineOptions({ name: 'SsoCallback' });
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const errorMsg = ref<string>('');
onMounted(async () => {
const code = route.query.code as string | undefined;
// state 必须从 URL 原样回传,否则后端会抛 OAUTH2_GRANT_STATE_MISMATCH
const state = (route.query.state as string) || undefined;
// 立即清空 URL 查询参数,避免 code 出现在浏览器历史、后退时二次 exchange
window.history.replaceState(null, '', window.location.pathname);
if (!code) {
errorMsg.value = 'SSO 登录失败:缺少授权码';
message.error(errorMsg.value);
await router.replace(LOGIN_PATH);
return;
}
try {
const redirectUri = `${window.location.origin}/sso-callback`;
const tokenResp = await ssoCallback(CLIENT_ID, code, redirectUri, state);
// 用拿到的 token 完成登录(会拉用户信息、菜单并跳首页)
await authStore.ssoLogin(tokenResp.accessToken, tokenResp.refreshToken);
} catch (error: any) {
errorMsg.value = error?.message ?? 'SSO 登录失败,请重新登录';
message.error(errorMsg.value);
await router.replace(LOGIN_PATH);
}
});
</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>