🎨 更新用户版本

This commit is contained in:
2025-09-02 22:56:30 +08:00
parent 8276b3e87f
commit a1906d2a36
71 changed files with 4790 additions and 962 deletions

View File

@@ -37,7 +37,7 @@
const menuComponent = computed(() => {
if (
props.routerInfo.children &&
props.routerInfo.children.filter((item) => !item.hidden).length
props.routerInfo.children?.filter((item) => !item.hidden).length
) {
return AsyncSubmenu
} else {

View File

@@ -1,14 +1,16 @@
<template>
<el-menu-item
:index="routerInfo.name"
class="dark:text-slate-300 overflow-hidden"
:style="{
height: sideHeight
}"
height: sideHeight
}"
>
<el-icon v-if="routerInfo.meta.icon">
<component :is="routerInfo.meta.icon" />
</el-icon>
<template v-else>
{{ isCollapse ? routerInfo.meta.title[0] : "" }}
</template>
<template #title>
{{ routerInfo.meta.title }}
</template>
@@ -16,7 +18,7 @@
</template>
<script setup>
import { computed } from 'vue'
import {computed, inject} from 'vue'
import { useAppStore } from '@/pinia'
import { storeToRefs } from 'pinia'
const appStore = useAppStore()
@@ -35,6 +37,10 @@
}
})
const isCollapse = inject('isCollapse', {
default: false
})
const sideHeight = computed(() => {
return config.value.layout_side_item_height + 'px'
})

View File

@@ -7,7 +7,7 @@
<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)]"
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)"
>
@@ -34,7 +34,7 @@
:collapse="isCollapse"
:collapse-transition="false"
:default-active="active"
class="border-r-0 w-full"
class="!border-r-0 w-full"
unique-opened
@select="(index, _, ele) => selectMenuItem(index, _, ele, false)"
>

View File

@@ -1,13 +1,16 @@
<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"
ref="menuContainer"
>
<el-menu
:default-active="active"
mode="horizontal"
class="border-r-0 w-full flex gap-1 items-center box-border h-[calc(100%-1px)]"
class="!border-r-0 w-full flex gap-1 items-center box-border h-[calc(100%-1px)]"
unique-opened
:ellipsis="shouldEllipsis"
@select="selectMenuItem"
ref="menuRef"
>
<template v-for="item in routerStore.asyncRouters[0].children">
<aside-component
@@ -23,7 +26,7 @@
<script setup>
import AsideComponent from '@/view/layout/aside/asideComponent/index.vue'
import { ref, provide, watchEffect } from 'vue'
import { ref, provide, watchEffect, onMounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRouterStore } from '@/pinia/modules/router'
import { useAppStore } from '@/pinia'
@@ -39,6 +42,26 @@
const routerStore = useRouterStore()
const isCollapse = ref(false)
const active = ref('')
const menuRef = ref(null)
const menuContainer = ref(null)
const shouldEllipsis = ref(false)
// 计算是否需要启用省略功能
const calculateEllipsis = async () => {
await nextTick()
if (!menuRef.value || !menuContainer.value) return
const menuItems = menuRef.value.$el.querySelectorAll('.el-menu-item, .el-sub-menu')
let totalWidth = 0
menuItems.forEach(item => {
totalWidth += item.offsetWidth
})
const containerWidth = menuContainer.value.offsetWidth
shouldEllipsis.value = totalWidth > containerWidth
}
watchEffect(() => {
if (route.name === 'Iframe') {
active.value = decodeURIComponent(route.query.url)
@@ -53,10 +76,24 @@
} else {
isCollapse.value = false
}
// 设备变化时重新计算
calculateEllipsis()
})
// 当路由变化时重新计算
watchEffect(() => {
if (route.name) {
nextTick(calculateEllipsis)
}
})
provide('isCollapse', isCollapse)
onMounted(() => {
calculateEllipsis()
window.addEventListener('resize', calculateEllipsis)
})
const selectMenuItem = (index) => {
const query = {}
const params = {}
@@ -70,30 +107,26 @@
})
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 })
}
if (index === 'Iframe') {
query.url = decodeURIComponent(index)
}
router.push({ name: index, query, params })
}
</script>
<style lang="scss" scoped>
<style lang="scss">
.el-menu--horizontal.el-menu,
.el-menu--horizontal > .el-menu-item.is-active {
border-bottom: none !important;
}
.el-menu--horizontal>.el-sub-menu.is-active .el-sub-menu__title {
border-bottom: none !important;
}
.el-menu-item.is-active {
background-color: var(--el-color-primary-light-8) !important;
}

View File

