通用管理后台组件库-6-头部导航组件

头部组件

说明:包含主题设置、中英文转换、黑暗和明亮模式、全屏、账号信息头像。

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>
相关推荐
linux_cfan1 小时前
打造智慧校园视听新基建:高校与在线教育平台 Web 视频播放器选型指南 (2026版)
前端·学习·音视频·教育电商
JYeontu1 小时前
实现一个超萌的柯基交互输入框
前端
天蓝色的鱼鱼2 小时前
Vite 8:从“混动”到“纯电”,构建性能提升10倍+
前端·vite
dreams_dream2 小时前
XSS类型
前端·xss
wuhen_n2 小时前
副作用的概念与effect基础:Vue3响应式系统的核心
前端·javascript·vue.js
张3蜂2 小时前
Vue.js-知识体系
前端·javascript·vue.js
Cache技术分享2 小时前
333. Java Stream API - 按年份找出合作最多的作者对:避免 Optional.orElseThrow() 的风险
前端·后端
用户600071819102 小时前
【翻译】元素与 Children 属性
前端·react.js
Mintopia2 小时前
又快又好的前端界面软件是怎么做出来的
前端