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 */
|
||||
declare module 'vue' {
|
||||
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']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
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
|
||||
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 RouterLink: typeof import('vue-router')['RouterLink']
|
||||
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": {
|
||||
"@iconify/vue": "^5.0.1",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"element-plus": "^2.14.7",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"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>
|
||||
<el-config-provider :locale="zhCn">
|
||||
<router-view />
|
||||
</el-config-provider>
|
||||
</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>
|
||||
<div>
|
||||
<header>
|
||||
<router-link to="/" class="item">home</router-link>
|
||||
<router-link to="/demo" class="item">demo</router-link>
|
||||
</header>
|
||||
<router-view></router-view>
|
||||
<div class="app-shell" :style="cssVars">
|
||||
<ty-header
|
||||
v-if="app.hasHeader"
|
||||
:title="title"
|
||||
:has-sidebar="app.hasSidebar"
|
||||
: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>
|
||||
</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">
|
||||
.item {
|
||||
@apply line-height-loose mr-3 text-gray-400 mb-5;
|
||||
.app-shell {
|
||||
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>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
|
||||
import App from './App.vue'
|
||||
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>,
|
||||
| never
|
||||
>,
|
||||
'/login': RouteRecordInfo<
|
||||
'/login',
|
||||
'/login',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
}
|
||||
|
||||
/**
|
||||
@ -62,6 +69,12 @@ declare module 'vue-router/auto-routes' {
|
||||
views:
|
||||
| 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 { VueRouterAutoImports } from 'unplugin-vue-router'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import vueI18n from '@intlify/unplugin-vue-i18n/vite'
|
||||
import path from 'node:path'
|
||||
import { compression } from 'vite-plugin-compression2'
|
||||
@ -99,10 +100,12 @@ export default defineConfig(({ mode }) => {
|
||||
/\.md$/, // .md
|
||||
],
|
||||
imports: ['vue', VueRouterAutoImports, 'pinia', '@vueuse/core'],
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
Components({
|
||||
deep: true,
|
||||
directoryAsNamespace: false,
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
vueI18n({
|
||||
// 语言包目录
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user