布局框架搭建

This commit is contained in:
2026-01-16 11:19:54 +08:00
parent f33bf735d9
commit 08f97d1a21
10 changed files with 1573 additions and 70 deletions

View File

@@ -5,13 +5,51 @@
const userRoutes = [
{
path: '/home',
name: 'home',
name: 'Home',
component: 'home',
meta: {
title: 'dashboard',
icon: 'DashboardOutlined',
role: ['admin']
title: '首页',
icon: 'HomeOutlined',
affix: true,
noCache: false
}
},
{
path: '/system',
name: 'System',
meta: {
title: '系统管理',
icon: 'SettingOutlined'
},
children: [
{
path: '/system/user',
name: 'SystemUser',
component: 'system/user',
meta: {
title: '用户管理',
icon: 'UserOutlined'
}
},
{
path: '/system/role',
name: 'SystemRole',
component: 'system/role',
meta: {
title: '角色管理',
icon: 'TeamOutlined'
}
},
{
path: '/system/menu',
name: 'SystemMenu',
component: 'system/menu',
meta: {
title: '菜单管理',
icon: 'MenuOutlined'
}
}
]
}
]

View File

@@ -0,0 +1,9 @@
<template>
<a-drawer v-model:open="open" title="布局配置"></a-drawer>
</template>
<script setup>
import { ref } from 'vue';
const open = ref(false)
</script>

View File

