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:
liuyun 2026-10-11 00:12:59 +08:00
parent 7738abbbfd
commit 6ef0b1121c
23 changed files with 1898 additions and 461 deletions

52
components.d.ts vendored
View File

@ -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']
}

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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,
},
],
},
]

View File

@ -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>

View File

@ -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
View 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 },
)

View 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
View 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
View 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
View File

@ -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
}
}
/**

View File

@ -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({
// 语言包目录