init Project

This commit is contained in:
2025-04-09 12:10:46 +08:00
parent 505d08443c
commit 75a1447d66
207 changed files with 26387 additions and 13 deletions

View File

@@ -0,0 +1,66 @@
<template>
<el-sub-menu
ref="subMenu"
:index="routerInfo.name"
class="gva-sub-menu dark:text-slate-300 relative"
>
<template #title>
<div
v-if="!isCollapse"
class="flex items-center"
:style="{
height: sideHeight
}"
>
<el-icon v-if="routerInfo.meta.icon">
<component :is="routerInfo.meta.icon" />
</el-icon>
<span>{{ routerInfo.meta.title }}</span>
</div>
<template v-else>
<el-icon v-if="routerInfo.meta.icon">
<component :is="routerInfo.meta.icon" />
</el-icon>
<span>{{ routerInfo.meta.title }}</span>
</template>
</template>
<slot />
</el-sub-menu>
</template>
<script setup>
import { inject, computed } from 'vue'
import { useAppStore } from '@/pinia'
import { storeToRefs } from 'pinia'
const appStore = useAppStore()
const { config } = storeToRefs(appStore)
defineOptions({
name: 'AsyncSubmenu'
})
defineProps({
routerInfo: {
default: function () {
return null
},
type: Object
}
})
const isCollapse = inject('isCollapse', {
default: false
})
const sideHeight = computed(() => {
return config.value.layout_side_item_height + 'px'
})
</script>
<style lang="scss">
.gva-sub-menu {
.el-sub-menu__title {
height: v-bind('sideHeight') !important;
}
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<component
:is="menuComponent"
v-if="!routerInfo.hidden"
:router-info="routerInfo"
>
<template v-if="routerInfo.children && routerInfo.children.length">
<AsideComponent
v-for="item in routerInfo.children"
:key="item.name"
:router-info="item"
/>
</template>
</component>
</template>
<script setup>
import MenuItem from './menuItem.vue'
import AsyncSubmenu from './asyncSubmenu.vue'
import { computed } from 'vue'
defineOptions({
name: 'AsideComponent'
})
const props = defineProps({
routerInfo: {
type: Object,
default: () => null
},
mode: {
type: String,
default: 'vertical'
}
})
const menuComponent = computed(() => {
if (
props.routerInfo.children &&
props.routerInfo.children.filter((item) => !item.hidden).length
) {
return AsyncSubmenu
} else {
return MenuItem
}
})
</script>

View File

@@ -0,0 +1,43 @@
<template>
<el-menu-item
:index="routerInfo.name"
class="dark:text-slate-300 overflow-hidden"
:style="{
height: sideHeight
}"
>
<el-icon v-if="routerInfo.meta.icon">
<component :is="routerInfo.meta.icon" />
</el-icon>
<template #title>
{{ routerInfo.meta.title }}
</template>
</el-menu-item>
</template>
<script setup>
import { computed } from 'vue'
import { useAppStore } from '@/pinia'
import { storeToRefs } from 'pinia'
const appStore = useAppStore()
const { config } = storeToRefs(appStore)
defineOptions({
name: 'MenuItem'
})
defineProps({
routerInfo: {
default: function () {
return null
},
type: Object
}
})
const sideHeight = computed(() => {
return config.value.layout_side_item_height + 'px'
})
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,146 @@
<template>
<div class="h-full">
<div
v-if="mode === 'head'"
class="bg-white h-[calc(100%-4px)] text-slate-700 dark:text-slate-300 mx-2 dark:bg-slate-900 flex items-center w-[calc(100vw-600px)] overflow-auto"
>
<el-menu
:default-active="routerStore.topActive"
mode="horizontal"
class="border-r-0 border-b-0 w-full flex gap-1 items-center box-border h-[calc(100%-1px)]"
unique-opened
@select="(index, _, ele) => selectMenuItem(index, _, ele, true)"
>
<template v-for="item in routerStore.topMenu">
<aside-component
v-if="!item.hidden"
:key="item.name"
:router-info="item"
mode="horizontal"
/>
</template>
</el-menu>
</div>
<div
v-if="mode === 'normal'"
class="relative h-full bg-white text-slate-700 dark:text-slate-300 dark:bg-slate-900 border-r shadow dark:shadow-gray-700"
:class="isCollapse ? '' : ' px-2'"
:style="{
width: layoutSideWidth + 'px'
}"
>
<el-scrollbar>
<el-menu
:collapse="isCollapse"
:collapse-transition="false"
:default-active="active"
class="border-r-0 w-full"
unique-opened
@select="(index, _, ele) => selectMenuItem(index, _, ele, false)"
>
<template v-for="item in routerStore.leftMenu">
<aside-component
v-if="!item.hidden"
:key="item.name"
:router-info="item"
/>
</template>
</el-menu>
</el-scrollbar>
<div
class="absolute bottom-8 right-2 w-8 h-8 bg-gray-50 dark:bg-slate-800 flex items-center justify-center rounded cursor-pointer"
:class="isCollapse ? 'right-0 left-0 mx-auto' : 'right-2'"
@click="toggleCollapse"
>
<el-icon v-if="!isCollapse">
<DArrowLeft />
</el-icon>
<el-icon v-else>
<DArrowRight />
</el-icon>
</div>
</div>
</div>
</template>
<script setup>
import AsideComponent from '@/view/layout/aside/asideComponent/index.vue'
import { ref, provide, watchEffect, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRouterStore } from '@/pinia/modules/router'
import { useAppStore } from '@/pinia'
import { storeToRefs } from 'pinia'
const appStore = useAppStore()
const { device, config } = storeToRefs(appStore)
defineOptions({
name: 'GvaAside'
})
defineProps({
mode: {
type: String,
default: 'normal'
}
})
const route = useRoute()
const router = useRouter()
const routerStore = useRouterStore()
const isCollapse = ref(false)
const active = ref('')
const layoutSideWidth = computed(() => {
if (!isCollapse.value) {
return config.value.layout_side_width
} else {
return config.value.layout_side_collapsed_width
}
})
watchEffect(() => {
active.value = route.meta.activeName || route.name
})
watchEffect(() => {
if (device.value === 'mobile') {
isCollapse.value = true
} else {
isCollapse.value = false
}
})
provide('isCollapse', isCollapse)
const selectMenuItem = (index, _, ele, top) => {
const query = {}
const params = {}
routerStore.routeMap[index]?.parameters &&
routerStore.routeMap[index]?.parameters.forEach((item) => {
if (item.type === 'query') {
query[item.key] = item.value
} else {
params[item.key] = item.value
}
})
if (index === route.name) return
if (index.indexOf('http://') > -1 || index.indexOf('https://') > -1) {
window.open(index, '_blank')
return
}
if (!top) {
router.push({ name: index, query, params })
return
}
const leftMenu = routerStore.setLeftMenu(index)
if (!leftMenu) {
router.push({ name: index, query, params })
return;
}
const firstMenu = leftMenu.find((item) => !item.hidden && item.path.indexOf("http://") === -1 && item.path.indexOf("https://") === -1)
router.push({ name: firstMenu.name, query, params })
}
const toggleCollapse = () => {
isCollapse.value = !isCollapse.value
}
</script>