@@ -4,7 +4,8 @@
v-if="
config.side_mode === 'normal' ||
(device === 'mobile' && config.side_mode == 'head') ||
(device === 'mobile' && config.side_mode == 'combination')
(device === 'mobile' && config.side_mode == 'combination') ||
(device === 'mobile' && config.side_mode == 'sidebar')
"
/>
<head-mode v-if="config.side_mode === 'head' && device !== 'mobile'" />
@@ -12,6 +13,9 @@
v-if="config.side_mode === 'combination' && device !== 'mobile'"
:mode="mode"
/>
<sidebar-mode
v-if="config.side_mode === 'sidebar' && device !== 'mobile'"
/>
</div>
</template>
@@ -19,6 +23,7 @@
import NormalMode from './normalMode.vue'
import HeadMode from './headMode.vue'
import CombinationMode from './combinationMode.vue'
import SidebarMode from './sidebarMode.vue'
defineProps({
mode: {

View File

@@ -11,7 +11,7 @@
:collapse="isCollapse"
:collapse-transition="false"
:default-active="active"
class="border-r-0 w-full"
class="!border-r-0 w-full"
unique-opened
@select="selectMenuItem"
>

View File

@@ -0,0 +1,300 @@
<template>
<div class="flex h-full">
<!-- 一级菜单常驻侧边栏 -->
<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"
:style="{
width: config.layout_side_collapsed_width + 'px'
}"
>
<el-scrollbar>
<el-menu
:collapse="true"
:collapse-transition="false"
:default-active="topActive"
class="!border-r-0 w-full"
unique-opened
@select="selectTopMenuItem"
>
<template v-for="item in routerStore.asyncRouters[0]?.children || []">
<el-menu-item
v-if="!item.hidden && (!item.children || item.children.length === 0)"
:key="item.name"
:index="item.name"
class="dark:text-slate-300 overflow-hidden"
:style="{
height: config.layout_side_item_height + 'px'
}"
>
<el-icon v-if="item.meta.icon">
<component :is="item.meta.icon" />
</el-icon>
<template v-else>
{{ item.meta.title[0] }}
</template>
<template #title>
{{ item.meta.title }}
</template>
</el-menu-item>
<template v-else-if="!item.hidden" >
<el-menu-item
:key="item.name"
:index="item.name"
:class="{'is-active': topActive === item.name}"
class="dark:text-slate-300 overflow-hidden"
:style="{
height: config.layout_side_item_height + 'px'
}"
>
<el-icon v-if="item.meta.icon">
<component :is="item.meta.icon" />
</el-icon>
<template v-else>
{{ item.meta.title[0] }}
</template>
<template #title>
{{ item.meta.title }}
</template>
</el-menu-item>
</template>
</template>
</el-menu>
</el-scrollbar>
</div>
<!-- 二级菜单并列显示 -->
<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 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 secondLevelMenus">
<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: 'SidebarMode'
})
const route = useRoute()
const router = useRouter()
const routerStore = useRouterStore()
const isCollapse = ref(false)
const active = ref('')
const topActive = ref('')
const secondLevelMenus = ref([])
const layoutSideWidth = computed(() => {
if (!isCollapse.value) {
return config.value.layout_side_width
} else {
return config.value.layout_side_collapsed_width
}
})
provide('isCollapse', isCollapse)
// 更新二级菜单
const updateSecondLevelMenus = (menuName) => {
const menu = routerStore.asyncRouters[0]?.children.find(item => item.name === menuName)
if (menu && menu.children && menu.children.length > 0) {
secondLevelMenus.value = menu.children
}
}
// 选择一级菜单
const selectTopMenuItem = (index) => {
topActive.value = index
// 获取选中的菜单项
const menu = routerStore.asyncRouters[0]?.children.find(item => item.name === index)
// 只有当选中的菜单有子菜单时,才更新二级菜单区域
if (menu && menu.children && menu.children.length > 0) {
updateSecondLevelMenus(index)
// 导航到第一个可见的子菜单
const firstVisibleChild = menu.children.find(child => !child.hidden)
if (firstVisibleChild) {
navigateToMenuItem(firstVisibleChild.name)
}
} else {
// 如果没有子菜单,直接导航到该菜单,但不更新二级菜单区域
navigateToMenuItem(index)
}
}
// 选择二级或更深层级的菜单
const selectMenuItem = (index) => {
navigateToMenuItem(index)
}
// 导航到指定菜单
const navigateToMenuItem = (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
}
watchEffect(() => {
if (route.name === 'Iframe') {
active.value = decodeURIComponent(route.query.url)
return
}
active.value = route.meta.activeName || route.name
// 找到当前路由所属的一级菜单
const findParentMenu = () => {
// 首先检查当前路由是否就是一级菜单
const isTopMenu = routerStore.asyncRouters[0]?.children.some(
item => !item.hidden && item.name === route.name
)
if (isTopMenu) {
return route.name
}
for (const topMenu of routerStore.asyncRouters[0]?.children || []) {
if (topMenu.hidden) continue
// 检查当前路由是否是这个一级菜单的子菜单
if (topMenu.children && topMenu.children.some(child => child.name === route.name)) {
return topMenu.name
}
// 递归检查更深层级
const checkChildren = (items) => {
for (const item of items || []) {
if (item.name === route.name) {
return true
}
if (item.children && checkChildren(item.children)) {
return true
}
}
return false
}
if (topMenu.children && checkChildren(topMenu.children)) {
return topMenu.name
}
}
return null
}
const parentMenu = findParentMenu()
if (parentMenu) {
topActive.value = parentMenu
// 只有当父菜单有子菜单时,才更新二级菜单区域
const menu = routerStore.asyncRouters[0]?.children.find(item => item.name === parentMenu)
if (menu && menu.children && menu.children.length > 0) {
updateSecondLevelMenus(parentMenu)
} else {
// 如果找到的父菜单没有子菜单,保持当前一级菜单高亮,但需要显示一些二级菜单
// 寻找第一个有子菜单的一级菜单来显示其子菜单
const firstMenuWithChildren = routerStore.asyncRouters[0].children.find(
item => !item.hidden && item.children && item.children.length > 0
)
if (firstMenuWithChildren) {
// 只更新二级菜单区域,但保持当前一级菜单的高亮状态
updateSecondLevelMenus(firstMenuWithChildren.name)
}
}
} else if (routerStore.asyncRouters[0]?.children?.length > 0) {
// 如果没有找到父菜单,保持当前路由名称作为高亮,但需要显示一些二级菜单
// 寻找第一个有子菜单的一级菜单来显示其子菜单
const firstMenuWithChildren = routerStore.asyncRouters[0].children.find(
item => !item.hidden && item.children && item.children.length > 0
)
if (firstMenuWithChildren) {
// 只更新二级菜单区域,高亮状态保持为当前路由
topActive.value = route.name
secondLevelMenus.value = firstMenuWithChildren.children
}
}
})
watchEffect(() => {
if (device.value === 'mobile') {
isCollapse.value = true
} else {
isCollapse.value = false
}
})
</script>

