This commit is contained in:
zlwu committed 2026-08-08 20:43:48 +08:00
1 parent 2df2ef0808
commit 683f14828f
388 files changed
+2738488 -81306

No files matched your search

@@ -1,81 +1,118 @@
<!-- 门户顶栏:一级横排 + 方案 A 大面板(左右分行二级/三级文字链) -->
<!-- 门户顶栏:当前一级下的二级按宽度平铺 +「更多」+ 三级下拉(下拉 Teleport 到 body,避免被 overflow 裁切) -->
<template>
<div class="portal-mega-menu" ref="rootRef">
<nav class="portal-mega-l1" aria-label="一级模块">
<div class="portal-flat-nav" ref="rootRef">
<!-- 离屏测量:用于按宽度计算可见项,不参与布局 -->
<div ref="measureRef" class="portal-flat-nav__measure" aria-hidden="true">
<button
v-for="item in moduleList"
:key="item.path"
v-for="item in l2Items"
:key="`m-${item.path || item.meta?.title}`"
type="button"
class="portal-mega-l1__item"
:class="{
'is-selected': isL1Selected(item)
}"
@click="toggleModule(item)"
class="portal-flat-nav__item portal-flat-nav__measure-item"
>
<span>{{ item.formattedTitle }}</span>
<span class="portal-mega-l1__caret">{{ openModulePath === item.path ? '▴' : '▾' }}</span>
<span>{{ formatMenuTitle(item.meta.title) }}</span>
<span v-if="l2HasExpandableChildren(item)" class="portal-flat-nav__caret">▾</span>
</button>
<button type="button" class="portal-flat-nav__item portal-flat-nav__measure-more">
<span>更多</span>
<span class="portal-flat-nav__caret">▾</span>
</button>
</div>
<nav v-if="l2Items.length" class="portal-flat-nav__bar" aria-label="二级菜单">
<div
v-for="item in visibleItems"
:key="item.path || item.meta?.title"
class="portal-flat-nav__item-wrap"
>
<button
:ref="(el) => setL2TriggerRef(itemKey(item), el)"
type="button"
class="portal-flat-nav__item"
:class="{
'is-active': isL2Active(item),
'is-open': openL2Key === itemKey(item)
}"
@click="handleL2Click(item)"
>
<span>{{ formatMenuTitle(item.meta.title) }}</span>
<span v-if="l2HasExpandableChildren(item)" class="portal-flat-nav__caret">
{{ openL2Key === itemKey(item) ? '▴' : '▾' }}
</span>
</button>
</div>
<div v-if="overflowItems.length" class="portal-flat-nav__item-wrap">
<button
ref="moreTriggerRef"
type="button"
class="portal-flat-nav__item"
:class="{
'is-active': overflowHasActive,
'is-open': moreOpen
}"
@click="toggleMore"
>
<span>更多</span>
<span class="portal-flat-nav__caret">{{ moreOpen ? '▴' : '▾' }}</span>
</button>
</div>
</nav>
<p v-else class="portal-flat-nav__empty">暂无菜单</p>
<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"
v-if="openL2Item"
ref="l2FlyoutRef"
class="portal-flat-nav-flyout"
role="menu"
:style="l2FlyoutStyle"
>
<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>
<button
v-for="link in resolveMegaPanelL3Links(openL2Item)"
:key="link.path || link.meta?.title"
type="button"
class="portal-flat-nav-flyout__opt"
:class="{ 'is-active': isLinkActive(link) }"
@click.stop="handleLinkClick(link)"
>
{{ formatMenuTitle(link.meta.title) }}
</button>
</div>
<div
v-if="moreOpen"
ref="moreFlyoutRef"
class="portal-flat-nav-flyout portal-flat-nav-flyout--more"
role="menu"
:style="moreFlyoutStyle"
>
<div
v-for="item in overflowItems"
:key="item.path || item.meta?.title"
class="portal-flat-nav-flyout__block"
>
<template v-if="l2HasExpandableChildren(item)">
<div class="portal-flat-nav-flyout__group">{{ formatMenuTitle(item.meta.title) }}</div>
<button
v-for="link in resolveMegaPanelL3Links(item)"
:key="link.path || link.meta?.title"
type="button"
class="portal-flat-nav-flyout__opt portal-flat-nav-flyout__opt--sub"
:class="{ 'is-active': isLinkActive(link) }"
@click.stop="handleLinkClick(link)"
>
{{ formatMenuTitle(link.meta.title) }}
</button>
</template>
<button
v-else
type="button"
class="portal-flat-nav-flyout__opt"
:class="{ 'is-active': isL2Active(item) }"
@click.stop="handleLeafL2Click(item)"
>
{{ formatMenuTitle(item.meta.title) }}
</button>
</div>
</div>
</Teleport>
@@ -83,145 +120,255 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
type ComponentPublicInstance
} from 'vue'
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { onClickOutside } from '@vueuse/core'
import { onClickOutside, useResizeObserver } from '@vueuse/core'
import { formatMenuTitle } from '@/utils/router'
import { handleMenuJump } from '@/utils/navigation'
import { useMenuStore } from '@/store/modules/menu'
import {
buildMegaPanelColumns,
calcPortalFlatNavVisibleCount,
filterPortalTopModules,
isMegaPanelLinkActive,
isPortalModuleActive,
type PortalMegaPanelColumns
isPortalL2Active,
l2HasExpandableChildren,
listPortalFlatNavL2Items,
PORTAL_DEFAULT_MODULE_PATH,
resolveActivePortalModule,
resolveMegaPanelL3Links
} 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 measureRef = ref<HTMLElement | null>(null)
const moreTriggerRef = ref<HTMLElement | null>(null)
const l2FlyoutRef = ref<HTMLElement | null>(null)
const moreFlyoutRef = ref<HTMLElement | null>(null)
const l2TriggerRefs = ref<Record<string, HTMLElement | null>>({})
const openL2Key = ref<string | null>(null)
const moreOpen = ref(false)
const visibleCount = ref(0)
const l2FlyoutStyle = ref<CSSProperties>({})
const moreFlyoutStyle = 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 activeModule = computed(() => {
const matched = resolveActivePortalModule(menuList.value, currentRoutePath.value)
if (matched) {
return matched
}
const tops = filterPortalTopModules(menuList.value)
return tops.find((item) => item.path === PORTAL_DEFAULT_MODULE_PATH) || tops[0]
})
const openModule = computed(() => {
if (!openModulePath.value) return undefined
return moduleList.value.find((item) => item.path === openModulePath.value)
})
const l2Items = computed(() => listPortalFlatNavL2Items(activeModule.value))
const columns = computed<PortalMegaPanelColumns>(() =>
buildMegaPanelColumns(openModule.value)
const visibleItems = computed(() => l2Items.value.slice(0, visibleCount.value))
const overflowItems = computed(() => l2Items.value.slice(visibleCount.value))
const overflowHasActive = computed(() =>
overflowItems.value.some((item) => isPortalL2Active(item, currentRoutePath.value))
)
const isSingleColumn = computed(() => columns.value.right.length === 0)
const openL2Item = computed(() => {
if (!openL2Key.value) return undefined
return visibleItems.value.find((item) => itemKey(item) === openL2Key.value)
})
/** 与顶栏中间槽上限一致;一级较少时保证两栏二级可读 */
const PANEL_MIN_WIDTH = 720
const PANEL_EDGE_PAD = 16
const itemKey = (item: AppRouteRecord): string => String(item.path || item.meta?.title || '')
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 setL2TriggerRef = (key: string, el: Element | ComponentPublicInstance | null): void => {
if (!el) {
delete l2TriggerRefs.value[key]
return
}
l2TriggerRefs.value[key] = el as HTMLElement
}
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 isL2Active = (item: AppRouteRecord): boolean => {
return isPortalL2Active(item, currentRoutePath.value)
}
const isLinkActive = (link: AppRouteRecord): boolean => {
return isMegaPanelLinkActive(link, currentRoutePath.value)
}
const closePopovers = (): void => {
openL2Key.value = null
moreOpen.value = false
}
const clampFlyoutLeft = (left: number, width: number): number => {
const pad = 8
const maxLeft = Math.max(pad, window.innerWidth - width - pad)
return Math.min(Math.max(left, pad), maxLeft)
}
const syncL2FlyoutPosition = async (): Promise<void> => {
const key = openL2Key.value
if (!key) return
await nextTick()
const trigger = l2TriggerRefs.value[key]
const flyout = l2FlyoutRef.value
if (!trigger || !flyout) return
const rect = trigger.getBoundingClientRect()
const flyoutWidth = flyout.offsetWidth || 160
const left = clampFlyoutLeft(rect.left + rect.width / 2 - flyoutWidth / 2, flyoutWidth)
l2FlyoutStyle.value = {
top: `${rect.bottom - 2}px`,
left: `${left}px`
}
}
const syncMoreFlyoutPosition = async (): Promise<void> => {
if (!moreOpen.value) return
await nextTick()
const trigger = moreTriggerRef.value
const flyout = moreFlyoutRef.value
if (!trigger || !flyout) return
const rect = trigger.getBoundingClientRect()
const flyoutWidth = flyout.offsetWidth || 180
const left = clampFlyoutLeft(rect.right - flyoutWidth, flyoutWidth)
moreFlyoutStyle.value = {
top: `${rect.bottom - 2}px`,
left: `${left}px`
}
}
const toggleMore = async (): Promise<void> => {
moreOpen.value = !moreOpen.value
openL2Key.value = null
if (moreOpen.value) {
await syncMoreFlyoutPosition()
}
}
const recomputeVisibleCount = (): void => {
const root = rootRef.value
const measure = measureRef.value
const items = l2Items.value
if (!root || !measure || !items.length) {
visibleCount.value = 0
return
}
const availableWidth = root.clientWidth
const itemEls = measure.querySelectorAll<HTMLElement>('.portal-flat-nav__measure-item')
const moreEl = measure.querySelector<HTMLElement>('.portal-flat-nav__measure-more')
if (!itemEls.length || !moreEl) {
visibleCount.value = items.length
return
}
const itemWidths = Array.from(itemEls).map((el) => el.getBoundingClientRect().width)
const moreWidth = moreEl.getBoundingClientRect().width
visibleCount.value = calcPortalFlatNavVisibleCount(itemWidths, moreWidth, availableWidth)
}
/** 有三级:只展开下拉;无三级:直接跳转 */
const handleL2Click = async (item: AppRouteRecord): Promise<void> => {
if (l2HasExpandableChildren(item)) {
const key = itemKey(item)
openL2Key.value = openL2Key.value === key ? null : key
moreOpen.value = false
if (openL2Key.value) {
await syncL2FlyoutPosition()
}
return
}
closePopovers()
handleMenuJump(item)
}
const handleLeafL2Click = (item: AppRouteRecord): void => {
closePopovers()
handleMenuJump(item)
}
const handleLinkClick = (link: AppRouteRecord): void => {
closePanel()
closePopovers()
handleMenuJump(link)
}
watch(
() => route.fullPath,
() => {
closePanel()
closePopovers()
}
)
watch(openModulePath, async (path) => {
if (!path) return
await nextTick()
syncPanelPosition()
watch(
l2Items,
async () => {
closePopovers()
await nextTick()
recomputeVisibleCount()
},
{ deep: true }
)
useResizeObserver(rootRef, () => {
recomputeVisibleCount()
if (openL2Key.value) {
void syncL2FlyoutPosition()
}
if (moreOpen.value) {
void syncMoreFlyoutPosition()
}
})
onMounted(() => {
window.addEventListener('resize', syncPanelPosition)
window.addEventListener('scroll', syncPanelPosition, true)
const syncOpenFlyoutPositions = (): void => {
if (openL2Key.value) void syncL2FlyoutPosition()
if (moreOpen.value) void syncMoreFlyoutPosition()
}
onMounted(async () => {
await nextTick()
recomputeVisibleCount()
window.addEventListener('resize', syncOpenFlyoutPositions)
window.addEventListener('scroll', syncOpenFlyoutPositions, true)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', syncPanelPosition)
window.removeEventListener('scroll', syncPanelPosition, true)
window.removeEventListener('resize', syncOpenFlyoutPositions)
window.removeEventListener('scroll', syncOpenFlyoutPositions, true)
})
onClickOutside(rootRef, (event) => {
const target = event.target as HTMLElement | null
if (target?.closest?.('.portal-mega-panel')) {
return
onClickOutside(
rootRef,
() => {
closePopovers()
},
{
ignore: [l2FlyoutRef, moreFlyoutRef]
}
closePanel()
})
)
</script>
<style lang="scss" scoped>
.portal-mega-menu {
.portal-flat-nav {
position: relative;
display: flex;
align-items: stretch;
justify-content: center;
@@ -230,181 +377,147 @@
height: 100%;
}
.portal-mega-l1 {
.portal-flat-nav__measure {
position: absolute;
top: 0;
left: 0;
z-index: -1;
display: flex;
gap: 2px;
align-items: stretch;
height: 56px;
pointer-events: none;
visibility: hidden;
white-space: nowrap;
}
.portal-flat-nav__bar {
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;
max-width: 100%;
height: 100%;
overflow: hidden;
}
.portal-mega-l1__item {
.portal-flat-nav__item-wrap {
position: relative;
display: flex;
flex-shrink: 0;
align-items: stretch;
height: 100%;
}
.portal-flat-nav__item {
box-sizing: border-box;
display: inline-flex;
flex-shrink: 0;
gap: 4px;
align-items: center;
height: 100%;
max-height: 56px;
padding: 0 14px;
padding: 0 12px;
margin: 0;
font-size: 14px;
font-size: 13px;
font-weight: 500;
line-height: 1.2;
color: #fff;
color: rgba(255, 255, 255, 0.92);
white-space: nowrap;
cursor: pointer;
background: transparent;
border: none;
border-bottom: 4px solid transparent;
border-bottom: 2px 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);
background: rgba(255, 255, 255, 0.12);
}
&.is-selected {
&.is-active,
&.is-open {
font-weight: 600;
color: #fff;
background: rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.16);
border-bottom-color: #fff;
}
}
.portal-mega-l1__caret {
.portal-flat-nav__caret {
font-size: 10px;
opacity: 0.75;
opacity: 0.8;
}
.portal-flat-nav__empty {
display: flex;
align-items: center;
margin: 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.72);
}
</style>
<style lang="scss">
.portal-mega-overlay {
.portal-flat-nav-flyout {
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;
z-index: 3200;
box-sizing: border-box;
max-height: min(720px, calc(100vh - 72px));
min-width: 160px;
max-height: min(360px, calc(100vh - 72px));
padding: 6px;
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);
}
border-radius: 10px;
box-shadow: 0 10px 28px 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);
&--more {
min-width: 180px;
}
&.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;
&__group {
padding: 6px 10px 4px;
font-size: 11px;
font-weight: 700;
color: var(--mint-text-muted, #64748b);
letter-spacing: 0.02em;
}
.portal-mega-pane--right {
padding-left: 0;
border-left: none;
}
&__opt {
display: block;
box-sizing: border-box;
width: 100%;
padding: 8px 10px;
margin: 0;
font-size: 13px;
font-weight: 500;
line-height: 1.35;
color: #1e293b;
text-align: left;
white-space: nowrap;
cursor: pointer;
background: transparent;
border: none;
border-radius: 8px;
.portal-mega-row {
grid-template-columns: 96px 1fr;
&:hover {
color: var(--primary-dark, #059669);
background: var(--primary-bg, #ecfdf5);
}
&.is-active {
font-weight: 600;
color: var(--primary-dark, #059669);
background: var(--primary-bg, #ecfdf5);
}
&--sub {
padding-left: 18px;
color: #475569;
}
}
}
</style>
@@ -1,18 +1,14 @@
<!-- 门户顶栏一级模块下拉 -->
<!-- 门户顶栏:品牌旁一级模块下拉 -->
<template>
<div class="portal-module-select">
<ElDropdown
v-if="moduleList.length"
trigger="click"
teleported
popper-class="portal-module-select-popper"
@command="handleModuleSelect"
>
<button type="button" class="portal-module-select-trigger">
<ArtSvgIcon
v-if="activeModule?.meta?.icon"
:icon="activeModule.meta.icon"
class="portal-module-select-trigger__icon"
/>
<span class="portal-module-select-trigger__label">{{ triggerLabel }}</span>
<ElIcon class="portal-module-select-trigger__arrow"><ArrowDown /></ElIcon>
</button>
@@ -24,11 +20,6 @@
:command="item.path"
:class="{ 'is-active': item.isActive }"
>
<ArtSvgIcon
v-if="item.meta.icon"
:icon="item.meta.icon"
class="portal-module-select-item__icon"
/>
<span>{{ item.formattedTitle }}</span>
</ElDropdownItem>
</ElDropdownMenu>
@@ -83,7 +74,10 @@
if (matched) {
return matched
}
return moduleList.value.find((item) => item.path === PORTAL_DEFAULT_MODULE_PATH) || moduleList.value[0]
return (
moduleList.value.find((item) => item.path === PORTAL_DEFAULT_MODULE_PATH) ||
moduleList.value[0]
)
})
const triggerLabel = computed(() => {
@@ -116,49 +110,46 @@
gap: 6px;
align-items: center;
max-width: 180px;
height: 36px;
padding: 0 12px;
height: 32px;
padding: 0 10px 0 12px;
cursor: pointer;
background: transparent;
border: none;
color: #fff;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 8px;
&__icon {
flex-shrink: 0;
font-size: 18px;
color: var(--primary, #1ca851);
}
&__label {
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
font-size: 13px;
font-weight: 600;
line-height: 1.2;
color: var(--mint-text, #1e293b);
color: #fff;
white-space: nowrap;
}
&__arrow {
flex-shrink: 0;
font-size: 14px;
color: var(--mint-text-muted, #64748b);
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
}
&:hover:not(.is-disabled) {
background: transparent;
background: rgba(255, 255, 255, 0.22);
border-color: rgba(255, 255, 255, 0.55);
}
&.is-disabled {
cursor: not-allowed;
background: transparent;
opacity: 0.72;
}
}
</style>
<style lang="scss">
/* 高于顶栏中间二级(含 Teleport 下拉),避免一级面板被挡住 */
.portal-module-select-popper.el-popper {
z-index: 3300 !important;
backdrop-filter: none;
}
@@ -167,17 +158,13 @@
display: flex;
gap: 8px;
align-items: center;
font-size: 15px;
font-size: 14px;
&.is-active {
color: var(--primary, #1ca851);
background: var(--primary-bg, #e6f8ed);
font-weight: 600;
color: var(--primary-dark, #059669);
background: var(--primary-bg, #ecfdf5);
}
}
.portal-module-select-item__icon {
flex-shrink: 0;
font-size: 16px;
}
}
</style>
@@ -2,11 +2,11 @@
<template>
<div
v-if="showWorkTab"
class="worktab-strip box-border flex w-full items-center px-5 mb-3 select-none max-sm:px-[15px]"
class="worktab-strip worktab-strip--capsule box-border flex w-full items-center select-none"
>
<div class="worktab-scroll-wrap min-w-0 flex-1" ref="scrollRef">
<ul
class="float-left whitespace-nowrap !bg-transparent flex"
class="worktab-list float-left whitespace-nowrap !bg-transparent flex"
:class="[tabStyle === 'tab-google' ? 'pl-1' : '']"
ref="tabsRef"
:style="{
@@ -15,8 +15,11 @@
}"
>
<li
class="worktab-chip inline-flex flex-cc mr-1.5 text-xs c-p group"
:class="[item.path === activeTab ? 'is-active' : '', item.fixedTab ? 'is-fixed' : '']"
class="worktab-chip inline-flex flex-cc text-xs c-p group"
:class="[
item.path === activeTab ? 'is-active' : '',
item.fixedTab ? 'is-fixed is-home' : ''
]"
v-for="(item, index) in list"
:key="item.path"
:ref="item.path"
@@ -24,24 +27,26 @@
@click="clickTab(item)"
@contextmenu.prevent="(e) => showMenu(e, item.path)"
>
{{ item.customTitle || formatMenuTitle(item.title) }}
<span class="worktab-chip__label">{{
item.customTitle || formatMenuTitle(item.title)
}}</span>
<span
v-if="list.length > 1 && !item.fixedTab"
class="worktab-close inline-flex flex-cc relative ml-0.5 p-1 rounded-full tad-200"
class="worktab-close inline-flex flex-cc relative rounded-full tad-200"
@click.stop="closeWorktab('current', item.path)"
>
<ArtSvgIcon icon="close-bold" class="text-[10px] text-inherit" />
<ArtSvgIcon icon="close-bold" class="worktab-close__icon text-[10px]" />
</span>
</li>
</ul>
</div>
<div class="flex shrink-0">
<div class="worktab-menu-wrap flex shrink-0">
<div
class="worktab-menu-btn flex-cc relative top-0 size-8 leading-8 text-center c-p tad-200"
class="worktab-menu-btn flex-cc relative top-0 leading-none text-center c-p tad-200"
@click="(e) => showMenu(e, activeTab)"
>
<ArtSvgIcon icon="arrow-down" class="text-2xl text-g-700" />
<ArtSvgIcon icon="arrow-down" class="text-xl text-inherit" />
</div>
</div>
@@ -508,93 +513,167 @@
</script>
<style scoped>
.worktab-strip {
/* 方案 B′:浅底胶囊居中 + 未激活白底描边(docs/portal-worktab-capsule-v2-demo.html) */
.worktab-strip--capsule {
position: relative;
padding: 0 24px 4px !important;
background: var(--mint-surface);
gap: 8px;
align-items: center;
height: 48px;
min-height: 48px;
padding: 0 14px !important;
margin: 0 !important;
background: #f1f5f9;
border-bottom: 1px solid var(--mint-border, #e2e8f0);
overflow: visible !important;
}
.worktab-scroll-wrap {
display: flex;
align-items: center;
min-height: 100%;
overflow-x: clip;
overflow-y: visible;
}
.worktab-list {
gap: 6px;
align-items: center;
margin: 0;
padding: 0;
}
.worktab-chip {
position: relative;
z-index: 1;
max-width: 220px;
padding: 10px 16px !important;
margin-bottom: -7px;
display: inline-flex;
gap: 6px;
align-items: center;
max-width: 160px;
height: 32px;
min-height: 32px;
padding: 0 8px 0 12px !important;
margin: 0 !important;
overflow: hidden;
font-size: 13px;
font-weight: 600;
color: var(--mint-text-muted);
font-weight: 500;
line-height: 1;
color: #475569;
white-space: nowrap;
cursor: pointer;
background: #fff;
border: 1px solid #dbe3ec;
border-radius: 8px !important;
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.02);
transition:
background 0.15s ease,
color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.worktab-chip__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: var(--mint-surface);
border: 1px solid var(--mint-border);
border-radius: 8px 8px 0 0 !important;
transform: translateY(1px);
}
.worktab-chip.is-fixed {
padding-right: 12px !important;
padding-left: 12px !important;
}
.worktab-chip:hover {
color: var(--mint-text);
background: var(--mint-row-alt);
color: var(--mint-text, #1e293b);
background: #fff;
border-color: #cbd5e1;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.worktab-chip.is-active {
z-index: 2;
color: var(--mint-primary-dark);
border-bottom-color: var(--mint-surface);
box-shadow: none;
font-weight: 600;
color: var(--mint-primary-dark, #059669);
background: #fff;
border-color: #6ee7b7;
box-shadow:
0 0 0 1px rgba(16, 185, 129, 0.12),
0 1px 2px rgba(16, 185, 129, 0.1);
}
.worktab-chip.is-active::after {
content: '';
position: absolute;
z-index: 3;
right: 0;
bottom: 1px;
left: 0;
height: 2px;
background: var(--mint-primary);
pointer-events: none;
.worktab-chip.is-home.is-active {
background: var(--mint-primary-bg, #ecfdf5);
border-color: #6ee7b7;
}
.worktab-close {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
margin: 0;
color: #64748b;
border-radius: 999px;
opacity: 1;
transition:
background 0.15s ease,
color 0.15s ease;
}
.worktab-close__icon {
color: #64748b;
}
.worktab-close:hover {
background: var(--mint-row-alt);
color: var(--mint-text, #1e293b);
background: #f1f5f9;
}
.worktab-close:hover .worktab-close__icon {
color: var(--mint-text, #1e293b);
}
.worktab-chip.is-active .worktab-close,
.worktab-chip.is-active .worktab-close__icon {
color: #64748b;
}
.worktab-menu-wrap {
display: inline-flex;
align-items: center;
}
.worktab-menu-btn {
margin-left: 6px;
color: var(--mint-text-muted);
border: 1px solid var(--mint-border);
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: var(--mint-text-muted, #64748b);
background: #fff;
border: 1px solid #dbe3ec;
border-radius: 8px;
background: var(--mint-surface);
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.02);
}
.worktab-menu-btn:hover {
color: var(--mint-text);
background: var(--mint-row-alt);
color: var(--mint-primary-dark, #059669);
background: var(--mint-primary-bg, #ecfdf5);
border-color: #a7f3d0;
}
@media only screen and (width <= 768px) {
.worktab-strip {
padding-right: 0.625rem;
padding-left: 0.625rem;
.worktab-strip--capsule {
padding-right: 12px !important;
padding-left: 12px !important;
}
}
@media only screen and (width <= 640px) {
.worktab-strip {
padding-right: 0.9375rem;
padding-left: 0.9375rem;
.worktab-strip--capsule {
padding-right: 10px !important;
padding-left: 10px !important;
}
}
</style>
@@ -1,7 +1,10 @@
<template>
<div
class="filter-date-shortcuts list-toolbar-date-shortcuts"
:class="{ 'is-select': variant === 'select' }"
:class="{
'is-select': variant === 'select',
'is-pills': variant === 'pills'
}"
role="group"
:aria-label="ariaLabel"
>
@@ -20,6 +23,22 @@
:value="item.type"
/>
</el-select>
<div v-else-if="variant === 'pills'" class="panel-pill-tabs panel-pill-tabs--start list-toolbar-date-pills">
<div class="panel-pill-tabs__track" role="tablist" :aria-label="ariaLabel">
<button
v-for="item in shortcutItems"
:key="item.type"
type="button"
role="tab"
class="panel-pill-tabs__btn"
:class="{ 'is-active': active === item.type }"
:aria-selected="active === item.type"
@click="emit('change', item.type)"
>
{{ item.label }}
</button>
</div>
</div>
<template v-else>
<el-button
v-for="item in shortcutItems"
@@ -42,8 +61,8 @@
defineProps<{
active: DateShortcutType | ''
ariaLabel?: string
/** buttons=五个描边按钮;select=下拉选择(列表工具栏默认) */
variant?: 'buttons' | 'select'
/** buttons=描边按钮;select=下拉;pills=胶囊分段 Tab(列表工具栏推荐) */
variant?: 'buttons' | 'select' | 'pills'
}>(),
{
ariaLabel: '日期快捷筛选',
@@ -87,6 +106,15 @@
margin-right: 8px;
}
.list-toolbar-date-shortcuts.is-pills {
display: inline-flex;
align-items: center;
flex-shrink: 0;
padding-right: 8px;
margin-right: 4px;
border-right: 1px solid var(--mint-border, #d9e8e2);
}
.date-shortcut-select {
width: 112px;
}
@@ -94,4 +122,22 @@
.date-shortcut-select :deep(.el-select__wrapper) {
min-height: 36px;
}
/* 工具栏内胶囊 Tab:紧凑尺寸,左对齐,无面板级外边距 */
.list-toolbar-date-pills.panel-pill-tabs {
margin: 0;
justify-content: flex-start;
}
.list-toolbar-date-pills .panel-pill-tabs__track {
gap: 2px;
padding: 3px;
}
.list-toolbar-date-pills .panel-pill-tabs__btn {
min-width: 0;
height: 30px;
padding: 0 12px;
font-size: 13px;
}
</style>