🧹 清理重复配置文件

- 删除根目录中重复的 NestJS 配置文件
- 删除 tsconfig.json, tsconfig.build.json, eslint.config.mjs, .prettierrc
- 保留 wwjcloud-nest/ 目录中的完整配置
- 避免配置冲突,确保项目结构清晰
This commit is contained in:
wanwu
2025-10-14 23:56:20 +08:00
parent 7a160dd04b
commit e7a1d6b4d6
3263 changed files with 356 additions and 112679 deletions

View File

@@ -0,0 +1,314 @@
<template>
<div :class="['layout-aside ease-in duration-200 flex box-border', { 'bright': !dark}]">
<div class="flex flex-col border-0 border-r-[1px] border-solid border-[var(--el-color-info-light-8)] box-border overflow-hidden">
<div :class="['w-[150px] one-menu hide-scrollbar', { 'expanded': systemStore.menuIsCollapse }]" >
<!-- <div class="flex items-center justify-center bg-primary text-white h-[40px] text-[16px] rounded-[4px] mb-[10px]"><span class="text-[20px]">+</span><span v-if="systemStore.menuIsCollapse" class="ml-[10px]">应用市场</span></div> -->
<div class="flex flex-col items-center">
<template v-for="(item, index) in oneMenuData">
<div v-if="item.meta.show" :title="systemStore.menuIsCollapse ? item.meta.title : item.meta.short_title" class="menu-item my-[2px] p-2 flex w-full box-border cursor-pointer relative" :class="{'is-active':oneMenuActive===item.original_name,'hover-left': systemStore.menuIsCollapse, 'vertical': !systemStore.menuIsCollapse , 'horizontal': systemStore.menuIsCollapse }" :style="{ height: (systemStore.menuIsCollapse ) ? '40px' : '55px' }" @click="router.push({ name: item.name })">
<div class="w-[20px] h-[20px] flex items-center justify-center menu-icon" :class="{'is-active':oneMenuActive===item.original_name}">
<template v-if="item.meta.icon">
<el-image class="w-[20px] h-[20px] overflow-hidden" :src="item.meta.icon" fit="fill" v-if="isUrl(item.meta.icon)"/>
<icon :name="item.meta.icon" size="20px" color="#1D1F3A" v-else />
</template>
<icon v-else :name="'iconfont iconshezhi1'" color="#1D1F3A" />
</div>
<div v-if="systemStore.menuIsCollapse" class="text-left text-[14px] mt-[3px] w-[75px] using-hidden ml-[10px]">{{ item.meta.title || item.meta.short_title }}</div>
<div v-else class="text-center text-[12px] using-hidden mt-1">{{ item.meta.short_title || item.meta.title }}</div>
<div v-if="systemStore.menuIsCollapse && item.name=='app_store' && recentlyUpdated.length>0" class="text-[11px] bg-[#DA203E] px-[10px] rounded-[12px] text-[#fff] absolute right-[6px]">更新</div>
<div v-if="!systemStore.menuIsCollapse && item.name=='app_store' && recentlyUpdated.length>0" class="w-[7px] h-[7px] bg-[#DA203E] absolute flex items-center justify-center rounded-full top-[4px] right-[14px]"></div>
<div v-if="systemStore.menuIsCollapse && item.original_name=='tool' && isNewVersion" class="text-[11px] bg-[#DA203E] px-[10px] rounded-[12px] text-[#fff] absolute right-[6px]">更新</div>
<div v-if="!systemStore.menuIsCollapse && item.original_name=='tool' && isNewVersion" class="w-[7px] h-[7px] bg-[#DA203E] absolute flex items-center justify-center rounded-full top-[4px] right-[14px]"></div>
</div>
</template>
</div>
</div>
</div>
<div class="flex flex-col two-menu w-[185px] " v-if="twoMenuData.length">
<!-- <div class="w-[185px] h-[64px] flex items-center justify-center text-[16px]">{{ route.matched[1].meta.title }}</div> -->
<el-scrollbar class="flex-1" >
<el-menu :default-active="route.name" :default-openeds="defaultOpeneds" :router="true" class="aside-menu">
<menu-item v-for="(route, index) in twoMenuData" :routes="route" :key="index" :isNewVersion="isNewVersion" />
</el-menu>
<div class="h-[48px]"></div>
</el-scrollbar>
</div>
</div>
</template>
<script lang="ts" setup>
import { watch, ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import useSystemStore from '@/stores/modules/system'
import useUserStore from '@/stores/modules/user'
import { ADMIN_ROUTE,findFirstValidRoute } from "@/router/routers"
import { isUrl } from '@/utils/common'
import menuItem from './menu-item.vue'
import { getAddonLocal} from '@/app/api/addon'
import { getVersions } from "@/app/api/auth"
import { getFrameworkVersionList } from "@/app/api/module"
const route = useRoute()
const userStore = useUserStore()
const routers = userStore.routers
const systemStore = useSystemStore()
const router = useRouter()
const dark = computed(() => {
return systemStore.dark
})
const logoUrl = computed(() => {
return userStore.siteInfo.icon ? userStore.siteInfo.icon : systemStore.website.icon
})
const twoMenuData = ref<Record<string, any>[]>([])
const defaultOpeneds = ref<string[]>([]) // 默认打开的菜单项路径数组
const oneMenuData = ref<Record<string, any>[]>([])
routers.forEach(item => {
item.original_name = item.name
if (item.children && item.children.length) {
item.name = findFirstValidRoute(item.children)
}
oneMenuData.value.push(item)
})
const oneMenuActive = ref(oneMenuData.value[0].name)
watch(route, () => {
twoMenuData.value = route.matched[1].children ?? []
oneMenuActive.value = route.matched[1].name == ADMIN_ROUTE.children[0].name ? route.matched[2].name : route.matched[1].name
defaultOpeneds.value = twoMenuData.value.map(item => item.name)
}, { immediate: true })
const recentlyUpdated = ref([])
const localListFn = () => {
getAddonLocal({}).then((res) => {
const data = res.data.list
recentlyUpdated.value = []
for (const i in data) {
if (data[i].install_info && Object.keys(data[i].install_info)?.length) {
if (data[i].install_info.version != data[i].version) {
recentlyUpdated.value.push(data[i])
}
}
}
}).catch(() => {
})
}
localListFn()
const frameworkVersionList = ref([])
const isNewVersion = computed(() => {
if (!newVersion.value || newVersion.value.version_no === version.value) {
return false;
}
// 将版本号转为字符串再处理
const currentVersionStr = String(version.value);
const latestVersionStr = String(newVersion.value.version_no);
// 移除点号并转为数字比较
const currentVersionNum = parseInt(currentVersionStr.replace(/\./g, ''), 10);
const latestVersionNum = parseInt(latestVersionStr.replace(/\./g, ''), 10);
return latestVersionNum > currentVersionNum;
})
const getFrameworkVersionListFn = () => {
getFrameworkVersionList().then(({ data }) => {
frameworkVersionList.value = data
}).catch(() => {
})
}
getFrameworkVersionListFn()
const newVersion: any = computed(() => {
return frameworkVersionList.value.length ? frameworkVersionList.value[0] : null
})
const version = ref('')
const getVersionsInfo = () => {
getVersions().then((res) => {
version.value = res.data.version.version
})
}
getVersionsInfo()
</script>
<style lang="scss">
.one-menu{
padding: 20px 10px 10px;
width: 78px;
overflow-y: auto;
// transition: width 0.1s ease-out;
&.expanded {
width: 185px;
padding: 18px 15px 15px;
}
.menu-item{
border-radius: 2px;
justify-content: center;
&.vertical {
width: 55px;
height: 55px;
flex-direction: column;
align-items: center;
}
&.horizontal {
flex-direction: row;
align-items: center;
}
.menu-icon {
// background-color: transparent; /* 默认无背景色 */
color: #1D1F3A;
}
// .menu-icon.is-active {
// background-color: var(--el-color-primary); /* 选中时背景色 */
// color: white; /* 选中时图标颜色变白 */
// border-radius: 4px; /* 可选:使图标背景为圆形 */
// }
&:hover{
background-color: #EAEBF0 !important;
border-radius: 6px;
// background-color: var(--el-color-primary-light-9) !important;
// color:var(--el-color-primary);
}
&.is-active{
background-color: #EAEBF0 !important;
border-radius: 6px;
// background-color: var(--el-color-primary-light-9) !important;
// border: none;
// color:var(--el-color-primary);
}
span{
font-size: 14px;
margin-left: 8px;
}
}
.menu-item.hover-left {
justify-content: flex-start;
padding-left: 5px;
}
&.expanded .menu-item .text-center {
opacity: 1;
}
.el-menu{
border: 0;
}
.el-scrollbar{
height: calc(100vh - 65px);
}
}
.two-menu{
.aside-menu:not(.el-menu--collapse) {
width: 185px;
border: 0;
padding-top: 15px;
.el-menu-item{
height: 40px;
margin: 4px 15px;
padding: 0 8px !important;
border-radius: 2px;
span{
margin-left: 8px;
font-size: 14px;
}
&.is-active{
background-color: #EAEBF0 !important;
border-radius: 6px;
color: inherit;
// background-color: var(--el-color-primary-light-9) !important;
}
&:hover{
background-color: #EAEBF0 !important;
border-radius: 6px;
// background-color: var(--el-color-primary-light-9) !important;
// color: var(--el-color-primary);
}
}
.el-sub-menu{
width: 185px;
margin: 4px 0;
// margin-bottom: 8px;
.el-sub-menu__title{
margin: 0 15px;
height: 40px;
padding-left: 8px;
border-radius: 2px;
span{
height: 40px;
display: flex;
align-items: center;
font-size: 14px;
}
&:hover{
background-color:#EAEBF0 !important;
border-radius: 6px;
// background-color: var(--el-color-primary-light-9) !important;
// color: var(--el-color-primary);
}
.el-icon.el-sub-menu__icon-arrow{
right: 5px;
}
}
.el-menu-item{
padding-left: 25px !important;
}
}
}
}
.logo-wrap {
padding: 0;
display: flex;
white-space: nowrap;
align-items: center;
.logo {
height: 100%;
box-sizing: border-box;
}
.logo-title {
flex: 1;
width: 0;
text-overflow: ellipsis;
overflow: hidden;
font-size: var(--el-font-size-base);
}
}
// :deep(.el-scrollbar__bar){
// display: none !important;
// }
// .layout-aside .el-scrollbar__wrap--hidden-default, .layout-aside .el-scrollbar{
// overflow: inherit !important;
// }
// 隐藏滚动条
.hide-scrollbar::-webkit-scrollbar {
display: none;
/* Chrome/Safari/Edge */
}
.hide-scrollbar {
-ms-overflow-style: none;
/* IE/Edge */
scrollbar-width: none;
/* Firefox */
}
// .layout-aside .menu-item.is-active{
// position: relative;
// &:after{
// content: "";
// position: absolute;
// top: 0;
// bottom: 0;
// width: 1px;
// background: var(--el-color-primary);
// right: -1px;
// }
// }
</style>

View File

@@ -0,0 +1,80 @@
<template>
<template v-if="meta.show">
<el-sub-menu v-if="routes.children" :index="String(routes.name)">
<template #title>
<div v-if="meta.icon && props.level != 2" class="w-[13px] h-[13px] mr-[10rpx] relative flex justify-center items-center">
<icon v-if="meta.icon && props.level != 2" :name="meta.icon" color="#1D1F3A" class="absolute !w-auto" />
</div>
<span class="using-hidden" :class="['ml-[10px]', {'text-[15px]': routes.meta.class == 1}, {'text-[14px]': routes.meta.class != 1}]">{{ meta.title }}</span>
</template>
<menu-item v-for="(route, index) in routes.children" :routes="route" :level="props.level + 1" :key="index" :isNewVersion="props.isNewVersion" />
</el-sub-menu>
<el-menu-item v-else :index="String(routes.name)" :route="routes.path">
<template #title>
<div v-if="meta.icon && props.level != 2" class="w-[13px] h-[13px] mr-[10rpx] relative flex justify-center items-center">
<icon v-if="meta.icon && props.level != 2" color="#1D1F3A" :name="meta.icon" class="absolute !w-auto" />
</div>
<span class="using-hidden" :class="[{'text-[15px]': routes.meta.class == 1}, {'text-[14px]': routes.meta.class != 1}, {'ml-[10px]': routes.meta.class == 2, 'ml-[15px]': routes.meta.class == 3}]">{{ meta.title }}
<div v-if="meta.view=='app/upgrade'&& props.isNewVersion" class="w-[7px] h-[7px] bg-[#DA203E] absolute flex items-center justify-center rounded-full top-[10px] right-[65px]"></div>
</span>
</template>
</el-menu-item>
</template>
</template>
<script lang="ts" setup>
import { t } from '@/lang'
import { ref, computed } from 'vue'
import menuItem from './menu-item.vue'
import { CollectionTag } from '@element-plus/icons-vue'
const props = defineProps({
routes: {
type: Object,
required: true
},
level: {
type: Number,
default: 1
},
isNewVersion: {
type: Boolean,
default: false
}
})
const meta = computed(() => props.routes.meta)
</script>
<style lang="scss">
.el-sub-menu{
.el-icon{
width: auto;
}
li{
font-size: 15px;
}
}
.el-alert .el-alert__description{
margin-top: 0;
}
.index-item {
border: 1px solid;
border-color: var(--el-color-primary);
&:hover {
color: #fff;
background-color: var(--el-color-primary);
}
}
</style>
<style scoped>
.using-hidden {
word-break: break-all;
text-overflow: ellipsis;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,141 @@
<template>
<el-container class="w-100" :class="[{ 'sidebar-dark-mode': systemStore.sidebar == 'twoType' }, { 'sidebar-brightness-mode': systemStore.sidebar == 'oneType' }]">
<el-main class="menu-wrap">
<el-scrollbar>
<el-menu :default-active="menuActive" :router="true" class="aside-menu h-full" :unique-opened="true" :collapse="systemStore.menuIsCollapse">
<menu-item v-for="(route, index) in userStore.routers[0].children" :routes="route" :key="index" />
</el-menu>
<div class="h-[48px]"></div>
</el-scrollbar>
</el-main>
</el-container>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import useSystemStore from '@/stores/modules/system'
import useUserStore from '@/stores/modules/user'
import menuItem from './menu-item.vue'
const logo = ref('@/app/assets/images/login_logo.png')
const systemStore = useSystemStore()
const userStore = useUserStore()
const route = useRoute()
const router = useRouter()
const siteInfo = userStore.siteInfo
const menuActive = computed(() => String(route.name))
userStore.routers = userStore.routers.filter((item, index) => {
if (item.name == 'setting_manage') {
// item.meta.class = 1
if (item.children) {
item.children.forEach((subItem, subIndex) => {
subItem.meta.class = 1
if (subItem.children) {
subItem.children.forEach((threeItem, threeIndex) => {
threeItem.meta.class = 2
})
}
})
}
return item.children
}
})
// userStore.routers.forEach((item, index) => {
// item.meta.class = 1
// if (item.children) {
// item.children.forEach((subItem, subIndex) => {
// subItem.meta.class = 2
// if (subItem.children) {
// subItem.children.forEach((threeItem, threeIndex) => {
// threeItem.meta.class = 3
// })
// }
// })
// }
// })
</script>
<style lang="scss">
.logo-wrap {
padding: 0;
display: flex;
white-space: nowrap;
align-items: center;
.logo {
height: 100%;
box-sizing: border-box;
}
.logo-title {
flex: 1;
width: 0;
text-overflow: ellipsis;
overflow: hidden;
font-size: var(--el-font-size-base);
}
}
.menu-wrap {
flex: 1 !important;
padding: 0 !important;
.el-menu {
border-right: 0 !important;
}
}
.sidebar-dark-mode {
background-color: #191a23;
&>.logo-wrap {
.logo>i {
font-size: 20px;
}
border-bottom: 2px solid #101117;
}
.el-menu {
background-color: #191a23;
.el-sub-menu {
background: transparent !important;
}
.el-sub-menu__title,
.el-menu-item {
background: transparent !important;
color: #B7B7ba;
&:hover {
background-color: transparent !important;
color: #fff !important;
}
}
.el-menu-item.is-active {
color: #fff !important;
background-color: var(--el-color-primary) !important;
}
li::after {
content: "";
width: 0;
height: 0;
}
}
}
.sidebar-brightness-mode {
&>.logo-wrap {
.logo>i {
font-size: 20px;
}
}
}
</style>

View File

@@ -0,0 +1,200 @@
<template>
<el-container class="h-[64px] w-full layout-admin flex items-center justify-between pr-[15px] border-b-[1px] border-solid border-[var(--el-color-info-light-8)]" >
<!-- :class="['h-full px-[10px]',{'layout-header border-b border-color': !dark}]" -->
<div class="flex items-center">
<!-- <div class="navbar-item flex items-center h-full cursor-pointer" @click="toggleMenuCollapse">
<icon name="element Expand" v-if="systemStore.menuIsCollapse" />
<icon name="element Fold" v-else />
</div> -->
<div class="flex justify-center items-center flex-shrink-0" :class="{'w-[185px]': systemStore.menuIsCollapse,'w-[78px]': !systemStore.menuIsCollapse}">
<div class="w-full h-[40px] overflow-hidden">
<el-image style="width: 100%; height: 100%" :src="img(logoUrl)" fit="contain" v-if="!systemStore.menuIsCollapse">
<template #error>
<div class="flex justify-center items-center w-full h-full"><img class="max-w-[70px]" src="@/app/assets/images/logo.default.png" alt="" object-fit="contain"></div>
</template>
</el-image>
<el-image style="width: 100%; height: 100%" :src="img(longLogoUrl)" fit="contain" v-else>
<template #error>
<div class="flex justify-center items-center w-full h-full"><img class="max-w-[180px]" src="@/app/assets/images/logo.default.png" alt="" object-fit="contain"></div>
</template>
</el-image>
</div>
</div>
<div class="left-panel flex items-center text-[14px] leading-[1]">
<div class="navbar-item flex items-center h-full cursor-pointer" @click="toggleMenuCollapse">
<icon name="element Fold" v-if="systemStore.menuIsCollapse" />
<icon name="element Expand" v-else />
</div>
<!-- 刷新当前页 -->
<div class="navbar-item flex items-center h-full cursor-pointer" @click="refreshRouter">
<icon name="element Refresh" />
</div>
<!-- 面包屑导航 -->
<div class="flex items-center h-full pl-[10px] hidden-xs-only">
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="(route, index) in breadcrumb" :key="index" :to="route.path" class="inter">{{route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
</div>
</div>
<!-- <div>
<el-input placeholder="搜索站点或应用" v-model.trim="keywords" />
</div> -->
<div>
<div class="right-panel h-full flex items-center justify-end">
<div class="border-primary border-[1px] h-[30px] px-[15px] flex items-center rounded-[6px] mr-[10px] cursor-pointer" @click="toHome()">
<span class="iconfont iconguanliduan !text-primary mr-1"></span>
<span class="text-[14px] text-primary">客户端</span>
</div>
<div class="navbar-item flex items-center h-full cursor-pointer">
<message />
</div>
<div class="navbar-item flex items-center h-full cursor-pointer">
<layout-setting />
</div>
<!-- 用户信息 -->
<div class="navbar-item flex items-center h-full cursor-pointer">
<user-info />
</div>
</div>
</div>
<input type="hidden" v-model="comparisonToken" />
<input type="hidden" v-model="comparisonSiteId" />
<el-dialog v-model="detectionLoginDialog" :title="t('layout.detectionLoginTip')" width="30%" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
<span>{{ t('layout.detectionLoginContent') }}</span>
<template #footer>
<span class="dialog-footer">
<el-button @click="detectionLoginFn">{{ t('layout.detectionLoginOperation') }}</el-button>
</span>
</template>
</el-dialog>
<el-dialog v-model="showDialog" :title="t('indexTemplate')" width="550px" :destroy-on-close="true" >
<div class="flex flex-wrap">
<div v-for="(items, index) in indexList" :key="index" v-if="index_path == ''">
<div @click="index_path = items.view_path" class="index-item py-[5px] px-[10px] mr-[10px] rounded-[3px] cursor-pointer" :class="items.is_use == 1 ? 'bg-primary text-[#fff]' : '' ">
<span >{{ items.name }}</span>
</div>
</div>
<div v-for="(itemTo, indexTo) in indexList" :key="indexTo" v-else>
<div @click="index_path = itemTo.view_path" class="index-item py-[5px] px-[10px] mr-[10px] rounded-[3px] cursor-pointer" :class="index_path == itemTo.view_path ? 'bg-primary text-[#fff]' : '' ">
<span >{{ itemTo.name }}</span>
</div>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button type="primary" @click="submitIndex">{{ t('confirm') }}</el-button>
</span>
</template>
</el-dialog>
</el-container>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import useUserStore from '@/stores/modules/user'
import useAppStore from '@/stores/modules/app'
import useSystemStore from '@/stores/modules/system'
import { useRoute,useRouter } from 'vue-router'
import { img, getToken } from '@/utils/common'
import { t } from '@/lang'
import storage from '@/utils/storage'
import userInfo from './user-info.vue'
import layoutSetting from './layout-setting.vue'
import message from './message.vue'
const showDialog = ref<boolean>(false)
const systemStore = useSystemStore()
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const userStore = useUserStore()
const logoUrl = computed(() => {
return userStore.siteInfo.icon ? userStore.siteInfo.icon : systemStore.website.icon
})
const longLogoUrl = computed(() => {
return userStore.siteInfo.logo ? userStore.siteInfo.logo : systemStore.website.logo
})
// 检测登录 start
const detectionLoginDialog = ref(false)
const comparisonToken = ref('')
const comparisonSiteId = ref('')
if (storage.get('comparisonTokenStorage')) {
comparisonToken.value = storage.get('comparisonTokenStorage')
}
if (storage.get('comparisonSiteIdStorage')) {
comparisonSiteId.value = storage.get('comparisonSiteIdStorage')
}
// 监听标签页面切换
document.addEventListener('visibilitychange', e => {
if (document.visibilityState === 'visible' && (comparisonSiteId.value != storage.get('siteId') || comparisonToken.value != storage.get('token'))) {
detectionLoginDialog.value = true
}
})
const detectionLoginFn = () => {
detectionLoginDialog.value = false
location.reload()
}
// 检测登录 end
// 刷新路由
const refreshRouter = () => {
if (!appStore.routeRefreshTag) return
appStore.refreshRouterView()
}
// 面包屑导航
const breadcrumb = computed(() => {
const matched = route.matched.filter(item => { return item.meta.title })
if (matched[0] && matched[0].path == '/') matched.splice(0, 1)
return matched
})
storage.set({ key: 'currHeadMenuName', data: "" })
systemStore.toggleMenuCollapse(storage.get('menuiscollapse') || false)
const toggleMenuCollapse = () => {
systemStore.toggleMenuCollapse(!systemStore.menuIsCollapse)
}
const toHome = () => {
if (!window.localStorage.getItem('site.token')) {
window.localStorage.setItem('site.token', getToken())
window.localStorage.setItem('site.comparisonTokenStorage', getToken())
}
if (!window.localStorage.getItem('site.userinfo')) {
window.localStorage.setItem('site.userinfo', JSON.stringify(useUserStore().userInfo))
}
router.push({ path: '/home/index'})
}
</script>
<style lang="scss" scoped>
.layout-header{
position: relative;
z-index: 5;
border-bottom: 1px solid #e8e9eb;
}
.navbar-item {
padding: 0 8px;
}
.index-item {
border: 1px solid;
border-color: var(--el-color-primary);
&:hover {
color: #fff;
background-color: var(--el-color-primary);
}
}
// :deep(.el-input__wrapper) {
// box-shadow: none !important;
// border-radius: 4px !important;
// background: #F7F7FA !important;
// min-width: 638px;
// height: 40px;
// border-radius: 4px !important;
// }
</style>

View File

@@ -0,0 +1,104 @@
<template>
<div class="flex">
<icon name="element Setting" @click="drawer = true" />
<el-drawer v-model="drawer" :title="t('layout.layoutSetting')" size="300px">
<el-scrollbar>
<!-- 黑暗模式 -->
<div class="setting-item flex items-center justify-between mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.darkMode') }}</div>
<div>
<el-switch v-model="dark" :active-value="true" :inactive-value="false" />
</div>
</div>
<!-- 主题颜色 -->
<div class="setting-item flex items-center justify-between mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.themeColor') }}</div>
<div>
<el-color-picker v-model="theme" />
</div>
</div>
<!-- 布局风格 -->
<div class="setting-item mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.layoutStyle') }}</div>
<div class="flex mt-[10px] layout-style flex-wrap">
<div class="relative w-[125px] h-[100px] border mr-[10px] mb-[10px] hover:border-primary"
:class="{ 'border-primary': currLayout == item.key }" v-for="(item, index) in layouts"
@click="handleSetLayout(item.key)">
<img :src="item.image" alt="" class="w-full h-full">
</div>
</div>
</div>
</el-scrollbar>
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import useSystemStore from '@/stores/modules/system'
import { useDark, useToggle } from '@vueuse/core'
import { setThemeColor, img } from '@/utils/common'
import { t } from '@/lang'
import Storage from '@/utils/storage'
const layouts = ref([
{ key: 'admin', image: img('static/resource/images/system/layout_bussiness.png') },
{ key: 'admin_simplicity', image: img('static/resource/images/system/layout_darkside.png') }
])
const currLayout = ref(Storage.get('admin_layout') || 'admin')
const drawer = ref(false)
const systemStore = useSystemStore()
const isDark = useDark()
const toggleDark = useToggle(isDark)
const dark = computed({
get() {
return systemStore.dark
},
set(val) {
systemStore.setTheme('dark', val)
toggleDark(val)
setThemeColor(systemStore.theme, systemStore.dark ? 'dark' : 'light')
}
})
const sidebar = computed({
get() {
return systemStore.sidebar
},
set(val) {
systemStore.setTheme('sidebar', val)
setThemeColor(systemStore.theme, systemStore.dark ? 'dark' : 'light')
}
})
const theme = computed({
get() {
return systemStore.theme
},
set(val) {
systemStore.setTheme('theme', val)
setThemeColor(systemStore.theme, systemStore.dark ? 'dark' : 'light')
}
})
const handleSetLayout = (key: string) => {
Storage.set({ key: 'admin_layout', data: key })
location.reload()
}
</script>
<style lang="scss" scoped>
:deep(.el-drawer__header) {
margin-bottom: 0 !important;
}
.layout-style {
&>div:nth-child(2n+2) {
margin-right: 0;
}
}
</style>

View File

@@ -0,0 +1,64 @@
<template>
<el-popover class="box-item" :width="322">
<template #reference>
<div class="relative">
<icon name="iconfont iconFramec-1" />
<span v-if="showRedDot" class="absolute top-[-3px] right-[-5px] w-[12px] h-[12px] rounded-full bg-[#DA203E] text-white text-[12px] flex justify-center items-center">1</span>
</div>
</template>
<div class="flex items-center bg-[#F8FAFF] p-[10px] rounded-[8px]" v-if="showRedDot">
<div class="w-[36px] h-[36px] rounded-full flex justify-center items-center">
<img src="@/app/assets/images/index/cloud.png" alt="" class="w-[36px] h-[36px]" />
</div>
<div class="py-[3px] ml-[5px] flex-1">
<div class="text-[16px] font-bold text-[#1D1F3A] mb-[5px]">云编译</div>
<div class="text-[12px] text-[#4F516D] flex justify-between items-center">
<span>有正在执行的编译任务</span>
<span class="text-primary cursor-pointer ml-auto" @click="cloudBuildRef?.elNotificationClick()">点击查看</span>
</div>
</div>
</div>
<div class="flex items-center justify-center" v-else>
<img src="@/app/assets/images/index/message_empty.png" alt="">
</div>
</el-popover>
<!-- <i class="iconfont iconFramec-1 cursor-pointer" title="消息" v-else></i> -->
<cloud-build ref="cloudBuildRef"/>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import CloudBuild from "@/app/components/cloud-build/index.vue"
const cloudBuildRef = ref<any>(null)
const showRedDot = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
const startPolling = () => {
if (pollTimer) return
cloudBuildRef.value?.getCloudBuildTaskFn()
pollTimer = setInterval(() => {
const startTime = localStorage.getItem('cloud_build_task')
showRedDot.value = !!startTime
if (!startTime) {
stopPolling()
}
}, 1000)
}
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
startPolling()
</script>
<style lang="scss" scoped>
:deep(.box-item .el-popover.el-popper){
padding: 13px 16px !important;
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<el-dropdown @command="switchLang" :tabindex="1">
<icon name="iconfont iconfanyi" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="zh-cn" :disabled="systemStore.lang == 'zh-cn'">简体中文</el-dropdown-item>
<el-dropdown-item command="en" :disabled="systemStore.lang == 'en'">English</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script lang="ts" setup>
import useSystemStore from '@/stores/modules/system'
import { language } from '@/lang'
import { useRoute } from 'vue-router'
import storage from '@/utils/storage'
const route = useRoute()
const systemStore = useSystemStore()
const switchLang = (command: string) => {
systemStore.$patch((state) => {
state.lang = command
storage.set({ key: 'lang', data: command })
})
language.loadLocaleMessages(route.path, systemStore.lang)
location.reload()
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,159 @@
<template>
<div>
<el-dropdown @command="clickEvent" :tabindex="1">
<div class="userinfo flex h-full items-center">
<el-avatar v-if="userStore.userInfo.head_img" :size="25" :icon="UserFilled" :src="img(userStore.userInfo.head_img)"/>
<img v-else src="@/app/assets/images/member_head.png" class="w-[25px] rounded-full" />
<div class="user-name pl-[8px]">{{ userStore.userInfo.username }}</div>
<icon name="element ArrowDown" class="ml-[5px]" />
</div>
<template #dropdown>
<div class="p-[10px]">
<div class="userinfo flex h-full items-center pb-[10px] border-b-[1px] border-solid border-[#e5e5e5]">
<el-avatar v-if="userStore.userInfo.head_img" :size="45" :icon="UserFilled" :src="img(userStore.userInfo.head_img)"/>
<img v-else src="@/app/assets/images/member_head.png" class="w-[45px] rounded-full" />
<div>
<div class="user-name pl-[8px] text-[14px]">{{ userStore.userInfo.username }}</div>
<div class="pl-[8px] text-[13px] text-[#9699B6]">个人中心</div>
</div>
</div>
<el-dropdown-menu>
<el-dropdown-item @click="getUserInfoFn" class="rounded-[4px]">
<!-- <router-link to="/user/center"> -->
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconshezhi1 ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">账号设置</span>
</div>
<!-- </router-link> -->
</el-dropdown-item>
<el-dropdown-item class="rounded-[4px]">
<router-link to="/tools/authorize">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconshouquanxinxi2 ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">授权信息</span>
</div>
</router-link>
</el-dropdown-item>
<el-dropdown-item @click="changePasswordDialog=true" class="rounded-[4px]">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconxiugai ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">修改密码</span>
</div>
</el-dropdown-item>
<el-dropdown-item @click="logout" class="rounded-[4px]">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont icontuichudenglu ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">退出登录</span>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</div>
</template>
</el-dropdown>
<el-dialog v-model="changePasswordDialog" width="450px" title="修改密码">
<div>
<el-form :model="saveInfo" label-width="90px" ref="formRef" :rules="formRules" class="page-form">
<el-form-item :label="t('originalPassword')" prop="original_password">
<el-input v-model="saveInfo.original_password" type="password" :placeholder="t('originalPasswordPlaceholder')" clearable class="input-width" maxlength="40" />
</el-form-item>
<el-form-item :label="t('newPassword')" prop="password">
<el-input v-model="saveInfo.password" type="password" :placeholder="t('passwordPlaceholder')" clearable class="input-width" maxlength="40" />
<div class="form-tip">{{t('passwordTip')}}</div>
</el-form-item>
<el-form-item :label="t('passwordCopy')" prop="password_copy">
<el-input v-model="saveInfo.password_copy" type="password" :placeholder="t('passwordPlaceholder')" clearable class="input-width" maxlength="40" />
</el-form-item>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="changePasswordDialog = false">{{t('cancel')}}</el-button>
<el-button type="primary" @click="submitForm(formRef)">{{t('save')}}</el-button>
</span>
</template>
</el-dialog>
<user-info-edit ref="userInfoEditRef" />
</div>
</template>
<script lang="ts" setup>
import { UserFilled } from '@element-plus/icons-vue'
import { reactive, ref } from 'vue'
import type { FormInstance, FormRules, ElNotification } from 'element-plus'
import useUserStore from '@/stores/modules/user'
import { setUserInfo } from '@/app/api/personal'
import { t } from '@/lang'
import { img } from '@/utils/common'
import userInfoEdit from '@/app/components/user-info-edit/index.vue'
const userStore = useUserStore()
const clickEvent = (command: string) => {
switch (command) {
case 'logout':
userStore.logout()
break
}
}
const logout = () => {
userStore.logout()
}
const userInfoEditRef = ref(null)
const getUserInfoFn = () => {
userInfoEditRef.value?.open()
}
// 修改密码 --- start
const changePasswordDialog = ref(false)
const formRef = ref<FormInstance>()
// 提交信息
const saveInfo = reactive({
original_password: '',
password: '',
password_copy: ''
})
// 表单验证规则
const formRules = reactive<FormRules>({
original_password: [
{ required: true, message: t('originalPasswordPlaceholder'), trigger: 'blur' }
],
password: [
{ required: true, message: t('passwordPlaceholder'), trigger: 'blur' }
],
password_copy: [
{ required: true, message: t('passwordPlaceholder'), trigger: 'blur' }
]
})
const submitForm = (formEl: FormInstance | undefined) => {
if (!formEl) return
formEl.validate((valid) => {
if (valid) {
let msg = ''
if (saveInfo.password && !saveInfo.original_password) msg = t('originalPasswordHint')
if (saveInfo.password && saveInfo.original_password && !saveInfo.password_copy) msg = t('newPasswordHint')
if (saveInfo.password && saveInfo.original_password && saveInfo.password_copy && saveInfo.password != saveInfo.password_copy) msg = t('doubleCipherHint')
if (msg) {
ElNotification({
type: 'error',
message: msg
})
return
}
setUserInfo(saveInfo).then((res: any) => {
changePasswordDialog.value = false
})
} else {
return false
}
})
}
</script>
<style lang="scss" scoped>
.el-popper .el-dropdown-menu{
width: 150px;
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<div class="tab-wrap w-full px-[16px]">
<el-tabs :closable="tabbarStore.tabLength > 1" :model-value="route.path" @tab-click="tabClick" @tab-remove="removeTab">
<el-tab-pane v-for="(tab, key, index) in tabbarStore.tabs" :name="tab.path" :key="index">
<template #label>
<el-dropdown trigger="contextmenu" placement="bottom-start">
<span :class="{ 'text-primary': route.path == tab.path }" class="tab-name">{{ tab.title }}</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item icon="Back" :disabled="index == 0" @click="closeLeft(tab.path)">{{t('tabs.closeLeft') }}</el-dropdown-item>
<el-dropdown-item icon="Right" :disabled="index == (tabbarStore.tabLength - 1)" @click="closeRight(tab.path)">{{t('tabs.closeRight') }}</el-dropdown-item>
<el-dropdown-item icon="Close" :disabled="tabbarStore.tabLength == 1" @click="closeOther(tab.path)">{{t('tabs.closeOther') }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script lang="ts" setup>
import { watch, onMounted } from 'vue'
import useTabbarStore from '@/stores/modules/tabbar'
import { useRoute, useRouter } from 'vue-router'
import { t } from '@/lang'
const tabbarStore = useTabbarStore()
const route = useRoute()
const router = useRouter()
onMounted(() => {
tabbarStore.addTab(route)
})
watch(route, (nval: any) => {
tabbarStore.addTab(nval)
})
/**
* 添加tab
* @param content
*/
const tabClick = (content: any) => {
const tabRoute = tabbarStore.tabs[content.props.name]
router.push({ path: tabRoute.path, query: tabRoute.query })
}
/**
* 移除tab
* @param content
*/
const removeTab = (content: any) => {
if (route.path == content) {
const tabs = Object.keys(tabbarStore.tabs)
router.push({ path: tabs[tabs.indexOf(content) - 1] })
}
tabbarStore.removeTab(content)
}
/**
* 关闭左侧
* @param path
*/
const closeLeft = (path: string) => {
const tabs = Object.keys(tabbarStore.tabs)
for (let i = tabs.indexOf(path) - 1; i >= 0; i--) {
delete tabbarStore.tabs[tabs[i]]
}
router.push({ path })
}
/**
* 关闭右侧
* @param path
*/
const closeRight = (path: string) => {
const tabs = Object.keys(tabbarStore.tabs)
for (let i = tabs.indexOf(path) + 1; i < tabs.length; i++) {
delete tabbarStore.tabs[tabs[i]]
}
router.push({ path })
}
/**
* 关闭其他
* @param path
*/
const closeOther = (path: string) => {
const tabs = Object.keys(tabbarStore.tabs)
tabs.forEach((key: string) => { key != path && delete tabbarStore.tabs[key] })
router.push({ path })
}
</script>
<style lang="scss" scoped>
:deep(.el-tabs) {
.el-tabs--border-card {
border: none;
}
.el-tabs__header {
margin: 0;
}
.el-tabs__nav-wrap {
margin-bottom: 0;
&::after {
display: none;
}
}
.el-tabs__content {
display: none;
}
.el-tabs__item {
display: inline-flex !important;
padding: 0 20px !important;
align-items: center;
.tab-name:focus {
outline: none !important;
}
}
.el-tabs__active-bar {
display: none;
}
.el-tabs__item.is-active {
background-color: var(--el-color-primary-light-9);
}
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<div class="flex w-full h-screen">
<el-container>
<!-- 顶部 -->
<el-header>
<layout-header></layout-header>
</el-header>
<!-- 顶部 end -->
<el-container :style="{height:'calc(100vh - 64px)'}">
<!-- 左侧边栏 -->
<layout-aside></layout-aside>
<!-- 左侧边栏 end -->
<!-- 主体 -->
<el-main class="h-full p-0 bg-page">
<el-scrollbar>
<div class="p-[15px]">
<router-view v-slot="{ Component, route }" v-if="appStore.routeRefreshTag">
<keep-alive :include="tabbarStore.tabNames">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</router-view>
</div>
</el-scrollbar>
</el-main>
<!-- 主体 end -->
</el-container>
</el-container>
</div>
</template>
<script lang="ts" setup>
import layoutHeader from './components/header/index.vue'
import layoutAside from './components/aside/index.vue'
import useAppStore from '@/stores/modules/app'
import useTabbarStore from '@/stores/modules/tabbar'
const appStore = useAppStore()
const tabbarStore = useTabbarStore()
</script>
<style lang="scss" scoped>
.bg-page {
background-color: #F7F7FA;
}
</style>

View File

@@ -0,0 +1,67 @@
<template>
<el-aside class="layout-aside dark w-auto">
<el-scrollbar>
<side class="hidden-xs-only slide" />
</el-scrollbar>
</el-aside>
</template>
<script lang="ts" setup>
import { watch, computed } from 'vue'
import { useRoute } from 'vue-router'
import side from './side.vue'
import useSystemStore from '@/stores/modules/system'
const systemStore = useSystemStore()
const dark = computed(() => {
return systemStore.dark
})
const route = useRoute()
watch(route, () => {
systemStore.$patch(state => {
state.menuDrawer = false
})
})
</script>
<style lang="scss">
.layout-aside {
//--side-dark-color: #141414;
//background-color: var(--side-dark-color, var(--el-bg-color));
&.bright {
// background-color: #F5F7F9;
li {
// background-color: #F5F7F9;
&.is-active:not(.is-opened) {
position: relative;
color: #333;
// background-color: #fff;
&::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 2px;
//background-color: var(--el-menu-active-color);
}
}
}
}
.slide {
border-right: 1px solid var(--el-border-color-extra-light);
}
}
.aside-drawer {
.el-drawer__body {
padding: 0 !important;
}
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<template v-if="meta.show">
<el-sub-menu v-if="meta.type == 0 && routes.children" :index="String(routes.name)">
<template #title>
<div class="flex h-full flex-col items-center justify-center" v-if="systemStore.menuIsCollapse && props.level == 1">
<icon v-if="meta.icon" :name="meta.icon" :size="'20px'" class="leading-none"/>
<div class="leading-none mt-[5px]">{{ meta.short_title || meta.title }}</div>
</div>
<template v-else>
<icon v-if="meta.icon && props.level != 1" :name="meta.icon" :size="props.level == 1 ? '20px' : '16px'" class="absolute" />
</template>
<span class="text-[14px]" :class="{ 'font-bold': props.level === 1 , ' ml-[20px]': (meta.icon && props.level != 1) }">{{ meta.title }}</span>
</template>
<menu-item v-for="(route, index) in routes.children" :routes="route" :key="index" :level="props.level + 1" />
</el-sub-menu>
<template v-else>
<el-menu-item :index="String(routes.name)" @click="router.push({ name: routes.name })" >
<div class="flex h-full flex-col items-center justify-center" v-if="systemStore.menuIsCollapse && props.level == 1">
<icon v-if="meta.icon" :name="meta.icon" :size="'20px'" class="leading-none"/>
<div class="leading-none mt-[5px]">{{ meta.short_title || meta.title }}</div>
</div>
<template v-else>
<icon v-if="meta.icon && props.level != 1" :name="meta.icon" :size="props.level == 1 ? '20px' : '16px'" class="absolute" />
</template>
<template #title>
<span class="text-[14px]" :class="{ 'font-bold': props.level === 1 , ' ml-[20px]': (meta.icon && props.level != 1) }">{{ meta.title }}</span>
</template>
</el-menu-item>
</template>
</template>
</template>
<script lang="ts" setup>
import { useRouter, useRoute } from 'vue-router'
import { ref, computed, watch } from 'vue'
import menuItem from './menu-item.vue'
import useSystemStore from '@/stores/modules/system'
import useUserStore from '@/stores/modules/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const routers = useUserStore().routers
const props = defineProps({
routes: {
type: Object,
required: true
},
level: {
type: Number,
default: 1
}
})
const systemStore = useSystemStore()
const meta = computed(() => props.routes.meta)
const addons = computed(() => {
const addons:Record<string, any> = {}
userStore.siteInfo?.apps.forEach((item: any) => { addons[item.key] = item })
userStore.siteInfo?.site_addons.forEach((item: any) => { addons[item.key] = item })
return addons
})
const systemAddonKeys = computed(() => {
return userStore.siteInfo?.site_addons.map((item: any) => item.key)
})
const addonRouters: Record<string, any> = {}
routers.forEach(item => {
item.original_name = item.name
if (item.meta.addon) {
addonRouters[item.meta.addon] = item
}
})
const addonsMenus = ref(null)
watch(route, () => {
if (props.routes.name != 'addon_list') return
if (systemAddonKeys.value.includes(route.meta.addon) && addonRouters[route.meta.addon]) {
addonsMenus.value = addonRouters[route.meta.addon]
} else {
addonsMenus.value = null
}
}, { immediate: true })
</script>
<style lang="scss">
.el-sub-menu{
.el-icon{
// width: auto;
}
}
.el-menu {
.el-sub-menu__title,
.el-menu-item {
&:hover {
// background-color: #F1F5FF !important;
color: var(--el-color-primary);
}
}
.el-icon.el-sub-menu__icon-arrow{
font-size: 15px;
top: 50%;
}
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<el-container class="flex flex-col">
<el-main class="menu-wrap">
<el-scrollbar class="menu-scrollbar flex-1 h-0">
<el-menu :default-active="route.name" :default-openeds="defaultOpeneds" :router="true" :unique-opened="false" :collapse="systemStore.menuIsCollapse">
<menu-item v-for="(route, index) in menuData" :routes="route" :key="index" />
</el-menu>
<div class="h-[48px]"></div>
</el-scrollbar>
</el-main>
</el-container>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import useSystemStore from '@/stores/modules/system'
import useUserStore from '@/stores/modules/user'
import menuItem from './menu-item.vue'
import { findFirstValidRoute } from '@/router/routers'
const systemStore = useSystemStore()
const userStore = useUserStore()
const route = useRoute()
const siteInfo = userStore.siteInfo
const routers = userStore.routers
const addonIndexRoute = userStore.addonIndexRoute
const menuData = ref<Record<string, any>[]>([])
const addonRouters: Record<string, any> = {}
const website = computed(() => {
return systemStore.website
})
// 默认打开的菜单项路径数组
const defaultOpeneds = ref<string[]>([])
// 在组件挂载后,生成默认打开的菜单项路径数组
onMounted(() => {
})
routers.forEach(item => {
item.original_name = item.name
if (item.meta.addon == '') {
if (item.children && item.children.length) {
item.name = findFirstValidRoute(item.children)
}
menuData.value.push(item)
} else if (item.meta.addon != '' && siteInfo?.apps.length == 1 && siteInfo?.apps[0].key == item.meta.addon) {
if (item.children) {
item.children.forEach((citem: Record<string, any>) => {
citem.original_name = citem.name
if (citem.children && citem.children.length) {
citem.name = findFirstValidRoute(citem.children)
}
})
menuData.value.unshift(...item.children)
} else {
menuData.value.unshift(item)
}
} else {
addonRouters[item.meta.addon] = item
}
})
// 多应用时将应用插入菜单
if (siteInfo?.apps.length > 1) {
const routers:Record<string, any>[] = []
siteInfo?.apps.forEach((item: Record<string, any>) => {
if (addonRouters[item.key]) {
addonRouters[item.key].name = addonIndexRoute[item.key]
routers.push(addonRouters[item.key])
}
})
menuData.value.unshift(...routers)
}
defaultOpeneds.value = menuData.value.map(item => item.name)
</script>
<style lang="scss">
.logo-wrap {
// background: #1f2531;
background: #fff;
transition: transform getCssVar('transition-duration');
}
:root{
--aside-width: 200px;
}
.menu-wrap {
padding: 0!important;
// background: #1f2531;
// background: #fff;
display: flex;
flex-direction: column;
.el-menu {
border-right: 0!important;
&:not(.el-menu--collapse) {
width: var(--aside-width);
}
.el-menu-item, .el-sub-menu__title {
--el-menu-item-height: 40px;
}
.el-sub-menu .el-menu-item {
--el-menu-sub-item-height: 40px;
}
.el-menu-item.is-active {
background: var(--el-color-primary) !important;
color: #fff !important;
}
&.el-menu--inline {
// background: #282e3a;
// background: #fff;
}
.el-menu-item .el-menu-tooltip__trigger{
display: flex;
justify-content: center;
align-items: center;
}
&.el-menu--collapse {
.el-menu-item, .el-sub-menu__title {
--el-menu-item-height: 60px;
justify-content: center;
}
}
}
}
</style>

View File

@@ -0,0 +1,177 @@
<template>
<el-container class="h-[64px] layout-admin flex items-center justify-between px-[15px]" >
<!-- :class="['h-full px-[10px]',{'layout-header border-b border-color': !dark}]" -->
<div class="flex items-center">
<div class="max-w-[120px] flex justify-center items-center flex-shrink-0">
<div class="max-w-[120px] h-[40px] overflow-hidden">
<el-image class="w-full h-full" :src="img(website.icon)" fit="contain">
<template #error>
<div class="flex justify-center items-center w-full h-full"><img class="max-w-[120px]" src="@/app/assets/images/logo.default.png" alt="" object-fit="contain"></div>
</template>
</el-image>
</div>
</div>
<div class="left-panel flex items-center text-[14px] leading-[1]">
<div class="navbar-item flex items-center h-full cursor-pointer" @click="toggleMenuCollapse">
<icon name="element Expand" v-if="systemStore.menuIsCollapse" />
<icon name="element Fold" v-else />
</div>
<!-- 刷新当前页 -->
<div class="navbar-item flex items-center h-full cursor-pointer" @click="refreshRouter">
<icon name="element Refresh" />
</div>
<!-- 面包屑导航 -->
<div class="flex items-center h-full pl-[10px] hidden-xs-only">
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="(route, index) in breadcrumb" :key="index" :to="route.path" class="inter">{{route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
</div>
</div>
<div>
<div class="right-panel h-full flex items-center justify-end">
<div class="border-primary border-[1px] h-[30px] px-[15px] flex items-center rounded-[6px] mr-[10px] cursor-pointer" @click="toHome()">
<span class="iconfont iconguanliduan !text-primary mr-1"></span>
<span class="text-[14px] text-primary">客户端</span>
</div>
<div class="navbar-item flex items-center h-full cursor-pointer">
<message />
</div>
<div class="navbar-item flex items-center h-full cursor-pointer">
<layout-setting />
</div>
<!-- 用户信息 -->
<div class="navbar-item flex items-center h-full cursor-pointer">
<user-info />
</div>
</div>
</div>
<input type="hidden" v-model="comparisonToken">
<input type="hidden" v-model="comparisonSiteId">
<el-dialog v-model="detectionLoginDialog" :title="t('layout.detectionLoginTip')" width="30%" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
<span>{{ t('layout.detectionLoginContent') }}</span>
<template #footer>
<span class="dialog-footer">
<el-button @click="detectionLoginFn">{{ t('layout.detectionLoginOperation') }}</el-button>
</span>
</template>
</el-dialog>
<el-dialog v-model="showDialog" :title="t('indexTemplate')" width="550px" :destroy-on-close="true" >
<div class="flex flex-wrap">
<div v-for="(items, index) in indexList" :key="index" v-if="index_path == ''">
<div @click="index_path = items.view_path" class="index-item py-[5px] px-[10px] mr-[10px] rounded-[3px] cursor-pointer" :class="items.is_use == 1 ? 'bg-primary text-[#fff]' : '' ">
<span >{{ items.name }}</span>
</div>
</div>
<div v-for="(itemTo, indexTo) in indexList" :key="indexTo" v-else>
<div @click="index_path = itemTo.view_path" class="index-item py-[5px] px-[10px] mr-[10px] rounded-[3px] cursor-pointer" :class="index_path == itemTo.view_path ? 'bg-primary text-[#fff]' : '' ">
<span >{{ itemTo.name }}</span>
</div>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button type="primary" @click="submitIndex">{{ t('confirm') }}</el-button>
</span>
</template>
</el-dialog>
</el-container>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import useUserStore from '@/stores/modules/user'
import useAppStore from '@/stores/modules/app'
import { useRoute,useRouter } from 'vue-router'
import { t } from '@/lang'
import { img, getToken } from '@/utils/common'
import storage from '@/utils/storage'
import userInfo from './user-info.vue'
import layoutSetting from './layout-setting.vue'
import message from './message.vue'
import useSystemStore from "@/stores/modules/system";
const showDialog = ref<boolean>(false)
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const systemStore = useSystemStore()
const website = computed(() => {
return systemStore.website
})
// 检测登录 start
const detectionLoginDialog = ref(false)
const comparisonToken = ref('')
const comparisonSiteId = ref('')
if (storage.get('comparisonTokenStorage')) {
comparisonToken.value = storage.get('comparisonTokenStorage')
}
if (storage.get('comparisonSiteIdStorage')) {
comparisonSiteId.value = storage.get('comparisonSiteIdStorage')
}
// 监听标签页面切换
document.addEventListener('visibilitychange', e => {
if (document.visibilityState === 'visible' && (comparisonSiteId.value != storage.get('siteId') || comparisonToken.value != storage.get('token'))) {
detectionLoginDialog.value = true
}
})
systemStore.toggleMenuCollapse(storage.get('menuiscollapse') || false)
const detectionLoginFn = () => {
detectionLoginDialog.value = false
location.reload()
}
// 检测登录 end
// 刷新路由
const refreshRouter = () => {
if (!appStore.routeRefreshTag) return
appStore.refreshRouterView()
}
// 面包屑导航
const breadcrumb = computed(() => {
const matched = route.matched.filter(item => { return item.meta.title })
if (matched[0] && matched[0].path == '/') matched.splice(0, 1)
return matched
})
storage.set({ key: 'currHeadMenuName', data: "" })
const toggleMenuCollapse = () => {
systemStore.toggleMenuCollapse(!systemStore.menuIsCollapse)
}
const toHome = () => {
if (!window.localStorage.getItem('site.token')) {
window.localStorage.setItem('site.token', getToken())
window.localStorage.setItem('site.comparisonTokenStorage', getToken())
}
if (!window.localStorage.getItem('site.userinfo')) {
window.localStorage.setItem('site.userinfo', JSON.stringify(useUserStore().userInfo))
}
router.push({ path: '/home/index'})
}
</script>
<style lang="scss" scoped>
.layout-header{
position: relative;
z-index: 5;
border-bottom: 1px solid #e8e9eb;
}
.navbar-item {
padding: 0 8px;
}
.index-item {
border: 1px solid;
border-color: var(--el-color-primary);
&:hover {
color: #fff;
background-color: var(--el-color-primary);
}
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<div class="flex">
<icon name="element Setting" @click="drawer = true" />
<el-drawer v-model="drawer" :title="t('layout.layoutSetting')" size="300px">
<el-scrollbar>
<!-- 黑暗模式 -->
<div class="setting-item flex items-center justify-between mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.darkMode') }}</div>
<div>
<el-switch v-model="dark" :active-value="true" :inactive-value="false" />
</div>
</div>
<!-- 主题颜色 -->
<div class="setting-item flex items-center justify-between mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.themeColor') }}</div>
<div>
<el-color-picker v-model="theme" />
</div>
</div>
<!-- 布局风格 -->
<div class="setting-item mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.layoutStyle') }}</div>
<div class="flex mt-[10px] layout-style flex-wrap">
<div class="relative w-[125px] h-[100px] border mr-[10px] mb-[10px] hover:border-primary"
:class="{ 'border-primary': currLayout == item.key }" v-for="(item, index) in layouts"
@click="handleSetLayout(item.key)">
<img :src="item.image" alt="" class="w-full h-full">
</div>
</div>
</div>
</el-scrollbar>
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import useSystemStore from '@/stores/modules/system'
import { useDark, useToggle } from '@vueuse/core'
import { setThemeColor, img } from '@/utils/common'
import { t } from '@/lang'
import Storage from '@/utils/storage'
const layouts = ref([
{ key: 'admin', image: img('static/resource/images/system/layout_bussiness.png') },
{ key: 'admin_simplicity', image: img('static/resource/images/system/layout_darkside.png') }
])
const currLayout = ref(Storage.get('admin_layout') || 'admin')
const drawer = ref(false)
const systemStore = useSystemStore()
const isDark = useDark()
const toggleDark = useToggle(isDark)
const dark = computed({
get() {
return systemStore.dark
},
set(val) {
systemStore.setTheme('dark', val)
toggleDark(val)
setThemeColor(systemStore.theme, systemStore.dark ? 'dark' : 'light')
}
})
const sidebar = computed({
get() {
return systemStore.sidebar
},
set(val) {
systemStore.setTheme('sidebar', val)
setThemeColor(systemStore.theme, systemStore.dark ? 'dark' : 'light')
}
})
const theme = computed({
get() {
return systemStore.theme
},
set(val) {
systemStore.setTheme('theme', val)
setThemeColor(systemStore.theme, systemStore.dark ? 'dark' : 'light')
}
})
const handleSetLayout = (key: string) => {
Storage.set({ key: 'admin_layout', data: key })
location.reload()
}
</script>
<style lang="scss" scoped>
:deep(.el-drawer__header) {
margin-bottom: 0 !important;
}
.layout-style {
&>div:nth-child(2n+2) {
margin-right: 0;
}
}
</style>

View File

@@ -0,0 +1,64 @@
<template>
<el-popover class="box-item" :width="322">
<template #reference>
<div class="relative">
<icon name="iconfont iconFramec-1" />
<span v-if="showRedDot" class="absolute top-[-3px] right-[-5px] w-[12px] h-[12px] rounded-full bg-[#DA203E] text-white text-[12px] flex justify-center items-center">1</span>
</div>
</template>
<div class="flex items-center bg-[#F8FAFF] p-[10px] rounded-[8px]" v-if="showRedDot">
<div class="w-[36px] h-[36px] rounded-full flex justify-center items-center">
<img src="@/app/assets/images/index/cloud.png" alt="" class="w-[36px] h-[36px]" />
</div>
<div class="py-[3px] ml-[5px] flex-1">
<div class="text-[16px] font-bold text-[#1D1F3A] mb-[5px]">云编译</div>
<div class="text-[12px] text-[#4F516D] flex justify-between items-center">
<span>有正在执行的编译任务</span>
<span class="text-primary cursor-pointer ml-auto" @click="cloudBuildRef?.elNotificationClick()">点击查看</span>
</div>
</div>
</div>
<div class="flex items-center justify-center" v-else>
<img src="@/app/assets/images/index/message_empty.png" alt="">
</div>
</el-popover>
<!-- <i class="iconfont iconFramec-1 cursor-pointer" title="消息" v-else></i> -->
<cloud-build ref="cloudBuildRef"/>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import CloudBuild from "@/app/components/cloud-build/index.vue"
const cloudBuildRef = ref<any>(null)
const showRedDot = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
const startPolling = () => {
if (pollTimer) return
cloudBuildRef.value?.getCloudBuildTaskFn()
pollTimer = setInterval(() => {
const startTime = localStorage.getItem('cloud_build_task')
showRedDot.value = !!startTime
if (!startTime) {
stopPolling()
}
}, 1000)
}
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
startPolling()
</script>
<style lang="scss" scoped>
:deep(.box-item .el-popover.el-popper){
padding: 13px 16px !important;
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<el-dropdown @command="switchLang" :tabindex="1">
<icon name="iconfont iconfanyi" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="zh-cn" :disabled="systemStore.lang == 'zh-cn'">简体中文</el-dropdown-item>
<el-dropdown-item command="en" :disabled="systemStore.lang == 'en'">English</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script lang="ts" setup>
import useSystemStore from '@/stores/modules/system'
import { language } from '@/lang'
import { useRoute } from 'vue-router'
import storage from '@/utils/storage'
const route = useRoute()
const systemStore = useSystemStore()
const switchLang = (command: string) => {
systemStore.$patch((state) => {
state.lang = command
storage.set({ key: 'lang', data: command })
})
language.loadLocaleMessages(route.path, systemStore.lang)
location.reload()
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,159 @@
<template>
<div>
<el-dropdown @command="clickEvent" :tabindex="1">
<div class="userinfo flex h-full items-center">
<el-avatar v-if="userStore.userInfo.head_img" :size="25" :icon="UserFilled" :src="img(userStore.userInfo.head_img)"/>
<img v-else src="@/app/assets/images/member_head.png" class="w-[25px] rounded-full" />
<div class="user-name pl-[8px]">{{ userStore.userInfo.username }}</div>
<icon name="element ArrowDown" class="ml-[5px]" />
</div>
<template #dropdown>
<div class="p-[10px]">
<div class="userinfo flex h-full items-center pb-[10px] border-b-[1px] border-solid border-[#e5e5e5]">
<el-avatar v-if="userStore.userInfo.head_img" :size="45" :icon="UserFilled" :src="img(userStore.userInfo.head_img)"/>
<img v-else src="@/app/assets/images/member_head.png" class="w-[45px] rounded-full" />
<div>
<div class="user-name pl-[8px] text-[14px]">{{ userStore.userInfo.username }}</div>
<div class="pl-[8px] text-[13px] text-[#9699B6]">个人中心</div>
</div>
</div>
<el-dropdown-menu>
<el-dropdown-item @click="getUserInfoFn" class="rounded-[4px]">
<!-- <router-link to="/user/center"> -->
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconshezhi1 ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">账号设置</span>
</div>
<!-- </router-link> -->
</el-dropdown-item>
<el-dropdown-item class="rounded-[4px]">
<router-link to="/tools/authorize">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconshouquanxinxi2 ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">授权信息</span>
</div>
</router-link>
</el-dropdown-item>
<el-dropdown-item @click="changePasswordDialog=true" class="rounded-[4px]">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconxiugai ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">修改密码</span>
</div>
</el-dropdown-item>
<el-dropdown-item @click="logout" class="rounded-[4px]">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont icontuichudenglu ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">退出登录</span>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</div>
</template>
</el-dropdown>
<el-dialog v-model="changePasswordDialog" width="450px" title="修改密码">
<div>
<el-form :model="saveInfo" label-width="90px" ref="formRef" :rules="formRules" class="page-form">
<el-form-item :label="t('originalPassword')" prop="original_password">
<el-input v-model="saveInfo.original_password" type="password" :placeholder="t('originalPasswordPlaceholder')" clearable class="input-width" maxlength="40" />
</el-form-item>
<el-form-item :label="t('newPassword')" prop="password">
<el-input v-model="saveInfo.password" type="password" :placeholder="t('passwordPlaceholder')" clearable class="input-width" maxlength="40" />
<div class="form-tip">{{t('passwordTip')}}</div>
</el-form-item>
<el-form-item :label="t('passwordCopy')" prop="password_copy">
<el-input v-model="saveInfo.password_copy" type="password" :placeholder="t('passwordPlaceholder')" clearable class="input-width" maxlength="40" />
</el-form-item>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="changePasswordDialog = false">{{t('cancel')}}</el-button>
<el-button type="primary" @click="submitForm(formRef)">{{t('save')}}</el-button>
</span>
</template>
</el-dialog>
<user-info-edit ref="userInfoEditRef" />
</div>
</template>
<script lang="ts" setup>
import { UserFilled } from '@element-plus/icons-vue'
import { reactive, ref } from 'vue'
import type { FormInstance, FormRules, ElNotification } from 'element-plus'
import useUserStore from '@/stores/modules/user'
import { setUserInfo } from '@/app/api/personal'
import { t } from '@/lang'
import { img } from '@/utils/common'
import userInfoEdit from '@/app/components/user-info-edit/index.vue'
const userStore = useUserStore()
const clickEvent = (command: string) => {
switch (command) {
case 'logout':
userStore.logout()
break
}
}
const logout = () => {
userStore.logout()
}
const userInfoEditRef = ref(null)
const getUserInfoFn = () => {
userInfoEditRef.value?.open()
}
// 修改密码 --- start
const changePasswordDialog = ref(false)
const formRef = ref<FormInstance>()
// 提交信息
const saveInfo = reactive({
original_password: '',
password: '',
password_copy: ''
})
// 表单验证规则
const formRules = reactive<FormRules>({
original_password: [
{ required: true, message: t('originalPasswordPlaceholder'), trigger: 'blur' }
],
password: [
{ required: true, message: t('passwordPlaceholder'), trigger: 'blur' }
],
password_copy: [
{ required: true, message: t('passwordPlaceholder'), trigger: 'blur' }
]
})
const submitForm = (formEl: FormInstance | undefined) => {
if (!formEl) return
formEl.validate((valid) => {
if (valid) {
let msg = ''
if (saveInfo.password && !saveInfo.original_password) msg = t('originalPasswordHint')
if (saveInfo.password && saveInfo.original_password && !saveInfo.password_copy) msg = t('newPasswordHint')
if (saveInfo.password && saveInfo.original_password && saveInfo.password_copy && saveInfo.password != saveInfo.password_copy) msg = t('doubleCipherHint')
if (msg) {
ElNotification({
type: 'error',
message: msg
})
return
}
setUserInfo(saveInfo).then((res: any) => {
changePasswordDialog.value = false
})
} else {
return false
}
})
}
</script>
<style lang="scss" scoped>
.el-popper .el-dropdown-menu{
width: 150px;
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<div class="tab-wrap w-full px-[16px]">
<el-tabs :closable="tabbarStore.tabLength > 1" :model-value="route.path" @tab-click="tabClick" @tab-remove="removeTab">
<el-tab-pane v-for="(tab, key, index) in tabbarStore.tabs" :name="tab.path" :key="index">
<template #label>
<el-dropdown trigger="contextmenu" placement="bottom-start">
<span :class="{ 'text-primary': route.path == tab.path }" class="tab-name">{{ tab.title }}</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item icon="Back" :disabled="index == 0" @click="closeLeft(tab.path)">{{t('tabs.closeLeft') }}</el-dropdown-item>
<el-dropdown-item icon="Right" :disabled="index == (tabbarStore.tabLength - 1)" @click="closeRight(tab.path)">{{t('tabs.closeRight') }}</el-dropdown-item>
<el-dropdown-item icon="Close" :disabled="tabbarStore.tabLength == 1" @click="closeOther(tab.path)">{{t('tabs.closeOther') }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script lang="ts" setup>
import { watch, onMounted } from 'vue'
import useTabbarStore from '@/stores/modules/tabbar'
import { useRoute, useRouter } from 'vue-router'
import { t } from '@/lang'
const tabbarStore = useTabbarStore()
const route = useRoute()
const router = useRouter()
onMounted(() => {
tabbarStore.addTab(route)
})
watch(route, (nval: any) => {
tabbarStore.addTab(nval)
})
/**
* 添加tab
* @param content
*/
const tabClick = (content: any) => {
const tabRoute = tabbarStore.tabs[content.props.name]
router.push({ path: tabRoute.path, query: tabRoute.query })
}
/**
* 移除tab
* @param content
*/
const removeTab = (content: any) => {
if (route.path == content) {
const tabs = Object.keys(tabbarStore.tabs)
router.push({ path: tabs[tabs.indexOf(content) - 1] })
}
tabbarStore.removeTab(content)
}
/**
* 关闭左侧
* @param path
*/
const closeLeft = (path: string) => {
const tabs = Object.keys(tabbarStore.tabs)
for (let i = tabs.indexOf(path) - 1; i >= 0; i--) {
delete tabbarStore.tabs[tabs[i]]
}
router.push({ path })
}
/**
* 关闭右侧
* @param path
*/
const closeRight = (path: string) => {
const tabs = Object.keys(tabbarStore.tabs)
for (let i = tabs.indexOf(path) + 1; i < tabs.length; i++) {
delete tabbarStore.tabs[tabs[i]]
}
router.push({ path })
}
/**
* 关闭其他
* @param path
*/
const closeOther = (path: string) => {
const tabs = Object.keys(tabbarStore.tabs)
tabs.forEach((key: string) => { key != path && delete tabbarStore.tabs[key] })
router.push({ path })
}
</script>
<style lang="scss" scoped>
:deep(.el-tabs) {
.el-tabs--border-card {
border: none;
}
.el-tabs__header {
margin: 0;
}
.el-tabs__nav-wrap {
margin-bottom: 0;
&::after {
display: none;
}
}
.el-tabs__content {
display: none;
}
.el-tabs__item {
display: inline-flex !important;
padding: 0 20px !important;
align-items: center;
.tab-name:focus {
outline: none !important;
}
}
.el-tabs__active-bar {
display: none;
}
.el-tabs__item.is-active {
background-color: var(--el-color-primary-light-9);
}
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<div class="flex w-full h-screen">
<el-container>
<!-- 顶部 -->
<el-header>
<layout-header></layout-header>
</el-header>
<!-- 顶部 end -->
<el-container :style="{height:'calc(100vh - 64px)'}">
<!-- 左侧边栏 -->
<layout-aside></layout-aside>
<!-- 左侧边栏 end -->
<!-- 主体 -->
<el-main class="h-full p-0 bg-page">
<el-scrollbar>
<div class="p-[15px]">
<router-view v-slot="{ Component, route }" v-if="appStore.routeRefreshTag">
<keep-alive :include="tabbarStore.tabNames">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</router-view>
</div>
</el-scrollbar>
</el-main>
<!-- 主体 end -->
</el-container>
</el-container>
</div>
</template>
<script lang="ts" setup>
import layoutHeader from './components/header/index.vue'
import layoutAside from './components/aside/index.vue'
import useAppStore from '@/stores/modules/app'
import useTabbarStore from '@/stores/modules/tabbar'
const appStore = useAppStore()
const tabbarStore = useTabbarStore()
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,34 @@
<template>
<el-aside class="h-screen layout-aside w-auto">
<side class="hidden-xs-only" />
</el-aside>
</template>
<script lang="ts" setup>
import { watch } from 'vue'
import { useRoute } from 'vue-router'
import side from './side.vue'
import useSystemStore from '@/stores/modules/system'
const systemStore = useSystemStore()
const route = useRoute()
watch(route, () => {
systemStore.$patch(state => {
state.menuDrawer = false
})
})
</script>
<style lang="scss">
.layout-aside {
background-color: var(--side-dark-color, var(--el-bg-color));
border-right: 1px solid var(--el-border-color-lighter);
}
.aside-drawer {
.el-drawer__body {
padding: 0 !important;
}
}
</style>

View File

@@ -0,0 +1,62 @@
<template>
<template v-if="meta.show">
<el-sub-menu v-if="routes.children" :index="String(routes.name)">
<template #title>
<span :class="['ml-[10px]']">{{ meta.title }}</span>
</template>
<menu-item v-for="(route, index) in routes.children" :routes="route" :key="index" />
</el-sub-menu>
<template v-else>
<el-menu-item :index="String(routes.name)" @click="router.push({ name: routes.name })" v-if="meta.addon && meta.parent_route && meta.parent_route.addon == ''">
<template #title>
<span :class="[{'text-[15px]': routes.meta.class == 1}, {'text-[14px]': routes.meta.class != 1}, {'ml-[10px]': routes.meta.class == 2, 'ml-[15px]': routes.meta.class == 3}]">{{ meta.title }}</span>
</template>
</el-menu-item>
<el-menu-item :index="String(routes.name)" @click="router.push({ name: routes.name })" v-else>
<template #title>
<span :class="[{'text-[15px]': routes.meta.class == 1}, {'text-[14px]': routes.meta.class != 1}, {'ml-[10px]': routes.meta.class == 2, 'ml-[15px]': routes.meta.class == 3}]">{{ meta.title }}</span>
</template>
</el-menu-item>
</template>
<div v-if="routes.is_border" class="!border-0 !border-t-[1px] border-solid mx-[25px] bg-[#f7f7f7] my-[5px]"></div>
</template>
</template>
<script lang="ts" setup>
import { useRouter } from 'vue-router'
import { computed } from 'vue'
import { img } from '@/utils/common'
import menuItem from './menu-item.vue'
import useUserStore from '@/stores/modules/user'
const router = useRouter()
const props = defineProps({
routes: {
type: Object,
required: true
}
})
const userStore = useUserStore()
const siteInfo = userStore.siteInfo
const meta = computed(() => props.routes.meta)
const addons = computed(() => {
const addons:Record<string, any> = {}
siteInfo?.apps.forEach((item: any) => { addons[item.key] = item })
siteInfo?.site_addons.forEach((item: any) => { addons[item.key] = item })
return addons
})
</script>
<style lang="scss">
.el-sub-menu{
.el-icon{
width: auto;
}
li{
font-size: 15px;
}
}
</style>

View File

@@ -0,0 +1,335 @@
<template>
<el-container class="w-100 h-screen">
<el-main class="p-0 flex">
<div class="w-[64px] bg-[#282c34] h-screen one-menu">
<el-header class="logo-wrap">
<div class="logo flex items-center m-auto h-[64px]" v-if="!systemStore.menuIsCollapse">
<el-image style="width: 40px; height: 40px" :src="img(logoUrl)" fit="contain">
<template #error>
<div class="flex justify-center items-center w-full h-[40px]"><img class="max-w-[40px]" src="@/app/assets/images/icon-addon-one.png" alt="" object-fit="contain"></div>
</template>
</el-image>
</div>
<div class="logo flex items-center justify-center h-[64px]" v-else>
<i class="text-3xl iconfont iconyunkongjian"></i>
</div>
</el-header>
<el-scrollbar class="h-[calc( 100vh - 64px )]">
<el-menu :default-active="oneMenuActive" :router="true" class="aside-menu" :unique-opened="true">
<template v-for="(item, index) in oneMenuData" :key="index">
<el-menu-item :index="item.original_name" @click="router.push({ name: item.name })" v-if="item.meta.show">
<div v-if="item.meta.icon" class="w-[16px] h-[16px] relative flex justify-center">
<icon :name="item.meta.icon" class="absolute top-[50%] -translate-y-[50%]" />
</div>
<template #title>
<div class="relative">
<span class="ml-[10px] text-[15px]">{{ item.meta.short_title || item.meta.title }}</span>
</div>
</template>
</el-menu-item>
</template>
</el-menu>
<div class="h-[48px]"></div>
</el-scrollbar>
</div>
<el-scrollbar v-if="twoMenuData.length" class="two-menu w-[190px]">
<div class="w-[190px] h-[64px] flex items-center justify-center text-[16px] border-0 border-b-[1px] border-solid border-[#eee]">{{ route.matched[1].meta.title }}</div>
<el-menu :default-active="route.name" :router="true" class="aside-menu" :collapse="systemStore.menuIsCollapse">
<menu-item v-for="(route, index) in twoMenuData" :routes="route" :key="index" />
</el-menu>
<div class="h-[48px]"></div>
</el-scrollbar>
</el-main>
</el-container>
</template>
<script lang="ts" setup>
import { ref, watch, computed, onMounted, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import useSystemStore from '@/stores/modules/system'
import useUserStore from '@/stores/modules/user'
import menuItem from './menu-item.vue'
import { getShowApp, getShowMarketing } from '@/app/api/site'
import { img } from '@/utils/common'
import { findFirstValidRoute } from '@/router/routers'
const systemStore = useSystemStore()
const userStore = useUserStore()
const route = useRoute()
const router = useRouter()
const siteInfo = userStore.siteInfo
const routers = userStore.routers
const addonIndexRoute = userStore.addonIndexRoute
const oneMenuData = ref<Record<string, any>[]>([])
const twoMenuData = ref<Record<string, any>[]>([])
const addonRouters: Record<string, any> = {}
const logoUrl = computed(() => {
return userStore.siteInfo.icon ? userStore.siteInfo.icon : systemStore.website.icon
})
routers.forEach(item => {
item.original_name = item.name
if (item.meta.addon == '') {
if (item.meta.attr == '') {
if (item.children && item.children.length) {
item.name = findFirstValidRoute(item.children)
}
oneMenuData.value.push(item)
}
} else if (item.meta.addon != '' && siteInfo?.apps.length <= 1 && siteInfo?.apps[0].key == item.meta.addon && item.meta.show) {
if (item.children) {
item.children.forEach((citem: Record<string, any>) => {
citem.original_name = citem.name
if (citem.children && citem.children.length) {
citem.name = findFirstValidRoute(citem.children)
}
})
oneMenuData.value.unshift(...item.children)
} else {
oneMenuData.value.unshift(item)
}
} else {
addonRouters[item.meta.addon] = item
}
// 排序, 功能正确,改了排序后需要把菜单排序的默认值重新调整一下【多应用一级菜单,单应用二级菜单】
// oneMenuData.value.sort((a, b) => {
// if (a.meta.sort && b.meta.sort) {
// return b.meta.sort - a.meta.sort
// } else if (a.meta.sort) {
// return -1
// } else if (b.meta.sort) {
// return 1
// } else {
// return 0
// }
// })
})
// 多应用时将应用插入菜单
if (siteInfo?.apps.length > 1) {
const routers:Record<string, any>[] = []
siteInfo?.apps.forEach((item: Record<string, any>) => {
if (addonRouters[item.key]) {
addonRouters[item.key].name = addonIndexRoute[item.key]
routers.push(addonRouters[item.key])
}
})
oneMenuData.value.unshift(...routers)
// 排序, 功能正确,改了排序后需要把菜单排序的默认值重新调整一下【多应用一级菜单,单应用二级菜单】
// oneMenuData.value.sort((a, b) => {
// if (a.meta.sort && b.meta.sort) {
// return b.meta.sort - a.meta.sort
// } else if (a.meta.sort) {
// return -1
// } else if (b.meta.sort) {
// return 1
// } else {
// return 0
// }
// })
}
const oneMenuActive = ref(route.matched[1].name)
const appList = ref(null)
const marketingList = ref(null)
// const loading = ref(true);
const getAppList = async () => {
const res = await getShowApp()
appList.value = res.data
// loading.value = false;
}
const getMarketingList = async () => {
const res = await getShowMarketing()
marketingList.value = res.data
}
onMounted(async () => {
await getAppList() // 确保数据先加载
await getMarketingList()
})
watchEffect(() => {
// if (!appList.value || loading.value) return; // 确保数据加载完毕
const addonKeys = appList.value?.addon?.list?.map(item => item.key) ?? []
const toolKeys = appList.value?.tool?.list?.map(item => item.key) ?? []
const allKeys = [...addonKeys, ...toolKeys]
const marketingKeys = marketingList.value?.marketing?.list?.map(item => item.key) ?? []
const matchedName = route.matched[1]?.name
if (allKeys.includes(matchedName)) {
oneMenuActive.value = 'addon'
twoMenuData.value = route.matched[1]?.children ?? []
} else if (marketingKeys.includes(matchedName)) {
oneMenuActive.value = 'active'
twoMenuData.value = route.matched[1]?.children ?? []
} else if (route.meta.attr !== '') {
oneMenuActive.value = route.matched[2]?.name
twoMenuData.value = route.matched[1]?.children ?? []
} else {
// 多应用
if (siteInfo?.apps.length > 1) {
twoMenuData.value = route.matched[1]?.children
oneMenuActive.value = route.matched[1]?.name
} else {
// 单应用
const oneMenu = route.matched[1]
if (oneMenu.meta.addon === '') {
oneMenuActive.value = route.matched[1]?.name
twoMenuData.value = route.matched[1]?.children ?? []
} else {
if (oneMenu.meta.addon === siteInfo?.apps[0]?.key) {
oneMenuActive.value = route.matched[2]?.name
twoMenuData.value = route.matched[2]?.children ?? []
} else {
oneMenuActive.value = route.matched[1]?.name
twoMenuData.value = route.matched[1]?.children ?? []
}
}
}
}
})
// watch(route, () => {
// if (route.meta.attr != '') {
// oneMenuActive.value = route.matched[2].name
// twoMenuData.value = route.matched[1].children ?? []
// } else {
// // 多应用
// if (siteInfo?.apps.length > 1) {
// twoMenuData.value = route.matched[1].children
// oneMenuActive.value = route.matched[1].name
// } else {
// // 单应用
// const oneMenu = route.matched[1]
// if (oneMenu.meta.addon == '') {
// oneMenuActive.value = route.matched[1].name
// twoMenuData.value = route.matched[1].children ?? []
// } else {
// if (oneMenu.meta.addon == siteInfo?.apps[0].key) {
// oneMenuActive.value = route.matched[2].name
// twoMenuData.value = route.matched[2].children ?? []
// } else {
// oneMenuActive.value = route.matched[1].name
// twoMenuData.value = route.matched[1].children ?? []
// }
// }
// }
// }
// }, { immediate: true })
</script>
<style lang="scss">
.one-menu{
.aside-menu:not(.el-menu--collapse) {
background-color: transparent;
.el-menu-item{
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 5px;
height: 54px;
width: 54px;
color: #b9b9bf;
font-size: 14px;
border-radius: 5px;
line-height: 1;
&>div{
i{
position: static;
transform: inherit;
}
span{
display: block;
margin: 5px 0 0;
}
}
&:hover{
background-color: transparent;
color: var(--el-color-primary);
}
&.is-active{
background-color: var(--el-color-primary) !important;
color: #fff !important;
}
span{
font-size: 14px;
margin-left: 8px;
}
}
}
.el-menu{
border: 0;
}
.el-scrollbar{
height: calc(100vh - 65px);
}
}
.two-menu{
.aside-menu:not(.el-menu--collapse) {
width: 190px;
border: 0;
padding-top: 10px;
.el-menu-item{
height: 40px;
margin: 0 8px 2px;
padding: 0 10px !important;
border-radius: 2px;
span{
margin-left: 8px;
font-size: 14px;
}
&.is-active{
background-color: #f3f3f3 !important;
color: #333;
}
&:hover{
background-color: transparent;
color: var(--el-color-primary);
}
}
.el-sub-menu{
.el-sub-menu__title{
margin: 0 8px 2px;
height: 40px;
padding-left: 8px;
border-radius: 2px;
span{
height: 40px;
display: flex;
align-items: center;
font-size: 14px;
}
&:hover{
background-color: transparent;
color: var(--el-color-primary);
}
}
.el-menu-item{
padding-left: 20px !important;
}
}
}
}
.logo-wrap {
padding: 0;
display: flex;
white-space: nowrap;
align-items: center;
.logo {
height: 100%;
box-sizing: border-box;
}
.logo-title {
flex: 1;
width: 0;
text-overflow: ellipsis;
overflow: hidden;
font-size: var(--el-font-size-base);
}
}
</style>

View File

@@ -0,0 +1,284 @@
<template>
<el-container :class="['h-full px-[10px]',{'layout-header border-b border-color': !dark}]" >
<el-row class="w-100 h-full w-full">
<el-col :span="10">
<div class="left-panel h-full flex items-center">
<!-- 左侧菜单折叠 -->
<!-- <div class="navbar-item flex items-center h-full cursor-pointer" @click="toggleMenuCollapse">
<icon name="element Expand" v-if="systemStore.menuIsCollapse" />
<icon name="element Fold" v-else />
</div> -->
<!-- 刷新当前页 -->
<div class="navbar-item flex items-center h-full cursor-pointer" @click="refreshRouter">
<icon name="element Refresh" />
</div>
<!-- 面包屑导航 -->
<div class="flex items-center h-full pl-[10px] hidden-xs-only">
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="(route, index) in breadcrumb" :key="index" :to="route.path" class="inter">{{route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
</div>
</el-col>
<el-col :span="14">
<div class="right-panel h-full flex items-center justify-end">
<div class="flex items-center flex-shrink-0 hidden-xs-only">
<el-dropdown trigger="hover" :hide-on-click="false" popper-class="site-info-wrap" class="mr-[8px]">
<!-- 状态 -->
<div class="mx-[8px] bg-[#f6f6f6] border-[1px] border-solid border-[#eee] rounded-[4px] px-[9px] py-[6px] flex items-center">
<span class="mr-[6px] text-[12px] !text-[#333]">{{siteInfo.site_name}}</span>
<span class="!text-[10px] text-[#f56c6c]" :class="{'!text-[#67c23a]': siteInfo.status == 1, '!text-[#f56c6c]': siteInfo.status == 3}">{{ siteInfo.status_name }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>
<!-- 站点id -->
<div class="text-[14px]">站点编号{{siteInfo.site_id}}</div>
</el-dropdown-item>
<el-dropdown-item>
<!-- 到期时间 -->
<div v-if="siteInfo.expire_time == 0" class="text-[14px]">到期时间永久</div>
<div v-else class="text-[14px]">到期时间{{ siteInfo.expire_time }}</div>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="flex items-center flex-shrink-0 hidden-xs-only">
<el-popover placement="bottom" :width="330" trigger="click" v-model:visible="isMenuSearch" >
<template #reference>
<i class="iconfont icona-sousuoV6xx-36 cursor-pointer px-[8px] !text-[14px]"></i>
</template>
<template #default>
<div class="flex items-center">
<el-select v-model="selectedRoute" filterable class="!w-[250px] mr-[20px] menu-select" :teleported="false" clearable @change="handleRouteSelect">
<el-option v-for="item in flatRoutes" :key="item.name" :label="item.full_title" :value="item.name" >
</el-option>
</el-select>
<el-button type="primary" link @click="isMenuSearch = false">{{t('取消')}}</el-button>
</div>
</template>
</el-popover>
</div>
<!-- 预览 只有站点时展示-->
<i class="iconfont iconicon_huojian1 cursor-pointer px-[8px]" :title="t('visitWap')" @click="toPreview"></i>
<i class="iconfont iconlingdang-xianxing cursor-pointer px-[8px]" :title="t('newInfo')" v-if="appType == 'site'"></i>
<!-- 切换语言 -->
<!-- <div class="navbar-item flex items-center h-full cursor-pointer">
<switch-lang />
</div> -->
<!-- 切换全屏 -->
<!-- <div class="navbar-item flex items-center h-full cursor-pointer" @click="toggleFullscreen">
<icon name="iconfont icontuichuquanping" v-if="isFullscreen" />
<icon name="iconfont iconquanping" v-else />
</div> -->
<!-- 布局设置 -->
<div class="navbar-item flex items-center h-full cursor-pointer">
<layout-setting />
</div>
<!-- 用户信息 -->
<div class="navbar-item flex items-center h-full cursor-pointer">
<user-info />
</div>
</div>
</el-col>
</el-row>
<input type="hidden" v-model="comparisonToken">
<input type="hidden" v-model="comparisonSiteId">
<el-dialog v-model="detectionLoginDialog" :title="t('layout.detectionLoginTip')" width="30%" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
<span>{{ t('layout.detectionLoginContent') }}</span>
<template #footer>
<span class="dialog-footer">
<el-button @click="detectionLoginFn">{{ t('layout.detectionLoginOperation') }}</el-button>
</span>
</template>
</el-dialog>
</el-container>
</template>
<script lang="ts" setup>
import { computed, ref, onMounted } from 'vue'
import layoutSetting from './layout-setting.vue'
import userInfo from './user-info.vue'
import { useFullscreen } from '@vueuse/core'
import useSystemStore from '@/stores/modules/system'
import useUserStore from '@/stores/modules/user'
import useAppStore from '@/stores/modules/app'
import { useRoute, useRouter } from 'vue-router'
import { t } from '@/lang'
import storage from '@/utils/storage'
const appType = storage.get('app_type')
const { toggle: toggleFullscreen, isFullscreen } = useFullscreen()
const systemStore = useSystemStore()
const appStore = useAppStore()
const route = useRoute()
const router = useRouter()
const screenWidth = ref(window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth)
const userStore = useUserStore()
const siteInfo:any = computed(() => {
return userStore.siteInfo
})
const dark = computed(() => {
return systemStore.dark
})
const isMenuSearch = ref(false)
const routers = userStore.routers
const getParentTitleChain=(meta:any) =>{
let titles = []
let current = meta?.parent_route
while (current) {
if (current.short_title) {
titles.unshift(current.short_title)
}
current = current.parent_route
}
return titles.join(' - ')
}
const flattenRoutes = (routes:any, parent = null)=> {
let flat = [];
routes.forEach(route => {
const { path, name, meta = {}, short_title, children } = route
const isLeaf = meta.type ==1 && meta.show==1
if(isLeaf){
const title = meta.title || short_title || ''
const parentTitleChain = getParentTitleChain(meta)
const fullTitle = parentTitleChain ? `${parentTitleChain} - ${title}` : title
const item = {
path,
name,
title,
parent_title: parentTitleChain,
full_title: fullTitle
};
flat.push(item);
}
if (children && children.length > 0) {
flat = flat.concat(flattenRoutes(children, route))
}
});
return flat;
}
const flatRoutes = flattenRoutes(routers)
const selectedRoute = ref('')
const handleRouteSelect = (name:any) => {
if (name) {
router.push({ name })
isMenuSearch.value = false
}
}
// 检测登录 start
const detectionLoginDialog = ref(false)
const comparisonToken = ref('')
const comparisonSiteId = ref('')
if (storage.get('comparisonTokenStorage')) {
comparisonToken.value = storage.get('comparisonTokenStorage')
// storage.remove(['comparisonTokenStorage']);
}
if (storage.get('comparisonSiteIdStorage')) {
comparisonSiteId.value = storage.get('comparisonSiteIdStorage')
// storage.remove(['comparisonSiteIdStorage']);
}
// 监听标签页面切换
document.addEventListener('visibilitychange', e => {
if (document.visibilityState === 'visible' && (comparisonSiteId.value != storage.get('siteId') || comparisonToken.value != storage.get('token'))) {
detectionLoginDialog.value = true
}
})
const detectionLoginFn = () => {
detectionLoginDialog.value = false
location.href = `${location.origin}/site/`
}
// 检测登录 end
onMounted(() => {
// 监听窗体宽度变化
window.onresize = () => {
return (() => {
screenWidth.value = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth
})()
}
})
// watch(screenWidth, () => {
// if (screenWidth.value < 992) {
// if (!systemStore.menuIsCollapse) systemStore.toggleMenuCollapse(true)
// } else {
// if (systemStore.menuIsCollapse) systemStore.toggleMenuCollapse(false)
// }
// })
// 菜单栏展开折叠
// const toggleMenuCollapse = () => {
// systemStore.$patch((state) => {
// if (screenWidth.value < 768) {
// state.menuDrawer = true
// state.menuIsCollapse = false
// } else {
// systemStore.toggleMenuCollapse(!systemStore.menuIsCollapse)
// }
// })
// }
// 刷新路由
const refreshRouter = () => {
if (!appStore.routeRefreshTag) return
appStore.refreshRouterView()
}
// 面包屑导航
const breadcrumb = computed(() => {
const matched = route.matched.filter(item => { return item.meta.title })
if (matched[0] && matched[0].path == '/') matched.splice(0, 1)
return matched
})
// 跳转去预览
const toPreview = () => {
const url = router.resolve({
path: '/preview/wap',
query: {
page: '/'
}
})
window.open(url.href)
}
</script>
<style lang="scss" scoped>
.layout-header{
position: relative;
z-index: 5;
box-shadow: 0 0 4px 0 rgba(0,145,255,0.1);
}
.navbar-item {
padding: 0 8px;
&:hover {
background-color: var(--el-bg-color-page);
}
}
.index-item {
border: 1px solid;
border-color: var(--el-color-primary);
&:hover {
color: #fff;
background-color: var(--el-color-primary);
}
}
:deep(.el-dropdown-menu__item) {
&:focus {
background-color: transparent !important;
color: #333 !important;
}
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<div class="flex">
<icon name="element Setting" @click="drawer = true" />
<el-drawer v-model="drawer" :title="t('layout.layoutSetting')" size="300px">
<el-scrollbar>
<!-- 黑暗模式 -->
<div class="setting-item flex items-center justify-between mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.darkMode') }}</div>
<div>
<el-switch v-model="dark" :active-value="true" :inactive-value="false" />
</div>
</div>
<!-- 主题颜色 -->
<div class="setting-item flex items-center justify-between mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.themeColor') }}</div>
<div>
<el-color-picker v-model="theme" />
</div>
</div>
<!-- 标签栏 -->
<div class="setting-item flex items-center justify-between mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.tab') }}</div>
<div>
<el-switch v-model="tab" :active-value="true" :inactive-value="false" />
</div>
</div>
</el-scrollbar>
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import useSystemStore from '@/stores/modules/system'
import { useDark, useToggle } from '@vueuse/core'
import { setThemeColor } from '@/utils/common'
import { t } from '@/lang'
import storage from "@/utils/storage";
const drawer = ref(false)
const systemStore = useSystemStore()
const isDark = useDark()
const toggleDark = useToggle(isDark)
const dark = computed({
get () {
return systemStore.dark
},
set (val) {
systemStore.setTheme('dark', val)
toggleDark(val)
setThemeColor(systemStore.theme, systemStore.dark ? 'dark' : 'light')
}
})
const tab = computed({
get () {
return systemStore.tab
},
set (val) {
systemStore.$patch((state) => {
state.tab = val
storage.set({ key: 'tab', data: val })
})
}
})
const theme = computed({
get () {
return systemStore.theme
},
set (val) {
systemStore.setTheme('theme', val)
setThemeColor(systemStore.theme, systemStore.dark ? 'dark' : 'light')
}
})
</script>
<style lang="scss" scoped>
:deep(.el-drawer__header) {
margin-bottom: 0 !important;
}
.layout-style {
&>div:nth-child(2n+2) {
margin-right: 0;
}
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<el-dropdown @command="switchLang" :tabindex="1">
<icon name="iconfont iconfanyi" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="zh-cn" :disabled="systemStore.lang == 'zh-cn'">简体中文</el-dropdown-item>
<el-dropdown-item command="en" :disabled="systemStore.lang == 'en'">English</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script lang="ts" setup>
import useSystemStore from '@/stores/modules/system'
import { language } from '@/lang'
import { useRoute } from 'vue-router'
import storage from '@/utils/storage'
const route = useRoute()
const systemStore = useSystemStore()
const switchLang = (command: string) => {
systemStore.$patch((state) => {
state.lang = command
storage.set({ key: 'lang', data: command })
})
language.loadLocaleMessages(route.path, systemStore.lang)
location.reload()
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,160 @@
<template>
<div>
<el-dropdown @command="clickEvent" :tabindex="1">
<div class="userinfo flex h-full items-center">
<el-avatar v-if="userStore.userInfo.head_img" :size="25" :icon="UserFilled" :src="img(userStore.userInfo.head_img)"/>
<img v-else src="@/app/assets/images/member_head.png" class="w-[25px] rounded-full" />
<div class="user-name pl-[8px]">{{ userStore.userInfo.username }}</div>
<icon name="element ArrowDown" class="ml-[5px]" />
</div>
<template #dropdown>
<div class="p-[10px]">
<div class="userinfo flex h-full items-center pb-[10px] border-b-[1px] border-solid border-[#e5e5e5]">
<el-avatar v-if="userStore.userInfo.head_img" :size="45" :icon="UserFilled" :src="img(userStore.userInfo.head_img)"/>
<img v-else src="@/app/assets/images/member_head.png" class="w-[45px] rounded-full" />
<div>
<div class="user-name pl-[8px] text-[14px]">{{ userStore.userInfo.username }}</div>
<div class="pl-[8px] text-[13px] text-[#9699B6]">个人中心</div>
</div>
</div>
<el-dropdown-menu>
<el-dropdown-item @click="toLink('/home/index')" v-if="isAllowChange">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconqiehuan ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">切换站点</span>
</div>
</el-dropdown-item>
<el-dropdown-item @click="getUserInfoFn">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconshezhi1 ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">账号设置</span>
</div>
</el-dropdown-item>
<el-dropdown-item @click="changePasswordDialog=true">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconxiugai ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">修改密码</span>
</div>
</el-dropdown-item>
<el-dropdown-item @click="logout">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont icontuichudenglu ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">退出登录</span>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</div>
</template>
</el-dropdown>
<el-dialog v-model="changePasswordDialog" width="450px" title="修改密码">
<div>
<el-form :model="saveInfo" label-width="90px" ref="formRef" :rules="formRules" class="page-form">
<el-form-item :label="t('originalPassword')" prop="original_password">
<el-input v-model="saveInfo.original_password" type="password" :placeholder="t('originalPasswordPlaceholder')" clearable class="input-width" maxlength="40" />
</el-form-item>
<el-form-item :label="t('newPassword')" prop="password">
<el-input v-model="saveInfo.password" type="password" :placeholder="t('passwordPlaceholder')" clearable class="input-width" maxlength="40" />
<div class="form-tip">{{t('passwordTip')}}</div>
</el-form-item>
<el-form-item :label="t('passwordCopy')" prop="password_copy">
<el-input v-model="saveInfo.password_copy" type="password" :placeholder="t('passwordPlaceholder')" clearable class="input-width" maxlength="40" />
</el-form-item>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="changePasswordDialog = false">{{t('cancel')}}</el-button>
<el-button type="primary" @click="submitForm(formRef)">{{t('save')}}</el-button>
</span>
</template>
</el-dialog>
<user-info-edit ref="userInfoEditRef" />
</div>
</template>
<script lang="ts" setup>
import { UserFilled } from '@element-plus/icons-vue'
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { FormInstance, FormRules, ElNotification } from 'element-plus'
import useUserStore from '@/stores/modules/user'
import { setUserInfo } from '@/app/api/personal'
import { img } from '@/utils/common'
import { t } from '@/lang'
import userInfoEdit from '@/app/components/user-info-edit/index.vue'
const isAllowChange = localStorage.getItem('isAllowChange') === 'true';
const userStore = useUserStore()
const router = useRouter()
const clickEvent = (command: string) => {
switch (command) {
case 'logout':
userStore.logout()
break
}
}
const logout = () => {
userStore.logout()
}
const toLink = (link) => {
router.push(link)
}
const userInfoEditRef = ref(null)
const getUserInfoFn = () => {
userInfoEditRef.value?.open()
}
// 修改密码 --- start
const changePasswordDialog = ref(false)
const formRef = ref<FormInstance>()
// 提交信息
const saveInfo = reactive({
original_password: '',
password: '',
password_copy: ''
})
// 表单验证规则
const formRules = reactive<FormRules>({
original_password: [
{ required: true, message: t('originalPasswordPlaceholder'), trigger: 'blur' }
],
password: [
{ required: true, message: t('passwordPlaceholder'), trigger: 'blur' }
],
password_copy: [
{ required: true, message: t('passwordPlaceholder'), trigger: 'blur' }
]
})
const submitForm = (formEl: FormInstance | undefined) => {
if (!formEl) return
formEl.validate((valid) => {
if (valid) {
let msg = ''
if (saveInfo.password && !saveInfo.original_password) msg = t('originalPasswordHint')
if (saveInfo.password && saveInfo.original_password && !saveInfo.password_copy) msg = t('newPasswordHint')
if (saveInfo.password && saveInfo.original_password && saveInfo.password_copy && saveInfo.password != saveInfo.password_copy) msg = t('doubleCipherHint')
if (msg) {
ElNotification({
type: 'error',
message: msg
})
return
}
setUserInfo(saveInfo).then((res: any) => {
changePasswordDialog.value = false
})
} else {
return false
}
})
}
</script>
<style lang="scss" scoped>
.el-popper .el-dropdown-menu{
width: 150px;
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<div class="tab-wrap w-full px-[16px]" v-show="systemStore.tab">
<el-tabs :closable="tabbarStore.tabLength > 1" :model-value="route.name" @tab-click="tabClick" @tab-remove="removeTab">
<el-tab-pane v-for="(tab, key, index) in tabbarStore.tabs" :name="tab.name" :key="index">
<template #label>
<el-dropdown trigger="contextmenu" placement="bottom-start">
<span :class="{ 'text-primary': route.name == tab.name }" class="tab-name">{{ tab.title }}</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item icon="Back" :disabled="index == 0" @click="closeLeft(tab.name)">{{t('tabs.closeLeft') }}</el-dropdown-item>
<el-dropdown-item icon="Right" :disabled="index == (tabbarStore.tabLength - 1)" @click="closeRight(tab.name)">{{t('tabs.closeRight') }}</el-dropdown-item>
<el-dropdown-item icon="Close" :disabled="tabbarStore.tabLength == 1" @click="closeOther(tab.name)">{{t('tabs.closeOther') }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script lang="ts" setup>
import { watch, onMounted } from 'vue'
import useTabbarStore from '@/stores/modules/tabbar'
import useSystemStore from '@/stores/modules/system'
import { useRoute, useRouter } from 'vue-router'
import { t } from '@/lang'
const tabbarStore = useTabbarStore()
const systemStore = useSystemStore()
const route = useRoute()
const router = useRouter()
onMounted(() => {
tabbarStore.addTab(route)
})
watch(route, (nval: any) => {
tabbarStore.addTab(nval)
})
/**
* 添加tab
* @param content
*/
const tabClick = (content: any) => {
const tabRoute = tabbarStore.tabs[content.props.name]
router.push({ name: tabRoute.name, query: tabRoute.query })
}
/**
* 移除tab
* @param content
*/
const removeTab = (content: any) => {
if (route.name == content) {
const tabs = Object.keys(tabbarStore.tabs)
if (tabs.indexOf(content) == 0) {
router.push({ name: tabs[1] })
} else {
router.push({ name: tabs[tabs.indexOf(content) - 1] })
}
}
tabbarStore.removeTab(content)
}
/**
* 关闭左侧
* @param name
*/
const closeLeft = (name: string) => {
const tabs = Object.keys(tabbarStore.tabs)
for (let i = tabs.indexOf(name) - 1; i >= 0; i--) {
delete tabbarStore.tabs[tabs[i]]
}
router.push({ name })
}
/**
* 关闭右侧
* @param name
*/
const closeRight = (name: string) => {
const tabs = Object.keys(tabbarStore.tabs)
for (let i = tabs.indexOf(name) + 1; i < tabs.length; i++) {
delete tabbarStore.tabs[tabs[i]]
}
router.push({ name })
}
/**
* 关闭其他
* @param name
*/
const closeOther = (name: string) => {
const tabs = Object.keys(tabbarStore.tabs)
tabs.forEach((key: string) => { key != name && delete tabbarStore.tabs[key] })
router.push({ name })
}
</script>
<style lang="scss" scoped>
:deep(.el-tabs) {
.el-tabs--border-card {
border: none;
}
.el-tabs__header {
margin: 0;
}
.el-tabs__nav-wrap {
margin-bottom: 0;
&::after {
display: none;
}
}
.el-tabs__content {
display: none;
}
.el-tabs__item {
display: inline-flex !important;
padding: 0 20px !important;
align-items: center;
.tab-name:focus {
outline: none !important;
}
}
.el-tabs__active-bar {
display: none;
}
.el-tabs__item.is-active {
background-color: var(--el-color-primary-light-9);
}
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<div class="flex w-full h-screen">
<!-- 左侧边栏 -->
<layout-aside></layout-aside>
<!-- 左侧边栏 end -->
<el-container>
<!-- 顶部 -->
<el-header>
<layout-header></layout-header>
</el-header>
<!-- 顶部 end -->
<layout-tab />
<!-- 主体 -->
<el-main class="h-full p-0 bg-page">
<el-scrollbar>
<div class="p-[15px]">
<router-view v-slot="{ Component, route }" v-if="appStore.routeRefreshTag">
<keep-alive :include="tabbarStore.tabNames">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</router-view>
</div>
</el-scrollbar>
</el-main>
<!-- 主体 end -->
</el-container>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import layoutHeader from './components/header/index.vue'
import layoutAside from './components/aside/index.vue'
import layoutTab from './components/tabs.vue'
import useAppStore from '@/stores/modules/app'
import useTabbarStore from '@/stores/modules/tabbar'
import useSystemStore from '@/stores/modules/system'
const appStore = useAppStore()
const tabbarStore = useTabbarStore()
const systemStore = useSystemStore()
const dark = computed(() => {
return systemStore.dark
})
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,4 @@
{
"layout": "bussiness",
"cover": "/static/resource/images/system/layout_bussiness.png"
}

View File

@@ -0,0 +1,71 @@
<template>
<el-aside class="layout-aside dark w-auto">
<side class="hidden-xs-only slide" />
</el-aside>
<el-drawer v-model="systemStore.menuDrawer" direction="ltr" :with-header="false" custom-class="aside-drawer" size="210px">
<template #default>
<side />
</template>
</el-drawer>
</template>
<script lang="ts" setup>
import { watch, computed } from 'vue'
import { useRoute } from 'vue-router'
import side from './side.vue'
import useSystemStore from '@/stores/modules/system'
const systemStore = useSystemStore()
const dark = computed(() => {
return systemStore.dark
})
const route = useRoute()
watch(route, () => {
systemStore.$patch(state => {
state.menuDrawer = false
})
})
</script>
<style lang="scss">
.layout-aside {
--side-dark-color: #141414;
background-color: var(--side-dark-color, var(--el-bg-color));
&.bright {
background-color: #F5F7F9;
li {
background-color: #F5F7F9;
&.is-active:not(.is-opened) {
position: relative;
color: #333;
background-color: #fff;
&::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 2px;
background-color: var(--el-menu-active-color);
}
}
}
}
.slide {
border-right: 1px solid var(--el-border-color-extra-light);
}
}
.aside-drawer {
.el-drawer__body {
padding: 0 !important;
}
}
</style>

View File

@@ -0,0 +1,119 @@
<template>
<template v-if="meta.show">
<el-sub-menu v-if="routes.children" :index="String(routes.name)">
<template #title>
<div class="w-[16px] h-[16px] relative flex items-center" v-if="props.level == 1">
<icon v-if="meta.icon" :name="meta.icon" class="absolute !w-auto" />
</div>
<span class="ml-[10px]">{{ meta.title }}</span>
</template>
<menu-item v-for="(route, index) in routes.children" :routes="route" :key="index" :level="props.level + 1" />
<template v-if="routes.name == 'addon_list' || routes.name == 'marketing_list'">
<template v-if="addonsMenus">
<menu-item :routes="addonsMenus" :key="index" :level="props.level + 1"/>
</template>
</template>
</el-sub-menu>
<template v-else>
<el-menu-item :index="String(routes.name)" @click="router.push({ name: routes.name })" v-if="meta.addon && meta.parent_route && meta.parent_route.addon == ''">
<template #title>
<div class="w-[16px] h-[16px] relative flex items-center" v-if="props.level == 1">
<icon v-if="meta.icon" :name="meta.icon" class="absolute !w-auto" />
</div>
<span class="ml-[10px]">{{ meta.title }}</span>
</template>
</el-menu-item>
<el-menu-item :index="String(routes.name)" @click="router.push({ name: routes.name })" v-else>
<template #title>
<div class="w-[16px] h-[16px] relative flex items-center" v-if="props.level == 1">
<icon v-if="meta.icon" :name="meta.icon" class="absolute !w-auto" />
</div>
<span class="ml-[10px]">{{ meta.title }}</span>
</template>
</el-menu-item>
</template>
<div v-if="routes.is_border" class="!border-0 !border-t-[1px] border-solid mx-[25px] bg-[#f7f7f7] my-[5px]"></div>
</template>
</template>
<script lang="ts" setup>
import { useRouter, useRoute } from 'vue-router'
import { ref, computed, watch, onMounted } from 'vue'
import menuItem from './menu-item.vue'
import useSystemStore from '@/stores/modules/system'
import useUserStore from '@/stores/modules/user'
import storage from '@/utils/storage'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const routers = useUserStore().routers
const props = defineProps({
routes: {
type: Object,
required: true
},
level: {
type: Number,
default: 1
}
})
const systemStore = useSystemStore()
const meta = computed(() => props.routes.meta)
const addons = computed(() => {
const addons:Record<string, any> = {}
userStore.siteInfo?.apps.forEach((item: any) => { addons[item.key] = item })
userStore.siteInfo?.site_addons.forEach((item: any) => { addons[item.key] = item })
return addons
})
const systemAddonKeys = computed(() => {
return userStore.siteInfo?.site_addons.map((item: any) => item.key)
})
const addonRouters: Record<string, any> = {}
routers.forEach(item => {
item.original_name = item.name
if (item.meta.addon) {
addonRouters[item.meta.addon] = item
}
if (item.meta.attr) {
addonRouters[item.meta.attr] = item
}
})
const addonsMenus = ref(null)
watch(route, () => {
if (props.routes.name == 'addon_list') {
if (systemAddonKeys.value.includes(route.meta.addon) && addonRouters[route.meta.addon]) {
addonsMenus.value = addonRouters[route.meta.addon]
} else if (route.meta.attr && addonRouters[route.meta.attr]) {
addonsMenus.value = addonRouters[route.meta.attr]
} else {
addonsMenus.value = null
}
}
const marketingKeys = storage.get('darksideMarketingKeys')
const matchedName = route.matched[1]?.name
if (props.routes.name == 'marketing_list') {
if (marketingKeys && marketingKeys.includes(matchedName)) {
addonsMenus.value = route.matched[1] ?? []
addonsMenus.value.meta.show = 1
} else {
addonsMenus.value = null
}
}
}, { immediate: true })
</script>
<style lang="scss">
.el-sub-menu{
.el-icon{
width: auto;
}
}
</style>

View File

@@ -0,0 +1,151 @@
<template>
<el-container class="w-[200px] h-screen flex flex-col">
<el-header class="logo-wrap flex items-center justify-center h-[64px]">
<div class="logo flex items-center m-auto h-[64px]" v-if="!systemStore.menuIsCollapse">
<el-image style="width: 40px; height: 40px" :src="img(logoUrl)" fit="contain">
<template #error>
<div class="flex justify-center items-center w-full h-[40px]"><img class="max-w-[40px]" src="@/app/assets/images/icon-addon-one.png" alt="" object-fit="contain"></div>
</template>
</el-image>
</div>
<div class="logo flex items-center justify-center h-[64px]" v-else>
<i class="text-3xl iconfont iconyunkongjian"></i>
</div>
</el-header>
<el-main class="menu-wrap">
<el-scrollbar>
<el-menu :default-active="route.name" :router="true" background-color="--side-dark-color" text-color="#fff" active-text-color="#fff" :unique-opened="true" :collapse="systemStore.menuIsCollapse" >
<menu-item v-for="(route, index) in menuData" :routes="route" :key="index" />
</el-menu>
<div class="h-[48px]"></div>
</el-scrollbar>
</el-main>
</el-container>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import useSystemStore from '@/stores/modules/system'
import useUserStore from '@/stores/modules/user'
import menuItem from './menu-item.vue'
import { img } from '@/utils/common'
import { findFirstValidRoute } from '@/router/routers'
import { getShowMarketing } from '@/app/api/site'
import storage from '@/utils/storage'
const systemStore = useSystemStore()
const userStore = useUserStore()
const route = useRoute()
const siteInfo = userStore.siteInfo
const routers = userStore.routers
const addonIndexRoute = userStore.addonIndexRoute
const menuData = ref<Record<string, any>[]>([])
const addonRouters: Record<string, any> = {}
const logoUrl = computed(() => {
return userStore.siteInfo.icon ? userStore.siteInfo.icon : systemStore.website.icon
})
const getMarketingList = async () => {
const res = await getShowMarketing()
const marketingList = res.data
const marketingKeys = marketingList?.marketing?.list?.map(item => item.key) ?? []
// menuData.value.forEach((item, index, arr) => {
// if (marketingKeys.includes(item.name)) {
// arr.splice(index, 1)
// }
// })
storage.set({ key: 'darksideMarketingKeys', data: marketingKeys })
}
onMounted(() => {
getMarketingList()
})
routers.forEach((item, index) => {
item.original_name = item.name
if (item.meta.addon == '') {
if (item.meta.attr == '' && item.name != 'sign' && item.name != 'verify') {
if (item.children && item.children.length) {
item.name = findFirstValidRoute(item.children)
}
menuData.value.push(item)
}
} else if (item.meta.addon != '' && siteInfo?.apps.length == 1 && siteInfo?.apps[0].key == item.meta.addon && item.meta.show) {
if (item.children) {
item.children.forEach((citem: Record<string, any>) => {
citem.original_name = citem.name
if (citem.children && citem.children.length) {
citem.name = findFirstValidRoute(citem.children)
}
})
menuData.value.unshift(...item.children)
} else {
menuData.value.unshift(item)
}
} else {
addonRouters[item.meta.addon] = item
}
// 排序, 功能正确,改了排序后需要把菜单排序的默认值重新调整一下【多应用一级菜单,单应用二级菜单】
// menuData.value.sort((a, b) => {
// if (a.meta.sort && b.meta.sort) {
// return b.meta.sort - a.meta.sort
// } else if (a.meta.sort) {
// return -1
// } else if (b.meta.sort) {
// return 1
// } else {
// return 0
// }
// })
})
// 多应用时将应用插入菜单
if (siteInfo?.apps.length > 1) {
const routers:Record<string, any>[] = []
siteInfo?.apps.forEach((item: Record<string, any>) => {
if (addonRouters[item.key]) {
addonRouters[item.key].name = addonIndexRoute[item.key]
routers.push(addonRouters[item.key])
}
})
menuData.value.unshift(...routers)
// 排序, 功能正确,改了排序后需要把菜单排序的默认值重新调整一下【多应用一级菜单,单应用二级菜单】
// menuData.value.sort((a, b) => {
// if (a.meta.sort && b.meta.sort) {
// return b.meta.sort - a.meta.sort
// } else if (a.meta.sort) {
// return -1
// } else if (b.meta.sort) {
// return 1
// } else {
// return 0
// }
// })
}
</script>
<style lang="scss">
.menu-wrap {
padding: 0!important;
.el-menu {
border-right: 0!important;
.el-menu-item, .el-sub-menu__title {
--el-menu-item-height: 40px;
}
.el-sub-menu .el-menu-item {
--el-menu-sub-item-height: 40px;
}
.el-menu-item.is-active {
background-color: var(--el-color-primary)
}
}
}
</style>

View File

@@ -0,0 +1,285 @@
<template>
<el-container :class="['h-full px-[10px]',{'layout-header border-b border-color': !dark}]" >
<el-row class="w-100 h-full w-full">
<el-col :span="10">
<div class="left-panel h-full flex items-center">
<!-- 左侧菜单折叠 -->
<div class="hidden-sm-and-up navbar-item flex items-center h-full cursor-pointer" @click="toggleMenuCollapse">
<icon name="element Expand" v-if="systemStore.menuIsCollapse" />
<icon name="element Fold" v-else />
</div>
<!-- 刷新当前页 -->
<div class="navbar-item flex items-center h-full cursor-pointer" @click="refreshRouter">
<icon name="element Refresh" />
</div>
<!-- 面包屑导航 -->
<div class="flex items-center h-full pl-[10px]">
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="(route, index) in breadcrumb" :key="index" :to="route.path" class="inter">{{route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
</div>
</el-col>
<el-col :span="14">
<div class="right-panel h-full flex items-center justify-end">
<div class="flex items-center flex-shrink-0 hidden-xs-only">
<el-dropdown trigger="hover" :hide-on-click="false" popper-class="site-info-wrap" class="mr-[8px]">
<!-- 状态 -->
<div class="mx-[8px] bg-[#f6f6f6] border-[1px] border-solid border-[#eee] rounded-[4px] px-[9px] py-[6px] flex items-center">
<span class="mr-[6px] text-[12px] !text-[#333]">{{siteInfo.site_name}}</span>
<span class="!text-[10px] text-[#f56c6c]" :class="{'!text-[#67c23a]': siteInfo.status == 1, '!text-[#f56c6c]': siteInfo.status == 3}">{{ siteInfo.status_name }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>
<!-- 站点id -->
<div class="text-[14px]">站点编号{{siteInfo.site_id}}</div>
</el-dropdown-item>
<el-dropdown-item>
<!-- 到期时间 -->
<div v-if="siteInfo.expire_time == 0" class="text-[14px]">到期时间永久</div>
<div v-else class="text-[14px]">到期时间{{ siteInfo.expire_time }}</div>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="flex items-center flex-shrink-0 hidden-xs-only">
<el-popover placement="bottom" :width="330" trigger="click" v-model:visible="isMenuSearch" >
<template #reference>
<i class="iconfont icona-sousuoV6xx-36 cursor-pointer px-[8px] !text-[14px]"></i>
</template>
<template #default>
<div class="flex items-center">
<el-select v-model="selectedRoute" filterable class="!w-[250px] mr-[20px] menu-select" :teleported="false" clearable @change="handleRouteSelect">
<el-option v-for="item in flatRoutes" :key="item.name" :label="item.full_title" :value="item.name" >
</el-option>
</el-select>
<el-button type="primary" link @click="isMenuSearch = false">{{t('取消')}}</el-button>
</div>
</template>
</el-popover>
</div>
<!-- 预览 只有站点时展示-->
<i class="iconfont iconicon_huojian1 cursor-pointer px-[8px]" :title="t('visitWap')" @click="toPreview"></i>
<i class="iconfont iconlingdang-xianxing cursor-pointer px-[8px]" :title="t('newInfo')" v-if="appType == 'site'"></i>
<!-- 切换语言 -->
<!-- <div class="navbar-item flex items-center h-full cursor-pointer">
<switch-lang />
</div> -->
<!-- 切换全屏 -->
<!-- <div class="navbar-item flex items-center h-full cursor-pointer" @click="toggleFullscreen">
<icon name="iconfont icontuichuquanping" v-if="isFullscreen" />
<icon name="iconfont iconquanping" v-else />
</div> -->
<!-- 布局设置 -->
<div class="navbar-item flex items-center h-full cursor-pointer">
<layout-setting />
</div>
<!-- 用户信息 -->
<div class="navbar-item flex items-center h-full cursor-pointer">
<user-info />
</div>
</div>
</el-col>
</el-row>
<input type="hidden" v-model="comparisonToken">
<input type="hidden" v-model="comparisonSiteId">
<el-dialog v-model="detectionLoginDialog" :title="t('layout.detectionLoginTip')" width="30%" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
<span>{{ t('layout.detectionLoginContent') }}</span>
<template #footer>
<span class="dialog-footer">
<el-button @click="detectionLoginFn">{{ t('layout.detectionLoginOperation') }}</el-button>
</span>
</template>
</el-dialog>
</el-container>
</template>
<script lang="ts" setup>
import { computed, ref, onMounted } from 'vue'
import layoutSetting from './layout-setting.vue'
import userInfo from './user-info.vue'
import { useFullscreen } from '@vueuse/core'
import useSystemStore from '@/stores/modules/system'
import useUserStore from '@/stores/modules/user'
import useAppStore from '@/stores/modules/app'
import { useRoute, useRouter } from 'vue-router'
import { t } from '@/lang'
import storage from '@/utils/storage'
const appType = storage.get('app_type')
const { toggle: toggleFullscreen } = useFullscreen()
const systemStore = useSystemStore()
const appStore = useAppStore()
const route = useRoute()
const router = useRouter()
const screenWidth = ref(window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth)
const userStore = useUserStore()
const siteInfo:any = computed(() => {
return userStore.siteInfo
})
const dark = computed(() => {
return systemStore.dark
})
const isMenuSearch = ref(false)
const routers = userStore.routers
const getParentTitleChain=(meta:any) =>{
let titles = []
let current = meta?.parent_route
while (current) {
if (current.short_title) {
titles.unshift(current.short_title)
}
current = current.parent_route
}
return titles.join(' - ')
}
const flattenRoutes = (routes:any, parent = null)=> {
let flat = [];
routes.forEach(route => {
const { path, name, meta = {}, short_title, children } = route
const isLeaf = meta.type ==1 && meta.show==1
if(isLeaf){
const title = meta.title || short_title || ''
const parentTitleChain = getParentTitleChain(meta)
const fullTitle = parentTitleChain ? `${parentTitleChain} - ${title}` : title
const item = {
path,
name,
title,
parent_title: parentTitleChain,
full_title: fullTitle
};
flat.push(item);
}
if (children && children.length > 0) {
flat = flat.concat(flattenRoutes(children, route))
}
});
return flat;
}
const flatRoutes = flattenRoutes(routers)
const selectedRoute = ref('')
const handleRouteSelect = (name:any) => {
if (name) {
router.push({ name })
isMenuSearch.value = false
}
}
// 检测登录 start
const detectionLoginDialog = ref(false)
const comparisonToken = ref('')
const comparisonSiteId = ref('')
if (storage.get('comparisonTokenStorage')) {
comparisonToken.value = storage.get('comparisonTokenStorage')
// storage.remove(['comparisonTokenStorage']);
}
if (storage.get('comparisonSiteIdStorage')) {
comparisonSiteId.value = storage.get('comparisonSiteIdStorage')
// storage.remove(['comparisonSiteIdStorage']);
}
// 监听标签页面切换
document.addEventListener('visibilitychange', e => {
if (document.visibilityState === 'visible' && (comparisonSiteId.value != storage.get('siteId') || comparisonToken.value != storage.get('token'))) {
detectionLoginDialog.value = true
}
})
const detectionLoginFn = () => {
detectionLoginDialog.value = false
location.href = `${location.origin}/site/`
}
// 检测登录 end
onMounted(() => {
// 监听窗体宽度变化
window.onresize = () => {
return (() => {
screenWidth.value = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth
})()
}
})
// watch(screenWidth, () => {
// if (screenWidth.value < 992) {
// if (!systemStore.menuIsCollapse) systemStore.toggleMenuCollapse(true)
// } else {
// if (systemStore.menuIsCollapse) systemStore.toggleMenuCollapse(false)
// }
// })
// 菜单栏展开折叠
const toggleMenuCollapse = () => {
systemStore.$patch((state) => {
if (screenWidth.value < 768) {
state.menuDrawer = true
state.menuIsCollapse = false
} else {
systemStore.toggleMenuCollapse(!systemStore.menuIsCollapse)
}
})
}
// 刷新路由
const refreshRouter = () => {
if (!appStore.routeRefreshTag) return
appStore.refreshRouterView()
}
// 面包屑导航
const breadcrumb = computed(() => {
const matched = route.matched.filter(item => { return item.meta.title })
if (matched[0] && matched[0].path == '/') matched.splice(0, 1)
return matched
})
// 跳转去预览
const toPreview = () => {
const url = router.resolve({
path: '/preview/wap',
query: {
page:'/'
}
})
window.open(url.href)
}
</script>
<style lang="scss" scoped>
.layout-header{
position: relative;
z-index: 5;
box-shadow: 0 0 4px 0 rgba(0,145,255,0.1);
}
.navbar-item {
padding: 0 8px;
&:hover {
background-color: var(--el-bg-color-page);
}
}
.index-item {
border: 1px solid;
border-color: var(--el-color-primary);
&:hover {
color: #fff;
background-color: var(--el-color-primary);
}
}
:deep(.el-dropdown-menu__item) {
&:focus {
background-color: transparent !important;
color: #333 !important;
}
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<div class="flex">
<icon name="element Setting" @click="drawer = true" />
<el-drawer v-model="drawer" :title="t('layout.layoutSetting')" size="300px">
<el-scrollbar>
<!-- 黑暗模式 -->
<div class="setting-item flex items-center justify-between mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.darkMode') }}</div>
<div>
<el-switch v-model="dark" :active-value="true" :inactive-value="false" />
</div>
</div>
<!-- 主题颜色 -->
<div class="setting-item flex items-center justify-between mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.themeColor') }}</div>
<div>
<el-color-picker v-model="theme" />
</div>
</div>
<!-- 标签栏 -->
<div class="setting-item flex items-center justify-between mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.tab') }}</div>
<div>
<el-switch v-model="tab" :active-value="true" :inactive-value="false" />
</div>
</div>
</el-scrollbar>
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import useSystemStore from '@/stores/modules/system'
import { useDark, useToggle } from '@vueuse/core'
import { setThemeColor } from '@/utils/common'
import { t } from '@/lang'
import storage from "@/utils/storage";
const drawer = ref(false)
const systemStore = useSystemStore()
const isDark = useDark()
const toggleDark = useToggle(isDark)
const dark = computed({
get () {
return systemStore.dark
},
set (val) {
systemStore.setTheme('dark', val)
toggleDark(val)
setThemeColor(systemStore.theme, systemStore.dark ? 'dark' : 'light')
}
})
const tab = computed({
get () {
return systemStore.tab
},
set (val) {
systemStore.$patch((state) => {
state.tab = val
storage.set({ key: 'tab', data: val })
})
}
})
const theme = computed({
get () {
return systemStore.theme
},
set (val) {
systemStore.setTheme('theme', val)
setThemeColor(systemStore.theme, systemStore.dark ? 'dark' : 'light')
}
})
</script>
<style lang="scss" scoped>
:deep(.el-drawer__header) {
margin-bottom: 0 !important;
}
.layout-style {
&>div:nth-child(2n+2) {
margin-right: 0;
}
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<el-dropdown @command="switchLang" :tabindex="1">
<icon name="iconfont iconfanyi" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="zh-cn" :disabled="systemStore.lang == 'zh-cn'">简体中文</el-dropdown-item>
<el-dropdown-item command="en" :disabled="systemStore.lang == 'en'">English</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script lang="ts" setup>
import useSystemStore from '@/stores/modules/system'
import { language } from '@/lang'
import { useRoute } from 'vue-router'
import storage from '@/utils/storage'
const route = useRoute()
const systemStore = useSystemStore()
const switchLang = (command: string) => {
systemStore.$patch((state) => {
state.lang = command
storage.set({ key: 'lang', data: command })
})
language.loadLocaleMessages(route.path, systemStore.lang)
location.reload()
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,159 @@
<template>
<div>
<el-dropdown @command="clickEvent" :tabindex="1">
<div class="userinfo flex h-full items-center">
<el-avatar v-if="userStore.userInfo.head_img" :size="25" :icon="UserFilled" :src="img(userStore.userInfo.head_img)"/>
<img v-else src="@/app/assets/images/member_head.png" class="w-[25px] rounded-full" />
<div class="user-name pl-[8px]">{{ userStore.userInfo.username }}</div>
<icon name="element ArrowDown" class="ml-[5px]" />
</div>
<template #dropdown>
<div class="p-[10px]">
<div class="userinfo flex h-full items-center pb-[10px] border-b-[1px] border-solid border-[#e5e5e5]">
<el-avatar v-if="userStore.userInfo.head_img" :size="45" :icon="UserFilled" :src="img(userStore.userInfo.head_img)"/>
<img v-else src="@/app/assets/images/member_head.png" class="w-[45px] rounded-full" />
<div>
<div class="user-name pl-[8px] text-[14px]">{{ userStore.userInfo.username }}</div>
<div class="pl-[8px] text-[13px] text-[#9699B6]">个人中心</div>
</div>
</div>
<el-dropdown-menu>
<el-dropdown-item @click="toLink('/home/index')" v-if="isAllowChange">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconqiehuan ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">切换站点</span>
</div>
</el-dropdown-item>
<el-dropdown-item @click="getUserInfoFn">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconshezhi1 ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">账号设置</span>
</div>
</el-dropdown-item>
<el-dropdown-item @click="changePasswordDialog=true">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconxiugai ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">修改密码</span>
</div>
</el-dropdown-item>
<el-dropdown-item @click="logout">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont icontuichudenglu ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">退出登录</span>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</div>
</template>
</el-dropdown>
<el-dialog v-model="changePasswordDialog" width="450px" title="修改密码">
<div>
<el-form :model="saveInfo" label-width="90px" ref="formRef" :rules="formRules" class="page-form">
<el-form-item :label="t('originalPassword')" prop="original_password">
<el-input v-model="saveInfo.original_password" type="password" :placeholder="t('originalPasswordPlaceholder')" clearable class="input-width" maxlength="40" />
</el-form-item>
<el-form-item :label="t('newPassword')" prop="password">
<el-input v-model="saveInfo.password" type="password" :placeholder="t('passwordPlaceholder')" clearable class="input-width" maxlength="40" />
<div class="form-tip">{{t('passwordTip')}}</div>
</el-form-item>
<el-form-item :label="t('passwordCopy')" prop="password_copy">
<el-input v-model="saveInfo.password_copy" type="password" :placeholder="t('passwordPlaceholder')" clearable class="input-width" maxlength="40" />
</el-form-item>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="changePasswordDialog = false">{{t('cancel')}}</el-button>
<el-button type="primary" @click="submitForm(formRef)">{{t('save')}}</el-button>
</span>
</template>
</el-dialog>
<user-info-edit ref="userInfoEditRef" />
</div>
</template>
<script lang="ts" setup>
import { UserFilled } from '@element-plus/icons-vue'
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { FormInstance, FormRules, ElNotification } from 'element-plus'
import useUserStore from '@/stores/modules/user'
import { setUserInfo } from '@/app/api/personal'
import { t } from '@/lang'
import userInfoEdit from '@/app/components/user-info-edit/index.vue'
import { img } from '@/utils/common'
const isAllowChange = localStorage.getItem('isAllowChange') === 'true';
const userStore = useUserStore()
const router = useRouter()
const clickEvent = (command: string) => {
switch (command) {
case 'logout':
userStore.logout()
break
}
}
const logout = () => {
userStore.logout()
}
const toLink = (link) => {
router.push(link)
}
const userInfoEditRef = ref(null)
const getUserInfoFn = () => {
userInfoEditRef.value?.open()
}
// 修改密码 --- start
const changePasswordDialog = ref(false)
const formRef = ref<FormInstance>()
// 提交信息
const saveInfo = reactive({
original_password: '',
password: '',
password_copy: ''
})
// 表单验证规则
const formRules = reactive<FormRules>({
original_password: [
{ required: true, message: t('originalPasswordPlaceholder'), trigger: 'blur' }
],
password: [
{ required: true, message: t('passwordPlaceholder'), trigger: 'blur' }
],
password_copy: [
{ required: true, message: t('passwordPlaceholder'), trigger: 'blur' }
]
})
const submitForm = (formEl: FormInstance | undefined) => {
if (!formEl) return
formEl.validate((valid) => {
if (valid) {
let msg = ''
if (saveInfo.password && !saveInfo.original_password) msg = t('originalPasswordHint')
if (saveInfo.password && saveInfo.original_password && !saveInfo.password_copy) msg = t('newPasswordHint')
if (saveInfo.password && saveInfo.original_password && saveInfo.password_copy && saveInfo.password != saveInfo.password_copy) msg = t('doubleCipherHint')
if (msg) {
ElNotification({
type: 'error',
message: msg
})
return
}
setUserInfo(saveInfo).then((res: any) => {
changePasswordDialog.value = false
})
} else {
return false
}
})
}
</script>
<style lang="scss" scoped>
.el-popper .el-dropdown-menu{
width: 150px;
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<div class="tab-wrap w-full px-[16px]" v-show="systemStore.tab">
<el-tabs :closable="tabbarStore.tabLength > 1" :model-value="route.name" @tab-click="tabClick" @tab-remove="removeTab">
<el-tab-pane v-for="(tab, key, index) in tabbarStore.tabs" :name="tab.name" :key="index">
<template #label>
<el-dropdown trigger="contextmenu" placement="bottom-start">
<span :class="{ 'text-primary': route.name == tab.name }" class="tab-name">{{ tab.title }}</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item icon="Back" :disabled="index == 0" @click="closeLeft(tab.name)">{{t('tabs.closeLeft') }}</el-dropdown-item>
<el-dropdown-item icon="Right" :disabled="index == (tabbarStore.tabLength - 1)" @click="closeRight(tab.name)">{{t('tabs.closeRight') }}</el-dropdown-item>
<el-dropdown-item icon="Close" :disabled="tabbarStore.tabLength == 1" @click="closeOther(tab.name)">{{t('tabs.closeOther') }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script lang="ts" setup>
import { watch, onMounted } from 'vue'
import useTabbarStore from '@/stores/modules/tabbar'
import useSystemStore from '@/stores/modules/system'
import { useRoute, useRouter } from 'vue-router'
import { t } from '@/lang'
const tabbarStore = useTabbarStore()
const systemStore = useSystemStore()
const route = useRoute()
const router = useRouter()
onMounted(() => {
tabbarStore.addTab(route)
})
watch(route, (nval: any) => {
tabbarStore.addTab(nval)
})
/**
* 添加tab
* @param content
*/
const tabClick = (content: any) => {
const tabRoute = tabbarStore.tabs[content.props.name]
router.push({ name: tabRoute.name, query: tabRoute.query })
}
/**
* 移除tab
* @param content
*/
const removeTab = (content: any) => {
if (route.name == content) {
const tabs = Object.keys(tabbarStore.tabs)
if (tabs.indexOf(content) == 0) {
router.push({ name: tabs[1] })
} else {
router.push({ name: tabs[tabs.indexOf(content) - 1] })
}
}
tabbarStore.removeTab(content)
}
/**
* 关闭左侧
* @param name
*/
const closeLeft = (name: string) => {
const tabs = Object.keys(tabbarStore.tabs)
for (let i = tabs.indexOf(name) - 1; i >= 0; i--) {
delete tabbarStore.tabs[tabs[i]]
}
router.push({ name })
}
/**
* 关闭右侧
* @param name
*/
const closeRight = (name: string) => {
const tabs = Object.keys(tabbarStore.tabs)
for (let i = tabs.indexOf(name) + 1; i < tabs.length; i++) {
delete tabbarStore.tabs[tabs[i]]
}
router.push({ name })
}
/**
* 关闭其他
* @param name
*/
const closeOther = (name: string) => {
const tabs = Object.keys(tabbarStore.tabs)
tabs.forEach((key: string) => { key != name && delete tabbarStore.tabs[key] })
router.push({ name })
}
</script>
<style lang="scss" scoped>
:deep(.el-tabs) {
.el-tabs--border-card {
border: none;
}
.el-tabs__header {
margin: 0;
}
.el-tabs__nav-wrap {
margin-bottom: 0;
&::after {
display: none;
}
}
.el-tabs__content {
display: none;
}
.el-tabs__item {
display: inline-flex !important;
padding: 0 20px !important;
align-items: center;
.tab-name:focus {
outline: none !important;
}
}
.el-tabs__active-bar {
display: none;
}
.el-tabs__item.is-active {
background-color: var(--el-color-primary-light-9);
}
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<div class="flex w-full h-screen">
<!-- 左侧边栏 -->
<layout-aside></layout-aside>
<!-- 左侧边栏 end -->
<el-container>
<!-- 顶部 -->
<el-header>
<layout-header></layout-header>
</el-header>
<!-- 顶部 end -->
<layout-tab />
<!-- 主体 -->
<el-main class="h-full p-0 bg-page">
<el-scrollbar>
<div class="p-[15px]">
<router-view v-slot="{ Component, route }" v-if="appStore.routeRefreshTag">
<keep-alive :include="tabbarStore.tabNames">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</router-view>
</div>
</el-scrollbar>
</el-main>
<!-- 主体 end -->
</el-container>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import layoutHeader from './components/header/index.vue'
import layoutAside from './components/aside/index.vue'
import layoutTab from './components/tabs.vue'
import useAppStore from '@/stores/modules/app'
import useTabbarStore from '@/stores/modules/tabbar'
import useSystemStore from '@/stores/modules/system'
const appStore = useAppStore()
const tabbarStore = useTabbarStore()
const systemStore = useSystemStore()
const dark = computed(() => {
return systemStore.dark
})
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,4 @@
{
"layout": "darkside",
"cover": "/static/resource/images/system/layout_darkside.png"
}

View File

@@ -0,0 +1,10 @@
<template>
<el-container class="w-screen h-screen min-w-[1200px]">
<router-view></router-view>
</el-container>
</template>
<script lang="ts" setup>
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,68 @@
<template>
<el-aside class="layout-aside w-auto">
<side class="hidden-xs-only slide" />
</el-aside>
<el-drawer v-model="systemStore.menuDrawer" direction="ltr" :with-header="false" custom-class="aside-drawer" size="210px">
<template #default>
<side />
</template>
</el-drawer>
</template>
<script lang="ts" setup>
import { watch, computed } from 'vue'
import { useRoute } from 'vue-router'
import side from './side.vue'
import useSystemStore from '@/stores/modules/system'
const systemStore = useSystemStore()
const dark = computed(() => {
return systemStore.dark
})
const route = useRoute()
watch(route, () => {
systemStore.$patch(state => {
state.menuDrawer = false
})
})
</script>
<style lang="scss">
.layout-aside {
&.bright {
background-color: #F5F7F9;
li {
background-color: #F5F7F9;
&.is-active:not(.is-opened) {
position: relative;
color: #333;
background-color: #fff;
&::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 2px;
background-color: var(--el-menu-active-color);
}
}
}
}
.slide {
border-right: 1px solid var(--el-border-color-extra-light);
}
}
.aside-drawer {
.el-drawer__body {
padding: 0 !important;
}
}
</style>

View File

@@ -0,0 +1,119 @@
<template>
<template v-if="meta.show">
<el-sub-menu v-if="routes.children" :index="String(routes.name)">
<template #title>
<div class="w-[16px] h-[16px] relative flex items-center" v-if="props.level == 1">
<icon v-if="meta.icon" :name="meta.icon" class="absolute !w-auto" />
</div>
<span class="ml-[10px]">{{ meta.title }}</span>
</template>
<menu-item v-for="(route, index) in routes.children" :routes="route" :key="index" :level="props.level + 1" />
<template v-if="routes.name == 'addon_list' || routes.name == 'marketing_list'">
<template v-if="addonsMenus">
<menu-item :routes="addonsMenus" :key="index" :level="props.level + 1"/>
</template>
</template>
</el-sub-menu>
<template v-else>
<el-menu-item :index="String(routes.name)" @click="router.push({ name: routes.name })" v-if="meta.addon && meta.parent_route && meta.parent_route.addon == ''">
<template #title>
<div class="w-[16px] h-[16px] relative flex items-center" v-if="props.level == 1">
<icon v-if="meta.icon" :name="meta.icon" class="absolute !w-auto" />
</div>
<span class="ml-[10px]">{{ meta.title }}</span>
</template>
</el-menu-item>
<el-menu-item :index="String(routes.name)" @click="router.push({ name: routes.name })" v-else>
<template #title>
<div class="w-[16px] h-[16px] relative flex items-center" v-if="props.level == 1">
<icon v-if="meta.icon" :name="meta.icon" class="absolute !w-auto" />
</div>
<span class="ml-[10px]">{{ meta.title }}</span>
</template>
</el-menu-item>
</template>
<div v-if="routes.is_border" class="!border-0 !border-t-[1px] border-solid mx-[25px] bg-[#f7f7f7] my-[5px]"></div>
</template>
</template>
<script lang="ts" setup>
import { useRouter, useRoute } from 'vue-router'
import { ref, computed, watch } from 'vue'
import menuItem from './menu-item.vue'
import useSystemStore from '@/stores/modules/system'
import useUserStore from '@/stores/modules/user'
import storage from '@/utils/storage'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const routers = useUserStore().routers
const props = defineProps({
routes: {
type: Object,
required: true
},
level: {
type: Number,
default: 1
}
})
const systemStore = useSystemStore()
const meta = computed(() => props.routes.meta)
const addons = computed(() => {
const addons:Record<string, any> = {}
userStore.siteInfo?.apps.forEach((item: any) => { addons[item.key] = item })
userStore.siteInfo?.site_addons.forEach((item: any) => { addons[item.key] = item })
return addons
})
const systemAddonKeys = computed(() => {
return userStore.siteInfo?.site_addons.map((item: any) => item.key)
})
const addonRouters: Record<string, any> = {}
routers.forEach(item => {
item.original_name = item.name
if (item.meta.addon) {
addonRouters[item.meta.addon] = item
}
if (item.meta.attr) {
addonRouters[item.meta.attr] = item
}
})
const addonsMenus = ref(null)
watch(route, () => {
if (props.routes.name == 'addon_list') {
if (systemAddonKeys.value.includes(route.meta.addon) && addonRouters[route.meta.addon]) {
addonsMenus.value = addonRouters[route.meta.addon]
} else if (route.meta.attr && addonRouters[route.meta.attr]) {
addonsMenus.value = addonRouters[route.meta.attr]
} else {
addonsMenus.value = null
}
}
const marketingKeys = storage.get('defaultMarketingKeys')
const matchedName = route.matched[1]?.name
if (props.routes.name == 'marketing_list') {
if (marketingKeys && marketingKeys.includes(matchedName)) {
addonsMenus.value = route.matched[1] ?? []
addonsMenus.value.meta.show = 1
} else {
addonsMenus.value = null
}
}
}, { immediate: true })
</script>
<style lang="scss">
.el-sub-menu{
.el-icon{
width: auto;
}
}
</style>

View File

@@ -0,0 +1,146 @@
<template>
<el-container class="w-[200px] h-screen layout-aside flex flex-col">
<el-header class="logo-wrap flex items-center justify-center h-[64px]">
<div class="logo flex items-center m-auto h-[64px]" v-if="!systemStore.menuIsCollapse">
<el-image style="width: 40px; height: 40px" :src="img(logoUrl)" fit="contain">
<template #error>
<div class="flex justify-center items-center w-full h-[40px]"><img class="max-w-[40px]" src="@/app/assets/images/icon-addon-one.png" alt="" object-fit="contain"></div>
</template>
</el-image>
</div>
<div class="logo flex items-center justify-center h-[64px]" v-else>
<i class="text-3xl iconfont iconyunkongjian"></i>
</div>
</el-header>
<el-main class="menu-wrap">
<el-scrollbar>
<el-menu :default-active="route.name" :router="true" class="aside-menu h-full" :unique-opened="true" :collapse="systemStore.menuIsCollapse" >
<menu-item v-for="(route, index) in menuData" :routes="route" :key="index" />
</el-menu>
<div class="h-[48px]"></div>
</el-scrollbar>
</el-main>
</el-container>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import useSystemStore from '@/stores/modules/system'
import useUserStore from '@/stores/modules/user'
import menuItem from './menu-item.vue'
import { img } from '@/utils/common'
import { findFirstValidRoute } from '@/router/routers'
import { getShowMarketing } from '@/app/api/site'
import storage from '@/utils/storage'
const systemStore = useSystemStore()
const userStore = useUserStore()
const route = useRoute()
const siteInfo = userStore.siteInfo
const routers = userStore.routers
const addonIndexRoute = userStore.addonIndexRoute
const menuData = ref<Record<string, any>[]>([])
const addonRouters: Record<string, any> = {}
const logoUrl = computed(() => {
return userStore.siteInfo.icon ? userStore.siteInfo.icon : systemStore.website.icon
})
const getMarketingList = async () => {
const res = await getShowMarketing()
const marketingList = res.data
const marketingKeys = marketingList?.marketing?.list?.map(item => item.key) ?? []
// menuData.value.forEach((item, index, arr) => {
// if (marketingKeys.includes(item.name)) {
// arr.splice(index, 1)
// }
// })
storage.set({ key: 'defaultMarketingKeys', data: marketingKeys })
}
onMounted(() => {
getMarketingList()
})
routers.forEach(item => {
item.original_name = item.name
if (item.meta.addon == '') {
if (item.meta.attr == '' && item.name != 'sign' && item.name != 'verify') {
if (item.children && item.children.length) {
item.name = findFirstValidRoute(item.children)
}
menuData.value.push(item)
}
} else if (item.meta.addon != '' && siteInfo?.apps.length == 1 && siteInfo?.apps[0].key == item.meta.addon && item.meta.show) {
if (item.children) {
item.children.forEach((citem: Record<string, any>) => {
citem.original_name = citem.name
if (citem.children && citem.children.length) {
citem.name = findFirstValidRoute(citem.children)
}
})
menuData.value.unshift(...item.children)
} else {
menuData.value.unshift(item)
}
} else {
addonRouters[item.meta.addon] = item
}
// 排序, 功能正确,改了排序后需要把菜单排序的默认值重新调整一下【多应用一级菜单,单应用二级菜单】
// menuData.value.sort((a, b) => {
// if (a.meta.sort && b.meta.sort) {
// return b.meta.sort - a.meta.sort
// } else if (a.meta.sort) {
// return -1
// } else if (b.meta.sort) {
// return 1
// } else {
// return 0
// }
// })
})
// 多应用时将应用插入菜单
if (siteInfo?.apps.length > 1) {
const routers:Record<string, any>[] = []
siteInfo?.apps.forEach((item: Record<string, any>) => {
if (addonRouters[item.key]) {
addonRouters[item.key].name = addonIndexRoute[item.key]
routers.push(addonRouters[item.key])
}
})
menuData.value.unshift(...routers)
// 排序, 功能正确,改了排序后需要把菜单排序的默认值重新调整一下【多应用一级菜单,单应用二级菜单】
// menuData.value.sort((a, b) => {
// if (a.meta.sort && b.meta.sort) {
// return b.meta.sort - a.meta.sort
// } else if (a.meta.sort) {
// return -1
// } else if (b.meta.sort) {
// return 1
// } else {
// return 0
// }
// })
}
</script>
<style lang="scss">
.menu-wrap {
padding: 0!important;
.el-menu {
border-right: 0!important;
.el-menu-item, .el-sub-menu__title {
--el-menu-item-height: 40px;
}
.el-sub-menu .el-menu-item {
--el-menu-sub-item-height: 40px;
}
}
}
</style>

View File

@@ -0,0 +1,288 @@
<template>
<el-container :class="['h-full px-[10px]',{'layout-header border-b border-color': !dark}]" >
<el-row class="w-100 h-full w-full">
<el-col :span="10">
<div class="left-panel h-full flex items-center">
<!-- 左侧菜单折叠 -->
<div class="hidden-sm-and-up navbar-item flex items-center h-full cursor-pointer" @click="toggleMenuCollapse">
<icon name="element Expand" v-if="systemStore.menuIsCollapse" />
<icon name="element Fold" v-else />
</div>
<!-- 刷新当前页 -->
<div class="navbar-item flex items-center h-full cursor-pointer" @click="refreshRouter">
<icon name="element Refresh" />
</div>
<!-- 面包屑导航 -->
<div class="flex items-center h-full pl-[10px]">
<el-breadcrumb separator="/">
<!-- :to="route.path" class="inter" 这种修改方式导致部分跳转不对需要重新调整菜单才可以 -->
<el-breadcrumb-item v-for="(route, index) in breadcrumb" :to="route.path" class="inter" :key="index">{{route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
</div>
</el-col>
<el-col :span="14">
<div class="right-panel h-full flex items-center justify-end">
<div class="flex items-center flex-shrink-0 hidden-xs-only">
<el-dropdown trigger="hover" :hide-on-click="false" popper-class="site-info-wrap" class="mr-[8px]">
<!-- 状态 -->
<div class="mx-[8px] bg-[#f6f6f6] border-[1px] border-solid border-[#eee] rounded-[4px] px-[9px] py-[6px] flex items-center">
<span class="mr-[6px] text-[12px] !text-[#333]">{{siteInfo.site_name}}</span>
<span class="!text-[10px] text-[#f56c6c]" :class="{'!text-[#67c23a]': siteInfo.status == 1, '!text-[#f56c6c]': siteInfo.status == 3}">{{ siteInfo.status_name }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>
<!-- 站点id -->
<div class="text-[14px]">站点编号{{siteInfo.site_id}}</div>
</el-dropdown-item>
<el-dropdown-item>
<!-- 到期时间 -->
<div v-if="siteInfo.expire_time == 0" class="text-[14px]">到期时间永久</div>
<div v-else class="text-[14px]">到期时间{{ siteInfo.expire_time }}</div>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="flex items-center flex-shrink-0 hidden-xs-only">
<el-popover placement="bottom" :width="330" trigger="click" v-model:visible="isMenuSearch" >
<template #reference>
<i class="iconfont icona-sousuoV6xx-36 cursor-pointer px-[8px] !text-[14px]"></i>
</template>
<template #default>
<div class="flex items-center">
<el-select v-model="selectedRoute" filterable class="!w-[250px] mr-[20px] menu-select" :teleported="false" clearable @change="handleRouteSelect">
<el-option v-for="item in flatRoutes" :key="item.name" :label="item.full_title" :value="item.name" >
</el-option>
</el-select>
<el-button type="primary" link @click="isMenuSearch = false">{{t('取消')}}</el-button>
</div>
</template>
</el-popover>
</div>
<!-- 预览 只有站点时展示-->
<i class="iconfont iconicon_huojian1 cursor-pointer px-[8px]" :title="t('visitWap')" @click="toPreview"></i>
<i class="iconfont iconlingdang-xianxing cursor-pointer px-[8px]" :title="t('newInfo')" v-if="appType == 'site'"></i>
<!-- 切换语言 -->
<!-- <div class="navbar-item flex items-center h-full cursor-pointer">
<switch-lang />
</div> -->
<!-- 切换全屏 -->
<!-- <div class="navbar-item flex items-center h-full cursor-pointer" @click="toggleFullscreen">
<icon name="iconfont icontuichuquanping" v-if="isFullscreen" />
<icon name="iconfont iconquanping" v-else />
</div> -->
<!-- 布局设置 -->
<div class="navbar-item flex items-center h-full cursor-pointer">
<layout-setting />
</div>
<!-- 用户信息 -->
<div class="navbar-item flex items-center h-full cursor-pointer">
<user-info />
</div>
</div>
</el-col>
</el-row>
<input type="hidden" v-model="comparisonToken">
<input type="hidden" v-model="comparisonSiteId">
<el-dialog v-model="detectionLoginDialog" :title="t('layout.detectionLoginTip')" width="30%" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
<span>{{ t('layout.detectionLoginContent') }}</span>
<template #footer>
<span class="dialog-footer">
<el-button @click="detectionLoginFn">{{ t('layout.detectionLoginOperation') }}</el-button>
</span>
</template>
</el-dialog>
</el-container>
</template>
<script lang="ts" setup>
import { computed, ref, onMounted } from 'vue'
import layoutSetting from './layout-setting.vue'
import userInfo from './user-info.vue'
import { useFullscreen } from '@vueuse/core'
import useSystemStore from '@/stores/modules/system'
import useUserStore from '@/stores/modules/user'
import useAppStore from '@/stores/modules/app'
import { useRoute, useRouter } from 'vue-router'
import { t } from '@/lang'
import storage from '@/utils/storage'
const appType = storage.get('app_type')
const { toggle: toggleFullscreen } = useFullscreen()
const systemStore = useSystemStore()
const appStore = useAppStore()
const route = useRoute()
const router = useRouter()
const screenWidth = ref(window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth)
const userStore = useUserStore()
const siteInfo:any = computed(() => {
return userStore.siteInfo
})
const dark = computed(() => {
return systemStore.dark
})
const isMenuSearch = ref(false)
const routers = userStore.routers
const getParentTitleChain=(meta:any) =>{
let titles = []
let current = meta?.parent_route
while (current) {
if (current.short_title) {
titles.unshift(current.short_title)
}
current = current.parent_route
}
return titles.join(' - ')
}
const flattenRoutes = (routes:any, parent = null)=> {
let flat = [];
routes.forEach(route => {
const { path, name, meta = {}, short_title, children } = route
const isLeaf = meta.type ==1 && meta.show==1
if(isLeaf){
const title = meta.title || short_title || ''
const parentTitleChain = getParentTitleChain(meta)
const fullTitle = parentTitleChain ? `${parentTitleChain} - ${title}` : title
const item = {
path,
name,
title,
parent_title: parentTitleChain,
full_title: fullTitle
};
flat.push(item);
}
if (children && children.length > 0) {
flat = flat.concat(flattenRoutes(children, route))
}
});
return flat;
}
const flatRoutes = flattenRoutes(routers)
const selectedRoute = ref('')
const handleRouteSelect = (name:any) => {
if (name) {
router.push({ name })
isMenuSearch.value = false
}
}
// 检测登录 start
const detectionLoginDialog = ref(false)
const comparisonToken = ref('')
const comparisonSiteId = ref('')
if (storage.get('comparisonTokenStorage')) {
comparisonToken.value = storage.get('comparisonTokenStorage')
// storage.remove(['comparisonTokenStorage']);
}
if (storage.get('comparisonSiteIdStorage')) {
comparisonSiteId.value = storage.get('comparisonSiteIdStorage')
// storage.remove(['comparisonSiteIdStorage']);
}
// 监听标签页面切换
document.addEventListener('visibilitychange', e => {
if (document.visibilityState === 'visible' && (comparisonSiteId.value != storage.get('siteId') || comparisonToken.value != storage.get('token'))) {
detectionLoginDialog.value = true
}
})
const detectionLoginFn = () => {
detectionLoginDialog.value = false
location.href = `${location.origin}/site/`
}
// 检测登录 end
onMounted(() => {
// 监听窗体宽度变化
window.onresize = () => {
return (() => {
screenWidth.value = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth
})()
}
})
// watch(screenWidth, () => {
// if (screenWidth.value < 992) {
// if (!systemStore.menuIsCollapse) systemStore.toggleMenuCollapse(true)
// } else {
// if (systemStore.menuIsCollapse) systemStore.toggleMenuCollapse(false)
// }
// })
// 菜单栏展开折叠
const toggleMenuCollapse = () => {
systemStore.$patch((state) => {
if (screenWidth.value < 768) {
state.menuDrawer = true
state.menuIsCollapse = false
} else {
systemStore.toggleMenuCollapse(!systemStore.menuIsCollapse)
}
})
}
// 刷新路由
const refreshRouter = () => {
if (!appStore.routeRefreshTag) return
appStore.refreshRouterView()
}
// 面包屑导航
const breadcrumb = computed(() => {
const matched = route.matched.filter(item => { return item.meta.title })
if (matched[0] && matched[0].path == '/') matched.splice(0, 1)
return matched
})
// 跳转去预览
const toPreview = () => {
const url = router.resolve({
path: '/preview/wap',
query: {
page:'/'
}
})
window.open(url.href)
}
</script>
<style lang="scss" scoped>
.layout-header{
position: relative;
z-index: 5;
box-shadow: 0 0 4px 0 rgba(0,145,255,0.1);
}
.navbar-item {
padding: 0 8px;
&:hover {
background-color: var(--el-bg-color-page);
}
}
.index-item {
border: 1px solid;
border-color: var(--el-color-primary);
&:hover {
color: #fff;
background-color: var(--el-color-primary);
}
}
:deep(.el-dropdown-menu__item) {
&:focus {
background-color: transparent !important;
color: #333 !important;
}
}
:deep(.inter .el-breadcrumb__inner){
font-weight: inherit !important;
color: var(--el-text-color-regular) !important;
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<div class="flex">
<icon name="element Setting" @click="drawer = true" />
<el-drawer v-model="drawer" :title="t('layout.layoutSetting')" size="300px">
<el-scrollbar>
<!-- 黑暗模式 -->
<div class="setting-item flex items-center justify-between mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.darkMode') }}</div>
<div>
<el-switch v-model="dark" :active-value="true" :inactive-value="false" />
</div>
</div>
<!-- 主题颜色 -->
<div class="setting-item flex items-center justify-between mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.themeColor') }}</div>
<div>
<el-color-picker v-model="theme" />
</div>
</div>
<!-- 标签栏 -->
<div class="setting-item flex items-center justify-between mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.tab') }}</div>
<div>
<el-switch v-model="tab" :active-value="true" :inactive-value="false" />
</div>
</div>
</el-scrollbar>
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import useSystemStore from '@/stores/modules/system'
import { useDark, useToggle } from '@vueuse/core'
import { setThemeColor } from '@/utils/common'
import { t } from '@/lang'
import storage from "@/utils/storage";
const drawer = ref(false)
const systemStore = useSystemStore()
const isDark = useDark()
const toggleDark = useToggle(isDark)
const dark = computed({
get () {
return systemStore.dark
},
set (val) {
systemStore.setTheme('dark', val)
toggleDark(val)
setThemeColor(systemStore.theme, systemStore.dark ? 'dark' : 'light')
}
})
const tab = computed({
get () {
return systemStore.tab
},
set (val) {
systemStore.$patch((state) => {
state.tab = val
storage.set({ key: 'tab', data: val })
})
}
})
const theme = computed({
get () {
return systemStore.theme
},
set (val) {
systemStore.setTheme('theme', val)
setThemeColor(systemStore.theme, systemStore.dark ? 'dark' : 'light')
}
})
</script>
<style lang="scss" scoped>
:deep(.el-drawer__header) {
margin-bottom: 0 !important;
}
.layout-style {
&>div:nth-child(2n+2) {
margin-right: 0;
}
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<el-dropdown @command="switchLang" :tabindex="1">
<icon name="iconfont iconfanyi" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="zh-cn" :disabled="systemStore.lang == 'zh-cn'">简体中文</el-dropdown-item>
<el-dropdown-item command="en" :disabled="systemStore.lang == 'en'">English</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script lang="ts" setup>
import useSystemStore from '@/stores/modules/system'
import { language } from '@/lang'
import { useRoute } from 'vue-router'
import storage from '@/utils/storage'
const route = useRoute()
const systemStore = useSystemStore()
const switchLang = (command: string) => {
systemStore.$patch((state) => {
state.lang = command
storage.set({ key: 'lang', data: command })
})
language.loadLocaleMessages(route.path, systemStore.lang)
location.reload()
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,159 @@
<template>
<div>
<el-dropdown @command="clickEvent" :tabindex="1">
<div class="userinfo flex h-full items-center">
<el-avatar v-if="userStore.userInfo.head_img" :size="25" :icon="UserFilled" :src="img(userStore.userInfo.head_img)"/>
<img v-else src="@/app/assets/images/member_head.png" class="w-[25px] rounded-full" />
<div class="user-name pl-[8px]">{{ userStore.userInfo.username }}</div>
<icon name="element ArrowDown" class="ml-[5px]" />
</div>
<template #dropdown>
<div class="p-[10px]">
<div class="userinfo flex h-full items-center pb-[10px] border-b-[1px] border-solid border-[#e5e5e5]">
<el-avatar v-if="userStore.userInfo.head_img" :size="45" :icon="UserFilled" :src="img(userStore.userInfo.head_img)"/>
<img v-else src="@/app/assets/images/member_head.png" class="w-[45px] rounded-full" />
<div>
<div class="user-name pl-[8px] text-[14px]">{{ userStore.userInfo.username }}</div>
<div class="pl-[8px] text-[13px] text-[#9699B6]">个人中心</div>
</div>
</div>
<el-dropdown-menu>
<el-dropdown-item @click="toLink('/home/index')" v-if="isAllowChange">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconqiehuan ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">切换站点</span>
</div>
</el-dropdown-item>
<el-dropdown-item @click="getUserInfoFn">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconshezhi1 ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">账号设置</span>
</div>
</el-dropdown-item>
<el-dropdown-item @click="changePasswordDialog=true">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconxiugai ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">修改密码</span>
</div>
</el-dropdown-item>
<el-dropdown-item @click="logout">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont icontuichudenglu ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">退出登录</span>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</div>
</template>
</el-dropdown>
<el-dialog v-model="changePasswordDialog" width="450px" title="修改密码">
<div>
<el-form :model="saveInfo" label-width="90px" ref="formRef" :rules="formRules" class="page-form">
<el-form-item :label="t('originalPassword')" prop="original_password">
<el-input v-model="saveInfo.original_password" type="password" :placeholder="t('originalPasswordPlaceholder')" clearable class="input-width" maxlength="40" />
</el-form-item>
<el-form-item :label="t('newPassword')" prop="password">
<el-input v-model="saveInfo.password" type="password" :placeholder="t('passwordPlaceholder')" clearable class="input-width" maxlength="40" />
<div class="form-tip">{{t('passwordTip')}}</div>
</el-form-item>
<el-form-item :label="t('passwordCopy')" prop="password_copy">
<el-input v-model="saveInfo.password_copy" type="password" :placeholder="t('passwordPlaceholder')" clearable class="input-width" maxlength="40" />
</el-form-item>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="changePasswordDialog = false">{{t('cancel')}}</el-button>
<el-button type="primary" @click="submitForm(formRef)">{{t('save')}}</el-button>
</span>
</template>
</el-dialog>
<user-info-edit ref="userInfoEditRef" />
</div>
</template>
<script lang="ts" setup>
import { UserFilled } from '@element-plus/icons-vue'
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { FormInstance, FormRules, ElNotification } from 'element-plus'
import useUserStore from '@/stores/modules/user'
import { setUserInfo } from '@/app/api/personal'
import { t } from '@/lang'
import userInfoEdit from '@/app/components/user-info-edit/index.vue'
import { img } from '@/utils/common'
const isAllowChange = localStorage.getItem('isAllowChange') === 'true';
const userStore = useUserStore()
const router = useRouter()
const clickEvent = (command: string) => {
switch (command) {
case 'logout':
userStore.logout()
break
}
}
const logout = () => {
userStore.logout()
}
const toLink = (link) => {
router.push(link)
}
const userInfoEditRef = ref(null)
const getUserInfoFn = () => {
userInfoEditRef.value?.open()
}
// 修改密码 --- start
const changePasswordDialog = ref(false)
const formRef = ref<FormInstance>()
// 提交信息
const saveInfo = reactive({
original_password: '',
password: '',
password_copy: ''
})
// 表单验证规则
const formRules = reactive<FormRules>({
original_password: [
{ required: true, message: t('originalPasswordPlaceholder'), trigger: 'blur' }
],
password: [
{ required: true, message: t('passwordPlaceholder'), trigger: 'blur' }
],
password_copy: [
{ required: true, message: t('passwordPlaceholder'), trigger: 'blur' }
]
})
const submitForm = (formEl: FormInstance | undefined) => {
if (!formEl) return
formEl.validate((valid) => {
if (valid) {
let msg = ''
if (saveInfo.password && !saveInfo.original_password) msg = t('originalPasswordHint')
if (saveInfo.password && saveInfo.original_password && !saveInfo.password_copy) msg = t('newPasswordHint')
if (saveInfo.password && saveInfo.original_password && saveInfo.password_copy && saveInfo.password != saveInfo.password_copy) msg = t('doubleCipherHint')
if (msg) {
ElNotification({
type: 'error',
message: msg
})
return
}
setUserInfo(saveInfo).then((res: any) => {
changePasswordDialog.value = false
})
} else {
return false
}
})
}
</script>
<style lang="scss" scoped>
.el-popper .el-dropdown-menu{
width: 150px;
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<div class="tab-wrap w-full px-[16px]" v-show="systemStore.tab">
<el-tabs :closable="tabbarStore.tabLength > 1" :model-value="route.name" @tab-click="tabClick" @tab-remove="removeTab">
<el-tab-pane v-for="(tab, key, index) in tabbarStore.tabs" :name="tab.name" :key="index">
<template #label>
<el-dropdown trigger="contextmenu" placement="bottom-start">
<span :class="{ 'text-primary': route.name == tab.name }" class="tab-name">{{ tab.title }}</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item icon="Back" :disabled="index == 0" @click="closeLeft(tab.name)">{{t('tabs.closeLeft') }}</el-dropdown-item>
<el-dropdown-item icon="Right" :disabled="index == (tabbarStore.tabLength - 1)" @click="closeRight(tab.name)">{{t('tabs.closeRight') }}</el-dropdown-item>
<el-dropdown-item icon="Close" :disabled="tabbarStore.tabLength == 1" @click="closeOther(tab.name)">{{t('tabs.closeOther') }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script lang="ts" setup>
import { watch, onMounted } from 'vue'
import useTabbarStore from '@/stores/modules/tabbar'
import useSystemStore from '@/stores/modules/system'
import { useRoute, useRouter } from 'vue-router'
import { t } from '@/lang'
const tabbarStore = useTabbarStore()
const systemStore = useSystemStore()
const route = useRoute()
const router = useRouter()
onMounted(() => {
tabbarStore.addTab(route)
})
watch(route, (nval: any) => {
tabbarStore.addTab(nval)
})
/**
* 添加tab
* @param content
*/
const tabClick = (content: any) => {
const tabRoute = tabbarStore.tabs[content.props.name]
router.push({ name: tabRoute.name, query: tabRoute.query })
}
/**
* 移除tab
* @param content
*/
const removeTab = (content: any) => {
if (route.name == content) {
const tabs = Object.keys(tabbarStore.tabs)
if (tabs.indexOf(content) == 0) {
router.push({ name: tabs[1] })
} else {
router.push({ name: tabs[tabs.indexOf(content) - 1] })
}
}
tabbarStore.removeTab(content)
}
/**
* 关闭左侧
* @param name
*/
const closeLeft = (name: string) => {
const tabs = Object.keys(tabbarStore.tabs)
for (let i = tabs.indexOf(name) - 1; i >= 0; i--) {
delete tabbarStore.tabs[tabs[i]]
}
router.push({ name })
}
/**
* 关闭右侧
* @param name
*/
const closeRight = (name: string) => {
const tabs = Object.keys(tabbarStore.tabs)
for (let i = tabs.indexOf(name) + 1; i < tabs.length; i++) {
delete tabbarStore.tabs[tabs[i]]
}
router.push({ name })
}
/**
* 关闭其他
* @param name
*/
const closeOther = (name: string) => {
const tabs = Object.keys(tabbarStore.tabs)
tabs.forEach((key: string) => { key != name && delete tabbarStore.tabs[key] })
router.push({ name })
}
</script>
<style lang="scss" scoped>
:deep(.el-tabs) {
.el-tabs--border-card {
border: none;
}
.el-tabs__header {
margin: 0;
}
.el-tabs__nav-wrap {
margin-bottom: 0;
&::after {
display: none;
}
}
.el-tabs__content {
display: none;
}
.el-tabs__item {
display: inline-flex !important;
padding: 0 20px !important;
align-items: center;
.tab-name:focus {
outline: none !important;
}
}
.el-tabs__active-bar {
display: none;
}
.el-tabs__item.is-active {
background-color: var(--el-color-primary-light-9);
}
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<div class="flex w-full h-screen">
<!-- 左侧边栏 -->
<layout-aside></layout-aside>
<!-- 左侧边栏 end -->
<el-container>
<!-- 顶部 -->
<el-header>
<layout-header></layout-header>
</el-header>
<!-- 顶部 end -->
<layout-tab />
<!-- 主体 -->
<el-main class="h-full p-0 bg-page">
<el-scrollbar>
<div class="p-[15px]">
<router-view v-slot="{ Component, route }" v-if="appStore.routeRefreshTag">
<keep-alive :include="tabbarStore.tabNames">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</router-view>
</div>
</el-scrollbar>
</el-main>
<!-- 主体 end -->
</el-container>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import layoutHeader from './components/header/index.vue'
import layoutAside from './components/aside/index.vue'
import layoutTab from './components/tabs.vue'
import useAppStore from '@/stores/modules/app'
import useTabbarStore from '@/stores/modules/tabbar'
import useSystemStore from '@/stores/modules/system'
const appStore = useAppStore()
const tabbarStore = useTabbarStore()
const systemStore = useSystemStore()
const dark = computed(() => {
return systemStore.dark
})
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,4 @@
{
"layout": "default",
"cover": "/static/resource/images/system/layout_default.png"
}

View File

@@ -0,0 +1,53 @@
<template>
<component :is="layout" />
</template>
<script lang="ts" setup>
import { ref, markRaw, defineAsyncComponent, provide } from 'vue'
import { getAppType } from '@/utils/common'
import useUserStore from '@/stores/modules/user'
import useSystemStore from '@/stores/modules/system'
import Storage from '@/utils/storage'
const sysLayout = import.meta.glob('./*/index.vue')
const addonLayout = import.meta.glob('@/addon/**/layout/index.vue')
const modules = Object.assign(sysLayout, addonLayout)
let siteLayout = 'default'
switch (getAppType()) {
case 'admin':
siteLayout = Storage.get('admin_layout') || 'admin'
break
default:
const siteInfo = useUserStore().siteInfo
if (siteInfo && siteInfo.apps) {
const layouts = useSystemStore().layoutConfig
if (siteInfo.apps.length == 1) {
layouts[siteInfo.apps[0].key] != undefined && (siteLayout = layouts[siteInfo.apps[0].key])
} else {
layouts.system != undefined && (siteLayout = layouts.system)
}
}
}
const layout = ref<any>(null)
Object.keys(modules).forEach(key => {
key.indexOf(`/${siteLayout}/`) !== -1 && (layout.value = markRaw(defineAsyncComponent(modules[key])))
})
!layout.value && (layout.value = markRaw(defineAsyncComponent(modules['./default/index.vue'])))
/**
* 监听某些页面需要独立配置布局
*/
provide('setLayout', (name: any) => {
if (siteLayout == name) return
siteLayout = name
Object.keys(modules).forEach(key => {
key.indexOf(`/${name}/`) !== -1 && (layout.value = markRaw(defineAsyncComponent(modules[key])))
})
})
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,43 @@
<template>
<el-aside :class="['layout-aside w-auto h-screen', { 'bright': !dark }]">
<side class="hidden-xs-only" />
</el-aside>
<el-drawer v-model="systemStore.menuDrawer" direction="ltr" :with-header="false" custom-class="aside-drawer" size="210px">
<template #default>
<side />
</template>
</el-drawer>
</template>
<script lang="ts" setup>
import { watch, computed } from 'vue'
import { useRoute } from 'vue-router'
import useSystemStore from '@/stores/modules/system'
import side from './side.vue'
const route = useRoute()
const systemStore = useSystemStore()
const dark = computed(() => {
return systemStore.dark
})
watch(route, () => {
systemStore.$patch(state => {
state.menuDrawer = false
})
})
</script>
<style lang="scss">
.layout-aside {
border-right: 1px solid var(--el-border-color-lighter);
}
.aside-drawer {
.el-drawer__body {
padding: 0 !important;
}
}
</style>

View File

@@ -0,0 +1,63 @@
<template>
<template v-if="meta.show">
<el-sub-menu v-if="routes.children" :index="String(routes.name)">
<template #title>
<span :class="['ml-[10px]']">{{ meta.title }}</span>
</template>
<menu-item v-for="(route, index) in routes.children" :routes="route" :key="index" />
</el-sub-menu>
<template v-else>
<el-menu-item :index="String(routes.name)" @click="router.push({ name: routes.name })" v-if="meta.addon && meta.parent_route && meta.parent_route.addon == ''">
<template #title>
<span :class="[{'text-[15px]': routes.meta.class == 1}, {'text-[14px]': routes.meta.class != 1}, {'ml-[10px]': routes.meta.class == 2, 'ml-[15px]': routes.meta.class == 3}]">{{ meta.title }}</span>
</template>
</el-menu-item>
<el-menu-item :index="String(routes.name)" @click="router.push({ name: routes.name })" v-else>
<template #title>
<span :class="[{'text-[15px]': routes.meta.class == 1}, {'text-[14px]': routes.meta.class != 1}, {'ml-[10px]': routes.meta.class == 2, 'ml-[15px]': routes.meta.class == 3}]">{{ meta.title }}</span>
</template>
</el-menu-item>
</template>
<div v-if="routes.is_border" class="!border-0 !border-t-[1px] border-solid mx-[25px] bg-[#f7f7f7] my-[5px]"></div>
</template>
</template>
<script lang="ts" setup>
import { useRouter } from 'vue-router'
import { computed } from 'vue'
import menuItem from './menu-item.vue'
import useUserStore from '@/stores/modules/user'
const router = useRouter()
const props = defineProps({
routes: {
type: Object,
required: true
}
})
const userStore = useUserStore()
const siteInfo = userStore.siteInfo
const meta = computed(() => props.routes.meta)
const addons = computed(() => {
const addons:Record<string, any> = {}
siteInfo?.apps.forEach((item: any) => { addons[item.key] = item })
siteInfo?.site_addons.forEach((item: any) => { addons[item.key] = item })
return addons
})
</script>
<style lang="scss">
.el-sub-menu {
.el-icon {
width: auto;
}
li {
font-size: 15px;
}
}
</style>

View File

@@ -0,0 +1,361 @@
<template>
<el-container class="w-100 h-screen">
<el-main class="flex p-0">
<div class="one-menu w-[124px] h-screen px-[8px] bg-[#1c2233]">
<el-header class="logo-wrap">
<div class="logo flex items-center m-auto h-[64px]" v-if="!systemStore.menuIsCollapse">
<el-image style="width: 40px; height: 40px" :src="img(logoUrl)" fit="contain">
<template #error>
<div class="flex justify-center items-center w-full h-[40px]">
<img class="max-w-[40px]" src="@/app/assets/images/icon-addon-one.png" alt="" object-fit="contain">
</div>
</template>
</el-image>
</div>
<div class="logo flex items-center justify-center h-[64px]" v-else>
<i class="text-3xl iconfont iconyunkongjian"></i>
</div>
</el-header>
<el-scrollbar class="h-[calc( 100vh - 64px )]">
<el-menu :default-active="oneMenuActive" :router="true" class="aside-menu" :unique-opened="true" :collapse="systemStore.menuIsCollapse">
<template v-for="(item, index) in oneMenuData" :key="index">
<el-menu-item :index="item.original_name" @click="router.push({ name: item.name })" v-if="item.meta.show">
<div v-if="item.meta.icon" class="w-[16px] h-[16px] relative flex justify-center">
<el-image class="w-[16px] h-[16px] rounded-[50%] overflow-hidden" :src="item.meta.icon" fit="fill" v-if="isUrl(item.meta.icon)"/>
<icon :name="item.meta.icon" class="absolute top-[50%] -translate-y-[50%]" v-else />
</div>
<div v-else class="w-[16px] h-[16px]"></div>
<template #title>
<div class="relative flex-1 w-0">
<span class="ml-[10px] w-full truncate">{{ item.meta.short_title || item.meta.title }}</span>
</div>
</template>
</el-menu-item>
</template>
</el-menu>
<div class="h-[48px]"></div>
</el-scrollbar>
</div>
<el-scrollbar v-if="twoMenuData.length" class="two-menu w-[132px]">
<div class="w-[132px] h-[64px] flex items-center justify-center text-[16px] border-b-[1px] border-solid border-[var(--el-border-color-lighter)]">
{{ route.matched[1].meta.title }}
</div>
<el-menu class="aside-menu" :default-active="route.name" :default-openeds="menuOption" :router="true" :collapse="systemStore.menuIsCollapse">
<menu-item v-for="(route, index) in twoMenuData" :routes="route" :key="index" />
</el-menu>
<div class="h-[48px]"></div>
</el-scrollbar>
</el-main>
</el-container>
</template>
<script lang="ts" setup>
import { ref, watch, computed, onMounted, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import useSystemStore from '@/stores/modules/system'
import { getShowApp, getShowMarketing } from '@/app/api/site'
import useUserStore from '@/stores/modules/user'
import { img, isUrl } from '@/utils/common'
import { findFirstValidRoute } from '@/router/routers'
import menuItem from './menu-item.vue'
import { cloneDeep } from 'lodash-es'
const route = useRoute()
const router = useRouter()
const systemStore = useSystemStore()
const userStore = useUserStore()
const siteInfo = userStore.siteInfo
const routers = userStore.routers
const addonIndexRoute = userStore.addonIndexRoute
const oneMenuData = ref<Record<string, any>[]>([])
const twoMenuData = ref<Record<string, any>[]>([])
const addonRouters: Record<string, any> = {}
const logoUrl = computed(() => {
return userStore.siteInfo.icon ? userStore.siteInfo.icon : systemStore.website.icon
})
routers.forEach(item => {
item.original_name = item.name
if (item.meta.addon == '') {
if (item.meta.attr == '') {
if (item.children && item.children.length) {
item.name = findFirstValidRoute(item.children)
}
oneMenuData.value.push(item)
}
} else if (item.meta.addon != '' && siteInfo?.apps.length <= 1 && siteInfo?.apps[0].key == item.meta.addon && item.meta.show) {
if (item.children) {
item.children.forEach((citem: Record<string, any>) => {
citem.original_name = citem.name
if (citem.children && citem.children.length) {
citem.name = findFirstValidRoute(citem.children)
}
})
oneMenuData.value.unshift(...item.children)
} else {
oneMenuData.value.unshift(item)
}
} else {
addonRouters[item.meta.addon] = item
}
// 排序, 功能正确,改了排序后需要把菜单排序的默认值重新调整一下【多应用一级菜单,单应用二级菜单】
// oneMenuData.value.sort((a, b) => {
// if (a.meta.sort && b.meta.sort) {
// return b.meta.sort - a.meta.sort
// } else if (a.meta.sort) {
// return -1
// } else if (b.meta.sort) {
// return 1
// } else {
// return 0
// }
// })
})
// 多应用时将应用插入菜单
if (siteInfo?.apps.length > 1) {
const routers:Record<string, any>[] = []
siteInfo?.apps.forEach((item: Record<string, any>) => {
if (addonRouters[item.key]) {
addonRouters[item.key].name = addonIndexRoute[item.key]
routers.push(addonRouters[item.key])
}
})
oneMenuData.value.unshift(...routers)
// 排序, 功能正确,改了排序后需要把菜单排序的默认值重新调整一下【多应用一级菜单,单应用二级菜单】
// oneMenuData.value.sort((a, b) => {
// if (a.meta.sort && b.meta.sort) {
// return b.meta.sort - a.meta.sort
// } else if (a.meta.sort) {
// return -1
// } else if (b.meta.sort) {
// return 1
// } else {
// return 0
// }
// })
}
const appList = ref(null)
const marketingList = ref(null)
// const loading = ref(true);
const oneMenuActive = ref(route.matched[1].name)
const getAppList = async () => {
const res = await getShowApp()
appList.value = res.data
// loading.value = false;
}
const getMarketingList = async () => {
const res = await getShowMarketing()
marketingList.value = res.data
}
onMounted(async () => {
await getAppList() // 确保数据先加载
await getMarketingList()
})
// 让二级菜单默认展开
const menuOption = ref([])
const secondMenuShowWayFn = () => {
menuOption.value = []
if (oneMenuActive.value !== 'active' && oneMenuActive.value !== 'addon' && twoMenuData.value && Object.values(twoMenuData.value).length) {
const data = cloneDeep(twoMenuData.value)
for (const key in data) {
menuOption.value.push(data[key].name)
}
}
}
watchEffect(() => {
// if (!appList.value || loading.value) return; // 确保数据加载完毕
const addonKeys = appList.value?.addon?.list?.map(item => item.key) ?? []
const toolKeys = appList.value?.tool?.list?.map(item => item.key) ?? []
const allKeys = [...addonKeys, ...toolKeys]
const marketingKeys = marketingList.value?.marketing?.list?.map(item => item.key) ?? []
const matchedName = route.matched[1]?.name
if (allKeys.includes(matchedName)) {
oneMenuActive.value = 'addon'
twoMenuData.value = route.matched[1]?.children ?? []
} else if (marketingKeys.includes(matchedName)) {
oneMenuActive.value = 'active'
twoMenuData.value = route.matched[1]?.children ?? []
} else if (route.meta.attr !== '') {
oneMenuActive.value = route.matched[2]?.name
twoMenuData.value = route.matched[1]?.children ?? []
} else {
// 多应用
if (siteInfo?.apps.length > 1) {
twoMenuData.value = route.matched[1]?.children
oneMenuActive.value = route.matched[1]?.name
} else {
// 单应用
const oneMenu = route.matched[1]
if (oneMenu.meta.addon === '') {
oneMenuActive.value = route.matched[1]?.name
twoMenuData.value = route.matched[1]?.children ?? []
} else {
if (oneMenu.meta.addon === siteInfo?.apps[0]?.key) {
oneMenuActive.value = route.matched[2]?.name
twoMenuData.value = route.matched[2]?.children ?? []
} else {
oneMenuActive.value = route.matched[1]?.name
twoMenuData.value = route.matched[1]?.children ?? []
}
}
}
}
secondMenuShowWayFn()
})
// watch(route, () => {
// if (route.meta.attr != '') {
// oneMenuActive.value = route.matched[2].name
// twoMenuData.value = route.matched[1].children ?? []
// } else {
// // 多应用
// if (siteInfo?.apps.length > 1) {
// twoMenuData.value = route.matched[1].children
// oneMenuActive.value = route.matched[1].name
// } else {
// // 单应用
// const oneMenu = route.matched[1]
// if (oneMenu.meta.addon == '') {
// oneMenuActive.value = route.matched[1].name
// twoMenuData.value = route.matched[1].children ?? []
// } else {
// if (oneMenu.meta.addon == siteInfo?.apps[0].key) {
// oneMenuActive.value = route.matched[2].name
// twoMenuData.value = route.matched[2].children ?? []
// } else {
// oneMenuActive.value = route.matched[1].name
// twoMenuData.value = route.matched[1].children ?? []
// }
// }
// }
// }
// }, { immediate: true })
</script>
<style lang="scss">
.one-menu {
.aside-menu:not(.el-menu--collapse) {
background-color: transparent;
.el-menu-item {
font-size: 14px;
height: 40px;
margin-bottom: 4px;
padding-left: 12px !important;
color: rgba(255,255,255,.7);
border-radius: 2px;
&:hover {
color: #ffffff;
background-color: var(--el-color-primary);
}
&.is-active {
color: #fff !important;
background-color: var(--el-color-primary) !important;
}
span {
margin-left: 8px;
font-size: 14px;
}
}
}
.el-menu {
border: 0;
}
.el-scrollbar {
height: calc(100vh - 65px);
}
}
.two-menu {
.aside-menu:not(.el-menu--collapse) {
width: 132px;
padding-top: 16px;
border: 0;
.el-menu-item {
height: 36px;
margin: 0 12px 4px;
padding: 0 !important;
border-radius: 2px;
span {
margin-left: 8px;
font-size: 14px;
}
&.is-active {
background-color: var(--el-color-primary-light-9) !important;
}
&:hover {
color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9) !important;
}
}
.el-sub-menu {
margin-bottom: 8px;
.el-sub-menu__title {
height: 36px;
margin: 0 8px 4px;
padding-left: 0;
border-radius: 2px;
span {
font-size: 14px;
display: flex;
height: 36px;
align-items: center;
}
&:hover {
color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9) !important;
}
.el-icon.el-sub-menu__icon-arrow {
right: 5px;
}
}
.el-menu-item {
padding-left: 20px !important;
span{
margin-left: 0 !important;
}
}
}
}
}
.logo-wrap {
display: flex;
padding: 0;
white-space: nowrap;
align-items: center;
.logo {
height: 100%;
box-sizing: border-box;
}
}
</style>

View File

@@ -0,0 +1,291 @@
<template>
<el-container :class="['h-full px-[10px]',{'layout-header border-b border-color': !dark}]" >
<el-row class="w-100 h-full w-full">
<el-col :span="10">
<div class="left-panel h-full flex items-center">
<!-- 左侧菜单折叠 -->
<!-- <div class="navbar-item flex items-center h-full cursor-pointer" @click="toggleMenuCollapse">
<icon name="element Expand" v-if="systemStore.menuIsCollapse" />
<icon name="element Fold" v-else />
</div> -->
<!-- 刷新当前页 -->
<div class="navbar-item flex items-center h-full cursor-pointer" @click="refreshRouter">
<icon name="element Refresh" />
</div>
<!-- 面包屑导航 -->
<div class="flex items-center h-full pl-[10px] hidden-xs-only">
<el-breadcrumb separator="/">
<!-- :to="route.path" class="inter" 这种修改方式导致部分跳转不对需要重新调整菜单才可以 -->
<el-breadcrumb-item v-for="(route, index) in breadcrumb" :key="index" :to="route.path" class="inter">{{route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
</div>
</el-col>
<el-col :span="14">
<div class="right-panel h-full flex items-center justify-end">
<div class="flex items-center flex-shrink-0 hidden-xs-only">
<el-dropdown trigger="hover" :hide-on-click="false" popper-class="site-info-wrap" class="mr-[8px]">
<!-- 状态 -->
<div class="mx-[8px] bg-[#f6f6f6] border-[1px] border-solid border-[#eee] rounded-[4px] px-[9px] py-[6px] flex items-center">
<span class="mr-[6px] text-[12px] !text-[#333]">{{siteInfo.site_name}}</span>
<span class="!text-[10px] text-[#f56c6c]" :class="{'!text-[#67c23a]': siteInfo.status == 1, '!text-[#f56c6c]': siteInfo.status == 3}">{{ siteInfo.status_name }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>
<!-- 站点id -->
<div class="text-[14px]">站点编号{{siteInfo.site_id}}</div>
</el-dropdown-item>
<el-dropdown-item>
<!-- 到期时间 -->
<div v-if="siteInfo.expire_time == 0" class="text-[14px]">到期时间永久</div>
<div v-else class="text-[14px]">到期时间{{ siteInfo.expire_time }}</div>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="flex items-center flex-shrink-0 hidden-xs-only">
<el-popover placement="bottom" :width="330" trigger="click" v-model:visible="isMenuSearch" >
<template #reference>
<i class="iconfont icona-sousuoV6xx-36 cursor-pointer px-[8px] !text-[14px]"></i>
</template>
<template #default>
<div class="flex items-center">
<el-select v-model="selectedRoute" filterable class="!w-[250px] mr-[20px] menu-select" :teleported="false" clearable @change="handleRouteSelect">
<el-option v-for="item in flatRoutes" :key="item.name" :label="item.full_title" :value="item.name" >
</el-option>
</el-select>
<el-button type="primary" link @click="isMenuSearch = false">{{t('取消')}}</el-button>
</div>
</template>
</el-popover>
</div>
<!-- 预览 只有站点时展示-->
<i class="iconfont iconicon_huojian1 cursor-pointer px-[8px]" :title="t('visitWap')" @click="toPreview"></i>
<i class="iconfont iconlingdang-xianxing cursor-pointer px-[8px]" :title="t('newInfo')" v-if="appType == 'site'"></i>
<!-- 切换语言 -->
<!-- <div class="navbar-item flex items-center h-full cursor-pointer">
<switch-lang />
</div> -->
<!-- 切换全屏 -->
<!-- <div class="navbar-item flex items-center h-full cursor-pointer" @click="toggleFullscreen">
<icon name="iconfont icontuichuquanping" v-if="isFullscreen" />
<icon name="iconfont iconquanping" v-else />
</div> -->
<!-- 布局设置 -->
<div class="navbar-item flex items-center h-full cursor-pointer">
<layout-setting />
</div>
<!-- 用户信息 -->
<div class="navbar-item flex items-center h-full cursor-pointer">
<user-info />
</div>
</div>
</el-col>
</el-row>
<input type="hidden" v-model="comparisonToken">
<input type="hidden" v-model="comparisonSiteId">
<el-dialog v-model="detectionLoginDialog" :title="t('layout.detectionLoginTip')" width="30%" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
<span>{{ t('layout.detectionLoginContent') }}</span>
<template #footer>
<span class="dialog-footer">
<el-button @click="detectionLoginFn">{{ t('layout.detectionLoginOperation') }}</el-button>
</span>
</template>
</el-dialog>
</el-container>
</template>
<script lang="ts" setup>
import { computed, ref, onMounted } from 'vue'
import layoutSetting from './layout-setting.vue'
import userInfo from './user-info.vue'
import { useFullscreen } from '@vueuse/core'
import useSystemStore from '@/stores/modules/system'
import useUserStore from '@/stores/modules/user'
import useAppStore from '@/stores/modules/app'
import { useRoute, useRouter } from 'vue-router'
import { t } from '@/lang'
import storage from '@/utils/storage'
const appType = storage.get('app_type')
const { toggle: toggleFullscreen } = useFullscreen()
const systemStore = useSystemStore()
const appStore = useAppStore()
const route = useRoute()
const router = useRouter()
const screenWidth = ref(window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth)
const userStore = useUserStore()
const siteInfo:any = computed(() => {
return userStore.siteInfo
})
const dark = computed(() => {
return systemStore.dark
})
const isMenuSearch = ref(false)
const routers = userStore.routers
const getParentTitleChain=(meta:any) =>{
let titles = []
let current = meta?.parent_route
while (current) {
if (current.short_title) {
titles.unshift(current.short_title)
}
current = current.parent_route
}
return titles.join(' - ')
}
const flattenRoutes = (routes:any, parent = null)=> {
let flat = [];
routes.forEach(route => {
const { path, name, meta = {}, short_title, children } = route
const isLeaf = meta.type ==1 && meta.show==1
if(isLeaf){
const title = meta.title || short_title || ''
const parentTitleChain = getParentTitleChain(meta)
const fullTitle = parentTitleChain ? `${parentTitleChain} - ${title}` : title
const item = {
path,
name,
title,
parent_title: parentTitleChain,
full_title: fullTitle
};
flat.push(item);
}
if (children && children.length > 0) {
flat = flat.concat(flattenRoutes(children, route))
}
});
return flat;
}
const flatRoutes = flattenRoutes(routers)
const selectedRoute = ref('')
const handleRouteSelect = (name:any) => {
if (name) {
router.push({ name })
isMenuSearch.value = false
}
}
// 检测登录 start
const detectionLoginDialog = ref(false)
const comparisonToken = ref('')
const comparisonSiteId = ref('')
if (storage.get('comparisonTokenStorage')) {
comparisonToken.value = storage.get('comparisonTokenStorage')
// storage.remove(['comparisonTokenStorage']);
}
if (storage.get('comparisonSiteIdStorage')) {
comparisonSiteId.value = storage.get('comparisonSiteIdStorage')
// storage.remove(['comparisonSiteIdStorage']);
}
// 监听标签页面切换
document.addEventListener('visibilitychange', e => {
if (document.visibilityState === 'visible' && (comparisonSiteId.value != storage.get('siteId') || comparisonToken.value != storage.get('token'))) {
detectionLoginDialog.value = true
}
})
const detectionLoginFn = () => {
detectionLoginDialog.value = false
location.href = `${location.origin}/site/`
}
// 检测登录 end
onMounted(() => {
// 监听窗体宽度变化
window.onresize = () => {
return (() => {
screenWidth.value = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth
})()
}
})
// watch(screenWidth, () => {
// if (screenWidth.value < 992) {
// if (!systemStore.menuIsCollapse) systemStore.toggleMenuCollapse(true)
// } else {
// if (systemStore.menuIsCollapse) systemStore.toggleMenuCollapse(false)
// }
// })
// 菜单栏展开折叠
// const toggleMenuCollapse = () => {
// systemStore.$patch((state) => {
// if (screenWidth.value < 768) {
// state.menuDrawer = true
// state.menuIsCollapse = false
// } else {
// systemStore.toggleMenuCollapse(!systemStore.menuIsCollapse)
// }
// })
// }
// 刷新路由
const refreshRouter = () => {
if (!appStore.routeRefreshTag) return
appStore.refreshRouterView()
}
// 面包屑导航
const breadcrumb = computed(() => {
// console.log(route.matched)
const matched = route.matched.filter(item => { return item.meta.title })
if (matched[0] && matched[0].path == '/') matched.splice(0, 1)
return matched
})
// 跳转去预览
const toPreview = () => {
const url = router.resolve({
path: '/preview/wap',
query: {
page:'/'
}
})
window.open(url.href)
}
</script>
<style lang="scss" scoped>
.layout-header{
position: relative;
z-index: 5;
box-shadow: 0 0 4px 0 rgba(0,145,255,0.1);
}
.navbar-item {
padding: 0 8px;
&:hover {
background-color: var(--el-bg-color-page);
}
}
.index-item {
border: 1px solid;
border-color: var(--el-color-primary);
&:hover {
color: #fff;
background-color: var(--el-color-primary);
}
}
:deep(.el-dropdown-menu__item) {
&:focus {
background-color: transparent !important;
color: #333 !important;
}
}
:deep(.inter .el-breadcrumb__inner){
font-weight: inherit !important;
color: var(--el-text-color-regular) !important;
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<div class="flex">
<icon name="element Setting" @click="drawer = true" />
<el-drawer v-model="drawer" :title="t('layout.layoutSetting')" size="300px">
<el-scrollbar>
<!-- 黑暗模式 -->
<div class="setting-item flex items-center justify-between mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.darkMode') }}</div>
<div>
<el-switch v-model="dark" :active-value="true" :inactive-value="false" />
</div>
</div>
<!-- 主题颜色 -->
<div class="setting-item flex items-center justify-between mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.themeColor') }}</div>
<div>
<el-color-picker v-model="theme" />
</div>
</div>
<!-- 标签栏 -->
<div class="setting-item flex items-center justify-between mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.tab') }}</div>
<div>
<el-switch v-model="tab" :active-value="true" :inactive-value="false" />
</div>
</div>
</el-scrollbar>
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import useSystemStore from '@/stores/modules/system'
import { useDark, useToggle } from '@vueuse/core'
import { setThemeColor } from '@/utils/common'
import { t } from '@/lang'
import storage from "@/utils/storage";
const drawer = ref(false)
const systemStore = useSystemStore()
const isDark = useDark()
const toggleDark = useToggle(isDark)
const dark = computed({
get () {
return systemStore.dark
},
set (val) {
systemStore.setTheme('dark', val)
toggleDark(val)
setThemeColor(systemStore.theme, systemStore.dark ? 'dark' : 'light')
}
})
const tab = computed({
get () {
return systemStore.tab
},
set (val) {
systemStore.$patch((state) => {
state.tab = val
storage.set({ key: 'tab', data: val })
})
}
})
const theme = computed({
get () {
return systemStore.theme
},
set (val) {
systemStore.setTheme('theme', val)
setThemeColor(systemStore.theme, systemStore.dark ? 'dark' : 'light')
}
})
</script>
<style lang="scss" scoped>
:deep(.el-drawer__header) {
margin-bottom: 0 !important;
}
.layout-style {
&>div:nth-child(2n+2) {
margin-right: 0;
}
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<el-dropdown @command="switchLang" :tabindex="1">
<icon name="iconfont iconfanyi" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="zh-cn" :disabled="systemStore.lang == 'zh-cn'">简体中文</el-dropdown-item>
<el-dropdown-item command="en" :disabled="systemStore.lang == 'en'">English</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script lang="ts" setup>
import useSystemStore from '@/stores/modules/system'
import { language } from '@/lang'
import { useRoute } from 'vue-router'
import storage from '@/utils/storage'
const route = useRoute()
const systemStore = useSystemStore()
const switchLang = (command: string) => {
systemStore.$patch((state) => {
state.lang = command
storage.set({ key: 'lang', data: command })
})
language.loadLocaleMessages(route.path, systemStore.lang)
location.reload()
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,158 @@
<template>
<div>
<el-dropdown @command="clickEvent" :tabindex="1">
<div class="userinfo flex h-full items-center">
<el-avatar v-if="userStore.userInfo.head_img" :size="25" :icon="UserFilled" :src="img(userStore.userInfo.head_img)"/>
<img v-else src="@/app/assets/images/member_head.png" class="w-[25px] rounded-full" />
<div class="user-name pl-[8px]">{{ userStore.userInfo.username }}</div>
<icon name="element ArrowDown" class="ml-[5px]" />
</div>
<template #dropdown>
<div class="p-[10px]">
<div class="userinfo flex h-full items-center pb-[10px] border-b-[1px] border-solid border-[#e5e5e5]">
<el-avatar v-if="userStore.userInfo.head_img" :size="45" :icon="UserFilled" :src="img(userStore.userInfo.head_img)"/>
<img v-else src="@/app/assets/images/member_head.png" class="w-[45px] rounded-full" />
<div>
<div class="user-name pl-[8px] text-[14px]">{{ userStore.userInfo.username }}</div>
<div class="pl-[8px] text-[13px] text-[#9699B6]">个人中心</div>
</div>
</div>
<el-dropdown-menu>
<el-dropdown-item @click="toLink('/home/index')" v-if="isAllowChange">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconqiehuan ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">切换站点</span>
</div>
</el-dropdown-item>
<el-dropdown-item @click="getUserInfoFn">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconshezhi1 ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">账号设置</span>
</div>
</el-dropdown-item>
<el-dropdown-item @click="changePasswordDialog=true">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconxiugai ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">修改密码</span>
</div>
</el-dropdown-item>
<el-dropdown-item @click="logout">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont icontuichudenglu ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">退出登录</span>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</div>
</template>
</el-dropdown>
<el-dialog v-model="changePasswordDialog" width="450px" title="修改密码">
<div>
<el-form :model="saveInfo" label-width="90px" ref="formRef" :rules="formRules" class="page-form">
<el-form-item :label="t('originalPassword')" prop="original_password">
<el-input v-model="saveInfo.original_password" type="password" :placeholder="t('originalPasswordPlaceholder')" clearable class="input-width" maxlength="40" />
</el-form-item>
<el-form-item :label="t('newPassword')" prop="password">
<el-input v-model="saveInfo.password" type="password" :placeholder="t('passwordPlaceholder')" clearable class="input-width" maxlength="40" />
<div class="form-tip">{{t('passwordTip')}}</div>
</el-form-item>
<el-form-item :label="t('passwordCopy')" prop="password_copy">
<el-input v-model="saveInfo.password_copy" type="password" :placeholder="t('passwordPlaceholder')" clearable class="input-width" maxlength="40" />
</el-form-item>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="changePasswordDialog = false">{{t('cancel')}}</el-button>
<el-button type="primary" @click="submitForm(formRef)">{{t('save')}}</el-button>
</span>
</template>
</el-dialog>
<user-info-edit ref="userInfoEditRef" />
</div>
</template>
<script lang="ts" setup>
import { UserFilled } from '@element-plus/icons-vue'
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { FormInstance, FormRules, ElNotification } from 'element-plus'
import useUserStore from '@/stores/modules/user'
import { setUserInfo } from '@/app/api/personal'
import { t } from '@/lang'
import userInfoEdit from '@/app/components/user-info-edit/index.vue'
import { img } from '@/utils/common'
const isAllowChange = localStorage.getItem('isAllowChange') === 'true';
const userStore = useUserStore()
const router = useRouter()
const clickEvent = (command: string) => {
switch (command) {
case 'logout':
userStore.logout()
break
}
}
const logout = () => {
userStore.logout()
}
const toLink = (link) => {
router.push(link)
}
const userInfoEditRef = ref(null)
const getUserInfoFn = () => {
userInfoEditRef.value?.open()
}
// 修改密码 --- start
const changePasswordDialog = ref(false)
const formRef = ref<FormInstance>()
// 提交信息
const saveInfo = reactive({
original_password: '',
password: '',
password_copy: ''
})
// 表单验证规则
const formRules = reactive<FormRules>({
original_password: [
{ required: true, message: t('originalPasswordPlaceholder'), trigger: 'blur' }
],
password: [
{ required: true, message: t('passwordPlaceholder'), trigger: 'blur' }
],
password_copy: [
{ required: true, message: t('passwordPlaceholder'), trigger: 'blur' }
]
})
const submitForm = (formEl: FormInstance | undefined) => {
if (!formEl) return
formEl.validate((valid) => {
if (valid) {
let msg = ''
if (saveInfo.password && !saveInfo.original_password) msg = t('originalPasswordHint')
if (saveInfo.password && saveInfo.original_password && !saveInfo.password_copy) msg = t('newPasswordHint')
if (saveInfo.password && saveInfo.original_password && saveInfo.password_copy && saveInfo.password != saveInfo.password_copy) msg = t('doubleCipherHint')
if (msg) {
ElNotification({
type: 'error',
message: msg
})
return
}
setUserInfo(saveInfo).then((res: any) => {
changePasswordDialog.value = false
})
} else {
return false
}
})
}
</script>
<style lang="scss" scoped>
.el-popper .el-dropdown-menu{
width: 150px;
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<div class="tab-wrap w-full px-[16px]" v-show="systemStore.tab">
<el-tabs :closable="tabbarStore.tabLength > 1" :model-value="route.name" @tab-click="tabClick" @tab-remove="removeTab">
<el-tab-pane v-for="(tab, key, index) in tabbarStore.tabs" :name="tab.name" :key="index">
<template #label>
<el-dropdown trigger="contextmenu" placement="bottom-start">
<span :class="{ 'text-primary': route.name == tab.name }" class="tab-name">{{ tab.title }}</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item icon="Back" :disabled="index == 0" @click="closeLeft(tab.name)">{{t('tabs.closeLeft') }}</el-dropdown-item>
<el-dropdown-item icon="Right" :disabled="index == (tabbarStore.tabLength - 1)" @click="closeRight(tab.name)">{{t('tabs.closeRight') }}</el-dropdown-item>
<el-dropdown-item icon="Close" :disabled="tabbarStore.tabLength == 1" @click="closeOther(tab.name)">{{t('tabs.closeOther') }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script lang="ts" setup>
import { watch, onMounted } from 'vue'
import useTabbarStore from '@/stores/modules/tabbar'
import useSystemStore from '@/stores/modules/system'
import { useRoute, useRouter } from 'vue-router'
import { t } from '@/lang'
const tabbarStore = useTabbarStore()
const systemStore = useSystemStore()
const route = useRoute()
const router = useRouter()
onMounted(() => {
tabbarStore.addTab(route)
})
watch(route, (nval: any) => {
tabbarStore.addTab(nval)
})
/**
* 添加tab
* @param content
*/
const tabClick = (content: any) => {
const tabRoute = tabbarStore.tabs[content.props.name]
router.push({ name: tabRoute.name, query: tabRoute.query })
}
/**
* 移除tab
* @param content
*/
const removeTab = (content: any) => {
if (route.name == content) {
const tabs = Object.keys(tabbarStore.tabs)
if (tabs.indexOf(content) == 0) {
router.push({ name: tabs[1] })
} else {
router.push({ name: tabs[tabs.indexOf(content) - 1] })
}
}
tabbarStore.removeTab(content)
}
/**
* 关闭左侧
* @param name
*/
const closeLeft = (name: string) => {
const tabs = Object.keys(tabbarStore.tabs)
for (let i = tabs.indexOf(name) - 1; i >= 0; i--) {
delete tabbarStore.tabs[tabs[i]]
}
router.push({ name })
}
/**
* 关闭右侧
* @param name
*/
const closeRight = (name: string) => {
const tabs = Object.keys(tabbarStore.tabs)
for (let i = tabs.indexOf(name) + 1; i < tabs.length; i++) {
delete tabbarStore.tabs[tabs[i]]
}
router.push({ name })
}
/**
* 关闭其他
* @param name
*/
const closeOther = (name: string) => {
const tabs = Object.keys(tabbarStore.tabs)
tabs.forEach((key: string) => { key != name && delete tabbarStore.tabs[key] })
router.push({ name })
}
</script>
<style lang="scss" scoped>
:deep(.el-tabs) {
.el-tabs--border-card {
border: none;
}
.el-tabs__header {
margin: 0;
}
.el-tabs__nav-wrap {
margin-bottom: 0;
&::after {
display: none;
}
}
.el-tabs__content {
display: none;
}
.el-tabs__item {
display: inline-flex !important;
padding: 0 20px !important;
align-items: center;
.tab-name:focus {
outline: none !important;
}
}
.el-tabs__active-bar {
display: none;
}
.el-tabs__item.is-active {
background-color: var(--el-color-primary-light-9);
}
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<div class="flex w-full h-screen">
<!-- 左侧边栏 -->
<layout-aside></layout-aside>
<!-- 左侧边栏 end -->
<el-container>
<!-- 顶部 -->
<el-header>
<layout-header></layout-header>
</el-header>
<!-- 顶部 end -->
<layout-tab />
<!-- 主体 -->
<el-main class="h-full p-0 bg-page">
<el-scrollbar>
<div class="p-[15px]">
<router-view v-slot="{ Component, route }" v-if="appStore.routeRefreshTag">
<keep-alive :include="tabbarStore.tabNames">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</router-view>
</div>
</el-scrollbar>
</el-main>
<!-- 主体 end -->
</el-container>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import layoutHeader from './components/header/index.vue'
import layoutAside from './components/aside/index.vue'
import layoutTab from './components/tabs.vue'
import useAppStore from '@/stores/modules/app'
import useTabbarStore from '@/stores/modules/tabbar'
import useSystemStore from '@/stores/modules/system'
const appStore = useAppStore()
const tabbarStore = useTabbarStore()
const systemStore = useSystemStore()
const dark = computed(() => {
return systemStore.dark
})
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,4 @@
{
"layout": "profession",
"cover": "/static/resource/images/system/layout_profession.png"
}