feat: add portal home launcher, favorites UI, and WorkTab overflow eviction

Replace top-bar module navigation with a card home page, persist favorites via API, reopen favorite tabs on cold login, and enable portal multi-tabs with overflow-based eviction.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-08 12:06:49 +08:00
1 parent 7898d65437
commit 2df2ef0808
17 files changed
+2242 -157

No files matched your search

+19 -3
View File
@@ -3,6 +3,7 @@
<div v-if="isPortalLayout" class="app-layout app-layout-portal" :style="appLayoutStyle">
<div id="app-portal-header">
<ArtPortalHeader />
<ArtWorkTab />
</div>
<div id="app-portal-body">
@@ -42,13 +43,16 @@
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { MenuTypeEnum, MenuWidth } from '@/enums/appEnum'
import { HOME_PAGE_PATH } from '@/router'
import { useSettingStore } from '@/store/modules/setting'
import { useWorktabStore } from '@/store/modules/worktab'
import { useCommon } from '@/hooks/core/useCommon'
defineOptions({ name: 'AppLayout' })
const settingStore = useSettingStore()
const worktabStore = useWorktabStore()
const { homePath } = useCommon()
const { menuType, getMenuOpenWidth } = storeToRefs(settingStore)
const isPortalLayout = computed(() => menuType.value === MenuTypeEnum.PORTAL)
@@ -68,9 +72,21 @@
if (menuType.value !== MenuTypeEnum.PORTAL) {
settingStore.switchMenuLayouts(MenuTypeEnum.PORTAL)
}
// 门户布局强制严格单页(忽略 localStorage 中历史 showWorkTab=true)
settingStore.setWorkTab(false)
worktabStore.enforceSinglePageTabs()
// 门户:开启多页签;首页固定 Tab
settingStore.setWorkTab(true)
const home = homePath.value || HOME_PAGE_PATH || '/home'
if (home && !worktabStore.opened.some((t) => t.path === home)) {
worktabStore.openTab(
{
path: home,
title: '首页',
name: 'PortalHome',
fixedTab: true,
keepAlive: true
},
{ activate: false }
)
}
})
</script>
+167
View File
@@ -0,0 +1,167 @@
<template>
<div class="portal-home">
<section v-if="favoriteCards.length" class="portal-home-section">
<h2 class="title-bar-heading">收藏</h2>
<div class="portal-home-grid">
<button
v-for="card in favoriteCards"
:key="'fav-' + card.path"
type="button"
class="portal-home-card"
@click="openCard(card.path)"
>
<span class="portal-home-card__title">{{ card.title }}</span>
<button
type="button"
class="portal-home-card__star is-on"
aria-label="取消收藏"
@click.stop="onToggleStar(card.path)"
>
<ElIcon><StarFilled /></ElIcon>
</button>
</button>
</div>
</section>
<section v-for="sec in sections" :key="sec.modulePath" class="portal-home-section">
<h2 class="title-bar-heading">{{ sec.moduleTitle }}</h2>
<div class="portal-home-grid">
<button
v-for="card in sec.cards"
:key="card.path"
type="button"
class="portal-home-card"
@click="openCard(card.path)"
>
<span class="portal-home-card__title">{{ card.title }}</span>
<button
type="button"
class="portal-home-card__star"
:class="{ 'is-on': favStore.isFavorite(card.path) }"
:aria-label="favStore.isFavorite(card.path) ? '取消收藏' : '收藏'"
@click.stop="onToggleStar(card.path)"
>
<ElIcon>
<StarFilled v-if="favStore.isFavorite(card.path)" />
<Star v-else />
</ElIcon>
</button>
</button>
</div>
</section>
<p v-if="!sections.length" class="portal-home-empty">暂无可用菜单</p>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { Star, StarFilled } from '@element-plus/icons-vue'
import { useMenuStore } from '@/store/modules/menu'
import { useMenuFavoriteStore } from '@/store/modules/menuFavorite'
import {
collectPortalHomeSections,
findHomeCardByPath
} from '@/utils/router/portal-home-cards'
defineOptions({ name: 'PortalHome' })
const router = useRouter()
const menuStore = useMenuStore()
const favStore = useMenuFavoriteStore()
const { menuList } = storeToRefs(menuStore)
const sections = computed(() => collectPortalHomeSections(menuList.value || []))
const favoriteCards = computed(() =>
favStore.items.map(
(i) => findHomeCardByPath(sections.value, i.path) || { path: i.path, title: i.path }
)
)
onMounted(async () => {
if (!favStore.loaded) {
await favStore.loadFavorites()
}
})
const openCard = (path: string) => {
router.push(path)
}
const onToggleStar = async (path: string) => {
await favStore.toggleFavorite(path)
}
</script>
<style scoped lang="scss">
.portal-home {
padding: 0 0 24px;
}
.portal-home-section {
margin-bottom: 28px;
}
.portal-home-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
margin-top: 12px;
}
.portal-home-card {
position: relative;
display: flex;
align-items: center;
min-height: 72px;
padding: 12px 36px 12px 14px;
text-align: left;
cursor: pointer;
background: #fff;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 10px;
transition:
background 0.15s ease,
border-color 0.15s ease;
&:hover {
background: var(--mint-primary-bg, #ecfdf5);
border-color: #a7f3d0;
}
}
.portal-home-card__title {
font-size: 14px;
font-weight: 500;
color: var(--mint-text, #1e293b);
}
.portal-home-card__star {
position: absolute;
top: 8px;
right: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
font-size: 16px;
line-height: 1;
color: var(--mint-text-muted, #94a3b8);
cursor: pointer;
background: transparent;
border: 0;
&.is-on {
color: #d97706;
}
}
.portal-home-empty {
margin-top: 48px;
color: var(--mint-text-muted, #94a3b8);
text-align: center;
}
</style>