View File

@@ -0,0 +1,106 @@
<template>
<div
class="bg-white h-[calc(100%-4px)] text-slate-700 dark:text-slate-300 mx-2 dark:bg-slate-900 flex items-center w-[calc(100vw-600px)] overflow-auto"
>
<el-menu
:default-active="active"
mode="horizontal"
class="border-r-0 w-full flex gap-1 items-center box-border h-[calc(100%-1px)]"
unique-opened
@select="selectMenuItem"
>
<template v-for="item in routerStore.asyncRouters[0].children">
<aside-component
v-if="!item.hidden"
:key="item.name"
:router-info="item"
mode="horizontal"
/>
</template>
</el-menu>
</div>
</template>
<script setup>
import AsideComponent from '@/view/layout/aside/asideComponent/index.vue'
import { ref, provide, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRouterStore } from '@/pinia/modules/router'
import { useAppStore } from '@/pinia'
import { storeToRefs } from 'pinia'
const appStore = useAppStore()
const { device } = storeToRefs(appStore)
defineOptions({
name: 'GvaAside'
})
const route = useRoute()
const router = useRouter()
const routerStore = useRouterStore()
const isCollapse = ref(false)
const active = ref('')
watchEffect(() => {
if (route.name === 'Iframe') {
active.value = decodeURIComponent(route.query.url)
return
}
active.value = route.meta.activeName || route.name
})
watchEffect(() => {
if (device.value === 'mobile') {
isCollapse.value = true
} else {
isCollapse.value = false
}
})
provide('isCollapse', isCollapse)
const selectMenuItem = (index) => {
const query = {}
const params = {}
routerStore.routeMap[index]?.parameters &&
routerStore.routeMap[index]?.parameters.forEach((item) => {
if (item.type === 'query') {
query[item.key] = item.value
} else {
params[item.key] = item.value
}
})
if (index === route.name) return
if (index.indexOf('http://') > -1 || index.indexOf('https://') > -1) {
if (index === 'Iframe') {
query.url = decodeURIComponent(index)
router.push({
name: 'Iframe',
query,
params
})
return
} else {
window.open(index, '_blank')
return
}
} else {
router.push({ name: index, query, params })
}
}
</script>
<style lang="scss" scoped>
.el-menu--horizontal.el-menu,
.el-menu--horizontal > .el-menu-item.is-active {
border-bottom: none !important;
}
.el-menu-item.is-active {
background-color: var(--el-color-primary-light-8) !important;
}
.dark {
.el-menu-item.is-active {
background-color: var(--el-color-primary-bg) !important;
}
}
</style>

View File

@@ -0,0 +1,34 @@
<template>
<div>
<normal-mode
v-if="
config.side_mode === 'normal' ||
(device === 'mobile' && config.side_mode == 'head') ||
(device === 'mobile' && config.side_mode == 'combination')
"
/>
<head-mode v-if="config.side_mode === 'head' && device !== 'mobile'" />
<combination-mode
v-if="config.side_mode === 'combination' && device !== 'mobile'"
:mode="mode"
/>
</div>
</template>
<script setup>
import NormalMode from './normalMode.vue'
import HeadMode from './headMode.vue'
import CombinationMode from './combinationMode.vue'
defineProps({
mode: {
type: String,
default: 'normal'
}
})
import { storeToRefs } from 'pinia'
import { useAppStore } from '@/pinia'
const appStore = useAppStore()
const { config, device } = storeToRefs(appStore)
</script>

View File

