This commit is contained in:
zlwu committed 2026-08-01 17:29:37 +08:00
1 parent 06956b742b
commit e59b2aa6cb
104 files changed
+653227 -7621

No files matched your search

@@ -14,7 +14,8 @@
</div>
</div>
<RouterView v-if="isRefresh" v-slot="{ Component, route }" :style="contentStyle">
<!-- 勿强制 minHeight:calc(100vh-…),否则门户布局会叠出多余纵向滚动条 -->
<RouterView v-if="isRefresh" v-slot="{ Component, route }">
<!-- 缓存路由动画 -->
<Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
<KeepAlive :max="keepAliveMax" :exclude="keepAliveExclude">
@@ -58,7 +59,8 @@
defineOptions({ name: 'ArtPageContent' })
const route = useRoute()
const { containerMinHeight } = useAutoLayoutHeight()
// 仅同步 --art-full-height;不给业务页强制 minHeight
useAutoLayoutHeight()
const { pageTransition, containerWidth, refresh, showWorkTab } = storeToRefs(useSettingStore())
const { keepAliveExclude } = storeToRefs(useWorktabStore())
@@ -114,12 +116,6 @@
}
)
const contentStyle = computed(
(): CSSProperties => ({
minHeight: containerMinHeight.value
})
)
const reload = () => {
isRefresh.value = false
nextTick(() => {
@@ -1,71 +1,47 @@
<!-- 一体化门户顶栏 -->
<!-- 一体化门户顶栏:一级横排 + 大面板菜单(方案 A) -->
<template>
<header class="portal-header">
<div class="portal-header-start">
<div class="portal-header-brand" @click="toHome">
<ArtLogo class="portal-header-logo" src="/logo-sx.svg" :size="28" />
<p class="portal-header-title">{{ AppConfig.systemInfo.name }}</p>
</div>
<div class="portal-header-brand" @click="toHome">
<ArtLogo class="portal-header-logo" src="/logo-sx.svg" :size="28" />
<p class="portal-header-title">{{ AppConfig.systemInfo.name }}</p>
</div>
<div class="portal-header-nav">
<div class="portal-header-nav">
<ArtPortalMegaMenu />
</div>
<div class="portal-header-end">
<div class="portal-header-actions">
<ArtIconButton
icon="fold"
class="portal-header-icon-btn portal-header-fold-btn"
:class="{ 'is-menu-collapsed': !menuOpen }"
@click="toggleSidebar"
v-if="shouldShowFullscreen"
icon="fullscreen"
:class="[
'portal-header-icon-btn max-md:!hidden',
!isFullscreen ? 'full-screen-btn' : 'exit-full-screen-btn'
]"
@click="toggleFullScreen"
/>
<ArtPortalModuleSelect />
<ArtUserMenu />
</div>
</div>
<div class="portal-header-center" aria-hidden="false">
<p v-if="pageTitle" class="portal-header-page-title">{{ pageTitle }}</p>
</div>
<div class="portal-header-actions">
<ArtIconButton
v-if="shouldShowFullscreen"
icon="fullscreen"
:class="[
'portal-header-icon-btn max-md:!hidden',
!isFullscreen ? 'full-screen-btn' : 'exit-full-screen-btn'
]"
@click="toggleFullScreen"
/>
<ArtUserMenu />
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { useRouter } from 'vue-router'
import { useFullscreen } from '@vueuse/core'
import AppConfig from '@/config'
import { useCommon } from '@/hooks/core/useCommon'
import { useHeaderBar } from '@/hooks/core/useHeaderBar'
import { useSettingStore } from '@/store/modules/setting'
import { formatMenuTitle } from '@/utils/router'
import ArtUserMenu from '../art-header-bar/widget/ArtUserMenu.vue'
import ArtPortalMegaMenu from '../art-portal-mega-menu/index.vue'
defineOptions({ name: 'ArtPortalHeader' })
const route = useRoute()
const router = useRouter()
const { homePath } = useCommon()
const { shouldShowFullscreen } = useHeaderBar()
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
const settingStore = useSettingStore()
const { menuOpen } = storeToRefs(settingStore)
const pageTitle = computed(() => {
const matched = route.matched
if (!matched.length) return ''
const current = matched[matched.length - 1]
const title = current.meta?.title
return title ? formatMenuTitle(String(title)) : ''
})
const toHome = (): void => {
router.push(homePath.value)
@@ -74,10 +50,6 @@
const toggleFullScreen = (): void => {
toggleFullscreen()
}
const toggleSidebar = (): void => {
settingStore.setMenuOpen(!menuOpen.value)
}
</script>
<style lang="scss" scoped>
@@ -85,42 +57,27 @@
position: relative;
box-sizing: border-box;
display: flex;
gap: 12px;
gap: 8px;
align-items: center;
justify-content: space-between;
width: 100%;
height: 56px;
/* 左侧无 padding:brand 区与侧栏同宽,右边框才能纵向对齐 */
padding: 0 var(--mint-content-padding, 24px) 0 0;
user-select: none;
background: var(--mint-primary, #1ca851);
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.portal-header-start {
display: flex;
flex-shrink: 0;
gap: 12px;
align-items: center;
min-width: 0;
z-index: 1;
}
.portal-header-brand {
box-sizing: border-box;
z-index: 1;
display: flex;
flex-shrink: 0;
gap: 10px;
align-items: center;
/* +1px:与侧栏 #app-sidebar 外边框(内容宽 + 1px border)纵向对齐 */
width: calc(var(--app-sidebar-open-width, 270px) + 1px);
min-width: calc(var(--app-sidebar-open-width, 270px) + 1px);
max-width: calc(var(--app-sidebar-open-width, 270px) + 1px);
max-width: none;
padding-left: var(--mint-content-padding, 24px);
padding-right: 16px;
overflow: hidden;
cursor: pointer;
border-right: 1px solid rgba(255, 255, 255, 0.22);
}
.portal-header-logo {
@@ -128,11 +85,8 @@
}
.portal-header-title {
flex: 1;
min-width: 0;
flex: 0 0 auto;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 16px;
font-weight: 600;
line-height: 1.35;
@@ -141,34 +95,27 @@
}
.portal-header-nav {
display: flex;
gap: 12px;
align-items: center;
min-width: 0;
}
.portal-header-center {
position: absolute;
top: 0;
left: 50%;
z-index: 0;
display: flex;
align-items: center;
align-items: stretch;
justify-content: center;
max-width: min(520px, calc(100% - 420px));
pointer-events: none;
width: max-content;
max-width: min(720px, calc(100% - 520px));
height: 100%;
overflow: hidden;
transform: translateX(-50%);
}
.portal-header-page-title {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 16px;
font-weight: 600;
line-height: 1.35;
color: #fff;
text-align: center;
white-space: nowrap;
.portal-header-end {
z-index: 1;
display: flex;
flex-shrink: 0;
gap: 8px;
align-items: center;
margin-left: auto;
}
.portal-header-actions {
@@ -176,21 +123,6 @@
flex-shrink: 0;
gap: 8px;
align-items: center;
z-index: 1;
}
:deep(.portal-module-select-trigger) {
background: transparent;
.portal-module-select-trigger__icon,
.portal-module-select-trigger__label,
.portal-module-select-trigger__arrow {
color: #fff;
}
&:hover:not(.is-disabled) {
background: transparent;
}
}
:deep(.portal-header-icon-btn) {
@@ -201,26 +133,10 @@
}
}
.portal-header-fold-btn :deep(.art-svg-icon) {
transition: transform 0.3s ease;
}
.portal-header-fold-btn.is-menu-collapsed :deep(.art-svg-icon) {
transform: rotate(180deg);
}
@media only screen and (width <= 800px) {
.portal-header-title {
display: none;
}
.portal-header-center {
max-width: min(290px, calc(100% - 290px));
}
.portal-header-page-title {
font-size: 15px;
}
}
</style>
@@ -0,0 +1,410 @@
<!-- 门户顶栏:一级横排 + 方案 A 大面板(左右分行二级/三级文字链) -->
<template>
<div class="portal-mega-menu" ref="rootRef">
<nav class="portal-mega-l1" aria-label="一级模块">
<button
v-for="item in moduleList"
:key="item.path"
type="button"
class="portal-mega-l1__item"
:class="{
'is-selected': isL1Selected(item)
}"
@click="toggleModule(item)"
>
<span>{{ item.formattedTitle }}</span>
<span class="portal-mega-l1__caret">{{ openModulePath === item.path ? '▴' : '▾' }}</span>
</button>
</nav>
<Teleport to="body">
<div
v-if="openModule"
class="portal-mega-overlay"
@click="closePanel"
/>
<div
v-if="openModule"
class="portal-mega-panel"
role="dialog"
aria-label="子菜单"
:style="panelStyle"
>
<div class="portal-mega-panel__body">
<div class="portal-mega-split" :class="{ 'is-single': isSingleColumn }">
<div class="portal-mega-pane">
<div
v-for="row in columns.left"
:key="row.l2.path || row.l2.meta?.title"
class="portal-mega-row"
>
<div class="portal-mega-row__l2">{{ formatMenuTitle(row.l2.meta.title) }}</div>
<div class="portal-mega-row__l3">
<button
v-for="link in row.l3Links"
:key="link.path || link.meta?.title"
type="button"
class="portal-mega-link"
:class="{ 'is-active': isLinkActive(link) }"
@click="handleLinkClick(link)"
>
{{ formatMenuTitle(link.meta.title) }}
</button>
</div>
</div>
<p v-if="!columns.left.length" class="portal-mega-empty">暂无菜单</p>
</div>
<div v-if="!isSingleColumn" class="portal-mega-pane portal-mega-pane--right">
<div
v-for="row in columns.right"
:key="row.l2.path || row.l2.meta?.title"
class="portal-mega-row"
>
<div class="portal-mega-row__l2">{{ formatMenuTitle(row.l2.meta.title) }}</div>
<div class="portal-mega-row__l3">
<button
v-for="link in row.l3Links"
:key="link.path || link.meta?.title"
type="button"
class="portal-mega-link"
:class="{ 'is-active': isLinkActive(link) }"
@click="handleLinkClick(link)"
>
{{ formatMenuTitle(link.meta.title) }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { onClickOutside } from '@vueuse/core'
import { formatMenuTitle } from '@/utils/router'
import { handleMenuJump } from '@/utils/navigation'
import { useMenuStore } from '@/store/modules/menu'
import {
buildMegaPanelColumns,
filterPortalTopModules,
isMegaPanelLinkActive,
isPortalModuleActive,
type PortalMegaPanelColumns
} from '@/utils/router/portal-menu'
import type { AppRouteRecord } from '@/types/router'
import type { CSSProperties } from 'vue'
defineOptions({ name: 'ArtPortalMegaMenu' })
interface PortalModuleItem extends AppRouteRecord {
isRouteActive: boolean
formattedTitle: string
}
const route = useRoute()
const menuStore = useMenuStore()
const { menuList } = storeToRefs(menuStore)
const rootRef = ref<HTMLElement | null>(null)
const openModulePath = ref<string | null>(null)
const panelStyle = ref<CSSProperties>({})
const currentRoutePath = computed(() => String(route.meta.activePath || route.path))
const moduleList = computed<PortalModuleItem[]>(() => {
return filterPortalTopModules(menuList.value).map((item) => ({
...item,
isRouteActive: isPortalModuleActive(item, currentRoutePath.value),
formattedTitle: formatMenuTitle(item.meta.title)
}))
})
const openModule = computed(() => {
if (!openModulePath.value) return undefined
return moduleList.value.find((item) => item.path === openModulePath.value)
})
const columns = computed<PortalMegaPanelColumns>(() =>
buildMegaPanelColumns(openModule.value)
)
const isSingleColumn = computed(() => columns.value.right.length === 0)
/** 与顶栏中间槽上限一致;一级较少时保证两栏二级可读 */
const PANEL_MIN_WIDTH = 720
const PANEL_EDGE_PAD = 16
const syncPanelPosition = (): void => {
const el = rootRef.value
if (!el) return
const rect = el.getBoundingClientRect()
const viewportW = window.innerWidth
const minWidth = Math.min(PANEL_MIN_WIDTH, Math.max(0, viewportW - PANEL_EDGE_PAD * 2))
const width = Math.max(rect.width, minWidth)
const centerX = rect.left + rect.width / 2
let left = centerX - width / 2
left = Math.max(PANEL_EDGE_PAD, Math.min(left, viewportW - width - PANEL_EDGE_PAD))
panelStyle.value = {
top: `${rect.bottom}px`,
left: `${left}px`,
width: `${width}px`
}
}
const closePanel = (): void => {
openModulePath.value = null
}
const toggleModule = async (item: PortalModuleItem): Promise<void> => {
const path = String(item.path || '')
if (!path) return
openModulePath.value = openModulePath.value === path ? null : path
if (openModulePath.value) {
await nextTick()
syncPanelPosition()
}
}
/** 顶栏同时只高亮一个一级:面板展开时跟展开项,否则跟当前路由 */
const isL1Selected = (item: PortalModuleItem): boolean => {
if (openModulePath.value) {
return openModulePath.value === item.path
}
return item.isRouteActive
}
const isLinkActive = (link: AppRouteRecord): boolean => {
return isMegaPanelLinkActive(link, currentRoutePath.value)
}
const handleLinkClick = (link: AppRouteRecord): void => {
closePanel()
handleMenuJump(link)
}
watch(
() => route.fullPath,
() => {
closePanel()
}
)
watch(openModulePath, async (path) => {
if (!path) return
await nextTick()
syncPanelPosition()
})
onMounted(() => {
window.addEventListener('resize', syncPanelPosition)
window.addEventListener('scroll', syncPanelPosition, true)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', syncPanelPosition)
window.removeEventListener('scroll', syncPanelPosition, true)
})
onClickOutside(rootRef, (event) => {
const target = event.target as HTMLElement | null
if (target?.closest?.('.portal-mega-panel')) {
return
}
closePanel()
})
</script>
<style lang="scss" scoped>
.portal-mega-menu {
display: flex;
align-items: stretch;
justify-content: center;
width: 100%;
min-width: 0;
height: 100%;
}
.portal-mega-l1 {
display: flex;
flex-wrap: nowrap;
gap: 2px;
align-items: stretch;
justify-content: center;
min-width: 0;
max-height: 100%;
/* 仅允许横向溢出;若只写 overflow-x:auto,overflow-y 会被算成 auto,易出现竖向滚动条 */
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
}
.portal-mega-l1__item {
box-sizing: border-box;
display: inline-flex;
flex-shrink: 0;
gap: 4px;
align-items: center;
height: 100%;
max-height: 56px;
padding: 0 14px;
margin: 0;
font-size: 14px;
line-height: 1.2;
color: #fff;
white-space: nowrap;
cursor: pointer;
background: transparent;
border: none;
border-bottom: 4px solid transparent;
transition:
background-color 0.15s ease,
color 0.15s ease,
border-color 0.15s ease;
&:hover {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
&.is-selected {
font-weight: 600;
color: #fff;
background: rgba(255, 255, 255, 0.14);
border-bottom-color: #fff;
}
}
.portal-mega-l1__caret {
font-size: 10px;
opacity: 0.75;
}
</style>
<style lang="scss">
.portal-mega-overlay {
position: fixed;
inset: 56px 0 0 0;
z-index: 190;
background: rgba(15, 23, 42, 0.25);
}
.portal-mega-panel {
position: fixed;
z-index: 200;
box-sizing: border-box;
max-height: min(720px, calc(100vh - 72px));
overflow: auto;
background: #fff;
border: 1px solid var(--mint-border, #e2e8f0);
border-top: none;
border-radius: 0 0 10px 10px;
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.12);
}
.portal-mega-panel__body {
padding: 12px 16px 18px;
}
.portal-mega-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
align-items: stretch;
&.is-single {
grid-template-columns: 1fr;
}
}
.portal-mega-pane {
min-height: 100%;
}
.portal-mega-pane--right {
padding-left: 16px;
border-left: 1px solid var(--mint-border, #e2e8f0);
}
.portal-mega-row {
display: grid;
grid-template-columns: 110px 1fr;
gap: 8px 14px;
align-items: start;
padding: 12px 0;
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: 0;
}
}
.portal-mega-row__l2 {
padding-top: 6px;
font-size: 12px;
font-weight: 500;
line-height: 1.35;
color: #666666;
}
.portal-mega-row__l3 {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 4px;
align-items: stretch;
}
.portal-mega-link {
display: block;
width: 100%;
box-sizing: border-box;
padding: 4px 0;
margin: 0;
font-size: 13px;
font-weight: 500;
line-height: 1.45;
color: #0f172a;
text-align: left;
cursor: pointer;
background: transparent;
border: none;
border-radius: 0;
transition: color 0.15s ease;
&:hover {
color: var(--primary-dark, #059669);
}
&.is-active {
font-weight: 600;
color: var(--primary-dark, #059669);
}
}
.portal-mega-empty {
margin: 12px 0;
font-size: 13px;
color: #94a3b8;
}
@media only screen and (width <= 900px) {
.portal-mega-split {
grid-template-columns: 1fr;
}
.portal-mega-pane--right {
padding-left: 0;
border-left: none;
}
.portal-mega-row {
grid-template-columns: 96px 1fr;
}
}
</style>