@@ -0,0 +1,174 @@
<template>
<a-menu mode="inline" :theme="theme" :collapsed="collapsed" :selected-keys="selectedKeys" :open-keys="openKeys"
@select="handleSelect" @open-change="handleOpenChange" class="side-menu">
<template v-for="item in menuList">
<!-- 有子菜单 -->
<a-sub-menu v-if="item.children && item.children.length > 0" :key="item.path + '-submenu'">
<template #icon>
<component :is="item.meta?.icon || 'MenuOutlined'" />
</template>
<template #title>{{ item.meta?.title || item.name }}</template>
<a-menu-item v-for="child in item.children.filter(sub => !sub.children || sub.children.length === 0)"
:key="child.path">
<template #icon>
<component :is="child.meta?.icon || 'FileOutlined'" />
</template>
{{ child.meta?.title || child.name }}
</a-menu-item>
<a-sub-menu v-for="child in item.children.filter(sub => sub.children && sub.children.length > 0)"
:key="child.path">
<template #icon>
<component :is="child.meta?.icon || 'AppstoreOutlined'" />
</template>
<template #title>{{ child.meta?.title || child.name }}</template>
<a-menu-item v-for="grandChild in child.children" :key="grandChild.path">
<template #icon>
<component :is="grandChild.meta?.icon || 'FileOutlined'" />
</template>
{{ grandChild.meta?.title || grandChild.name }}
</a-menu-item>
</a-sub-menu>
</a-sub-menu>
<!-- 无子菜单 -->
<a-menu-item v-else :key="item.path + '-item'">
<template #icon>
<component :is="item.meta?.icon || 'MenuOutlined'" />
</template>
{{ item.meta?.title || item.name }}
</a-menu-item>
</template>
</a-menu>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLayoutStore } from '@/stores/modules/layout'
import { getUserMenu } from '@/api/menu'
const props = defineProps({
collapsed: {
type: Boolean,
default: false
},
theme: {
type: String,
default: 'light'
}
})
const route = useRoute()
const router = useRouter()
const layoutStore = useLayoutStore()
const menuList = ref([])
const selectedKeys = ref([])
const openKeys = ref([])
// 获取菜单数据
const getMenuList = async () => {
try {
const res = await getUserMenu()
if (res.code === 200) {
menuList.value = res.data || []
}
} catch (error) {
console.error('获取菜单失败:', error)
// 模拟数据
menuList.value = [
{
path: '/home',
name: 'Home',
meta: { title: '首页', icon: 'HomeOutlined' }
},
{
path: '/system',
name: 'System',
meta: { title: '系统管理', icon: 'SettingOutlined' },
children: [
{
path: '/system/user',
name: 'User',
meta: { title: '用户管理', icon: 'UserOutlined' }
},
{
path: '/system/role',
name: 'Role',
meta: { title: '角色管理', icon: 'TeamOutlined' }
},
{
path: '/system/menu',
name: 'Menu',
meta: { title: '菜单管理', icon: 'MenuOutlined' }
}
]
}
]
}
}
// 更新选中的菜单
const updateSelectedKeys = () => {
selectedKeys.value = [route.path]
// 获取父级菜单路径
const matched = route.matched
.filter(item => item.path !== '/' && item.path !== route.path)
.map(item => item.path)
// 折叠时不自动展开
if (!props.collapsed) {
openKeys.value = matched
}
}
// 处理菜单选择
const handleSelect = ({ key }) => {
router.push(key)
}
// 处理菜单展开/收起
const handleOpenChange = (keys) => {
openKeys.value = keys
}
// 监听路由变化
watch(() => route.path, () => {
updateSelectedKeys()
}, { immediate: true })
// 监听折叠状态
watch(() => props.collapsed, (val) => {
if (val) {
openKeys.value = []
} else {
updateSelectedKeys()
}
})
onMounted(() => {
getMenuList()
})
</script>
<style scoped lang="scss">
.side-menu {
height: calc(100% - 64px);
border-right: none;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
</style>

View File

@@ -0,0 +1,274 @@
<template>
<div class="tags-view" v-show="showTags">
<a-dropdown :trigger="['contextmenu']">
<div class="tags-wrapper">
<a-space :size="4">
<a-tag v-for="tag in visitedViews" :key="tag.fullPath" :closable="!tag.meta?.affix"
@close="closeSelectedTag(tag)" @click="clickTag(tag)"
@contextmenu.prevent="handleContextMenu($event, tag)" class="tag-item"
:class="{ active: isActive(tag) }">
{{ tag.meta?.title || tag.name }}
</a-tag>
</a-space>
</div>
<template #overlay>
<a-menu @click="handleMenuClick">
<a-menu-item key="refresh">
<ReloadOutlined />
<span>刷新</span>
</a-menu-item>
<a-menu-item key="close" v-if="!selectedTag.meta?.affix">
<CloseOutlined />
<span>关闭</span>
</a-menu-item>
<a-menu-item key="closeOthers">
<ColumnWidthOutlined />
<span>关闭其他</span>
</a-menu-item>
<a-menu-item key="closeAll">
<CloseCircleOutlined />
<span>关闭所有</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<div class="tags-actions">
<a-tooltip title="刷新当前页">
<a-button type="text" size="small" @click="refreshSelectedTag">
<ReloadOutlined />
</a-button>
</a-tooltip>
<a-tooltip title="关闭其他">
<a-button type="text" size="small" @click="closeOthersTags">
<ColumnWidthOutlined />
</a-button>
</a-tooltip>
<a-tooltip title="关闭所有">
<a-button type="text" size="small" @click="closeAllTags">
<CloseCircleOutlined />
</a-button>
</a-tooltip>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { useRoute, useRouter, onBeforeRouteUpdate } from 'vue-router'
import { useLayoutStore } from '@/stores/modules/layout'
import { message } from 'ant-design-vue'
import {
ReloadOutlined,
CloseOutlined,
ColumnWidthOutlined,
CloseCircleOutlined
} from '@ant-design/icons-vue'
const route = useRoute()
const router = useRouter()
const layoutStore = useLayoutStore()
const showTags = ref(true)
const selectedTag = ref({})
const visitedViews = computed(() => layoutStore.viewTags)
// 判断是否是当前激活的标签
const isActive = (tag) => {
return tag.fullPath === route.fullPath
}
// 添加标签
const addTags = () => {
const { name } = route
if (name && !route.meta?.noCache) {
layoutStore.updateViewTags({
fullPath: route.fullPath,
path: route.path,
name: name,
query: route.query,
params: route.params,
meta: route.meta
})
}
return false
}
// 移除标签
const closeSelectedTag = (view) => {
layoutStore.removeViewTags(view.fullPath)
if (isActive(view)) {
toLastView(visitedViews.value)
}
}
// 关闭其他标签
const closeOthersTags = () => {
router.push(selectedTag.value)
layoutStore.viewTags = visitedViews.value.filter(tag => tag.meta?.affix || tag.fullPath === selectedTag.value.fullPath)
}
// 关闭所有标签
const closeAllTags = () => {
const affixTags = visitedViews.value.filter(tag => tag.meta?.affix)
layoutStore.viewTags = affixTags
if (affixTags.length > 0) {
router.push(affixTags[0].fullPath)
}
}
// 跳转到最后一个标签
const toLastView = (visitedViews) => {
const latestView = visitedViews.slice(-1)[0]
if (latestView) {
router.push(latestView.fullPath)
} else {
// 如果没有标签,跳转到首页
router.push('/')
}
}
// 点击标签
const clickTag = (tag) => {
selectedTag.value = tag
router.push(tag.fullPath)
}
// 刷新当前标签
const refreshSelectedTag = () => {
const { fullPath } = route
router.replace({
path: '/redirect' + fullPath
})
}
// 右键菜单处理
const handleContextMenu = (event, tag) => {
event.preventDefault()
selectedTag.value = tag
}
// 菜单点击处理
const handleMenuClick = ({ key }) => {
switch (key) {
case 'refresh':
refreshSelectedTag()
break
case 'close':
if (!selectedTag.value.meta?.affix) {
closeSelectedTag(selectedTag.value)
}
break
case 'closeOthers':
closeOthersTags()
break
case 'closeAll':
closeAllTags()
break
}
}
// 监听路由变化
watch(() => route.path, () => {
addTags()
}, { immediate: true })
// 监听路由更新
onBeforeRouteUpdate((to) => {
addTags()
})
onMounted(() => {
addTags()
// 初始化选中的标签
selectedTag.value = visitedViews.value.find(tag => isActive(tag)) || {}
})
</script>
<style scoped lang="scss">
.tags-view {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
background-color: #ffffff;
border-bottom: 1px solid #f0f0f0;
padding: 0 16px;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
.tags-wrapper {
flex: 1;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
&::-webkit-scrollbar {
height: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 2px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
.tag-item {
display: inline-flex;
align-items: center;
height: 28px;
line-height: 28px;
padding: 0 12px;
margin: 0 4px;
border: 1px solid #d9d9d9;
background-color: #fafafa;
border-radius: 2px;
cursor: pointer;
transition: all 0.3s;
&:hover {
color: #1890ff;
border-color: #1890ff;
}
&.active {
background-color: #1890ff;
border-color: #1890ff;
color: #ffffff;
&:hover {
background-color: #40a9ff;
border-color: #40a9ff;
}
}
:deep(.ant-tag-close-icon) {
color: inherit;
&:hover {
background-color: rgba(255, 255, 255, 0.3);
}
}
}
}
.tags-actions {
display: flex;
align-items: center;
gap: 4px;
margin-left: 12px;
flex-shrink: 0;
:deep(.ant-btn) {
font-size: 14px;
padding: 2px 6px;
&:hover {
color: #1890ff;
}
}
}
}
</style>

View File

@@ -0,0 +1,91 @@
<template>
<a-menu mode="horizontal" :selected-keys="selectedKeys" @select="handleSelect" class="top-menu">
<a-menu-item v-for="item in menuList" :key="item.path">
<template #icon>
<component :is="item.meta?.icon || 'AppstoreOutlined'" />
</template>
{{ item.meta?.title || item.name }}
</a-menu-item>
</a-menu>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getUserMenu } from '@/api/menu'
const route = useRoute()
const router = useRouter()
const menuList = ref([])
const selectedKeys = ref([])
// 获取菜单数据
const getMenuList = async () => {
try {
const res = await getUserMenu()
if (res.code === 200) {
// 只显示一级菜单
menuList.value = (res.data || []).filter(item => !item.meta?.hidden)
}
} catch (error) {
console.error('获取菜单失败:', error)
// 模拟数据
menuList.value = [
{
path: '/home',
name: 'Home',
meta: { title: '首页', icon: 'HomeOutlined' }
},
{
path: '/system',
name: 'System',
meta: { title: '系统管理', icon: 'SettingOutlined' }
}
]
}
}
// 更新选中的菜单
const updateSelectedKeys = () => {
// 查找当前路由对应的顶级菜单
const path = route.path
const matched = route.matched
if (matched.length > 0) {
const topPath = matched[0].path
selectedKeys.value = [topPath]
}
}
// 处理菜单选择
const handleSelect = ({ key }) => {
router.push(key)
}
// 监听路由变化
watch(() => route.path, () => {
updateSelectedKeys()
}, { immediate: true })
onMounted(() => {
getMenuList()
})
</script>
<style scoped lang="scss">
.top-menu {
border-bottom: none;
flex: 1;
background: transparent;
:deep(.ant-menu-item) {
&:hover {
color: #1890ff;
}
&.ant-menu-item-selected {
background-color: rgba(24, 144, 255, 0.1);
}
}
}
</style>

View File

@@ -0,0 +1,251 @@
<template>
<div class="userbar">
<a-dropdown :trigger="['click']">
<div class="user-info">
<a-avatar :size="32" :src="userStore.user?.avatar || ''">
{{ userStore.user?.username?.charAt(0)?.toUpperCase() || 'U' }}
</a-avatar>
<span class="username">{{ userStore.user?.username || 'Admin' }}</span>
<DownOutlined />
</div>
<template #overlay>
<a-menu @click="handleMenuClick">
<a-menu-item key="profile">
<UserOutlined />
<span>个人中心</span>
</a-menu-item>
<a-menu-item key="settings">
<SettingOutlined />
<span>系统设置</span>
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout">
<LogoutOutlined />
<span>退出登录</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-tooltip title="全屏">
<a-button type="text" @click="toggleFullscreen" class="action-btn">
<FullscreenOutlined v-if="!isFullscreen" />
<FullscreenExitOutlined v-else />
</a-button>
</a-tooltip>
<a-tooltip title="布局设置">
<a-button type="text" @click="showSetting = true" class="action-btn">
<SettingOutlined />
</a-button>
</a-tooltip>
<!-- 布局设置抽屉 -->
<a-drawer v-model:open="showSetting" title="布局设置" placement="right" :width="280">
<div class="setting-content">
<div class="setting-item">
<div class="setting-title">布局模式</div>
<a-radio-group v-model:value="layoutStore.layoutMode" @change="handleLayoutChange">
<a-radio value="default">默认布局</a-radio>
<a-radio value="menu">菜单布局</a-radio>
<a-radio value="top">顶部布局</a-radio>
</a-radio-group>
</div>
<div class="setting-item">
<div class="setting-title">主题颜色</div>
<div class="color-list">
<div v-for="color in themeColors" :key="color" class="color-item"
:class="{ active: themeColor === color }" :style="{ backgroundColor: color }"
@click="changeThemeColor(color)">
<CheckOutlined v-if="themeColor === color" />
</div>
</div>
</div>
</div>
</a-drawer>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import { useUserStore } from '@/stores/modules/user'
import { useLayoutStore } from '@/stores/modules/layout'
import {
DownOutlined,
UserOutlined,
SettingOutlined,
LogoutOutlined,
FullscreenOutlined,
FullscreenExitOutlined,
CheckOutlined
} from '@ant-design/icons-vue'
const router = useRouter()
const userStore = useUserStore()
const layoutStore = useLayoutStore()
const isFullscreen = ref(false)
const showSetting = ref(false)
const themeColor = ref('#1890ff')
const themeColors = [
'#1890ff',
'#f5222d',
'#fa541c',
'#faad14',
'#13c2c2',
'#52c41a',
'#2f54eb',
'#722ed1'
]
// 切换全屏
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen()
isFullscreen.value = true
} else {
document.exitFullscreen()
isFullscreen.value = false
}
}
// 监听全屏变化
const handleFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement
}
onMounted(() => {
document.addEventListener('fullscreenchange', handleFullscreenChange)
})
onUnmounted(() => {
document.removeEventListener('fullscreenchange', handleFullscreenChange)
})
// 处理菜单点击
const handleMenuClick = ({ key }) => {
switch (key) {
case 'profile':
router.push('/profile')
break
case 'settings':
showSetting.value = true
break
case 'logout':
handleLogout()
break
}
}
// 退出登录
const handleLogout = () => {
Modal.confirm({
title: '确认退出',
content: '确定要退出登录吗?',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
await userStore.logout()
message.success('退出成功')
router.push('/login')
} catch (error) {
message.error('退出失败')
}
}
})
}
// 切换布局
const handleLayoutChange = (e) => {
layoutStore.setLayoutMode(e.target.value)
message.success(`已切换到${e.target.value === 'default' ? '默认' : e.target.value === 'menu' ? '菜单' : '顶部'}布局`)
}
// 切换主题颜色
const changeThemeColor = (color) => {
themeColor.value = color
// 这里可以实现主题切换逻辑
message.success('主题颜色已更新')
}
</script>
<style scoped lang="scss">
.userbar {
display: flex;
align-items: center;
gap: 12px;
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 12px;
border-radius: 4px;
transition: background-color 0.3s;
&:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.username {
font-size: 14px;
color: #333;
}
}
.action-btn {
font-size: 16px;
padding: 4px 8px;
&:hover {
color: #1890ff;
}
}
}
.setting-content {
.setting-item {
margin-bottom: 24px;
.setting-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 12px;
color: #333;
}
.color-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
.color-item {
width: 32px;
height: 32px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s;
&:hover {
transform: scale(1.1);
}
&.active {
.anticon {
color: #fff;
}
}
}
}
}
}
</style>