@@ -0,0 +1,120 @@
<template>
<div
class="relative h-full bg-white text-slate-700 dark:text-slate-300 dark:bg-slate-900 border-r shadow dark:shadow-gray-700"
:class="isCollapse ? '' : ' px-2'"
:style="{
width: layoutSideWidth + 'px'
}"
>
<el-scrollbar>
<el-menu
:collapse="isCollapse"
:collapse-transition="false"
:default-active="active"
class="border-r-0 w-full"
unique-opened
@select="selectMenuItem"
>
<template v-for="item in routerStore.asyncRouters[0]?.children || []">
<aside-component
v-if="!item.hidden"
:key="item.name"
:router-info="item"
/>
</template>
</el-menu>
</el-scrollbar>
<div
class="absolute bottom-8 right-2 w-8 h-8 bg-gray-50 dark:bg-slate-800 flex items-center justify-center rounded cursor-pointer"
:class="isCollapse ? 'right-0 left-0 mx-auto' : 'right-2'"
@click="toggleCollapse"
>
<el-icon v-if="!isCollapse">
<DArrowLeft />
</el-icon>
<el-icon v-else>
<DArrowRight />
</el-icon>
</div>
</div>
</template>
<script setup>
import AsideComponent from '@/view/layout/aside/asideComponent/index.vue'
import { ref, provide, watchEffect, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRouterStore } from '@/pinia/modules/router'
import { useAppStore } from '@/pinia'
import { storeToRefs } from 'pinia'
const appStore = useAppStore()
const { device, config } = storeToRefs(appStore)
defineOptions({
name: 'GvaAside'
})
const route = useRoute()
const router = useRouter()
const routerStore = useRouterStore()
const isCollapse = ref(false)
const active = ref('')
const layoutSideWidth = computed(() => {
if (!isCollapse.value) {
return config.value.layout_side_width
} else {
return config.value.layout_side_collapsed_width
}
})
watchEffect(() => {
if (route.name === 'Iframe') {
active.value = decodeURIComponent(route.query.url)
return
}
active.value = route.meta.activeName || route.name
})
watchEffect(() => {
if (device.value === 'mobile') {
isCollapse.value = true
} else {
isCollapse.value = false
}
})
provide('isCollapse', isCollapse)
const selectMenuItem = (index) => {
const query = {}
const params = {}
routerStore.routeMap[index]?.parameters &&
routerStore.routeMap[index]?.parameters.forEach((item) => {
if (item.type === 'query') {
query[item.key] = item.value
} else {
params[item.key] = item.value
}
})
if (index === route.name) return
if (index.indexOf('http://') > -1 || index.indexOf('https://') > -1) {
if (index === 'Iframe') {
query.url = decodeURIComponent(index)
router.push({
name: 'Iframe',
query,
params
})
return
} else {
window.open(index, '_blank')
return
}
} else {
router.push({ name: index, query, params })
}
}
const toggleCollapse = () => {
isCollapse.value = !isCollapse.value
}
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,140 @@
<!--
@auther: bypanghu<bypanghu@163.com>
@date: 2024/5/7
!-->
<template>
<div
class="flex justify-between fixed top-0 left-0 right-0 z-10 h-16 bg-white text-slate-700 dark:text-slate-300 dark:bg-slate-900 shadow dark:shadow-gray-700 items-center px-2"
>
<div class="flex items-center cursor-pointer flex-1">
<div
class="flex items-center cursor-pointer"
:class="isMobile ? '' : 'min-w-48'"
@click="router.push({ path: '/' })"
>
<img
alt
class="h-12 bg-white rounded-full"
:src="$GIN_VUE_ADMIN.appLogo"
/>
<div
v-if="!isMobile"
class="inline-flex font-bold text-2xl ml-2"
:class="
(config.side_mode === 'head' ||
config.side_mode === 'combination') &&
'min-w-fit'
"
>
{{ $GIN_VUE_ADMIN.appName }}
</div>
</div>
<el-breadcrumb
v-show="!isMobile"
v-if="config.side_mode !== 'head' && config.side_mode !== 'combination'"
class="ml-4"
>
<el-breadcrumb-item
v-for="item in matched.slice(1, matched.length)"
:key="item.path"
>
{{ fmtTitle(item.meta.title, route) }}
</el-breadcrumb-item>
</el-breadcrumb>
<gva-aside
v-if="config.side_mode === 'head' && !isMobile"
class="flex-1"
/>
<gva-aside
v-if="config.side_mode === 'combination' && !isMobile"
mode="head"
class="flex-1"
/>
</div>
<div class="ml-2 flex items-center">
<tools />
<el-dropdown>
<div class="flex justify-center items-center h-full w-full">
<span
class="cursor-pointer flex justify-center items-center text-black dark:text-gray-100"
>
<CustomPic />
<span v-show="!isMobile" class="w-16">{{
userStore.userInfo.nickName
}}</span>
<el-icon>
<arrow-down />
</el-icon>
</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>
<span class="font-bold">
当前角色{{ userStore.userInfo.authority.authorityName }}
</span>
</el-dropdown-item>
<template v-if="userStore.userInfo.authorities">
<el-dropdown-item
v-for="item in userStore.userInfo.authorities.filter(
(i) => i.authorityId !== userStore.userInfo.authorityId
)"
:key="item.authorityId"
@click="changeUserAuth(item.authorityId)"
>
<span> 切换为:{{ item.authorityName }} </span>
</el-dropdown-item>
</template>
<el-dropdown-item icon="avatar" @click="toPerson">
个人信息
</el-dropdown-item>
<el-dropdown-item icon="reading-lamp" @click="userStore.LoginOut">
登 出
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<script setup>
import tools from './tools.vue'
import CustomPic from '@/components/customPic/index.vue'
import { useUserStore } from '@/pinia/modules/user'
import { useRoute, useRouter } from 'vue-router'
import { useAppStore } from '@/pinia'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { setUserAuthority } from '@/api/user'
import { fmtTitle } from '@/utils/fmtRouterTitle'
import gvaAside from '@/view/layout/aside/index.vue'
const userStore = useUserStore()
const router = useRouter()
const route = useRoute()
const appStore = useAppStore()
const { device, config } = storeToRefs(appStore)
const isMobile = computed(() => {
return device.value === 'mobile'
})
const toPerson = () => {
router.push({ name: 'person' })
}
const matched = computed(() => route.meta.matched)
const changeUserAuth = async (id) => {
const res = await setUserAuthority({
authorityId: id
})
if (res.code === 0) {
window.sessionStorage.setItem('needCloseAll', 'true')
window.sessionStorage.setItem('needToHome', 'true')
window.location.reload()
}
}
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,192 @@
<!--
@auther: bypanghu<bypanghu@163.com>
@date: 2024/5/7
!-->
<template>
<div class="flex items-center mx-4 gap-4">
<el-tooltip class="" effect="dark" content="视频教程" placement="bottom">
<el-dropdown @command="toDoc">
<el-icon
class="w-8 h-8 shadow rounded-full border border-gray-200 dark:border-gray-600 cursor-pointer border-solid"
>
<Film />
</el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="item in videoList"
:key="item.link"
:command="item.link"
>{{ item.title }}</el-dropdown-item
>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-tooltip>
<el-tooltip class="" effect="dark" content="搜索" placement="bottom">
<el-icon
@click="handleCommand"
class="w-8 h-8 shadow rounded-full border border-gray-200 dark:border-gray-600 cursor-pointer border-solid"
>
<Search />
</el-icon>
</el-tooltip>
<el-tooltip class="" effect="dark" content="系统设置" placement="bottom">
<el-icon
class="w-8 h-8 shadow rounded-full border border-gray-200 dark:border-gray-600 cursor-pointer border-solid"
@click="toggleSetting"
>
<Setting />
</el-icon>
</el-tooltip>
<el-tooltip class="" effect="dark" content="刷新" placement="bottom">
<el-icon
class="w-8 h-8 shadow rounded-full border border-gray-200 dark:border-gray-600 cursor-pointer border-solid"
:class="showRefreshAnmite ? 'animate-spin' : ''"
@click="toggleRefresh"
>
<Refresh />
</el-icon>
</el-tooltip>
<el-tooltip
class=""
effect="dark"
content="切换主题"
placement="bottom"
>
<el-icon
v-if="appStore.isDark"
class="w-8 h-8 shadow rounded-full border border-gray-600 cursor-pointer border-solid"
@click="appStore.toggleTheme(false)"
>
<Sunny />
</el-icon>
<el-icon
v-else
class="w-8 h-8 shadow rounded-full border border-gray-200 cursor-pointer border-solid"
@click="appStore.toggleTheme(true)"
>
<Moon />
</el-icon>
</el-tooltip>
<gva-setting v-model:drawer="showSettingDrawer"></gva-setting>
<command-menu ref="command" />
</div>
</template>
<script setup>
import { useAppStore } from '@/pinia'
import GvaSetting from '@/view/layout/setting/index.vue'
import { ref } from 'vue'
import { emitter } from '@/utils/bus.js'
import CommandMenu from '@/components/commandMenu/index.vue'
import { toDoc } from '@/utils/doc'
const appStore = useAppStore()
const showSettingDrawer = ref(false)
const showRefreshAnmite = ref(false)
const toggleRefresh = () => {
showRefreshAnmite.value = true
emitter.emit('reload')
setTimeout(() => {
showRefreshAnmite.value = false
}, 1000)
}
const toggleSetting = () => {
showSettingDrawer.value = true
}
const first = ref('')
const command = ref()
const handleCommand = () => {
command.value.open()
}
const initPage = () => {
// 判断当前用户的操作系统
if (window.localStorage.getItem('osType') === 'WIN') {
first.value = 'Ctrl'
} else {
first.value = '⌘'
}
// 当用户同时按下ctrl和k键的时候
const handleKeyDown = (e) => {
if (e.ctrlKey && e.key === 'k') {
// 阻止浏览器默认事件
e.preventDefault()
handleCommand()
}
}
window.addEventListener('keydown', handleKeyDown)
}
initPage()
const videoList = [
{
title: '1.clone项目和安装依赖',
link: 'https://www.bilibili.com/video/BV1jx4y1s7xx'
},
{
title: '2.初始化项目',
link: 'https://www.bilibili.com/video/BV1sr421K7sv'
},
{
title: '3.开启调试工具+创建初始化包',
link: 'https://www.bilibili.com/video/BV1iH4y1c7Na'
},
{
title: '4.手动使用自动化创建功能',
link: 'https://www.bilibili.com/video/BV1UZ421T7fV'
},
{
title: '5.使用已有表格创建业务',
link: 'https://www.bilibili.com/video/BV1NE4m1977s'
},
{
title: '6.使用AI创建业务和创建数据源模式的可选项',
link: 'https://www.bilibili.com/video/BV17i421a7DE'
},
{
title: '7.创建自己的后端方法',
link: 'https://www.bilibili.com/video/BV1Yw4m1k7fg'
},
{
title: '8.新增一个前端页面',
link: 'https://www.bilibili.com/video/BV12y411i7oE'
},
{
title: '9.配置一个前端二级页面',
link: 'https://www.bilibili.com/video/BV1ZM4m1y7i3'
},
{
title: '10.配置一个前端菜单参数',
link: 'https://www.bilibili.com/video/BV1WS42197DZ'
},
{
title: '11.菜单参数实战+动态菜单标题+菜单高亮配置',
link: 'https://www.bilibili.com/video/BV1NE4m1979c'
},
{
title: '12.增加菜单可控按钮',
link: 'https://www.bilibili.com/video/BV1Sw4m1k746'
},
{
title: '14.新增客户角色和其相关配置教学',
link: 'https://www.bilibili.com/video/BV1Ki421a7X2'
},
{
title: '15.发布项目上线',
link: 'https://www.bilibili.com/video/BV1Lx4y1s77D'
}
]
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,73 @@
<template>
<div
class="bg-gray-50 text-slate-700 dark:text-slate-500 dark:bg-slate-800 w-screen h-screen"
>
<iframe
v-if="reloadFlag"
id="gva-base-load-dom"
class="gva-body-h bg-gray-50 dark:bg-slate-800 w-full border-t border-gray-200 dark:border-slate-700"
:src="url"
></iframe>
</div>
</template>
<script setup>
import useResponsive from '@/hooks/responsive'
import { emitter } from '@/utils/bus.js'
import { ref, onMounted, nextTick, reactive, watchEffect } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/pinia/modules/user'
import { useAppStore } from '@/pinia'
import { storeToRefs } from 'pinia'
const appStore = useAppStore()
const { isDark } = storeToRefs(appStore)
defineOptions({
name: 'GvaLayoutIframe'
})
useResponsive(true)
const font = reactive({
color: 'rgba(0, 0, 0, .15)'
})
watchEffect(() => {
font.color = isDark.value ? 'rgba(255,255,255, .15)' : 'rgba(0, 0, 0, .15)'
})
const router = useRouter()
const route = useRoute()
const url = route.query.url || 'https://www.gin-vue-admin.com'
onMounted(() => {
// 挂载一些通用的事件
emitter.on('reload', reload)
if (userStore.loadingInstance) {
userStore.loadingInstance.close()
}
})
const userStore = useUserStore()
const reloadFlag = ref(true)
let reloadTimer = null
const reload = async () => {
if (reloadTimer) {
window.clearTimeout(reloadTimer)
}
reloadTimer = window.setTimeout(async () => {
if (route.meta.keepAlive) {
reloadFlag.value = false
await nextTick()
reloadFlag.value = true
} else {
const title = route.meta.title
router.push({ name: 'Reload', params: { title } })
}
}, 400)
}
</script>
<style lang="scss"></style>

