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

+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',