admin跳转

This commit is contained in:
2025-12-13 14:23:40 +08:00
parent 1776aa2d1e
commit 3442f96214
6 changed files with 795 additions and 269 deletions

View File

@@ -1,130 +0,0 @@
// Windows temporarily needs this file, https://github.com/module-federation/vite/issues/68
import {loadShare} from "@module-federation/runtime";
const importMap = {
"element-plus": async () => {
let pkg = await import("__mf__virtual/platform__prebuild__element_mf_2_plus__prebuild__.js");
return pkg;
}
,
"vue": async () => {
let pkg = await import("__mf__virtual/platform__prebuild__vue__prebuild__.js");
return pkg;
}
,
"vue-router": async () => {
let pkg = await import("__mf__virtual/platform__prebuild__vue_mf_2_router__prebuild__.js");
return pkg;
}
}
const usedShared = {
"element-plus": {
name: "element-plus",
version: "2.12.0",
scope: ["default"],
loaded: false,
from: "platform",
async get () {
if (false) {
throw new Error(`Shared module '${"element-plus"}' must be provided by host`);
}
usedShared["element-plus"].loaded = true
const {"element-plus": pkgDynamicImport} = importMap
const res = await pkgDynamicImport()
const exportModule = {...res}
// All npm packages pre-built by vite will be converted to esm
Object.defineProperty(exportModule, "__esModule", {
value: true,
enumerable: false
})
return function () {
return exportModule
}
},
shareConfig: {
singleton: false,
requiredVersion: "^2.12.0",
}
}
,
"vue": {
name: "vue",
version: "3.5.25",
scope: ["default"],
loaded: false,
from: "platform",
async get () {
if (false) {
throw new Error(`Shared module '${"vue"}' must be provided by host`);
}
usedShared["vue"].loaded = true
const {"vue": pkgDynamicImport} = importMap
const res = await pkgDynamicImport()
const exportModule = {...res}
// All npm packages pre-built by vite will be converted to esm
Object.defineProperty(exportModule, "__esModule", {
value: true,
enumerable: false
})
return function () {
return exportModule
}
},
shareConfig: {
singleton: false,
requiredVersion: "^3.5.25",
}
}
,
"vue-router": {
name: "vue-router",
version: "4.6.3",
scope: ["default"],
loaded: false,
from: "platform",
async get () {
if (false) {
throw new Error(`Shared module '${"vue-router"}' must be provided by host`);
}
usedShared["vue-router"].loaded = true
const {"vue-router": pkgDynamicImport} = importMap
const res = await pkgDynamicImport()
const exportModule = {...res}
// All npm packages pre-built by vite will be converted to esm
Object.defineProperty(exportModule, "__esModule", {
value: true,
enumerable: false
})
return function () {
return exportModule
}
},
shareConfig: {
singleton: false,
requiredVersion: "^4.6.3",
}
}
}
const usedRemotes = [
{
entryGlobalName: "shared",
name: "shared",
type: "module",
entry: "http://localhost:5000/remoteEntry.js",
shareScope: "default",
}
]
export {
usedShared,
usedRemotes
}

View File

