项目初始化

This commit is contained in:
2026-03-18 15:54:43 +08:00
commit dfcd762e23
601 changed files with 57883 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
<template>
<el-aside v-if="!navTabs.state.tabFullScreen" :class="['layout-aside-' + config.layout.layoutMode, config.layout.shrink ? 'shrink' : '']">
<Logo v-if="config.layout.menuShowTopBar" />
<MenuVerticalChildren v-if="config.layout.layoutMode == 'Double'" />
<MenuVertical v-else />
</el-aside>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Logo from '/@/layouts/backend/components/logo.vue'
import MenuVertical from '/@/layouts/backend/components/menus/menuVertical.vue'
import MenuVerticalChildren from '/@/layouts/backend/components/menus/menuVerticalChildren.vue'
import { useConfig } from '/@/stores/config'
import { useNavTabs } from '/@/stores/navTabs'
import { SYSTEM_ZINDEX } from '/@/stores/constant/common'
defineOptions({
name: 'layout/aside',
})
const config = useConfig()
const navTabs = useNavTabs()
const menuWidth = computed(() => config.menuWidth())
</script>
<style scoped lang="scss">
.layout-aside-Default:not(.shrink) {
background: var(--ba-bg-color-overlay);
margin: 16px 0 16px 16px;
height: calc(100% - 32px);
box-shadow: var(--el-box-shadow-light);
border-radius: var(--el-border-radius-base);
overflow: hidden;
transition: width 0.3s ease;
width: v-bind(menuWidth);
}
.layout-aside-Default.shrink,
.layout-aside-Classic,
.layout-aside-Double {
background: var(--ba-bg-color-overlay);
margin: 0;
height: 100%;
overflow: hidden;
transition: width 0.3s ease;
width: v-bind(menuWidth);
}
.shrink {
position: fixed;
top: 0;
left: 0;
z-index: v-bind('SYSTEM_ZINDEX');
}
</style>

View File

