整合前端仓库并保留提交历史

This commit is contained in:
2026-08-31 11:52:41 +08:00
172 changed files with 120216 additions and 1 deletions
@@ -0,0 +1,248 @@
<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"
unique-opened
>
<div v-for="(item, idx) in systemStore.site.member_menu" :key="idx">
<el-sub-menu
v-if="item.children && item.children.length && checkMenuShow(item)"
:index="item.id.toString()"
>
<template #title>
<Icon v-if="item.icon" :icon="item.icon" size="16" />
<span>{{ item.title }}</span>
</template>
<div v-for="(menu, index) in item.children.filter(m => m)" :key="index">
<el-menu-item
v-if="checkMenuShow(menu)"
:index="menu.path"
@click="routerPush(menu)"
>
<Icon v-if="menu.icon" :icon="menu.icon" size="16" />
<span>{{ menu.title }}</span>
</el-menu-item>
</div>
</el-sub-menu>
<el-menu-item
v-else-if="checkMenuShow(item)"
:index="item.path"
@click="routerPush(item)"
>
<Icon v-if="item.icon" :icon="item.icon" size="16" />
<span>{{ item.title }}</span>
</el-menu-item>
</div>
</el-menu>
</el-aside>
</template>
<script setup lang="ts">
import type { Menus } from '~/stores/interface'
// 在组件顶部导入图片
import defaultAvatar from '~/assets/images/default_avatar.png'
import { useSystemStore } from '~/stores/system'
import { useMemberStore } from '~/stores/member'
import { useRoute, navigateTo } from 'nuxt/app'
import { computed } from 'vue'
import { fullUrl } from '~/utils/common'
import { Icon } from '~/components/icon'
const route = useRoute()
const memberStore = useMemberStore()
const systemStore = useSystemStore()
const checkMenuShow = (menu: Menus): boolean => {
if (!menu) {
return false
}
const permissions = menu.meta?.permissions
if (!permissions || permissions.length === 0) {
return true
}
const result = systemStore.checkMenuPermission(menu)
return result
}
// 当前激活的菜单
const activeMenu = computed(() => {
return route.path
})
/**
* 获取用户头像URL
*/
const getAvatarUrl = (avatarUrl: string | null | undefined): string => {
if (!avatarUrl || avatarUrl.trim() === "") {
return defaultAvatar
}
return fullUrl(avatarUrl)
}
/**
* 处理菜单点击
*/
const onClickMenu = (menu: Menus) => {
if (systemStore.isDirectory(menu)) {
return
}
if (systemStore.isExternalLink(menu)) {
const target = systemStore.getTarget(menu)
if (menu.url) {
window.open(menu.url, target)
}
return
}
if (menu.path) {
navigateTo(menu.path)
}
}
/**
* 菜单跳转
*/
const routerPush = (route: string | Menus) => {
if (typeof route === 'string') {
navigateTo(route)
} else {
onClickMenu(route)
}
}
</script>
<style scoped lang="scss">
.member-sidebar {
width: 240px;
background-color: var(--ma-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(--ma-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%;
}
}
@media screen and (max-width: 768px) {
.member-sidebar {
display: none;
}
}
</style>
@@ -0,0 +1,89 @@
<template>
<div class="theme-toggle-content" @click="toggleDark">
<div class="switch">
<div class="switch-action">
<Icon icon="ant-design:moon-outlined" color="#ffffff" size="13px" class="switch-icon dark-icon" />
<Icon icon="ant-design:sun-outlined" color="#303133" size="13px" class="switch-icon light-icon" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Icon } from '~/components/icon'
import { getDark, setDark } from '~/utils/dark'
const isDark = computed(() => getDark())
function toggleDark() {
setDark(!isDark.value)
}
</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,16 @@
<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">
import { computed } from 'vue'
import { getDark, setDark } from '~/utils/dark'
const isDark = computed(() => getDark())
function toggleDark() {
setDark(!isDark.value)
}
</script>
@@ -0,0 +1,192 @@
<template>
<el-footer class="md-footer">
<div class="footer-content">
<!-- 友情链接区域 -->
<div v-if="links.length > 0" class="footer-links-section">
<h3 class="footer-links-title">友情链接</h3>
<div class="footer-links-container">
<a
v-for="link in links"
:key="link.id"
:href="link.url"
:target="link.target || '_blank'"
:title="link.name"
class="footer-link"
rel="noopener noreferrer"
>
<img
v-if="link.logo"
:src="link.logo"
:alt="link.name"
class="footer-link-logo"
/>
<span v-else class="footer-link-text">{{ link.name }}</span>
</a>
</div>
</div>
<!-- 版权区域 -->
<div class="footer-copyright">
<div class="copyright-text">{{ configStore.siteSetting?.copyright }}</div>
<div
v-if="configStore.siteSetting?.icp || configStore.siteSetting?.network_security"
class="copyright-links"
>
<a
v-if="configStore.siteSetting?.icp"
:href="configStore.siteSetting?.icp_url || 'https://beian.miit.gov.cn/'"
target="_blank"
rel="noopener noreferrer"
class="copyright-link"
>
{{ configStore.siteSetting?.icp }}
</a>
<a
v-if="configStore.siteSetting?.network_security"
:href="configStore.siteSetting?.network_security_url"
target="_blank"
rel="noopener noreferrer"
class="copyright-link"
>
{{ configStore.siteSetting?.network_security }}
</a>
</div>
</div>
</div>
</el-footer>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useConfigStore } from '~/stores/config'
import { getLinks } from '~/api/site'
const configStore = useConfigStore()
const links = ref([])
// 初始化加载友情链接
onMounted(async () => {
try {
const response = await getLinks({ type: 'footer' })
links.value = response || []
} catch (error) {
console.error('获取页脚链接失败:', error)
}
})
</script>
<script lang="ts">
export default {
name: 'LayoutFooter'
}
</script>
<style lang="scss" scoped>
.md-footer {
padding: 40px 0;
}
.footer-content {
max-width: 1450px;
width: 100%;
margin: 0 auto;
}
// 友情链接区域样式
.footer-links-section {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
padding-bottom: 20px;
margin-bottom: 30px;
.footer-links-title {
font-size: 15px;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 15px;
margin-top: 0;
}
.footer-links-container {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.footer-link {
display: inline-flex;
align-items: center;
padding: 5px 15px;
color: var(--el-text-color-secondary);
text-decoration: none;
font-size: 14px;
transition: color 0.3s ease;
&:hover {
color: var(--el-color-primary);
}
}
.footer-link-logo {
max-width: 120px;
max-height: 120px;
object-fit: contain;
margin-right: 8px;
}
.footer-link-text {
font-size: 13px;
}
}
// 版权区域样式
.footer-copyright {
text-align: left;
.copyright-text {
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.8;
padding-bottom: 15px;
}
.copyright-links {
display: flex;
gap: 15px;
flex-wrap: wrap;
.copyright-link {
color: var(--el-text-color-secondary);
text-decoration: none;
font-size: 12px;
transition: color 0.3s ease;
&:hover {
color: var(--el-color-primary);
}
}
}
}
// 响应式设计
@media screen and (max-width: 768px) {
.footer-links-section {
margin-bottom: 15px;
padding-bottom: 15px;
}
.footer-links-container {
gap: 8px;
}
.footer-link {
font-size: 13px;
padding: 4px 10px;
}
.copyright-links {
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
}
</style>
@@ -0,0 +1,334 @@
<template>
<el-menu :default-active="state.activeMenu" @select="onSelect">
<!-- 语言切换 -->
<el-sub-menu
v-if="configStore.language.is_enabled && availableLanguages.length > 1"
index="language-switch"
class="language-switch"
:key="languageKey"
>
<template #title>
<Icon v-if="showIcon" icon="ant-design:translation-outlined" color="var(--el-text-color-primary)" />
{{ t("common.language") }}
</template>
<el-menu-item
@click="handleLanguageChange(item.name)"
v-for="item in availableLanguages"
:key="item.name"
:index="'language-switch-' + item.name"
class="language-switch"
:class="{ 'is-active': item.name === currentLanguage }"
:title="`item.name: ${item.name}, currentLanguage: ${currentLanguage}`"
>
{{ item.value }}
</el-menu-item>
</el-sub-menu>
<!-- 主题切换 -->
<el-menu-item
v-if="configStore.theme.is_enabled"
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"
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("common.user") }}
</div>
</template>
<el-menu-item index="user" @click="$emit('menu-click'); navigateTo('/member/profile')">
<Icon
v-if="showIcon"
icon="ant-design:user-outlined"
color="var(--el-text-color-primary)"
/>
{{ t("common.member_center") }}
</el-menu-item>
<!-- 会员中心菜单 -->
<MenuSub :menus="systemStore.site.member_menu" :show-icon="showIcon" @menu-click="$emit('menu-click')" />
<el-menu-item @click="$emit('menu-click'); memberStore.logout()" index="user-logout">
<Icon
v-if="showIcon"
icon="ant-design:logout-outlined"
color="var(--el-text-color-primary)"
/>
{{ t("common.logout_login") }}
</el-menu-item>
</el-sub-menu>
<!-- 未登录场景 -->
<el-menu-item v-else @click="handleLoginClick" index="user">
<Icon
v-if="showIcon"
icon="ant-design:user-outlined"
color="var(--el-text-color-primary)"
/>
{{ t("common.login") }}
</el-menu-item>
<LoadingDialog />
</el-menu>
</template>
<script setup lang="ts">
import { computed, reactive, ref, nextTick, onMounted, onUnmounted, watch } from "vue";
import { navigateTo } from "nuxt/app";
import type { Menus } from "~/stores/interface";
import defaultAvatar from "~/assets/images/default_avatar.png";
import { fullUrl, getToken } from "~/utils/common";
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";
import { Icon } from '~/components/icon';
import { ElMessage } from "element-plus";
import { useMemberStore } from "~/stores/member";
import { useSystemStore } from "~/stores/system";
import { useConfigStore } from "~/stores/config";
import { t } from "~/composables/lang";
import { getDark, setDark } from "~/utils/dark";
const memberStore = useMemberStore();
const systemStore = useSystemStore();
const configStore = useConfigStore();
// 添加响应式的语言 key,用于强制组件重新渲染
const languageKey = ref(systemStore.lang);
// 监听语言变化,更新 languageKey
watch(() => systemStore.lang, (newLang, oldLang) => {
if (newLang !== oldLang) {
languageKey.value = newLang;
}
}, { immediate: true });
const availableLanguages = computed(() => {
const languages = systemStore.getLanguages;
return languages;
});
const currentLanguage = computed(() => {
// 从 systemStore 中获取语言设置,确保与状态保持同步
return systemStore.lang;
});
interface Props {
showIcon?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
showIcon: true,
});
const emit = defineEmits<{
'menu-click': []
}>();
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();
};
const handleLoginClick = () => {
handleLogin();
emit('menu-click');
};
/**
* 处理语言切换
* @param lang 语言代码
*/
const handleLanguageChange = (lang: string) => {
const success = systemStore.setLanguage(lang);
// 强制更新 languageKey,触发组件重新渲染
languageKey.value = lang;
// 不再刷新页面,语言切换应该立即生效
};
/**
* 菜单被点击时额外对无需激活的菜单处理(外链、暗黑模式开关、语言切换等)
* 检查菜单是否需要激活,如果否,还原 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;
color: var(--el-menu-active-color) !important;
font-weight: 500 !important;
:deep(.el-sub-menu__title) {
border-bottom: none;
}
}
}
.theme-switch {
--el-menu-hover-bg-color: none;
padding-right: 0;
display: flex;
align-items: center;
justify-content: center;
}
.vertical-theme-switch {
.theme-toggle-content {
padding: 0;
}
}
.theme-toggle-content {
padding-right: 0;
}
// 语言切换选中状态
.language-switch.is-active {
color: var(--el-menu-active-color) !important;
font-weight: 500;
}
</style>
@@ -0,0 +1,268 @@
<template>
<div class="ma-header" :class="{ 'is-scrolled': isScrolled }">
<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" style="height: var(--el-header-height);">
<Menu class="frontend-header-menu" :ellipsis="false" mode="horizontal" :show-icon="true" />
</el-scrollbar>
</client-only>
</div>
<!-- 右侧功能区域 -->
<div class="header-right">
<client-only>
<div v-if="isMobile" class="mobile-menu-wrapper">
<div
v-if="!systemStore.$state.site.menu_expand"
@click="systemStore.toggleMenuExpand(true)"
class="mobile-menu-toggle mr-2"
>
<Icon icon="ant-design:menu-outlined" color="var(--el-color-primary)" size="20" />
</div>
</div>
<el-scrollbar class="hidden-sm-and-down" style="height: var(--el-header-height);">
<HeaderActions class="frontend-header-menu" :ellipsis="false" mode="horizontal" v-if="!isMobile"/>
</el-scrollbar>
</client-only>
</div>
</div>
<!-- 移动端抽屉菜单(仅在移动端显示) -->
<MobileDrawer
v-if="isMobile"
v-model="systemStore.$state.site.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'
import { Icon } from '~/components/icon'
import { useSystemStore } from '~/stores/system'
import { inject, ref, nextTick, onMounted, onUnmounted } from 'vue'
// 注入父级提供的 scrollbar ref
const mainScrollbarRef = inject('mainScrollbarRef', ref(null))
// 判断是否为移动端
const isMobile = ref(false)
const isScrolled = ref(false)
const systemStore = useSystemStore()
const checkMobile = () => {
isMobile.value = window.innerWidth <= 768
}
const handleScroll = () => {
const scrollbar = mainScrollbarRef.value as any
if (scrollbar) {
const wrap = scrollbar.wrapRef || scrollbar.$el?.querySelector?.('.el-scrollbar__wrap')
if (wrap) {
isScrolled.value = wrap.scrollTop > 0
}
}
}
onMounted(() => {
checkMobile()
nextTick(() => {
handleScroll()
const scrollbar = mainScrollbarRef.value as any
const wrap = scrollbar?.wrapRef || scrollbar?.$el?.querySelector?.('.el-scrollbar__wrap')
if (wrap) {
wrap.addEventListener('scroll', handleScroll)
}
})
window.addEventListener('resize', checkMobile)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
const scrollbar = mainScrollbarRef.value as any
const wrap = scrollbar?.wrapRef || scrollbar?.$el?.querySelector?.('.el-scrollbar__wrap')
if (wrap) {
wrap.removeEventListener('scroll', handleScroll)
}
})
const handleLanguageChange = (languageName: string) => {
if (isMobile.value) {
useSystemStore().toggleMenuExpand(false)
}
}
const handleLogout = () => {
// 可以添加额外的登出处理逻辑
}
const toggleDarkMode = () => {
setDark(!getDark())
}
</script>
<style scoped lang="scss">
.ma-header {
background-color: var(--ma-bg-color-overlay);
position: relative;
z-index: 1000;
width: 100%;
// 使用伪元素实现底部边框线,横跨整个视口宽度
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100vw;
height: 1px;
background-color: transparent;
transition: background-color 0.3s ease;
}
&.is-scrolled::after {
background-color: var(--el-border-color-light);
}
}
.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);
background: transparent;
.el-menu-item,
.el-sub-menu .el-sub-menu__title {
&.is-active {
color: var(--el-color-primary) !important;
}
&:hover {
background-color: transparent !important;
color: var(--el-menu-hover-text-color) !important;
}
}
}
:deep(.el-menu--horizontal) {
border-bottom: none;
}
}
.header-right {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0;
.frontend-header-menu {
height: var(--el-header-height);
background: transparent;
.el-menu-item,
.el-sub-menu .el-sub-menu__title {
&.is-active {
color: var(--el-color-primary) !important;
}
&:hover {
background-color: transparent !important;
color: var(--el-menu-hover-text-color) !important;
}
}
}
}
.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;
height: 40px;
width: 40px;
&:hover {
background-color: var(--el-fill-color-light);
}
}
.mobile-menu-wrapper {
display: flex;
align-items: center;
height: 100%;
}
// 响应式设计
@media screen and (max-width: 768px) {
.header-container {
padding: 0 15px;
}
.header-center {
display: none;
}
.header-right {
justify-content: flex-start;
}
}
@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,256 @@
<template>
<div v-for="(item, idx) in props.menus" :key="idx">
<el-sub-menu v-if="systemStore.hasChildren(item) && checkMenuShow(item)" :index="`column-${item.id}`">
<template #title>
<Icon v-if="showIcon && item.icon" :icon="item.icon" color="var(--el-text-color-primary)" />
{{ item.title }}
</template>
<MenuSub :menus="item.children" :show-icon="showIcon" @menu-click="$emit('menu-click')" />
</el-sub-menu>
<el-menu-item
v-else-if="checkMenuShow(item)"
@click="onClickMenu(item)"
:index="'column-' + item.id"
:class="getMenuItemClass(item)"
:data-menu-id="item.id"
:data-menu-name="item.name"
:data-menu-path="item.path"
:data-menu-controllable="item.extra?.controllable ? 'true' : undefined"
>
<Icon v-if="showIcon && item.icon" :icon="item.icon" color="var(--el-text-color-primary)" />
<template #title>
<span class="menu-title">{{ item.title }}</span>
<!-- 徽章 -->
<span
v-if="item.extra?.badge"
class="menu-badge"
:class="[`badge-${item.extra?.badgeType || 'danger'}`]"
:data-badge="item.extra.badge"
>
{{ item.extra.badge }}
</span>
<!-- 红点(仅显示状态无数量时) -->
<span
v-else-if="item.extra?.badge === '' || item.extra?.dot"
class="menu-dot"
:class="[`dot-${item.extra?.badgeType || 'danger'}`]"
></span>
</template>
</el-menu-item>
</div>
</template>
<script setup lang="ts">
import type { Menus } from '~/stores/interface'
import { navigateTo } from 'nuxt/app'
import { useSystemStore } from '~/stores/system'
import { Icon } from '~/components/icon'
import { onMounted, onUnmounted } from 'vue'
interface Props {
menus: Menus[]
showIcon?: boolean
}
const props = withDefaults(defineProps<Props>(), {
menus: () => [],
showIcon: false,
})
const emit = defineEmits<{
'menu-click': []
}>()
const systemStore = useSystemStore()
// 组件挂载后,如果有可操控菜单,触发事件通知外部
onMounted(() => {
const hasControllable = props.menus.some(item => item.extra?.controllable)
if (hasControllable) {
window.dispatchEvent(new CustomEvent('menu:controllable:mounted'))
}
})
const checkMenuShow = (menu: Menus): boolean => {
if (!menu) {
return false
}
const permissions = menu.meta?.permissions
if (!permissions || permissions.length === 0) {
return true
}
return systemStore.checkMenuPermission(menu)
}
/**
* 获取菜单项的 CSS 类名
* - 提供可操作的 DOM 标识
*/
const getMenuItemClass = (item: Menus): string => {
const classes: string[] = []
// 基础类:根据 name 生成(保留中文,仅替换特殊字符)
if (item.name) {
const baseClass = item.name.replace(/[^\w\u4e00-\u9fa5\-]/g, '-')
if (baseClass) classes.push(`menu-${baseClass}`)
}
// 可操控菜单标识(通过 extra.controllable)
if (item.extra?.controllable) {
classes.push('menu-controllable')
}
// 有徽章时的特殊类
if (item.extra?.badge || item.extra?.dot) {
classes.push('has-badge')
}
return classes.join(' ')
}
const onClickMenu = (menu: Menus) => {
// 检查权限
if (!systemStore.checkMenuPermission(menu)) {
return
}
if (systemStore.isDirectory(menu)) {
return
}
// 先触发菜单点击事件,关闭弹窗
emit('menu-click')
if (systemStore.isExternalLink(menu)) {
const target = systemStore.getTarget(menu)
if (menu.url) {
window.open(menu.url, target)
}
return
}
if (menu.path) {
navigateTo(menu.path)
}
}
</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;
}
// 菜单标题
.menu-title {
flex: 1;
}
// 徽章样式
.menu-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 6px;
font-size: 12px;
font-weight: 500;
border-radius: 9px;
margin-left: 6px;
flex-shrink: 0;
// 颜色变体
&.badge-danger {
background-color: var(--el-color-danger);
color: #fff;
}
&.badge-warning {
background-color: var(--el-color-warning);
color: #fff;
}
&.badge-success {
background-color: var(--el-color-success);
color: #fff;
}
&.badge-info {
background-color: var(--el-color-info);
color: #fff;
}
&.badge-primary {
background-color: var(--el-color-primary);
color: #fff;
}
}
// 红点样式
.menu-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-left: 6px;
flex-shrink: 0;
&.dot-danger {
background-color: var(--el-color-danger);
}
&.dot-warning {
background-color: var(--el-color-warning);
}
&.dot-success {
background-color: var(--el-color-success);
}
&.dot-info {
background-color: var(--el-color-info);
}
&.dot-primary {
background-color: var(--el-color-primary);
}
}
// 可操控菜单特殊样式(如通知菜单)
:deep(.menu-controllable) {
position: relative;
.menu-badge,
.menu-dot {
animation: pulse 2s infinite;
}
}
// 脉冲动画
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
// 有徽章的菜单项高亮
:deep(.has-badge) {
.menu-title {
font-weight: 500;
}
}
</style>
@@ -0,0 +1,185 @@
<template>
<el-menu :default-active="state.activeMenu" @select="onSelect">
<MenuSub :menus="systemStore.navMenu" :show-icon="showIcon" @menu-click="$emit('menu-click')" :key="navMenuKey" />
</el-menu>
</template>
<script setup lang="ts">
import type { RouteLocationNormalizedLoaded } from 'vue-router'
import type { Menus } from '~/stores/interface'
import { reactive, nextTick, watch } from 'vue'
import { useRoute, navigateTo } from 'nuxt/app'
import { useSystemStore } from '~/stores/system'
import { useI18n } from 'vue-i18n'
import MenuSub from './menu-sub.vue'
const route = useRoute()
const systemStore = useSystemStore()
const { t } = useI18n()
interface Props {
showIcon?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showIcon: false,
})
const emit = defineEmits<{
'menu-click': []
}>()
const state = reactive({
activeMenu: '',
switchingLanguage: false,
})
// 调试用:检查 navMenu 中是否有消息中心
const navMenuKey = computed(() => {
const hasNotify = systemStore.navMenu?.some((m: Menus) => m.name === '消息中心' || m.path === '/notify')
console.log('[Menu] navMenu has 消息中心:', hasNotify, 'menus:', systemStore.navMenu?.map((m: Menus) => m.name))
return hasNotify ? 'with-notify' : 'no-notify'
})
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'
}
}
const onSelect = (index: string) => {
if (noNeedActive(systemStore.headNav, index)) {
const oldActiveMenu = state.activeMenu
state.activeMenu = ''
nextTick(() => {
state.activeMenu = oldActiveMenu
})
}
}
const noNeedActive = (menus: Menus[], index: string) => {
if (index.indexOf('language-switch') === 0 || index == 'theme-switch') {
return true
}
return isExternalLink(menus, index)
}
const isExternalLink = (menus: Menus[], index: string): boolean => {
if (!menus || !Array.isArray(menus)) {
return false
}
for (const menu of menus) {
if (!menu) continue
const columnIndex = `column-${menu.id}`
if (columnIndex == index) {
return systemStore.isExternalLink(menu)
}
if (menu.children && Array.isArray(menu.children) && menu.children.length) {
const result = isExternalLink(menu.children, index)
if (result) return true
}
}
return false
}
const searchMenuIndex = (menus: Menus[], route: RouteLocationNormalizedLoaded): number | false => {
if (!menus || !Array.isArray(menus)) {
return false
}
let find: boolean | number = false
for (const menu of menus) {
if (!menu) continue
if (menu.id && menu.path == route.fullPath) {
return menu.id
}
if (menu.children && Array.isArray(menu.children) && menu.children.length) {
find = searchMenuIndex(menu.children, route)
if (find !== false) return find
}
}
return find
}
const handleClick = (path: string) => {
navigateTo(path)
}
const findMenus = (route: RouteLocationNormalizedLoaded) => {
if (!systemStore.navMenu || !Array.isArray(systemStore.navMenu)) {
return false
}
const headNavIndex = searchMenuIndex(systemStore.navMenu, 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,121 @@
<template>
<el-drawer
class="ma-aside-drawer"
:append-to-body="true"
v-model="drawerVisible"
:with-header="false"
direction="ltr"
:size="'50%'"
>
<div class="drawer-header">
<Logo clickable show-text size="small" />
<div @click="closeDrawer" class="drawer-close">
<Icon icon="ant-design:close-outlined" color="var(--el-color-primary)" size="20" />
</div>
</div>
<!-- 移动端菜单 -->
<div class="mobile-menu-section">
<Menu :show-icon="true" mode="vertical" @menu-click="closeDrawer" />
</div>
<!-- 移动端-会员菜单 -->
<div class="mobile-menu-section">
<HeaderActions :show-icon="true" mode="vertical" @menu-click="closeDrawer" />
</div>
</el-drawer>
</template>
<script setup lang="ts">
import Menu from './menu.vue'
import Logo from './logo.vue'
import HeaderActions from './header-actions.vue'
import { Icon } from '~/components/icon'
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>