feat: admin layout shell with Element Plus and configurable theme
- 集成 Element Plus(on-demand resolver + 中文 locale + 暗黑 css-vars) - 布局壳组件(ty-header/ty-mixbar/ty-sidebar/ty-tabs/ty-breadcrumb/ty-footer/ty-setting/ty-setting-panel),中划线 + ty 前缀 + 自动导入,props/emits 解耦 - 5 种布局/导航模式 + 主题色(#2E7FA3) + 暗黑 + 侧栏折叠/宽度 + 固定顶栏/底栏 + 面包屑/多页签/全屏/手风琴 - 默认值集中配置 src/config/layout.ts;菜单字段对齐后端 resource(src/types/resource.ts + src/config/menu.ts) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7738abbbfd
commit
6ef0b1121c
52
components.d.ts
vendored
52
components.d.ts
vendored
@ -12,15 +12,67 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||||
|
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||||
|
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||||
|
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||||
|
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||||
|
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||||
|
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||||
|
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||||
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
|
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||||
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
|
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||||
|
ElSlider: typeof import('element-plus/es')['ElSlider']
|
||||||
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
|
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||||
|
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||||
Icon: typeof import('./src/components/icon/icon.vue')['default']
|
Icon: typeof import('./src/components/icon/icon.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
TyBreadcrumb: typeof import('./src/components/ty-breadcrumb.vue')['default']
|
||||||
|
TyFooter: typeof import('./src/components/ty-footer.vue')['default']
|
||||||
|
TyHeader: typeof import('./src/components/ty-header.vue')['default']
|
||||||
|
TyMixbar: typeof import('./src/components/ty-mixbar.vue')['default']
|
||||||
|
TySetting: typeof import('./src/components/ty-setting.vue')['default']
|
||||||
|
TySettingPanel: typeof import('./src/components/ty-setting-panel.vue')['default']
|
||||||
|
TySidebar: typeof import('./src/components/ty-sidebar.vue')['default']
|
||||||
|
TyTabs: typeof import('./src/components/ty-tabs.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For TSX support
|
// For TSX support
|
||||||
declare global {
|
declare global {
|
||||||
|
const ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||||
|
const ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||||
|
const ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||||
|
const ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||||
|
const ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||||
|
const ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||||
|
const ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||||
|
const ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||||
|
const ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
|
const ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
|
const ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||||
|
const ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
|
const ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||||
|
const ElSlider: typeof import('element-plus/es')['ElSlider']
|
||||||
|
const ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
|
const ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
|
const ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||||
|
const ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||||
const Icon: typeof import('./src/components/icon/icon.vue')['default']
|
const Icon: typeof import('./src/components/icon/icon.vue')['default']
|
||||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
const RouterView: typeof import('vue-router')['RouterView']
|
const RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
const TyBreadcrumb: typeof import('./src/components/ty-breadcrumb.vue')['default']
|
||||||
|
const TyFooter: typeof import('./src/components/ty-footer.vue')['default']
|
||||||
|
const TyHeader: typeof import('./src/components/ty-header.vue')['default']
|
||||||
|
const TyMixbar: typeof import('./src/components/ty-mixbar.vue')['default']
|
||||||
|
const TySetting: typeof import('./src/components/ty-setting.vue')['default']
|
||||||
|
const TySettingPanel: typeof import('./src/components/ty-setting-panel.vue')['default']
|
||||||
|
const TySidebar: typeof import('./src/components/ty-sidebar.vue')['default']
|
||||||
|
const TyTabs: typeof import('./src/components/ty-tabs.vue')['default']
|
||||||
}
|
}
|
||||||
@ -22,6 +22,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@iconify/vue": "^5.0.1",
|
"@iconify/vue": "^5.0.1",
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
|
"element-plus": "^2.14.7",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"pinia-plugin-persistedstate": "^4.7.1",
|
"pinia-plugin-persistedstate": "^4.7.1",
|
||||||
"vue": "^3.5.38",
|
"vue": "^3.5.38",
|
||||||
|
|||||||
1044
pnpm-lock.yaml
generated
1044
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<el-config-provider :locale="zhCn">
|
||||||
<router-view />
|
<router-view />
|
||||||
|
</el-config-provider>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts"></script>
|
|
||||||
<style scoped></style>
|
<script setup lang="ts">
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
</script>
|
||||||
|
|||||||
27
src/components/ty-breadcrumb.vue
Normal file
27
src/components/ty-breadcrumb.vue
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<el-breadcrumb class="ty-breadcrumb" separator="/">
|
||||||
|
<el-breadcrumb-item v-for="(item, index) in items" :key="index">
|
||||||
|
<span :class="{ 'is-link': item.path && index < items.length - 1 }">{{ item.title }}</span>
|
||||||
|
</el-breadcrumb-item>
|
||||||
|
</el-breadcrumb>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({ name: 'TyBreadcrumb' })
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
title: string
|
||||||
|
path?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
/** 面包屑项(按层级从高到低)。 */
|
||||||
|
items: BreadcrumbItem[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ty-breadcrumb {
|
||||||
|
font-size: var(--wm-font-size-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
40
src/components/ty-footer.vue
Normal file
40
src/components/ty-footer.vue
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<footer class="ty-footer" :class="{ 'is-fixed': fixed }">
|
||||||
|
<span>{{ copyright }}</span>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({ name: 'TyFooter' })
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 版权文案。 */
|
||||||
|
copyright?: string
|
||||||
|
/** 是否固定在视口底部。 */
|
||||||
|
fixed?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
copyright: '云南投云科技有限公司',
|
||||||
|
fixed: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ty-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: var(--wm-font-size-xs);
|
||||||
|
color: var(--wm-color-text-secondary);
|
||||||
|
background: var(--wm-bg-color-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ty-footer.is-fixed {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
220
src/components/ty-header.vue
Normal file
220
src/components/ty-header.vue
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
<template>
|
||||||
|
<header class="ty-header" :class="{ 'is-fixed': fixed }">
|
||||||
|
<div class="ty-header__left">
|
||||||
|
<div class="ty-header__logo">
|
||||||
|
<span class="ty-header__title">{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-if="hasSidebar" class="ty-header__action" type="button" title="折叠/展开菜单" @click="emit('toggle-collapse')">
|
||||||
|
<icon type="iconify" :icon="collapsed ? 'mdi:menu-open' : 'mdi:menu'" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 顶部导航:top 显示全树;horizontal 显示一级 -->
|
||||||
|
<el-menu
|
||||||
|
v-if="layout !== 'sidebar'"
|
||||||
|
class="ty-header__nav"
|
||||||
|
mode="horizontal"
|
||||||
|
:ellipsis="false"
|
||||||
|
:default-active="activePath"
|
||||||
|
@select="onNavSelect"
|
||||||
|
>
|
||||||
|
<template v-for="item in menus" :key="item.code">
|
||||||
|
<el-sub-menu v-if="layout === 'top' && item.children?.length" :index="item.code">
|
||||||
|
<template #title>
|
||||||
|
<icon type="iconify" :icon="item.icon ?? 'mdi:menu'" class="menu-icon" />
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
</template>
|
||||||
|
<el-menu-item v-for="child in item.children" :key="child.code" :index="child.path ?? child.code">
|
||||||
|
{{ child.name }}
|
||||||
|
</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
|
<el-menu-item v-else :index="item.path ?? item.code">
|
||||||
|
<icon type="iconify" :icon="item.icon ?? 'mdi:menu'" class="menu-icon" />
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
</el-menu-item>
|
||||||
|
</template>
|
||||||
|
</el-menu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ty-breadcrumb v-if="showBreadcrumb && layout !== 'top'" :items="breadcrumbs" class="ty-header__breadcrumb" />
|
||||||
|
|
||||||
|
<div class="ty-header__right">
|
||||||
|
<button v-if="showFullscreen" class="ty-header__action" type="button" title="全屏" @click="emit('toggle-fullscreen')">
|
||||||
|
<icon type="iconify" :icon="isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen'" />
|
||||||
|
</button>
|
||||||
|
<button class="ty-header__action" type="button" title="暗黑模式" @click="emit('toggle-dark')">
|
||||||
|
<icon type="iconify" :icon="isDark ? 'mdi:white-balance-sunny' : 'mdi:weather-night'" />
|
||||||
|
</button>
|
||||||
|
<button class="ty-header__action" type="button" title="布局设置" @click="emit('open-settings')">
|
||||||
|
<icon type="iconify" icon="mdi:cog-outline" />
|
||||||
|
</button>
|
||||||
|
<el-dropdown trigger="click" @command="onUserCommand">
|
||||||
|
<span class="ty-header__user">
|
||||||
|
<icon type="iconify" icon="mdi:account-circle" />
|
||||||
|
<span>{{ username }}</span>
|
||||||
|
</span>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ResourceNode } from '@/types/resource'
|
||||||
|
import type { LayoutType } from '@/config/layout'
|
||||||
|
import type { BreadcrumbItem } from './ty-breadcrumb.vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'TyHeader' })
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 应用标题。 */
|
||||||
|
title?: string
|
||||||
|
/** 是否有侧边栏(决定是否显示折叠按钮)。 */
|
||||||
|
hasSidebar?: boolean
|
||||||
|
/** 侧边栏是否折叠。 */
|
||||||
|
collapsed?: boolean
|
||||||
|
/** 顶栏是否固定。 */
|
||||||
|
fixed?: boolean
|
||||||
|
/** 是否显示面包屑。 */
|
||||||
|
showBreadcrumb?: boolean
|
||||||
|
/** 面包屑项。 */
|
||||||
|
breadcrumbs?: BreadcrumbItem[]
|
||||||
|
/** 是否显示全屏按钮。 */
|
||||||
|
showFullscreen?: boolean
|
||||||
|
/** 当前是否全屏。 */
|
||||||
|
isFullscreen?: boolean
|
||||||
|
/** 当前是否暗黑。 */
|
||||||
|
isDark?: boolean
|
||||||
|
/** 布局类型(决定顶部导航渲染方式)。 */
|
||||||
|
layout?: LayoutType
|
||||||
|
/** 顶部导航菜单。 */
|
||||||
|
menus?: ResourceNode[]
|
||||||
|
/** 当前激活路径。 */
|
||||||
|
activePath?: string
|
||||||
|
/** 当前用户名。 */
|
||||||
|
username?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
title: 'sysmgr',
|
||||||
|
hasSidebar: true,
|
||||||
|
collapsed: false,
|
||||||
|
fixed: true,
|
||||||
|
showBreadcrumb: true,
|
||||||
|
breadcrumbs: () => [],
|
||||||
|
showFullscreen: true,
|
||||||
|
isFullscreen: false,
|
||||||
|
isDark: false,
|
||||||
|
layout: 'horizontal',
|
||||||
|
menus: () => [],
|
||||||
|
activePath: '',
|
||||||
|
username: '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'toggle-collapse'): void
|
||||||
|
(e: 'toggle-dark'): void
|
||||||
|
(e: 'toggle-fullscreen'): void
|
||||||
|
(e: 'open-settings'): void
|
||||||
|
(e: 'navigate', path: string): void
|
||||||
|
(e: 'logout'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function onNavSelect(index: string): void {
|
||||||
|
emit('navigate', index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUserCommand(command: string): void {
|
||||||
|
if (command === 'logout') emit('logout')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ty-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: var(--ty-header-height, 56px);
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--wm-bg-color-base);
|
||||||
|
border-bottom: 1px solid var(--wm-border-color-light);
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
&.is-fixed {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: var(--wm-font-size-lg);
|
||||||
|
font-weight: var(--wm-font-weight-semibold);
|
||||||
|
color: var(--wm-color-text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--wm-border-radius-base);
|
||||||
|
background: transparent;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--wm-color-text-regular);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--wm-bg-color-page);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__nav {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-left: 8px;
|
||||||
|
|
||||||
|
:deep(.menu-icon) {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__breadcrumb {
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--wm-color-text-regular);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
75
src/components/ty-mixbar.vue
Normal file
75
src/components/ty-mixbar.vue
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="ty-mixbar">
|
||||||
|
<div
|
||||||
|
v-for="item in menus"
|
||||||
|
:key="item.code"
|
||||||
|
class="ty-mixbar__item"
|
||||||
|
:class="{ 'is-active': item.code === activeCode }"
|
||||||
|
:title="item.name"
|
||||||
|
@click="emit('select', item.code)"
|
||||||
|
>
|
||||||
|
<icon type="iconify" :icon="item.icon ?? 'mdi:menu'" />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ResourceNode } from '@/types/resource'
|
||||||
|
|
||||||
|
defineOptions({ name: 'TyMixbar' })
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 一级菜单(用于展示图标)。 */
|
||||||
|
menus: ResourceNode[]
|
||||||
|
/** 当前激活的一级 code。 */
|
||||||
|
activeCode?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
activeCode: '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'select', code: string): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ty-mixbar {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--ty-sidebar-top, 0);
|
||||||
|
align-self: flex-start;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 64px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: var(--ty-sidebar-height, 100vh);
|
||||||
|
padding-top: 8px;
|
||||||
|
background: var(--wm-bg-color-base);
|
||||||
|
border-right: 1px solid var(--wm-border-color-light);
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-radius: var(--wm-border-radius-base);
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--wm-color-text-regular);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--wm-bg-color-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
129
src/components/ty-setting-panel.vue
Normal file
129
src/components/ty-setting-panel.vue
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ty-setting-panel">
|
||||||
|
<div class="ty-setting-panel__section">
|
||||||
|
<div class="ty-setting-panel__title">布局</div>
|
||||||
|
<el-radio-group v-model="local.layout" @change="emitUpdate">
|
||||||
|
<el-radio-button value="horizontal">上下</el-radio-button>
|
||||||
|
<el-radio-button value="top">纯上下</el-radio-button>
|
||||||
|
<el-radio-button value="sidebar">左右</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ty-setting-panel__section">
|
||||||
|
<div class="ty-setting-panel__row">
|
||||||
|
<span>MixBar</span>
|
||||||
|
<el-switch v-model="local.mixbar" @change="emitUpdate" />
|
||||||
|
</div>
|
||||||
|
<div class="ty-setting-panel__row">
|
||||||
|
<span>暗黑模式</span>
|
||||||
|
<el-switch v-model="local.isDark" @change="emitUpdate" />
|
||||||
|
</div>
|
||||||
|
<div class="ty-setting-panel__row">
|
||||||
|
<span>固定顶栏</span>
|
||||||
|
<el-switch v-model="local.headerFixed" :disabled="local.layout === 'sidebar'" @change="emitUpdate" />
|
||||||
|
</div>
|
||||||
|
<div class="ty-setting-panel__row">
|
||||||
|
<span>固定底部</span>
|
||||||
|
<el-switch v-model="local.footerFixed" @change="emitUpdate" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ty-setting-panel__section">
|
||||||
|
<div class="ty-setting-panel__row">
|
||||||
|
<span>主题色</span>
|
||||||
|
<el-color-picker v-model="local.themeColor" @change="emitUpdate" />
|
||||||
|
</div>
|
||||||
|
<div class="ty-setting-panel__row ty-setting-panel__row--block">
|
||||||
|
<span>侧边菜单宽度</span>
|
||||||
|
<el-slider v-model="local.sidebarWidth" :min="SIDEBAR_WIDTH_MIN" :max="SIDEBAR_WIDTH_MAX" @change="emitUpdate" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ty-setting-panel__section">
|
||||||
|
<div class="ty-setting-panel__title">界面</div>
|
||||||
|
<div class="ty-setting-panel__row">
|
||||||
|
<span>面包屑</span>
|
||||||
|
<el-switch v-model="local.showBreadcrumb" @change="emitUpdate" />
|
||||||
|
</div>
|
||||||
|
<div class="ty-setting-panel__row">
|
||||||
|
<span>多页签</span>
|
||||||
|
<el-switch v-model="local.showTabs" @change="emitUpdate" />
|
||||||
|
</div>
|
||||||
|
<div class="ty-setting-panel__row">
|
||||||
|
<span>顶栏全屏</span>
|
||||||
|
<el-switch v-model="local.showFullscreen" @change="emitUpdate" />
|
||||||
|
</div>
|
||||||
|
<div class="ty-setting-panel__row">
|
||||||
|
<span>菜单手风琴</span>
|
||||||
|
<el-switch v-model="local.menuAccordion" @change="emitUpdate" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, watch } from 'vue'
|
||||||
|
import type { LayoutSettings } from '@/config/layout'
|
||||||
|
import { SIDEBAR_WIDTH_MIN, SIDEBAR_WIDTH_MAX } from '@/config/layout'
|
||||||
|
|
||||||
|
defineOptions({ name: 'TySettingPanel' })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 当前布局/主题设置。 */
|
||||||
|
settings: LayoutSettings
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update', settings: LayoutSettings): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const local = reactive<LayoutSettings>({ ...props.settings })
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.settings,
|
||||||
|
(v) => Object.assign(local, v),
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function emitUpdate(): void {
|
||||||
|
emit('update', { ...local })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ty-setting-panel {
|
||||||
|
&__section {
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--wm-border-color-light);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: var(--wm-font-size-sm);
|
||||||
|
font-weight: var(--wm-font-weight-medium);
|
||||||
|
color: var(--wm-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: var(--wm-font-size-sm);
|
||||||
|
color: var(--wm-color-text-regular);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--block {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
22
src/components/ty-setting.vue
Normal file
22
src/components/ty-setting.vue
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<el-drawer v-model="open" title="布局设置" :size="320" append-to-body>
|
||||||
|
<ty-setting-panel :settings="settings" @update="emit('update', $event)" />
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { LayoutSettings } from '@/config/layout'
|
||||||
|
|
||||||
|
defineOptions({ name: 'TySetting' })
|
||||||
|
|
||||||
|
const open = defineModel<boolean>({ default: false })
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
/** 当前布局/主题设置。 */
|
||||||
|
settings: LayoutSettings
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update', settings: LayoutSettings): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
92
src/components/ty-sidebar.vue
Normal file
92
src/components/ty-sidebar.vue
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="ty-sidebar" :style="{ width: `${collapsed ? COLLAPSED_WIDTH : width}px` }">
|
||||||
|
<el-scrollbar>
|
||||||
|
<el-menu
|
||||||
|
:default-active="activePath"
|
||||||
|
:collapse="collapsed"
|
||||||
|
:unique-opened="accordion"
|
||||||
|
:collapse-transition="false"
|
||||||
|
@select="onSelect"
|
||||||
|
>
|
||||||
|
<template v-for="item in menus" :key="item.code">
|
||||||
|
<el-sub-menu v-if="item.children?.length" :index="item.code">
|
||||||
|
<template #title>
|
||||||
|
<icon type="iconify" :icon="item.icon ?? 'mdi:menu'" class="menu-icon" />
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
</template>
|
||||||
|
<el-menu-item v-for="child in item.children" :key="child.code" :index="child.path ?? child.code">
|
||||||
|
<icon v-if="child.icon" type="iconify" :icon="child.icon" class="menu-icon" />
|
||||||
|
<span>{{ child.name }}</span>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
|
<el-menu-item v-else :index="item.path ?? item.code">
|
||||||
|
<icon type="iconify" :icon="item.icon ?? 'mdi:menu'" class="menu-icon" />
|
||||||
|
<template #title>
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
</template>
|
||||||
|
</el-menu-item>
|
||||||
|
</template>
|
||||||
|
</el-menu>
|
||||||
|
</el-scrollbar>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ResourceNode } from '@/types/resource'
|
||||||
|
|
||||||
|
defineOptions({ name: 'TySidebar' })
|
||||||
|
|
||||||
|
const COLLAPSED_WIDTH = 64
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 菜单树(字段对齐后端 resource)。 */
|
||||||
|
menus: ResourceNode[]
|
||||||
|
/** 是否折叠。 */
|
||||||
|
collapsed?: boolean
|
||||||
|
/** 展开时的宽度。 */
|
||||||
|
width?: number
|
||||||
|
/** 手风琴模式。 */
|
||||||
|
accordion?: boolean
|
||||||
|
/** 当前激活路径。 */
|
||||||
|
activePath?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
collapsed: false,
|
||||||
|
width: 210,
|
||||||
|
accordion: false,
|
||||||
|
activePath: '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'select', path: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function onSelect(index: string): void {
|
||||||
|
emit('select', index)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ty-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--ty-sidebar-top, 0);
|
||||||
|
align-self: flex-start;
|
||||||
|
height: var(--ty-sidebar-height, 100vh);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--wm-bg-color-base);
|
||||||
|
border-right: 1px solid var(--wm-border-color-light);
|
||||||
|
transition: width 0.2s;
|
||||||
|
|
||||||
|
.el-menu {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.menu-icon) {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
51
src/components/ty-tabs.vue
Normal file
51
src/components/ty-tabs.vue
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<el-tabs
|
||||||
|
v-if="tabs.length"
|
||||||
|
class="ty-tabs"
|
||||||
|
type="card"
|
||||||
|
:model-value="activePath"
|
||||||
|
closable
|
||||||
|
@tab-click="onTabClick"
|
||||||
|
@tab-remove="onTabRemove"
|
||||||
|
>
|
||||||
|
<el-tab-pane v-for="tab in tabs" :key="tab.path" :label="tab.title" :name="tab.path" :closable="tab.closable" />
|
||||||
|
</el-tabs>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TabItem } from '@/stores/modules/tabs'
|
||||||
|
|
||||||
|
defineOptions({ name: 'TyTabs' })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 打开的页签。 */
|
||||||
|
tabs: TabItem[]
|
||||||
|
/** 当前激活的路径。 */
|
||||||
|
activePath: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'click', path: string): void
|
||||||
|
(e: 'remove', path: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function onTabClick(pane: { paneName?: string | number }): void {
|
||||||
|
if (pane.paneName != null) emit('click', String(pane.paneName))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTabRemove(name: string | number): void {
|
||||||
|
emit('remove', String(name))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ty-tabs {
|
||||||
|
--el-tabs-header-height: 36px;
|
||||||
|
|
||||||
|
:deep(.el-tabs__header) {
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 8px 0;
|
||||||
|
background: var(--wm-bg-color-base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
36
src/config/layout.ts
Normal file
36
src/config/layout.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
/** 布局类型:horizontal 上下(顶部一级导航+左侧菜单)/ top 纯上下(仅顶部)/ sidebar 左右(仅左侧菜单)。 */
|
||||||
|
export type LayoutType = 'horizontal' | 'top' | 'sidebar'
|
||||||
|
|
||||||
|
/** 布局/主题设置。默认值在此集中配置,改一处统一生效(用户页面修改覆盖并持久化)。 */
|
||||||
|
export interface LayoutSettings {
|
||||||
|
layout: LayoutType
|
||||||
|
mixbar: boolean
|
||||||
|
themeColor: string
|
||||||
|
isDark: boolean
|
||||||
|
headerFixed: boolean
|
||||||
|
sidebarWidth: number
|
||||||
|
footerFixed: boolean
|
||||||
|
sidebarCollapsed: boolean
|
||||||
|
showBreadcrumb: boolean
|
||||||
|
showTabs: boolean
|
||||||
|
showFullscreen: boolean
|
||||||
|
menuAccordion: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SIDEBAR_WIDTH_MIN = 200
|
||||||
|
export const SIDEBAR_WIDTH_MAX = 340
|
||||||
|
|
||||||
|
export const defaultLayout: LayoutSettings = {
|
||||||
|
layout: 'horizontal',
|
||||||
|
mixbar: false,
|
||||||
|
themeColor: '#2E7FA3',
|
||||||
|
isDark: false,
|
||||||
|
headerFixed: true,
|
||||||
|
sidebarWidth: 210,
|
||||||
|
footerFixed: false,
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
showBreadcrumb: true,
|
||||||
|
showTabs: true,
|
||||||
|
showFullscreen: true,
|
||||||
|
menuAccordion: false,
|
||||||
|
}
|
||||||
133
src/config/menu.ts
Normal file
133
src/config/menu.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import type { ResourceNode } from '@/types/resource'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 占位菜单树(字段严格对齐后端 resource,children 已按 parentCode 组装)。
|
||||||
|
* 后续替换为后端「当前用户菜单树」接口返回,数据结构不变。
|
||||||
|
*/
|
||||||
|
export const placeholderMenus: ResourceNode[] = [
|
||||||
|
{
|
||||||
|
id: 'menu-dashboard',
|
||||||
|
code: 'dashboard',
|
||||||
|
name: '工作台',
|
||||||
|
parentCode: null,
|
||||||
|
type: 'menu',
|
||||||
|
path: '/',
|
||||||
|
component: 'dashboard/index',
|
||||||
|
icon: 'mdi:view-dashboard',
|
||||||
|
sort: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'menu-system',
|
||||||
|
code: 'system',
|
||||||
|
name: '系统管理',
|
||||||
|
parentCode: null,
|
||||||
|
type: 'menu',
|
||||||
|
path: '/system',
|
||||||
|
component: 'Layout',
|
||||||
|
icon: 'mdi:cog',
|
||||||
|
sort: 2,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'menu-user',
|
||||||
|
code: 'user',
|
||||||
|
name: '用户管理',
|
||||||
|
parentCode: 'system',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/system/user',
|
||||||
|
component: 'system/user/index',
|
||||||
|
icon: 'mdi:account-group',
|
||||||
|
sort: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'menu-role',
|
||||||
|
code: 'role',
|
||||||
|
name: '角色管理',
|
||||||
|
parentCode: 'system',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/system/role',
|
||||||
|
component: 'system/role/index',
|
||||||
|
icon: 'mdi:account-key',
|
||||||
|
sort: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'menu-resource',
|
||||||
|
code: 'resource',
|
||||||
|
name: '资源管理',
|
||||||
|
parentCode: 'system',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/system/resource',
|
||||||
|
component: 'system/resource/index',
|
||||||
|
icon: 'mdi:view-list',
|
||||||
|
sort: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'menu-org',
|
||||||
|
code: 'org',
|
||||||
|
name: '组织管理',
|
||||||
|
parentCode: 'system',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/system/org',
|
||||||
|
component: 'system/org/index',
|
||||||
|
icon: 'mdi:sitemap',
|
||||||
|
sort: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'menu-dict',
|
||||||
|
code: 'dict',
|
||||||
|
name: '字典管理',
|
||||||
|
parentCode: 'system',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/system/dict',
|
||||||
|
component: 'system/dict/index',
|
||||||
|
icon: 'mdi:book-open-variant',
|
||||||
|
sort: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'menu-config',
|
||||||
|
code: 'config',
|
||||||
|
name: '参数配置',
|
||||||
|
parentCode: 'system',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/system/config',
|
||||||
|
component: 'system/config/index',
|
||||||
|
icon: 'mdi:tune',
|
||||||
|
sort: 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'menu-monitor',
|
||||||
|
code: 'monitor',
|
||||||
|
name: '系统监控',
|
||||||
|
parentCode: null,
|
||||||
|
type: 'menu',
|
||||||
|
path: '/monitor',
|
||||||
|
component: 'Layout',
|
||||||
|
icon: 'mdi:monitor-dashboard',
|
||||||
|
sort: 3,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'menu-audit',
|
||||||
|
code: 'audit',
|
||||||
|
name: '操作日志',
|
||||||
|
parentCode: 'monitor',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/monitor/audit',
|
||||||
|
component: 'monitor/audit/index',
|
||||||
|
icon: 'mdi:clipboard-text',
|
||||||
|
sort: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'menu-login-log',
|
||||||
|
code: 'login-log',
|
||||||
|
name: '登录日志',
|
||||||
|
parentCode: 'monitor',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/monitor/login-log',
|
||||||
|
component: 'monitor/login-log/index',
|
||||||
|
icon: 'mdi:login',
|
||||||
|
sort: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@ -1,23 +1,249 @@
|
|||||||
<!--
|
|
||||||
@name: default.vue.vue
|
|
||||||
@description:
|
|
||||||
@author: 程序员优雅哥 youyacoder
|
|
||||||
@time: 2026/2/11 16:04
|
|
||||||
-->
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="app-shell" :style="cssVars">
|
||||||
<header>
|
<ty-header
|
||||||
<router-link to="/" class="item">home</router-link>
|
v-if="app.hasHeader"
|
||||||
<router-link to="/demo" class="item">demo</router-link>
|
:title="title"
|
||||||
</header>
|
:has-sidebar="app.hasSidebar"
|
||||||
<router-view></router-view>
|
:collapsed="app.sidebarCollapsed"
|
||||||
|
:fixed="app.headerFixed"
|
||||||
|
:show-breadcrumb="app.showBreadcrumb"
|
||||||
|
:breadcrumbs="breadcrumbs"
|
||||||
|
:show-fullscreen="app.showFullscreen"
|
||||||
|
:is-fullscreen="isFullscreen"
|
||||||
|
:is-dark="app.isDark"
|
||||||
|
:layout="app.layout"
|
||||||
|
:menus="menus"
|
||||||
|
:active-path="activePath"
|
||||||
|
:username="username"
|
||||||
|
@toggle-collapse="app.sidebarCollapsed = !app.sidebarCollapsed"
|
||||||
|
@toggle-dark="app.isDark = !app.isDark"
|
||||||
|
@toggle-fullscreen="toggleFullscreen"
|
||||||
|
@open-settings="settingsOpen = true"
|
||||||
|
@navigate="handleNavigate"
|
||||||
|
@logout="handleLogout"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="app-body">
|
||||||
|
<ty-mixbar
|
||||||
|
v-if="app.hasMixbar"
|
||||||
|
:menus="menus"
|
||||||
|
:active-code="activeTop?.code ?? ''"
|
||||||
|
@select="handleTopSelect"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ty-sidebar
|
||||||
|
v-if="app.hasSidebar"
|
||||||
|
:menus="sidebarMenus"
|
||||||
|
:collapsed="app.sidebarCollapsed"
|
||||||
|
:width="app.sidebarWidth"
|
||||||
|
:accordion="app.menuAccordion"
|
||||||
|
:active-path="activePath"
|
||||||
|
@select="handleNavigate"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main class="app-content">
|
||||||
|
<ty-tabs
|
||||||
|
v-if="app.showTabs"
|
||||||
|
:tabs="tabsStore.tabs"
|
||||||
|
:active-path="activePath"
|
||||||
|
@click="handleNavigate"
|
||||||
|
@remove="handleTabRemove"
|
||||||
|
/>
|
||||||
|
<div class="app-main">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
<ty-footer :fixed="app.footerFixed" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ty-setting v-model="settingsOpen" :settings="settings" @update="applySettings" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts"></script>
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAppStore } from '@/stores/modules/app'
|
||||||
|
import { useTabsStore } from '@/stores/modules/tabs'
|
||||||
|
import { useAuthStore } from '@/stores/modules/auth'
|
||||||
|
import { placeholderMenus } from '@/config/menu'
|
||||||
|
import type { LayoutSettings } from '@/config/layout'
|
||||||
|
import type { ResourceNode } from '@/types/resource'
|
||||||
|
import type { BreadcrumbItem } from '@/components/ty-breadcrumb.vue'
|
||||||
|
import { applyTheme } from '@/utils/theme'
|
||||||
|
|
||||||
|
const app = useAppStore()
|
||||||
|
const tabsStore = useTabsStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
|
||||||
|
|
||||||
|
const title = '投云科技'
|
||||||
|
const HEADER_HEIGHT = 56
|
||||||
|
const menus: ResourceNode[] = placeholderMenus
|
||||||
|
const settingsOpen = ref(false)
|
||||||
|
|
||||||
|
const activePath = computed(() => route.path)
|
||||||
|
|
||||||
|
/** 当前路由对应的顶级菜单(及其叶子)。 */
|
||||||
|
const activeTop = computed<ResourceNode | null>(() => {
|
||||||
|
for (const m of menus) {
|
||||||
|
if ((m.path ?? m.code) === activePath.value) return m
|
||||||
|
if (m.children?.some((c) => (c.path ?? c.code) === activePath.value)) return m
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 侧边栏菜单:horizontal/mixbar 显示当前顶级下的子菜单;sidebar 显示全树。 */
|
||||||
|
const sidebarMenus = computed<ResourceNode[]>(() => {
|
||||||
|
if (app.layout === 'top') return []
|
||||||
|
if (app.layout === 'horizontal') return activeTop.value?.children ?? []
|
||||||
|
return app.mixbar ? (activeTop.value?.children ?? []) : menus
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 面包屑:顶级 + 当前叶子。 */
|
||||||
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => {
|
||||||
|
const top = activeTop.value
|
||||||
|
if (!top) return []
|
||||||
|
if ((top.path ?? top.code) === activePath.value) return [{ title: top.name }]
|
||||||
|
const leaf = top.children?.find((c) => (c.path ?? c.code) === activePath.value)
|
||||||
|
if (leaf) return [{ title: top.name }, { title: leaf.name }]
|
||||||
|
return [{ title: top.name }]
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 侧边栏 sticky 定位(有固定顶栏时往下让出顶栏高度)。 */
|
||||||
|
const cssVars = computed(() => ({
|
||||||
|
'--ty-header-height': `${HEADER_HEIGHT}px`,
|
||||||
|
'--ty-sidebar-top': app.hasHeader && app.headerFixed ? `${HEADER_HEIGHT}px` : '0px',
|
||||||
|
'--ty-sidebar-height': app.hasHeader && app.headerFixed ? `calc(100vh - ${HEADER_HEIGHT}px)` : '100vh',
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** 布局设置快照(传给设置面板)。 */
|
||||||
|
const settings = computed<LayoutSettings>(() => ({
|
||||||
|
layout: app.layout,
|
||||||
|
mixbar: app.mixbar,
|
||||||
|
themeColor: app.themeColor,
|
||||||
|
isDark: app.isDark,
|
||||||
|
headerFixed: app.headerFixed,
|
||||||
|
sidebarWidth: app.sidebarWidth,
|
||||||
|
footerFixed: app.footerFixed,
|
||||||
|
sidebarCollapsed: app.sidebarCollapsed,
|
||||||
|
showBreadcrumb: app.showBreadcrumb,
|
||||||
|
showTabs: app.showTabs,
|
||||||
|
showFullscreen: app.showFullscreen,
|
||||||
|
menuAccordion: app.menuAccordion,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** 用户名(从 access token 解出)。 */
|
||||||
|
const username = computed(() => decodeUsername(auth.accessToken))
|
||||||
|
|
||||||
|
function applySettings(s: LayoutSettings): void {
|
||||||
|
app.$patch(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNavigate(index: string): void {
|
||||||
|
const target = resolveTarget(index)
|
||||||
|
if (target) router.push(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTopSelect(code: string): void {
|
||||||
|
const top = menus.find((m) => m.code === code)
|
||||||
|
const target = top ? firstLeafPath(top) : null
|
||||||
|
if (target) router.push(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTabRemove(path: string): void {
|
||||||
|
tabsStore.removeTab(path)
|
||||||
|
if (activePath.value === path) {
|
||||||
|
const last = tabsStore.tabs[tabsStore.tabs.length - 1]
|
||||||
|
router.push(last?.path ?? '/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout(): Promise<void> {
|
||||||
|
await auth.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把菜单索引(path 或 code)解析为可跳转的叶子路径。 */
|
||||||
|
function resolveTarget(index: string): string | null {
|
||||||
|
const node = findMenu(index)
|
||||||
|
if (!node) return index
|
||||||
|
if (node.children?.length) return firstLeafPath(node)
|
||||||
|
return node.path ?? node.code
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMenu(index: string): ResourceNode | null {
|
||||||
|
for (const m of menus) {
|
||||||
|
if ((m.path ?? m.code) === index) return m
|
||||||
|
const child = m.children?.find((c) => (c.path ?? c.code) === index)
|
||||||
|
if (child) return child
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstLeafPath(node: ResourceNode): string | null {
|
||||||
|
if (!node.children?.length) return node.path ?? node.code
|
||||||
|
return firstLeafPath(node.children[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeUsername(token: string | null): string {
|
||||||
|
if (!token) return ''
|
||||||
|
try {
|
||||||
|
const payload = token.split('.')[1]
|
||||||
|
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '=')
|
||||||
|
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))
|
||||||
|
const json = JSON.parse(new TextDecoder().decode(bytes))
|
||||||
|
return (json.username as string) ?? ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主题应用
|
||||||
|
watch(
|
||||||
|
[() => app.themeColor, () => app.isDark],
|
||||||
|
([color, dark]) => applyTheme(color, dark),
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// 页签同步
|
||||||
|
watch(
|
||||||
|
activePath,
|
||||||
|
(p) => {
|
||||||
|
const top = activeTop.value
|
||||||
|
const leaf = top?.children?.find((c) => (c.path ?? c.code) === p)
|
||||||
|
const title = leaf?.name ?? top?.name ?? p
|
||||||
|
tabsStore.addTab({ path: p, title, closable: p !== '/' })
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.item {
|
.app-shell {
|
||||||
@apply line-height-loose mr-3 text-gray-400 mb-5;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: flex-start;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-content {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
|
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import { installRouter } from './router'
|
import { installRouter } from './router'
|
||||||
|
|||||||
53
src/stores/modules/app.ts
Normal file
53
src/stores/modules/app.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { defaultLayout, type LayoutType } from '@/config/layout'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 布局/主题偏好。默认值来自 src/config/layout.ts(集中配置),
|
||||||
|
* 用户在页面上的修改会覆盖默认值并持久化到 localStorage。
|
||||||
|
*/
|
||||||
|
export const useAppStore = defineStore(
|
||||||
|
'appStore',
|
||||||
|
() => {
|
||||||
|
const layout = ref<LayoutType>(defaultLayout.layout)
|
||||||
|
const mixbar = ref(defaultLayout.mixbar)
|
||||||
|
const themeColor = ref(defaultLayout.themeColor)
|
||||||
|
const isDark = ref(defaultLayout.isDark)
|
||||||
|
const headerFixed = ref(defaultLayout.headerFixed)
|
||||||
|
const sidebarWidth = ref(defaultLayout.sidebarWidth)
|
||||||
|
const footerFixed = ref(defaultLayout.footerFixed)
|
||||||
|
const sidebarCollapsed = ref(defaultLayout.sidebarCollapsed)
|
||||||
|
const showBreadcrumb = ref(defaultLayout.showBreadcrumb)
|
||||||
|
const showTabs = ref(defaultLayout.showTabs)
|
||||||
|
const showFullscreen = ref(defaultLayout.showFullscreen)
|
||||||
|
const menuAccordion = ref(defaultLayout.menuAccordion)
|
||||||
|
|
||||||
|
/** 是否有顶栏:horizontal / top 有,sidebar(左右)无。 */
|
||||||
|
const hasHeader = computed(() => layout.value !== 'sidebar')
|
||||||
|
|
||||||
|
/** 是否渲染侧边栏:horizontal / sidebar 有,top 无。 */
|
||||||
|
const hasSidebar = computed(() => layout.value !== 'top')
|
||||||
|
|
||||||
|
/** 是否渲染 MixBar。 */
|
||||||
|
const hasMixbar = computed(() => mixbar.value && hasSidebar.value)
|
||||||
|
|
||||||
|
return {
|
||||||
|
layout,
|
||||||
|
mixbar,
|
||||||
|
themeColor,
|
||||||
|
isDark,
|
||||||
|
headerFixed,
|
||||||
|
sidebarWidth,
|
||||||
|
footerFixed,
|
||||||
|
sidebarCollapsed,
|
||||||
|
showBreadcrumb,
|
||||||
|
showTabs,
|
||||||
|
showFullscreen,
|
||||||
|
menuAccordion,
|
||||||
|
hasHeader,
|
||||||
|
hasSidebar,
|
||||||
|
hasMixbar,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ persist: true },
|
||||||
|
)
|
||||||
25
src/stores/modules/tabs.ts
Normal file
25
src/stores/modules/tabs.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export interface TabItem {
|
||||||
|
path: string
|
||||||
|
title: string
|
||||||
|
closable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 多页签状态(会话级,不持久化)。 */
|
||||||
|
export const useTabsStore = defineStore('tabsStore', () => {
|
||||||
|
const tabs = ref<TabItem[]>([])
|
||||||
|
|
||||||
|
function addTab(tab: TabItem): void {
|
||||||
|
if (!tabs.value.some((t) => t.path === tab.path)) {
|
||||||
|
tabs.value.push(tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTab(path: string): void {
|
||||||
|
tabs.value = tabs.value.filter((t) => t.path !== path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tabs, addTab, removeTab }
|
||||||
|
})
|
||||||
20
src/types/resource.ts
Normal file
20
src/types/resource.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
/** 资源类型(对齐后端 sys_resource.type)。 */
|
||||||
|
export type ResourceType = 'menu' | 'button' | 'api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 资源/菜单节点,字段严格对齐后端 Resource 模型
|
||||||
|
* (id/code/name/parentCode/type/path/component/icon/sort)。
|
||||||
|
* `children` 为前端按 parentCode 组装的渲染字段。
|
||||||
|
*/
|
||||||
|
export interface ResourceNode {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
parentCode: string | null
|
||||||
|
type: ResourceType
|
||||||
|
path: string | null
|
||||||
|
component: string | null
|
||||||
|
icon: string | null
|
||||||
|
sort: number
|
||||||
|
children?: ResourceNode[]
|
||||||
|
}
|
||||||
54
src/utils/theme.ts
Normal file
54
src/utils/theme.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
/** 十六进制颜色 → RGB 三元组。 */
|
||||||
|
function hexToRgb(hex: string): [number, number, number] {
|
||||||
|
let h = hex.replace('#', '')
|
||||||
|
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
|
||||||
|
const num = parseInt(h, 16)
|
||||||
|
return [(num >> 16) & 255, (num >> 8) & 255, num & 255]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RGB → 十六进制颜色。 */
|
||||||
|
function rgbToHex(r: number, g: number, b: number): string {
|
||||||
|
return `#${[r, g, b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 颜色混合:weight 为 target 占比(0=纯 base,1=纯 target)。 */
|
||||||
|
function mix(base: string, target: string, weight: number): string {
|
||||||
|
const [r1, g1, b1] = hexToRgb(base)
|
||||||
|
const [r2, g2, b2] = hexToRgb(target)
|
||||||
|
return rgbToHex(r1 + (r2 - r1) * weight, g1 + (g2 - g1) * weight, b1 + (b2 - b1) * weight)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应用主题色:写入 Element Plus 主色派生变量与 --wm-color-* 令牌(内联样式优先级最高,明暗皆生效)。 */
|
||||||
|
export function applyThemeColor(primary: string): void {
|
||||||
|
const root = document.documentElement
|
||||||
|
const set = (name: string, value: string) => root.style.setProperty(name, value)
|
||||||
|
|
||||||
|
set('--el-color-primary', primary)
|
||||||
|
set('--el-color-primary-light-3', mix(primary, '#ffffff', 0.3))
|
||||||
|
set('--el-color-primary-light-5', mix(primary, '#ffffff', 0.5))
|
||||||
|
set('--el-color-primary-light-7', mix(primary, '#ffffff', 0.7))
|
||||||
|
set('--el-color-primary-light-8', mix(primary, '#ffffff', 0.8))
|
||||||
|
set('--el-color-primary-light-9', mix(primary, '#ffffff', 0.9))
|
||||||
|
set('--el-color-primary-dark-2', mix(primary, '#000000', 0.2))
|
||||||
|
|
||||||
|
// 同步 --wm-color-primary 及色阶,保证 UnoCSS(text-primary 等)一致
|
||||||
|
set('--wm-color-primary', primary)
|
||||||
|
;[1, 2, 3, 5, 7, 8, 9].forEach((i) => {
|
||||||
|
set(`--wm-color-primary-light-${i}`, mix(primary, '#ffffff', i / 10))
|
||||||
|
})
|
||||||
|
set('--wm-color-primary-dark-1', mix(primary, '#000000', 0.1))
|
||||||
|
set('--wm-color-primary-dark-2', mix(primary, '#000000', 0.2))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应用暗黑模式:同时切换 Element Plus 的 html.dark 与现有 data-theme 令牌。 */
|
||||||
|
export function applyDarkMode(isDark: boolean): void {
|
||||||
|
const root = document.documentElement
|
||||||
|
root.classList.toggle('dark', isDark)
|
||||||
|
root.setAttribute('data-theme', isDark ? 'dark' : 'light')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一应用主题(颜色 + 暗黑)。 */
|
||||||
|
export function applyTheme(themeColor: string, isDark: boolean): void {
|
||||||
|
applyThemeColor(themeColor)
|
||||||
|
applyDarkMode(isDark)
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
13
typed-router.d.ts
vendored
13
typed-router.d.ts
vendored
@ -37,6 +37,13 @@ declare module 'vue-router/auto-routes' {
|
|||||||
Record<never, never>,
|
Record<never, never>,
|
||||||
| never
|
| never
|
||||||
>,
|
>,
|
||||||
|
'/login': RouteRecordInfo<
|
||||||
|
'/login',
|
||||||
|
'/login',
|
||||||
|
Record<never, never>,
|
||||||
|
Record<never, never>,
|
||||||
|
| never
|
||||||
|
>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -62,6 +69,12 @@ declare module 'vue-router/auto-routes' {
|
|||||||
views:
|
views:
|
||||||
| never
|
| never
|
||||||
}
|
}
|
||||||
|
'src/pages/login.vue': {
|
||||||
|
routes:
|
||||||
|
| '/login'
|
||||||
|
views:
|
||||||
|
| never
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import Layouts from 'vite-plugin-vue-layouts'
|
|||||||
import AutoImport from 'unplugin-auto-import/vite'
|
import AutoImport from 'unplugin-auto-import/vite'
|
||||||
import { VueRouterAutoImports } from 'unplugin-vue-router'
|
import { VueRouterAutoImports } from 'unplugin-vue-router'
|
||||||
import Components from 'unplugin-vue-components/vite'
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||||
import vueI18n from '@intlify/unplugin-vue-i18n/vite'
|
import vueI18n from '@intlify/unplugin-vue-i18n/vite'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { compression } from 'vite-plugin-compression2'
|
import { compression } from 'vite-plugin-compression2'
|
||||||
@ -99,10 +100,12 @@ export default defineConfig(({ mode }) => {
|
|||||||
/\.md$/, // .md
|
/\.md$/, // .md
|
||||||
],
|
],
|
||||||
imports: ['vue', VueRouterAutoImports, 'pinia', '@vueuse/core'],
|
imports: ['vue', VueRouterAutoImports, 'pinia', '@vueuse/core'],
|
||||||
|
resolvers: [ElementPlusResolver()],
|
||||||
}),
|
}),
|
||||||
Components({
|
Components({
|
||||||
deep: true,
|
deep: true,
|
||||||
directoryAsNamespace: false,
|
directoryAsNamespace: false,
|
||||||
|
resolvers: [ElementPlusResolver()],
|
||||||
}),
|
}),
|
||||||
vueI18n({
|
vueI18n({
|
||||||
// 语言包目录
|
// 语言包目录
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user