头部组件
说明:包含主题设置、中英文转换、黑暗和明亮模式、全屏、账号信息头像。
1.实现效果


2.主题设置
src/components/Themes/ThemeSettings.vue
xml
<template>
<div>
<Icon icon="ri:brush-2-line" @click="drawer = true" class="text-2xl cursor-pointer" />
<el-drawer v-model="drawer" title="主题设置" @close="handleClose" class="min-w-[330px] lt-sm:w-full!">
<el-form v-model="form">
<el-form-item label="主题颜色">
<el-color-picker v-model="form.theme" />
</el-form-item>
<el-form-item label="暗黑模式">
<el-switch v-model="form.darkMode" />
</el-form-item>
<el-form-item label="导航模式" class="flex-col nav">
<div class="flex justify-between w-full">
<el-tooltip content="左侧菜单">
<div
:class="['item', { active: form.mode === 'siderbar' }]"
@click="selectedMode('siderbar')"
>
<div class="w-1/4 h-full bg-dark left-0 top-0 absolute z-30"></div>
<div class="w-full h-1/4 bg-white left-0 top-0 absolute z-10"></div>
</div>
</el-tooltip>
<el-tooltip content="顶部左侧菜单混合">
<div :class="['item', { active: form.mode === 'mix' }]" @click="selectedMode('mix')">
<div class="w-1/4 h-full bg-white left-0 top-0 absolute z-10"></div>
<div class="w-full h-1/4 bg-dark left-0 top-0 absolute z-30"></div>
</div>
</el-tooltip>
<el-tooltip content="顶部菜单">
<div :class="['item', { active: form.mode === 'top' }]" @click="selectedMode('top')">
<div class="w-full h-1/4 bg-dark left-0 top-0 absolute"></div>
</div>
</el-tooltip>
<el-tooltip content="左侧菜单混合">
<div
:class="['item', { active: form.mode === 'mixbar' }]"
@click="selectedMode('mixbar')"
>
<div class="w-1/6 h-full bg-dark left-0 top-0 absolute z-30"></div>
<div class="w-1/6 h-full bg-white left-1/6 top-0 absolute z-10"></div>
<div class="w-full h-1/4 bg-white left-0 top-0 absolute z-20 border-b"></div>
</div>
</el-tooltip>
</div>
</el-form-item>
<el-form-item label="菜单背景">
<el-color-picker v-model="form.backgroundColor" />
</el-form-item>
<el-form-item label="菜单宽度">
<el-slider
class="ml-3"
:max="600"
:min="220"
v-model="form.menuWidth"
show-input
input-size="small"
/>
</el-form-item>
<el-form-item label="显示 Logo">
<el-switch v-model="form.showLogo" />
</el-form-item>
<el-form-item label="切换动画"></el-form-item>
<el-form-item label="标签页">
<el-switch v-model="form.showTabs" />
</el-form-item>
<el-form-item label="头部固定">
<el-switch v-model="form.fixedHead" />
</el-form-item>
<el-form-item label="显示面包屑">
<el-switch v-model="form.showBeadcrumb" />
</el-form-item>
</el-form>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import type { ModeNav, ThemeSettingProps } from './type'
const drawer = ref(false)
const props = withDefaults(defineProps<ThemeSettingProps>(), {
theme: '#409EFF',
darkMode: false,
menuWidth: 240,
showLogo: false,
showTabs: true,
fixedHead: false,
showBeadcrumb: true,
mode: 'siderbar',
backgroundColor: '#001529'
})
const form = reactive<ThemeSettingProps>({ ...props })
const selectedMode = (mode: ModeNav) => {
form.mode = mode
}
const emit = defineEmits<{
change: [settings: ThemeSettingProps]
}>()
onMounted(() => {
// 解决抽屉动态设置时,页面内容不更新的问题
emit('change', form)
})
// 关闭抽屉
const handleClose = () => {
emit('change', form)
}
</script>
<style scoped lang="scss">
:deep(.el-form-item__content) {
justify-content: flex-end;
}
:deep(.nav .el-form-item__label) {
justify-content: flex-start;
}
:deep(.nav .el-form-item__content) {
justify-content: space-between;
@apply ml-17;
}
.item {
@apply bg-gray-100 rounded w-15 h-10 relative overflow-hidden shadow border border-gray-100 cursor-pointer;
&.active {
@apply border-sky-800 border-2;
}
}
</style>
类型文件:types.d.ts
typescript
import type { IconifyIcon } from '@iconify/vue'
export interface LocaleItem {
// 选项名,中文、英文
text: string
icon?: string | IconifyIcon
// locale文件夹下的文件名,如en、zh-CN
name: string
}
// 菜单模式
export type ModeNav = 'siderbar' | 'mix' | 'top' | 'mixbar'
// 主题设置属性接口
export interface ThemeSettingProps {
theme: string
darkMode: boolean
menuWidth?: number
showLogo: boolean
showTabs: boolean
fixedHead: boolean
showBeadcrumb: boolean
// 导航模式
mode: ModeNav
backgroundColor: string
}
3.账号信息头像组件
Avatar.vue
xml
<template>
<el-dropdown v-bind="props" @command="handleCommand" :size="menuSize">
<div class="el-dropdown-link flex items-center">
<!-- src没有时自动显示用户名第一个大写字符 -->
<el-avatar
:size="size"
:src="src"
:icon="icon"
:alt="alt"
:shape="shape"
:fit="fit"
:src-set="srcSet"
>{{ username ? username[0].toUpperCase() : '' }}</el-avatar
>
<span class="ml-2" v-if="username">{{ username }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<!-- 遍历传递过来的下拉选项 -->
<template v-for="(menu, index) in data" :key="index">
<el-dropdown-item
v-if="(typeof menu === 'object' && menu?.key ? menu.key : menu) !== 'divider'"
:command="typeof menu === 'object' && menu?.key ? menu.key : menu"
>{{ typeof menu === 'object' && menu?.value ? menu.value : menu }}</el-dropdown-item
>
<el-divider class="my-0!" v-else></el-divider>
</template>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import type { AvatarMenuProps } from './types'
const props = withDefaults(defineProps<Partial<AvatarMenuProps>>(), {
trigger: 'click',
size: 25,
// src: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
username: ''
})
// 将选中的值传递出去
const emit = defineEmits<{
command: [arg: string | number | object]
}>()
const handleCommand = (command: string | number | object) => {
emit('command', command)
}
</script>
<style scoped></style>
4.头部导航组件Header.vue
xml
<template>
<el-row class="flex items-center mx-2 flex-nowrap! h-[50px]">
<!-- 折叠图标 -->
<Icon
:icon="collapseModel ? 'ep:expand' : 'ep:fold'"
@click="collapseModel = !collapseModel"
class="cursor-pointer text-2xl"
v-if="setting?.mode !== 'top'"
/>
<div class="flex-grow relative overflow-x-hidden">
<slot name="menu"></slot>
</div>
<el-row class="flex items-center flex-nowrap!">
<!-- 设置主题 -->
<ThemeSettings class="mr-3" @change="handleChange" v-bind="setting"></ThemeSettings>
<!-- 暗黑模式 -->
<DarkModeTaggle
class="mr-3"
:dark="setting?.darkMode"
@change="handleChangeDarkMode"
></DarkModeTaggle>
<!-- 国际化 -->
<ChangeLocale :locales="locales" class="mr-2" @change="changeLocale"></ChangeLocale>
<!-- 全屏 -->
<FullScreen class="mr-2"></FullScreen>
<el-divider direction="vertical"></el-divider>
<!-- 用户信息 -->
<Avatar
v-if="username || src"
v-bind="avatarProps"
@command="handleCommand"
class="ml-2"
></Avatar>
</el-row>
</el-row>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import type { ThemeSettingProps } from '../Themes/type'
import type { HeaderProps } from './types'
import { loadLocaleMessages } from '@/modules/i18n'
const props = withDefaults(defineProps<HeaderProps>(), {
collapse: false
})
// 使用v-model指令父子组件双向绑定,实现抽屉的展开和收起
const collapseModel = defineModel('collapse', {
type: Boolean,
default: false
})
// 获取头部导航栏数据,实现暗黑模式和主题设置中的数据转换
const localProps = reactive({ ...props })
// 过滤出头像数据
const avatarProps = computed(() => {
const { collapse, locales, ...restProps } = props
return restProps
})
// 回传数据
const emits = defineEmits<{
menuChange: [arg: string | number | object]
settingChange: [settings: ThemeSettingProps]
}>()
// 监听主题设置中的变化
watch(
() => localProps.setting,
() => {
emits('settingChange', localProps.setting!)
},
{ deep: true }
)
const handleCommand = (command: string | number | object) => {
emits('menuChange', command)
}
// 主题设置
const handleChange = (settings: ThemeSettingProps) => {
localProps.setting = settings
}
// 暗黑模式切换
const handleChangeDarkMode = (darkMode: boolean) => {
localProps.setting!.darkMode = darkMode
}
// 切换中英文
const changeLocale = (locale: string) => {
loadLocaleMessages(locale)
}
</script>
<style scoped></style>
类型文件types.d.ts
typescript
import type { AvatarMenuProps } from '../Avatar/types'
import type { LocaleItem, ThemeSettingProps } from '../Themes/type'
export interface HeaderProps extends Partial<AvatarMenuProps> {
// 是否折叠
collapse: boolean
// 语言数组
locales: LocaleItem[]
// 主题设置
setting?: ThemeSettingProps
}
5.默认布局default.vue中的导航组件引用
xml
<template>
<div class="w-full h-screen overflow-hidden flex">
<!-- 左右布局 -->
<!-- sidebar -->
<div
:style="{
width: mixbarMenuWidth,
backgroundColor: setting?.backgroundColor
}"
class="h-full transition-width shrink-0"
v-if="setting?.mode !== 'top'"
>
<el-row class="h-full">
<el-scrollbar
v-if="setting?.mode !== 'mix'"
:class="[setting?.mode !== 'mixbar' ? 'flex-1' : 'w-[64px] py-4']"
:style="{
backgroundColor:
setting?.mode !== 'mixbar' ? 'auto' : darkenColor(setting?.backgroundColor, 10)
}"
>
<!-- 左侧菜单和左侧菜单混合模式的布局-->
<Menu
:class="[{ mixbar: setting?.mode === 'mixbar' }]"
v-if="setting?.mode === 'siderbar' || setting?.mode === 'mixbar'"
mode="vertical"
:data="mixbarMenus"
:collapse="setting?.mode !== 'mixbar' && localSettings.collapse"
text-color="#b8b8b8"
:background-color="
setting?.mode !== 'mixbar' ? setting?.backgroundColor : 'transparent'
"
@select="handleMenuSelect"
></Menu>
</el-scrollbar>
<el-scrollbar v-if="setting?.mode === 'mix' || setting?.mode === 'mixbar'" class="flex-1">
<!-- 左侧菜单混合和顶部左侧菜单混合模式的二级menu -->
<Menu
mode="vertical"
:data="getSubMenus(menus)"
:collapse="localSettings.collapse"
text-color="#b8b8b8"
:background-color="setting?.backgroundColor"
@select="handleMenuSelect"
></Menu
></el-scrollbar>
</el-row>
</div>
<!-- content -->
<div class="w-full h-full">
<!-- header -->
<Header
:locales="locales"
:username="username"
:src="avatar"
:data="avatarMenu"
:setting="setting"
v-model:collapse="localSettings.collapse"
@setting-change="handleSettingChange"
@select="handleMenuSelect"
>
<template #menu>
<!-- 顶部菜单和混合模式布局 -->
<Menu
v-if="setting?.mode === 'top' || setting?.mode === 'mix'"
mode="horizontal"
:data="setting?.mode === 'mix' ? getTopMenus(menus) : menus"
:collapse="false"
@select="handleMenuSelect"
></Menu>
</template>
</Header>
<!-- main -->
<router-view></router-view>
</div>
<!-- 移动端菜单抽屉 -->
<el-drawer
direction="ltr"
class="w-full!"
:style="{ backgroundColor: setting?.backgroundColor }"
v-if="isMobile"
:model-value="!localSettings.collapse"
@close="localSettings.collapse = true"
>
<Menu
text-color="#b8b8b8"
:data="menus"
:background-color="setting?.backgroundColor"
@select="handleMenuSelect"
></Menu>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import type { DropMenuItem } from '@/components/Avatar/types'
import type { HeaderProps } from '@/components/Layouts/types'
import type { ThemeSettingProps } from '@/components/Themes/type'
import type { AppRouteMenuItem } from '@/components/menu/type'
import { useMenu } from '@/components/menu/useMenu'
import { darkenColor } from '@/utils'
import type { RouteRecordRaw } from 'vue-router/auto'
import { routes } from 'vue-router/auto-routes'
interface ThemeSettingsOptions extends HeaderProps {
username: string
avatar: string
avatarMenu: DropMenuItem[]
}
const router = useRouter()
// 设置配置默认数据
const localSettings = reactive<ThemeSettingsOptions>({
username: 'admin',
locales: [
{
name: 'zh-CN',
text: '中文',
icon: 'uil:letter-chinese-a'
},
{
text: '英文',
name: 'en',
icon: 'ri:english-input'
}
],
avatarMenu: [
{
key: '1',
value: '个人中心'
},
{
key: '2',
value: '修改密码'
},
{
key: 'divider',
value: ''
},
{
key: '4',
value: '退出登录'
}
],
avatar: '',
collapse: false,
setting: { menuWidth: 280 } as ThemeSettingProps
})
const { locales, avatarMenu, username, avatar } = toRefs(localSettings)
// 菜单和路由配置类型不相同,转换一下
const genrateMenuData = (routes: RouteRecordRaw[]): AppRouteMenuItem[] => {
const menuData: AppRouteMenuItem[] = []
routes.forEach((route) => {
if (route.meta?.hideMenu) return
let menuItem: AppRouteMenuItem = {
name: route.name,
path: route.path,
meta: route.meta,
alias: typeof route.redirect === 'string' ? route.redirect : undefined,
component: route.component
}
// 判断是否有子路由,递归转换
if (route.children && Array.isArray(route.children) && route.children.length > 0) {
menuItem.children = genrateMenuData(route.children)
}
menuData.push(menuItem)
})
return menuData
}
// 路由类型数据转换为菜单类型数据
const menus = computed(() => genrateMenuData(routes))
const isMobile = ref(false)
// 设置主题
const handleSettingChange = (themeSettings: ThemeSettingProps) => {
localSettings.setting = themeSettings
}
// 获取菜单宽度
const menuWidth = computed(() => (localSettings.setting ? localSettings.setting.menuWidth : 240))
// 获取设置菜单
const setting = computed(() => localSettings.setting)
// 获取mixbar和mix模式下的一二级菜单
const { getTopMenus, getSubMenus } = useMenu()
onMounted(() => {
console.log(getTopMenus(menus.value))
console.log(getSubMenus(menus.value))
})
// 混合mixbar模式下的菜单
const mixbarMenus = computed(() =>
setting.value?.mode === 'mixbar' ? getTopMenus(menus.value) : menus.value
)
// 混合mixbar模式下的二级菜单是否都设置了icon,判断收起的显示情况
const isFullIcons = computed(() => {
return getSubMenus(menus.value).every(
(item) => typeof item.meta?.icon !== 'undefined' && item.meta?.icon
)
})
// 混合mixbar模式下的菜单宽度
const mixbarMenuWidth = computed(() => {
if (isMobile.value) return 0
if (setting.value?.mode === 'mixbar' && isFullIcons.value) {
return localSettings.collapse ? 'auto' : menuWidth.value + 'px'
} else {
return localSettings.collapse ? '64px' : menuWidth.value + 'px'
}
})
// 选择menu事件
const handleMenuSelect = (menuItem: AppRouteMenuItem) => {
if (menuItem && menuItem.name) {
router.push(menuItem.name as string)
if (isMobile.value) {
localSettings.collapse = true
}
}
}
// 菜单抽屉展开折叠,屏幕宽度适配
const tmpWidth = ref(0)
const changeWidthFlag = ref(false)
useResizeObserver(document.body, (entries) => {
// 获取浏览器宽度
const { width } = entries[0].contentRect
if (tmpWidth.value === 0) {
// 记录初始宽度
tmpWidth.value = width
}
if (width > tmpWidth.value) {
// 扩大屏幕
changeWidthFlag.value = width < 640
} else {
// 缩小屏幕
changeWidthFlag.value = width > 1200
}
if (width < 640 && !changeWidthFlag.value) {
localSettings.collapse = true
}
if (width > 1200 && !changeWidthFlag.value) {
localSettings.collapse = false
}
// 是否是移动端屏幕宽度
isMobile.value = width < 440
tmpWidth.value = width
})
onBeforeMount(() => {
// 是否是移动端屏幕
if (
navigator.userAgent.match(
/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i
)
) {
isMobile.value = true
localSettings.collapse = true
}
})
</script>
<style lang="scss" scoped>
.mixbar {
:deep(.el-menu-item) {
height: auto;
line-height: unset !important;
flex-direction: column;
margin-bottom: 15px;
padding: 4px 0 !important;
svg {
margin-right: 0;
margin-bottom: 10px;
}
}
}
</style>