initial commit
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<el-aside class="member-sidebar">
|
||||
<!-- 用户信息区域 -->
|
||||
<div class="member-sidebar__user-info">
|
||||
<div @click="routerPush('/member/profile')" class="member-sidebar__avatar-container">
|
||||
<img class="member-sidebar__avatar" :src="getAvatarUrl(memberStore?.info?.avatar)" alt="" />
|
||||
</div>
|
||||
<p class="member-sidebar__username">{{ memberStore?.info?.nickname }}</p>
|
||||
</div>
|
||||
|
||||
<el-menu
|
||||
class="member-sidebar__menu"
|
||||
:default-active="activeMenu"
|
||||
router
|
||||
unique-opened
|
||||
>
|
||||
<template v-for="(item, idx) in personalCenterStore.state.user_menus" :key="idx">
|
||||
<el-sub-menu
|
||||
v-if="item.children && item.children.length"
|
||||
:index="item.id.toString()"
|
||||
>
|
||||
<template #title>
|
||||
<Icon v-if="item.icon" :name="item.icon" size="16" />
|
||||
<span>{{ item.title }}</span>
|
||||
</template>
|
||||
<el-menu-item
|
||||
v-for="(menu, index) in item.children"
|
||||
:key="index"
|
||||
:index="menu.path"
|
||||
@click="routerPush(menu)"
|
||||
>
|
||||
<Icon v-if="menu.icon" :name="menu.icon" size="16" />
|
||||
<span>{{ menu.title }}</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item
|
||||
v-else
|
||||
:index="item.path"
|
||||
@click="routerPush(item)"
|
||||
>
|
||||
<Icon v-if="item.icon" :name="item.icon" size="16" />
|
||||
<span>{{ item.title }}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Menus } from '~/stores/interface'
|
||||
// 在组件顶部导入图片
|
||||
import defaultAvatar from '~/assets/images/default_avatar.png'
|
||||
|
||||
const route = useRoute()
|
||||
const memberStore = useMemberStore()
|
||||
const personalCenterStore = usePersonalCenterStore()
|
||||
|
||||
// 当前激活的菜单
|
||||
const activeMenu = computed(() => {
|
||||
return route.path
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取用户头像URL
|
||||
*/
|
||||
const getAvatarUrl = (avatarUrl: string | null | undefined): string => {
|
||||
if (!avatarUrl || avatarUrl.trim() === "") {
|
||||
return defaultAvatar
|
||||
}
|
||||
return fullUrl(avatarUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单跳转
|
||||
*/
|
||||
const routerPush = (route: string | Menus) => {
|
||||
if (typeof route === 'string') {
|
||||
navigateTo(route)
|
||||
} else {
|
||||
onClickMenu(route as any)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.member-sidebar {
|
||||
width: 240px;
|
||||
background-color: var(--ba-bg-color-overlay);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.member-sidebar__user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px 20px;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.member-sidebar__avatar-container {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.member-sidebar__avatar {
|
||||
display: block;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.member-sidebar__avatar-gender {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--ba-bg-color-overlay);
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--el-box-shadow);
|
||||
}
|
||||
|
||||
.member-sidebar__username {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
margin: 8px 0 16px;
|
||||
font-size: var(--el-font-size-large);
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.member-sidebar__button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.member-sidebar__button {
|
||||
font-size: var(--el-font-size-small);
|
||||
height: 32px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.member-sidebar__menu {
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
:deep(.el-menu-item),
|
||||
:deep(.el-sub-menu__title) {
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
|
||||
.icon {
|
||||
margin-right: 8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-menu-item.is-active) {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media screen and (max-width: 991px) {
|
||||
.member-sidebar {
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.member-sidebar__user-info {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.member-sidebar__avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.member-sidebar__button-group {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.member-sidebar__button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="theme-toggle-content" @click="toggleDark">
|
||||
<div class="switch">
|
||||
<div class="switch-action">
|
||||
<Icon name="local-dark" color="#ffffff" size="13px" class="switch-icon dark-icon" />
|
||||
<Icon name="local-light" color="#303133" size="13px" class="switch-icon light-icon" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
const color = useColorMode()
|
||||
|
||||
|
||||
function toggleDark() {
|
||||
color.preference = color.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.theme-toggle-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.switch {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--ma-bg-color);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.3s,
|
||||
background-color 0.5s;
|
||||
}
|
||||
.switch-action {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
border-radius: 50%;
|
||||
background-color: #ffffff;
|
||||
transform: translate(0);
|
||||
color: var(--el-text-color-primary);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.switch-icon {
|
||||
position: absolute;
|
||||
left: 1px;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.light-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
.dark-icon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@at-root html.dark {
|
||||
.switch {
|
||||
background-color: #2c2c2c;
|
||||
}
|
||||
.switch-action {
|
||||
transform: translate(20px);
|
||||
background-color: #141414;
|
||||
}
|
||||
.dark-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
.light-icon {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<button class="inline-flex justify-center items-center" @click="toggleDark">
|
||||
<div class="i-ri-sun-line dark:i-ri-moon-line" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const color = useColorMode()
|
||||
|
||||
|
||||
function toggleDark() {
|
||||
color.preference = color.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<el-footer class="md-footer">
|
||||
<div>
|
||||
Copyright @ 2020~2026版权所有
|
||||
<a href="http://beian.miit.gov.cn/">189456784215</a>
|
||||
</div>
|
||||
</el-footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.md-footer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--el-color-info-light-7);
|
||||
a {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
@media screen and (max-width: 768px) {
|
||||
a {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<el-menu :default-active="state.activeMenu" @select="onSelect">
|
||||
<el-sub-menu v-blur index="language-switch" class="language-switch">
|
||||
<template #title>
|
||||
<Icon v-if="showIcon" name="local-lang" color="var(--el-text-color-primary)" />
|
||||
{{ t("language") }}
|
||||
</template>
|
||||
<el-menu-item
|
||||
@click="systemStore.setLanguage(item.name)"
|
||||
v-for="item in availableLanguages"
|
||||
:key="item.name"
|
||||
:index="'language-switch-' + item.value"
|
||||
class="language-switch"
|
||||
>
|
||||
{{ item.value }}
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 主题切换 -->
|
||||
<el-menu-item
|
||||
index="theme-switch"
|
||||
class="theme-switch"
|
||||
:class="$attrs.mode + '-theme-switch'"
|
||||
>
|
||||
<DarkSwitch />
|
||||
<!-- <DarkToggle v-if="!isMobile" /> -->
|
||||
</el-menu-item>
|
||||
|
||||
<!-- 已登录用户场景 -->
|
||||
<el-sub-menu
|
||||
v-if="memberStore.info"
|
||||
@click="$attrs.mode == 'vertical' ? '' : navigateTo('/member/profile')"
|
||||
v-blur
|
||||
index="user-box"
|
||||
>
|
||||
<template #title>
|
||||
<div class="header-user-box">
|
||||
<img
|
||||
class="header-user-avatar"
|
||||
:class="$attrs.mode == 'vertical' ? 'icon-header-user-avatar' : ''"
|
||||
:src="getAvatarUrl(memberStore?.info?.avatar || '')"
|
||||
alt=""
|
||||
/>
|
||||
{{ memberStore?.info?.nickname || t("user") }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-menu-item @click="navigateTo('/member/profile')" v-blur index="user">
|
||||
<Icon
|
||||
v-if="showIcon"
|
||||
name="fa fa-user-circle"
|
||||
color="var(--el-text-color-primary)"
|
||||
/>
|
||||
{{ t("member_center") }}
|
||||
</el-menu-item>
|
||||
|
||||
<!-- 动态菜单 -->
|
||||
<MenuSub :menus="personalCenterStore.state.nav_user_menus" :show-icon="showIcon" />
|
||||
|
||||
<!-- 会员中心菜单 -->
|
||||
<MenuSub :menus="personalCenterStore.state.user_menus" :show-icon="showIcon" />
|
||||
|
||||
<el-menu-item @click="memberStore.logout()" v-blur index="user-logout">
|
||||
<Icon
|
||||
v-if="showIcon"
|
||||
name="fa fa-sign-out"
|
||||
color="var(--el-text-color-primary)"
|
||||
/>
|
||||
{{ t("logout_login") }}
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 未登录场景 -->
|
||||
<el-menu-item v-else @click="handleLogin" v-blur index="user">
|
||||
<Icon
|
||||
v-if="showIcon"
|
||||
name="fa fa-user-circle"
|
||||
color="var(--el-text-color-primary)"
|
||||
/>
|
||||
{{ t("login") }}
|
||||
</el-menu-item>
|
||||
<LoadingDialog />
|
||||
|
||||
</el-menu>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Menus } from "~/stores/interface";
|
||||
import defaultAvatar from "~/assets/images/default_avatar.png";
|
||||
|
||||
import DarkToggle from "./dark-toggle.vue";
|
||||
import DarkSwitch from "./dark-switch.vue";
|
||||
import LoadingDialog from "@/components/login-dialog/index.vue";
|
||||
import MenuSub from "./menu-sub.vue";
|
||||
|
||||
const memberStore = useMemberStore();
|
||||
const systemStore = useSystemStore();
|
||||
const configStore = useConfigStore();
|
||||
const availableLanguages = computed(() => systemStore.getLanguages);
|
||||
const currentLanguage = computed(() => systemStore.lang);
|
||||
const personalCenterStore = usePersonalCenterStore();
|
||||
|
||||
|
||||
interface Props {
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showIcon: false,
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
activeMenu: "",
|
||||
});
|
||||
// 判断是否为移动端
|
||||
const isMobile = ref(false);
|
||||
|
||||
const checkMobile = () => {
|
||||
isMobile.value = window.innerWidth <= 768;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户头像URL
|
||||
* @param avatarUrl 用户头像URL,可为空、null或undefined
|
||||
* @returns 完整的头像URL,如果传入空值则返回默认头像
|
||||
*/
|
||||
const getAvatarUrl = (avatarUrl: string | null | undefined): string => {
|
||||
if (!avatarUrl || avatarUrl.trim() === "") {
|
||||
// 返回默认头像
|
||||
return defaultAvatar;
|
||||
}
|
||||
|
||||
return fullUrl(avatarUrl);
|
||||
};
|
||||
|
||||
const handleLogin = () => {
|
||||
if (
|
||||
!getToken() &&
|
||||
!configStore.login.is_username &&
|
||||
!configStore.login.is_mobile &&
|
||||
!configStore.login.is_bind_mobile
|
||||
) {
|
||||
ElMessage.error("商家未开启普通账号登录注册");
|
||||
return false;
|
||||
}
|
||||
memberStore.logOpen();
|
||||
};
|
||||
|
||||
/**
|
||||
* 菜单被点击时额外对无需激活的菜单处理(外链、暗黑模式开关、语言切换等)
|
||||
* 检查菜单是否需要激活,如果否,还原 state.activeMenu
|
||||
*/
|
||||
const onSelect = (index: string) => {
|
||||
if (noNeedActive(systemStore.site.head_nav, index)) {
|
||||
const oldActiveMenu = state.activeMenu;
|
||||
state.activeMenu = "";
|
||||
nextTick(() => {
|
||||
state.activeMenu = oldActiveMenu;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查一个菜单是否需要激活态
|
||||
* @param menus
|
||||
* @param index
|
||||
*/
|
||||
const noNeedActive = (menus: Menus[], index: string) => {
|
||||
if (index.indexOf("language-switch") === 0 || index == "theme-switch") {
|
||||
return true;
|
||||
}
|
||||
return isExternalLink(menus, index);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查一个菜单是否是外站链接,如果是,不要激活
|
||||
* @param menus
|
||||
* @param index
|
||||
*/
|
||||
const isExternalLink = (menus: Menus[], index: string): boolean => {
|
||||
// 修复:添加空值检查
|
||||
if (!menus || !Array.isArray(menus)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const key in menus) {
|
||||
// 修复:检查 menus[key] 是否存在
|
||||
if (!menus[key]) continue;
|
||||
|
||||
const columnIndex = `column-${menus[key].id}`;
|
||||
if (columnIndex == index) {
|
||||
return menus[key].meta?.menu_type == "link";
|
||||
}
|
||||
// 修复:添加 children 存在性检查
|
||||
if (
|
||||
menus[key].children &&
|
||||
Array.isArray(menus[key].children) &&
|
||||
menus[key].children.length
|
||||
) {
|
||||
const result = isExternalLink(menus[key].children, index);
|
||||
if (result) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const toggleDarkMode = () => {
|
||||
setDark(!getDark());
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", checkMobile);
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.header-user-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
.header-user-avatar {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
margin-right: 4px;
|
||||
border-radius: 50%;
|
||||
background-color: black;
|
||||
}
|
||||
.icon-header-user-avatar {
|
||||
margin-left: 4px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
.el-sub-menu .icon,
|
||||
.el-menu-item .icon {
|
||||
vertical-align: middle;
|
||||
margin-right: 2px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.is-active > .icon {
|
||||
color: var(--el-menu-active-color) !important;
|
||||
}
|
||||
.el-menu {
|
||||
border-bottom: none;
|
||||
border-right: none;
|
||||
.theme-switch.is-active,
|
||||
.language-switch.is-active {
|
||||
border-bottom: none;
|
||||
:deep(.el-sub-menu__title) {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.theme-switch {
|
||||
--el-menu-hover-bg-color: none;
|
||||
padding-right: 0;
|
||||
}
|
||||
.vertical-theme-switch {
|
||||
.theme-toggle-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
.theme-toggle-content {
|
||||
padding-right: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div class="ma-header">
|
||||
<div class="header-container">
|
||||
<!-- 左侧logo -->
|
||||
<div class="header-left">
|
||||
<Logo clickable show-text size="medium" />
|
||||
</div>
|
||||
|
||||
<!-- 中间菜单区域(靠右显示) -->
|
||||
<div class="header-center">
|
||||
<client-only>
|
||||
<el-scrollbar class="hidden-sm-and-down">
|
||||
<Menu class="frontend-header-menu" :ellipsis="false" mode="horizontal" />
|
||||
</el-scrollbar>
|
||||
</client-only>
|
||||
</div>
|
||||
|
||||
<!-- 右侧功能区域 -->
|
||||
<div class="header-right">
|
||||
<client-only>
|
||||
<el-scrollbar class="hidden-sm-and-down" >
|
||||
<HeaderActions class="frontend-header-menu" :ellipsis="false" mode="horizontal" v-if="!isMobile"/>
|
||||
<div
|
||||
v-if="isMobile && !personalCenter.state.menu_expand"
|
||||
@click="personalCenter.toggleMenuExpand(true)"
|
||||
class="mobile-menu-toggle mr-2"
|
||||
>
|
||||
<Icon name="el-icon-Expand" color="var(--el-color-primary)" size="20" />
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</client-only>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端抽屉菜单(仅在移动端显示) -->
|
||||
<MobileDrawer
|
||||
v-if="isMobile"
|
||||
v-model="personalCenterStore.state.menu_expand"
|
||||
@language-changed="handleLanguageChange"
|
||||
@logged-out="handleLogout"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Menu from './menu.vue'
|
||||
import Logo from './logo.vue'
|
||||
import MobileDrawer from './mobile-drawer.vue'
|
||||
import HeaderActions from './header-actions.vue'
|
||||
|
||||
const personalCenterStore = usePersonalCenterStore()
|
||||
|
||||
// 判断是否为移动端
|
||||
const isMobile = ref(false)
|
||||
|
||||
const checkMobile = () => {
|
||||
isMobile.value = window.innerWidth <= 768
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
const handleLanguageChange = (languageName: string) => {
|
||||
if (isMobile.value) {
|
||||
personalCenter.toggleMenuExpand(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
// 可以添加额外的登出处理逻辑
|
||||
}
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
setDark(!getDark())
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ma-header {
|
||||
background-color: var(--ba-bg-color-overlay);
|
||||
box-shadow: 0 0 8px rgba(0 0 0 / 8%);
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 1450px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
height: var(--el-header-height);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin: 0 20px;
|
||||
|
||||
.frontend-header-menu {
|
||||
height: var(--el-header-height);
|
||||
}
|
||||
|
||||
:deep(.el-menu--horizontal) {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-menu-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media screen and (max-width: 768px) {
|
||||
.header-container {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 414px) {
|
||||
.header-container {
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div @click="handleClick" class="logo-container" :class="{ 'clickable': clickable, [`size-${size}`]: true }">
|
||||
<img :src="effectiveLogoUrl" :alt="altText" class="logo-image" />
|
||||
<span v-if="showText" class="site-name">{{ siteName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
clickable?: boolean
|
||||
showText?: boolean
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
logoUrl?: string
|
||||
altText?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
clickable: true,
|
||||
showText: true,
|
||||
size: 'medium',
|
||||
logoUrl: '',
|
||||
altText: 'Logo'
|
||||
})
|
||||
|
||||
const systemStore = useSystemStore()
|
||||
const siteName = computed(() => systemStore?.site?.site_name || '快速开发框架')
|
||||
|
||||
// 在组件顶部导入图片
|
||||
import logoImage from '~/assets/images/logo.png'
|
||||
|
||||
const effectiveLogoUrl = computed(() => {
|
||||
if (props.logoUrl) {
|
||||
return props.logoUrl
|
||||
}
|
||||
return logoImage
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
if (props.clickable) {
|
||||
navigateTo('/')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
// 修复:尺寸样式应该设置在父容器上,然后影响子元素
|
||||
&.size-small {
|
||||
.logo-image {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&.size-medium {
|
||||
.logo-image {
|
||||
height: 34px;
|
||||
width: 34px;
|
||||
}
|
||||
}
|
||||
|
||||
&.size-large {
|
||||
.logo-image {
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
// 基础样式可以放在这里
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
padding-left: 8px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
.logo-container.size-small & {
|
||||
font-size: var(--el-font-size-small);
|
||||
}
|
||||
|
||||
.logo-container.size-medium & {
|
||||
font-size: var(--el-font-size-large);
|
||||
}
|
||||
|
||||
.logo-container.size-large & {
|
||||
font-size: var(--el-font-size-extra-large);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<template v-for="(item, idx) in props.menus" :key="idx">
|
||||
<template v-if="!isEmpty(item.children)">
|
||||
<el-sub-menu @click="onClickSubMenu(item)" v-blur :index="`column-${item.id}`">
|
||||
<template #title>
|
||||
<Icon v-if="showIcon && item.icon" :name="item.icon" color="var(--el-text-color-primary)" />
|
||||
{{ item.title }}
|
||||
</template>
|
||||
<MenuSub :menus="item.children" :show-icon="showIcon" />
|
||||
</el-sub-menu>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-menu-item @click="onClickMenu(item)" v-blur :index="'column-' + item.id" :class="item.name.replace(/[\/]/g, '-')">
|
||||
<Icon v-if="showIcon && item.icon" :name="item.icon" color="var(--el-text-color-primary)" />
|
||||
<template #title>{{ item.title }}</template>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isEmpty } from 'lodash-es'
|
||||
import type { Menus } from '~/stores/interface'
|
||||
|
||||
interface Props {
|
||||
menus: Menus[]
|
||||
showIcon?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
menus: () => [],
|
||||
showIcon: false,
|
||||
})
|
||||
|
||||
const onClickSubMenu = (menu: Menus) => {
|
||||
/**
|
||||
* 1、'/'表示菜单规则的 path 为空
|
||||
* 2、会员中心菜单目录不需要跳转
|
||||
*/
|
||||
if (menu.path == '/' || menu.type == 'menu_dir') return
|
||||
onClickMenu(menu)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.el-sub-menu .icon,
|
||||
.el-menu-item .icon {
|
||||
vertical-align: middle;
|
||||
margin-right: 2px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.is-active > .icon {
|
||||
color: var(--el-menu-active-color) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<el-menu :default-active="state.activeMenu" @select="onSelect">
|
||||
<el-menu-item @click="handleClick('/')" v-blur index="index">
|
||||
<Icon v-if="props.showIcon" name="fa fa-home" color="var(--el-text-color-primary)" />
|
||||
<template #title>{{ t('主页') }}</template>
|
||||
</el-menu-item>
|
||||
|
||||
<!-- 动态菜单 -->
|
||||
<MenuSub :menus="systemStore.site.head_nav" :show-icon="showIcon" />
|
||||
</el-menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import type { Menus } from '~/stores/interface'
|
||||
import MenuSub from './menu-sub.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const systemStore = useSystemStore()
|
||||
|
||||
interface Props {
|
||||
showIcon?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showIcon: false,
|
||||
})
|
||||
|
||||
const state = reactive({
|
||||
activeMenu: '',
|
||||
switchingLanguage: false,
|
||||
})
|
||||
|
||||
/**
|
||||
* 设置激活菜单
|
||||
*/
|
||||
const setActiveMenu = (route: RouteLocationNormalizedLoaded) => {
|
||||
if (route.path == '/') return (state.activeMenu = 'index')
|
||||
|
||||
const menuId = findMenus(route)
|
||||
if (menuId) {
|
||||
state.activeMenu = 'column-' + menuId
|
||||
} else if (route.path.startsWith('/user')) {
|
||||
state.activeMenu = 'user'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单被点击时额外对无需激活的菜单处理(外链、暗黑模式开关、语言切换等)
|
||||
* 检查菜单是否需要激活,如果否,还原 state.activeMenu
|
||||
*/
|
||||
const onSelect = (index: string) => {
|
||||
if (noNeedActive(systemStore.site.head_nav, index)) {
|
||||
const oldActiveMenu = state.activeMenu
|
||||
state.activeMenu = ''
|
||||
nextTick(() => {
|
||||
state.activeMenu = oldActiveMenu
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查一个菜单是否需要激活态
|
||||
* @param menus
|
||||
* @param index
|
||||
*/
|
||||
const noNeedActive = (menus: Menus[], index: string) => {
|
||||
if (index.indexOf('language-switch') === 0 || index == 'theme-switch') {
|
||||
return true
|
||||
}
|
||||
return isExternalLink(menus, index)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查一个菜单是否是外站链接,如果是,不要激活
|
||||
* @param menus
|
||||
* @param index
|
||||
*/
|
||||
const isExternalLink = (menus: Menus[], index: string): boolean => {
|
||||
// 修复:添加空值检查
|
||||
if (!menus || !Array.isArray(menus)) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const key in menus) {
|
||||
// 修复:检查 menus[key] 是否存在
|
||||
if (!menus[key]) continue
|
||||
|
||||
const columnIndex = `column-${menus[key].id}`
|
||||
if (columnIndex == index) {
|
||||
return menus[key].meta?.menu_type == 'link'
|
||||
}
|
||||
// 修复:添加 children 存在性检查
|
||||
if (menus[key].children && Array.isArray(menus[key].children) && menus[key].children.length) {
|
||||
const result = isExternalLink(menus[key].children, index)
|
||||
if (result) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归的搜索菜单 Index
|
||||
*/
|
||||
const searchMenuIndex = (menus: Menus[], route: RouteLocationNormalizedLoaded): number | false => {
|
||||
// 修复:添加空值检查
|
||||
if (!menus || !Array.isArray(menus)) {
|
||||
return false
|
||||
}
|
||||
|
||||
let find: boolean | number = false
|
||||
for (const key in menus) {
|
||||
// 修复:检查 menus[key] 是否存在
|
||||
if (!menus[key]) continue
|
||||
|
||||
if (menus[key].id && menus[key].path == route.fullPath) {
|
||||
return menus[key].id
|
||||
}
|
||||
if (menus[key].children && Array.isArray(menus[key].children) && menus[key].children.length) {
|
||||
find = searchMenuIndex(menus[key].children, route)
|
||||
if (find !== false) return find
|
||||
}
|
||||
}
|
||||
return find
|
||||
}
|
||||
|
||||
const handleClick = (path: string) => {
|
||||
navigateTo(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从动态菜单中-搜索一个菜单
|
||||
*/
|
||||
const findMenus = (route: RouteLocationNormalizedLoaded) => {
|
||||
// 修复:添加空值检查
|
||||
if (!systemStore.site.head_nav || !Array.isArray(systemStore.site.head_nav)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const headNavIndex = searchMenuIndex(systemStore.site.head_nav, route)
|
||||
if (headNavIndex !== false) return headNavIndex
|
||||
}
|
||||
|
||||
setActiveMenu(route)
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
setActiveMenu(route)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.header-user-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
.header-user-avatar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 4px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.icon-header-user-avatar {
|
||||
margin-left: 4px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
.el-sub-menu .icon,
|
||||
.el-menu-item .icon {
|
||||
vertical-align: middle;
|
||||
margin-right: 2px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.is-active > .icon {
|
||||
color: var(--el-menu-active-color) !important;
|
||||
}
|
||||
.el-menu {
|
||||
border-bottom: none;
|
||||
border-right: none;
|
||||
.theme-switch.is-active,
|
||||
.language-switch.is-active {
|
||||
border-bottom: none;
|
||||
:deep(.el-sub-menu__title) {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.theme-switch {
|
||||
--el-menu-hover-bg-color: none;
|
||||
padding-right: 0;
|
||||
}
|
||||
.vertical-theme-switch {
|
||||
.theme-toggle-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
.theme-toggle-content {
|
||||
padding-right: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
class="ma-aside-drawer"
|
||||
:append-to-body="true"
|
||||
v-model="drawerVisible"
|
||||
:with-header="false"
|
||||
direction="ltr"
|
||||
>
|
||||
<div class="drawer-header">
|
||||
<Logo clickable show-text size="small" />
|
||||
<div @click="closeDrawer" class="drawer-close">
|
||||
<Icon name="el-icon-Close" color="var(--el-color-primary)" size="20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端菜单 -->
|
||||
<div class="mobile-menu-section">
|
||||
<Menu :show-icon="true" mode="vertical" />
|
||||
</div>
|
||||
|
||||
<!-- 移动端-会员菜单 -->
|
||||
<div class="mobile-menu-section">
|
||||
<HeaderActions :show-icon="true" mode="vertical" />
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Menu from './menu.vue'
|
||||
import Logo from './logo.vue'
|
||||
import HeaderActions from './header-actions.vue'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
languageChanged: [languageName: string]
|
||||
loggedOut: []
|
||||
}>()
|
||||
|
||||
const drawerVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const closeDrawer = () => {
|
||||
drawerVisible.value = false
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 15px 20px;
|
||||
background-color: var(--el-color-info-light-9);
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
|
||||
.drawer-close {
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-menu-section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mobile-actions {
|
||||
padding: 20px;
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.mobile-action-section {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-action-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
padding: 10px 16px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.theme-toggle-mobile {
|
||||
padding: 10px 10px;
|
||||
}
|
||||
|
||||
// 暗黑模式适配
|
||||
@at-root html.dark {
|
||||
.drawer-header {
|
||||
background-color: var(--el-bg-color-page);
|
||||
}
|
||||
|
||||
.mobile-actions {
|
||||
border-top-color: var(--el-border-color);
|
||||
background-color: var(--el-fill-color-dark);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user