View File

@@ -8,7 +8,7 @@
<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"
class="w-8 h-8 p-2 shadow rounded-full border border-gray-200 dark:border-gray-600 cursor-pointer border-solid"
>
<Film />
</el-icon>
@@ -29,7 +29,7 @@
<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"
class="w-8 h-8 p-2 shadow rounded-full border border-gray-200 dark:border-gray-600 cursor-pointer border-solid"
>
<Search />
</el-icon>
@@ -37,7 +37,7 @@
<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="w-8 h-8 p-2 shadow rounded-full border border-gray-200 dark:border-gray-600 cursor-pointer border-solid"
@click="toggleSetting"
>
<Setting />
@@ -46,7 +46,7 @@
<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="w-8 h-8 p-2 shadow rounded-full border border-gray-200 dark:border-gray-600 cursor-pointer border-solid"
:class="showRefreshAnmite ? 'animate-spin' : ''"
@click="toggleRefresh"
>
@@ -61,14 +61,14 @@
>
<el-icon
v-if="appStore.isDark"
class="w-8 h-8 shadow rounded-full border border-gray-600 cursor-pointer border-solid"
class="w-8 h-8 p-2 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"
class="w-8 h-8 p-2 shadow rounded-full border border-gray-200 cursor-pointer border-solid"
@click="appStore.toggleTheme(true)"
>
<Moon />

View File

@@ -11,10 +11,10 @@
:content="userStore.userInfo.nickName"
/>
<gva-header />
<div class="flex flex-row w-full gva-container pt-16 box-border h-full">
<div class="flex flex-row w-full gva-container pt-16 box-border !h-full">
<gva-aside
v-if="
config.side_mode === 'normal' ||
config.side_mode === 'normal' || config.side_mode === 'sidebar' ||
(device === 'mobile' && config.side_mode == 'head') ||
(device === 'mobile' && config.side_mode == 'combination')
"
@@ -34,7 +34,7 @@
id="gva-base-load-dom"
class="gva-body-h bg-gray-50 dark:bg-slate-800"
>
<transition mode="out-in" :name="config.transition_type">
<transition mode="out-in" :name="route.meta.transitionType || config.transition_type">
<keep-alive :include="routerStore.keepAliveRouters">
<component :is="Component" :key="route.fullPath" />
</keep-alive>

View File

