前端代码
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<button
|
||||
v-if="settings.header.collapseButton.visible"
|
||||
type="button"
|
||||
:aria-label="collapsed ? '展开侧边栏' : '收起侧边栏'"
|
||||
:aria-expanded="!collapsed"
|
||||
:class="cn(
|
||||
'flex h-10 w-full flex-shrink-0 cursor-pointer appearance-none items-center justify-center border-0 border-t border-solid border-border bg-transparent text-base-text transition-colors hover:bg-muted',
|
||||
FOCUS_RING
|
||||
)"
|
||||
@click="appStore.toggleSideCollapse()"
|
||||
>
|
||||
<svg-icon
|
||||
:icon="collapsed ? 'lucide:chevrons-right' : 'lucide:chevrons-left'"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppStore, useThemeStore } from '@/pinia'
|
||||
import { cn, FOCUS_RING } from '@/core/componentLibrary/utils'
|
||||
|
||||
defineOptions({ name: 'AsideCollapseBar' })
|
||||
|
||||
const appStore = useAppStore()
|
||||
const themeStore = useThemeStore()
|
||||
const { sideCollapse: collapsed } = storeToRefs(appStore)
|
||||
const { settings } = storeToRefs(themeStore)
|
||||
</script>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="open"
|
||||
direction="ltr"
|
||||
:size="sideWidth"
|
||||
:with-header="false"
|
||||
class="kra-mobile-menu-drawer"
|
||||
>
|
||||
<div class="flex h-full flex-col" :class="[surfaceClass, siderDarkClass]">
|
||||
<!-- Logo -->
|
||||
<div
|
||||
class="flex h-16 flex-shrink-0 cursor-pointer items-center gap-2 px-4"
|
||||
@click="goHome"
|
||||
>
|
||||
<Logo :dark="sidebarDark" />
|
||||
<span class="whitespace-nowrap text-xl font-bold">
|
||||
{{ $KRA.appName }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 菜单 -->
|
||||
<el-scrollbar class="flex-1">
|
||||
<g-menu
|
||||
:items="menus"
|
||||
:theme="menuTheme"
|
||||
:active="activeKey"
|
||||
v-model:open-keys="openKeys"
|
||||
:item-height="settings.layout.sideItemHeight"
|
||||
@select="onSelect"
|
||||
/>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Logo from '@/components/logo/index.vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouterStore } from '@/pinia/modules/router'
|
||||
import { useAppStore, useThemeStore } from '@/pinia'
|
||||
import { useMenuActive, useMenuNavigation } from './composables/useMenu'
|
||||
import { useSidebarTheme } from './composables/useSidebarTheme'
|
||||
|
||||
defineOptions({ name: 'MobileMenuDrawer' })
|
||||
|
||||
const appStore = useAppStore()
|
||||
const themeStore = useThemeStore()
|
||||
const { mobileMenuOpen: open } = storeToRefs(appStore)
|
||||
const { settings } = storeToRefs(themeStore)
|
||||
const router = useRouter()
|
||||
const routerStore = useRouterStore()
|
||||
|
||||
const menus = computed(() => routerStore.rootMenus)
|
||||
const { menuTheme, surfaceClass, siderDarkClass, sidebarDark } = useSidebarTheme()
|
||||
const { activeKey, openKeys } = useMenuActive(menus, menuTheme)
|
||||
const { navigate } = useMenuNavigation()
|
||||
|
||||
const sideWidth = computed(() => settings.value.layout.sideWidth + 'px')
|
||||
|
||||
const onSelect = (key) => {
|
||||
navigate(key)
|
||||
appStore.toggleMobileMenu(false)
|
||||
}
|
||||
const goHome = () => {
|
||||
router.push({ path: '/' })
|
||||
appStore.toggleMobileMenu(false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 非 scoped:抽屉 body 被 teleport 到 body 外层,scoped :deep 触达不到,用类名限定的全局样式 -->
|
||||
<style>
|
||||
.kra-mobile-menu-drawer .el-drawer__body {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<!-- head:顶栏一级横向菜单 -->
|
||||
<div
|
||||
v-if="mode === 'head'"
|
||||
class="mx-2 flex h-[calc(100%-4px)] w-[calc(100vw-600px)] items-center text-base-text"
|
||||
>
|
||||
<g-menu
|
||||
orientation="horizontal"
|
||||
:items="routerStore.topMenu"
|
||||
:active="topActiveKey"
|
||||
class="w-full"
|
||||
@select="onTopSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- normal:左侧二级纵向菜单(当前一级无二级时整块不渲染) -->
|
||||
<div
|
||||
v-if="mode === 'normal' && showLeftAside"
|
||||
class="relative flex h-full flex-col shadow-sider transition-[width] duration-300 ease-in-out"
|
||||
:class="[surfaceClass, siderDarkClass]"
|
||||
:style="{ width: layoutSideWidth + 'px' }"
|
||||
>
|
||||
<el-scrollbar class="flex-1">
|
||||
<g-menu
|
||||
:items="leftMenus"
|
||||
:theme="menuTheme"
|
||||
:collapsed="sideCollapse"
|
||||
:active="activeKey"
|
||||
v-model:open-keys="openKeys"
|
||||
:item-height="settings.layout.sideItemHeight"
|
||||
@select="navigate"
|
||||
/>
|
||||
</el-scrollbar>
|
||||
<collapse-bar />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouterStore } from '@/pinia/modules/router'
|
||||
import { useAppStore, useThemeStore } from '@/pinia'
|
||||
import { resolveActiveTop, visibleItems } from '@/core/componentLibrary/menu/shared'
|
||||
import { useMenuActive, useMenuNavigation } from './composables/useMenu'
|
||||
import { useSidebarTheme } from './composables/useSidebarTheme'
|
||||
import { useSideWidth } from '@/hooks/useSideWidth'
|
||||
import CollapseBar from './CollapseBar.vue'
|
||||
|
||||
defineOptions({ name: 'AsideCombinationMode' })
|
||||
|
||||
defineProps({
|
||||
mode: { type: String, default: 'normal' }
|
||||
})
|
||||
|
||||
const { sideCollapse } = storeToRefs(useAppStore())
|
||||
const { settings } = storeToRefs(useThemeStore())
|
||||
const route = useRoute()
|
||||
const routerStore = useRouterStore()
|
||||
|
||||
// 当前路由所属的一级菜单:左栏内容与顶栏高亮都从它派生,
|
||||
// 直接读 asyncRouters(rootMenus),刷新 / 直达 / 跨一级切换都正确。
|
||||
const activeName = computed(() => route.meta.activeName || route.name)
|
||||
const activeTop = computed(() =>
|
||||
resolveActiveTop(routerStore.rootMenus, activeName.value)
|
||||
)
|
||||
const topActiveKey = computed(() => activeTop.value?.name || '')
|
||||
const leftMenus = computed(() => visibleItems(activeTop.value?.children))
|
||||
const showLeftAside = computed(() => leftMenus.value.length > 0)
|
||||
|
||||
const { menuTheme, surfaceClass, siderDarkClass } = useSidebarTheme()
|
||||
const { activeKey, openKeys } = useMenuActive(leftMenus, menuTheme)
|
||||
const { navigate } = useMenuNavigation()
|
||||
const { sideWidth: layoutSideWidth } = useSideWidth()
|
||||
|
||||
const isHttp = (s) =>
|
||||
!!s && (s.indexOf('http://') > -1 || s.indexOf('https://') > -1)
|
||||
|
||||
// 顶栏一级点击:外链新开;否则跳到该一级下第一个可见子项(无子项则跳自身)
|
||||
const onTopSelect = (index) => {
|
||||
if (isHttp(index)) {
|
||||
window.open(index, '_blank')
|
||||
return
|
||||
}
|
||||
const top = routerStore.rootMenus.find((i) => i.name === index)
|
||||
const first = visibleItems(top?.children).find((c) => !isHttp(c.path))
|
||||
navigate(first ? first.name : index)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ref, watch, watchEffect } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRouterStore } from '@/pinia/modules/router'
|
||||
import { findMenuPath, visibleItems } from '@/core/componentLibrary/menu/shared'
|
||||
|
||||
/**
|
||||
* 菜单导航:parameters 预置、外链新开、同名短路。
|
||||
* @returns {{ navigate: (key: string) => void }}
|
||||
*/
|
||||
export function useMenuNavigation() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const routerStore = useRouterStore()
|
||||
|
||||
const navigate = (key) => {
|
||||
if (!key || key === route.name) return
|
||||
if (key.indexOf('http://') > -1 || key.indexOf('https://') > -1) {
|
||||
window.open(key, '_blank')
|
||||
return
|
||||
}
|
||||
const query = {}
|
||||
const params = {}
|
||||
routerStore.routeMap[key]?.parameters?.forEach((item) => {
|
||||
if (item.type === 'query') query[item.key] = item.value
|
||||
else params[item.key] = item.value
|
||||
})
|
||||
router.push({ name: key, query, params })
|
||||
}
|
||||
|
||||
return { navigate }
|
||||
}
|
||||
|
||||
/**
|
||||
* 从路由推导激活键 + 展开键。
|
||||
* - activeKey:iframe 用 query.url,否则 meta.activeName || route.name。
|
||||
* - openKeys:非 group = 激活项的祖先路径(unique-opened,随路由更新);
|
||||
* group = 默认展开所有含可见子级的一级分组,仅在切到 group / 菜单变化时重置,
|
||||
* 路由跳转不重置,从而保留用户手动收起的分组。
|
||||
* @param {import('vue').Ref<Array>|(() => Array)} menus 菜单树(asyncRouters[0].children)
|
||||
* @param {import('vue').Ref<string>} [theme] 当前菜单风格
|
||||
*/
|
||||
export function useMenuActive(menus, theme) {
|
||||
const route = useRoute()
|
||||
const activeKey = ref('')
|
||||
const openKeys = ref([])
|
||||
const readMenus = () => (typeof menus === 'function' ? menus() : menus.value) || []
|
||||
|
||||
watchEffect(() => {
|
||||
if (route.name === 'kraLayoutIframe') {
|
||||
activeKey.value = decodeURIComponent(route.query.url)
|
||||
return
|
||||
}
|
||||
activeKey.value = route.meta.activeName || route.name
|
||||
})
|
||||
|
||||
// 非 group:openKeys = 激活项祖先路径,随路由 / 菜单变化更新
|
||||
watch(
|
||||
[activeKey, () => theme?.value, () => readMenus()],
|
||||
() => {
|
||||
if (theme?.value === 'group') return
|
||||
openKeys.value = findMenuPath(readMenus(), activeKey.value)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// group:默认展开所有含可见子级的一级分组;不随 activeKey 重置,保留手动收起态
|
||||
watch(
|
||||
[() => theme?.value, () => readMenus()],
|
||||
() => {
|
||||
if (theme?.value !== 'group') return
|
||||
openKeys.value = visibleItems(readMenus())
|
||||
.filter((m) => visibleItems(m.children).length > 0)
|
||||
.map((m) => m.name)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
return { activeKey, openKeys }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useThemeStore } from '@/pinia'
|
||||
import { SIDEBAR_SURFACE } from '@/core/componentLibrary/menu/variants'
|
||||
|
||||
/**
|
||||
* 侧栏深浅的单一事实源:供 5 个布局 mode 与移动抽屉共用。
|
||||
* - 解析规则:主题模式优先,浅色(含跟随系统解析为浅色)时才听「深色侧边栏」开关。
|
||||
* - 上色机制:浅色 + darkSider → 给侧栏容器加 .kra-sider-dark(作用域 token 翻转),
|
||||
* 全局暗色时 html.dark 已生效,无需再叠加;两条路径共用同一套 dark token,观感一致。
|
||||
* @returns {{
|
||||
* sidebarDark: import('vue').ComputedRef<boolean>,
|
||||
* siderDarkClass: import('vue').ComputedRef<string>,
|
||||
* menuTheme: import('vue').ComputedRef<string>,
|
||||
* surfaceClass: string
|
||||
* }}
|
||||
*/
|
||||
export function useSidebarTheme() {
|
||||
const themeStore = useThemeStore()
|
||||
const { settings, darkMode } = storeToRefs(themeStore)
|
||||
|
||||
// 侧栏是否呈深色(供 logo 等消费):全局暗色优先,其次听开关
|
||||
const sidebarDark = computed(
|
||||
() => darkMode.value || settings.value.menu.darkSider
|
||||
)
|
||||
// 仅「浅色主题 + 开启深色侧边栏」时叠作用域暗色类;全局暗色时留空(html.dark 已覆盖)
|
||||
const siderDarkClass = computed(() =>
|
||||
!darkMode.value && settings.value.menu.darkSider ? 'kra-sider-dark' : ''
|
||||
)
|
||||
// 传给 <g-menu> 的风格始终是用户所选(design/light/group),深浅交给 token
|
||||
const menuTheme = computed(() => settings.value.menu.theme)
|
||||
|
||||
return { sidebarDark, siderDarkClass, menuTheme, surfaceClass: SIDEBAR_SURFACE }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="mx-2 flex h-full w-[calc(100vw-600px)] items-center text-base-text">
|
||||
<g-menu
|
||||
orientation="horizontal"
|
||||
:items="menus"
|
||||
:active="activeKey"
|
||||
class="w-full"
|
||||
@select="navigate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouterStore } from '@/pinia/modules/router'
|
||||
import { useMenuActive, useMenuNavigation } from './composables/useMenu'
|
||||
|
||||
defineOptions({ name: 'AsideHeadMode' })
|
||||
|
||||
const routerStore = useRouterStore()
|
||||
const menus = computed(() => routerStore.rootMenus)
|
||||
const { activeKey } = useMenuActive(menus)
|
||||
const { navigate } = useMenuNavigation()
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div>
|
||||
<normal-mode v-if="effectiveMode === 'normal'" />
|
||||
<head-mode v-if="effectiveMode === 'head'" />
|
||||
<combination-mode
|
||||
v-if="effectiveMode === 'combination'"
|
||||
:mode="mode"
|
||||
/>
|
||||
<sidebar-mode v-if="effectiveMode === 'sidebar'" />
|
||||
<vertical-mode v-if="effectiveMode === 'vertical'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NormalMode from './normalMode.vue'
|
||||
import HeadMode from './headMode.vue'
|
||||
import CombinationMode from './combinationMode.vue'
|
||||
import SidebarMode from './sidebarMode.vue'
|
||||
import VerticalMode from './verticalMode.vue'
|
||||
import { useLayoutMode } from '@/hooks/useLayoutMode'
|
||||
|
||||
defineOptions({
|
||||
name: 'KraAside'
|
||||
})
|
||||
|
||||
defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'normal'
|
||||
}
|
||||
})
|
||||
|
||||
const { effectiveMode } = useLayoutMode()
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative z-10 flex h-full flex-col shadow-sider transition-[width] duration-300 ease-in-out"
|
||||
:class="[surfaceClass, siderDarkClass]"
|
||||
:style="{ width: layoutSideWidth + 'px' }"
|
||||
>
|
||||
<el-scrollbar class="flex-1 py-2">
|
||||
<g-menu
|
||||
:items="menus"
|
||||
:theme="menuTheme"
|
||||
:collapsed="sideCollapse"
|
||||
:active="activeKey"
|
||||
v-model:open-keys="openKeys"
|
||||
:item-height="settings.layout.sideItemHeight"
|
||||
@select="navigate"
|
||||
/>
|
||||
</el-scrollbar>
|
||||
<collapse-bar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouterStore } from '@/pinia/modules/router'
|
||||
import { useAppStore, useThemeStore } from '@/pinia'
|
||||
import { useMenuActive, useMenuNavigation } from './composables/useMenu'
|
||||
import { useSidebarTheme } from './composables/useSidebarTheme'
|
||||
import { useSideWidth } from '@/hooks/useSideWidth'
|
||||
import CollapseBar from './CollapseBar.vue'
|
||||
|
||||
defineOptions({ name: 'AsideNormalMode' })
|
||||
|
||||
const { sideCollapse } = storeToRefs(useAppStore())
|
||||
const { settings } = storeToRefs(useThemeStore())
|
||||
const routerStore = useRouterStore()
|
||||
|
||||
const menus = computed(() => routerStore.rootMenus)
|
||||
const { menuTheme, surfaceClass, siderDarkClass } = useSidebarTheme()
|
||||
const { activeKey, openKeys } = useMenuActive(menus, menuTheme)
|
||||
const { navigate } = useMenuNavigation()
|
||||
const { sideWidth: layoutSideWidth } = useSideWidth()
|
||||
</script>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="flex h-full">
|
||||
<!-- 一级图标常驻栏(与二级之间的分隔按卡片样式:border / shadow) -->
|
||||
<div
|
||||
class="relative h-full"
|
||||
:class="[surfaceClass, siderDarkClass, railSepClass]"
|
||||
:style="{ width: settings.layout.sideCollapsedWidth + 'px' }"
|
||||
>
|
||||
<el-scrollbar>
|
||||
<nav class="flex flex-col gap-1 p-2">
|
||||
<button
|
||||
v-for="item in topLevelMenus"
|
||||
:key="item.name"
|
||||
type="button"
|
||||
:class="cn(
|
||||
MENU_ICON_BUTTON,
|
||||
FOCUS_RING,
|
||||
topActive === item.name && 'bg-primary text-white hover:bg-primary'
|
||||
)"
|
||||
:style="{ height: settings.layout.sideItemHeight + 'px' }"
|
||||
:title="item.meta.title"
|
||||
@click="selectTopMenuItem(item.name)"
|
||||
>
|
||||
<component :is="item.meta.icon" v-if="item.meta.icon" class="h-5 w-5" />
|
||||
<span v-else class="text-[13px]">{{ item.meta.title[0] }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
|
||||
<!-- 二级并列纵向菜单:仅当当前一级菜单存在二级时才出现 -->
|
||||
<div
|
||||
v-if="secondLevelMenus.length"
|
||||
class="relative flex h-full flex-col shadow-sider transition-[width] duration-300 ease-in-out"
|
||||
:class="[surfaceClass, siderDarkClass]"
|
||||
:style="{ width: layoutSideWidth + 'px' }"
|
||||
>
|
||||
<el-scrollbar class="flex-1">
|
||||
<g-menu
|
||||
:items="secondLevelMenus"
|
||||
:theme="menuTheme"
|
||||
:collapsed="sideCollapse"
|
||||
:active="activeKey"
|
||||
v-model:open-keys="openKeys"
|
||||
:item-height="settings.layout.sideItemHeight"
|
||||
@select="navigate"
|
||||
/>
|
||||
</el-scrollbar>
|
||||
<collapse-bar />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouterStore } from '@/pinia/modules/router'
|
||||
import { useAppStore, useThemeStore } from '@/pinia'
|
||||
import { resolveActiveTop, visibleItems } from '@/core/componentLibrary/menu/shared'
|
||||
import { useMenuActive, useMenuNavigation } from './composables/useMenu'
|
||||
import { useSidebarTheme } from './composables/useSidebarTheme'
|
||||
import { useSideWidth } from '@/hooks/useSideWidth'
|
||||
import { cn, FOCUS_RING } from '@/core/componentLibrary/utils'
|
||||
import { MENU_ICON_BUTTON } from '@/core/componentLibrary/menu/variants'
|
||||
import CollapseBar from './CollapseBar.vue'
|
||||
|
||||
defineOptions({ name: 'SidebarMode' })
|
||||
|
||||
const { sideCollapse } = storeToRefs(useAppStore())
|
||||
const { settings } = storeToRefs(useThemeStore())
|
||||
const route = useRoute()
|
||||
const routerStore = useRouterStore()
|
||||
|
||||
const topActive = ref('')
|
||||
const secondLevelMenus = ref([])
|
||||
|
||||
const topLevelMenus = computed(() => visibleItems(routerStore.rootMenus))
|
||||
const { menuTheme, surfaceClass, siderDarkClass } = useSidebarTheme()
|
||||
const { activeKey, openKeys } = useMenuActive(secondLevelMenus, menuTheme)
|
||||
const { navigate } = useMenuNavigation()
|
||||
const { sideWidth: layoutSideWidth } = useSideWidth()
|
||||
|
||||
// 一级与二级之间的分隔:跟随系统「卡片样式」配置
|
||||
const railSepClass = computed(() =>
|
||||
settings.value.card.mode === 'border'
|
||||
? 'border-0 border-r border-solid border-border'
|
||||
: 'shadow-sider'
|
||||
)
|
||||
|
||||
// 选择一级:有二级→显示二级并跳首个可见子项;无二级→隐藏二级面板并直接跳
|
||||
const selectTopMenuItem = (name) => {
|
||||
topActive.value = name
|
||||
const top = routerStore.rootMenus.find((i) => i.name === name)
|
||||
const children = visibleItems(top?.children)
|
||||
if (children.length) {
|
||||
secondLevelMenus.value = top.children
|
||||
navigate(children[0].name)
|
||||
} else {
|
||||
secondLevelMenus.value = []
|
||||
navigate(name)
|
||||
}
|
||||
}
|
||||
|
||||
// 路由变化时定位当前所属一级;无二级时清空面板(不回落显示其它一级的子菜单)
|
||||
watchEffect(() => {
|
||||
const top = resolveActiveTop(
|
||||
routerStore.rootMenus,
|
||||
route.meta.activeName || route.name
|
||||
)
|
||||
if (top) {
|
||||
topActive.value = top.name
|
||||
secondLevelMenus.value = visibleItems(top.children).length ? top.children : []
|
||||
} else {
|
||||
topActive.value = route.name
|
||||
secondLevelMenus.value = []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed left-0 top-0 z-30 flex h-screen flex-col shadow-sider transition-[width] duration-300 ease-in-out"
|
||||
:style="{ width: sideWidth + 'px' }"
|
||||
:class="[surfaceClass, siderDarkClass]"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div
|
||||
class="flex h-16 flex-shrink-0 cursor-pointer items-center justify-center gap-2 overflow-hidden px-2"
|
||||
@click="router.push({ path: '/' })"
|
||||
>
|
||||
<Logo :dark="sidebarDark" />
|
||||
<span v-if="!sideCollapse" class="whitespace-nowrap text-xl font-bold">
|
||||
{{ $KRA.appName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 菜单 -->
|
||||
<el-scrollbar class="flex-1">
|
||||
<g-menu
|
||||
:items="menus"
|
||||
:theme="menuTheme"
|
||||
:collapsed="sideCollapse"
|
||||
:active="activeKey"
|
||||
v-model:open-keys="openKeys"
|
||||
:item-height="settings.layout.sideItemHeight"
|
||||
@select="navigate"
|
||||
/>
|
||||
</el-scrollbar>
|
||||
|
||||
<!-- 折叠按钮 -->
|
||||
<collapse-bar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Logo from '@/components/logo/index.vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouterStore } from '@/pinia/modules/router'
|
||||
import { useAppStore, useThemeStore } from '@/pinia'
|
||||
import { useMenuActive, useMenuNavigation } from './composables/useMenu'
|
||||
import { useSidebarTheme } from './composables/useSidebarTheme'
|
||||
import { useSideWidth } from '@/hooks/useSideWidth'
|
||||
import CollapseBar from './CollapseBar.vue'
|
||||
|
||||
defineOptions({ name: 'VerticalMode' })
|
||||
|
||||
const appStore = useAppStore()
|
||||
const themeStore = useThemeStore()
|
||||
const { sideCollapse } = storeToRefs(appStore)
|
||||
const { settings } = storeToRefs(themeStore)
|
||||
const router = useRouter()
|
||||
const routerStore = useRouterStore()
|
||||
|
||||
const menus = computed(() => routerStore.rootMenus)
|
||||
const { menuTheme, surfaceClass, siderDarkClass, sidebarDark } = useSidebarTheme()
|
||||
const { activeKey, openKeys } = useMenuActive(menus, menuTheme)
|
||||
const { navigate } = useMenuNavigation()
|
||||
const { sideWidth } = useSideWidth()
|
||||
</script>
|
||||
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-between fixed top-0 right-0 z-20 h-16 text-base-text items-center px-2"
|
||||
:style="{
|
||||
left: isVertical ? headerSideWidth + 'px' : '0px',
|
||||
background: 'var(--kra-header-bg)',
|
||||
boxShadow: 'var(--kra-header-shadow)'
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center cursor-pointer flex-1">
|
||||
<!-- 移动端(<640):展开抽屉菜单的入口,替代 Logo -->
|
||||
<button
|
||||
v-if="isMobile"
|
||||
type="button"
|
||||
aria-label="打开菜单"
|
||||
:class="cn(
|
||||
'flex h-9 w-9 cursor-pointer appearance-none items-center justify-center rounded-md bg-transparent text-base-text transition-colors hover:bg-muted',
|
||||
FOCUS_RING
|
||||
)"
|
||||
@click="appStore.toggleMobileMenu(true)"
|
||||
>
|
||||
<svg-icon icon="lucide:menu" class="h-5 w-5" />
|
||||
</button>
|
||||
<div
|
||||
v-else-if="!isVertical"
|
||||
class="flex min-w-48 cursor-pointer items-center justify-center"
|
||||
@click="router.push({ path: '/' })"
|
||||
>
|
||||
<Logo />
|
||||
<div
|
||||
class="inline-flex font-bold text-2xl ml-2"
|
||||
:class="
|
||||
(effectiveMode === 'head' || effectiveMode === 'combination') &&
|
||||
'min-w-fit'
|
||||
"
|
||||
>
|
||||
{{ $KRA.appName }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-breadcrumb
|
||||
v-show="!isMobile"
|
||||
v-if="settings.header.breadcrumb.visible && effectiveMode !== 'head' && effectiveMode !== 'combination'"
|
||||
class="ml-4"
|
||||
>
|
||||
<el-breadcrumb-item
|
||||
v-for="item in matched.slice(1, matched.length)"
|
||||
:key="item.path"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1 font-bold leading-[16px]">
|
||||
<el-icon
|
||||
v-if="settings.header.breadcrumb.showIcon && item.meta.icon"
|
||||
class="inline-flex h-4 w-4 items-center justify-center"
|
||||
>
|
||||
<component :is="item.meta.icon" />
|
||||
</el-icon>
|
||||
<span>
|
||||
{{ fmtTitle(item.meta.title, route) }}
|
||||
</span>
|
||||
</span>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
<kra-aside
|
||||
v-if="effectiveMode === 'head' && !isMobile"
|
||||
class="flex-1"
|
||||
/>
|
||||
<kra-aside
|
||||
v-if="effectiveMode === 'combination' && !isMobile"
|
||||
mode="head"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ml-2 flex items-center gap-3">
|
||||
<tools />
|
||||
<!-- 0.5px 极细分隔线,区分工具区与用户区 -->
|
||||
<div
|
||||
class="h-6 w-px bg-border"
|
||||
role="separator"
|
||||
/>
|
||||
<!-- hover 触发:鼠标移上用户胶囊即展开操作菜单;点击胶囊则钉住展开(移出也保持),点击面板外或选择菜单项收起 -->
|
||||
<HoverCardRoot :open="menuOpen" :open-delay="100" :close-delay="120" @update:open="onHoverChange">
|
||||
<HoverCardTrigger as-child>
|
||||
<!-- 用户信息聚合为胶囊组件:圆角跟随全局 --kra-radius + 内边距,hover 时出现浅色反馈 -->
|
||||
<div
|
||||
ref="triggerEl"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded-[var(--kra-radius)] outline-none cursor-pointer transition-colors hover:bg-black/10 dark:hover:bg-white/10"
|
||||
@pointerdown="pinMenu"
|
||||
>
|
||||
<CustomPic />
|
||||
<span
|
||||
v-show="!isMobile"
|
||||
class="text-base font-medium max-w-[8rem] truncate text-base-text"
|
||||
>{{ userStore.userInfo.nickName }}</span>
|
||||
<!-- 展开箭头:svgIcon,随面板展开/收缩沿顺时针翻转(0→180→360…始终增加) -->
|
||||
<svg-icon
|
||||
icon="lucide:chevron-down"
|
||||
class="h-4 w-4 text-muted-foreground transition-transform duration-200"
|
||||
:style="{ transform: `rotate(${arrowDeg}deg)` }"
|
||||
/>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent
|
||||
:side-offset="8"
|
||||
side="bottom"
|
||||
align="end"
|
||||
class="z-[3000] min-w-56 rounded-[10px] border border-border bg-container p-1.5 shadow-sider data-[state=open]:animate-[fade-in_0.1s_ease-out] data-[state=closed]:animate-[fade-out_0.1s_ease-in]"
|
||||
@pointer-down-outside="onPointerDownOutside"
|
||||
>
|
||||
<!-- 用户身份信息(label,非交互) -->
|
||||
<div class="kra-menu-label">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[15px] font-semibold text-base-text">
|
||||
{{ userStore.userInfo.nickName }}
|
||||
</div>
|
||||
<div class="truncate text-[13px] text-muted-foreground">
|
||||
当前角色:{{ userStore.userInfo.authority.authorityName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线:身份信息 / 角色切换 -->
|
||||
<div class="kra-menu-sep" />
|
||||
|
||||
<!-- 角色切换组 -->
|
||||
<template v-if="otherAuthorities.length">
|
||||
<div
|
||||
v-for="item in otherAuthorities"
|
||||
:key="item.authorityId"
|
||||
class="kra-menu-item"
|
||||
@click="handleSelect(() => changeUserAuth(item.authorityId))"
|
||||
>
|
||||
<svg-icon icon="lucide:repeat" class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span>切换为:{{ item.authorityName }}</span>
|
||||
</div>
|
||||
<!-- 分隔线:角色切换 / 账户操作 -->
|
||||
<div class="kra-menu-sep" />
|
||||
</template>
|
||||
|
||||
<!-- 账户操作组 -->
|
||||
<div class="kra-menu-item" @click="handleSelect(toPerson)">
|
||||
<svg-icon icon="lucide:user" class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span>个人信息</span>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线:个人信息 / 登出 -->
|
||||
<div class="kra-menu-sep" />
|
||||
|
||||
<!-- 退出登录(红色语义色) -->
|
||||
<div
|
||||
class="kra-menu-item kra-menu-item-danger"
|
||||
@click="handleSelect(userStore.LoginOut)"
|
||||
>
|
||||
<svg-icon icon="lucide:log-out" class="h-4 w-4 shrink-0" />
|
||||
<span class="whitespace-nowrap">登 出</span>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCardRoot>
|
||||
</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, useThemeStore } from '@/pinia'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { setUserAuthority } from '@/api/user'
|
||||
import { fmtTitle } from '@/utils/fmtRouterTitle'
|
||||
import kraAside from '@/view/layout/aside/index.vue'
|
||||
import Logo from '@/components/logo/index.vue'
|
||||
import { useLayoutMode } from '@/hooks/useLayoutMode'
|
||||
import { useSideWidth } from '@/hooks/useSideWidth'
|
||||
import { cn, FOCUS_RING } from '@/core/componentLibrary/utils'
|
||||
import {
|
||||
HoverCardRoot,
|
||||
HoverCardTrigger,
|
||||
HoverCardPortal,
|
||||
HoverCardContent
|
||||
} from 'reka-ui'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
const themeStore = useThemeStore()
|
||||
const { device } = storeToRefs(appStore)
|
||||
const { settings } = storeToRefs(themeStore)
|
||||
const isMobile = computed(() => {
|
||||
return device.value === 'mobile'
|
||||
})
|
||||
const { effectiveMode } = useLayoutMode()
|
||||
// 通栏侧边布局:header 让出左侧侧栏宽度,并隐藏自身 Logo
|
||||
const isVertical = computed(
|
||||
() => effectiveMode.value === 'vertical' && !isMobile.value
|
||||
)
|
||||
const { sideWidth: headerSideWidth } = useSideWidth()
|
||||
// 除当前角色外的可切换角色
|
||||
const otherAuthorities = computed(() => {
|
||||
const list = userStore.userInfo.authorities || []
|
||||
return list.filter(
|
||||
(i) => i.authorityId !== userStore.userInfo.authorityId
|
||||
)
|
||||
})
|
||||
const toPerson = () => {
|
||||
router.push({ name: 'person' })
|
||||
}
|
||||
const matched = computed(() => route.meta.matched)
|
||||
|
||||
// 用户菜单受控:hover 展开由 reka 驱动(hoverOpen),点击胶囊钉住(pinned)
|
||||
// menuOpen = 悬停中 或 已钉住;两者任一为真即展开
|
||||
const triggerEl = ref(null)
|
||||
const hoverOpen = ref(false)
|
||||
const pinned = ref(false)
|
||||
const menuOpen = computed(() => hoverOpen.value || pinned.value)
|
||||
const onHoverChange = (val) => {
|
||||
hoverOpen.value = val
|
||||
}
|
||||
// 点击胶囊:钉住展开,移出后仍保持(reka 会在 pointerdown 关闭 hover 态,pinned 兜底不收起)
|
||||
const pinMenu = () => {
|
||||
pinned.value = true
|
||||
}
|
||||
// 点击面板外收起;但点击胶囊本身(触发钉住)不算“外部”,避免与 pinMenu 抢占
|
||||
const onPointerDownOutside = (e) => {
|
||||
const target = e.detail?.originalEvent?.target
|
||||
if (triggerEl.value && target && triggerEl.value.contains(target)) return
|
||||
pinned.value = false
|
||||
}
|
||||
// 展开箭头角度:每次展开/收缩累加 180°,保证上下翻转始终顺时针
|
||||
const arrowDeg = ref(0)
|
||||
watch(menuOpen, () => {
|
||||
arrowDeg.value += 180
|
||||
})
|
||||
// 选择菜单项:清除钉住与 hover 态,立即收起
|
||||
const handleSelect = (fn) => {
|
||||
pinned.value = false
|
||||
hoverOpen.value = false
|
||||
fn && fn()
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<!-- 工具按钮组:扁平图标,无独立描边/阴影,hover 时图标 morph 为文案(取代 tooltip)。 -->
|
||||
<div class="flex items-center gap-1 mx-3">
|
||||
<g-dropdown-menu
|
||||
v-if="isDev"
|
||||
trigger="hover"
|
||||
:items="videoList"
|
||||
@select="(item) => toDoc(item.value)"
|
||||
>
|
||||
<icon-button icon="lucide:clapperboard" label="教程" />
|
||||
</g-dropdown-menu>
|
||||
|
||||
<icon-button
|
||||
v-if="settings.header.search.visible"
|
||||
icon="lucide:search"
|
||||
label="搜索"
|
||||
@click="handleCommand"
|
||||
/>
|
||||
|
||||
<icon-button
|
||||
icon="lucide:settings"
|
||||
label="设置"
|
||||
@click="toggleSetting"
|
||||
/>
|
||||
|
||||
<icon-button
|
||||
v-if="settings.header.refresh.visible"
|
||||
icon="lucide:refresh-cw"
|
||||
label="刷新"
|
||||
:icon-class="showRefreshAnmite ? 'animate-spin' : ''"
|
||||
@click="toggleRefresh"
|
||||
/>
|
||||
|
||||
<icon-button
|
||||
:icon="themeStore.isDark ? 'lucide:sun' : 'lucide:moon'"
|
||||
label="主题"
|
||||
@click="themeStore.toggleTheme(!themeStore.isDark)"
|
||||
/>
|
||||
|
||||
<kra-setting v-model:drawer="showSettingDrawer"></kra-setting>
|
||||
<command-menu ref="command" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useThemeStore } from '@/pinia'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import KraSetting from '@/view/layout/setting/index.vue'
|
||||
import { ref } from 'vue'
|
||||
import { emitter } from '@/utils/bus.js'
|
||||
import CommandMenu from '@/components/commandMenu/index.vue'
|
||||
import IconButton from '@/components/iconButton/index.vue'
|
||||
import { toDoc } from '@/utils/doc'
|
||||
import { isDev } from '@/utils/env.js'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { settings } = storeToRefs(themeStore)
|
||||
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 = [
|
||||
{
|
||||
label: '1.clone项目和安装依赖',
|
||||
value: 'https://www.bilibili.com/video/BV1jx4y1s7xx'
|
||||
},
|
||||
{
|
||||
label: '2.初始化项目',
|
||||
value: 'https://www.bilibili.com/video/BV1sr421K7sv'
|
||||
},
|
||||
{
|
||||
label: '3.开启调试工具+创建初始化包',
|
||||
value: 'https://www.bilibili.com/video/BV1iH4y1c7Na'
|
||||
},
|
||||
{
|
||||
label: '4.手动使用自动化创建功能',
|
||||
value: 'https://www.bilibili.com/video/BV1UZ421T7fV'
|
||||
},
|
||||
{
|
||||
label: '5.使用已有表格创建业务',
|
||||
value: 'https://www.bilibili.com/video/BV1NE4m1977s'
|
||||
},
|
||||
{
|
||||
label: '6.使用AI创建业务和创建数据源模式的可选项',
|
||||
value: 'https://www.bilibili.com/video/BV17i421a7DE'
|
||||
},
|
||||
{
|
||||
label: '7.创建自己的后端方法',
|
||||
value: 'https://www.bilibili.com/video/BV1Yw4m1k7fg'
|
||||
},
|
||||
{
|
||||
label: '8.新增一个前端页面',
|
||||
value: 'https://www.bilibili.com/video/BV12y411i7oE'
|
||||
},
|
||||
{
|
||||
label: '9.配置一个前端二级页面',
|
||||
value: 'https://www.bilibili.com/video/BV1ZM4m1y7i3'
|
||||
},
|
||||
{
|
||||
label: '10.配置一个前端菜单参数',
|
||||
value: 'https://www.bilibili.com/video/BV1WS42197DZ'
|
||||
},
|
||||
{
|
||||
label: '11.菜单参数实战+动态菜单标题+菜单高亮配置',
|
||||
value: 'https://www.bilibili.com/video/BV1NE4m1979c'
|
||||
},
|
||||
{
|
||||
label: '12.增加菜单可控按钮',
|
||||
value: 'https://www.bilibili.com/video/BV1Sw4m1k746'
|
||||
},
|
||||
{
|
||||
label: '14.新增客户角色和其相关配置教学',
|
||||
value: 'https://www.bilibili.com/video/BV1Ki421a7X2'
|
||||
},
|
||||
{
|
||||
label: '15.发布项目上线',
|
||||
value: 'https://www.bilibili.com/video/BV1Lx4y1s77D'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-main text-base-text w-screen h-screen"
|
||||
>
|
||||
<iframe
|
||||
v-if="reloadFlag"
|
||||
id="kra-base-load-dom"
|
||||
class="kra-body-h bg-main w-full border-t border-border"
|
||||
: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 { useThemeStore } from '@/pinia'
|
||||
import { storeToRefs } from 'pinia'
|
||||
const themeStore = useThemeStore()
|
||||
const { isDark } = storeToRefs(themeStore)
|
||||
|
||||
|
||||
defineOptions({
|
||||
name: 'KraLayoutIframe'
|
||||
})
|
||||
|
||||
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.kra.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>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-main text-base-text w-screen h-screen"
|
||||
>
|
||||
<el-watermark
|
||||
v-if="settings.watermark.visible"
|
||||
:font="font"
|
||||
:z-index="9999"
|
||||
:gap="[180, 150]"
|
||||
class="!absolute !inset-0 !pointer-events-none"
|
||||
:content="userStore.userInfo.nickName"
|
||||
/>
|
||||
<kra-header />
|
||||
<div
|
||||
class="flex flex-row w-full kra-container pt-16 box-border !h-full"
|
||||
:style="
|
||||
effectiveMode === 'vertical' && !isMobile
|
||||
? { paddingLeft: verticalSideWidth + 'px' }
|
||||
: {}
|
||||
"
|
||||
>
|
||||
<kra-aside
|
||||
v-if="
|
||||
!isMobile &&
|
||||
(effectiveMode === 'normal' ||
|
||||
effectiveMode === 'sidebar' ||
|
||||
effectiveMode === 'vertical')
|
||||
"
|
||||
/>
|
||||
<kra-aside
|
||||
v-if="!isMobile && effectiveMode === 'combination'"
|
||||
mode="normal"
|
||||
class="z-10"
|
||||
/>
|
||||
<mobile-menu-drawer v-if="isMobile" />
|
||||
<div class="flex-1 w-0 h-full">
|
||||
<kra-tabs v-if="settings.tab.visible" />
|
||||
<div
|
||||
class="overflow-auto px-2"
|
||||
:class="settings.tab.visible ? 'kra-container2' : 'kra-container pt-1'"
|
||||
>
|
||||
<router-view v-if="reloadFlag" v-slot="{ Component, route }">
|
||||
<div
|
||||
id="kra-base-load-dom"
|
||||
class="kra-body-h bg-main"
|
||||
>
|
||||
<transition
|
||||
mode="out-in"
|
||||
:name="route.meta.transitionType || settings.page.transition"
|
||||
>
|
||||
<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 KraAside from '@/view/layout/aside/index.vue'
|
||||
import MobileMenuDrawer from '@/view/layout/aside/MobileMenuDrawer.vue'
|
||||
import KraHeader from '@/view/layout/header/index.vue'
|
||||
import { useLayoutMode } from '@/hooks/useLayoutMode'
|
||||
import { useSideWidth } from '@/hooks/useSideWidth'
|
||||
import useResponsive from '@/hooks/responsive'
|
||||
import KraTabs from './tabs/index.vue'
|
||||
import BottomInfo from '@/components/bottomInfo/bottomInfo.vue'
|
||||
import { emitter } from '@/utils/bus.js'
|
||||
import { computed, 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, useThemeStore } from '@/pinia'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import '@/style/transition.scss'
|
||||
const appStore = useAppStore()
|
||||
const themeStore = useThemeStore()
|
||||
const { device } = storeToRefs(appStore)
|
||||
const { settings, isDark } = storeToRefs(themeStore)
|
||||
const isMobile = computed(() => device.value === 'mobile')
|
||||
const { effectiveMode } = useLayoutMode()
|
||||
const { sideWidth: verticalSideWidth } = useSideWidth()
|
||||
|
||||
defineOptions({
|
||||
name: 'KraLayout'
|
||||
})
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div @click="clickFull">
|
||||
<div v-if="isShow" class="kraIcon kraIcon-fullscreen-expand" />
|
||||
<div v-else class="kraIcon kraIcon-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>
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div class="search-component items-center">
|
||||
<div
|
||||
class="kraIcon kraIcon-refresh"
|
||||
:class="[reload ? 'reloading' : '']"
|
||||
@click="handleReload"
|
||||
/>
|
||||
<Screenfull class="search-icon" />
|
||||
<div class="kraIcon kraIcon-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-base-text;
|
||||
div {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
.el-input__inner {
|
||||
@apply border-b border-solid border-border;
|
||||
}
|
||||
.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>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="flex gap-1 p-1 bg-muted rounded-lg w-fit">
|
||||
<div
|
||||
v-for="item in modes"
|
||||
:key="item.value"
|
||||
class="px-5 py-1.5 text-sm font-medium rounded-md cursor-pointer transition-all duration-150 ease-in-out"
|
||||
:class="modelValue === item.value
|
||||
? 'text-white shadow-sm'
|
||||
: 'text-muted-foreground hover:text-base-text'"
|
||||
:style="modelValue === item.value ? { backgroundColor: primaryColor } : {}"
|
||||
@click="emit('update:modelValue', item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useThemeStore } from '@/pinia'
|
||||
|
||||
defineOptions({
|
||||
name: 'CardModeSelector'
|
||||
})
|
||||
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: 'border'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { settings } = storeToRefs(themeStore)
|
||||
const primaryColor = computed(() => settings.value.themeColor)
|
||||
|
||||
const modes = [
|
||||
{ value: 'border', label: '边框' },
|
||||
{ value: 'shadow', label: '阴影' }
|
||||
]
|
||||
</script>
|
||||
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-2 max-[480px]:grid-cols-1 gap-2.5 px-1 kra-theme-font">
|
||||
<div
|
||||
v-for="layout in layoutModes"
|
||||
:key="layout.value"
|
||||
class="kra-theme-layout-card"
|
||||
:style="modelValue === layout.value ? {
|
||||
borderColor: primaryColor,
|
||||
boxShadow: `0 0 0 1px ${primaryColor}`
|
||||
} : {}"
|
||||
@click="handleLayoutChange(layout.value)"
|
||||
>
|
||||
<div class="flex justify-center mb-1.5">
|
||||
<div
|
||||
class="w-full h-12 bg-gray-50 dark:bg-gray-600 border border-gray-200 dark:border-gray-500 rounded-md p-1.5 flex gap-1"
|
||||
:class="layout.containerClass"
|
||||
>
|
||||
<div
|
||||
v-if="layout.showSidebar"
|
||||
class="rounded-sm flex flex-col items-center pt-1"
|
||||
:class="[layout.sidebarClass]"
|
||||
:style="getSidebarStyle(layout)"
|
||||
>
|
||||
<!-- 通栏侧边:Logo 置顶指示 -->
|
||||
<div v-if="layout.topLogo" class="w-1.5 h-1.5 rounded-full bg-white/80"></div>
|
||||
</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-[13px] font-semibold kra-theme-text-main" :style="modelValue === layout.value ? { color: primaryColor } : {}">{{ layout.label }}</span>
|
||||
<span class="block text-[11px] text-muted-foreground mt-0.5 leading-tight">{{ layout.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useThemeStore } from '@/pinia'
|
||||
import { addOpacityToColor } from '@/theme/color'
|
||||
|
||||
defineOptions({
|
||||
name: 'LayoutModeCard'
|
||||
})
|
||||
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: 'normal'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { settings } = storeToRefs(themeStore)
|
||||
|
||||
const primaryColor = computed(() => settings.value.themeColor)
|
||||
const lighterPrimaryColor = computed(() => addOpacityToColor(primaryColor.value, 0.7))
|
||||
const lightestPrimaryColor = computed(() => addOpacityToColor(primaryColor.value, 0.4))
|
||||
|
||||
const layoutModes = [
|
||||
{
|
||||
value: 'normal',
|
||||
label: '经典布局',
|
||||
description: '左侧导航,顶部标题栏',
|
||||
containerClass: '',
|
||||
showSidebar: true,
|
||||
sidebarClass: 'w-1/4',
|
||||
showHeader: true,
|
||||
headerClass: 'h-1/4',
|
||||
contentClass: '',
|
||||
primaryElement: 'sidebar'
|
||||
},
|
||||
{
|
||||
value: 'head',
|
||||
label: '顶部导航',
|
||||
description: '水平导航栏布局',
|
||||
containerClass: 'flex-col',
|
||||
showSidebar: false,
|
||||
showHeader: true,
|
||||
headerClass: 'h-1/3',
|
||||
contentClass: '',
|
||||
primaryElement: 'header'
|
||||
},
|
||||
{
|
||||
value: 'combination',
|
||||
label: '混合布局',
|
||||
description: '多级导航组合模式',
|
||||
containerClass: '',
|
||||
showSidebar: true,
|
||||
sidebarClass: 'w-1/5',
|
||||
showHeader: true,
|
||||
headerClass: 'h-1/4',
|
||||
contentClass: '',
|
||||
primaryElement: 'header',
|
||||
secondaryElement: 'sidebar'
|
||||
},
|
||||
{
|
||||
value: 'sidebar',
|
||||
label: '侧栏常驻',
|
||||
description: '二级菜单会始终打开',
|
||||
containerClass: '',
|
||||
showSidebar: true,
|
||||
sidebarClass: 'w-1/3',
|
||||
showHeader: true,
|
||||
headerClass: 'h-1/4',
|
||||
contentClass: '',
|
||||
primaryElement: 'sidebar'
|
||||
},
|
||||
{
|
||||
value: 'vertical',
|
||||
label: '通栏侧边',
|
||||
description: '侧栏通顶,Logo 置顶',
|
||||
containerClass: '',
|
||||
showSidebar: true,
|
||||
sidebarClass: 'w-1/4',
|
||||
showHeader: true,
|
||||
headerClass: 'h-1/4',
|
||||
contentClass: '',
|
||||
primaryElement: 'sidebar',
|
||||
topLogo: true
|
||||
}
|
||||
]
|
||||
|
||||
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 = () => {
|
||||
return { backgroundColor: lightestPrimaryColor.value, opacity: '0.5' }
|
||||
}
|
||||
|
||||
const handleLayoutChange = (layout) => {
|
||||
emit('update:modelValue', layout)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-3 gap-3 px-1">
|
||||
<div
|
||||
v-for="item in menuThemes"
|
||||
:key="item.value"
|
||||
class="relative flex flex-col items-center p-1.5 bg-container border rounded-lg cursor-pointer transition-colors duration-150 ease-in-out hover:border-primary"
|
||||
:class="modelValue === item.value ? '' : 'border-border'"
|
||||
:style="modelValue === item.value ? { borderColor: primaryColor, boxShadow: `0 0 0 1px ${primaryColor}` } : {}"
|
||||
:title="item.label"
|
||||
@click="handleChange(item.value)"
|
||||
>
|
||||
<!-- 迷你预览:左侧栏 + 主区 -->
|
||||
<div class="w-full h-9 rounded overflow-hidden flex border border-border">
|
||||
<div class="w-1/3 h-full flex flex-col gap-1 p-1" :style="{ background: item.previewSide }">
|
||||
<div class="h-1.5 rounded-sm" :style="{ background: item.previewActive }"></div>
|
||||
<div class="h-1.5 rounded-sm opacity-40" :style="{ background: item.previewSideText }"></div>
|
||||
</div>
|
||||
<div class="flex-1 h-full" :style="{ background: item.previewMain }"></div>
|
||||
</div>
|
||||
<svg-icon
|
||||
v-if="modelValue === item.value"
|
||||
icon="lucide:check"
|
||||
class="absolute top-1 right-1"
|
||||
:style="{ color: primaryColor }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useThemeStore } from '@/pinia'
|
||||
|
||||
defineOptions({
|
||||
name: 'MenuThemeSelector'
|
||||
})
|
||||
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: 'design'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { settings } = storeToRefs(themeStore)
|
||||
const primaryColor = computed(() => settings.value.themeColor)
|
||||
|
||||
const menuThemes = [
|
||||
// 设计:浅主色药丸选中态
|
||||
{ value: 'design', label: '设计', previewSide: '#ffffff', previewMain: '#f5f6f8', previewActive: 'rgb(var(--primary-color) / 0.15)', previewSideText: '#94a3b8' },
|
||||
// 亮色:主色实底选中态
|
||||
{ value: 'light', label: '亮色', previewSide: '#ffffff', previewMain: '#f5f6f8', previewActive: 'rgb(var(--primary-color))', previewSideText: '#94a3b8' },
|
||||
// 分组:通栏纯侧栏,一级为可折叠分组标题,选中为浅灰药丸
|
||||
{ value: 'group', label: '分组', previewSide: '#ffffff', previewMain: '#f5f6f8', previewActive: 'rgb(var(--primary-color) / 0.15)', previewSideText: '#64748b' }
|
||||
]
|
||||
|
||||
const handleChange = (value) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative flex flex-col gap-3 p-4 bg-container border-2 border-border rounded-xl cursor-pointer transition-all duration-150 ease-in-out hover:-translate-y-0.5 hover:shadow-lg"
|
||||
:class="{ 'opacity-60': !compatible }"
|
||||
@click="$emit('apply')"
|
||||
>
|
||||
<!-- 迷你预览:侧栏 + 主区 + 调色板 -->
|
||||
<div class="h-14 rounded-lg overflow-hidden flex border border-border">
|
||||
<div class="w-1/4 h-full" :style="{ background: sideBg }"></div>
|
||||
<div class="flex-1 h-full p-2 flex flex-col gap-1" :style="{ background: mainBg }">
|
||||
<div class="h-2 w-2/3 rounded" :style="{ background: preview.primaryColor }"></div>
|
||||
<div class="flex gap-1 mt-1">
|
||||
<span class="w-3 h-3 rounded-full" :style="{ background: preview.primaryColor }"></span>
|
||||
<span class="w-3 h-3 rounded-full" :style="{ background: preview.successColor }"></span>
|
||||
<span class="w-3 h-3 rounded-full" :style="{ background: preview.warningColor }"></span>
|
||||
<span class="w-3 h-3 rounded-full" :style="{ background: preview.dangerColor }"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-semibold kra-theme-text-main truncate">{{ preset.name }}</span>
|
||||
<el-tag
|
||||
v-if="!compatible"
|
||||
size="small"
|
||||
type="warning"
|
||||
effect="plain"
|
||||
:title="`需要 KRA ≥ ${preset.minMainVersion}`"
|
||||
>
|
||||
需 v{{ preset.minMainVersion }}+
|
||||
</el-tag>
|
||||
<el-tag v-else-if="preset.builtin" size="small" type="info" effect="plain">内置</el-tag>
|
||||
</div>
|
||||
<button
|
||||
v-if="!preset.builtin"
|
||||
type="button"
|
||||
aria-label="删除预设"
|
||||
class="absolute top-2 right-2 p-0 m-0 bg-transparent border-0 cursor-pointer leading-none text-muted-foreground hover:text-red-500 [font:inherit]"
|
||||
@click.stop="$emit('remove')"
|
||||
>
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Delete } from '@element-plus/icons-vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'PresetCard'
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
preset: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
compatible: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['apply', 'remove'])
|
||||
|
||||
const preview = computed(() => {
|
||||
const theme = props.preset.theme || {}
|
||||
return {
|
||||
primaryColor: theme.themeColor,
|
||||
successColor: theme.otherColor?.success,
|
||||
warningColor: theme.otherColor?.warning,
|
||||
dangerColor: theme.otherColor?.error,
|
||||
menuTheme: theme.menu?.theme,
|
||||
themeScheme: theme.themeScheme
|
||||
}
|
||||
})
|
||||
|
||||
const sideBg = computed(() => {
|
||||
const t = preview.value.menuTheme
|
||||
if (t === 'dark') return '#1e293b'
|
||||
if (t === 'light') return '#f8fafc'
|
||||
return '#ffffff'
|
||||
})
|
||||
|
||||
const mainBg = computed(() =>
|
||||
preview.value.themeScheme === 'dark' ? '#0f172a' : '#f5f6f8'
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-4 w-full min-w-[220px]">
|
||||
<g-slider
|
||||
v-model="modelValue"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.05"
|
||||
:marks="marks"
|
||||
aria-label="全局圆角"
|
||||
class="flex-1"
|
||||
/>
|
||||
<span class="text-xs font-mono kra-theme-text-main w-14 text-right">{{ modelValue }}rem</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: 'RadiusSelector'
|
||||
})
|
||||
|
||||
// 双向绑定统一走 defineModel,取代手写 modelValue prop + emit
|
||||
const modelValue = defineModel({
|
||||
type: Number,
|
||||
default: 0.75
|
||||
})
|
||||
|
||||
const marks = {
|
||||
0: '0',
|
||||
0.5: '0.5',
|
||||
1: '1'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<SettingItem label="语义色">
|
||||
<div class="flex items-center gap-2">
|
||||
<g-color-picker
|
||||
v-for="c in colors"
|
||||
:key="c.key"
|
||||
:model-value="settings.otherColor[c.key]"
|
||||
:title="c.label"
|
||||
:show-value="false"
|
||||
:swatches="presetSwatches"
|
||||
@update:model-value="(val) => themeStore.updateThemeColors(c.key, val)"
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useThemeStore } from '@/pinia'
|
||||
import SettingItem from './settingItem.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'SemanticColorPicker'
|
||||
})
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { settings } = storeToRefs(themeStore)
|
||||
|
||||
const colors = [
|
||||
{ key: 'success', label: '成功' },
|
||||
{ key: 'warning', label: '警告' },
|
||||
{ key: 'error', label: '危险' },
|
||||
{ key: 'info', label: '信息' }
|
||||
]
|
||||
|
||||
// 语义色常用预设,供面板里快速选取
|
||||
const presetSwatches = ['#67c23a', '#e6a23c', '#f56c6c', '#909399', '#60c041', '#f9901f', '#2264f2', '#38c0fc']
|
||||
</script>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="kra-theme-setting-item">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="kra-theme-setting-label">{{ label }}</span>
|
||||
<slot name="suffix"></slot>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 抽屉内的表单控件已全部切到 reka-ui 基础组件层(按主题 token 上色),
|
||||
// 这里不再需要针对 Element Plus 控件的覆盖样式与 --setting-* 变量。
|
||||
defineOptions({
|
||||
name: 'SettingItem'
|
||||
})
|
||||
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-3 gap-3 px-1">
|
||||
<div
|
||||
v-for="item in tabModes"
|
||||
:key="item.value"
|
||||
class="relative flex flex-col items-center gap-1.5 p-1.5 bg-container border rounded-lg cursor-pointer transition-colors duration-150 ease-in-out hover:border-primary"
|
||||
:class="modelValue === item.value ? '' : 'border-border'"
|
||||
:style="
|
||||
modelValue === item.value
|
||||
? { borderColor: primaryColor, boxShadow: `0 0 0 1px ${primaryColor}` }
|
||||
: {}
|
||||
"
|
||||
:title="item.label"
|
||||
@click="handleChange(item.value)"
|
||||
>
|
||||
<!-- 默认风格:扁平药丸 + 短竖线分隔,选中项主色弱底 -->
|
||||
<div
|
||||
v-if="item.value === 'button'"
|
||||
class="flex h-9 w-full items-center gap-1 overflow-hidden rounded bg-white px-1.5 dark:bg-gray-800"
|
||||
>
|
||||
<span class="h-4 w-9 rounded" :style="{ backgroundColor: primarySoft }" />
|
||||
<span class="h-3 w-px bg-gray-200 dark:bg-gray-600" />
|
||||
<span class="h-4 w-9 rounded bg-gray-100 dark:bg-gray-700" />
|
||||
<span class="h-3 w-px bg-gray-200 dark:bg-gray-600" />
|
||||
<span class="h-4 w-7 rounded bg-gray-100 dark:bg-gray-700" />
|
||||
</div>
|
||||
|
||||
<!-- Chrome 风格:交叠标签,选中项抬升 + 主色弱底 -->
|
||||
<div
|
||||
v-else-if="item.value === 'chrome'"
|
||||
class="flex h-9 w-full items-end overflow-hidden rounded bg-gray-100 px-1 dark:bg-gray-800"
|
||||
>
|
||||
<span class="h-5 w-9 rounded-t bg-gray-200 dark:bg-gray-700" />
|
||||
<span
|
||||
class="-ml-1 h-6 w-9 rounded-t"
|
||||
:style="{ backgroundColor: primarySoft }"
|
||||
/>
|
||||
<span class="-ml-1 h-5 w-9 rounded-t bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
|
||||
<!-- Slider 风格:底部 2px 主色指示条 + 激活弱底 -->
|
||||
<div
|
||||
v-else-if="item.value === 'slider'"
|
||||
class="flex h-9 w-full items-end rounded bg-white px-1 dark:bg-gray-800"
|
||||
>
|
||||
<span class="mr-0.5 flex h-6 flex-1 items-center justify-center rounded-t-sm border-b-2 border-transparent bg-gray-50 dark:bg-gray-700">
|
||||
<span class="h-2 w-5 rounded-sm bg-gray-200 dark:bg-gray-600" />
|
||||
</span>
|
||||
<span
|
||||
class="mr-0.5 flex h-6 flex-1 items-center justify-center rounded-t-sm border-b-2"
|
||||
:style="{ borderColor: primaryColor, backgroundColor: primarySoft }"
|
||||
>
|
||||
<span class="h-2 w-5 rounded-sm" :style="{ backgroundColor: primaryColor }" />
|
||||
</span>
|
||||
<span class="flex h-6 flex-1 items-center justify-center rounded-t-sm border-b-2 border-transparent bg-gray-50 dark:bg-gray-700">
|
||||
<span class="h-2 w-4 rounded-sm bg-gray-200 dark:bg-gray-600" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="text-xs"
|
||||
:style="modelValue === item.value ? { color: primaryColor } : {}"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
|
||||
<svg-icon
|
||||
v-if="modelValue === item.value"
|
||||
icon="lucide:check"
|
||||
class="absolute top-1 right-1"
|
||||
:style="{ color: primaryColor }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useThemeStore } from '@/pinia'
|
||||
|
||||
defineOptions({
|
||||
name: 'TabModeSelector'
|
||||
})
|
||||
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: 'button'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { settings } = storeToRefs(themeStore)
|
||||
const primaryColor = computed(() => settings.value.themeColor)
|
||||
// 主色弱底(真实合成色,跟随换肤;预览只示意,不必严格等于实际标签底色)
|
||||
const primarySoft = 'rgb(var(--primary-color) / 0.15)'
|
||||
|
||||
const tabModes = [
|
||||
{ value: 'button', label: '默认' },
|
||||
{ value: 'chrome', label: 'Chrome' },
|
||||
{ value: 'slider', label: '指示条' }
|
||||
]
|
||||
|
||||
const handleChange = (value) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="kra-theme-card-bg py-3">
|
||||
<div class="flex items-center flex-wrap gap-2.5">
|
||||
<button
|
||||
v-for="colorItem in presetColors"
|
||||
:key="colorItem.color"
|
||||
type="button"
|
||||
class="relative w-6 h-6 rounded-full cursor-pointer border text-white border-black/5 transition-transform duration-150 hover:scale-110"
|
||||
:class="modelValue === colorItem.color ? 'scale-110 ring-2 ring-offset-2 ring-offset-muted' : ''"
|
||||
:style="{
|
||||
backgroundColor: colorItem.color,
|
||||
'--tw-ring-color': colorItem.color
|
||||
}"
|
||||
:title="colorItem.name"
|
||||
@click="handleColorChange(colorItem.color)"
|
||||
>
|
||||
<svg-icon
|
||||
v-if="modelValue === colorItem.color"
|
||||
icon="lucide:check"
|
||||
class="absolute inset-0 m-auto"
|
||||
style="font-size: 12px; text-shadow: 0 1px 2px rgba(0,0,0,.35)"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<span class="w-px h-5 bg-border mx-0.5"></span>
|
||||
|
||||
<g-color-picker
|
||||
:model-value="modelValue"
|
||||
:show-value="false"
|
||||
:swatches="presetColors.map((item) => item.color)"
|
||||
aria-label="自定义主题色"
|
||||
@update:model-value="handleCustomColorChange"
|
||||
/>
|
||||
|
||||
<code class="ml-auto text-xs font-mono text-muted-foreground">{{ modelValue }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: 'ThemeColorPicker'
|
||||
})
|
||||
|
||||
// 双向绑定统一走 defineModel,取代手写 modelValue prop + emit
|
||||
const modelValue = defineModel({
|
||||
type: String,
|
||||
default: '#2264f2'
|
||||
})
|
||||
|
||||
const presetColors = [
|
||||
{ color: '#2264f2', name: '默认' },
|
||||
{ color: '#b48df3', name: '雅紫' },
|
||||
{ color: '#1d84ff', name: '天蓝' },
|
||||
{ color: '#60c041', name: '清新绿' },
|
||||
{ color: '#38c0fc', name: '湖青' },
|
||||
{ color: '#f9901f', name: '活力橙' },
|
||||
{ color: '#ff80c8', name: '樱粉' }
|
||||
]
|
||||
|
||||
const handleColorChange = (color) => {
|
||||
modelValue.value = color
|
||||
}
|
||||
|
||||
const handleCustomColorChange = (color) => {
|
||||
if (color) {
|
||||
modelValue.value = color
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="flex justify-center">
|
||||
<div class="kra-theme-mode-selector">
|
||||
<div
|
||||
v-for="mode in themeModes"
|
||||
:key="mode.value"
|
||||
class="kra-theme-mode-item"
|
||||
:class="[
|
||||
modelValue === mode.value
|
||||
? 'text-white shadow-sm transform -translate-y-0.5'
|
||||
: 'text-muted-foreground hover:text-base-text hover:bg-muted'
|
||||
]"
|
||||
: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 { useThemeStore } from '@/pinia'
|
||||
|
||||
defineOptions({
|
||||
name: 'ThemeModeSelector'
|
||||
})
|
||||
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: 'auto'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { settings } = storeToRefs(themeStore)
|
||||
|
||||
const primaryColor = computed(() => settings.value.themeColor)
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="drawer"
|
||||
direction="rtl"
|
||||
:size="width"
|
||||
append-to-body
|
||||
:show-close="false"
|
||||
class="kra-theme-drawer"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-base">系统配置</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<g-button
|
||||
size="sm"
|
||||
class="rounded-lg font-medium hover:-translate-y-0.5"
|
||||
@click="resetConfig"
|
||||
>
|
||||
重置配置
|
||||
</g-button>
|
||||
<g-button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="rounded-full"
|
||||
aria-label="关闭系统配置"
|
||||
@click="closeDrawer"
|
||||
>
|
||||
<svg-icon local-icon="close" class="h-3.5 w-3.5" />
|
||||
</g-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="bg-container px-5">
|
||||
<div class="pt-3 pb-4">
|
||||
<div class="flex justify-center">
|
||||
<div class="inline-flex bg-muted rounded-lg p-1 gap-0.5">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="px-4 py-1.5 text-[13px] text-center cursor-pointer font-medium rounded-md transition-colors duration-150 ease-in-out min-w-[60px]"
|
||||
:class="[
|
||||
activeTab === tab.key
|
||||
? 'text-white shadow-sm'
|
||||
: 'text-muted-foreground hover:text-base-text'
|
||||
]"
|
||||
:style="activeTab === tab.key ? { backgroundColor: settings.themeColor } : {}"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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'" />
|
||||
<PresetSettings v-else-if="activeTab === 'presets'" />
|
||||
<GeneralSettings v-else-if="activeTab === 'general'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppStore, useThemeStore } from '@/pinia'
|
||||
import AppearanceSettings from './modules/appearance/index.vue'
|
||||
import LayoutSettings from './modules/layout/index.vue'
|
||||
import PresetSettings from './modules/presets/index.vue'
|
||||
import GeneralSettings from './modules/general/index.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'KraSetting'
|
||||
})
|
||||
|
||||
const appStore = useAppStore()
|
||||
const themeStore = useThemeStore()
|
||||
const { device } = storeToRefs(appStore)
|
||||
const { settings } = storeToRefs(themeStore)
|
||||
|
||||
const activeTab = ref('appearance')
|
||||
|
||||
const tabs = [
|
||||
{ key: 'appearance', label: '外观' },
|
||||
{ key: 'layout', label: '布局' },
|
||||
{ key: 'presets', label: '预设' },
|
||||
{ key: 'general', label: '通用' }
|
||||
]
|
||||
|
||||
const width = computed(() => {
|
||||
return device.value === 'mobile' ? '100%' : '440px'
|
||||
})
|
||||
|
||||
const drawer = defineModel('drawer', {
|
||||
default: true,
|
||||
type: Boolean
|
||||
})
|
||||
|
||||
const resetConfig = () => {
|
||||
themeStore.resetConfig()
|
||||
}
|
||||
|
||||
const closeDrawer = () => {
|
||||
drawer.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.kra-theme-drawer {
|
||||
.el-drawer {
|
||||
@apply bg-container;
|
||||
}
|
||||
|
||||
.el-drawer__body {
|
||||
@apply p-0;
|
||||
}
|
||||
}
|
||||
|
||||
.kra-theme-font {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.kra-theme-card-bg {
|
||||
@apply bg-layout border border-border rounded-lg px-3.5 py-0.5;
|
||||
}
|
||||
|
||||
.kra-theme-card-white {
|
||||
@apply bg-container border border-border rounded-lg px-3 py-2.5 transition-colors duration-150;
|
||||
}
|
||||
|
||||
.kra-theme-section-header {
|
||||
@apply flex items-center mb-2 mt-0.5;
|
||||
}
|
||||
|
||||
.kra-theme-section-title {
|
||||
@apply text-xs font-semibold tracking-wide text-muted-foreground;
|
||||
}
|
||||
|
||||
.kra-theme-divider {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.kra-theme-text-main {
|
||||
@apply text-base-text;
|
||||
}
|
||||
|
||||
.kra-theme-text-sub {
|
||||
@apply text-muted-foreground;
|
||||
}
|
||||
|
||||
.kra-theme-section-content {
|
||||
animation: fadeInUp 0.3s ease;
|
||||
}
|
||||
|
||||
.kra-theme-setting-item {
|
||||
@apply flex items-center justify-between py-2.5 kra-theme-font border-0 border-b border-solid border-border last:border-b-0;
|
||||
}
|
||||
|
||||
.kra-theme-setting-label {
|
||||
@apply text-[13px] font-medium kra-theme-text-main;
|
||||
}
|
||||
|
||||
.kra-theme-mode-selector {
|
||||
@apply inline-flex bg-layout border border-border rounded-lg p-1 gap-1;
|
||||
}
|
||||
|
||||
.kra-theme-mode-item {
|
||||
@apply flex flex-col items-center justify-center px-3 py-2 rounded-md cursor-pointer transition-all duration-150 ease-in-out min-w-[64px];
|
||||
}
|
||||
|
||||
.kra-theme-layout-card {
|
||||
@apply bg-container border border-border rounded-lg p-2 cursor-pointer transition-colors duration-150 ease-in-out hover:border-primary;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar for webkit browsers */
|
||||
.kra-theme-drawer ::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.kra-theme-drawer ::-webkit-scrollbar-track {
|
||||
background: #f3f4f6;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.kra-theme-drawer ::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .kra-theme-drawer ::-webkit-scrollbar-track {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
.dark .kra-theme-drawer ::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
|
||||
&:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="kra-theme-font">
|
||||
<!-- 主题模式 -->
|
||||
<div class="mb-6">
|
||||
<div class="kra-theme-section-header">
|
||||
<span class="kra-theme-section-title">主题模式</span>
|
||||
</div>
|
||||
<div class="kra-theme-section-content">
|
||||
<ThemeModeSelector
|
||||
:model-value="settings.themeScheme"
|
||||
@update:modelValue="themeStore.setThemeScheme"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主题色 -->
|
||||
<div class="mb-6">
|
||||
<div class="kra-theme-section-header">
|
||||
<span class="kra-theme-section-title">主题色</span>
|
||||
</div>
|
||||
<div class="kra-theme-section-content">
|
||||
<ThemeColorPicker
|
||||
:model-value="settings.themeColor"
|
||||
@update:modelValue="(value) => themeStore.updateThemeColors('primary', value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 菜单风格 -->
|
||||
<div class="mb-6">
|
||||
<div class="kra-theme-section-header">
|
||||
<span class="kra-theme-section-title">菜单风格</span>
|
||||
</div>
|
||||
<div class="kra-theme-section-content">
|
||||
<MenuThemeSelector v-model="settings.menu.theme" />
|
||||
<div v-if="showDarkSider" class="kra-theme-card-bg mt-3">
|
||||
<SettingItem label="深色侧边栏">
|
||||
<g-switch v-model="settings.menu.darkSider" aria-label="深色侧边栏" />
|
||||
</SettingItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签栏风格 -->
|
||||
<div class="mb-6">
|
||||
<div class="kra-theme-section-header">
|
||||
<span class="kra-theme-section-title">标签栏风格</span>
|
||||
</div>
|
||||
<div class="kra-theme-section-content">
|
||||
<TabModeSelector v-model="settings.tab.mode" />
|
||||
<div class="kra-theme-card-bg mt-3">
|
||||
<SettingItem label="展示图标">
|
||||
<g-switch v-model="settings.tab.showIcon" aria-label="展示图标" />
|
||||
</SettingItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 外观细节:圆角 / 卡片 / 语义色 -->
|
||||
<div class="mb-6">
|
||||
<div class="kra-theme-section-header">
|
||||
<span class="kra-theme-section-title">外观细节</span>
|
||||
</div>
|
||||
<div class="kra-theme-section-content">
|
||||
<div class="kra-theme-card-bg">
|
||||
<SettingItem label="全局圆角">
|
||||
<RadiusSelector v-model="settings.themeRadius" />
|
||||
</SettingItem>
|
||||
<SettingItem label="卡片样式">
|
||||
<CardModeSelector v-model="settings.card.mode" />
|
||||
</SettingItem>
|
||||
<SettingItem label="信息色跟随主色">
|
||||
<g-switch v-model="settings.isInfoFollowPrimary" aria-label="信息色跟随主色" />
|
||||
</SettingItem>
|
||||
<SemanticColorPicker />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 偏好:尺寸 / 视觉辅助 -->
|
||||
<div class="mb-6">
|
||||
<div class="kra-theme-section-header">
|
||||
<span class="kra-theme-section-title">偏好</span>
|
||||
</div>
|
||||
<div class="kra-theme-section-content">
|
||||
<div class="kra-theme-card-bg">
|
||||
<SettingItem label="全局尺寸">
|
||||
<g-select
|
||||
v-model="settings.size"
|
||||
class="min-w-24"
|
||||
:options="[
|
||||
{ label: '默认', value: 'default' },
|
||||
{ label: '大', value: 'large' },
|
||||
{ label: '小', value: 'small' }
|
||||
]"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label="灰色模式">
|
||||
<g-switch v-model="settings.grayscale" aria-label="灰色模式" />
|
||||
</SettingItem>
|
||||
<SettingItem label="色弱模式">
|
||||
<g-switch v-model="settings.colourWeakness" aria-label="色弱模式" />
|
||||
</SettingItem>
|
||||
<SettingItem label="显示水印">
|
||||
<g-switch v-model="settings.watermark.visible" aria-label="显示水印" />
|
||||
</SettingItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppStore, useThemeStore } from '@/pinia'
|
||||
import { useLayoutMode } from '@/hooks/useLayoutMode'
|
||||
import ThemeModeSelector from '../../components/themeModeSelector.vue'
|
||||
import ThemeColorPicker from '../../components/themeColorPicker.vue'
|
||||
import MenuThemeSelector from '../../components/menuThemeSelector.vue'
|
||||
import TabModeSelector from '../../components/tabModeSelector.vue'
|
||||
import SemanticColorPicker from '../../components/semanticColorPicker.vue'
|
||||
import RadiusSelector from '../../components/radiusSelector.vue'
|
||||
import CardModeSelector from '../../components/cardModeSelector.vue'
|
||||
import SettingItem from '../../components/settingItem.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'AppearanceSettings'
|
||||
})
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const appStore = useAppStore()
|
||||
const { settings, darkMode } = storeToRefs(themeStore)
|
||||
const { device } = storeToRefs(appStore)
|
||||
const { effectiveMode } = useLayoutMode()
|
||||
|
||||
// 「深色侧边栏」仅在浅色主题、且屏幕上确有侧栏(含移动抽屉、平板强制通栏)时可配;
|
||||
// 全局暗色或顶部导航(桌面 head)下无意义,隐藏。
|
||||
const showDarkSider = computed(
|
||||
() => !darkMode.value && (device.value === 'mobile' || effectiveMode.value !== 'head')
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div class="kra-theme-font">
|
||||
<div class="mb-10">
|
||||
<div class="kra-theme-section-header">
|
||||
<div class="kra-theme-divider"></div>
|
||||
<span class="kra-theme-section-title">系统信息</span>
|
||||
<div class="kra-theme-divider"></div>
|
||||
</div>
|
||||
|
||||
<div class="kra-theme-section-content">
|
||||
<div class="kra-theme-card-bg">
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="flex justify-between items-center py-3 border-b border-border">
|
||||
<span class="kra-theme-text-sub font-medium">版本</span>
|
||||
<span class="font-mono kra-theme-text-main font-semibold">v2.7.4</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center py-3 border-b border-border">
|
||||
<span class="kra-theme-text-sub font-medium">前端框架</span>
|
||||
<span class="font-mono kra-theme-text-main font-semibold">Vue 3</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center py-3 border-b border-border">
|
||||
<span class="kra-theme-text-sub font-medium">UI 组件库</span>
|
||||
<span class="font-mono kra-theme-text-main font-semibold">Element Plus</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center py-3 border-b border-border">
|
||||
<span class="kra-theme-text-sub font-medium">构建工具</span>
|
||||
<span class="font-mono kra-theme-text-main font-semibold">Vite</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center py-3">
|
||||
<span class="kra-theme-text-sub font-medium">浏览器</span>
|
||||
<span class="font-mono kra-theme-text-main font-semibold">{{ browserInfo }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center py-3">
|
||||
<span class="kra-theme-text-sub font-medium">屏幕分辨率</span>
|
||||
<span class="font-mono kra-theme-text-main font-semibold">{{ screenResolution }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-10">
|
||||
<div class="kra-theme-section-header">
|
||||
<div class="kra-theme-divider"></div>
|
||||
<span class="kra-theme-section-title">配置管理</span>
|
||||
<div class="kra-theme-divider"></div>
|
||||
</div>
|
||||
|
||||
<div class="kra-theme-section-content">
|
||||
<div class="kra-theme-card-bg">
|
||||
<div
|
||||
class="kra-theme-card-white flex items-center justify-between">
|
||||
<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 kra-theme-text-main">重置配置</h4>
|
||||
<p class="text-xs text-muted-foreground mt-1">将所有设置恢复为默认值(导入/导出已迁移至「预设」)</p>
|
||||
</div>
|
||||
</div>
|
||||
<g-button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="rounded-lg font-medium hover:-translate-y-0.5"
|
||||
@click="handleResetConfig"
|
||||
>
|
||||
重置配置
|
||||
</g-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-10">
|
||||
<div class="kra-theme-section-header">
|
||||
<div class="kra-theme-divider"></div>
|
||||
<span class="kra-theme-section-title">关于项目</span>
|
||||
<div class="kra-theme-divider"></div>
|
||||
</div>
|
||||
|
||||
<div class="kra-theme-section-content">
|
||||
<div class="kra-theme-card-bg">
|
||||
<div class="flex items-start gap-5">
|
||||
<div
|
||||
class="w-16 h-16 bg-container border border-border rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<Logo />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="text-xl font-semibold kra-theme-text-main mb-3">Kra</h4>
|
||||
<p class="text-sm kra-theme-text-sub mb-5 leading-relaxed">
|
||||
基于 Vue3 + Gin 的全栈开发基础平台,提供完整的后台管理解决方案
|
||||
</p>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<a href="https://github.com/kra/kra" target="_blank"
|
||||
class="font-medium transition-colors duration-150 hover:underline"
|
||||
:style="{ color: settings.themeColor }">
|
||||
GitHub 仓库
|
||||
</a>
|
||||
<span class="text-muted-foreground">·</span>
|
||||
<a href="https://www.kra.com/" target="_blank"
|
||||
class="font-medium transition-colors duration-150 hover:underline"
|
||||
:style="{ color: settings.themeColor }">
|
||||
官方文档
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useThemeStore } from '@/pinia'
|
||||
import Logo from '@/components/logo/index.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'GeneralSettings'
|
||||
})
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { settings } = storeToRefs(themeStore)
|
||||
|
||||
const browserInfo = ref('')
|
||||
const screenResolution = 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 handleResetConfig = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定要重置所有配置吗?此操作不可撤销。',
|
||||
'重置配置',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
themeStore.resetConfig()
|
||||
ElMessage.success('配置已重置')
|
||||
} catch {
|
||||
// User cancelled
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="kra-theme-font">
|
||||
<!-- 布局模式 -->
|
||||
<div class="mb-6">
|
||||
<div class="kra-theme-section-header">
|
||||
<span class="kra-theme-section-title">布局模式</span>
|
||||
</div>
|
||||
<div class="kra-theme-section-content">
|
||||
<LayoutModeCard v-model="settings.layout.mode" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 顶栏:可见性 + 配色 -->
|
||||
<div class="mb-6">
|
||||
<div class="kra-theme-section-header">
|
||||
<span class="kra-theme-section-title">顶栏</span>
|
||||
</div>
|
||||
<div class="kra-theme-section-content">
|
||||
<div class="kra-theme-card-bg">
|
||||
<SettingItem label="显示面包屑">
|
||||
<g-switch v-model="settings.header.breadcrumb.visible" aria-label="显示面包屑" />
|
||||
</SettingItem>
|
||||
<SettingItem label="显示面包屑图标">
|
||||
<g-switch
|
||||
v-model="settings.header.breadcrumb.showIcon"
|
||||
:disabled="!settings.header.breadcrumb.visible"
|
||||
aria-label="显示面包屑图标"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label="显示刷新按钮">
|
||||
<g-switch v-model="settings.header.refresh.visible" aria-label="显示刷新按钮" />
|
||||
</SettingItem>
|
||||
<SettingItem label="显示搜索按钮">
|
||||
<g-switch v-model="settings.header.search.visible" aria-label="显示搜索按钮" />
|
||||
</SettingItem>
|
||||
<SettingItem label="显示折叠按钮">
|
||||
<g-switch v-model="settings.header.collapseButton.visible" aria-label="显示折叠按钮" />
|
||||
</SettingItem>
|
||||
<SettingItem label="顶栏背景">
|
||||
<template #suffix>
|
||||
<span class="text-xs text-muted-foreground ml-2">留空跟随主题</span>
|
||||
</template>
|
||||
<g-color-picker
|
||||
v-model="settings.header.bg"
|
||||
alpha
|
||||
clearable
|
||||
format="rgb"
|
||||
placeholder="跟随主题"
|
||||
aria-label="顶栏背景"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label="顶栏阴影">
|
||||
<g-select
|
||||
v-model="settings.header.shadow"
|
||||
class="min-w-24"
|
||||
:options="[
|
||||
{ label: '无', value: 'none' },
|
||||
{ label: '小', value: 'sm' },
|
||||
{ label: '中', value: 'md' },
|
||||
{ label: '大', value: 'lg' }
|
||||
]"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label="标签栏背景">
|
||||
<g-color-picker
|
||||
v-model="settings.tab.bg"
|
||||
alpha
|
||||
clearable
|
||||
format="rgb"
|
||||
placeholder="跟随主题"
|
||||
aria-label="标签栏背景"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label="标签栏阴影">
|
||||
<g-select
|
||||
v-model="settings.tab.shadow"
|
||||
class="min-w-24"
|
||||
:options="[
|
||||
{ label: '无', value: 'none' },
|
||||
{ label: '小', value: 'sm' },
|
||||
{ label: '中', value: 'md' },
|
||||
{ label: '大', value: 'lg' }
|
||||
]"
|
||||
/>
|
||||
</SettingItem>
|
||||
<div class="flex items-center gap-1.5 py-2.5 text-xs text-muted-foreground leading-snug">
|
||||
<svg-icon icon="lucide:info" class="flex-shrink-0" />
|
||||
<span>暗色模式下将基于以上配色自动推导深色版本,无需单独设置</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 界面 -->
|
||||
<div class="mb-6">
|
||||
<div class="kra-theme-section-header">
|
||||
<span class="kra-theme-section-title">界面</span>
|
||||
</div>
|
||||
<div class="kra-theme-section-content">
|
||||
<div class="kra-theme-card-bg">
|
||||
<SettingItem label="显示标签页">
|
||||
<g-switch v-model="settings.tab.visible" aria-label="显示标签页" />
|
||||
</SettingItem>
|
||||
<SettingItem label="页面切换动画">
|
||||
<g-select
|
||||
v-model="settings.page.transition"
|
||||
class="min-w-24"
|
||||
:options="[
|
||||
{ label: '淡入淡出', value: 'fade' },
|
||||
{ label: '滑动', value: 'slide' },
|
||||
{ label: '缩放', value: 'zoom' },
|
||||
{ label: '无动画', value: 'none' }
|
||||
]"
|
||||
/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 侧栏尺寸 -->
|
||||
<div class="mb-6">
|
||||
<div class="kra-theme-section-header">
|
||||
<span class="kra-theme-section-title">侧栏尺寸</span>
|
||||
</div>
|
||||
<div class="kra-theme-section-content">
|
||||
<div class="kra-theme-card-bg">
|
||||
<SettingItem label="展开宽度">
|
||||
<g-number-field v-model="settings.layout.sideWidth" :min="150" :max="400" :step="10" class="w-28" />
|
||||
</SettingItem>
|
||||
<SettingItem label="收缩宽度">
|
||||
<g-number-field v-model="settings.layout.sideCollapsedWidth" :min="60" :max="100" class="w-28" />
|
||||
</SettingItem>
|
||||
<SettingItem label="菜单项高度">
|
||||
<g-number-field v-model="settings.layout.sideItemHeight" :min="30" :max="50" class="w-28" />
|
||||
</SettingItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useThemeStore } from '@/pinia'
|
||||
import LayoutModeCard from '../../components/layoutModeCard.vue'
|
||||
import SettingItem from '../../components/settingItem.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'LayoutSettings'
|
||||
})
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { settings } = storeToRefs(themeStore)
|
||||
</script>
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="kra-theme-font">
|
||||
<!-- 内置预设 -->
|
||||
<div class="mb-10">
|
||||
<div class="kra-theme-section-header">
|
||||
<div class="kra-theme-divider"></div>
|
||||
<span class="kra-theme-section-title">内置预设</span>
|
||||
<div class="kra-theme-divider"></div>
|
||||
</div>
|
||||
<div class="kra-theme-section-content">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<PresetCard
|
||||
v-for="p in builtinPresets"
|
||||
:key="p.name"
|
||||
:preset="p"
|
||||
:compatible="isPresetCompatible(p)"
|
||||
@apply="handleApply(p)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 我的预设 -->
|
||||
<div class="mb-10">
|
||||
<div class="kra-theme-section-header">
|
||||
<div class="kra-theme-divider"></div>
|
||||
<span class="kra-theme-section-title">我的预设</span>
|
||||
<div class="kra-theme-divider"></div>
|
||||
</div>
|
||||
<div class="kra-theme-section-content">
|
||||
<div v-if="customPresets.length" class="grid grid-cols-2 gap-4 mb-4">
|
||||
<PresetCard
|
||||
v-for="p in customPresets"
|
||||
:key="p.name"
|
||||
:preset="p"
|
||||
:compatible="isPresetCompatible(p)"
|
||||
@apply="handleApply(p)"
|
||||
@remove="handleRemove(p)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="kra-theme-card-bg text-center text-sm text-muted-foreground py-6 mb-4"
|
||||
>
|
||||
暂无自定义预设,点击下方「保存当前为预设」
|
||||
</div>
|
||||
<g-button class="w-full rounded-lg font-medium" @click="handleSaveCurrent">
|
||||
保存当前为预设
|
||||
</g-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导入导出 -->
|
||||
<div class="mb-10">
|
||||
<div class="kra-theme-section-header">
|
||||
<div class="kra-theme-divider"></div>
|
||||
<span class="kra-theme-section-title">导入导出</span>
|
||||
<div class="kra-theme-divider"></div>
|
||||
</div>
|
||||
<div class="kra-theme-section-content">
|
||||
<div class="kra-theme-card-bg flex gap-3">
|
||||
<g-button
|
||||
variant="outline-primary"
|
||||
class="flex-1 rounded-lg"
|
||||
@click="handleExport"
|
||||
>
|
||||
导出当前配置
|
||||
</g-button>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept=".json"
|
||||
class="flex-1"
|
||||
@change="handleImport"
|
||||
>
|
||||
<g-button variant="outline-success" class="w-full rounded-lg">
|
||||
导入配置
|
||||
</g-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-3">
|
||||
导出当前完整配置(主题 / 布局 / 顶栏 / 界面),可跨账号迁移;
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useThemeStore } from '@/pinia'
|
||||
import PresetCard from '../../components/presetCard.vue'
|
||||
import {
|
||||
BUILTIN_PRESETS,
|
||||
loadCustomPresets,
|
||||
addCustomPreset,
|
||||
removeCustomPreset,
|
||||
serializePreset,
|
||||
parsePreset,
|
||||
isPresetCompatible
|
||||
} from '@/theme'
|
||||
|
||||
defineOptions({
|
||||
name: 'PresetSettings'
|
||||
})
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const builtinPresets = BUILTIN_PRESETS
|
||||
const customPresets = ref(loadCustomPresets())
|
||||
const uploadRef = ref()
|
||||
|
||||
const handleApply = (preset) => {
|
||||
if (!isPresetCompatible(preset)) {
|
||||
ElMessage.warning(`该预设需要 KRA ≥ ${preset.minMainVersion},当前版本不支持`)
|
||||
return
|
||||
}
|
||||
themeStore.applyPreset(preset)
|
||||
ElMessage.success(`已应用预设「${preset.name}」`)
|
||||
}
|
||||
|
||||
const handleSaveCurrent = async () => {
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('请输入预设名称', '保存当前为预设', {
|
||||
confirmButtonText: '保存',
|
||||
cancelButtonText: '取消',
|
||||
inputPattern: /\S+/,
|
||||
inputErrorMessage: '名称不能为空'
|
||||
})
|
||||
customPresets.value = addCustomPreset(themeStore.exportPreset(value.trim()))
|
||||
ElMessage.success('预设已保存')
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (preset) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除预设「${preset.name}」吗?`, '删除预设', {
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
customPresets.value = removeCustomPreset(preset.name)
|
||||
ElMessage.success('预设已删除')
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
const data = serializePreset(themeStore.exportPreset('kra-theme'))
|
||||
const blob = new Blob([data], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `kra-theme-${new Date().toISOString().split('T')[0]}.json`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success('配置已导出')
|
||||
}
|
||||
|
||||
const handleImport = (file) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
let preset
|
||||
try {
|
||||
preset = parsePreset(e.target.result)
|
||||
} catch {
|
||||
ElMessage.error('配置文件格式错误')
|
||||
return
|
||||
}
|
||||
// JSON 合法但不是有效预设(缺 theme / 旧格式)时 parsePreset 返回 null,需显式拦截,
|
||||
// 否则会出现「什么都没应用却提示导入成功」的误导
|
||||
if (!preset?.theme) {
|
||||
ElMessage.error('配置文件不兼容或为空')
|
||||
return
|
||||
}
|
||||
if (!isPresetCompatible(preset)) {
|
||||
ElMessage.warning(`该配置需要 KRA ≥ ${preset.minMainVersion},当前版本不支持`)
|
||||
return
|
||||
}
|
||||
themeStore.applyPreset(preset)
|
||||
ElMessage.success('配置已导入')
|
||||
}
|
||||
reader.readAsText(file.raw)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,387 @@
|
||||
<template>
|
||||
<div
|
||||
ref="scrollRef"
|
||||
class="kra-tabs isolate flex items-center gap-1.5 h-11 w-full overflow-x-auto bg-[var(--kra-tabs-bg)] px-3 pt-1.5 shadow-[var(--kra-tabs-shadow)]"
|
||||
:class="containerClass"
|
||||
>
|
||||
<ContextMenuRoot
|
||||
v-for="item in historys"
|
||||
:key="getFmtString(item)"
|
||||
:modal="false"
|
||||
@update:open="(open) => onContextMenuOpen(open, item)"
|
||||
>
|
||||
<ContextMenuTrigger as-child :disabled="contextMenuDisabled">
|
||||
<g-page-tab
|
||||
:mode="tabMode"
|
||||
:active="isActive(item)"
|
||||
:closable="isClosable(item)"
|
||||
@click="switchTo(item)"
|
||||
@mousedown="middleCloseTab($event, item)"
|
||||
@close="removeTab(getFmtString(item))"
|
||||
>
|
||||
<template v-if="showTabIcon && item.meta.icon" #prefix>
|
||||
<component :is="item.meta.icon" class="h-4 w-4 shrink-0" />
|
||||
</template>
|
||||
{{ fmtTitle(item.meta.title, item) }}
|
||||
</g-page-tab>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuPortal>
|
||||
<ContextMenuContent
|
||||
:side-offset="4"
|
||||
class="z-[3000] min-w-[120px] overflow-hidden rounded-md border border-border bg-container py-1 text-[13px] text-base-text shadow-card"
|
||||
>
|
||||
<ContextMenuItem
|
||||
v-for="action in contextMenuActions"
|
||||
:key="action.key"
|
||||
class="cursor-pointer select-none px-4 py-1.5 outline-none transition-colors data-[highlighted]:bg-muted"
|
||||
@select="action.handler"
|
||||
>
|
||||
{{ action.label }}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenuPortal>
|
||||
</ContextMenuRoot>
|
||||
</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 { useThemeStore } from '@/pinia'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { fmtTitle } from '@/utils/fmtRouterTitle'
|
||||
import {
|
||||
ContextMenuRoot,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuPortal,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem
|
||||
} from 'reka-ui'
|
||||
|
||||
defineOptions({
|
||||
name: 'HistoryComponent'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const themeStore = useThemeStore()
|
||||
const { settings } = storeToRefs(themeStore)
|
||||
|
||||
// 标签风格(button / chrome / slider),由主题设置驱动,可换肤
|
||||
const tabMode = computed(() => settings.value.tab.mode || 'button')
|
||||
// 是否在标签上展示路由图标
|
||||
const showTabIcon = computed(() => settings.value.tab.showIcon !== false)
|
||||
const containerClass = computed(
|
||||
() =>
|
||||
({
|
||||
button: 'items-center',
|
||||
chrome: 'items-end gap-0',
|
||||
slider: 'items-end gap-1'
|
||||
})[tabMode.value]
|
||||
)
|
||||
|
||||
|
||||
const getFmtString = (item) => {
|
||||
return item.name + JSON.stringify(item.query) + JSON.stringify(item.params)
|
||||
}
|
||||
|
||||
const historys = ref([])
|
||||
const activeValue = ref('')
|
||||
const rightActive = ref('')
|
||||
|
||||
const isCollapse = ref(false)
|
||||
const isMobile = ref(false)
|
||||
const defaultRouter = computed(
|
||||
() => userStore.userInfo.authority.defaultRouter
|
||||
)
|
||||
|
||||
// 常驻标签(首页 / 默认路由)不可关闭:不渲染关闭按钮、也不占位;其它标签始终可关。
|
||||
const isClosable = (item) => item.name !== defaultRouter.value
|
||||
// 右键菜单禁用条件:仅“唯一首页”时右键不弹出(保留原 el-tabs 行为)。
|
||||
const contextMenuDisabled = computed(
|
||||
() => historys.value.length === 1 && route.name === defaultRouter.value
|
||||
)
|
||||
|
||||
const isActive = (item) => getFmtString(item) === activeValue.value
|
||||
|
||||
const scrollRef = ref(null)
|
||||
// 路由变化后把激活标签滚入可视区(el-tabs 原本免费提供,这里显式补回)
|
||||
const scrollActiveIntoView = async () => {
|
||||
await nextTick()
|
||||
if (!scrollRef.value) return
|
||||
const index = historys.value.findIndex(isActive)
|
||||
scrollRef.value.children[index]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
inline: 'nearest',
|
||||
block: 'nearest'
|
||||
})
|
||||
}
|
||||
|
||||
// 左键:切换路由(关闭按钮已 stop,不会触达这里)
|
||||
const switchTo = (item) => {
|
||||
router.push({
|
||||
name: item.name,
|
||||
query: item.query,
|
||||
params: item.params
|
||||
})
|
||||
}
|
||||
|
||||
// 中键:关闭标签。保留唯一首页不可关的守卫与 preventDefault(抑制浏览器自动滚动)
|
||||
const middleCloseTab = (e, item) => {
|
||||
if (e.button !== 1) return
|
||||
if (!isClosable(item)) return
|
||||
e.preventDefault()
|
||||
removeTab(getFmtString(item))
|
||||
}
|
||||
|
||||
// 右键菜单打开时记录目标标签(替代旧的 e.srcElement.id.substring(4) DOM 取值)
|
||||
const onContextMenuOpen = (open, item) => {
|
||||
if (open) {
|
||||
rightActive.value = getFmtString(item)
|
||||
}
|
||||
}
|
||||
|
||||
const closeAll = () => {
|
||||
historys.value = [
|
||||
{
|
||||
name: defaultRouter.value,
|
||||
meta: {
|
||||
title: '首页'
|
||||
},
|
||||
query: {},
|
||||
params: {}
|
||||
}
|
||||
]
|
||||
router.push({ name: defaultRouter.value })
|
||||
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 contextMenuActions = [
|
||||
{ key: 'all', label: '关闭所有', handler: closeAll },
|
||||
{ key: 'left', label: '关闭左侧', handler: closeLeft },
|
||||
{ key: 'right', label: '关闭右侧', handler: closeRight },
|
||||
{ key: 'other', label: '关闭其他', handler: closeOther }
|
||||
]
|
||||
|
||||
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 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(
|
||||
() => 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))
|
||||
emitter.emit('setKeepAlive', historys.value)
|
||||
},
|
||||
{
|
||||
deep: true
|
||||
}
|
||||
)
|
||||
|
||||
// 激活标签变化时滚动到可视区
|
||||
watch(activeValue, scrollActiveIntoView)
|
||||
|
||||
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')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 横向滚动条不占位(保留可滚动,仅隐藏滚动条本体) */
|
||||
.kra-tabs::-webkit-scrollbar {
|
||||
height: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user