feat: 适配5.x后端,重构前端架构
主要变更: 1. 重构API目录结构,采用模块化设计,适配5.x后端接口规范 2. 优化布局组件,提升用户界面体验 3. 增强国际化支持,完善多语言配置 4. 新增权限控制功能,支持前端路由权限管理 5. 优化认证流程,支持JWT认证机制 6. 新增中间件和插件,提升系统扩展性 7. 优化状态管理,采用更清晰的模块化设计 8. 新增搜索框、广告等组件,丰富页面功能 9. 更改包管理工具从npm到pnpm,提升依赖管理效率 10. 完善配置系统,支持多环境配置 适配5.x后端特性: - 采用新的API接口规范 - 支持JWT认证机制 - 适配后端权限控制体系 - 支持后端国际化接口 - 优化数据交互方式
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<el-container class="layout-container">
|
||||
<el-header class="layout-header">
|
||||
<layout-header />
|
||||
</el-header>
|
||||
<el-scrollbar :style="calcHeight(60)" class="main-scrollbar" ref="mainScrollbarRef">
|
||||
<div class="layout-main">
|
||||
<div class="layout-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useTemplateRef, watch, provide } from 'vue';
|
||||
import layoutHeader from "./components/header.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const mainScrollbarRef = useTemplateRef('mainScrollbarRef');
|
||||
|
||||
// 计算滚动区域高度
|
||||
const calcHeight = (headerHeight: number) => {
|
||||
return {
|
||||
height: `calc(100vh - ${headerHeight}px)`,
|
||||
maxHeight: `calc(100vh - ${headerHeight}px)`
|
||||
};
|
||||
};
|
||||
|
||||
// 路由切换时滚动条滚动至顶部
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
if (!route.meta.disableScrollTo) {
|
||||
mainScrollbarRef.value?.scrollTo(0, 0);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 将滚动条的 ref provide 给子级组件
|
||||
provide('mainScrollbarRef', mainScrollbarRef);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.layout-container {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.layout-header {
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
background-color: var(--ma-bg-color-overlay);
|
||||
height: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.main-scrollbar {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.layout-main {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
|
||||
.layout-content {
|
||||
flex: 1;
|
||||
min-height: var(--content-min-height, 500px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 全局重置 */
|
||||
:global(html, body) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 响应式断点 */
|
||||
@media screen and (max-width: 768px) {
|
||||
.layout-container {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
--content-min-height: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
.layout-container {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
--content-min-height: 400px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 组件样式适配 */
|
||||
.layout-header {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
:deep(.header-logo) {
|
||||
span {
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.frontend-header-menu) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 页面特定样式 */
|
||||
.page-index {
|
||||
.layout-content {
|
||||
/* 首页特定样式 */
|
||||
}
|
||||
}
|
||||
|
||||
.page-member {
|
||||
.layout-content {
|
||||
/* 会员页面特定样式 */
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条样式优化 */
|
||||
.main-scrollbar {
|
||||
:deep(.el-scrollbar__thumb) {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-scrollbar__track) {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色模式滚动条 */
|
||||
@at-root html.dark {
|
||||
.main-scrollbar {
|
||||
:deep(.el-scrollbar__thumb) {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-scrollbar__track) {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.page-index {
|
||||
background: url(~/assets/images/bg-dark.jpg) repeat;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
@media screen and (max-width: 1024px) {
|
||||
.layout-container {
|
||||
.main-scrollbar {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
padding: 0 15px;
|
||||
--content-min-height: 400px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.layout-container {
|
||||
.main-scrollbar {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
padding: 0 10px;
|
||||
--content-min-height: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-height: 650px) {
|
||||
.layout-container {
|
||||
.main-scrollbar {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
--content-min-height: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,38 +11,38 @@
|
||||
<el-menu
|
||||
class="member-sidebar__menu"
|
||||
:default-active="activeMenu"
|
||||
router
|
||||
unique-opened
|
||||
>
|
||||
<template v-for="(item, idx) in personalCenterStore.state.user_menus" :key="idx">
|
||||
<div v-for="(item, idx) in systemStore.site.member_menu" :key="idx">
|
||||
<el-sub-menu
|
||||
v-if="item.children && item.children.length"
|
||||
v-if="item.children && item.children.length && checkMenuShow(item)"
|
||||
:index="item.id.toString()"
|
||||
>
|
||||
<template #title>
|
||||
<Icon v-if="item.icon" :name="item.icon" size="16" />
|
||||
<Icon v-if="item.icon" :icon="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>
|
||||
<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
|
||||
v-else-if="checkMenuShow(item)"
|
||||
:index="item.path"
|
||||
@click="routerPush(item)"
|
||||
>
|
||||
<Icon v-if="item.icon" :name="item.icon" size="16" />
|
||||
<Icon v-if="item.icon" :icon="item.icon" size="16" />
|
||||
<span>{{ item.title }}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</div>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
</template>
|
||||
@@ -51,10 +51,31 @@
|
||||
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 personalCenterStore = usePersonalCenterStore()
|
||||
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(() => {
|
||||
@@ -71,6 +92,27 @@ const getAvatarUrl = (avatarUrl: string | null | undefined): string => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单跳转
|
||||
*/
|
||||
@@ -78,7 +120,7 @@ const routerPush = (route: string | Menus) => {
|
||||
if (typeof route === 'string') {
|
||||
navigateTo(route)
|
||||
} else {
|
||||
onClickMenu(route as any)
|
||||
onClickMenu(route)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -86,7 +128,7 @@ const routerPush = (route: string | Menus) => {
|
||||
<style scoped lang="scss">
|
||||
.member-sidebar {
|
||||
width: 240px;
|
||||
background-color: var(--ba-bg-color-overlay);
|
||||
background-color: var(--ma-bg-color-overlay);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
@@ -124,7 +166,7 @@ const routerPush = (route: string | Menus) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--ba-bg-color-overlay);
|
||||
background-color: var(--ma-bg-color-overlay);
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--el-box-shadow);
|
||||
}
|
||||
@@ -197,4 +239,10 @@ const routerPush = (route: string | Menus) => {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.member-sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,20 +2,22 @@
|
||||
<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" />
|
||||
<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 } from 'vue'
|
||||
const color = useColorMode()
|
||||
import { ref, computed } from 'vue'
|
||||
import { Icon } from '~/components/icon'
|
||||
import { getDark, setDark } from '~/utils/dark'
|
||||
|
||||
const isDark = computed(() => getDark())
|
||||
|
||||
function toggleDark() {
|
||||
color.preference = color.value === 'dark' ? 'light' : 'dark'
|
||||
setDark(!isDark.value)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const color = useColorMode()
|
||||
import { computed } from 'vue'
|
||||
import { getDark, setDark } from '~/utils/dark'
|
||||
|
||||
const isDark = computed(() => getDark())
|
||||
|
||||
function toggleDark() {
|
||||
color.preference = color.value === 'dark' ? 'light' : 'dark'
|
||||
setDark(!isDark.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,31 +1,192 @@
|
||||
<template>
|
||||
<el-footer class="md-footer">
|
||||
<div>
|
||||
Copyright @ 2020~2026版权所有
|
||||
<a href="http://beian.miit.gov.cn/">189456784215</a>
|
||||
<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>
|
||||
</el-footer>
|
||||
</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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
<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;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: var(--el-color-info-light-7);
|
||||
a {
|
||||
color: var(--el-text-color-secondary);
|
||||
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);
|
||||
}
|
||||
@media screen and (max-width: 768px) {
|
||||
a {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
<template>
|
||||
<el-menu :default-active="state.activeMenu" @select="onSelect">
|
||||
<el-sub-menu v-blur index="language-switch" class="language-switch">
|
||||
<!-- 语言切换 -->
|
||||
<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" name="local-lang" color="var(--el-text-color-primary)" />
|
||||
{{ t("language") }}
|
||||
<Icon v-if="showIcon" icon="ant-design:translation-outlined" color="var(--el-text-color-primary)" />
|
||||
{{ t("common.language") }}
|
||||
</template>
|
||||
|
||||
<el-menu-item
|
||||
@click="systemStore.setLanguage(item.name)"
|
||||
@click="handleLanguageChange(item.name)"
|
||||
v-for="item in availableLanguages"
|
||||
:key="item.name"
|
||||
:index="'language-switch-' + item.value"
|
||||
: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>
|
||||
@@ -18,6 +27,7 @@
|
||||
|
||||
<!-- 主题切换 -->
|
||||
<el-menu-item
|
||||
v-if="configStore.theme.is_enabled"
|
||||
index="theme-switch"
|
||||
class="theme-switch"
|
||||
:class="$attrs.mode + '-theme-switch'"
|
||||
@@ -29,8 +39,6 @@
|
||||
<!-- 已登录用户场景 -->
|
||||
<el-sub-menu
|
||||
v-if="memberStore.info"
|
||||
@click="$attrs.mode == 'vertical' ? '' : navigateTo('/member/profile')"
|
||||
v-blur
|
||||
index="user-box"
|
||||
>
|
||||
<template #title>
|
||||
@@ -41,63 +49,87 @@
|
||||
:src="getAvatarUrl(memberStore?.info?.avatar || '')"
|
||||
alt=""
|
||||
/>
|
||||
{{ memberStore?.info?.nickname || t("user") }}
|
||||
{{ memberStore?.info?.nickname || t("common.user") }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-menu-item @click="navigateTo('/member/profile')" v-blur index="user">
|
||||
<el-menu-item index="user" @click="$emit('menu-click'); navigateTo('/member/profile')">
|
||||
<Icon
|
||||
v-if="showIcon"
|
||||
name="fa fa-user-circle"
|
||||
icon="ant-design:user-outlined"
|
||||
color="var(--el-text-color-primary)"
|
||||
/>
|
||||
{{ t("member_center") }}
|
||||
{{ t("common.member_center") }}
|
||||
</el-menu-item>
|
||||
|
||||
<!-- 动态菜单 -->
|
||||
<MenuSub :menus="personalCenterStore.state.nav_user_menus" :show-icon="showIcon" />
|
||||
|
||||
<!-- 会员中心菜单 -->
|
||||
<MenuSub :menus="personalCenterStore.state.user_menus" :show-icon="showIcon" />
|
||||
<MenuSub :menus="systemStore.site.member_menu" :show-icon="showIcon" @menu-click="$emit('menu-click')" />
|
||||
|
||||
<el-menu-item @click="memberStore.logout()" v-blur index="user-logout">
|
||||
<el-menu-item @click="$emit('menu-click'); memberStore.logout()" index="user-logout">
|
||||
<Icon
|
||||
v-if="showIcon"
|
||||
name="fa fa-sign-out"
|
||||
icon="ant-design:logout-outlined"
|
||||
color="var(--el-text-color-primary)"
|
||||
/>
|
||||
{{ t("logout_login") }}
|
||||
{{ t("common.logout_login") }}
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 未登录场景 -->
|
||||
<el-menu-item v-else @click="handleLogin" v-blur index="user">
|
||||
<el-menu-item v-else @click="handleLoginClick" index="user">
|
||||
<Icon
|
||||
v-if="showIcon"
|
||||
name="fa fa-user-circle"
|
||||
icon="ant-design:user-outlined"
|
||||
color="var(--el-text-color-primary)"
|
||||
/>
|
||||
{{ t("login") }}
|
||||
{{ 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();
|
||||
const availableLanguages = computed(() => systemStore.getLanguages);
|
||||
const currentLanguage = computed(() => systemStore.lang);
|
||||
const personalCenterStore = usePersonalCenterStore();
|
||||
|
||||
// 添加响应式的语言 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 {
|
||||
@@ -105,9 +137,13 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showIcon: false,
|
||||
showIcon: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'menu-click': []
|
||||
}>();
|
||||
|
||||
const state = reactive({
|
||||
activeMenu: "",
|
||||
});
|
||||
@@ -145,6 +181,24 @@ const handleLogin = () => {
|
||||
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
|
||||
@@ -250,6 +304,8 @@ onUnmounted(() => {
|
||||
.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;
|
||||
}
|
||||
@@ -258,6 +314,9 @@ onUnmounted(() => {
|
||||
.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 {
|
||||
@@ -267,4 +326,10 @@ onUnmounted(() => {
|
||||
.theme-toggle-content {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
// 语言切换选中状态
|
||||
.language-switch.is-active {
|
||||
color: var(--el-menu-active-color) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<!-- 中间菜单区域(靠右显示) -->
|
||||
<div class="header-center">
|
||||
<client-only>
|
||||
<el-scrollbar class="hidden-sm-and-down">
|
||||
<Menu class="frontend-header-menu" :ellipsis="false" mode="horizontal" />
|
||||
<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>
|
||||
@@ -18,16 +18,18 @@
|
||||
<!-- 右侧功能区域 -->
|
||||
<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 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>
|
||||
<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>
|
||||
@@ -35,7 +37,7 @@
|
||||
<!-- 移动端抽屉菜单(仅在移动端显示) -->
|
||||
<MobileDrawer
|
||||
v-if="isMobile"
|
||||
v-model="personalCenterStore.state.menu_expand"
|
||||
v-model="systemStore.$state.site.menu_expand"
|
||||
@language-changed="handleLanguageChange"
|
||||
@logged-out="handleLogout"
|
||||
/>
|
||||
@@ -47,12 +49,13 @@ 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'
|
||||
|
||||
const personalCenterStore = usePersonalCenterStore()
|
||||
|
||||
// 判断是否为移动端
|
||||
const isMobile = ref(false)
|
||||
|
||||
const systemStore = useSystemStore()
|
||||
const checkMobile = () => {
|
||||
isMobile.value = window.innerWidth <= 768
|
||||
}
|
||||
@@ -68,7 +71,7 @@ onUnmounted(() => {
|
||||
|
||||
const handleLanguageChange = (languageName: string) => {
|
||||
if (isMobile.value) {
|
||||
personalCenter.toggleMenuExpand(false)
|
||||
useSystemStore().toggleMenuExpand(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,10 +86,12 @@ const toggleDarkMode = () => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ma-header {
|
||||
background-color: var(--ba-bg-color-overlay);
|
||||
box-shadow: 0 0 8px rgba(0 0 0 / 8%);
|
||||
background-color: var(--ma-bg-color-overlay);
|
||||
/* 暂时注释掉阴影,避免左右两边出现阴影线条 */
|
||||
/* box-shadow: 0 2px 4px rgba(0 0 0 / 8%); */
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
@@ -113,6 +118,17 @@ const toggleDarkMode = () => {
|
||||
|
||||
.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) {
|
||||
@@ -126,6 +142,21 @@ const toggleDarkMode = () => {
|
||||
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 {
|
||||
@@ -156,12 +187,20 @@ const toggleDarkMode = () => {
|
||||
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 {
|
||||
@@ -171,6 +210,10 @@ const toggleDarkMode = () => {
|
||||
.header-center {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 414px) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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>
|
||||
<span v-if="showText" class="site-name">{{ siteName }} </span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
<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>
|
||||
<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="item.name.replace(/[\/]/g, '-')"
|
||||
>
|
||||
<Icon v-if="showIcon && item.icon" :icon="item.icon" color="var(--el-text-color-primary)" />
|
||||
<template #title>{{ item.title }}</template>
|
||||
</el-menu-item>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isEmpty } from 'lodash-es'
|
||||
import type { Menus } from '~/stores/interface'
|
||||
import { navigateTo } from 'nuxt/app'
|
||||
import { useSystemStore } from '~/stores/system'
|
||||
import { Icon } from '~/components/icon'
|
||||
|
||||
interface Props {
|
||||
menus: Menus[]
|
||||
@@ -32,13 +35,50 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
showIcon: false,
|
||||
})
|
||||
|
||||
const onClickSubMenu = (menu: Menus) => {
|
||||
/**
|
||||
* 1、'/'表示菜单规则的 path 为空
|
||||
* 2、会员中心菜单目录不需要跳转
|
||||
*/
|
||||
if (menu.path == '/' || menu.type == 'menu_dir') return
|
||||
onClickMenu(menu)
|
||||
const emit = defineEmits<{
|
||||
'menu-click': []
|
||||
}>()
|
||||
|
||||
const systemStore = useSystemStore()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
<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" />
|
||||
<MenuSub :menus="systemStore.navMenu" :show-icon="showIcon" @menu-click="$emit('menu-click')" />
|
||||
</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
|
||||
@@ -26,14 +25,15 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
showIcon: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'menu-click': []
|
||||
}>()
|
||||
|
||||
const state = reactive({
|
||||
activeMenu: '',
|
||||
switchingLanguage: false,
|
||||
})
|
||||
|
||||
/**
|
||||
* 设置激活菜单
|
||||
*/
|
||||
const setActiveMenu = (route: RouteLocationNormalizedLoaded) => {
|
||||
if (route.path == '/') return (state.activeMenu = 'index')
|
||||
|
||||
@@ -45,12 +45,8 @@ const setActiveMenu = (route: RouteLocationNormalizedLoaded) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单被点击时额外对无需激活的菜单处理(外链、暗黑模式开关、语言切换等)
|
||||
* 检查菜单是否需要激活,如果否,还原 state.activeMenu
|
||||
*/
|
||||
const onSelect = (index: string) => {
|
||||
if (noNeedActive(systemStore.site.head_nav, index)) {
|
||||
if (noNeedActive(systemStore.headNav, index)) {
|
||||
const oldActiveMenu = state.activeMenu
|
||||
state.activeMenu = ''
|
||||
nextTick(() => {
|
||||
@@ -59,11 +55,6 @@ const onSelect = (index: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查一个菜单是否需要激活态
|
||||
* @param menus
|
||||
* @param index
|
||||
*/
|
||||
const noNeedActive = (menus: Menus[], index: string) => {
|
||||
if (index.indexOf('language-switch') === 0 || index == 'theme-switch') {
|
||||
return true
|
||||
@@ -71,53 +62,40 @@ const noNeedActive = (menus: Menus[], index: string) => {
|
||||
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
|
||||
for (const menu of menus) {
|
||||
if (!menu) continue
|
||||
|
||||
const columnIndex = `column-${menus[key].id}`
|
||||
const columnIndex = `column-${menu.id}`
|
||||
if (columnIndex == index) {
|
||||
return menus[key].meta?.menu_type == 'link'
|
||||
return systemStore.isExternalLink(menu)
|
||||
}
|
||||
// 修复:添加 children 存在性检查
|
||||
if (menus[key].children && Array.isArray(menus[key].children) && menus[key].children.length) {
|
||||
const result = isExternalLink(menus[key].children, index)
|
||||
if (menu.children && Array.isArray(menu.children) && menu.children.length) {
|
||||
const result = isExternalLink(menu.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
|
||||
for (const menu of menus) {
|
||||
if (!menu) continue
|
||||
|
||||
if (menus[key].id && menus[key].path == route.fullPath) {
|
||||
return menus[key].id
|
||||
if (menu.id && menu.path == route.fullPath) {
|
||||
return menu.id
|
||||
}
|
||||
if (menus[key].children && Array.isArray(menus[key].children) && menus[key].children.length) {
|
||||
find = searchMenuIndex(menus[key].children, route)
|
||||
if (menu.children && Array.isArray(menu.children) && menu.children.length) {
|
||||
find = searchMenuIndex(menu.children, route)
|
||||
if (find !== false) return find
|
||||
}
|
||||
}
|
||||
@@ -128,16 +106,12 @@ const handleClick = (path: string) => {
|
||||
navigateTo(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从动态菜单中-搜索一个菜单
|
||||
*/
|
||||
const findMenus = (route: RouteLocationNormalizedLoaded) => {
|
||||
// 修复:添加空值检查
|
||||
if (!systemStore.site.head_nav || !Array.isArray(systemStore.site.head_nav)) {
|
||||
if (!systemStore.navMenu || !Array.isArray(systemStore.navMenu)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const headNavIndex = searchMenuIndex(systemStore.site.head_nav, route)
|
||||
const headNavIndex = searchMenuIndex(systemStore.navMenu, route)
|
||||
if (headNavIndex !== false) return headNavIndex
|
||||
}
|
||||
|
||||
@@ -148,8 +122,6 @@ watch(
|
||||
setActiveMenu(route)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -203,4 +175,4 @@ watch(
|
||||
.theme-toggle-content {
|
||||
padding-right: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -5,22 +5,23 @@
|
||||
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 name="el-icon-Close" color="var(--el-color-primary)" size="20" />
|
||||
<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 :show-icon="true" mode="vertical" @menu-click="closeDrawer" />
|
||||
</div>
|
||||
|
||||
<!-- 移动端-会员菜单 -->
|
||||
<div class="mobile-menu-section">
|
||||
<HeaderActions :show-icon="true" mode="vertical" />
|
||||
<HeaderActions :show-icon="true" mode="vertical" @menu-click="closeDrawer" />
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
@@ -29,6 +30,7 @@
|
||||
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
|
||||
|
||||
+123
-57
@@ -3,62 +3,94 @@
|
||||
<el-header class="layout-header">
|
||||
<layout-header />
|
||||
</el-header>
|
||||
<el-main class="layout-main">
|
||||
<div class="layout-content">
|
||||
<slot></slot>
|
||||
<el-scrollbar :style="calcHeight(60)" class="main-scrollbar" ref="mainScrollbarRef">
|
||||
<div class="layout-main">
|
||||
<div class="layout-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<layout-footer />
|
||||
</div>
|
||||
<layout-footer />
|
||||
</el-main>
|
||||
</el-scrollbar>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useTemplateRef, watch, provide, onMounted } from 'vue';
|
||||
import layoutHeader from "./components/header.vue";
|
||||
import layoutFooter from "./components/footer.vue";
|
||||
import { useMemberStore } from '@/stores/member';
|
||||
|
||||
const route = useRoute();
|
||||
const mainScrollbarRef = useTemplateRef('mainScrollbarRef');
|
||||
const memberStore = useMemberStore();
|
||||
|
||||
// 计算滚动区域高度
|
||||
const calcHeight = (headerHeight: number) => {
|
||||
return {
|
||||
height: `calc(100vh - ${headerHeight}px)`,
|
||||
maxHeight: `calc(100vh - ${headerHeight}px)`
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
// 路由切换时滚动条滚动至顶部
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
if (!route.meta.disableScrollTo) {
|
||||
mainScrollbarRef.value?.scrollTo(0, 0);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 将滚动条的 ref provide 给子级组件
|
||||
provide('mainScrollbarRef', mainScrollbarRef);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.layout-container {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.layout-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
background-color: var(--el-bg-color);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
background-color: var(--ma-bg-color-overlay);
|
||||
height: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
--el-main-padding: 0 !important;
|
||||
padding: 0 !important;
|
||||
min-height: calc(100vh - 60px);
|
||||
margin-top: 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
.main-scrollbar {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.layout-content {
|
||||
flex: 1;
|
||||
min-height: var(--content-min-height, 500px);
|
||||
.layout-main {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.layout-footer {
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 100%;
|
||||
|
||||
.layout-content {
|
||||
flex: 1;
|
||||
min-height: var(--content-min-height, 500px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.layout-footer {
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,8 +126,6 @@ import layoutFooter from "./components/footer.vue";
|
||||
|
||||
/* 组件样式适配 */
|
||||
.layout-header {
|
||||
background-color: var(--el-bg-color) !important;
|
||||
box-shadow: var(--el-box-shadow-light) !important;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
@@ -146,41 +176,77 @@ import layoutFooter from "./components/footer.vue";
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条样式优化 */
|
||||
.main-scrollbar {
|
||||
:deep(.el-scrollbar__thumb) {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-scrollbar__track) {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色模式滚动条 */
|
||||
@at-root html.dark {
|
||||
.main-scrollbar {
|
||||
:deep(.el-scrollbar__thumb) {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-scrollbar__track) {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.page-index {
|
||||
background: url(~/assets/images/bg-dark.jpg) repeat;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
@media screen and (max-width: 1024px) {
|
||||
.page-index {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
/* 平板端首页样式 */
|
||||
.layout-container {
|
||||
.main-scrollbar {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
padding: 0 15px;
|
||||
--content-min-height: 400px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 375px) {
|
||||
.page-index {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
/* 小屏手机首页样式 */
|
||||
@media screen and (max-width: 768px) {
|
||||
.layout-container {
|
||||
.main-scrollbar {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
padding: 0 10px;
|
||||
--content-min-height: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-height: 650px) {
|
||||
.page-index {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
/* 矮屏设备样式 */
|
||||
.layout-container {
|
||||
.main-scrollbar {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
--content-min-height: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色模式 */
|
||||
@at-root html.dark {
|
||||
.page-index {
|
||||
background: url(~/assets/images/bg-dark.jpg) repeat;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+195
-12
@@ -1,29 +1,46 @@
|
||||
<template>
|
||||
<div class="member-layout">
|
||||
<el-container class="is-vertical">
|
||||
<Header />
|
||||
<el-container class="layout-container">
|
||||
<el-header class="layout-header">
|
||||
<Header />
|
||||
</el-header>
|
||||
<el-scrollbar :style="calcHeight(60)" class="main-scrollbar" ref="mainScrollbarRef">
|
||||
<el-row class="layouts-main" justify="center">
|
||||
<el-col class="user-layouts" :span="16" :xs="24">
|
||||
<Aside class="hidden-sm-and-down" />
|
||||
<el-main class="layout-main">
|
||||
<slot />
|
||||
</el-main>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<Footer />
|
||||
<div class="layout-main">
|
||||
<div class="layout-content">
|
||||
<el-row class="layouts-main" justify="center">
|
||||
<el-col class="user-layouts" :span="16" :xs="24">
|
||||
<client-only>
|
||||
<Aside class="hidden-sm-and-down" />
|
||||
</client-only>
|
||||
<el-main class="layout-main-content">
|
||||
<slot />
|
||||
</el-main>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef, watch, provide } from 'vue'
|
||||
import Header from './components/header.vue'
|
||||
import Aside from './components/aside.vue'
|
||||
import Footer from './components/footer.vue'
|
||||
const route = useRoute()
|
||||
const mainScrollbarRef = useTemplateRef('mainScrollbarRef')
|
||||
|
||||
// 计算滚动区域高度
|
||||
const calcHeight = (headerHeight: number) => {
|
||||
return {
|
||||
height: `calc(100vh - ${headerHeight}px)`,
|
||||
maxHeight: `calc(100vh - ${headerHeight}px)`
|
||||
}
|
||||
}
|
||||
|
||||
// 路由切换时滚动条滚动至顶部
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
@@ -39,13 +56,179 @@ provide('mainScrollbarRef', mainScrollbarRef)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.layout-container {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.layout-header {
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
background-color: var(--ma-bg-color-overlay);
|
||||
height: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.main-scrollbar {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.layout-main {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
|
||||
.layout-content {
|
||||
flex: 1;
|
||||
min-height: var(--content-min-height, 500px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.layout-footer {
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-layouts {
|
||||
display: flex;
|
||||
padding-top: 15px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.layout-main {
|
||||
.layout-main-content {
|
||||
padding: 0 !important;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--ma-bg-color-overlay);
|
||||
margin-left: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.layout-main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-main-content-mobile {
|
||||
padding: 0 !important;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--ma-bg-color-overlay);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 组件样式适配 */
|
||||
.layout-header {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
:deep(.header-logo) {
|
||||
span {
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.frontend-header-menu) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-footer {
|
||||
color: var(--el-text-color-secondary);
|
||||
background-color: transparent !important;
|
||||
position: relative;
|
||||
bottom: auto;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* 响应式断点 */
|
||||
@media screen and (max-width: 768px) {
|
||||
.layout-container {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
--content-min-height: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-layouts-mobile {
|
||||
padding-top: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
.layout-container {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
--content-min-height: 400px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
@media screen and (max-width: 1024px) {
|
||||
.layout-container {
|
||||
.main-scrollbar {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
padding: 0 15px;
|
||||
--content-min-height: 400px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.layout-container {
|
||||
.main-scrollbar {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
padding: 0 10px;
|
||||
--content-min-height: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-height: 650px) {
|
||||
.layout-container {
|
||||
.main-scrollbar {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
--content-min-height: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user