@@ -0,0 +1,219 @@
<template>
<div class="grid grid-cols-2 gap-6 font-inter px-6">
<div
v-for="layout in layoutModes"
:key="layout.value"
class="bg-white dark:bg-gray-700 border-2 border-gray-200 dark:border-gray-600 rounded-xl p-6 cursor-pointer transition-all duration-150 ease-in-out hover:transform hover:-translate-y-1 hover:shadow-xl"
:class="{
'ring-2 ring-offset-2 ring-offset-gray-50 dark:ring-offset-gray-900 transform -translate-y-1 shadow-xl': modelValue === layout.value
}"
:style="modelValue === layout.value ? {
borderColor: primaryColor,
ringColor: primaryColor + '40'
} : {}"
@click="handleLayoutChange(layout.value)"
>
<div class="flex justify-center mb-5">
<div
class="w-28 h-20 bg-gray-50 dark:bg-gray-600 border border-gray-200 dark:border-gray-500 rounded-lg p-2 flex gap-1.5 shadow-inner"
:class="layout.containerClass"
>
<div
v-if="layout.showSidebar"
class="rounded-sm"
:class="[layout.sidebarClass]"
:style="getSidebarStyle(layout)"
></div>
<div class="flex-1 flex flex-col gap-1.5">
<div
v-if="layout.showHeader"
class="rounded-sm"
:class="layout.headerClass"
:style="getHeaderStyle(layout)"
></div>
<div
class="flex-1 rounded-sm"
:class="layout.contentClass"
:style="getContentStyle(layout)"
></div>
</div>
</div>
</div>
<div class="text-center">
<span class="block text-base font-semibold text-gray-900 dark:text-white mb-2" :class="{ 'text-current': modelValue === layout.value }" :style="modelValue === layout.value ? { color: primaryColor } : {}">{{ layout.label }}</span>
<span class="block text-sm text-gray-500 dark:text-gray-400">{{ layout.description }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useAppStore } from '@/pinia'
defineOptions({
name: 'LayoutModeCard'
})
const props = defineProps({
modelValue: {
type: String,
default: 'normal'
}
})
const emit = defineEmits(['update:modelValue'])
const appStore = useAppStore()
const { config } = storeToRefs(appStore)
const primaryColor = computed(() => config.value.primaryColor)
const lighterPrimaryColor = computed(() => {
const hex = config.value.primaryColor.replace('#', '')
const r = parseInt(hex.substr(0, 2), 16)
const g = parseInt(hex.substr(2, 2), 16)
const b = parseInt(hex.substr(4, 2), 16)
return `rgba(${r}, ${g}, ${b}, 0.7)`
})
const lightestPrimaryColor = computed(() => {
const hex = config.value.primaryColor.replace('#', '')
const r = parseInt(hex.substr(0, 2), 16)
const g = parseInt(hex.substr(2, 2), 16)
const b = parseInt(hex.substr(4, 2), 16)
return `rgba(${r}, ${g}, ${b}, 0.4)`
})
const layoutModes = [
{
value: 'normal',
label: '经典布局',
description: '左侧导航,顶部标题栏',
containerClass: '',
showSidebar: true,
sidebarClass: 'w-1/4',
showHeader: true,
headerClass: 'h-1/4',
contentClass: '',
showRightSidebar: false,
primaryElement: 'sidebar'
},
{
value: 'head',
label: '顶部导航',
description: '水平导航栏布局',
containerClass: 'flex-col',
showSidebar: false,
showHeader: true,
headerClass: 'h-1/3',
contentClass: '',
showRightSidebar: false,
primaryElement: 'header'
},
{
value: 'combination',
label: '混合布局',
description: '多级导航组合模式',
containerClass: '',
showSidebar: true,
sidebarClass: 'w-1/5',
showHeader: true,
headerClass: 'h-1/4',
contentClass: '',
showRightSidebar: true,
rightSidebarClass: 'w-1/5',
primaryElement: 'header',
secondaryElement: 'sidebar'
},
{
value: 'sidebar',
label: '侧栏常驻',
description: '二级菜单会始终打开',
containerClass: '',
showSidebar: true,
sidebarClass: 'w-1/3',
showHeader: true,
headerClass: 'h-1/4',
contentClass: '',
showRightSidebar: false,
primaryElement: 'sidebar'
}
]
const getSidebarStyle = (layout) => {
if (layout.primaryElement === 'sidebar') {
return { backgroundColor: primaryColor.value, opacity: '0.95' }
} else if (layout.secondaryElement === 'sidebar') {
return { backgroundColor: lighterPrimaryColor.value, opacity: '0.85' }
} else {
return { backgroundColor: lightestPrimaryColor.value, opacity: '0.6' }
}
}
const getHeaderStyle = (layout) => {
if (layout.primaryElement === 'header') {
return { backgroundColor: primaryColor.value, opacity: '0.95' }
} else if (layout.secondaryElement === 'header') {
return { backgroundColor: lighterPrimaryColor.value, opacity: '0.85' }
} else {
return { backgroundColor: lightestPrimaryColor.value, opacity: '0.6' }
}
}
const getContentStyle = (layout) => {
return { backgroundColor: lightestPrimaryColor.value, opacity: '0.5' }
}
const getRightSidebarStyle = (layout) => {
if (layout.primaryElement === 'rightSidebar') {
return { backgroundColor: primaryColor.value, opacity: '0.95' }
} else if (layout.secondaryElement === 'rightSidebar') {
return { backgroundColor: lighterPrimaryColor.value, opacity: '0.85' }
} else {
return { backgroundColor: lightestPrimaryColor.value, opacity: '0.6' }
}
}
const handleLayoutChange = (layout) => {
emit('update:modelValue', layout)
}
</script>
<style scoped>
.font-inter {
font-family: 'Inter', sans-serif;
}
.flex-col {
flex-direction: column;
}
.w-1\/4 {
width: 25%;
}
.w-1\/3 {
width: 33.333333%;
}
.w-1\/5 {
width: 20%;
}
.h-1\/4 {
height: 25%;
}
.h-1\/3 {
height: 33.333333%;
}
@media (max-width: 480px) {
.grid-cols-2 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
}
</style>

View File

