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

+28
View File
@@ -0,0 +1,28 @@
import request from '@/utils/http'
export interface MenuFavoriteItem {
id: string
path: string
orderNum?: number
createTime?: string
}
export function fetchMenuFavorites() {
return request.get<MenuFavoriteItem[]>({
url: '/web/admin/menu-favorites'
})
}
export function fetchAddMenuFavorite(path: string) {
return request.post<MenuFavoriteItem>({
url: '/web/admin/menu-favorites',
data: { path }
})
}
export function fetchRemoveMenuFavorite(path: string) {
return request.delete<{ path: string }>({
url: '/web/admin/menu-favorites',
data: { path }
})
}
@@ -1,4 +1,4 @@
<!-- 一体化门户顶栏:一级横排 + 大面板菜单(方案 A) -->
<!-- 一体化门户顶栏:品牌 + 用户(业务菜单改由首页启动器进入) -->
<template>
<header class="portal-header">
<div class="portal-header-brand" @click="toHome">
@@ -6,9 +6,7 @@
<p class="portal-header-title">{{ AppConfig.systemInfo.name }}</p>
</div>
<div class="portal-header-nav">
<ArtPortalMegaMenu />
</div>
<div class="portal-header-spacer" />
<div class="portal-header-end">
<div class="portal-header-actions">
@@ -34,7 +32,6 @@
import { useCommon } from '@/hooks/core/useCommon'
import { useHeaderBar } from '@/hooks/core/useHeaderBar'
import ArtUserMenu from '../art-header-bar/widget/ArtUserMenu.vue'
import ArtPortalMegaMenu from '../art-portal-mega-menu/index.vue'
defineOptions({ name: 'ArtPortalHeader' })
@@ -44,7 +41,7 @@
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
const toHome = (): void => {
router.push(homePath.value)
router.push(homePath.value || '/home')
}
const toggleFullScreen = (): void => {
@@ -76,7 +73,7 @@
align-items: center;
max-width: none;
padding-left: var(--mint-content-padding, 24px);
padding-right: 16px;
padding-right: 4px;
cursor: pointer;
}
@@ -94,19 +91,9 @@
white-space: nowrap;
}
.portal-header-nav {
position: absolute;
top: 0;
left: 50%;
z-index: 0;
display: flex;
align-items: stretch;
justify-content: center;
width: max-content;
max-width: min(720px, calc(100% - 520px));
height: 100%;
overflow: hidden;
transform: translateX(-50%);
.portal-header-spacer {
flex: 1 1 auto;
min-width: 0;
}
.portal-header-end {
@@ -115,7 +102,7 @@
flex-shrink: 0;
gap: 8px;
align-items: center;
margin-left: auto;
margin-left: 4px;
}
.portal-header-actions {
@@ -68,6 +68,7 @@
import { getPortalDefaultHomePath, getPortalDefaultPathForRoute } from '@/utils/router/portal-menu'
import { formatMenuTitle } from '@/utils/router'
import { useSettingStore } from '@/store/modules/setting'
import { MenuTypeEnum } from '@/enums/appEnum'
import { MenuItemType } from '../../others/art-menu-right/index.vue'
import { useCommon } from '@/hooks/core/useCommon'
import { WorkTab } from '@/types'
@@ -155,8 +156,9 @@
const menuItems = computed(() => {
const { clickedIndex, currentTab, isLastTab, isOneTab, isCurrentTab } = getClickedTabInfo()
const fixedStatus = checkTabsFixedStatus(clickedIndex)
const isPortal = settingStore.menuType === MenuTypeEnum.PORTAL
return [
const items = [
{
key: 'refresh',
label: t('worktab.btn.refresh'),
@@ -195,6 +197,9 @@
disabled: isOneTab || fixedStatus.areAllTabsFixed
}
]
// 门户:收藏与 Tab「固定」分离,隐藏固定项
return isPortal ? items.filter((item) => item.key !== 'fixed') : items
})
return { menuItems }
@@ -454,14 +459,23 @@
const { clickTab, closeWorktab, showMenu, handleSelect } =
useTabOperations(adjustPositionAfterClose)
const isOverflowing = (): boolean => {
const wrap = scrollRef.value
const ul = tabsRef.value
if (!wrap || !ul) return false
return ul.scrollWidth > wrap.clientWidth + 1
}
// 生命周期
onMounted(() => {
setupEventListeners()
autoPositionTab()
store.registerOverflowChecker(isOverflowing)
})
onUnmounted(() => {
cleanupEventListeners()
store.registerOverflowChecker(null)
})
// 监听器
@@ -482,6 +496,15 @@
})
}
)
watch(
() => store.opened.length,
async () => {
await nextTick()
store.evictUntilNoOverflow()
autoPositionTab()
}
)
</script>
<style scoped>
+54 -1
View File
@@ -52,7 +52,49 @@ export class MenuProcessor {
this.validateMenuPaths(menuList)
// 规范化路径(将相对路径转换为完整路径)
return this.normalizeMenuPaths(this.filterDashboardMenus(menuList))
const normalized = this.normalizeMenuPaths(this.filterDashboardMenus(menuList))
// 门户启动器首页:始终注入(不依赖后端 menuCodes)
return this.ensurePortalHomeRoute(normalized)
}
/** 确保 /home 启动器路由存在于菜单树(供动态注册) */
private ensurePortalHomeRoute(menuList: AppRouteRecord[]): AppRouteRecord[] {
const hasHome = menuList.some(
(m) =>
m.path === '/home-root' ||
m.name === 'PortalHomeRoot' ||
m.children?.some((c) => c.path === '/home' || c.name === 'PortalHome')
)
if (hasHome) {
return menuList
}
const homeRoute: AppRouteRecord = {
id: 'HOME00000000000000000000000000001',
name: 'PortalHomeRoot',
path: '/home-root',
component: '/index/index',
meta: {
title: '首页',
keepAlive: false,
isHide: true
},
children: [
{
id: 'HOME00000000000000000000000000002',
name: 'PortalHome',
path: '/home',
component: '/portal/home/index',
meta: {
title: '首页',
keepAlive: true,
fixedTab: true,
isHide: true
}
}
]
}
return [homeRoute, ...menuList]
}
/**
@@ -174,6 +216,17 @@ export class MenuProcessor {
level = 1
): AppRouteRecord[] {
return menu.reduce((acc: AppRouteRecord[], item) => {
// 门户启动器首页不参与 menuCodes 过滤
if (
item.path === '/home-root' ||
item.path === '/home' ||
item.name === 'PortalHomeRoot' ||
item.name === 'PortalHome'
) {
acc.push({ ...item })
return acc
}
const rawCode = (item.meta as any)?.menuCode ?? item.name
const code = this.normalizeMenuCode(rawCode)
const hasSelfPermission = !!code && codes.has(code)
+24 -6
View File
@@ -43,6 +43,7 @@ import { useUserStore } from '@/store/modules/user'
import { useMenuStore } from '@/store/modules/menu'
import { useDictStore } from '@/store/modules/dict'
import { setWorktab } from '@/utils/navigation'
import { preopenFavoriteTabsIfNeeded } from '@/utils/navigation/preopen-favorites'
import { setPageTitle } from '@/utils/router'
import { RoutesAlias } from '../routesAlias'
import { staticRoutes } from '../routes/staticRoutes'
@@ -328,30 +329,47 @@ async function handleDynamicRoutes(
// 7. 验证工作标签页
useWorktabStore().validateWorktabs(router)
// 7.5 冷登录:按收藏预开 Tab,落点首页
const userStore = useUserStore()
const userId = String(userStore.info?.userId || '')
let didPreopenFavorites = false
try {
didPreopenFavorites = await preopenFavoriteTabsIfNeeded(userId)
} catch (e) {
console.warn('[RouteGuard] 预开收藏菜单失败:', e)
}
const { homePath } = useCommon()
const portalHome = homePath.value || '/home'
// 8. 静态路由不依赖菜单权限,初始化后直接恢复目标地址。
if (isStaticRoute(to.path)) {
routeInitInProgress = false
next({
path: to.path,
query: to.query,
hash: to.hash,
path: didPreopenFavorites ? portalHome : to.path,
query: didPreopenFavorites ? undefined : to.query,
hash: didPreopenFavorites ? undefined : to.hash,
replace: true
})
return
}
// 8. 验证目标路径权限
const { homePath } = useCommon()
const { path: validatedPath, hasPermission } = RoutePermissionValidator.validatePath(
to.path,
menuList,
homePath.value || '/'
portalHome
)
// 初始化成功,重置进行中标记
routeInitInProgress = false
// 9. 重新导航到目标路由
// 9. 重新导航到目标路由(冷登录预开后强制首页)
if (didPreopenFavorites) {
next({ path: portalHome, replace: true })
return
}
if (!hasPermission) {
// 无权限访问,跳转到首页
closeLoading()
+2 -2
View File
@@ -19,5 +19,5 @@ export function initRouter(app: App<Element>): void {
app.use(router)
}
// 主页路径,留空则自动使用菜单第一个有效路径
export const HOME_PAGE_PATH = ''
// 门户菜单启动器首页
export const HOME_PAGE_PATH = '/home'
+162 -113
View File
@@ -5,6 +5,37 @@ import type { AppRouteRecord } from '@/types/router'
* 与后端菜单对齐时请同步 `sys_menu`(bx 见 docs/sql/20260606_bx_menu.sql;db 见 docs/sql/20260607_db_menu.sql)。
*/
export const sqxpAdminRoutes: AppRouteRecord[] = [
// ──────────────────────────────────────────────
// 门户菜单启动器首页(不在业务菜单树中展示)
// ──────────────────────────────────────────────
{
id: 'HOME00000000000000000000000000001',
name: 'PortalHomeRoot',
path: '/home-root',
component: '/index/index',
meta: {
title: '首页',
keepAlive: false,
isHide: true,
roles: ['admin', 'super_admin']
},
children: [
{
id: 'HOME00000000000000000000000000002',
name: 'PortalHome',
path: '/home',
component: '/portal/home/index',
meta: {
title: '首页',
keepAlive: true,
fixedTab: true,
isHide: true,
roles: ['admin', 'super_admin']
}
}
]
},
// ──────────────────────────────────────────────
// 控制台(已废弃,门户布局不展示;路由保留兼容旧链接)
// ──────────────────────────────────────────────
@@ -258,7 +289,12 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
name: 'CrmProductCategory',
path: 'product-category',
component: '/crm/product-category/index',
meta: { title: '产品分类', keepAlive: true, menuCode: 'crm:productCategory:list' }
meta: {
title: '产品分类',
keepAlive: true,
isHide: true,
menuCode: 'crm:productCategory:list'
}
},
{
id: 'CRM00000000000000000000000000003',
@@ -267,92 +303,6 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
component: '/crm/product/index',
meta: { title: '产品管理', keepAlive: true, menuCode: 'crm:product:list' }
},
{
id: 'CRM1000000000000000000000000010',
name: 'CrmClue',
path: 'clue',
component: '/crm/clue/index',
meta: { title: '线索管理', keepAlive: true, menuCode: 'crm:clue:list' }
},
{
id: 'CRM1000000000000000000000000011',
name: 'CrmClueDetail',
path: 'clue/detail/:id',
component: '/crm/clue/detail',
meta: {
title: '线索详情',
keepAlive: false,
isHide: true,
menuCode: 'crm:clue:query'
}
},
{
id: 'CRM1000000000000000000000000012',
name: 'CrmClueStatistics',
path: 'clue/statistics',
component: '/crm/clue/statistics/index',
meta: {
title: '线索统计',
keepAlive: true,
isHide: true,
menuCode: 'crm:clue:statistics'
}
},
{
id: 'CRM2000000000000000000000000010',
name: 'CrmOpportunity',
path: 'opportunity',
component: '/crm/opportunity/index',
meta: { title: '商机管理', keepAlive: true, menuCode: 'crm:opportunity:list' }
},
{
id: 'CRM2000000000000000000000000011',
name: 'CrmOpportunityDetail',
path: 'opportunity/detail/:id',
component: '/crm/opportunity/detail',
meta: {
title: '商机详情',
keepAlive: false,
isHide: true,
menuCode: 'crm:opportunity:query'
}
},
{
id: 'CRM2000000000000000000000000012',
name: 'CrmOpportunityKanban',
path: 'opportunity/kanban',
component: '/crm/opportunity/kanban',
meta: {
title: '商机看板',
keepAlive: true,
isHide: true,
menuCode: 'crm:opportunity:list'
}
},
{
id: 'CRM2000000000000000000000000013',
name: 'CrmOpportunityFunnel',
path: 'opportunity/funnel',
component: '/crm/opportunity/funnel',
meta: {
title: '销售漏斗',
keepAlive: true,
isHide: true,
menuCode: 'crm:opportunity:list'
}
},
{
id: 'CRM2000000000000000000000000014',
name: 'CrmOpportunityStatistics',
path: 'opportunity/statistics',
component: '/crm/opportunity/statistics',
meta: {
title: '商机统计',
keepAlive: true,
isHide: true,
menuCode: 'crm:opportunity:list'
}
},
{
id: 'CRM3000000000000000000000000010',
name: 'CrmQuote',
@@ -368,6 +318,7 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
meta: {
title: '报价单审批人设置',
keepAlive: true,
isHide: true,
menuCode: 'crm:quote:approverSetting'
}
},
@@ -422,6 +373,7 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
meta: {
title: '合同审批人设置',
keepAlive: true,
isHide: true,
menuCode: 'crm:contract:approverSetting'
}
},
@@ -498,32 +450,6 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
path: 'customer',
component: '/crm/customer/index',
meta: { title: '客户管理', keepAlive: true, menuCode: 'crm:customer:list' }
},
{
id: 'CRM1000000000000000000000000003',
name: 'CrmCustomerPool',
path: 'customer-pool',
component: '/crm/customer/pool/index',
meta: { title: '公海池', keepAlive: true, menuCode: 'crm:customerPool:list' }
},
{
id: 'CRM3000000000000000000000000040',
name: 'CrmSupplierHazard',
path: 'supplier-hazard',
component: '/crm/supplier-hazard/index',
meta: { title: '主体危化品能力', keepAlive: true, menuCode: 'crm:supplierHazard:list' }
},
{
id: 'CRM1000000000000000000000000004',
name: 'CrmPoolRule',
path: 'pool-rule',
component: '/crm/customer/pool/RuleIndex',
meta: {
title: '公海池规则',
keepAlive: true,
isHide: true,
menuCode: 'crm:poolRule:list'
}
}
]
},
@@ -767,6 +693,129 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
menuCode: 'system:operlog:list'
}
},
{
id: 'SYS_APPROVER_SETTING',
name: 'SystemApproverSetting',
path: 'approver-setting',
component: '/system/approver-setting/index',
meta: {
title: '报价、合同审批人设置',
keepAlive: true,
menuCode: 'system:approverSetting'
}
},
{
id: 'CRM1000000000000000000000000010',
name: 'CrmClue',
path: 'clue',
component: '/crm/clue/index',
meta: { title: '线索管理', keepAlive: true, menuCode: 'crm:clue:list' }
},
{
id: 'CRM1000000000000000000000000011',
name: 'CrmClueDetail',
path: 'clue/detail/:id',
component: '/crm/clue/detail',
meta: {
title: '线索详情',
keepAlive: false,
isHide: true,
menuCode: 'crm:clue:query'
}
},
{
id: 'CRM1000000000000000000000000012',
name: 'CrmClueStatistics',
path: 'clue/statistics',
component: '/crm/clue/statistics/index',
meta: {
title: '线索统计',
keepAlive: true,
isHide: true,
menuCode: 'crm:clue:statistics'
}
},
{
id: 'CRM2000000000000000000000000010',
name: 'CrmOpportunity',
path: 'opportunity',
component: '/crm/opportunity/index',
meta: { title: '商机管理', keepAlive: true, menuCode: 'crm:opportunity:list' }
},
{
id: 'CRM2000000000000000000000000011',
name: 'CrmOpportunityDetail',
path: 'opportunity/detail/:id',
component: '/crm/opportunity/detail',
meta: {
title: '商机详情',
keepAlive: false,
isHide: true,
menuCode: 'crm:opportunity:query'
}
},
{
id: 'CRM2000000000000000000000000012',
name: 'CrmOpportunityKanban',
path: 'opportunity/kanban',
component: '/crm/opportunity/kanban',
meta: {
title: '商机看板',
keepAlive: true,
isHide: true,
menuCode: 'crm:opportunity:list'
}
},
{
id: 'CRM2000000000000000000000000013',
name: 'CrmOpportunityFunnel',
path: 'opportunity/funnel',
component: '/crm/opportunity/funnel',
meta: {
title: '销售漏斗',
keepAlive: true,
isHide: true,
menuCode: 'crm:opportunity:list'
}
},
{
id: 'CRM2000000000000000000000000014',
name: 'CrmOpportunityStatistics',
path: 'opportunity/statistics',
component: '/crm/opportunity/statistics',
meta: {
title: '商机统计',
keepAlive: true,
isHide: true,
menuCode: 'crm:opportunity:list'
}
},
{
id: 'CRM1000000000000000000000000003',
name: 'CrmCustomerPool',
path: 'customer-pool',
component: '/crm/customer/pool/index',
meta: { title: '公海池', keepAlive: true, menuCode: 'crm:customerPool:list' }
},
{
id: 'CRM1000000000000000000000000004',
name: 'CrmPoolRule',
path: 'pool-rule',
component: '/crm/customer/pool/RuleIndex',
meta: {
title: '公海池规则',
keepAlive: true,
isHide: true,
menuCode: 'crm:poolRule:list'
}
},
{
id: 'CRM3000000000000000000000000040',
name: 'CrmSupplierHazard',
path: 'supplier-hazard',
component: '/crm/supplier-hazard/index',
meta: { title: '主体危化品能力', keepAlive: true, menuCode: 'crm:supplierHazard:list' }
},
{
id: '10000000000000000000000000000008',
name: 'UserDetail',
+5 -1
View File
@@ -55,7 +55,11 @@ export const useMenuStore = defineStore('menuStore', () => {
*/
const setMenuList = (list: AppRouteRecord[]) => {
menuList.value = list
setHomePath(HOME_PAGE_PATH || getPortalDefaultHomePath(list) || getFirstMenuPath(list))
if (HOME_PAGE_PATH) {
setHomePath(HOME_PAGE_PATH)
} else {
setHomePath(getPortalDefaultHomePath(list) || getFirstMenuPath(list))
}
}
/**
@@ -0,0 +1,59 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
fetchAddMenuFavorite,
fetchMenuFavorites,
fetchRemoveMenuFavorite,
type MenuFavoriteItem
} from '@/api/menuFavorite'
const MAX = 10
export const useMenuFavoriteStore = defineStore('menuFavoriteStore', () => {
const items = ref<MenuFavoriteItem[]>([])
const loaded = ref(false)
const paths = computed(() => new Set(items.value.map((i) => i.path)))
const isFavorite = (path: string) => paths.value.has(path)
async function loadFavorites() {
const list = await fetchMenuFavorites()
items.value = Array.isArray(list) ? list : []
loaded.value = true
}
async function removeFavorite(path: string) {
await fetchRemoveMenuFavorite(path)
items.value = items.value.filter((i) => i.path !== path)
}
async function toggleFavorite(path: string) {
if (!path) return
if (isFavorite(path)) {
await removeFavorite(path)
return
}
if (items.value.length >= MAX) {
ElMessage.warning(`最多收藏 ${MAX} 个菜单`)
return
}
const row = await fetchAddMenuFavorite(path)
items.value = [...items.value, row]
}
function clear() {
items.value = []
loaded.value = false
}
return {
items,
loaded,
isFavorite,
loadFavorites,
toggleFavorite,
removeFavorite,
clear
}
})
+7 -1
View File
@@ -17,6 +17,8 @@ import { ref, computed } from 'vue'
import { LanguageEnum } from '@/enums/appEnum'
import { router } from '@/router'
import { useWorktabStore } from './worktab'
import { useMenuFavoriteStore } from './menuFavorite'
import { clearFavoritePreopenFlag } from '@/utils/navigation/preopen-favorites'
import { AppRouteRecord } from '@/types/router'
import { resetRouterState } from '@/router/guards/beforeEach'
import { useMenuStore } from './menu'
@@ -142,6 +144,8 @@ export const useUserStore = defineStore(
// 清空主页路径
useMenuStore().setHomePath('')
useMenuBadgeStore().clearCounts()
useMenuFavoriteStore().clear()
clearFavoritePreopenFlag()
// 重置路由状态
resetRouterState(500)
// 跳转到登录页,携带当前路由作为 redirect 参数
@@ -173,11 +177,13 @@ export const useUserStore = defineStore(
return
}
// 不同用户登录,清空工作台标签页
// 不同用户登录,清空工作台标签页与收藏预开标记
if (String(currentUserId) !== lastUserId) {
const worktabStore = useWorktabStore()
worktabStore.opened = []
worktabStore.keepAliveExclude = []
useMenuFavoriteStore().clear()
clearFavoritePreopenFlag()
}
// 清除临时存储
+51 -8
View File
@@ -40,15 +40,18 @@
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { router } from '@/router'
import { HOME_PAGE_PATH, router } from '@/router'
import { LocationQueryRaw, Router } from 'vue-router'
import { WorkTab } from '@/types'
import { MenuTypeEnum } from '@/enums/appEnum'
import { useCommon } from '@/hooks/core/useCommon'
import { useMenuStore } from '@/store/modules/menu'
import { isWorkbenchRoute } from '@/utils/navigation/route'
import { getPortalDefaultHomePath, getPortalDefaultPathForRoute } from '@/utils/router/portal-menu'
import { useSettingStore } from '@/store/modules/setting'
export type OpenTabOptions = { activate?: boolean }
interface WorktabState {
current: Partial<WorkTab>
opened: WorkTab[]
@@ -115,9 +118,18 @@ export const useWorktabStore = defineStore(
/**
* 标签全部关闭后的默认落点:当前一级模块首个二级菜单(无则回退首页路径)
*/
const isPortalMultiTabMode = (): boolean => {
const setting = useSettingStore()
return setting.menuType === MenuTypeEnum.PORTAL && setting.showWorkTab
}
const resolveEmptyTabsPath = (): string => {
const menuStore = useMenuStore()
const { homePath } = useCommon()
const home = homePath.value || HOME_PAGE_PATH || '/home'
if (isPortalMultiTabMode()) {
return home
}
const menuStore = useMenuStore()
const currentPath = String(
router.currentRoute.value.meta.activePath || router.currentRoute.value.path
)
@@ -129,13 +141,35 @@ export const useWorktabStore = defineStore(
if (fromMenu) {
return fromMenu
}
const home = homePath.value
if (home && !isWorkbenchRoute(home)) {
return home
}
return '/'
}
const overflowCheckHandler = ref<null | (() => boolean)>(null)
const registerOverflowChecker = (fn: (() => boolean) | null) => {
overflowCheckHandler.value = fn
}
const evictUntilNoOverflow = (): void => {
if (!isPortalMultiTabMode()) return
const checker = overflowCheckHandler.value
if (!checker) return
let guard = 0
while (checker() && guard++ < 50) {
if (!evictOldestClosableTab()) break
}
if (current.value.path && findTabIndex(String(current.value.path)) === -1) {
const fallback = opened.value[opened.value.length - 1]
if (fallback?.path) {
current.value = fallback
safeRouterPush(fallback)
}
}
}
/**
* 安全的路由跳转
*/
@@ -181,15 +215,18 @@ export const useWorktabStore = defineStore(
/**
* 打开或激活一个选项卡
* @param options.activate 默认 true;登录预开收藏时传 false,避免切走当前页
*/
const openTab = (tab: WorkTab): void => {
const openTab = (tab: WorkTab, options: OpenTabOptions = {}): void => {
if (!tab.path) {
console.warn('尝试打开无效的标签页')
return
}
if (shouldIgnoreTab(tab)) return
const activate = options.activate !== false
const singlePage = isSinglePageMode()
const portalMulti = isPortalMultiTabMode()
// 从 keepAlive 排除列表中移除
if (tab.name) {
@@ -215,8 +252,8 @@ export const useWorktabStore = defineStore(
return
}
// 新增标签页:超出上限时淘汰最早的可关闭标签
if (!ensureCapacityForNewTab()) {
// 非门户:保留固定数量上限;门户改由 DOM 溢出淘汰
if (!portalMulti && !ensureCapacityForNewTab()) {
return
}
@@ -228,7 +265,9 @@ export const useWorktabStore = defineStore(
opened.value.push(newTab)
}
current.value = newTab
if (activate || !current.value.path) {
current.value = newTab
}
} else {
// 更新现有标签页(当动态路由参数或查询变更时,复用同一标签)
const existingTab = opened.value[existingIndex]
@@ -245,7 +284,9 @@ export const useWorktabStore = defineStore(
icon: tab.icon || existingTab.icon
}
current.value = opened.value[existingIndex]
if (activate) {
current.value = opened.value[existingIndex]
}
if (singlePage) {
enforceSinglePageTabs()
@@ -673,6 +714,8 @@ export const useWorktabStore = defineStore(
toggleFixedTab,
validateWorktabs,
enforceSinglePageTabs,
registerOverflowChecker,
evictUntilNoOverflow,
clearAll,
getStateSnapshot,
@@ -0,0 +1,80 @@
import { HOME_PAGE_PATH, router } from '@/router'
import { useMenuFavoriteStore } from '@/store/modules/menuFavorite'
import { useWorktabStore } from '@/store/modules/worktab'
import { useMenuStore } from '@/store/modules/menu'
import { collectPortalHomeSections, findHomeCardByPath } from '@/utils/router/portal-home-cards'
const PREOPEN_FLAG_KEY = 'portal-favorites-preopened-user'
/**
* 冷登录:按收藏预开 Tab,当前仍停在首页。
* 同用户刷新且已有业务 Tab 时跳过(依赖 worktab 本地持久化)。
*/
/** @returns 是否执行了冷登录预开(调用方应落点首页) */
export async function preopenFavoriteTabsIfNeeded(userId: string): Promise<boolean> {
if (!userId) return false
const home = HOME_PAGE_PATH || '/home'
const worktab = useWorktabStore()
const hasBusinessTabs = worktab.opened.some(
(t) => t.path && t.path !== home && !t.fixedTab
)
// 刷新恢复:已有业务 Tab 则不再按收藏重开
if (hasBusinessTabs) {
sessionStorage.setItem(PREOPEN_FLAG_KEY, userId)
return false
}
// 同会话已预开过(仅首页等)则跳过
if (sessionStorage.getItem(PREOPEN_FLAG_KEY) === userId) {
return false
}
const favStore = useMenuFavoriteStore()
await favStore.loadFavorites()
const menuStore = useMenuStore()
const sections = collectPortalHomeSections(menuStore.menuList || [])
worktab.openTab(
{
path: home,
title: '首页',
name: 'PortalHome',
fixedTab: true,
keepAlive: true
},
{ activate: true }
)
for (const item of [...favStore.items]) {
const card = findHomeCardByPath(sections, item.path)
if (!card) {
try {
await favStore.removeFavorite(item.path)
} catch {
// 忽略清理失败,继续预开其余项
}
continue
}
const resolved = router.resolve(card.path)
if (!resolved.matched.length) {
continue
}
worktab.openTab(
{
path: card.path,
title: card.title,
name: String(resolved.name || ''),
fixedTab: false,
keepAlive: true
},
{ activate: false }
)
}
sessionStorage.setItem(PREOPEN_FLAG_KEY, userId)
return true
}
export function clearFavoritePreopenFlag(): void {
sessionStorage.removeItem(PREOPEN_FLAG_KEY)
}
@@ -0,0 +1,85 @@
import type { AppRouteRecord } from '@/types/router'
import {
filterPortalTopModules,
resolveMegaPanelL3Links
} from '@/utils/router/portal-menu'
import {
filterSidebarRoutes,
isSidebarNavigableRoute
} from '@/utils/router/sidebar-menu-filter'
export interface PortalHomeCardItem {
path: string
title: string
icon?: string
}
export interface PortalHomeSection {
/** 一级模块 path */
modulePath: string
moduleTitle: string
cards: PortalHomeCardItem[]
}
function routeTitle(item: AppRouteRecord): string {
return String(item.meta?.title ?? item.name ?? item.path ?? '')
}
function toCard(item: AppRouteRecord): PortalHomeCardItem | null {
const path = String(item.meta?.activePath || item.path || '')
if (!path) return null
return {
path,
title: routeTitle(item),
icon: item.meta?.icon ? String(item.meta.icon) : undefined
}
}
/** 从一级模块收集可点卡片(跳过不可导航二级标题) */
export function collectCardsForModule(module: AppRouteRecord): PortalHomeCardItem[] {
const l2List = filterSidebarRoutes(module.children || [])
const cards: PortalHomeCardItem[] = []
const seen = new Set<string>()
const push = (item: AppRouteRecord) => {
const card = toCard(item)
if (!card || seen.has(card.path)) return
seen.add(card.path)
cards.push(card)
}
for (const l2 of l2List) {
const links = resolveMegaPanelL3Links(l2)
for (const link of links) {
push(link)
}
const hasKids = filterSidebarRoutes(l2.children || []).length > 0
if (hasKids && isSidebarNavigableRoute(l2)) {
push(l2)
}
}
return cards
}
export function collectPortalHomeSections(menuList: AppRouteRecord[]): PortalHomeSection[] {
const tops = filterPortalTopModules(menuList)
return tops
.map((mod) => ({
modulePath: String(mod.path || ''),
moduleTitle: routeTitle(mod),
cards: collectCardsForModule(mod)
}))
.filter((s) => s.cards.length > 0)
}
/** 在全部分区中按 path 查找卡片元数据 */
export function findHomeCardByPath(
sections: PortalHomeSection[],
path: string
): PortalHomeCardItem | undefined {
for (const s of sections) {
const hit = s.cards.find((c) => c.path === path)
if (hit) return hit
}
return undefined
}
+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>