@@ -0,0 +1,259 @@
<!-- 模块市场 CRUD 记录页面等地方的 BuildAdmin 官方账户登录弹窗 -->
<template>
<div>
<el-dialog v-model="model" class="ba-account-dialog" width="25%" :title="t('layouts.Member information')">
<template v-if="baAccount.token">
<div v-loading="state.loading" class="userinfo">
<div class="user-avatar-box">
<img class="user-avatar" :src="baAccount.avatar" alt="" />
<Icon
class="user-avatar-gender"
:name="baAccount.getGenderIcon()['name']"
size="14"
:color="baAccount.getGenderIcon()['color']"
/>
</div>
<p class="username">{{ baAccount.nickname }}</p>
<p class="user-info">
<span>{{ $t('Integral') + ' ' + baAccount.score }}</span>
<span>{{ $t('Balance') + ' ' + baAccount.money }}</span>
</p>
<div class="userinfo-buttons">
<a href="https://uni.buildadmin.com/user" target="_blank" rel="noopener noreferrer">
<el-button v-blur size="default" type="primary">
{{ $t('layouts.Member center') }}
</el-button>
</a>
<el-button @click="baAccount.logout()" v-blur size="default" type="warning">{{ $t('layouts.Logout') }}</el-button>
</div>
</div>
</template>
<template v-else>
<div class="ba-login">
<h3 class="ba-title">{{ t('layouts.Login to the buildadmin') }}</h3>
<el-form
@keyup.enter="onBaAccountSubmitPre()"
ref="baAccountFormRef"
:rules="baAccountFormRules"
class="ba-account-login-form"
:model="state.user"
>
<FormItem
v-model="state.user.username"
type="string"
prop="username"
:placeholder="t('layouts.Please enter buildadmin account name or email')"
:input-attr="{
size: 'large',
}"
/>
<FormItem
v-model="state.user.password"
type="password"
prop="password"
:placeholder="t('layouts.Please enter the buildadmin account password')"
:input-attr="{
size: 'large',
}"
/>
<el-form-item class="form-buttons">
<el-button @click="onBaAccountSubmitPre()" :loading="state.submitLoading" round type="primary" size="large">
{{ t('layouts.Login') }}
</el-button>
<a
target="_blank"
class="ba-account-register"
href="https://uni.buildadmin.com/user/login?type=register"
rel="noopener noreferrer"
>
<el-button round plain type="info" size="large"> {{ t('layouts.Register') }} </el-button>
</a>
</el-form-item>
</el-form>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { reactive, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { baAccountCheckIn, baAccountGetUserInfo } from '/@/api/backend/index'
import clickCaptcha from '/@/components/clickCaptcha'
import FormItem from '/@/components/formItem/index.vue'
import { useBaAccount } from '/@/stores/baAccount'
import { useSiteConfig } from '/@/stores/siteConfig'
import { uuid } from '/@/utils/random'
import { buildValidatorData } from '/@/utils/validate'
const { t } = useI18n()
const baAccount = useBaAccount()
const siteConfig = useSiteConfig()
const model = defineModel<boolean>()
const baAccountFormRef = useTemplateRef('baAccountFormRef')
interface Props {
loginCallback?: (res: ApiResponse) => void
}
const props = withDefaults(defineProps<Props>(), {
loginCallback: () => {},
})
const state = reactive({
loading: true,
submitLoading: false,
user: {
tab: 'login',
username: '',
password: '',
captchaId: uuid(),
captchaInfo: '',
keep: false,
},
})
const onBaAccountSubmitPre = () => {
baAccountFormRef.value?.validate((valid) => {
if (valid) {
clickCaptcha(state.user.captchaId, (captchaInfo: string) => onBaAccountSubmit(captchaInfo), { apiBaseURL: siteConfig.apiUrl })
}
})
}
const onBaAccountSubmit = (captchaInfo = '') => {
state.submitLoading = true
state.user.captchaInfo = captchaInfo
baAccountCheckIn(state.user)
.then((res) => {
baAccount.dataFill(res.data.userInfo, false)
props.loginCallback(res)
})
.finally(() => {
state.submitLoading = false
})
}
const baAccountFormRules: Partial<Record<string, FormItemRule[]>> = reactive({
username: [buildValidatorData({ name: 'required', title: t('layouts.Username') })],
password: [buildValidatorData({ name: 'required', title: t('layouts.Password') }), buildValidatorData({ name: 'password' })],
})
watch(
() => model.value,
(newVal) => {
if (newVal && baAccount.token) {
baAccountGetUserInfo()
.then((res) => {
baAccount.dataFill(res.data.userInfo)
})
.catch(() => {
baAccount.removeToken()
})
.finally(() => {
state.loading = false
})
}
}
)
</script>
<style scoped lang="scss">
.userinfo {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
padding: 20px 0;
.username {
display: block;
text-align: center;
width: 100%;
padding-top: 10px;
font-size: var(--el-font-size-large);
font-weight: bold;
}
.user-info {
display: block;
text-align: center;
width: 100%;
padding: 10px 0;
font-size: var(--el-font-size-base);
span {
padding: 0 4px;
}
}
.user-avatar-box {
position: relative;
cursor: pointer;
}
.user-avatar {
display: block;
width: 100px;
border-radius: 50%;
border: 1px solid var(--el-border-color-extra-light);
}
.user-avatar-gender {
position: absolute;
bottom: 0px;
right: 10px;
height: 22px;
width: 22px;
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
border-radius: 50%;
box-shadow: var(--el-box-shadow);
}
.userinfo-buttons {
margin-top: 10px;
a {
margin-right: 15px;
}
}
}
.ba-login {
display: flex;
justify-content: center;
flex-wrap: wrap;
padding: 20px;
.ba-title {
width: 100%;
text-align: center;
}
.form-buttons {
.el-button {
width: 100%;
letter-spacing: 2px;
font-weight: 300;
margin-top: 20px;
margin-left: 0;
}
}
.ba-account-register {
width: 100%;
text-decoration: none;
}
.ba-account-login-form {
width: 350px;
padding-top: 20px;
}
}
/* 会员信息弹窗-s */
@media screen and (max-width: 1440px) {
:deep(.ba-account-dialog) {
--el-dialog-width: 40% !important;
}
}
@media screen and (max-width: 1024px) {
:deep(.ba-account-dialog) {
--el-dialog-width: 70% !important;
}
}
/* 会员信息弹窗-e */
</style>

View File

@@ -0,0 +1,72 @@
<template>
<div :title="$t('layouts.Exit full screen')" @mouseover.stop="onMouseover" @mouseout.stop="onMouseout">
<div @click.stop="onCloseFullScreen" class="close-full-screen" :style="{ top: state.closeBoxTop + 'px' }">
<Icon name="el-icon-Close" />
</div>
<div class="close-full-screen-on"></div>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted } from 'vue'
import { useNavTabs } from '/@/stores/navTabs'
import { SYSTEM_ZINDEX } from '/@/stores/constant/common'
const navTabs = useNavTabs()
const state = reactive({
closeBoxTop: 20,
})
onMounted(() => {
setTimeout(() => {
state.closeBoxTop = -30
}, 300)
})
/*
* 鼠标滑到顶部显示关闭全屏按钮
* 要检查 hover 的元素在外部直接使用事件而不是css
*/
const onMouseover = () => {
state.closeBoxTop = 20
}
const onMouseout = () => {
state.closeBoxTop = -30
}
const onCloseFullScreen = () => {
navTabs.setFullScreen(false)
}
</script>
<style scoped lang="scss">
.close-full-screen {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
right: calc(50% - 20px);
z-index: v-bind('SYSTEM_ZINDEX');
height: 40px;
width: 40px;
background-color: rgba($color: #000000, $alpha: 0.1);
border-radius: 50%;
box-shadow: var(--el-box-shadow-light);
transition: all 0.3s ease;
.icon {
color: rgba($color: #000000, $alpha: 0.6) !important;
}
&:hover {
background-color: rgba($color: #000000, $alpha: 0.3);
.icon {
color: rgba($color: #ffffff, $alpha: 0.6) !important;
}
}
}
.close-full-screen-on {
position: fixed;
top: 0;
z-index: v-bind('SYSTEM_ZINDEX - 1');
height: 60px;
width: 100px;
left: calc(50% - 50px);
}
</style>

View File

@@ -0,0 +1,420 @@
<template>
<div class="layout-config-drawer">
<el-drawer :model-value="configStore.layout.showDrawer" :title="t('layouts.Layout configuration')" size="310px" @close="onCloseDrawer">
<el-scrollbar class="layout-mode-style-scrollbar">
<el-form :model="configStore.layout">
<div class="layout-mode-styles-box">
<el-divider border-style="dashed">{{ t('layouts.Layout mode') }}</el-divider>
<div class="layout-mode-box-style">
<el-row class="layout-mode-box-style-row" :gutter="10">
<el-col :span="12">
<div
@click="setLayoutMode('Default')"
class="layout-mode-style default"
:class="configStore.layout.layoutMode == 'Default' ? 'active' : ''"
>
<div class="layout-mode-style-box">
<div class="layout-mode-style-aside"></div>
<div class="layout-mode-style-container-box">
<div class="layout-mode-style-header"></div>
<div class="layout-mode-style-container"></div>
</div>
</div>
<div class="layout-mode-style-name">{{ t('layouts.default') }}</div>
</div>
</el-col>
<el-col :span="12">
<div
@click="setLayoutMode('Classic')"
class="layout-mode-style classic"
:class="configStore.layout.layoutMode == 'Classic' ? 'active' : ''"
>
<div class="layout-mode-style-box">
<div class="layout-mode-style-aside"></div>
<div class="layout-mode-style-container-box">
<div class="layout-mode-style-header"></div>
<div class="layout-mode-style-container"></div>
</div>
</div>
<div class="layout-mode-style-name">{{ t('layouts.classic') }}</div>
</div>
</el-col>
</el-row>
<el-row :gutter="10">
<el-col :span="12">
<div
@click="setLayoutMode('Streamline')"
class="layout-mode-style streamline"
:class="configStore.layout.layoutMode == 'Streamline' ? 'active' : ''"
>
<div class="layout-mode-style-box">
<div class="layout-mode-style-container-box">
<div class="layout-mode-style-header"></div>
<div class="layout-mode-style-container"></div>
</div>
</div>
<div class="layout-mode-style-name">{{ t('layouts.Single column') }}</div>
</div>
</el-col>
<el-col :span="12">
<div
@click="setLayoutMode('Double')"
class="layout-mode-style double"
:class="configStore.layout.layoutMode == 'Double' ? 'active' : ''"
>
<div class="layout-mode-style-box">
<div class="layout-mode-style-aside"></div>
<div class="layout-mode-style-container-box">
<div class="layout-mode-style-header"></div>
<div class="layout-mode-style-container"></div>
</div>
</div>
<div class="layout-mode-style-name">{{ t('layouts.Double column') }}</div>
</div>
</el-col>
</el-row>
</div>
<el-divider border-style="dashed">{{ t('layouts.overall situation') }}</el-divider>
<div class="layout-config-global">
<el-form-item size="large" :label="t('layouts.Dark mode')">
<DarkSwitch @click="toggleDark()" />
</el-form-item>
<el-form-item :label="t('layouts.Background page switching animation')">
<el-select
@change="onCommitState($event, 'mainAnimation')"
:model-value="configStore.layout.mainAnimation"
:placeholder="t('layouts.Please select an animation name')"
>
<el-option label="slide-right" value="slide-right"></el-option>
<el-option label="slide-left" value="slide-left"></el-option>
<el-option label="el-fade-in-linear" value="el-fade-in-linear"></el-option>
<el-option label="el-fade-in" value="el-fade-in"></el-option>
<el-option label="el-zoom-in-center" value="el-zoom-in-center"></el-option>
<el-option label="el-zoom-in-top" value="el-zoom-in-top"></el-option>
<el-option label="el-zoom-in-bottom" value="el-zoom-in-bottom"></el-option>
</el-select>
</el-form-item>
</div>
<el-divider border-style="dashed">{{ t('layouts.sidebar') }}</el-divider>
<div class="layout-config-aside">
<el-form-item :label="t('layouts.Side menu bar background color')">
<el-color-picker
@change="onCommitColorState($event, 'menuBackground')"
:model-value="configStore.getColorVal('menuBackground')"
/>
</el-form-item>
<el-form-item :label="t('layouts.Side menu text color')">
<el-color-picker
@change="onCommitColorState($event, 'menuColor')"
:model-value="configStore.getColorVal('menuColor')"
/>
</el-form-item>
<el-form-item :label="t('layouts.Side menu active item background color')">
<el-color-picker
@change="onCommitColorState($event, 'menuActiveBackground')"
:model-value="configStore.getColorVal('menuActiveBackground')"
/>
</el-form-item>
<el-form-item :label="t('layouts.Side menu active item text color')">
<el-color-picker
@change="onCommitColorState($event, 'menuActiveColor')"
:model-value="configStore.getColorVal('menuActiveColor')"
/>
</el-form-item>
<el-form-item :label="t('layouts.Show side menu top bar (logo bar)')">
<el-switch
@change="onCommitState($event, 'menuShowTopBar')"
:model-value="configStore.layout.menuShowTopBar"
></el-switch>
</el-form-item>
<el-form-item :label="t('layouts.Side menu top bar background color')">
<el-color-picker
@change="onCommitColorState($event, 'menuTopBarBackground')"
:model-value="configStore.getColorVal('menuTopBarBackground')"
/>
</el-form-item>
<el-form-item :label="t('layouts.Side menu width (when expanded)')">
<el-input
@input="onCommitState($event, 'menuWidth')"
type="number"
:step="10"
:model-value="configStore.layout.menuWidth"
>
<template #append>px</template>
</el-input>
</el-form-item>
<el-form-item :label="t('layouts.Side menu default icon')">
<IconSelector
@change="onCommitMenuDefaultIcon($event, 'menuDefaultIcon')"
:model-value="configStore.layout.menuDefaultIcon"
/>
</el-form-item>
<el-form-item :label="t('layouts.Side menu horizontal collapse')">
<el-switch @change="onCommitState($event, 'menuCollapse')" :model-value="configStore.layout.menuCollapse"></el-switch>
</el-form-item>
<el-form-item :label="t('layouts.Side menu accordion')">
<el-switch
@change="onCommitState($event, 'menuUniqueOpened')"
:model-value="configStore.layout.menuUniqueOpened"
></el-switch>
</el-form-item>
</div>
<el-divider border-style="dashed">{{ t('layouts.Top bar') }}</el-divider>
<div class="layout-config-aside">
<el-form-item :label="t('layouts.Top bar background color')">
<el-color-picker
@change="onCommitColorState($event, 'headerBarBackground')"
:model-value="configStore.getColorVal('headerBarBackground')"
/>
</el-form-item>
<el-form-item :label="t('layouts.Top bar text color')">
<el-color-picker
@change="onCommitColorState($event, 'headerBarTabColor')"
:model-value="configStore.getColorVal('headerBarTabColor')"
/>
</el-form-item>
<el-form-item :label="t('layouts.Background color when hovering over the top bar')">
<el-color-picker
@change="onCommitColorState($event, 'headerBarHoverBackground')"
:model-value="configStore.getColorVal('headerBarHoverBackground')"
/>
</el-form-item>
<el-form-item :label="t('layouts.Top bar menu active item background color')">
<el-color-picker
@change="onCommitColorState($event, 'headerBarTabActiveBackground')"
:model-value="configStore.getColorVal('headerBarTabActiveBackground')"
/>
</el-form-item>
<el-form-item :label="t('layouts.Top bar menu active item text color')">
<el-color-picker
@change="onCommitColorState($event, 'headerBarTabActiveColor')"
:model-value="configStore.getColorVal('headerBarTabActiveColor')"
/>
</el-form-item>
</div>
<el-popconfirm
@confirm="restoreDefault"
:title="t('layouts.Are you sure you want to restore all configurations to the default values?')"
>
<template #reference>
<div class="ba-center">
<el-button class="w80" type="info">{{ t('layouts.Restore default') }}</el-button>
</div>
</template>
</el-popconfirm>
</div>
</el-form>
</el-scrollbar>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import IconSelector from '/@/components/baInput/components/iconSelector.vue'
import DarkSwitch from '/@/layouts/common/components/darkSwitch.vue'
import { useConfig } from '/@/stores/config'
import { BEFORE_RESIZE_LAYOUT, STORE_CONFIG } from '/@/stores/constant/cacheKey'
import type { Layout } from '/@/stores/interface'
import { useNavTabs } from '/@/stores/navTabs'
import { Local, Session } from '/@/utils/storage'
import toggleDark from '/@/utils/useDark'
const { t } = useI18n()
const configStore = useConfig()
const navTabs = useNavTabs()
const router = useRouter()
const onCommitState = (value: any, name: any) => {
configStore.setLayout(name, value)
}
const onCommitColorState = (value: string | null, name: keyof Layout) => {
if (value === null) return
const colors = configStore.layout[name] as string[]
if (configStore.layout.isDark) {
colors[1] = value
} else {
colors[0] = value
}
configStore.setLayout(name, colors)
}
const setLayoutMode = (mode: string) => {
Session.set(BEFORE_RESIZE_LAYOUT, {
layoutMode: mode,
menuCollapse: configStore.layout.menuCollapse,
})
configStore.setLayoutMode(mode)
}
// 修改默认菜单图标
const onCommitMenuDefaultIcon = (value: any, name: any) => {
configStore.setLayout(name, value)
const menus = navTabs.state.tabsViewRoutes
navTabs.setTabsViewRoutes([])
nextTick(() => {
navTabs.setTabsViewRoutes(menus)
})
}
const onCloseDrawer = () => {
configStore.setLayout('showDrawer', false)
}
const restoreDefault = () => {
Local.remove(STORE_CONFIG)
Session.remove(BEFORE_RESIZE_LAYOUT)
router.go(0)
}
</script>
<style scoped lang="scss">
.layout-config-drawer :deep(.el-input__inner) {
padding: 0 0 0 6px;
}
.layout-config-drawer :deep(.el-input-group__append) {
padding: 0 10px;
}
.layout-config-drawer :deep(.el-drawer__header) {
margin-bottom: 0 !important;
}
.layout-config-drawer :deep(.el-drawer__body) {
padding: 0;
}
.layout-mode-styles-box {
padding: 20px;
}
.layout-mode-box-style-row {
margin-bottom: 15px;
}
.layout-mode-style {
position: relative;
height: 100px;
border: 1px solid var(--el-border-color-light);
border-radius: var(--el-border-radius-small);
&:hover,
&.active {
border: 1px solid var(--el-color-primary);
}
.layout-mode-style-name {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
color: var(--el-color-primary-light-5);
border-radius: 50%;
height: 50px;
width: 50px;
border: 1px solid var(--el-color-primary-light-3);
}
.layout-mode-style-box {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
&.default {
display: flex;
align-items: center;
justify-content: center;
.layout-mode-style-aside {
width: 18%;
height: 90%;
background-color: var(--el-border-color-lighter);
}
.layout-mode-style-container-box {
width: 68%;
height: 90%;
margin-left: 4%;
.layout-mode-style-header {
width: 100%;
height: 10%;
background-color: var(--el-border-color-lighter);
}
.layout-mode-style-container {
width: 100%;
height: 85%;
background-color: var(--el-border-color-extra-light);
margin-top: 5%;
}
}
}
&.classic {
display: flex;
align-items: center;
justify-content: center;
.layout-mode-style-aside {
width: 18%;
height: 100%;
background-color: var(--el-border-color-lighter);
}
.layout-mode-style-container-box {
width: 82%;
height: 100%;
.layout-mode-style-header {
width: 100%;
height: 10%;
background-color: var(--el-border-color);
}
.layout-mode-style-container {
width: 100%;
height: 90%;
background-color: var(--el-border-color-extra-light);
}
}
}
&.streamline {
display: flex;
align-items: center;
justify-content: center;
.layout-mode-style-container-box {
width: 100%;
height: 100%;
.layout-mode-style-header {
width: 100%;
height: 10%;
background-color: var(--el-border-color);
}
.layout-mode-style-container {
width: 100%;
height: 90%;
background-color: var(--el-border-color-extra-light);
}
}
}
&.double {
display: flex;
align-items: center;
justify-content: center;
.layout-mode-style-aside {
width: 18%;
height: 100%;
background-color: var(--el-border-color);
}
.layout-mode-style-container-box {
width: 82%;
height: 100%;
.layout-mode-style-header {
width: 100%;
height: 10%;
background-color: var(--el-border-color);
}
.layout-mode-style-container {
width: 100%;
height: 90%;
background-color: var(--el-border-color-extra-light);
}
}
}
}
.w80 {
width: 90%;
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<el-header v-if="!navTabs.state.tabFullScreen" class="layout-header">
<component :is="config.layout.layoutMode + 'NavBar'"></component>
</el-header>
</template>
<script setup lang="ts">
import { useConfig } from '/@/stores/config'
import { useNavTabs } from '/@/stores/navTabs'
import DefaultNavBar from '/@/layouts/backend/components/navBar/default.vue'
import ClassicNavBar from '/@/layouts/backend/components/navBar/classic.vue'
import StreamlineNavBar from '/@/layouts/backend/components/menus/menuHorizontal.vue'
import DoubleNavBar from '/@/layouts/backend/components/navBar/double.vue'
defineOptions({
name: 'layout/header',
components: { DefaultNavBar, ClassicNavBar, StreamlineNavBar, DoubleNavBar },
})
const config = useConfig()
const navTabs = useNavTabs()
</script>
<style scoped lang="scss">
.layout-header {
height: auto;
padding: 0;
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="layout-logo">
<img v-if="!config.layout.menuCollapse" class="logo-img" src="~assets/logo.png" alt="logo" />
<div v-if="!config.layout.menuCollapse" :style="{ color: config.getColorVal('menuActiveColor') }" class="website-name">
{{ siteConfig.siteName }}
</div>
<Icon
v-if="config.layout.layoutMode != 'Streamline'"
@click="onMenuCollapse"
:name="config.layout.menuCollapse ? 'fa fa-indent' : 'fa fa-dedent'"
:class="config.layout.menuCollapse ? 'unfold' : ''"
:color="config.getColorVal('menuActiveColor')"
size="18"
class="fold"
/>
</div>
</template>
<script setup lang="ts">
import { useConfig } from '/@/stores/config'
import { useSiteConfig } from '/@/stores/siteConfig'
import { closeShade } from '/@/utils/pageShade'
import { Session } from '/@/utils/storage'
import { BEFORE_RESIZE_LAYOUT } from '/@/stores/constant/cacheKey'
import { setNavTabsWidth } from '/@/utils/layout'
const config = useConfig()
const siteConfig = useSiteConfig()
const onMenuCollapse = function () {
if (config.layout.shrink && !config.layout.menuCollapse) {
closeShade()
}
config.setLayout('menuCollapse', !config.layout.menuCollapse)
Session.set(BEFORE_RESIZE_LAYOUT, {
layoutMode: config.layout.layoutMode,
menuCollapse: config.layout.menuCollapse,
})
// 等待侧边栏动画结束后重新计算导航栏宽度
setTimeout(() => {
setNavTabsWidth()
}, 350)
}
</script>
<style scoped lang="scss">
.layout-logo {
width: 100%;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 10px;
background: v-bind('config.layout.layoutMode != "Streamline" ? config.getColorVal("menuTopBarBackground"):"transparent"');
}
.logo-img {
width: 28px;
}
.website-name {
display: block;
width: 180px;
padding-left: 4px;
font-size: var(--el-font-size-extra-large);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fold {
margin-left: auto;
}
.unfold {
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,110 @@
<template>
<div class="layouts-menu-horizontal">
<div class="menu-horizontal-logo" v-if="config.layout.menuShowTopBar">
<Logo />
</div>
<el-scrollbar ref="layoutMenuScrollbarRef" class="horizontal-menus-scrollbar">
<el-menu ref="layoutMenuRef" class="menu-horizontal" mode="horizontal" :default-active="state.defaultActive">
<MenuTree :extends="{ position: 'horizontal', level: 1 }" :menus="navTabs.state.tabsViewRoutes" />
</el-menu>
</el-scrollbar>
<NavMenus />
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
import { onBeforeRouteUpdate, useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
import Logo from '/@/layouts/backend/components/logo.vue'
import MenuTree from '/@/layouts/backend/components/menus/menuTree.vue'
import NavMenus from '/@/layouts/backend/components/navMenus.vue'
import { useConfig } from '/@/stores/config'
import { useNavTabs } from '/@/stores/navTabs'
import { layoutMenuRef, layoutMenuScrollbarRef } from '/@/stores/refs'
import horizontalScroll from '/@/utils/horizontalScroll'
import { getMenuKey } from '/@/utils/router'
const config = useConfig()
const navTabs = useNavTabs()
const route = useRoute()
const state = reactive({
defaultActive: '',
})
/**
* 激活当前路由对应的菜单
*/
const currentRouteActive = (currentRoute: RouteLocationNormalizedLoaded) => {
// 以路由 fullPath 匹配的菜单优先,且 fullPath 无匹配时,回退到 path 的匹配菜单
const tabView = navTabs.getTabsViewDataByRoute(currentRoute)
if (tabView) {
state.defaultActive = getMenuKey(tabView, tabView.meta!.matched as string)
}
}
/**
* 滚动条横向滚动到激活菜单所在位置
*/
const verticalMenusScroll = () => {
setTimeout(() => {
let activeMenu: HTMLElement | null = document.querySelector('.el-menu.menu-horizontal li.is-active')
if (activeMenu) {
layoutMenuScrollbarRef.value?.setScrollLeft(activeMenu.offsetLeft)
}
}, 500)
}
onMounted(() => {
currentRouteActive(route)
verticalMenusScroll()
new horizontalScroll(layoutMenuScrollbarRef.value!.wrapRef!)
})
onBeforeRouteUpdate((to) => {
currentRouteActive(to)
})
</script>
<style scoped lang="scss">
.layouts-menu-horizontal {
display: flex;
align-items: center;
width: 100vw;
height: var(--el-header-height);
background-color: var(--ba-bg-color-overlay);
border-bottom: 1px solid var(--el-color-info-light-8);
}
.menu-horizontal-logo {
width: 180px;
&:hover {
background-color: v-bind('config.getColorVal("headerBarHoverBackground")');
}
}
.horizontal-menus-scrollbar {
flex: 1;
height: var(--el-header-height);
}
.menu-horizontal {
border: none;
--el-menu-bg-color: v-bind('config.getColorVal("menuBackground")');
--el-menu-text-color: v-bind('config.getColorVal("menuColor")');
--el-menu-active-color: v-bind('config.getColorVal("menuActiveColor")');
}
.el-sub-menu .icon,
.el-menu-item .icon {
vertical-align: middle;
margin-right: 5px;
width: 24px;
text-align: center;
flex-shrink: 0;
}
.is-active .icon {
color: var(--el-menu-active-color) !important;
}
.el-menu-item.is-active {
background-color: v-bind('config.getColorVal("menuActiveBackground")');
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<template v-for="menu in props.menus">
<template v-if="menu.children && menu.children.length > 0">
<el-sub-menu @click="onClickSubMenu(menu)" :index="getMenuKey(menu)" :key="getMenuKey(menu)">
<template #title>
<Icon :color="config.getColorVal('menuColor')" :name="menu.meta?.icon ? menu.meta?.icon : config.layout.menuDefaultIcon" />
<span>{{ menu.meta?.title ? menu.meta?.title : $t('noTitle') }}</span>
</template>
<MenuTree :extends="{ ...props.extends, level: props.extends.level + 1 }" :menus="menu.children" />
</el-sub-menu>
</template>
<template v-else>
<el-menu-item @click="onClickMenu(menu)" :index="getMenuKey(menu)" :key="getMenuKey(menu)">
<Icon :color="config.getColorVal('menuColor')" :name="menu.meta?.icon ? menu.meta?.icon : config.layout.menuDefaultIcon" />
<span>{{ menu.meta?.title ? menu.meta?.title : $t('noTitle') }}</span>
</el-menu-item>
</template>
</template>
</template>
<script setup lang="ts">
import { ElNotification } from 'element-plus'
import { useI18n } from 'vue-i18n'
import type { RouteRecordRaw } from 'vue-router'
import { useConfig } from '/@/stores/config'
import { getFirstRoute, getMenuKey, onClickMenu } from '/@/utils/router'
const { t } = useI18n()
const config = useConfig()
interface Props {
menus: RouteRecordRaw[]
extends?: {
level: number
[key: string]: any
}
}
const props = withDefaults(defineProps<Props>(), {
menus: () => [],
extends: () => {
return {
level: 1,
}
},
})
/**
* sub-menu-item 被点击 - 用于单栏布局和双栏布局
* 顶栏菜单:点击时打开第一个菜单
* 侧边菜单(若有):点击只展开收缩
*
* sub-menu-item 被点击时,也会触发到 menu-item 的点击事件,由 el-menu 内部触发,无法很好的排除,在此检查 level 值
*/
const onClickSubMenu = (menu: RouteRecordRaw) => {
if (props.extends?.position == 'horizontal' && props.extends.level <= 1 && menu.children?.length) {
const firstRoute = getFirstRoute(menu.children)
if (firstRoute) {
onClickMenu(firstRoute)
} else {
ElNotification({
type: 'error',
message: t('utils.No child menu to jump to!'),
})
}
}
}
</script>
<style scoped lang="scss">
.el-sub-menu .icon,
.el-menu-item .icon {
vertical-align: middle;
margin-right: 5px;
width: 24px;
text-align: center;
flex-shrink: 0;
}
.is-active > .icon {
color: var(--el-menu-active-color) !important;
}
.el-menu-item.is-active {
background-color: v-bind('config.getColorVal("menuActiveBackground")');
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<el-scrollbar ref="layoutMenuScrollbarRef" class="vertical-menus-scrollbar">
<el-menu
class="layouts-menu-vertical"
:collapse-transition="false"
:unique-opened="config.layout.menuUniqueOpened"
:default-active="state.defaultActive"
:collapse="config.layout.menuCollapse"
ref="layoutMenuRef"
>
<MenuTree :menus="navTabs.state.tabsViewRoutes" />
</el-menu>
</el-scrollbar>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive } from 'vue'
import { onBeforeRouteUpdate, useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
import MenuTree from '/@/layouts/backend/components/menus/menuTree.vue'
import { useConfig } from '/@/stores/config'
import { useNavTabs } from '/@/stores/navTabs'
import { layoutMenuRef, layoutMenuScrollbarRef } from '/@/stores/refs'
import { getMenuKey } from '/@/utils/router'
const config = useConfig()
const navTabs = useNavTabs()
const route = useRoute()
const state = reactive({
defaultActive: '',
})
const verticalMenusScrollbarHeight = computed(() => {
const menuTopBarHeight = config.layout.menuShowTopBar ? 50 : 0
return 'calc(100% - ' + menuTopBarHeight + 'px)'
})
/**
* 激活当前路由对应的菜单
*/
const currentRouteActive = (currentRoute: RouteLocationNormalizedLoaded) => {
// 以路由 fullPath 匹配的菜单优先,且 fullPath 无匹配时,回退到 path 的匹配菜单
const tabView = navTabs.getTabsViewDataByRoute(currentRoute)
if (tabView) {
state.defaultActive = getMenuKey(tabView, tabView.meta!.matched as string)
}
}
/**
* 滚动条滚动到激活菜单所在位置
*/
const verticalMenusScroll = () => {
setTimeout(() => {
let activeMenu: HTMLElement | null = document.querySelector('.el-menu.layouts-menu-vertical li.is-active')
if (activeMenu) {
layoutMenuScrollbarRef.value?.setScrollTop(activeMenu.offsetTop)
}
}, 500)
}
onMounted(() => {
currentRouteActive(route)
verticalMenusScroll()
})
onBeforeRouteUpdate((to) => {
currentRouteActive(to)
})
</script>
<style>
.vertical-menus-scrollbar {
height: v-bind(verticalMenusScrollbarHeight);
background-color: v-bind('config.getColorVal("menuBackground")');
}
.layouts-menu-vertical {
border: 0;
--el-menu-bg-color: v-bind('config.getColorVal("menuBackground")');
--el-menu-text-color: v-bind('config.getColorVal("menuColor")');
--el-menu-active-color: v-bind('config.getColorVal("menuActiveColor")');
}
</style>

View File

@@ -0,0 +1,99 @@
<template>
<el-scrollbar ref="layoutMenuScrollbarRef" class="children-vertical-menus-scrollbar">
<el-menu
class="layouts-menu-vertical-children"
:collapse-transition="false"
:unique-opened="config.layout.menuUniqueOpened"
:default-active="state.defaultActive"
:collapse="config.layout.menuCollapse"
ref="layoutMenuRef"
>
<MenuTree v-if="state.routeChildren.length > 0" :menus="state.routeChildren" />
</el-menu>
</el-scrollbar>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, useTemplateRef } from 'vue'
import type { RouteLocationNormalizedLoaded, RouteRecordRaw } from 'vue-router'
import { onBeforeRouteUpdate, useRoute } from 'vue-router'
import MenuTree from '/@/layouts/backend/components/menus/menuTree.vue'
import { useConfig } from '/@/stores/config'
import { useNavTabs } from '/@/stores/navTabs'
import { layoutMenuRef } from '/@/stores/refs'
import { getMenuKey } from '/@/utils/router'
const config = useConfig()
const navTabs = useNavTabs()
const route = useRoute()
const layoutMenuScrollbarRef = useTemplateRef('layoutMenuScrollbarRef')
const state: {
defaultActive: string
routeChildren: RouteRecordRaw[]
} = reactive({
defaultActive: '',
routeChildren: [],
})
const verticalMenusScrollbarHeight = computed(() => {
const menuTopBarHeight = config.layout.menuShowTopBar ? 50 : 0
return 'calc(100% - ' + menuTopBarHeight + 'px)'
})
/**
* 激活当前路由的菜单
*/
const currentRouteActive = (currentRoute: RouteLocationNormalizedLoaded) => {
// 以路由 fullPath 匹配的菜单优先,且 fullPath 无匹配时,回退到 path 的匹配菜单
const tabView = navTabs.getTabsViewDataByRoute(currentRoute)
if (tabView) {
state.defaultActive = getMenuKey(tabView, tabView.meta!.matched as string)
}
let routeChildren = navTabs.getTabsViewDataByRoute(currentRoute, 'above')
if (routeChildren) {
if (routeChildren.children && routeChildren.children.length > 0) {
state.routeChildren = routeChildren.children
} else {
state.routeChildren = [routeChildren]
}
} else if (!state.routeChildren) {
state.routeChildren = navTabs.state.tabsViewRoutes
}
}
/**
* 侧栏菜单滚动条滚动到激活菜单所在位置
*/
const verticalMenusScroll = () => {
setTimeout(() => {
let activeMenu: HTMLElement | null = document.querySelector('.el-menu.layouts-menu-vertical-children li.is-active')
if (activeMenu) {
layoutMenuScrollbarRef.value?.setScrollTop(activeMenu.offsetTop)
}
}, 500)
}
onMounted(() => {
currentRouteActive(route)
verticalMenusScroll()
})
onBeforeRouteUpdate((to) => {
currentRouteActive(to)
})
</script>
<style>
.children-vertical-menus-scrollbar {
height: v-bind(verticalMenusScrollbarHeight);
background-color: v-bind('config.getColorVal("menuBackground")');
}
.layouts-menu-vertical-children {
border: 0;
--el-menu-bg-color: v-bind('config.getColorVal("menuBackground")');
--el-menu-text-color: v-bind('config.getColorVal("menuColor")');
--el-menu-active-color: v-bind('config.getColorVal("menuActiveColor")');
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="nav-bar">
<div v-if="config.layout.shrink && config.layout.menuCollapse" class="unfold">
<Icon @click="onMenuCollapse" name="fa fa-indent" :color="config.getColorVal('menuActiveColor')" size="18" />
</div>
<NavTabs v-if="!config.layout.shrink" ref="layoutNavTabsRef" />
<NavMenus />
</div>
</template>
<script setup lang="ts">
import { useConfig } from '/@/stores/config'
import NavTabs from '/@/layouts/backend/components/navBar/tabs.vue'
import { layoutNavTabsRef } from '/@/stores/refs'
import NavMenus from '../navMenus.vue'
import { showShade } from '/@/utils/pageShade'
const config = useConfig()
const onMenuCollapse = () => {
showShade('ba-aside-menu-shade', () => {
config.setLayout('menuCollapse', true)
})
config.setLayout('menuCollapse', false)
}
</script>
<style scoped lang="scss">
.nav-bar {
display: flex;
height: 50px;
width: 100%;
background-color: v-bind('config.getColorVal("headerBarBackground")');
:deep(.nav-tabs) {
display: flex;
height: 100%;
position: relative;
.ba-nav-tab {
display: flex;
align-items: center;
justify-content: center;
padding: 0 20px;
cursor: pointer;
z-index: 1;
height: 100%;
user-select: none;
color: v-bind('config.getColorVal("headerBarTabColor")');
transition: all 0.2s;
-webkit-transition: all 0.2s;
.close-icon {
padding: 2px;
margin: 2px 0 0 4px;
}
.close-icon:hover {
background: var(--ba-color-primary-light);
color: var(--el-border-color) !important;
border-radius: 50%;
}
&.active {
color: v-bind('config.getColorVal("headerBarTabActiveColor")');
}
&:hover {
background-color: v-bind('config.getColorVal("headerBarHoverBackground")');
}
}
.nav-tabs-active-box {
position: absolute;
height: 50px;
background-color: v-bind('config.getColorVal("headerBarTabActiveBackground")');
transition: all 0.2s;
-webkit-transition: all 0.2s;
}
}
}
.unfold {
align-self: center;
padding-left: var(--ba-main-space);
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<div class="nav-bar" :class="config.layout.shrink ? 'shrink' : ''">
<!-- 小屏设备下的展开菜单按钮 -->
<div v-if="config.layout.shrink && config.layout.menuCollapse" class="unfold">
<Icon @click="onMenuCollapse" name="fa fa-indent" :color="config.getColorVal('menuActiveColor')" size="18" />
</div>
<NavTabs v-if="!config.layout.shrink" ref="layoutNavTabsRef" />
<NavMenus />
</div>
</template>
<script setup lang="ts">
import { useConfig } from '/@/stores/config'
import NavTabs from '/@/layouts/backend/components/navBar/tabs.vue'
import NavMenus from '../navMenus.vue'
import { layoutNavTabsRef } from '/@/stores/refs'
import { showShade } from '/@/utils/pageShade'
const config = useConfig()
const onMenuCollapse = () => {
showShade('ba-aside-menu-shade', () => {
config.setLayout('menuCollapse', true)
})
config.setLayout('menuCollapse', false)
}
</script>
<style lang="scss" scoped>
.nav-bar {
display: flex;
height: 50px;
margin: 20px var(--ba-main-space) 0 var(--ba-main-space);
:deep(.nav-tabs) {
display: flex;
height: 100%;
position: relative;
.ba-nav-tab {
display: flex;
align-items: center;
justify-content: center;
padding: 0 20px;
cursor: pointer;
z-index: 1;
user-select: none;
opacity: 0.7;
color: v-bind('config.getColorVal("headerBarTabColor")');
.close-icon {
padding: 2px;
margin: 2px 0 0 4px;
}
.close-icon:hover {
background: var(--ba-color-primary-light);
color: var(--el-border-color) !important;
border-radius: 50%;
}
&.active {
color: v-bind('config.getColorVal("headerBarTabActiveColor")');
}
&:hover {
opacity: 1;
}
}
.nav-tabs-active-box {
position: absolute;
height: 40px;
border-radius: var(--el-border-radius-base);
background-color: v-bind('config.getColorVal("headerBarTabActiveBackground")');
box-shadow: var(--el-box-shadow-light);
transition: all 0.2s;
-webkit-transition: all 0.2s;
}
}
}
.nav-bar.shrink {
width: 100%;
background-color: v-bind('config.getColorVal("headerBarBackground")');
margin: 0;
.unfold {
align-self: center;
padding-left: var(--ba-main-space);
}
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<div class="layouts-menu-horizontal-double">
<el-scrollbar ref="layoutMenuScrollbarRef" class="double-menus-scrollbar">
<el-menu ref="layoutMenuRef" class="menu-horizontal" mode="horizontal" :default-active="state.defaultActive">
<MenuTree :extends="{ position: 'horizontal', level: 1 }" :menus="navTabs.state.tabsViewRoutes" />
</el-menu>
</el-scrollbar>
<NavMenus />
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
import { onBeforeRouteUpdate, useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
import MenuTree from '/@/layouts/backend/components/menus/menuTree.vue'
import NavMenus from '/@/layouts/backend/components/navMenus.vue'
import { useConfig } from '/@/stores/config'
import { useNavTabs } from '/@/stores/navTabs'
import { layoutMenuRef, layoutMenuScrollbarRef } from '/@/stores/refs'
import horizontalScroll from '/@/utils/horizontalScroll'
import { getMenuKey } from '/@/utils/router'
const config = useConfig()
const navTabs = useNavTabs()
const route = useRoute()
const state = reactive({
defaultActive: '',
})
/**
* 激活当前路由的菜单
*/
const currentRouteActive = (currentRoute: RouteLocationNormalizedLoaded) => {
// 以路由 fullPath 匹配的菜单优先,且 fullPath 无匹配时,回退到 path 的匹配菜单
const tabView = navTabs.getTabsViewDataByRoute(currentRoute)
if (tabView) {
state.defaultActive = getMenuKey(tabView, tabView.meta!.matched as string)
}
}
// 滚动条滚动到激活菜单所在位置
const verticalMenusScroll = () => {
setTimeout(() => {
let activeMenu: HTMLElement | null = document.querySelector('.el-menu.menu-horizontal li.is-active')
if (activeMenu) {
layoutMenuScrollbarRef.value?.setScrollLeft(activeMenu.offsetLeft)
}
}, 500)
}
onMounted(() => {
currentRouteActive(route)
verticalMenusScroll()
new horizontalScroll(layoutMenuScrollbarRef.value!.wrapRef!)
})
onBeforeRouteUpdate((to) => {
currentRouteActive(to)
})
</script>
<style scoped lang="scss">
.layouts-menu-horizontal-double {
display: flex;
align-items: center;
height: var(--el-header-height);
background-color: var(--ba-bg-color-overlay);
border-bottom: 1px solid var(--el-color-info-light-8);
}
.double-menus-scrollbar {
width: 70vw;
height: var(--el-header-height);
}
.menu-horizontal {
border: none;
--el-menu-bg-color: v-bind('config.getColorVal("menuBackground")');
--el-menu-text-color: v-bind('config.getColorVal("menuColor")');
--el-menu-active-color: v-bind('config.getColorVal("menuActiveColor")');
}
.el-sub-menu .icon,
.el-menu-item .icon {
vertical-align: middle;
margin-right: 5px;
width: 24px;
text-align: center;
flex-shrink: 0;
}
.is-active .icon {
color: var(--el-menu-active-color) !important;
}
.el-menu-item.is-active {
background-color: v-bind('config.getColorVal("menuActiveBackground")');
}
</style>

View File

@@ -0,0 +1,263 @@
<template>
<div class="nav-tabs" ref="tabScrollbarRef">
<div
v-for="(item, idx) in navTabs.state.tabsView"
@click="onTab(item)"
@contextmenu.prevent="onContextmenu(item, $event)"
class="ba-nav-tab"
:class="navTabs.state.activeIndex == idx ? 'active' : ''"
:ref="tabsRefs.set"
:key="idx"
>
{{ item.meta.title }}
<transition @after-leave="selectNavTab(tabsRefs[navTabs.state.activeIndex])" name="el-fade-in">
<Icon v-show="navTabs.state.tabsView.length > 1" class="close-icon" @click.stop="closeTab(item)" size="15" name="el-icon-Close" />
</transition>
</div>
<div :style="activeBoxStyle" class="nav-tabs-active-box"></div>
<Contextmenu ref="contextmenuRef" :items="state.contextmenuItems" @menuClick="onContextMenuClick" />
</div>
</template>
<script setup lang="ts">
import { nextTick, onMounted, reactive, useTemplateRef } from 'vue'
import { useRoute, useRouter, onBeforeRouteUpdate, type RouteLocationNormalized } from 'vue-router'
import { useConfig } from '/@/stores/config'
import { useNavTabs } from '/@/stores/navTabs'
import { useTemplateRefsList } from '@vueuse/core'
import type { ContextMenuItem, ContextMenuItemClickEmitArg } from '/@/components/contextmenu/interface'
import useCurrentInstance from '/@/utils/useCurrentInstance'
import Contextmenu from '/@/components/contextmenu/index.vue'
import horizontalScroll from '/@/utils/horizontalScroll'
import { getFirstRoute, routePush } from '/@/utils/router'
import { adminBaseRoutePath } from '/@/router/static/adminBase'
const route = useRoute()
const router = useRouter()
const config = useConfig()
const navTabs = useNavTabs()
const { proxy } = useCurrentInstance()
const tabsRefs = useTemplateRefsList<HTMLDivElement>()
const contextmenuRef = useTemplateRef('contextmenuRef')
const tabScrollbarRef = useTemplateRef('tabScrollbarRef')
const state: {
contextmenuItems: ContextMenuItem[]
} = reactive({
contextmenuItems: [
{ name: 'refresh', label: '重新加载', icon: 'fa fa-refresh' },
{ name: 'close', label: '关闭标签', icon: 'fa fa-times' },
{ name: 'fullScreen', label: '当前标签全屏', icon: 'el-icon-FullScreen' },
{ name: 'closeOther', label: '关闭其他标签', icon: 'fa fa-minus' },
{ name: 'closeAll', label: '关闭全部标签', icon: 'fa fa-stop' },
],
})
const activeBoxStyle = reactive({
width: '0',
transform: 'translateX(0px)',
})
const onTab = (menu: RouteLocationNormalized) => {
router.push(menu.fullPath)
}
// tab 激活状态切换
const selectNavTab = function (dom: HTMLDivElement) {
if (!dom) {
return false
}
activeBoxStyle.width = dom.clientWidth + 'px'
activeBoxStyle.transform = `translateX(${dom.offsetLeft}px)`
if (tabScrollbarRef.value) {
let scrollLeft = dom.offsetLeft + dom.clientWidth - tabScrollbarRef.value.clientWidth
if (dom.offsetLeft < tabScrollbarRef.value.scrollLeft) {
tabScrollbarRef.value.scrollTo(dom.offsetLeft, 0)
} else if (scrollLeft > tabScrollbarRef.value.scrollLeft) {
tabScrollbarRef.value.scrollTo(scrollLeft, 0)
}
}
}
const toLastTab = () => {
const lastTab = navTabs.state.tabsView.slice(-1)[0]
if (lastTab) {
router.push(lastTab.fullPath)
} else {
router.push(adminBaseRoutePath)
}
}
const closeTab = (route: RouteLocationNormalized) => {
navTabs._closeTab(route)
proxy.eventBus.emit('onTabViewClose', route)
if (navTabs.state.activeRoute?.fullPath === route.fullPath) {
toLastTab()
} else {
navTabs._setActiveRoute(navTabs.state.activeRoute!)
nextTick(() => {
selectNavTab(tabsRefs.value[navTabs.state.activeIndex])
})
}
contextmenuRef.value?.onHideContextmenu()
}
const closeOtherTab = (menu: RouteLocationNormalized) => {
navTabs._closeTabs(menu)
navTabs._setActiveRoute(menu)
if (navTabs.state.activeRoute?.fullPath !== route.fullPath) {
router.push(menu!.fullPath)
}
}
/**
* 关闭所有tab等同于 navTabs.closeAllTab
* @param menu 需要保留的标签,否则关闭全部标签
*/
const closeAllTab = (menu?: RouteLocationNormalized) => {
let firstRoute = getFirstRoute(navTabs.state.tabsViewRoutes)
if (menu && firstRoute && firstRoute.path == menu.fullPath) {
return closeOtherTab(menu)
}
if (firstRoute && firstRoute.path == navTabs.state.activeRoute?.fullPath) {
return closeOtherTab(navTabs.state.activeRoute)
}
navTabs._closeTabs(false)
if (firstRoute) routePush(firstRoute.path)
}
const onContextmenu = (menu: RouteLocationNormalized, el: MouseEvent) => {
// 禁用刷新
state.contextmenuItems[0].disabled = route.fullPath !== menu.fullPath
// 禁用关闭其他和关闭全部
state.contextmenuItems[4].disabled = state.contextmenuItems[3].disabled = navTabs.state.tabsView.length == 1 ? true : false
const { clientX, clientY } = el
contextmenuRef.value?.onShowContextmenu(menu, {
x: clientX,
y: clientY,
})
}
const onContextMenuClick = (item: ContextMenuItemClickEmitArg<RouteLocationNormalized>) => {
const { name, sourceData } = item
if (!sourceData) return
switch (name) {
case 'refresh':
proxy.eventBus.emit('onTabViewRefresh', sourceData)
break
case 'close':
closeTab(sourceData)
break
case 'closeOther':
closeOtherTab(sourceData)
break
case 'closeAll':
closeAllTab(sourceData)
break
case 'fullScreen':
if (route.fullPath !== sourceData.fullPath) {
router.push(sourceData.fullPath as string)
}
navTabs.setFullScreen(true)
break
}
}
const updateTab = function (newRoute: RouteLocationNormalized) {
// 添加tab
navTabs._addTab(newRoute)
// 激活当前tab
navTabs._setActiveRoute(newRoute)
nextTick(() => {
selectNavTab(tabsRefs.value[navTabs.state.activeIndex])
})
}
onBeforeRouteUpdate(async (to) => {
updateTab(to)
})
onMounted(() => {
updateTab(router.currentRoute.value)
if (tabScrollbarRef.value) {
new horizontalScroll(tabScrollbarRef.value)
}
})
/**
* 通过路由路径关闭tab等同于 navTabs.closeTabByPath
* @param fullPath 需要关闭的 tab 的路径
*/
const closeTabByPath = (fullPath: string) => {
for (const key in navTabs.state.tabsView) {
if (navTabs.state.tabsView[key].fullPath == fullPath) {
closeTab(navTabs.state.tabsView[key])
break
}
}
}
/**
* 修改 tab 标题(等同于 navTabs.updateTabTitle
* @param fullPath 需要修改标题的 tab 的路径
* @param title 新的标题
*/
const updateTabTitle = (fullPath: string, title: string) => {
navTabs._updateTabTitle(fullPath, title)
nextTick(() => {
selectNavTab(tabsRefs.value[navTabs.state.activeIndex])
})
}
defineExpose({
closeAllTab,
closeTabByPath,
updateTabTitle,
})
</script>
<style scoped lang="scss">
.dark {
.close-icon {
color: v-bind('config.getColorVal("headerBarTabColor")') !important;
}
.ba-nav-tab.active {
.close-icon {
color: v-bind('config.getColorVal("headerBarTabActiveColor")') !important;
}
}
}
.nav-tabs {
overflow-x: auto;
overflow-y: hidden;
margin-right: var(--ba-main-space);
scrollbar-width: none;
&::-webkit-scrollbar {
height: 5px;
}
&::-webkit-scrollbar-thumb {
background: #eaeaea;
border-radius: var(--el-border-radius-base);
box-shadow: none;
-webkit-box-shadow: none;
}
&::-webkit-scrollbar-track {
background: v-bind('config.layout.layoutMode == "Default" ? "none":config.getColorVal("headerBarBackground")');
}
&:hover {
&::-webkit-scrollbar-thumb:hover {
background: #c8c9cc;
}
}
}
.ba-nav-tab {
white-space: nowrap;
height: 40px;
}
</style>

View File

@@ -0,0 +1,329 @@
<template>
<div class="nav-menus" :class="[configStore.layout.layoutMode, configStore.layout.shrink ? 'shrink' : '']">
<!-- 需要重启 Vite 热更新服务警告 -->
<el-popover
ref="reloadHotServerPopover"
@show="onCurrentNavMenu(true, 'reloadHotServer')"
@hide="onCurrentNavMenu(false, 'reloadHotServer')"
:width="360"
v-if="hotUpdateState.dirtyFile"
>
<div>
<div class="el-popover__title">{{ t('vite.Reload hot server title') }}</div>
<div class="reload-hot-server-content">
<p>
<span>{{ t('vite.Reload hot server tips 1') }}</span>
<span>{{ t(`vite.Close type ${hotUpdateState.closeType}`) }}</span>
<span>{{ t('vite.Reload hot server tips 2') }}</span>
</p>
<p>{{ t('vite.Reload hot server tips 3') }}</p>
<div class="reload-hot-server-buttons">
<el-button @click="onHotServerOpt('cancel')">{{ t('vite.Later') }}</el-button>
<el-button @click="onHotServerOpt('reload')" type="primary">{{ t('vite.Restart hot update') }}</el-button>
</div>
</div>
</div>
<template #reference>
<div class="nav-menu-item" :class="state.currentNavMenu == 'reloadHotServer' ? 'hover' : ''">
<Icon color="var(--el-color-danger)" class="nav-menu-icon" name="el-icon-Warning" size="18" />
</div>
</template>
</el-popover>
<!-- 站点主页 -->
<router-link class="h100" target="_blank" :title="t('Home')" to="/">
<div class="nav-menu-item">
<Icon :color="configStore.getColorVal('headerBarTabColor')" class="nav-menu-icon" name="el-icon-Monitor" size="18" />
</div>
</router-link>
<!-- 语言切换 -->
<el-dropdown
@visible-change="onCurrentNavMenu($event, 'lang')"
class="h100"
size="large"
:hide-timeout="50"
placement="bottom"
trigger="click"
:hide-on-click="true"
>
<div class="nav-menu-item pt2" :class="state.currentNavMenu == 'lang' ? 'hover' : ''">
<Icon :color="configStore.getColorVal('headerBarTabColor')" class="nav-menu-icon" name="local-lang" size="18" />
</div>
<template #dropdown>
<el-dropdown-menu class="dropdown-menu-box">
<el-dropdown-item v-for="item in configStore.lang.langArray" :key="item.name" @click="editDefaultLang(item.name)">
{{ item.value }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 全屏切换 -->
<div @click="onFullScreen" class="nav-menu-item" :class="state.isFullScreen ? 'hover' : ''">
<Icon
:color="configStore.getColorVal('headerBarTabColor')"
class="nav-menu-icon"
v-if="state.isFullScreen"
name="local-full-screen-cancel"
size="18"
/>
<Icon :color="configStore.getColorVal('headerBarTabColor')" class="nav-menu-icon" v-else name="el-icon-FullScreen" size="18" />
</div>
<!-- 终端 - 仅超管 -->
<div v-if="adminInfo.super" @click="terminal.toggle()" class="nav-menu-item pt2">
<el-badge :is-dot="terminal.state.showDot">
<Icon :color="configStore.getColorVal('headerBarTabColor')" class="nav-menu-icon" name="local-terminal" size="26" />
</el-badge>
</div>
<!-- 清理缓存 - 仅超管 -->
<el-dropdown
v-if="adminInfo.super"
@visible-change="onCurrentNavMenu($event, 'clear')"
class="h100"
size="large"
:hide-timeout="50"
placement="bottom"
trigger="click"
:hide-on-click="true"
>
<div class="nav-menu-item" :class="state.currentNavMenu == 'clear' ? 'hover' : ''">
<Icon :color="configStore.getColorVal('headerBarTabColor')" class="nav-menu-icon" name="el-icon-Delete" size="18" />
</div>
<template #dropdown>
<el-dropdown-menu class="dropdown-menu-box">
<el-dropdown-item @click="onClearCache('tp')">{{ t('utils.Clean up system cache') }}</el-dropdown-item>
<el-dropdown-item @click="onClearCache('storage')">{{ t('utils.Clean up browser cache') }}</el-dropdown-item>
<el-dropdown-item @click="onClearCache('all')" divided>{{ t('utils.Clean up all cache') }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 管理员信息 -->
<el-popover
v-if="siteConfig.userInitialize"
@show="onCurrentNavMenu(true, 'adminInfo')"
@hide="onCurrentNavMenu(false, 'adminInfo')"
placement="bottom-end"
:hide-after="0"
:width="260"
trigger="click"
popper-class="admin-info-box"
v-model:visible="state.showAdminInfoPopover"
>
<template #reference>
<div class="admin-info" :class="state.currentNavMenu == 'adminInfo' ? 'hover' : ''">
<el-avatar :size="25" :src="fullUrl(adminInfo.avatar)"></el-avatar>
<div class="admin-name">{{ adminInfo.nickname }}</div>
</div>
</template>
<div>
<div class="admin-info-base">
<el-avatar :size="70" :src="fullUrl(adminInfo.avatar)"></el-avatar>
<div class="admin-info-other">
<div class="admin-info-name">{{ adminInfo.nickname }}</div>
<div class="admin-info-lasttime">{{ timeFormat(adminInfo.last_login_time) }}</div>
</div>
</div>
<div class="admin-info-footer">
<el-button @click="onAdminInfo" type="primary" plain>{{ t('layouts.Profile') }}</el-button>
<el-button @click="onLogout" type="danger" plain>{{ t('layouts.Logout') }}</el-button>
</div>
</div>
</el-popover>
<!-- 配置 -->
<div @click="configStore.setLayout('showDrawer', true)" class="nav-menu-item">
<Icon :color="configStore.getColorVal('headerBarTabColor')" class="nav-menu-icon" name="fa fa-cogs" size="18" />
</div>
<Config />
<Terminal />
</div>
</template>
<script lang="ts" setup>
import { ElMessage } from 'element-plus'
import screenfull from 'screenfull'
import { reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Config from './config.vue'
import { logout } from '/@/api/backend/index'
import { postClearCache } from '/@/api/common'
import Terminal from '/@/components/terminal/index.vue'
import { editDefaultLang } from '/@/lang'
import router from '/@/router'
import { useAdminInfo } from '/@/stores/adminInfo'
import { useConfig } from '/@/stores/config'
import { ADMIN_INFO, BA_ACCOUNT } from '/@/stores/constant/cacheKey'
import { useSiteConfig } from '/@/stores/siteConfig'
import { useTerminal } from '/@/stores/terminal'
import { fullUrl, timeFormat } from '/@/utils/common'
import { routePush } from '/@/utils/router'
import { Local, Session } from '/@/utils/storage'
import { hotUpdateState, reloadServer } from '/@/utils/vite'
const { t } = useI18n()
const adminInfo = useAdminInfo()
const configStore = useConfig()
const terminal = useTerminal()
const siteConfig = useSiteConfig()
const reloadHotServerPopover = useTemplateRef('reloadHotServerPopover')
const state = reactive({
isFullScreen: false,
currentNavMenu: '',
showLayoutDrawer: false,
showAdminInfoPopover: false,
})
const onCurrentNavMenu = (status: boolean, name: string) => {
state.currentNavMenu = status ? name : ''
}
const onHotServerOpt = (opt: 'reload' | 'cancel') => {
if (opt == 'cancel') {
reloadHotServerPopover.value?.hide()
} else {
reloadServer('manual')
}
}
const onFullScreen = () => {
if (!screenfull.isEnabled) {
ElMessage.warning(t('layouts.Full screen is not supported'))
return false
}
screenfull.toggle()
screenfull.onchange(() => {
state.isFullScreen = screenfull.isFullscreen
})
}
const onAdminInfo = () => {
state.showAdminInfoPopover = false
routePush({ name: 'routine/adminInfo' })
}
const onLogout = () => {
logout().then(() => {
Local.remove(ADMIN_INFO)
router.go(0)
})
}
const onClearCache = (type: string) => {
if (type == 'storage' || type == 'all') {
const adminInfo = Local.get(ADMIN_INFO)
const baAccount = Local.get(BA_ACCOUNT)
Session.clear()
Local.clear()
Local.set(ADMIN_INFO, adminInfo)
Local.set(BA_ACCOUNT, baAccount)
if (type == 'storage') return
}
postClearCache(type).then(() => {})
}
</script>
<style scoped lang="scss">
.nav-menus.Default:not(.shrink) {
border-radius: var(--el-border-radius-base);
box-shadow: var(--el-box-shadow-light);
}
.reload-hot-server-content {
font-size: var(--el-font-size-small);
p {
margin-bottom: 6px;
}
.reload-hot-server-buttons {
display: flex;
justify-content: flex-end;
}
}
.nav-menus {
display: flex;
align-items: center;
height: 100%;
margin-left: auto;
background-color: v-bind('configStore.getColorVal("headerBarBackground")');
.nav-menu-item {
height: 100%;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
.nav-menu-icon {
box-sizing: content-box;
color: v-bind('configStore.getColorVal("headerBarTabColor")');
}
&:hover {
.icon {
animation: twinkle 0.3s ease-in-out;
}
}
}
.admin-info {
display: flex;
height: 100%;
padding: 0 10px;
align-items: center;
cursor: pointer;
user-select: none;
color: v-bind('configStore.getColorVal("headerBarTabColor")');
}
.admin-name {
padding-left: 6px;
white-space: nowrap;
}
.nav-menu-item:hover,
.admin-info:hover,
.nav-menu-item.hover,
.admin-info.hover {
background: v-bind('configStore.getColorVal("headerBarHoverBackground")');
}
}
.dropdown-menu-box :deep(.el-dropdown-menu__item) {
justify-content: center;
}
.admin-info-base {
display: flex;
justify-content: center;
flex-wrap: wrap;
padding-top: 10px;
.admin-info-other {
display: block;
width: 100%;
text-align: center;
padding: 10px 0;
.admin-info-name {
font-size: var(--el-font-size-large);
}
}
}
.admin-info-footer {
padding: 10px 0;
margin: 0 -12px -12px -12px;
display: flex;
justify-content: space-around;
}
.pt2 {
padding-top: 2px;
}
@keyframes twinkle {
0% {
transform: scale(0);
}
80% {
transform: scale(1.2);
}
100% {
transform: scale(1);
}
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<el-container class="layout-container">
<Aside />
<el-container class="content-wrapper">
<Header />
<Main />
</el-container>
</el-container>
<CloseFullScreen v-if="navTabs.state.tabFullScreen" />
</template>
<script setup lang="ts">
import Aside from '/@/layouts/backend/components/aside.vue'
import Header from '/@/layouts/backend/components/header.vue'
import Main from '/@/layouts/backend/router-view/main.vue'
import CloseFullScreen from '/@/layouts/backend/components/closeFullScreen.vue'
import { useNavTabs } from '/@/stores/navTabs'
const navTabs = useNavTabs()
</script>
<style scoped>
.layout-container {
height: 100%;
width: 100%;
}
.content-wrapper {
flex-direction: column;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<el-container class="layout-container">
<Aside />
<el-container class="content-wrapper">
<Header />
<Main />
</el-container>
</el-container>
<CloseFullScreen v-if="navTabs.state.tabFullScreen" />
</template>
<script setup lang="ts">
import Aside from '/@/layouts/backend/components/aside.vue'
import Header from '/@/layouts/backend/components/header.vue'
import Main from '/@/layouts/backend/router-view/main.vue'
import CloseFullScreen from '/@/layouts/backend/components/closeFullScreen.vue'
import { useNavTabs } from '/@/stores/navTabs'
const navTabs = useNavTabs()
</script>
<style scoped>
.layout-container {
height: 100%;
width: 100%;
}
.content-wrapper {
flex-direction: column;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<el-container class="layout-container">
<Aside />
<el-container class="content-wrapper">
<Header />
<Main />
</el-container>
</el-container>
<CloseFullScreen v-if="navTabs.state.tabFullScreen" />
</template>
<script setup lang="ts">
import Aside from '/@/layouts/backend/components/aside.vue'
import Header from '/@/layouts/backend/components/header.vue'
import Main from '/@/layouts/backend/router-view/main.vue'
import CloseFullScreen from '/@/layouts/backend/components/closeFullScreen.vue'
import { useNavTabs } from '/@/stores/navTabs'
const navTabs = useNavTabs()
</script>
<style scoped>
.layout-container {
height: 100%;
width: 100%;
}
.content-wrapper {
flex-direction: column;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,29 @@
<template>
<el-container class="layout-container">
<el-container class="content-wrapper">
<Header />
<Main />
</el-container>
</el-container>
<CloseFullScreen v-if="navTabs.state.tabFullScreen" />
</template>
<script setup lang="ts">
import Header from '/@/layouts/backend/components/header.vue'
import Main from '/@/layouts/backend/router-view/main.vue'
import CloseFullScreen from '/@/layouts/backend/components/closeFullScreen.vue'
import { useNavTabs } from '/@/stores/navTabs'
const navTabs = useNavTabs()
</script>
<style scoped>
.layout-container {
height: 100%;
width: 100%;
}
.content-wrapper {
flex-direction: column;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,120 @@
<template>
<component :is="config.layout.layoutMode"></component>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import { useConfig } from '/@/stores/config'
import { useNavTabs } from '/@/stores/navTabs'
import { useTerminal } from '/@/stores/terminal'
import { useSiteConfig } from '/@/stores/siteConfig'
import { useAdminInfo } from '/@/stores/adminInfo'
import { useRoute } from 'vue-router'
import Default from '/@/layouts/backend/container/default.vue'
import Classic from '/@/layouts/backend/container/classic.vue'
import Streamline from '/@/layouts/backend/container/streamline.vue'
import Double from '/@/layouts/backend/container/double.vue'
import { onMounted, onBeforeMount } from 'vue'
import { Session } from '/@/utils/storage'
import { index } from '/@/api/backend'
import { handleAdminRoute, getFirstRoute, routePush } from '/@/utils/router'
import router from '/@/router/index'
import { adminBaseRoutePath } from '/@/router/static/adminBase'
import { useEventListener } from '@vueuse/core'
import { BEFORE_RESIZE_LAYOUT } from '/@/stores/constant/cacheKey'
import { isEmpty } from 'lodash-es'
import { setNavTabsWidth } from '/@/utils/layout'
defineOptions({
components: { Default, Classic, Streamline, Double },
})
const terminal = useTerminal()
const navTabs = useNavTabs()
const config = useConfig()
const route = useRoute()
const siteConfig = useSiteConfig()
const adminInfo = useAdminInfo()
const state = reactive({
autoMenuCollapseLock: false,
})
onMounted(() => {
if (!adminInfo.token) return router.push({ name: 'adminLogin' })
init()
setNavTabsWidth()
useEventListener(window, 'resize', setNavTabsWidth)
})
onBeforeMount(() => {
onAdaptiveLayout()
useEventListener(window, 'resize', onAdaptiveLayout)
})
const init = () => {
/**
* 后台初始化请求,获取站点配置,动态路由等信息
*/
index()
.then((res) => {
siteConfig.dataFill(res.data.siteConfig)
terminal.changePackageManager(res.data.terminal.npmPackageManager)
terminal.changePHPDevelopmentServer(res.data.terminal.phpDevelopmentServer)
siteConfig.setInitialize(true)
if (!isEmpty(res.data.adminInfo)) {
adminInfo.dataFill(res.data.adminInfo)
siteConfig.setUserInitialize(true)
}
if (res.data.menus) {
handleAdminRoute(res.data.menus)
// 预跳转到上次路径
if (route.params.to) {
const lastRoute = JSON.parse(route.params.to as string)
if (lastRoute.path != adminBaseRoutePath) {
let query = !isEmpty(lastRoute.query) ? lastRoute.query : {}
routePush({ path: lastRoute.path, query: query })
return
}
}
// 跳转到第一个菜单
let firstRoute = getFirstRoute(navTabs.state.tabsViewRoutes)
if (firstRoute) routePush(firstRoute.path)
}
})
.catch(() => {
// 303/401 等由 axios 拦截器处理跳转登录;其他错误由 loading 页 6 秒后显示重试按钮
})
}
const onAdaptiveLayout = () => {
let defaultBeforeResizeLayout = {
menuCollapse: config.layout.menuCollapse,
}
let beforeResizeLayout = Session.get(BEFORE_RESIZE_LAYOUT)
if (!beforeResizeLayout) Session.set(BEFORE_RESIZE_LAYOUT, defaultBeforeResizeLayout)
const clientWidth = document.body.clientWidth
if (clientWidth < 1024) {
/**
* 锁定窗口改变自动调整 menuCollapse
* 避免已是小窗且打开了菜单栏时,意外的自动关闭菜单栏
*/
if (!state.autoMenuCollapseLock) {
state.autoMenuCollapseLock = true
config.setLayout('menuCollapse', true)
}
config.setLayout('shrink', true)
} else {
state.autoMenuCollapseLock = false
let beforeResizeLayoutTemp = beforeResizeLayout || defaultBeforeResizeLayout
config.setLayout('menuCollapse', beforeResizeLayoutTemp.menuCollapse)
config.setLayout('shrink', false)
}
}
</script>

View File

@@ -0,0 +1,105 @@
<template>
<el-main class="layout-main">
<el-scrollbar class="layout-main-scrollbar" :style="layoutMainScrollbarStyle" ref="layoutMainScrollbarRef">
<router-view v-slot="{ Component }">
<transition :name="config.layout.mainAnimation" mode="out-in">
<keep-alive :include="state.keepAliveComponentNameList">
<component :is="Component" :key="state.componentKey" />
</keep-alive>
</transition>
</router-view>
</el-scrollbar>
</el-main>
</template>
<script setup lang="ts">
import { reactive, onMounted, watch, onBeforeMount, onUnmounted, nextTick } from 'vue'
import { useRoute, type RouteLocationNormalized } from 'vue-router'
import useCurrentInstance from '/@/utils/useCurrentInstance'
import { useConfig } from '/@/stores/config'
import { useNavTabs } from '/@/stores/navTabs'
import { layoutMainScrollbarRef, layoutMainScrollbarStyle } from '/@/stores/refs'
defineOptions({
name: 'layout/main',
})
const { proxy } = useCurrentInstance()
const route = useRoute()
const config = useConfig()
const navTabs = useNavTabs()
const state: {
componentKey: string
keepAliveComponentNameList: string[]
} = reactive({
componentKey: route.fullPath,
keepAliveComponentNameList: [],
})
const addKeepAliveComponentName = function (keepAliveName: string | undefined) {
if (keepAliveName) {
let exist = state.keepAliveComponentNameList.find((name: string) => {
return name === keepAliveName
})
if (exist) return
state.keepAliveComponentNameList.push(keepAliveName)
}
}
const addActiveRouteKeepAlive = () => {
if (navTabs.state.activeRoute) {
const tabView = navTabs.getTabsViewDataByRoute(navTabs.state.activeRoute)
if (tabView && typeof tabView.meta?.keepalive == 'string') {
addKeepAliveComponentName(tabView.meta.keepalive)
}
}
}
onBeforeMount(() => {
proxy.eventBus.on('onTabViewRefresh', (menu: RouteLocationNormalized) => {
state.keepAliveComponentNameList = state.keepAliveComponentNameList.filter((name: string) => menu.meta.keepalive !== name)
state.componentKey = ''
nextTick(() => {
state.componentKey = menu.fullPath
addKeepAliveComponentName(menu.meta.keepalive as string)
})
})
proxy.eventBus.on('onTabViewClose', (menu: RouteLocationNormalized) => {
state.keepAliveComponentNameList = state.keepAliveComponentNameList.filter((name: string) => menu.meta.keepalive !== name)
})
})
onUnmounted(() => {
proxy.eventBus.off('onTabViewRefresh')
proxy.eventBus.off('onTabViewClose')
})
onMounted(() => {
// 确保刷新页面时也能正确取得当前路由 keepalive 参数(热更新)
addActiveRouteKeepAlive()
})
watch(
() => route.fullPath,
() => {
state.componentKey = route.fullPath
addActiveRouteKeepAlive()
}
)
</script>
<style scoped lang="scss">
.layout-container .layout-main {
padding: 0 !important;
overflow: hidden;
width: 100%;
height: 100%;
}
.layout-main-scrollbar {
width: 100%;
position: relative;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<div class="theme-toggle-content">
<div class="switch">
<div class="switch-action">
<Icon name="local-dark" color="#f2f2f2" size="13px" class="switch-icon dark-icon" />
<Icon name="local-light" color="#303133" size="13px" class="switch-icon light-icon" />
</div>
</div>
</div>
</template>
<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(--ba-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;
bottom: 1px;
transition: all 0.3s;
cursor: pointer;
}
.light-icon {
opacity: 1;
}
.dark-icon {
opacity: 0;
}
@at-root .dark {
.switch {
background-color: #2c2c2c;
}
.switch-action {
transform: translate(20px);
background-color: #141414;
}
.dark-icon {
opacity: 1;
}
.light-icon {
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,64 @@
<template>
<div>
<div
v-loading="true"
element-loading-background="var(--ba-bg-color-overlay)"
:element-loading-text="$t('utils.Loading')"
class="default-main ba-main-loading"
></div>
<div v-if="state.showReload" class="loading-footer">
<el-button @click="refresh" type="warning">{{ $t('utils.Reload') }}</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { onUnmounted, reactive } from 'vue'
import router from '/@/router/index'
import { useMemberCenter } from '/@/stores/memberCenter'
import { useNavTabs } from '/@/stores/navTabs'
import { isAdminApp } from '/@/utils/common'
import { getFirstRoute, routePush } from '/@/utils/router'
let timer: number
const navTabs = useNavTabs()
const memberCenter = useMemberCenter()
const state = reactive({
maximumWait: 1000 * 6,
showReload: false,
})
const refresh = () => {
router.go(0)
}
if (isAdminApp() && navTabs.state.tabsViewRoutes) {
let firstRoute = getFirstRoute(navTabs.state.tabsViewRoutes)
if (firstRoute) routePush(firstRoute.path)
} else if (memberCenter.state.viewRoutes) {
let firstRoute = getFirstRoute(memberCenter.state.viewRoutes)
if (firstRoute) routePush(firstRoute.path)
}
timer = window.setTimeout(() => {
state.showReload = true
}, state.maximumWait)
onUnmounted(() => {
clearTimeout(timer)
})
</script>
<style scoped lang="scss">
.ba-main-loading {
height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
.loading-footer {
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<div class="iframe-main" v-loading="state.loading">
<iframe :src="state.iframeSrc" :style="iframeStyle(35)" height="100%" width="100%" id="iframe" @load="hideLoading"></iframe>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import { useRouter } from 'vue-router'
import { mainHeight as iframeStyle } from '/@/utils/layout'
const router = useRouter()
const state = reactive({
loading: true,
iframeSrc: router.currentRoute.value.meta.url as string,
})
const hideLoading = () => {
state.loading = false
}
</script>
<style scoped lang="scss">
.iframe-main {
margin: var(--ba-main-space);
iframe {
border: none;
}
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<el-aside class="ba-user-layouts">
<div class="userinfo">
<div @click="routerPush('account/profile')" class="user-avatar-box">
<img class="user-avatar" :src="fullUrl(userInfo.avatar ? userInfo.avatar : '/static/images/avatar.png')" alt="" />
<Icon class="user-avatar-gender" :name="userInfo.getGenderIcon()['name']" size="14" :color="userInfo.getGenderIcon()['color']" />
</div>
<p class="username">{{ userInfo.nickname }}</p>
<el-button-group>
<el-button
@click="routerPush('account/integral')"
v-blur
class="userinfo-button-item"
:title="$t('Integral') + ' ' + userInfo.score"
size="default"
plain
>
<span>{{ $t('Integral') + ' ' + userInfo.score }}</span>
</el-button>
<el-button
@click="routerPush('account/balance')"
v-blur
class="userinfo-button-item"
:title="$t('Balance') + ' ' + userInfo.money"
size="default"
plain
>
<span>{{ $t('Balance') + ' ' + userInfo.money }}</span>
</el-button>
</el-button-group>
</div>
<div class="user-menus">
<template v-for="(item, idx) in memberCenter.state.viewRoutes" :key="idx">
<div v-if="memberCenter.state.showHeadline" class="user-menu-max-title">{{ item.meta?.title }}</div>
<div
v-for="(menu, index) in item.children"
:key="index"
@click="routerPush(menu)"
class="user-menu-item"
:class="route.fullPath == menu.path ? 'active' : ''"
>
<Icon :name="menu.meta?.icon" size="16" color="var(--el-text-color-secondary)" />
<span>{{ menu.meta?.title }}</span>
</div>
</template>
</div>
</el-aside>
</template>
<script setup lang="ts">
import { useRoute, useRouter, type RouteRecordRaw } from 'vue-router'
import { useMemberCenter } from '/@/stores/memberCenter'
import { useUserInfo } from '/@/stores/userInfo'
import { fullUrl } from '/@/utils/common'
import { onClickMenu } from '/@/utils/router'
const route = useRoute()
const router = useRouter()
const userInfo = useUserInfo()
const memberCenter = useMemberCenter()
const routerPush = (route: string | RouteRecordRaw) => {
if (typeof route === 'string') {
router.push({ name: route })
} else {
onClickMenu(route)
}
}
</script>
<style scoped lang="scss">
.ba-user-layouts {
width: 240px;
background-color: var(--ba-bg-color-overlay);
box-shadow: var(--el-box-shadow-light);
}
.userinfo {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
padding: 20px 0;
}
.username {
display: block;
text-align: center;
width: 100%;
padding: 10px 0;
font-size: var(--el-font-size-large);
font-weight: bold;
}
.user-avatar-box {
position: relative;
width: 100px;
height: 100px;
cursor: pointer;
}
.user-avatar {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.user-avatar-gender {
position: absolute;
bottom: 0;
right: 10px;
height: 22px;
width: 22px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--ba-bg-color-overlay);
border-radius: 50%;
box-shadow: var(--el-box-shadow);
}
.userinfo-button-item {
font-size: var(--el-font-size-small);
height: 30px;
}
.user-menus {
font-size: var(--el-font-size-base);
color: var(--el-text-color-regular);
padding-bottom: 20px;
}
.user-menu-max-title {
font-size: 15px;
color: var(--el-text-color-secondary);
padding: 5px 30px;
}
.user-menu-item {
padding: 10px 30px;
cursor: pointer;
.icon {
width: 16px;
height: 16px;
text-align: center;
margin-right: 8px;
}
}
.user-menu-item:hover,
.user-menu-item.active {
border-left: 2px solid var(--el-color-primary);
padding-left: 28px;
color: var(--el-color-primary);
.icon {
color: var(--el-color-primary) !important;
}
background-color: var(--el-color-info-light-8);
}
@media screen and (max-width: 991px) {
.ba-user-layouts {
width: 100%;
background-color: var(--ba-bg-color-overlay);
box-shadow: none;
}
}
</style>

View File

@@ -0,0 +1,33 @@
<template>
<el-footer class="footer">
<div>
Copyright @ 2020~{{ new Date().getFullYear() }} {{ siteConfig.siteName }} {{ $t('Copyright') }}
<a href="http://beian.miit.gov.cn/">{{ siteConfig.recordNumber }}</a>
</div>
</el-footer>
</template>
<script setup lang="ts">
import { useSiteConfig } from '/@/stores/siteConfig'
const siteConfig = useSiteConfig()
</script>
<style scoped lang="scss">
.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>

View File

@@ -0,0 +1,127 @@
<template>
<el-header class="header">
<el-row justify="center">
<el-col class="header-row" :xs="24" :sm="24" :md="16">
<div @click="router.push({ name: '/' })" class="header-logo">
<img src="~assets/logo.png" />
<span class="site-name">{{ siteConfig.siteName }}</span>
</div>
<div v-if="!memberCenter.state.menuExpand" @click="memberCenter.toggleMenuExpand(true)" class="user-menus-expand hidden-md-and-up">
<Icon name="fa fa-indent" color="var(--el-color-primary)" size="20" />
</div>
<el-scrollbar ref="layoutMenuScrollbarRef" class="hidden-sm-and-down">
<Menu class="frontend-header-menu" :ellipsis="false" mode="horizontal" />
</el-scrollbar>
</el-col>
</el-row>
<el-drawer
class="ba-aside-drawer"
:append-to-body="true"
v-model="memberCenter.state.menuExpand"
:with-header="false"
direction="ltr"
:size="memberCenter.state.shrink ? '70%' : '40%'"
>
<div class="header-row">
<div @click="router.push({ name: '/' })" class="header-logo">
<img src="~assets/logo.png" />
<span class="site-name">{{ siteConfig.siteName }}</span>
</div>
<div @click="memberCenter.toggleMenuExpand(false)" class="user-menus-expand hidden-md-and-up">
<Icon name="fa fa-dedent" color="var(--el-color-primary)" size="20" />
</div>
</div>
<Menu :show-icon="true" mode="vertical" />
</el-drawer>
</el-header>
</template>
<script setup lang="ts">
import { onBeforeRouteUpdate, useRouter } from 'vue-router'
import { initialize } from '/@/api/frontend/index'
import Menu from '/@/layouts/frontend/components/menu.vue'
import { useMemberCenter } from '/@/stores/memberCenter'
import { layoutMenuScrollbarRef } from '/@/stores/refs'
import { useSiteConfig } from '/@/stores/siteConfig'
const router = useRouter()
const siteConfig = useSiteConfig()
const memberCenter = useMemberCenter()
onBeforeRouteUpdate(() => {
memberCenter.toggleMenuExpand(false)
})
/**
* 前端初始化请求,获取站点配置信息,动态路由信息等
*/
initialize()
</script>
<style scoped lang="scss">
.header {
background-color: var(--ba-bg-color-overlay);
box-shadow: 0 0 8px rgba(0 0 0 / 8%);
.frontend-header-menu {
height: var(--el-header-height);
}
}
.header-row {
display: flex;
justify-content: space-between;
.header-logo {
display: flex;
height: var(--el-header-height);
align-items: center;
padding-right: 15px;
cursor: pointer;
img {
height: 34px;
width: 34px;
}
.site-name {
padding-left: 4px;
font-size: var(--el-font-size-large);
white-space: nowrap;
}
}
.user-menus-expand {
display: flex;
height: var(--el-header-height);
align-items: center;
justify-content: center;
}
}
.ba-aside-drawer {
.header-row {
padding: 10px 20px;
background-color: var(--el-color-info-light-9);
.header-logo {
img {
height: 28px;
width: 28px;
}
}
}
}
@at-root html.dark {
.header-logo .site-name {
color: var(--el-text-color-primary);
}
}
@media screen and (max-width: 768px) {
.user-menus-expand {
padding: 0;
}
}
@media screen and (max-width: 414px) {
.frontend-header-menu :deep(.el-sub-menu .el-sub-menu__title) {
padding: 0 20px;
.el-icon {
display: none;
}
}
}
</style>

View File

@@ -0,0 +1,22 @@
<template>
<el-main class="layout-main">
<router-view v-slot="{ Component }">
<transition :name="config.layout.mainAnimation" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
</template>
<script setup lang="ts">
import { useConfig } from '/@/stores/config'
const config = useConfig()
</script>
<style scoped lang="scss">
.layout-main {
padding: 0 !important;
overflow-x: hidden;
}
</style>

View File

@@ -0,0 +1,256 @@
<template>
<el-menu ref="layoutMenuRef" :default-active="state.activeMenu" @select="onSelect">
<el-menu-item @click="router.push({ name: '/' })" v-blur index="index">
<Icon v-if="props.showIcon" name="fa fa-home" color="var(--el-text-color-primary)" />
<template #title>{{ $t('Home') }}</template>
</el-menu-item>
<!-- 动态菜单 -->
<MenuSub :menus="siteConfig.headNav" :show-icon="showIcon" />
<template v-if="memberCenter.state.open">
<el-sub-menu v-if="userInfo.isLogin()" @click="$attrs.mode == 'vertical' ? '' : router.push({ name: 'user' })" 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="fullUrl(userInfo.avatar ? userInfo.avatar : '/static/images/avatar.png')"
alt=""
/>
{{ userInfo.nickname }}
</div>
</template>
<el-menu-item @click="router.push({ name: 'user' })" 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="memberCenter.state.navUserMenus" :show-icon="showIcon" />
<!-- 会员中心菜单 -->
<MenuSub :menus="memberCenter.state.viewRoutes" :show-icon="showIcon" />
<el-menu-item @click="userInfo.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="router.push({ name: 'user' })" 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>
<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="editDefaultLang(item.name)"
v-for="item in config.lang.langArray"
:key="item.name"
:index="'language-switch-' + item.value"
class="language-switch"
>
<Icon v-if="showIcon" name="fa fa-circle-o" color="var(--el-text-color-primary)" />
{{ item.value }}
</el-menu-item>
</el-sub-menu>
<el-menu-item index="theme-switch" class="theme-switch" :class="$attrs.mode + '-theme-switch'">
<DarkSwitch @click="toggleDark()" />
</el-menu-item>
</template>
</el-menu>
</template>
<script setup lang="ts">
import { nextTick, reactive } from 'vue'
import { editDefaultLang } from '/@/lang/index'
import { useConfig } from '/@/stores/config'
import { useUserInfo } from '/@/stores/userInfo'
import { useSiteConfig } from '/@/stores/siteConfig'
import { useMemberCenter } from '/@/stores/memberCenter'
import { fullUrl } from '/@/utils/common'
import MenuSub from '/@/layouts/frontend/components/menuSub.vue'
import toggleDark from '/@/utils/useDark'
import DarkSwitch from '/@/layouts/common/components/darkSwitch.vue'
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import type { RouteLocationNormalizedLoaded, RouteRecordRaw } from 'vue-router'
import { layoutMenuRef } from '/@/stores/refs'
const route = useRoute()
const router = useRouter()
const config = useConfig()
const userInfo = useUserInfo()
const siteConfig = useSiteConfig()
const memberCenter = useMemberCenter()
interface Props {
showIcon?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showIcon: false,
})
const state = reactive({
activeMenu: '',
})
/**
* 设置激活菜单
*/
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(siteConfig.headNav, index) ||
noNeedActive(memberCenter.state.navUserMenus, index) ||
noNeedActive(memberCenter.state.viewRoutes, index)
) {
const oldActiveMenu = state.activeMenu
state.activeMenu = ''
nextTick(() => {
state.activeMenu = oldActiveMenu
})
}
}
/**
* 检查一个菜单是否需要激活态
* @param menus
* @param index
*/
const noNeedActive = (menus: RouteRecordRaw[], index: string) => {
if (index.indexOf('language-switch') === 0 || index == 'theme-switch') {
return true
}
return isExternalLink(menus, index)
}
/**
* 检查一个菜单是否是外站链接,如果是,不要激活
* @param menus
* @param index
*/
const isExternalLink = (menus: RouteRecordRaw[], index: string): boolean => {
for (const key in menus) {
const columnIndex = `column-${menus[key].meta?.id}`
if (columnIndex == index) {
return menus[key].meta?.menu_type == 'link'
}
if (menus[key].children?.length) {
return isExternalLink(menus[key].children!, index)
}
}
return false
}
/**
* 递归的搜索菜单 Index
*/
const searchMenuIndex = (menus: RouteRecordRaw[], route: RouteLocationNormalizedLoaded): number | false => {
let find: boolean | number = false
for (const key in menus) {
if (menus[key].meta?.id && menus[key].path == route.fullPath) {
return menus[key].meta.id as number
}
if (menus[key].children && menus[key].children?.length) {
find = searchMenuIndex(menus[key].children, route)
if (find !== false) return find
}
}
return find
}
/**
* 从动态菜单(顶栏、会员中心下拉、会员中心菜单)中搜索一个菜单
*/
const findMenus = (route: RouteLocationNormalizedLoaded) => {
// 顶栏菜单
const headNavIndex = searchMenuIndex(siteConfig.headNav, route)
if (headNavIndex !== false) return headNavIndex
// 会员中心下拉菜单
const navUserMenuIndex = searchMenuIndex(memberCenter.state.navUserMenus, route)
if (navUserMenuIndex !== false) return navUserMenuIndex
// 会员中心菜单
return searchMenuIndex(memberCenter.state.viewRoutes, route)
}
setActiveMenu(route)
onBeforeRouteUpdate((to) => {
setActiveMenu(to)
})
</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>

View File

@@ -0,0 +1,59 @@
<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.meta?.id}`">
<template #title>
<Icon v-if="showIcon" :name="item.meta?.icon" color="var(--el-text-color-primary)" />
{{ item.meta?.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.meta?.id" :class="(item.name as string).replace(/[\/]/g, '-')">
<Icon v-if="showIcon" :name="item.meta?.icon" color="var(--el-text-color-primary)" />
<template #title>{{ item.meta?.title }}</template>
</el-menu-item>
</template>
</template>
</template>
<script setup lang="ts">
import { isEmpty } from 'lodash-es'
import { onClickMenu } from '/@/utils/router'
import type { RouteRecordRaw } from 'vue-router'
import MenuSub from '/@/layouts/frontend/components/menuSub.vue'
interface Props {
menus: RouteRecordRaw[]
showIcon?: boolean
}
const props = withDefaults(defineProps<Props>(), {
menus: () => [],
showIcon: false,
})
const onClickSubMenu = (menu: RouteRecordRaw) => {
/**
* 1、'/'表示菜单规则的 path 为空
* 2、会员中心菜单目录不需要跳转
*/
if (menu.path == '/' || menu.meta?.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>

View File

@@ -0,0 +1,35 @@
<template>
<el-container class="is-vertical">
<Header />
<el-scrollbar :style="layoutMainScrollbarStyle" ref="layoutMainScrollbarRef">
<el-row class="frontend-footer-brother" justify="center">
<el-col class="user-layouts" :span="16" :xs="24">
<Aside class="hidden-sm-and-down" />
<Main />
</el-col>
</el-row>
<Footer />
</el-scrollbar>
</el-container>
</template>
<script setup lang="ts">
import Header from '/@/layouts/frontend/components/header.vue'
import Aside from '/@/layouts/frontend/components/aside.vue'
import Main from '/@/layouts/frontend/components/main.vue'
import Footer from '/@/layouts/frontend/components/footer.vue'
import { layoutMainScrollbarRef, layoutMainScrollbarStyle } from '/@/stores/refs'
</script>
<style scoped lang="scss">
.user-layouts {
display: flex;
padding-top: 15px;
align-items: flex-start;
}
@media screen and (max-width: 768px) {
.user-layouts {
padding-top: 0;
}
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<el-container class="is-vertical">
<Header />
<el-scrollbar :style="layoutMainScrollbarStyle" ref="layoutMainScrollbarRef">
<el-row class="frontend-footer-brother" justify="center">
<el-col class="user-layouts" :span="16" :xs="24">
<el-alert :center="true" :title="$t('Member center disabled')" type="error" />
</el-col>
</el-row>
<Footer />
</el-scrollbar>
</el-container>
</template>
<script setup lang="ts">
import Header from '/@/layouts/frontend/components/header.vue'
import Footer from '/@/layouts/frontend/components/footer.vue'
import { layoutMainScrollbarRef, layoutMainScrollbarStyle } from '/@/stores/refs'
</script>
<style scoped lang="scss">
.user-layouts {
display: flex;
padding-top: 15px;
align-items: flex-start;
}
@media screen and (max-width: 768px) {
.user-layouts {
padding-top: 0;
}
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<component :is="memberCenter.state.layoutMode"></component>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useUserInfo } from '/@/stores/userInfo'
import { useSiteConfig } from '/@/stores/siteConfig'
import { useMemberCenter } from '/@/stores/memberCenter'
import { initialize } from '/@/api/frontend/index'
import { getFirstRoute, routePush } from '/@/utils/router'
import { memberCenterBaseRoutePath } from '/@/router/static/memberCenterBase'
import { useRoute, useRouter } from 'vue-router'
import Default from '/@/layouts/frontend/container/default.vue'
import Disable from '/@/layouts/frontend/container/disable.vue'
import { ElNotification } from 'element-plus'
import { useI18n } from 'vue-i18n'
import userMounted from '/@/components/mixins/userMounted'
import { isEmpty } from 'lodash-es'
defineOptions({
components: { Default, Disable },
})
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const userInfo = useUserInfo()
const siteConfig = useSiteConfig()
const memberCenter = useMemberCenter()
onMounted(async () => {
const ret = await userMounted()
if (ret.type == 'break') return
if (ret.type == 'reload') return (window.location.href = ret.url)
if (!userInfo.token) return router.push({ name: 'userLogin' })
/**
* 会员中心初始化请求,获取会员中心菜单信息等
*/
const callback = () => {
if (ret.type == 'jump') return router.push(ret.url)
// 预跳转到上次路径
if (route.params.to) {
const lastRoute = JSON.parse(route.params.to as string)
if (lastRoute.path != memberCenterBaseRoutePath) {
let query = !isEmpty(lastRoute.query) ? lastRoute.query : {}
routePush({ path: lastRoute.path, query: query })
return
}
}
// 跳转到第一个菜单
if (route.name == 'userMainLoading') {
let firstRoute = getFirstRoute(memberCenter.state.viewRoutes)
if (firstRoute) {
router.push({ path: firstRoute.path })
} else {
ElNotification({
type: 'error',
message: t('No route found to jump~'),
})
}
}
}
if (siteConfig.userInitialize) {
callback()
} else {
initialize(callback, true)
}
if (document.body.clientWidth < 1024) {
memberCenter.setShrink(true)
} else {
memberCenter.setShrink(false)
}
})
</script>
<style scoped lang="scss"></style>