新增: 多项目切换 + 业务平台 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

@@ -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_idIoT 平台固定为 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,
});
}

View File

@@ -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;
},
});

View 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',
);
}

View 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)}`;
}

View File

@@ -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;
}
// 设置当前项目 IDaxios 拦截器会自动带 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>

View File

@@ -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 };

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,

View File

@@ -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>

View File

@@ -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';

View File

@@ -0,0 +1 @@
export { default as ProjectDropdown } from './project-dropdown.vue';

View File

@@ -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>

View File

@@ -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,