@@ -0,0 +1,264 @@
.sidebar-layout {
display: flex;
width: 100%;
height: 100vh;
overflow: hidden;
}
// ==================== 侧边栏 ====================
.sidebar {
width: 220px;
height: 100%;
background: #F0EAF4;
display: flex;
flex-direction: column;
color: #333;
flex-shrink: 0;
transition: width 0.3s ease;
border-right: 1px solid rgba(0, 0, 0, 0.08);
&.collapsed {
width: 64px;
.sidebar-header {
padding: 16px 12px;
justify-content: center;
.logo {
justify-content: center;
}
.collapse-btn {
position: static;
margin-left: 0;
}
}
.nav-item {
justify-content: center;
padding: 12px;
}
.user-section {
justify-content: center;
padding: 16px 12px;
}
}
}
// 侧边栏头部
.sidebar-header {
padding: 16px 20px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
}
.collapse-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
cursor: pointer;
color: #888;
transition: all 0.2s;
&:hover {
background: rgba(124, 58, 237, 0.1);
color: #7c3aed;
}
}
.logo {
display: flex;
align-items: center;
gap: 10px;
.logo-img {
width: 40px;
height: 40px;
border-radius: 6px;
object-fit: contain;
background: #fff;
padding: 2px;
}
.logo-text {
font-size: 16px;
font-weight: 600;
color: #333;
}
}
// 导航菜单
.nav-menu {
flex: 1;
overflow-y: auto;
padding: 12px 0;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
}
.nav-section {
padding: 8px 0;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
margin-bottom: 4px;
cursor: pointer;
transition: all 0.2s ease;
color: #555;
font-size: 14px;
&:hover {
background: rgba(124, 58, 237, 0.1);
color: #7c3aed;
}
&.active {
background: rgba(124, 58, 237, 0.15);
color: #7c3aed;
font-weight: 500;
}
.el-icon {
font-size: 18px;
flex-shrink: 0;
}
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
// 用户信息
.user-section {
padding: 16px 20px;
border-top: 1px solid rgba(0, 0, 0, 0.08);
cursor: pointer;
transition: background 0.2s;
&:hover {
background: rgba(124, 58, 237, 0.05);
}
.user-info-wrapper {
display: flex;
align-items: center;
gap: 12px;
}
.user-avatar {
flex-shrink: 0;
}
.user-name {
font-size: 14px;
font-weight: 500;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
// ==================== 主内容区 ====================
.main-content {
flex: 1;
height: 100%;
overflow: hidden;
background: #fff;
position: relative;
}
// iframe 容器
.iframe-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
position: relative;
}
.iframe-header {
height: 56px;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e5e7eb;
background: #fafafa;
flex-shrink: 0;
}
.iframe-title {
font-size: 16px;
font-weight: 600;
color: #333;
}
.content-iframe {
flex: 1;
width: 100%;
height: 100%;
border: none;
background: #fff;
}
.iframe-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: #7c3aed;
font-size: 14px;
z-index: 10;
.el-icon {
font-size: 32px;
}
}
// ==================== 响应式 ====================
@media (max-width: 768px) {
.sidebar {
width: 64px;
&:not(.collapsed) {
width: 220px;
position: fixed;
left: 0;
top: 0;
z-index: 1000;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
}
}
.iframe-header {
padding: 0 16px;
.iframe-title {
font-size: 14px;
}
}
}

View File

@@ -0,0 +1,292 @@
<template>
<div class="sidebar-layout">
<!-- 侧边栏 -->
<aside class="sidebar" :class="{ collapsed: collapsed }">
<div class="sidebar-header">
<div class="logo">
<img src="/logo.jpg" alt="Logo" class="logo-img" />
<span v-if="!collapsed" class="logo-text">城市生命线</span>
</div>
<div class="collapse-btn" @click="toggleSidebar">
<el-icon>
<DArrowLeft v-if="!collapsed" />
<DArrowRight v-else />
</el-icon>
</div>
</div>
<nav class="nav-menu">
<div class="nav-section">
<div
v-for="item in menuItems"
:key="item.key"
class="nav-item"
:class="{ active: activeMenu === item.key }"
@click="handleMenuClick(item)"
>
<el-icon><component :is="item.icon" /></el-icon>
<span v-if="!collapsed">{{ item.label }}</span>
</div>
</div>
</nav>
<!-- 用户信息和返回按钮 -->
<div class="user-section">
<div class="user-avatar">
<el-avatar :size="36" src="/avatar.svg" @error="handleAvatarError" />
</div>
<span v-if="!collapsed" class="user-name">{{ userName }}</span>
<el-tooltip content="返回主系统" placement="top">
<div class="back-icon" @click="backToMain">
<el-icon><Back /></el-icon>
</div>
</el-tooltip>
</div>
</aside>
<!-- 主内容区 -->
<main class="main-content">
<!-- iframe 模式 -->
<div v-if="currentIframeUrl" class="iframe-container">
<div class="iframe-header">
<span class="iframe-title">{{ currentMenuItem?.label }}</span>
<el-button
text
@click="handleRefreshIframe"
:icon="Refresh"
>
刷新
</el-button>
</div>
<iframe
ref="iframeRef"
:src="currentIframeUrl"
class="content-iframe"
frameborder="0"
@load="handleIframeLoad"
/>
<div v-if="iframeLoading" class="iframe-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
</div>
<!-- 路由模式 -->
<router-view v-else />
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
ChatDotRound,
Grid,
Connection,
Document,
Service,
DArrowLeft,
DArrowRight,
User,
Setting,
SwitchButton,
Refresh,
Loading,
Back
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
interface MenuItem {
key: string
label: string
icon: string
url?: string
type: 'route' | 'iframe'
}
const router = useRouter()
const route = useRoute()
// 状态管理
const collapsed = ref(false)
const activeMenu = ref('home')
const iframeLoading = ref(false)
const iframeRef = ref<HTMLIFrameElement>()
// 从 LocalStorage 获取用户名
function getUserName(): string {
try {
const loginDomainStr = localStorage.getItem('loginDomain')
if (loginDomainStr) {
const loginDomain = JSON.parse(loginDomainStr)
return loginDomain.user?.username || loginDomain.userInfo?.username || '管理员'
}
} catch (error) {
}
return '管理员'
}
const userName = ref(getUserName())
/**
* 从 LocalStorage 加载菜单
*/
function loadMenuFromStorage(): MenuItem[] {
try {
const loginDomainStr = localStorage.getItem('loginDomain')
if (!loginDomainStr) {
return []
}
const loginDomain = JSON.parse(loginDomainStr)
const userViews = loginDomain.userViews || []
// 过滤出 SidebarLayout 的顶级菜单(没有 parentId且属于 platform 服务且不是admin路由
const sidebarViews = userViews.filter((view: any) =>
view.layout === 'SidebarLayout' &&
!view.parentId &&
view.type === 1 && // type 1 是侧边栏菜单
view.service === 'platform' && // 只显示 platform 服务的视图
view.url?.startsWith('/admin') // 只留admin 路由(由 AdminSidebar 管理)
)
// 按 orderNum 排序
sidebarViews.sort((a: any, b: any) => (a.orderNum || 0) - (b.orderNum || 0))
// 转换为 MenuItem 格式
const menuItems: MenuItem[] = sidebarViews.map((view: any) => {
// 根据 viewType 或 iframeUrl 判断是 route 还是 iframe
const isIframe = view.viewType === 'iframe' || !!view.iframeUrl
// 确定菜单的路由路径
let menuUrl = view.url
if (isIframe && view.url && (view.url.startsWith('http://') || view.url.startsWith('https://'))) {
// iframe 类型且 url 是外部链接,使用 viewId 作为路由路径
menuUrl = `/${view.viewId}`
}
return {
key: view.viewId || view.name,
label: view.name,
icon: view.icon || 'Grid',
url: menuUrl,
type: isIframe ? 'iframe' : 'route'
}
})
return menuItems
} catch (error) {
return []
}
}
// 菜单配置(从 LocalStorage 加载)
const menuItems = ref<MenuItem[]>(loadMenuFromStorage())
// 当前菜单项
const currentMenuItem = computed(() => {
return menuItems.value.find(item => item.key === activeMenu.value)
})
// 当前 iframe URL从路由 meta 读取)
const currentIframeUrl = computed(() => {
const meta = route.meta as any
return meta?.iframeUrl || null
})
// 切换侧边栏
const toggleSidebar = () => {
collapsed.value = !collapsed.value
}
// 处理菜单点击
const handleMenuClick = (item: MenuItem) => {
activeMenu.value = item.key
// 所有菜单都通过路由跳转
if (item.url) {
router.push(item.url)
if (item.type === 'iframe') {
iframeLoading.value = true
}
}
}
// iframe 加载完成
const handleIframeLoad = () => {
iframeLoading.value = false
}
// 刷新 iframe
const handleRefreshIframe = () => {
if (iframeRef.value) {
iframeLoading.value = true
iframeRef.value.src = iframeRef.value.src
}
}
// 用户头像加载错误
const handleAvatarError = () => {
return true
}
// 返回主系统SidebarLayout
const backToMain = () => {
// 查找第一个非admin路由
const loginDomainStr = localStorage.getItem('loginDomain')
if (loginDomainStr) {
const loginDomain = JSON.parse(loginDomainStr)
const userViews = loginDomain.userViews || []
const mainViews = userViews.filter((view: any) =>
view.service === 'platform' && !view.url?.startsWith('/admin')
)
if (mainViews.length > 0) {
// 按 orderNum 排序,跳转到第一个
mainViews.sort((a: any, b: any) => (a.orderNum || 0) - (b.orderNum || 0))
router.push(mainViews[0].url)
}
}
}
// 用户操作
const handleUserCommand = (command: string) => {
switch (command) {
case 'profile':
router.push('/profile')
break
case 'settings':
router.push('/settings')
break
case 'logout':
localStorage.clear()
ElMessage.success('退出成功')
router.push('/login')
break
}
}
// 监听路由变化,同步激活菜单
watch(
() => route.path,
(newPath) => {
// 查找匹配的菜单项route 或 iframe 类型)
const menuItem = menuItems.value.find((item: MenuItem) => item.url === newPath)
if (menuItem) {
activeMenu.value = menuItem.key
} else {
// 如果路径不匹配,尝试通过 route.name 匹配 viewId
const menuByName = menuItems.value.find((item: MenuItem) => item.key === route.name)
if (menuByName) {
activeMenu.value = menuByName.key
}
}
},
{ immediate: true }
)
</script>
<style lang="scss" scoped>
@import url("./SidebarLayout.scss");
</style>

View File

@@ -0,0 +1,214 @@
# SidebarLayout 侧边栏布局组件
## 功能特性
### 🎯 核心功能
- **侧边栏菜单导航**:左侧固定侧边栏,支持折叠/展开
- **双模式内容区**
- **路由模式**:普通路由页面渲染
- **iframe 模式**:嵌入外部应用(招标助手、泰豪小电、智能体编排)
- **用户信息展示**:底部用户头像和下拉菜单
- **响应式设计**:支持移动端适配
### 📱 菜单配置
```typescript
const menuItems: MenuItem[] = [
{
key: 'home',
label: '工作台',
icon: 'Grid',
path: '/home',
type: 'route'
},
{
key: 'bidding',
label: '招标助手',
icon: 'Document',
iframeUrl: 'http://localhost:5002',
type: 'iframe'
},
{
key: 'service',
label: '泰豪小电',
icon: 'Service',
iframeUrl: 'http://localhost:5003',
type: 'iframe'
},
{
key: 'workflow',
label: '智能体编排',
icon: 'Connection',
iframeUrl: 'http://localhost:3000', // Dify 地址
type: 'iframe'
}
]
```
## 使用方式
### 在路由中使用
```typescript
// router/index.ts
import { SidebarLayout } from '@/layouts'
const routes = [
{
path: '/',
component: SidebarLayout,
children: [
{
path: '/home',
component: () => import('@/views/Home.vue')
},
{
path: '/chat',
component: () => import('@/views/Chat.vue')
}
]
}
]
```
### 在 App.vue 中使用
```vue
<template>
<SidebarLayout />
</template>
<script setup lang="ts">
import { SidebarLayout } from '@/layouts'
</script>
```
## 菜单项类型
```typescript
interface MenuItem {
key: string // 唯一标识
label: string // 显示名称
icon: string // Element Plus 图标组件名
path?: string // 路由路径route 类型必需)
iframeUrl?: string // iframe URLiframe 类型必需)
type: 'route' | 'iframe' // 菜单类型
}
```
## iframe 应用说明
### 1. 招标助手 (Bidding)
- **端口**5002
- **URL**http://localhost:5002
- **说明**:招投标业务管理系统
### 2. 泰豪小电 (Service)
- **端口**5003
- **URL**http://localhost:5003
- **说明**:智能客服工单管理系统
### 3. 智能体编排 (Workflow)
- **端口**3000
- **URL**http://localhost:3000
- **说明**Dify 智能体编排界面
## 样式自定义
### 主题色调整
```scss
// 修改侧边栏背景色
.sidebar {
background: #F0EAF4; // 当前淡紫色背景
}
// 修改激活项颜色
.nav-item.active {
background: rgba(124, 58, 237, 0.15);
color: #7c3aed;
}
```
### 侧边栏宽度
```scss
.sidebar {
width: 220px; // 展开宽度
&.collapsed {
width: 64px; // 折叠宽度
}
}
```
## 功能说明
### 侧边栏折叠
- 点击头部箭头图标可切换折叠/展开状态
- 折叠后只显示图标,展开后显示图标+文字
### iframe 加载
- 自动显示加载中状态
- 支持刷新按钮重新加载
- 显示当前应用标题
### 用户操作
- **个人中心**:跳转到 /profile
- **系统设置**:跳转到 /settings
- **退出登录**:跳转到 /login
## 注意事项
1. **跨域问题**:确保 iframe 应用允许被嵌入
```nginx
# nginx 配置
add_header X-Frame-Options "SAMEORIGIN";
# 或者
add_header Content-Security-Policy "frame-ancestors 'self' http://localhost:5001";
```
2. **端口配置**:确保对应服务已启动
- platform: 5001
- bidding: 5002
- workcase: 5003
- dify: 3000
3. **路由同步**iframe 模式不会改变浏览器 URL
4. **通信机制**:如需与 iframe 通信,使用 postMessage API
## 扩展建议
### 添加新菜单项
```typescript
// 在 menuItems 数组中添加
{
key: 'new-app',
label: '新应用',
icon: 'Plus',
iframeUrl: 'http://localhost:5004',
type: 'iframe'
}
```
### 动态菜单加载
```typescript
// 从 API 获取菜单配置
const loadMenus = async () => {
const response = await fetch('/api/menus')
menuItems.value = await response.json()
}
```
### 权限控制
```typescript
const menuItems = computed(() => {
return allMenuItems.filter(item =>
hasPermission(item.key)
)
})
```

View File

@@ -44,9 +44,9 @@
<el-icon><User /></el-icon>
个人中心
</el-dropdown-item>
<el-dropdown-item command="settings" divided>
<el-dropdown-item v-if="hasAdmin" command="settings" divided>
<el-icon><Setting /></el-icon>
系统设置
管理后台
</el-dropdown-item>
<el-dropdown-item command="logout" divided>
<el-icon><SwitchButton /></el-icon>
@@ -125,6 +125,7 @@ const collapsed = ref(false)
const activeMenu = ref('home')
const iframeLoading = ref(false)
const iframeRef = ref<HTMLIFrameElement>()
const hasAdmin = ref(false)
// 从 LocalStorage 获取用户名
function getUserName(): string {
@@ -163,6 +164,13 @@ function loadMenuFromStorage(): MenuItem[] {
view.service === 'platform' && // 只显示 platform 服务的视图
!view.url?.startsWith('/admin') // 排除 admin 路由(由 AdminSidebar 管理)
)
hasAdmin.value = userViews.filter((view: any) =>
view.layout === 'SidebarLayout' &&
!view.parentId &&
view.type === 1 && // type 1 是侧边栏菜单
view.service === 'platform' && // 只显示 platform 服务的视图
view.url?.startsWith('/admin') // 排除 admin 路由(由 AdminSidebar 管理)
).length>0
// 按 orderNum 排序
sidebarViews.sort((a: any, b: any) => (a.orderNum || 0) - (b.orderNum || 0))
@@ -251,7 +259,21 @@ const handleUserCommand = (command: string) => {
router.push('/profile')
break
case 'settings':
router.push('/settings')
// 跳转到管理后台AdminSidebarLayout
// 查找第一个 admin 路由
const loginDomainStr = localStorage.getItem('loginDomain')
if (loginDomainStr) {
const loginDomain = JSON.parse(loginDomainStr)
const userViews = loginDomain.userViews || []
const adminViews = userViews.filter((view: any) =>
view.service === 'platform' && view.url?.startsWith('/admin')
)
if (adminViews.length > 0) {
// 按 orderNum 排序,跳转到第一个
adminViews.sort((a: any, b: any) => (a.orderNum || 0) - (b.orderNum || 0))
router.push(adminViews[0].url)
}
}
break
case 'logout':
localStorage.clear()