View File

@@ -2,33 +2,131 @@
<a-layout class="app-wrapper" :class="layoutClass">
<!-- 默认布局左侧双栏布局 -->
<template v-if="layoutMode === 'default'">
<a-layout-sider theme="dark" width="70"></a-layout-sider>
<a-layout-sider theme="light"></a-layout-sider>
<a-layout>
<a-layout-sider theme="dark" width="70" class="logo-sidebar">
<div class="logo-box">
<span class="logo-text">VUE</span>
</div>
</a-layout-sider>
<a-layout-sider theme="light" :collapsed="sidebarCollapsed" :collapsible="true" @collapse="handleCollapse"
class="menu-sidebar" width="200" :collapsed-width="64">
<side-menu :collapsed="sidebarCollapsed" />
</a-layout-sider>
<a-layout class="main-layout">
<a-layout-header class="app-header">
<breadcrumb />
<div class="header-left">
<a-button type="text" :icon="sidebarCollapsed ? 'menu-unfold' : 'menu-fold'"
@click="toggleSidebar" class="collapse-btn" />
<breadcrumb />
</div>
<userbar />
</a-layout-header>
<a-layout-content><router-view></router-view></a-layout-content>
<tags />
<a-layout-content class="app-main">
<router-view v-slot="{ Component }">
<keep-alive :include="cachedViews">
<component :is="Component" :key="$route.fullPath" />
</keep-alive>
</router-view>
</a-layout-content>
</a-layout>
</template>
<!-- Menu布局左侧菜单栏布局 -->
<template v-else-if="layoutMode === 'menu'">
<a-layout-sider theme="light" :collapsed="sidebarCollapsed" :collapsible="true" @collapse="handleCollapse"
class="full-menu-sidebar" width="200" :collapsed-width="64">
<div class="logo-box-full">
<span v-if="!sidebarCollapsed" class="logo-text">VUE ADMIN</span>
<span v-else class="logo-text-mini">V</span>
</div>
<side-menu :collapsed="sidebarCollapsed" />
</a-layout-sider>
<a-layout class="main-layout">
<a-layout-header class="app-header">
<div class="header-left">
<a-button type="text" :icon="sidebarCollapsed ? 'menu-unfold' : 'menu-fold'"
@click="toggleSidebar" class="collapse-btn" />
<breadcrumb />
</div>
<userbar />
</a-layout-header>
<tags />
<a-layout-content class="app-main">
<router-view v-slot="{ Component }">
<keep-alive :include="cachedViews">
<component :is="Component" :key="$route.fullPath" />
</keep-alive>
</router-view>
</a-layout-content>
</a-layout>
</template>
<!-- Top布局顶部菜单栏布局 -->
<template v-else-if="layoutMode === 'top'">
<a-layout-header class="app-header top-header">
<div class="top-header-left">
<div class="logo-box-top">
<span class="logo-text">VUE ADMIN</span>
</div>
<topMenu />
</div>
<userbar />
</a-layout-header>
<tags />
<a-layout-content class="app-main top-content">
<router-view v-slot="{ Component }">
<keep-alive :include="cachedViews">
<component :is="Component" :key="$route.fullPath" />
</keep-alive>
</router-view>
</a-layout-content>
</template>
</a-layout>
</template>
<script setup>
import { ref } from 'vue'
import { RouterView } from 'vue-router'
import breadcrumb from './components/breadcrumb.vue';
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useLayoutStore } from '@/stores/modules/layout'
const layoutMode = ref('default')
const layoutClass = ref('layout-default')
import userbar from './components/userbar.vue'
import breadcrumb from './components/breadcrumb.vue'
import tags from './components/tags.vue'
import topMenu from './components/topMenu.vue'
import sideMenu from './components/sideMenu.vue'
const route = useRoute()
const layoutStore = useLayoutStore()
const layoutMode = computed(() => layoutStore.layoutMode)
const sidebarCollapsed = computed(() => layoutStore.sidebarCollapsed)
// 缓存的视图列表
const cachedViews = computed(() => {
return layoutStore.viewTags
.filter(tag => !tag.meta?.noCache)
.map(tag => tag.name)
})
// 布局类名
const layoutClass = computed(() => {
return {
'layout-default': layoutMode.value === 'default',
'layout-menu': layoutMode.value === 'menu',
'layout-top': layoutMode.value === 'top',
'is-collapse': sidebarCollapsed.value
}
})
// 切换侧边栏
const toggleSidebar = () => {
layoutStore.toggleSidebar()
}
// 处理折叠
const handleCollapse = (collapsed) => {
layoutStore.sidebarCollapsed = collapsed
}
</script>
<style scoped lang="scss">
@@ -37,98 +135,160 @@ const layoutClass = ref('layout-default')
width: 100%;
height: 100vh;
display: flex;
overflow: hidden;
.app-header {
display: flex;
background-color: #ffffff;
padding-inline: 20px;
height: 50px;
justify-content: space-between;
align-items: center;
background-color: #ffffff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
z-index: 9;
.header-left {
display: flex;
align-items: center;
gap: 12px;
.collapse-btn {
font-size: 16px;
padding: 4px 8px;
}
}
}
.app-main {
padding: 16px;
background-color: #f0f2f5;
overflow-y: auto;
flex: 1;
height: calc(100vh - 106px);
}
/* 默认布局 */
&.layout-default {
flex-direction: row;
.logo-sidebar {
background-color: #001529;
display: flex;
align-items: center;
justify-content: center;
.main-container {
margin-left: 200px;
.logo-box {
width: 100%;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
&.has-sidebar {
margin-left: 200px;
}
.fixed-header {
width: calc(100% - 200px);
.logo-text {
color: #ffffff;
font-size: 20px;
font-weight: bold;
letter-spacing: 2px;
}
}
}
&.is-collapse {
.main-container {
margin-left: 64px;
.menu-sidebar {
background-color: #ffffff;
box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);
z-index: 10;
}
.fixed-header {
width: calc(100% - 64px);
}
}
.main-layout {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.app-header {
padding: 0 20px;
height: 50px;
}
.app-main {
height: calc(100vh - 106px);
}
}
/* Menu布局 */
&.layout-menu {
.main-container {
margin-left: 200px;
.full-menu-sidebar {
background-color: #ffffff;
box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);
z-index: 10;
&.has-sidebar {
margin-left: 200px;
}
.logo-box-full {
width: 100%;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #f0f0f0;
.fixed-header {
width: calc(100% - 200px);
.logo-text {
color: #1890ff;
font-size: 18px;
font-weight: bold;
letter-spacing: 1px;
}
.logo-text-mini {
color: #1890ff;
font-size: 20px;
font-weight: bold;
}
}
}
&.is-collapse {
.main-container {
margin-left: 64px;
.main-layout {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.fixed-header {
width: calc(100% - 64px);
}
}
.app-header {
padding: 0 20px;
height: 50px;
}
.app-main {
height: calc(100vh - 106px);
}
}
/* Top布局 */
&.layout-top {
.top-layout-container {
width: 100%;
height: 100%;
flex-direction: column;
.top-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1002;
}
.top-header {
padding: 0 20px;
height: 60px;
display: flex;
justify-content: space-between;
align-items: center;
.top-main {
padding-top: 50px;
.top-header-left {
display: flex;
align-items: center;
gap: 20px;
.top-userbar {
position: fixed;
top: 50px;
right: 0;
z-index: 1001;
width: 100%;
padding: 0;
}
.app-main {
padding-top: 60px;
.logo-box-top {
.logo-text {
color: #1890ff;
font-size: 20px;
font-weight: bold;
letter-spacing: 1px;
}
}
}
}
.top-content {
height: calc(100vh - 116px);
}
}
}
</style>

View File

@@ -0,0 +1,193 @@
<template>
<div class="system-menu">
<a-card title="菜单管理">
<template #extra>
<a-button type="primary" @click="handleAdd">
<PlusOutlined />
新增菜单
</a-button>
</template>
<a-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="false" row-key="id">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'icon'">
<component :is="record.icon || 'MenuOutlined'" />
</template>
<template v-else-if="column.key === 'type'">
<a-tag :color="getTypeColor(record.type)">
{{ getTypeText(record.type) }}
</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'red'">
{{ record.status === 1 ? '正常' : '禁用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-button type="link" size="small" @click="handleAddChild(record)">新增子菜单</a-button>
<a-button type="link" size="small" danger @click="handleDelete(record)">删除</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined, MenuOutlined } from '@ant-design/icons-vue'
const columns = [
{
title: '菜单名称',
dataIndex: 'title',
key: 'title'
},
{
title: '图标',
dataIndex: 'icon',
key: 'icon',
width: 80
},
{
title: '路径',
dataIndex: 'path',
key: 'path'
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 100
},
{
title: '排序',
dataIndex: 'sort',
key: 'sort',
width: 80
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100
},
{
title: '操作',
key: 'action',
width: 220
}
]
const dataSource = ref([])
const loading = ref(false)
// 模拟数据
const mockData = [
{
id: 1,
title: '首页',
path: '/home',
icon: 'HomeOutlined',
type: 1,
sort: 1,
status: 1,
children: []
},
{
id: 2,
title: '系统管理',
path: '/system',
icon: 'SettingOutlined',
type: 1,
sort: 2,
status: 1,
children: [
{
id: 21,
title: '用户管理',
path: '/system/user',
icon: 'UserOutlined',
type: 2,
sort: 1,
status: 1
},
{
id: 22,
title: '角色管理',
path: '/system/role',
icon: 'TeamOutlined',
type: 2,
sort: 2,
status: 1
},
{
id: 23,
title: '菜单管理',
path: '/system/menu',
icon: 'MenuOutlined',
type: 2,
sort: 3,
status: 1
}
]
}
]
const loadData = () => {
loading.value = true
// 模拟接口请求
setTimeout(() => {
dataSource.value = mockData
loading.value = false
}, 500)
}
const getTypeColor = (type) => {
const colorMap = {
1: 'blue',
2: 'green',
3: 'orange'
}
return colorMap[type] || 'default'
}
const getTypeText = (type) => {
const textMap = {
1: '目录',
2: '菜单',
3: '按钮'
}
return textMap[type] || '未知'
}
const handleAdd = () => {
message.info('新增菜单功能开发中')
}
const handleEdit = (record) => {
message.info('编辑菜单功能开发中')
}
const handleAddChild = (record) => {
message.info(`新增 ${record.title} 的子菜单功能开发中`)
}
const handleDelete = (record) => {
message.info('删除菜单功能开发中')
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.system-menu {
// 样式
}
</style>

View File

@@ -0,0 +1,141 @@
<template>
<div class="system-role">
<a-card title="角色管理">
<template #extra>
<a-button type="primary" @click="handleAdd">
<PlusOutlined />
新增角色
</a-button>
</template>
<a-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="pagination">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'red'">
{{ record.status === 1 ? '正常' : '禁用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-button type="link" size="small" @click="handlePermission(record)">权限</a-button>
<a-button type="link" size="small" danger @click="handleDelete(record)">删除</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80
},
{
title: '角色名称',
dataIndex: 'roleName',
key: 'roleName'
},
{
title: '角色标识',
dataIndex: 'roleCode',
key: 'roleCode'
},
{
title: '描述',
dataIndex: 'description',
key: 'description'
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime'
},
{
title: '操作',
key: 'action',
width: 180
}
]
const dataSource = ref([])
const loading = ref(false)
const pagination = ref({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showTotal: (total) => `${total}`
})
// 模拟数据
const mockData = [
{
id: 1,
roleName: '管理员',
roleCode: 'admin',
description: '系统管理员',
status: 1,
createTime: '2024-01-01 10:00:00'
},
{
id: 2,
roleName: '普通用户',
roleCode: 'user',
description: '普通用户角色',
status: 1,
createTime: '2024-01-02 10:00:00'
}
]
const loadData = () => {
loading.value = true
// 模拟接口请求
setTimeout(() => {
dataSource.value = mockData
pagination.value.total = mockData.length
loading.value = false
}, 500)
}
const handleAdd = () => {
message.info('新增角色功能开发中')
}
const handleEdit = (record) => {
message.info('编辑角色功能开发中')
}
const handlePermission = (record) => {
message.info('权限配置功能开发中')
}
const handleDelete = (record) => {
message.info('删除角色功能开发中')
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.system-role {
// 样式
}
</style>

View File

@@ -0,0 +1,172 @@
<template>
<div class="system-user">
<a-card title="用户管理">
<template #extra>
<a-button type="primary" @click="handleAdd">
<PlusOutlined />
新增用户
</a-button>
</template>
<div class="search-form">
<a-form :model="searchForm" layout="inline">
<a-form-item label="用户名">
<a-input v-model:value="searchForm.username" placeholder="请输入用户名" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="searchForm.status" placeholder="请选择状态" style="width: 120px">
<a-select-option value="">全部</a-select-option>
<a-select-option :value="1">正常</a-select-option>
<a-select-option :value="0">禁用</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="handleSearch">查询</a-button>
<a-button @click="handleReset">重置</a-button>
</a-space>
</a-form-item>
</a-form>
</div>
<a-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="pagination">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'red'">
{{ record.status === 1 ? '正常' : '禁用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-button type="link" size="small" danger @click="handleDelete(record)">删除</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80
},
{
title: '用户名',
dataIndex: 'username',
key: 'username'
},
{
title: '昵称',
dataIndex: 'nickname',
key: 'nickname'
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime'
},
{
title: '操作',
key: 'action',
width: 150
}
]
const searchForm = ref({
username: '',
status: ''
})
const dataSource = ref([])
const loading = ref(false)
const pagination = ref({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showTotal: (total) => `${total}`
})
// 模拟数据
const mockData = [
{
id: 1,
username: 'admin',
nickname: '管理员',
status: 1,
createTime: '2024-01-01 10:00:00'
},
{
id: 2,
username: 'user',
nickname: '普通用户',
status: 1,
createTime: '2024-01-02 10:00:00'
}
]
const loadData = () => {
loading.value = true
// 模拟接口请求
setTimeout(() => {
dataSource.value = mockData
pagination.value.total = mockData.length
loading.value = false
}, 500)
}
const handleSearch = () => {
loadData()
}
const handleReset = () => {
searchForm.value = {
username: '',
status: ''
}
loadData()
}
const handleAdd = () => {
message.info('新增用户功能开发中')
}
const handleEdit = (record) => {
message.info('编辑用户功能开发中')
}
const handleDelete = (record) => {
message.info('删除用户功能开发中')
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.system-user {
.search-form {
margin-bottom: 16px;
padding: 16px;
background-color: #fafafa;
border-radius: 4px;
}
}
</style>