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

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