sysmgr-admin/src/components/ty-header.vue
liuyun b9c3a64c34 feat: resource management page + 404 fallback + header fixes
- 资源管理:树形表格 + type 联动表单(menu/button/api 字段显隐)
- ty-form 新增 when 条件字段显隐;ty-crud 表单模型改 reactive
- 抽出 src/utils/tree.ts(buildTree/filterTree/collectDescendants),组织页复用
- 404 兜底:catch-all 路由 [...all].vue
- 顶栏去掉横向菜单底边框;修复切 Tab 顶部一级菜单高亮不跟随

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 11:06:21 +08:00

279 lines
7.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<header class="ty-header" :class="{ 'is-fixed': fixed }">
<div class="ty-header__left">
<div class="ty-header__logo" :style="logoWidth != null ? { width: `${logoWidth}px` } : undefined">
<img v-if="logo" class="ty-header__logo-img" :src="logo" alt="logo" />
<span v-if="showTitle" 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="activeIndex"
@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.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" :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 { computed } from 'vue'
import type { ResourceNode } from '@/types/resource'
import type { LayoutType } from '@/config/layout'
import type { BreadcrumbItem } from './ty-breadcrumb.vue'
defineOptions({ name: 'TyHeader' })
const props = withDefaults(
defineProps<{
/** 应用标题。 */
title?: string
/** Logo 图片地址。 */
logo?: string
/** 是否显示标题文字(侧栏折叠或纯图标时仅显 Logo)。 */
showTitle?: boolean
/** Logo 区宽度(px,与左侧菜单/菜单+MixBar 对齐);null 表示自适应。 */
logoWidth?: number | null
/** 是否有侧边栏(决定是否显示折叠按钮)。 */
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',
logo: '',
showTitle: true,
logoWidth: null,
hasSidebar: true,
collapsed: false,
fixed: true,
showBreadcrumb: true,
breadcrumbs: () => [],
showFullscreen: true,
isFullscreen: false,
isDark: false,
layout: 'horizontal',
menus: () => [],
activePath: '',
username: '',
},
)
/** 当前激活的顶部一级菜单 code(高亮包含当前路由的一级菜单)。 */
const activeIndex = computed(() => {
const path = props.activePath
for (const m of props.menus) {
if ((m.path ?? m.code) === path) return m.code
if (m.children?.some((c) => (c.path ?? c.code) === path)) return m.code
}
return path
})
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 0 0;
background: var(--wm-bg-color-base);
box-shadow: 0 1px 4px rgb(0 21 41 / 6%);
z-index: 100;
&.is-fixed {
position: sticky;
top: 0;
}
&__left {
display: flex;
align-items: center;
gap: 8px;
}
&__logo {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-shrink: 0;
overflow: hidden;
height: var(--ty-header-height, 56px);
padding: 0 8px;
background: var(--wm-bg-color-base);
border-right: 1px solid var(--wm-border-color-light);
}
&__logo-img {
width: 30px;
height: 30px;
flex-shrink: 0;
}
&__title {
font-size: var(--wm-font-size-lg);
font-weight: var(--wm-font-weight-semibold);
letter-spacing: 0.5px;
color: var(--wm-color-text-primary);
white-space: nowrap;
}
&__action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border: none;
border-radius: var(--wm-border-radius-base);
background: transparent;
font-size: 18px;
color: var(--wm-color-text-regular);
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
&:hover {
background: var(--wm-bg-color-page);
color: var(--el-color-primary);
}
}
&__nav {
height: var(--ty-header-height, 56px);
margin-left: 4px;
// 覆盖 EP 横向菜单根元素默认的 1px 底边框(提高选择器优先级)
&.el-menu--horizontal {
border-bottom: none;
}
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
height: var(--ty-header-height, 56px);
line-height: var(--ty-header-height, 56px);
color: var(--wm-color-text-regular);
}
:deep(.el-menu-item.is-active) {
color: var(--el-color-primary);
border-bottom: none;
}
:deep(.menu-icon) {
margin-right: 4px;
font-size: 16px;
}
}
&__breadcrumb {
margin-left: 12px;
}
&__right {
display: flex;
align-items: center;
gap: 2px;
margin-left: auto;
}
&__user {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 8px;
font-size: 16px;
color: var(--wm-color-text-regular);
cursor: pointer;
&:hover {
color: var(--el-color-primary);
}
}
}
</style>