@@ -0,0 +1,113 @@
<template>
<div class="flex items-center justify-between py-4 font-inter border-b border-gray-100 dark:border-gray-700 last:border-b-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ label }}</span>
<slot name="suffix"></slot>
</div>
<div class="flex items-center setting-controls">
<slot></slot>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useAppStore } from '@/pinia'
defineOptions({
name: 'SettingItem'
})
defineProps({
label: {
type: String,
required: true
}
})
const appStore = useAppStore()
const { config } = storeToRefs(appStore)
const primaryColor = computed(() => config.value.primaryColor)
const primaryColorWithOpacity = computed(() => config.value.primaryColor + '40')
</script>
<style scoped>
.font-inter {
font-family: 'Inter', sans-serif;
}
.setting-controls {
::v-deep(.el-switch) {
--el-switch-on-color: v-bind(primaryColor);
--el-switch-off-color: #d1d5db;
}
::v-deep(.el-select) {
.el-input__wrapper {
border: 1px solid #e5e7eb;
border-radius: 6px;
transition: all 150ms ease-in-out;
&:hover {
border-color: v-bind(primaryColor);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&.is-focus {
border-color: v-bind(primaryColor);
box-shadow: 0 0 0 2px v-bind(primaryColorWithOpacity);
}
}
}
::v-deep(.el-input-number) {
.el-input__wrapper {
border: 1px solid #e5e7eb;
border-radius: 6px;
transition: all 150ms ease-in-out;
&:hover {
border-color: v-bind(primaryColor);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&.is-focus {
border-color: v-bind(primaryColor);
box-shadow: 0 0 0 2px v-bind(primaryColorWithOpacity);
}
}
}
}
.dark .setting-controls {
::v-deep(.el-switch) {
--el-switch-off-color: #4b5563;
}
::v-deep(.el-select) {
.el-input__wrapper {
border-color: #4b5563;
background-color: #374151;
&:hover {
border-color: v-bind(primaryColor);
}
}
}
::v-deep(.el-input-number) {
.el-input__wrapper {
border-color: #4b5563;
background-color: #374151;
&:hover {
border-color: v-bind(primaryColor);
}
}
}
}
</style>

View File

@@ -0,0 +1,152 @@
<template>
<div class="font-inter">
<div class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-8 shadow-sm">
<div class="mb-8">
<p class="text-base font-semibold text-gray-700 dark:text-gray-300 mb-5">精选色彩</p>
<div class="grid grid-cols-3 gap-4">
<div
v-for="colorItem in presetColors"
:key="colorItem.color"
class="flex items-center gap-4 p-4 bg-white dark:bg-gray-700 border-2 border-gray-200 dark:border-gray-600 rounded-xl cursor-pointer transition-all duration-150 ease-in-out hover:transform hover:-translate-y-1 hover:shadow-lg"
:class="{
'ring-2 ring-offset-2 ring-offset-gray-50 dark:ring-offset-gray-800 transform -translate-y-1 shadow-lg': modelValue === colorItem.color
}"
:style="modelValue === colorItem.color ? {
borderColor: colorItem.color,
ringColor: colorItem.color + '40'
} : {}"
@click="handleColorChange(colorItem.color)"
>
<div
class="relative w-10 h-10 rounded-lg border border-gray-300 dark:border-gray-500 flex-shrink-0 shadow-sm"
:style="{ backgroundColor: colorItem.color }"
>
<div
v-if="modelValue === colorItem.color"
class="absolute inset-0 flex items-center justify-center text-white text-base"
style="text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);"
>
<el-icon>
<Check />
</el-icon>
</div>
</div>
<div class="min-w-0 flex-1">
<span class="block text-sm font-semibold text-gray-900 dark:text-white">{{ colorItem.name }}</span>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between p-5 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-xl mb-6 shadow-sm">
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white">自定义颜色</h4>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">选择任意颜色作为主题色</p>
</div>
<el-color-picker
v-model="customColor"
size="large"
:predefine="presetColors.map(item => item.color)"
@change="handleCustomColorChange"
class="custom-color-picker"
/>
</div>
<div class="bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-xl p-5 shadow-sm">
<div class="flex items-center justify-between">
<span class="text-base font-semibold text-gray-700 dark:text-gray-300">当前主题色</span>
<div class="flex items-center gap-3">
<div
class="w-6 h-6 rounded-lg border border-gray-300 dark:border-gray-500 shadow-sm"
:style="{ backgroundColor: modelValue }"
></div>
<code class="text-sm font-mono bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-500">
{{ modelValue }}
</code>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Check } from '@element-plus/icons-vue'
defineOptions({
name: 'ThemeColorPicker'
})
const props = defineProps({
modelValue: {
type: String,
default: '#3b82f6'
}
})
const emit = defineEmits(['update:modelValue'])
const customColor = ref(props.modelValue)
const presetColors = [
{ color: '#4E80EE', name: '默认' },
{ color: '#8bb5d1', name: '晨雾蓝' },
{ color: '#a8c8a8', name: '薄荷绿' },
{ color: '#d4a5a5', name: '玫瑰粉' },
{ color: '#c8a8d8', name: '薰衣草' },
{ color: '#f0c674', name: '暖阳黄' },
{ color: '#b8b8b8', name: '月光银' },
{ color: '#d8a8a8', name: '珊瑚橙' },
{ color: '#a8d8d8', name: '海雾青' },
{ color: '#c8c8a8', name: '橄榄绿' },
{ color: '#d8c8a8', name: '奶茶棕' },
{ color: '#a8a8d8', name: '梦幻紫' },
{ color: '#c8d8a8', name: '抹茶绿' }
]
const handleColorChange = (color) => {
customColor.value = color
emit('update:modelValue', color)
}
const handleCustomColorChange = (color) => {
if (color) {
emit('update:modelValue', color)
}
}
watch(() => props.modelValue, (newValue) => {
customColor.value = newValue
})
</script>
<style scoped>
.font-inter {
font-family: 'Inter', sans-serif;
}
.custom-color-picker {
::v-deep(.el-color-picker__trigger) {
border: 1px solid #e5e7eb;
border-radius: 6px;
transition: all 150ms ease-in-out;
&:hover {
border-color: #9ca3af;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
}
.dark .custom-color-picker {
::v-deep(.el-color-picker__trigger) {
border-color: #4b5563;
&:hover {
border-color: #6b7280;
}
}
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<div class="flex justify-center">
<div class="inline-flex bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-1 gap-1">
<div
v-for="mode in themeModes"
:key="mode.value"
class="flex flex-col items-center justify-center px-4 py-3 rounded-md cursor-pointer transition-all duration-150 ease-in-out min-w-[64px]"
:class="[
modelValue === mode.value
? 'text-white shadow-sm transform -translate-y-0.5'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
]"
:style="modelValue === mode.value ? { backgroundColor: primaryColor } : {}"
@click="handleModeChange(mode.value)"
>
<el-icon class="text-lg mb-1">
<component :is="mode.icon" />
</el-icon>
<span class="text-xs font-medium">{{ mode.label }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { Sunny, Moon, Monitor } from '@element-plus/icons-vue'
import { useAppStore } from '@/pinia'
defineOptions({
name: 'ThemeModeSelector'
})
const props = defineProps({
modelValue: {
type: String,
default: 'auto'
}
})
const emit = defineEmits(['update:modelValue'])
const appStore = useAppStore()
const { config } = storeToRefs(appStore)
const primaryColor = computed(() => config.value.primaryColor)
const themeModes = [
{
value: 'light',
label: '浅色',
icon: Sunny
},
{
value: 'dark',
label: '深色',
icon: Moon
},
{
value: 'auto',
label: '跟随系统',
icon: Monitor
}
]
const handleModeChange = (mode) => {
emit('update:modelValue', mode)
}
</script>

View File

@@ -5,208 +5,175 @@
direction="rtl"
:size="width"
:show-close="false"
class="theme-config-drawer"
>
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg">系统配置</span>
<el-button type="primary" @click="saveConfig">保存配置</el-button>
<div class="flex items-center justify-between w-full px-6 py-4 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white font-inter">系统配置</h2>
<el-button
type="primary"
size="small"
class="reset-btn"
:style="{ backgroundColor: config.primaryColor, borderColor: config.primaryColor }"
@click="resetConfig"
>
重置配置
</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"
<div class="bg-white dark:bg-gray-900">
<div class="px-8 pt-4 pb-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex justify-center">
<div class="inline-flex bg-gray-100 dark:bg-gray-800 rounded-xl p-1.5 border border-gray-200 dark:border-gray-700 shadow-sm">
<div
v-for="tab in tabs"
:key="tab.key"
class="px-6 py-3 text-base text-center cursor-pointer font-medium rounded-lg transition-all duration-150 ease-in-out min-w-[80px]"
:class="[
activeTab === tab.key
? 'text-white shadow-md transform -translate-y-0.5'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
]"
:style="activeTab === tab.key ? { backgroundColor: config.primaryColor } : {}"
@click="activeTab = tab.key"
>
<el-option value="fade" label="淡入淡出" />
<el-option value="slide" label="滑动" />
<el-option value="zoom" label="缩放" />
<el-option value="none" label="无动画" />
</el-select>
{{ tab.label }}
</div>
</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 class="pb-8 h-full overflow-y-auto">
<div class="transition-all duration-300 ease-in-out">
<AppearanceSettings v-if="activeTab === 'appearance'" />
<LayoutSettings v-else-if="activeTab === 'layout'" />
<GeneralSettings v-else-if="activeTab === 'general'" />
</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 { ref, computed, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useAppStore } from '@/pinia'
import { setSelfSetting } from '@/api/user'
import Title from './title.vue'
const appStore = useAppStore()
const { config, device } = storeToRefs(appStore)
import AppearanceSettings from './modules/appearance/index.vue'
import LayoutSettings from './modules/layout/index.vue'
import GeneralSettings from './modules/general/index.vue'
defineOptions({
name: 'GvaSetting'
})
const appStore = useAppStore()
const { config, device } = storeToRefs(appStore)
const activeTab = ref('appearance')
const tabs = [
{ key: 'appearance', label: '外观' },
{ key: 'layout', label: '布局' },
{ key: 'general', label: '通用' }
]
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('')
const resetConfig = () => {
appStore.resetConfig()
}
watch(config, async () => {
await saveConfig();
}, { deep: true });
</script>
<style lang="scss" scoped>
::v-deep(.el-drawer__header) {
@apply border-gray-400 dark:border-gray-600;
.theme-config-drawer {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
::v-deep(.el-drawer) {
background: white;
}
::v-deep(.el-drawer__header) {
padding: 0;
border: 0;
}
::v-deep(.el-drawer__body) {
padding: 0;
}
}
.dark .theme-config-drawer {
::v-deep(.el-drawer) {
background: #111827;
}
}
.font-inter {
font-family: 'Inter', sans-serif;
}
.reset-btn {
border-radius: 0.5rem;
font-weight: 500;
transition: all 150ms ease-in-out;
&:hover {
transform: translateY(-2px);
filter: brightness(0.9);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
/* Custom scrollbar for webkit browsers */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f3f4f6;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
&:hover {
background: #9ca3af;
}
}
.dark ::-webkit-scrollbar-track {
background: #1f2937;
}
.dark ::-webkit-scrollbar-thumb {
background: #4b5563;
&:hover {
background: #6b7280;
}
}
</style>

View File

@@ -0,0 +1,114 @@
<template>
<div class="font-inter">
<!-- Theme Mode Section -->
<div class="mb-10">
<div class="flex items-center justify-center mb-6">
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
<span class="px-6 text-lg font-semibold text-gray-700 dark:text-gray-300">主题模式</span>
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
</div>
<div class="section-content">
<ThemeModeSelector
v-model="config.darkMode"
@update:modelValue="appStore.toggleDarkMode"
/>
</div>
</div>
<!-- Theme Color Section -->
<div class="mb-10">
<div class="flex items-center justify-center mb-6">
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
<span class="px-6 text-lg font-semibold text-gray-700 dark:text-gray-300">主题颜色</span>
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
</div>
<div class="section-content">
<ThemeColorPicker
v-model="config.primaryColor"
@update:modelValue="appStore.togglePrimaryColor"
/>
</div>
</div>
<!-- Visual Accessibility Section -->
<div class="mb-10">
<div class="flex items-center justify-center mb-6">
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
<span class="px-6 text-lg font-semibold text-gray-700 dark:text-gray-300">视觉辅助</span>
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
</div>
<div class="section-content">
<div class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 shadow-sm">
<SettingItem label="灰色模式">
<template #suffix>
<span class="text-xs text-gray-400 dark:text-gray-500 ml-2">降低色彩饱和度</span>
</template>
<el-switch
v-model="config.grey"
@change="appStore.toggleGrey"
/>
</SettingItem>
<SettingItem label="色弱模式">
<template #suffix>
<span class="text-xs text-gray-400 dark:text-gray-500 ml-2">优化色彩对比度</span>
</template>
<el-switch
v-model="config.weakness"
@change="appStore.toggleWeakness"
/>
</SettingItem>
<SettingItem label="显示水印">
<template #suffix>
<span class="text-xs text-gray-400 dark:text-gray-500 ml-2">在页面显示水印标识</span>
</template>
<el-switch
v-model="config.show_watermark"
@change="appStore.toggleConfigWatermark"
/>
</SettingItem>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { storeToRefs } from 'pinia'
import { useAppStore } from '@/pinia'
import ThemeModeSelector from '../../components/themeModeSelector.vue'
import ThemeColorPicker from '../../components/themeColorPicker.vue'
import SettingItem from '../../components/settingItem.vue'
defineOptions({
name: 'AppearanceSettings'
})
const appStore = useAppStore()
const { config } = storeToRefs(appStore)
</script>
<style scoped>
.font-inter {
font-family: 'Inter', sans-serif;
}
.section-content {
animation: fadeInUp 0.3s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,288 @@
<template>
<div class="font-inter">
<div class="mb-10">
<div class="flex items-center justify-center mb-6">
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
<span class="px-6 text-lg font-semibold text-gray-700 dark:text-gray-300">系统信息</span>
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
</div>
<div class="section-content">
<div class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 shadow-sm">
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="flex justify-between items-center py-3 border-b border-gray-200 dark:border-gray-600">
<span class="text-gray-600 dark:text-gray-400 font-medium">版本</span>
<span class="font-mono text-gray-900 dark:text-white font-semibold">v2.7.4</span>
</div>
<div class="flex justify-between items-center py-3 border-b border-gray-200 dark:border-gray-600">
<span class="text-gray-600 dark:text-gray-400 font-medium">前端框架</span>
<span class="font-mono text-gray-900 dark:text-white font-semibold">Vue 3</span>
</div>
<div class="flex justify-between items-center py-3 border-b border-gray-200 dark:border-gray-600">
<span class="text-gray-600 dark:text-gray-400 font-medium">UI 组件库</span>
<span class="font-mono text-gray-900 dark:text-white font-semibold">Element Plus</span>
</div>
<div class="flex justify-between items-center py-3 border-b border-gray-200 dark:border-gray-600">
<span class="text-gray-600 dark:text-gray-400 font-medium">构建工具</span>
<span class="font-mono text-gray-900 dark:text-white font-semibold">Vite</span>
</div>
<div class="flex justify-between items-center py-3">
<span class="text-gray-600 dark:text-gray-400 font-medium">浏览器</span>
<span class="font-mono text-gray-900 dark:text-white font-semibold">{{ browserInfo }}</span>
</div>
<div class="flex justify-between items-center py-3">
<span class="text-gray-600 dark:text-gray-400 font-medium">屏幕分辨率</span>
<span class="font-mono text-gray-900 dark:text-white font-semibold">{{ screenResolution }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="mb-10">
<div class="flex items-center justify-center mb-6">
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
<span class="px-6 text-lg font-semibold text-gray-700 dark:text-gray-300">配置管理</span>
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
</div>
<div class="section-content">
<div class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 shadow-sm">
<div class="space-y-5">
<div class="bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg p-5 flex items-center justify-between hover:shadow-md transition-all duration-150 ease-in-out hover:-translate-y-0.5">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl flex items-center justify-center text-red-600 dark:text-red-400 text-xl">
🔄
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">重置配置</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">将所有设置恢复为默认值</p>
</div>
</div>
<el-button
type="danger"
size="small"
class="rounded-lg font-medium transition-all duration-150 ease-in-out hover:-translate-y-0.5"
@click="handleResetConfig"
>
重置配置
</el-button>
</div>
<div class="bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg p-5 flex items-center justify-between hover:shadow-md transition-all duration-150 ease-in-out hover:-translate-y-0.5">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl flex items-center justify-center text-blue-600 dark:text-blue-400 text-xl">
📤
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">导出配置</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">导出当前配置为 JSON 文件</p>
</div>
</div>
<el-button
type="primary"
size="small"
class="rounded-lg font-medium transition-all duration-150 ease-in-out hover:-translate-y-0.5"
:style="{ backgroundColor: config.primaryColor, borderColor: config.primaryColor }"
@click="handleExportConfig"
>
导出配置
</el-button>
</div>
<div class="bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg p-5 flex items-center justify-between hover:shadow-md transition-all duration-150 ease-in-out hover:-translate-y-0.5">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl flex items-center justify-center text-green-600 dark:text-green-400 text-xl">
📥
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">导入配置</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1"> JSON 文件导入配置</p>
</div>
</div>
<el-upload
ref="uploadRef"
:auto-upload="false"
:show-file-list="false"
accept=".json"
@change="handleImportConfig"
>
<el-button
type="success"
size="small"
class="rounded-lg font-medium transition-all duration-150 ease-in-out hover:-translate-y-0.5"
>
导入配置
</el-button>
</el-upload>
</div>
</div>
</div>
</div>
</div>
<div class="mb-10">
<div class="flex items-center justify-center mb-6">
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
<span class="px-6 text-lg font-semibold text-gray-700 dark:text-gray-300">关于项目</span>
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
</div>
<div class="section-content">
<div class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 shadow-sm">
<div class="flex items-start gap-5">
<div class="w-16 h-16 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm">
<img
src="/logo.png"
alt="Gin-Vue-Admin Logo"
class="w-10 h-10 object-contain"
@error="handleLogoError"
/>
</div>
<div class="flex-1">
<h4 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Gin-Vue-Admin</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-5 leading-relaxed">
基于 Vue3 + Gin 的全栈开发基础平台提供完整的后台管理解决方案
</p>
<div class="flex items-center gap-3 text-sm">
<a
href="https://github.com/flipped-aurora/gin-vue-admin"
target="_blank"
class="font-medium transition-colors duration-150 hover:underline"
:style="{ color: config.primaryColor }"
>
GitHub 仓库
</a>
<span class="text-gray-400 dark:text-gray-500">·</span>
<a
href="https://www.gin-vue-admin.com/"
target="_blank"
class="font-medium transition-colors duration-150 hover:underline"
:style="{ color: config.primaryColor }"
>
官方文档
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { storeToRefs } from 'pinia'
import { useAppStore } from '@/pinia'
defineOptions({
name: 'GeneralSettings'
})
const appStore = useAppStore()
const { config } = storeToRefs(appStore)
const uploadRef = ref()
const browserInfo = ref('')
const screenResolution = ref('')
const logoUrl = ref('')
onMounted(() => {
const userAgent = navigator.userAgent
if (userAgent.includes('Chrome')) {
browserInfo.value = 'Chrome'
} else if (userAgent.includes('Firefox')) {
browserInfo.value = 'Firefox'
} else if (userAgent.includes('Safari')) {
browserInfo.value = 'Safari'
} else if (userAgent.includes('Edge')) {
browserInfo.value = 'Edge'
} else {
browserInfo.value = 'Unknown'
}
screenResolution.value = `${screen.width}×${screen.height}`
})
const handleLogoError = () => {
logoUrl.value = ''
}
const handleResetConfig = async () => {
try {
await ElMessageBox.confirm(
'确定要重置所有配置吗?此操作不可撤销。',
'重置配置',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
appStore.resetConfig()
ElMessage.success('配置已重置')
} catch {
// User cancelled
}
}
const handleExportConfig = () => {
const configData = JSON.stringify(config.value, null, 2)
const blob = new Blob([configData], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `gin-vue-admin-config-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('配置已导出')
}
const handleImportConfig = (file) => {
const reader = new FileReader()
reader.onload = (e) => {
try {
const importedConfig = JSON.parse(e.target.result)
Object.keys(importedConfig).forEach(key => {
if (key in config.value) {
config.value[key] = importedConfig[key]
}
})
ElMessage.success('配置已导入')
} catch (error) {
ElMessage.error('配置文件格式错误')
}
}
reader.readAsText(file.raw)
}
</script>
<style scoped>
.font-inter {
font-family: 'Inter', sans-serif;
}
.section-content {
animation: fadeInUp 0.3s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,164 @@
<template>
<div class="font-inter">
<div class="mb-10">
<div class="flex items-center justify-center mb-6">
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
<span class="px-6 text-lg font-semibold text-gray-700 dark:text-gray-300">布局模式</span>
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
</div>
<div class="section-content">
<LayoutModeCard
v-model="config.side_mode"
@update:modelValue="appStore.toggleSideMode"
/>
</div>
</div>
<div class="mb-10">
<div class="flex items-center justify-center mb-6">
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
<span class="px-6 text-lg font-semibold text-gray-700 dark:text-gray-300">界面配置</span>
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
</div>
<div class="section-content">
<div class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 shadow-sm">
<SettingItem label="显示标签页">
<template #suffix>
<span class="text-xs text-gray-400 dark:text-gray-500 ml-2">页面标签导航</span>
</template>
<el-switch
v-model="config.showTabs"
@change="appStore.toggleTabs"
/>
</SettingItem>
<SettingItem label="页面切换动画">
<template #suffix>
<span class="text-xs text-gray-400 dark:text-gray-500 ml-2">页面过渡效果</span>
</template>
<el-select
v-model="config.transition_type"
@change="appStore.toggleTransition"
class="w-32"
size="small"
>
<el-option value="fade" label="淡入淡出" />
<el-option value="slide" label="滑动" />
<el-option value="zoom" label="缩放" />
<el-option value="none" label="无动画" />
</el-select>
</SettingItem>
</div>
</div>
</div>
<div class="mb-10">
<div class="flex items-center justify-center mb-6">
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
<span class="px-6 text-lg font-semibold text-gray-700 dark:text-gray-300">尺寸配置</span>
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
</div>
<div class="section-content">
<div class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 shadow-sm">
<div class="space-y-6">
<div class="bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg p-5 hover:shadow-md transition-all duration-150 ease-in-out hover:-translate-y-0.5">
<div class="flex items-center justify-between">
<div>
<h4 class="text-sm font-medium text-gray-900 dark:text-white">侧边栏展开宽度</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">侧边栏完全展开时的宽度</p>
</div>
<div class="flex items-center gap-2">
<el-input-number
v-model="config.layout_side_width"
:min="150"
:max="400"
:step="10"
size="small"
class="w-24"
/>
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">px</span>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg p-5 hover:shadow-md transition-all duration-150 ease-in-out hover:-translate-y-0.5">
<div class="flex items-center justify-between">
<div>
<h4 class="text-sm font-medium text-gray-900 dark:text-white">侧边栏收缩宽度</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">侧边栏收缩时的最小宽度</p>
</div>
<div class="flex items-center gap-2">
<el-input-number
v-model="config.layout_side_collapsed_width"
:min="60"
:max="100"
size="small"
class="w-24"
/>
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">px</span>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg p-5 hover:shadow-md transition-all duration-150 ease-in-out hover:-translate-y-0.5">
<div class="flex items-center justify-between">
<div>
<h4 class="text-sm font-medium text-gray-900 dark:text-white">菜单项高度</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">侧边栏菜单项的行高</p>
</div>
<div class="flex items-center gap-2">
<el-input-number
v-model="config.layout_side_item_height"
:min="30"
:max="50"
size="small"
class="w-24"
/>
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">px</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { storeToRefs } from 'pinia'
import { useAppStore } from '@/pinia'
import LayoutModeCard from '../../components/layoutModeCard.vue'
import SettingItem from '../../components/settingItem.vue'
defineOptions({
name: 'LayoutSettings'
})
const appStore = useAppStore()
const { config } = storeToRefs(appStore)
</script>
<style scoped>
.font-inter {
font-family: 'Inter', sans-serif;
}
.section-content {
animation: fadeInUp 0.3s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -8,7 +8,7 @@
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"
class="bg-white text-slate-700 dark:text-slate-500 dark:bg-slate-900 pt-1"
@contextmenu.prevent="openContextMenu($event)"
@tab-click="changeTab"
@tab-remove="removeTab"