114
src/view/layout/index.vue Normal file
View File

@@ -0,0 +1,114 @@
<template>
<div
class="bg-gray-50 text-slate-700 dark:text-slate-500 dark:bg-slate-800 w-screen h-screen"
>
<el-watermark
v-if="config.show_watermark"
:font="font"
:z-index="9999"
:gap="[180, 150]"
class="absolute inset-0 pointer-events-none"
:content="userStore.userInfo.nickName"
/>
<gva-header />
<div class="flex flex-row w-full gva-container pt-16 box-border h-full">
<gva-aside
v-if="
config.side_mode === 'normal' ||
(device === 'mobile' && config.side_mode == 'head') ||
(device === 'mobile' && config.side_mode == 'combination')
"
/>
<gva-aside
v-if="config.side_mode === 'combination' && device !== 'mobile'"
mode="normal"
/>
<div class="flex-1 px-2 w-0 h-full">
<gva-tabs v-if="config.showTabs" />
<div
class="overflow-auto"
:class="config.showTabs ? 'gva-container2' : 'gva-container pt-1'"
>
<router-view v-if="reloadFlag" v-slot="{ Component, route }">
<div
id="gva-base-load-dom"
class="gva-body-h bg-gray-50 dark:bg-slate-800"
>
<transition mode="out-in" :name="config.transition_type">
<keep-alive :include="routerStore.keepAliveRouters">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</transition>
</div>
</router-view>
<BottomInfo />
</div>
</div>
</div>
</div>
</template>
<script setup>
import GvaAside from '@/view/layout/aside/index.vue'
import GvaHeader from '@/view/layout/header/index.vue'
import useResponsive from '@/hooks/responsive'
import GvaTabs from './tabs/index.vue'
import BottomInfo from '@/components/bottomInfo/bottomInfo.vue'
import { emitter } from '@/utils/bus.js'
import { ref, onMounted, nextTick, reactive, watchEffect } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useRouterStore } from '@/pinia/modules/router'
import { useUserStore } from '@/pinia/modules/user'
import { useAppStore } from '@/pinia'
import { storeToRefs } from 'pinia'
import '@/style/transition.scss'
const appStore = useAppStore()
const { config, isDark, device } = storeToRefs(appStore)
defineOptions({
name: 'GvaLayout'
})
useResponsive(true)
const font = reactive({
color: 'rgba(0, 0, 0, .15)'
})
watchEffect(() => {
font.color = isDark.value ? 'rgba(255,255,255, .15)' : 'rgba(0, 0, 0, .15)'
})
const router = useRouter()
const route = useRoute()
const routerStore = useRouterStore()
onMounted(() => {
// 挂载一些通用的事件
emitter.on('reload', reload)
if (userStore.loadingInstance) {
userStore.loadingInstance.close()
}
})
const userStore = useUserStore()
const reloadFlag = ref(true)
let reloadTimer = null
const reload = async () => {
if (reloadTimer) {
window.clearTimeout(reloadTimer)
}
reloadTimer = window.setTimeout(async () => {
if (route.meta.keepAlive) {
reloadFlag.value = false
await nextTick()
reloadFlag.value = true
} else {
const title = route.meta.title
router.push({ name: 'Reload', params: { title } })
}
}, 400)
}
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,62 @@
<template>
<div @click="clickFull">
<div v-if="isShow" class="gvaIcon gvaIcon-fullscreen-expand" />
<div v-else class="gvaIcon gvaIcon-fullscreen-shrink" />
</div>
</template>
<script setup>
import screenfull from 'screenfull' // 引入screenfull
import { onMounted, onUnmounted, ref } from 'vue'
defineOptions({
name: 'Screenfull'
})
defineProps({
width: {
type: Number,
default: 22
},
height: {
type: Number,
default: 22
},
fill: {
type: String,
default: '#48576a'
}
})
onMounted(() => {
if (screenfull.isEnabled) {
screenfull.on('change', changeFullShow)
}
})
onUnmounted(() => {
screenfull.off('change')
})
const clickFull = () => {
if (screenfull.isEnabled) {
screenfull.toggle()
}
}
const isShow = ref(true)
const changeFullShow = () => {
isShow.value = !screenfull.isFullscreen
}
</script>
<style scoped lang="scss">
.screenfull-svg {
width: 16px;
height: 16px;
cursor: pointer;
vertical-align: middle;
margin-right: 32px;
fill: rgba(0, 0, 0, 0.45);
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<div class="search-component items-center">
<div
class="gvaIcon gvaIcon-refresh"
:class="[reload ? 'reloading' : '']"
@click="handleReload"
/>
<Screenfull class="search-icon" />
<div class="gvaIcon gvaIcon-customer-service" @click="toService" />
<el-switch
v-model="isDark"
:active-action-icon="Moon"
:inactive-action-icon="Sunny"
@change="handleDarkSwitch"
/>
</div>
</template>
<script setup>
import Screenfull from '@/view/layout/screenfull/index.vue'
import { emitter } from '@/utils/bus.js'
import { Sunny, Moon } from '@element-plus/icons-vue'
import { ref, watchEffect } from 'vue'
defineOptions({
name: 'BtnBox'
})
const isDark = ref(localStorage.getItem('isDark') === 'true' || true)
watchEffect(() => {
if (isDark.value) {
document.documentElement.classList.add('dark')
localStorage.setItem('isDark', true)
} else {
document.documentElement.classList.remove('dark')
localStorage.setItem('isDark', false)
}
})
const reload = ref(false)
const handleReload = () => {
reload.value = true
emitter.emit('reload')
setTimeout(() => {
reload.value = false
}, 500)
}
const toService = () => {
window.open('https://support.qq.com/product/371961')
}
const handleDarkSwitch = (e) => {
isDark.value = e
}
</script>
<style scoped lang="scss">
.search-component {
@apply inline-flex overflow-hidden text-center gap-5 mr-5 text-black dark:text-gray-100;
div {
@apply cursor-pointer;
}
.el-input__inner {
@apply border-b border-solid border-gray-300;
}
.el-dropdown-link {
@apply cursor-pointer;
}
}
.reload {
font-size: 18px;
}
.reloading {
animation: turn 0.5s linear infinite;
}
@keyframes turn {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(90deg);
}
50% {
transform: rotate(180deg);
}
75% {
transform: rotate(270deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,212 @@
<template>
<el-drawer
v-model="drawer"
title="系统配置"
direction="rtl"
:size="width"
:show-close="false"
>
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg">系统配置</span>
<el-button type="primary" @click="saveConfig">保存配置</el-button>
</div>
</template>
<div class="flex flex-col">
<div class="mb-8">
<Title title="默认主题"></Title>
<div class="mt-2 text-sm p-2 flex items-center justify-center gap-2">
<el-segmented
v-model="config.darkMode"
:options="options"
size="default"
@change="appStore.toggleDarkMode"
/>
</div>
</div>
<div class="mb-8">
<Title title="主题色"></Title>
<div class="mt-2 text-sm p-2 flex items-center gap-2 justify-center">
<div
v-for="item in colors"
:key="item"
class="w-5 h-5 rounded cursor-pointer flex items-center justify-center"
:style="`background:${item}`"
@click="appStore.togglePrimaryColor(item)"
>
<el-icon v-if="config.primaryColor === item">
<Select />
</el-icon>
</div>
<el-color-picker
v-model="customColor"
@change="appStore.togglePrimaryColor"
/>
</div>
</div>
<div class="mb-8">
<Title title="主题配置"></Title>
<div class="mt-2 text-md p-2 flex flex-col gap-2">
<div class="flex items-center justify-between">
<div>展示水印</div>
<el-switch
v-model="config.show_watermark"
@change="appStore.toggleConfigWatermark"
/>
</div>
<div class="flex items-center justify-between">
<div>灰色模式</div>
<el-switch v-model="config.grey" @change="appStore.toggleGrey" />
</div>
<div class="flex items-center justify-between">
<div>色弱模式</div>
<el-switch
v-model="config.weakness"
@change="appStore.toggleWeakness"
/>
</div>
<div class="flex items-center justify-between">
<div>菜单模式</div>
<el-segmented
v-model="config.side_mode"
:options="sideModes"
size="default"
@change="appStore.toggleSideMode"
/>
</div>
<div class="flex items-center justify-between">
<div>显示标签页</div>
<el-switch
v-model="config.showTabs"
@change="appStore.toggleTabs"
/>
</div>
<div class="flex items-center justify-between gap-2">
<div class="flex-shrink-0">页面切换动画</div>
<el-select
v-model="config.transition_type"
@change="appStore.toggleTransition"
class="w-40"
>
<el-option value="fade" label="淡入淡出" />
<el-option value="slide" label="滑动" />
<el-option value="zoom" label="缩放" />
<el-option value="none" label="无动画" />
</el-select>
</div>
</div>
</div>
<div class="mb-8">
<Title title="layout 大小配置"></Title>
<div class="mt-2 text-md p-2 flex flex-col gap-2">
<div class="flex items-center justify-between mb-2">
<div>侧边栏展开宽度</div>
<el-input-number
v-model="config.layout_side_width"
:min="150"
:max="400"
:step="10"
></el-input-number>
</div>
<div class="flex items-center justify-between mb-2">
<div>侧边栏收缩宽度</div>
<el-input-number
v-model="config.layout_side_collapsed_width"
:min="60"
:max="100"
></el-input-number>
</div>
<div class="flex items-center justify-between mb-2">
<div>侧边栏子项高度</div>
<el-input-number
v-model="config.layout_side_item_height"
:min="30"
:max="50"
></el-input-number>
</div>
</div>
</div>
<!-- <el-alert type="warning" :closable="false">
请注意所有配置请保存到本地文件的
<el-tag>config.json</el-tag> 文件中否则刷新页面后会丢失配置
</el-alert>-->
</div>
</el-drawer>
</template>
<script setup>
import { useAppStore } from '@/pinia'
import { storeToRefs } from 'pinia'
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { setSelfSetting } from '@/api/user'
import Title from './title.vue'
const appStore = useAppStore()
const { config, device } = storeToRefs(appStore)
defineOptions({
name: 'GvaSetting'
})
const width = computed(() => {
return device.value === 'mobile' ? '100%' : '500px'
})
const colors = [
'#EB2F96',
'#3b82f6',
'#2FEB54',
'#EBEB2F',
'#EB2F2F',
'#2FEBEB'
]
const drawer = defineModel('drawer', {
default: true,
type: Boolean
})
const options = ['dark', 'light', 'auto']
const sideModes = [
{
label: '正常模式',
value: 'normal'
},
{
label: '顶部菜单栏模式',
value: 'head'
},
{
label: '组合模式',
value: 'combination'
}
]
const saveConfig = async () => {
/*const input = document.createElement("textarea");
input.value = JSON.stringify(config.value);
// 添加回车
input.value = input.value.replace(/,/g, ",\n");
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
ElMessage.success("复制成功, 请自行保存到本地文件中");*/
const res = await setSelfSetting(config.value)
if (res.code === 0) {
localStorage.setItem('originSetting', JSON.stringify(config.value))
ElMessage.success('保存成功')
drawer.value = false
}
}
const customColor = ref('')
</script>
<style lang="scss" scoped>
::v-deep(.el-drawer__header) {
@apply border-gray-400 dark:border-gray-600;
}
</style>

View File

@@ -0,0 +1,34 @@
<template>
<div class="title relative my-2">
<div class="flex-shrink-0 text-center text-xl text-gray-600">
{{ title }}
</div>
</div>
</template>
<script setup>
defineOptions({
name: 'layoutSettingTitle'
})
defineProps({
title: String
})
</script>
<style scoped>
.title {
display: flex;
align-items: center;
justify-content: center;
gap: 4rem;
}
.title::before,
.title::after {
content: '';
height: 1px;
width: 100%;
background-color: #e3e3e3;
}
</style>

View File

@@ -0,0 +1,425 @@
<!--
@auther: bypanghu<bypanghu@163.com>
@date: 2024/5/7
!-->
<template>
<div class="gva-tabs">
<el-tabs
v-model="activeValue"
:closable="!(historys.length === 1 && $route.name === defaultRouter)"
type="card"
class="bg-white text-slate-700 dark:text-slate-500 dark:bg-slate-900"
@contextmenu.prevent="openContextMenu($event)"
@tab-click="changeTab"
@tab-remove="removeTab"
@click.middle.prevent="middleCloseTab($event)"
>
<el-tab-pane
v-for="item in historys"
:key="getFmtString(item)"
:label="item.meta.title"
:name="getFmtString(item)"
:tab="item"
class="border-none"
>
<template #label>
<span
:tab="item"
:class="
activeValue === getFmtString(item)
? 'text-active'
: 'text-gray-600 dark:text-slate-400 '
"
><i
:class="
activeValue === getFmtString(item)
? 'text-active'
: 'text-gray-600 dark:text-slate-400'
"
/>
{{ fmtTitle(item.meta.title, item) }}</span
>
</template>
</el-tab-pane>
</el-tabs>
<!--自定义右键菜单html代码-->
<ul
v-show="contextMenuVisible"
:style="{ left: left + 'px', top: top + 'px' }"
class="contextmenu"
>
<li @click="closeAll">关闭所有</li>
<li @click="closeLeft">关闭左侧</li>
<li @click="closeRight">关闭右侧</li>
<li @click="closeOther">关闭其他</li>
</ul>
</div>
</template>
<script setup>
import { emitter } from '@/utils/bus.js'
import { computed, onUnmounted, ref, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/pinia/modules/user'
import { fmtTitle } from '@/utils/fmtRouterTitle'
defineOptions({
name: 'HistoryComponent'
})
const route = useRoute()
const router = useRouter()
const getFmtString = (item) => {
return item.name + JSON.stringify(item.query) + JSON.stringify(item.params)
}
const historys = ref([])
const activeValue = ref('')
const contextMenuVisible = ref(false)
const userStore = useUserStore()
const left = ref(0)
const top = ref(0)
const isCollapse = ref(false)
const isMobile = ref(false)
const rightActive = ref('')
const defaultRouter = computed(
() => userStore.userInfo.authority.defaultRouter
)
const openContextMenu = (e) => {
if (historys.value.length === 1 && route.name === defaultRouter.value) {
return false
}
let id = ''
if (e.srcElement.nodeName === 'SPAN') {
id = e.srcElement.offsetParent.id
} else {
id = e.srcElement.id
}
if (id) {
contextMenuVisible.value = true
left.value = e.clientX
top.value = e.clientY + 10
rightActive.value = id.substring(4)
}
}
const closeAll = () => {
historys.value = [
{
name: defaultRouter.value,
meta: {
title: '首页'
},
query: {},
params: {}
}
]
router.push({ name: defaultRouter.value })
contextMenuVisible.value = false
sessionStorage.setItem('historys', JSON.stringify(historys.value))
}
const closeLeft = () => {
let right
const rightIndex = historys.value.findIndex((item) => {
if (getFmtString(item) === rightActive.value) {
right = item
}
return getFmtString(item) === rightActive.value
})
const activeIndex = historys.value.findIndex(
(item) => getFmtString(item) === activeValue.value
)
historys.value.splice(0, rightIndex)
if (rightIndex > activeIndex) {
router.push(right)
}
sessionStorage.setItem('historys', JSON.stringify(historys.value))
}
const closeRight = () => {
let right
const leftIndex = historys.value.findIndex((item) => {
if (getFmtString(item) === rightActive.value) {
right = item
}
return getFmtString(item) === rightActive.value
})
const activeIndex = historys.value.findIndex(
(item) => getFmtString(item) === activeValue.value
)
historys.value.splice(leftIndex + 1, historys.value.length)
if (leftIndex < activeIndex) {
router.push(right)
}
sessionStorage.setItem('historys', JSON.stringify(historys.value))
}
const closeOther = () => {
let right
historys.value = historys.value.filter((item) => {
if (getFmtString(item) === rightActive.value) {
right = item
}
return getFmtString(item) === rightActive.value
})
router.push(right)
sessionStorage.setItem('historys', JSON.stringify(historys.value))
}
const isSame = (route1, route2) => {
if (route1.name !== route2.name) {
return false
}
if (
Object.keys(route1.query).length !== Object.keys(route2.query).length ||
Object.keys(route1.params).length !== Object.keys(route2.params).length
) {
return false
}
for (const key in route1.query) {
if (route1.query[key] !== route2.query[key]) {
return false
}
}
for (const key in route1.params) {
if (route1.params[key] !== route2.params[key]) {
return false
}
}
return true
}
const setTab = (route) => {
if (!historys.value.some((item) => isSame(item, route))) {
const obj = {}
obj.name = route.name
obj.meta = { ...route.meta }
delete obj.meta.matched
obj.query = route.query
obj.params = route.params
historys.value.push(obj)
}
window.sessionStorage.setItem('activeValue', getFmtString(route))
}
const historyMap = ref({})
const changeTab = (TabsPaneContext) => {
const name = TabsPaneContext?.props?.name
if (!name) return
const tab = historyMap.value[name]
router.push({
name: tab.name,
query: tab.query,
params: tab.params
})
}
const removeTab = (tab) => {
const index = historys.value.findIndex((item) => getFmtString(item) === tab)
if (getFmtString(route) === tab) {
if (historys.value.length === 1) {
router.push({ name: defaultRouter.value })
} else {
if (index < historys.value.length - 1) {
router.push({
name: historys.value[index + 1].name,
query: historys.value[index + 1].query,
params: historys.value[index + 1].params
})
} else {
router.push({
name: historys.value[index - 1].name,
query: historys.value[index - 1].query,
params: historys.value[index - 1].params
})
}
}
}
historys.value.splice(index, 1)
}
watch(
() => contextMenuVisible.value,
() => {
if (contextMenuVisible.value) {
document.body.addEventListener('click', () => {
contextMenuVisible.value = false
})
} else {
document.body.removeEventListener('click', () => {
contextMenuVisible.value = false
})
}
}
)
watch(
() => route,
(to) => {
if (to.name === 'Login' || to.name === 'Reload') {
return
}
historys.value = historys.value.filter((item) => !item.meta.closeTab)
setTab(to)
sessionStorage.setItem('historys', JSON.stringify(historys.value))
activeValue.value = window.sessionStorage.getItem('activeValue')
},
{ deep: true }
)
watch(
() => historys.value,
() => {
sessionStorage.setItem('historys', JSON.stringify(historys.value))
historyMap.value = {}
historys.value.forEach((item) => {
historyMap.value[getFmtString(item)] = item
})
emitter.emit('setKeepAlive', historys.value)
},
{
deep: true
}
)
const initPage = () => {
// 全局监听 关闭当前页面函数
emitter.on('closeThisPage', () => {
removeTab(getFmtString(route))
})
// 全局监听 关闭所有页面函数
emitter.on('closeAllPage', () => {
closeAll()
})
emitter.on('mobile', (data) => {
isMobile.value = data
})
emitter.on('collapse', (data) => {
isCollapse.value = data
})
emitter.on('setQuery', (data) => {
const index = historys.value.findIndex(
(item) => getFmtString(item) === activeValue.value
)
historys.value[index].query = data
activeValue.value = getFmtString(historys.value[index])
const currentUrl = window.location.href.split('?')[0]
const currentSearchParams = new URLSearchParams(data).toString()
window.history.replaceState(
{},
'',
`${currentUrl}?${currentSearchParams}`
)
sessionStorage.setItem('historys', JSON.stringify(historys.value))
})
emitter.on('switchTab', async (data) => {
const index = historys.value.findIndex((item) => item.name === data.name)
if (index < 0) {
return
}
for (const key in data.query) {
data.query[key] = String(data.query[key])
}
for (const key in data.params) {
data.params[key] = String(data.params[key])
}
historys.value[index].query = data.query || {}
historys.value[index].params = data.params || {}
await nextTick()
router.push(historys.value[index])
})
const initHistorys = [
{
name: defaultRouter.value,
meta: {
title: '首页'
},
query: {},
params: {}
}
]
setTab(route)
historys.value =
JSON.parse(sessionStorage.getItem('historys')) || initHistorys
if (!window.sessionStorage.getItem('activeValue')) {
activeValue.value = getFmtString(route)
} else {
activeValue.value = window.sessionStorage.getItem('activeValue')
}
if (window.sessionStorage.getItem('needCloseAll') === 'true') {
closeAll()
window.sessionStorage.removeItem('needCloseAll')
}
}
initPage()
onUnmounted(() => {
emitter.off('collapse')
emitter.off('mobile')
})
const middleCloseTab = (e) => {
if (historys.value.length === 1 && route.name === defaultRouter.value) {
return false
}
let id = ''
if (e.srcElement.nodeName === 'SPAN') {
id = e.srcElement.offsetParent.id
} else {
id = e.srcElement.id
}
if (id) {
removeTab(id.substring(4))
}
}
</script>
<style lang="scss" scoped>
.contextmenu {
@apply bg-white dark:bg-slate-900 w-28 m-0 py-2.5 px-0 border border-gray-200 text-sm shadow-md rounded absolute z-50 border-solid dark:border-slate-800;
}
.contextmenu li {
@apply text-slate-700 dark:text-slate-200 text-base list-none px-4 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-600 cursor-pointer;
}
$base-tag-item-height: 4rem;
.gva-tabs {
::v-deep(.el-tabs--card > .el-tabs__header) {
border: none;
}
::v-deep(.el-tabs__nav-scroll) {
padding: 4px 4px;
}
::v-deep(.el-tabs__nav) {
border: 0;
}
::v-deep(.el-tabs__header) {
border-bottom: 0;
}
::v-deep(.el-tabs__item) {
box-sizing: border-box;
border: 1px solid var(--el-border-color-darker);
border-radius: 2px;
margin-right: 5px;
margin-left: 2px;
transition: padding 0.3s cubic-bezier(0.645, 0.045, 0.355, 1) !important;
height: 34px;
&.is-active {
border: 1px solid var(--el-color-primary);
}
}
::v-deep(.el-tabs__item):first-child {
border: 1px solid var(--el-border-color-darker);
&.is-active {
border: 1px solid var(--el-color-primary);
}
}
}
</style>