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:
1 parent
7898d65437
commit
2df2ef0808
17 files changed
+2242
-157
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,251 @@
|
||||
# 门户菜单首页启动器 + 多页签(方案 1)
|
||||
|
||||
## 背景与目标
|
||||
|
||||
当前门户(`MenuTypeEnum.PORTAL`)将业务菜单放在顶栏(一级下拉 + 二级平铺/三级下拉),横向空间不足。本设计将导航改为:
|
||||
|
||||
1. **菜单首页**:按一级分区的可点卡片启动器,顶部为收藏区。
|
||||
2. **WorkTab 多页签**:打开的业务页以 Tab 管理;首页 Tab 固定不可关。
|
||||
3. **瘦顶栏**:不再展示二/三级业务菜单。
|
||||
|
||||
权威视觉仍遵循 Fresh Mint(`docs/web-admin-ui-spec-fresh-mint.md`)。本设计**取代**门户顶栏平铺/大面板作为主导航权威;`docs/portal-l1-select-flat-nav-demo.html`、`2026-08-01-portal-mega-menu-design.md` 不再作为目标交互。
|
||||
|
||||
## 已确认决策
|
||||
|
||||
| 项 | 决策 |
|
||||
|----|------|
|
||||
| 业务页顶栏菜单 | 不保留二/三级(方案 A) |
|
||||
| Tab 淘汰 | Tab 栏出现横向滚动后再开新页时,静默关掉最早打开的可关 Tab |
|
||||
| 未保存内容 | 淘汰/关闭时静默丢弃(与现 KeepAlive 一致) |
|
||||
| 首页辅助区 | 收藏菜单(非最近访问),置顶,上限 **10** |
|
||||
| 收藏操作 | 仅首页卡片星标收藏/取消 |
|
||||
| 登录预开 | 按收藏自动打开对应 Tab,**落点仍为首页** |
|
||||
| 总体方案 | 方案 1:瘦顶栏 + 首页启动器 + 重开 WorkTab + 后端持久化收藏 |
|
||||
|
||||
---
|
||||
|
||||
## §1 整体布局与导航收敛
|
||||
|
||||
### 壳层
|
||||
|
||||
```
|
||||
┌─ 顶栏 56px:Logo(→ 首页) 全屏(可选) | 用户 ─┐
|
||||
├─ WorkTab:🏠首页(固定) | 业务… × [▾ 批量关闭] ─┤
|
||||
└─ 内容区:菜单首页 / 业务页(keep-alive)──────────────────┘
|
||||
```
|
||||
|
||||
### SHALL
|
||||
|
||||
1. 默认布局仍为 `PORTAL`,导航模式改为「启动器 + 多页签」。
|
||||
2. 顶栏去掉 `ArtPortalModuleSelect`、`ArtPortalMegaMenu`(及仅服务于顶栏导航的移动端等价入口)。
|
||||
3. 顶栏保留:品牌 Logo、系统名(视宽度)、用户区;Logo 点击进入菜单首页(不关闭其它 Tab)。
|
||||
4. 强制开启 WorkTab;撤销门户下 `setWorkTab(false)` 与 `enforceSinglePageTabs()`。
|
||||
5. 菜单首页为新固定路由,作为门户 `homePath`;对应 Tab `fixedTab=true`。
|
||||
6. 业务页不再提供二/三级顶栏导航;跨功能仅通过回首页点卡片或切换已开 Tab。
|
||||
|
||||
### 与现状差异
|
||||
|
||||
| 项 | 现状 | 本设计 |
|
||||
|----|------|--------|
|
||||
| 顶栏菜单 | 一级下拉 + 二级平铺 | 无业务菜单 |
|
||||
| WorkTab | 门户强制关闭 | 强制开启 |
|
||||
| 默认首页 | 某模块首个业务页 | 菜单启动器页 |
|
||||
| 关全部后落点 | 模块首个二级 | 菜单首页 |
|
||||
|
||||
### 非目标(§1)
|
||||
|
||||
- 不改小程序导航。
|
||||
- 不恢复左侧栏为默认布局。
|
||||
- Demo 工作台(`/#/demo/console`)可保留,但不作为门户登录落点。
|
||||
|
||||
---
|
||||
|
||||
## §2 菜单首页(卡片拍平 + 收藏区)
|
||||
|
||||
### 页面结构
|
||||
|
||||
自上而下:
|
||||
|
||||
1. **收藏**(有数据才展示整块;空则隐藏):最多 10 张已收藏卡片。
|
||||
2. **按一级模块分区**:一级标题(不可点)+ 其下可点菜单卡片网格。
|
||||
|
||||
一级模块顺序:`PORTAL_TOP_MODULE_ORDER`(`filterPortalTopModules`)。
|
||||
|
||||
### 卡片拍平规则
|
||||
|
||||
数据源:当前用户权限菜单树。对每个一级下的二级节点:
|
||||
|
||||
| 情况 | 展示 |
|
||||
|------|------|
|
||||
| 二级不可导航且有可点子级 | **不展示二级**;其下可导航叶子直接作为卡片 |
|
||||
| 二级可导航且无子级 | 一张二级卡片 |
|
||||
| 二级可导航且仍有子级 | 各可导航叶子一张卡;若二级本身也可导航则额外一张二级卡 |
|
||||
| `isHide` / 无权限 | 不出现 |
|
||||
|
||||
实现复用并抽出与 `resolveMegaPanelL3Links`(`web-admin/src/utils/router/portal-menu.ts`)同语义的叶子收集。
|
||||
|
||||
卡片字段:标题、可选 `meta.icon`、路由 path、是否已收藏。
|
||||
|
||||
### 交互
|
||||
|
||||
1. 点击卡片主体 → 打开或激活对应 WorkTab。
|
||||
2. 点击星标 → 收藏/取消(`stopPropagation`);上限 10,超出 `ElMessage.warning`。
|
||||
3. 收藏顺序:按收藏时间升序;取消再藏视为新时间排到末尾。
|
||||
4. 同一菜单可同时出现在收藏区与原分区,星标状态同步。
|
||||
5. 无任何可点叶子:空态「暂无可用菜单」。
|
||||
|
||||
### 视觉
|
||||
|
||||
- Fresh Mint;一级标题使用 `h2.title-bar-heading`(必要时在 `App.vue` `panelTitleBarTextRules` 注册)。
|
||||
- 卡片:约 10px 圆角、浅边框、hover 薄荷绿底;星标区分已藏/未藏。
|
||||
- **不做**统计、图表、仪表盘指标——首页只做启动器。
|
||||
|
||||
### 路由
|
||||
|
||||
- 建议 path:`/home`,名称如 `PortalHome`,组件如 `views/portal/home/index.vue`。
|
||||
- 设为门户 `homePath`;登录成功与关全部 Tab 后均落此页。
|
||||
- **不**在业务菜单树中再挂「首页」菜单项(由布局固定注入 Tab)。
|
||||
|
||||
---
|
||||
|
||||
## §3 WorkTab、溢出淘汰与登录预开
|
||||
|
||||
### WorkTab
|
||||
|
||||
1. 门户开启 `showWorkTab`。
|
||||
2. 首页 Tab 固定:无关闭按钮;关闭全部/其他后仍保留并回首页。
|
||||
3. 保留:单关、右键关左/右/其他/全部、同 path 复用。
|
||||
4. 门户下 **隐藏 Tab「固定」**,避免与「收藏」概念混淆。
|
||||
5. KeepAlive:关页/淘汰时 exclude;静默丢缓存。
|
||||
|
||||
### 溢出淘汰(替换门户硬上限 10)
|
||||
|
||||
触发:准备 **新开** 可关 Tab 时(激活已有 Tab 不触发)。
|
||||
|
||||
1. 加入 `opened` 后测量:若 Tab 条 `scrollWidth > clientWidth`(出现横向滚动)。
|
||||
2. `while` 溢出:`evictOldestClosableTab`(`opened` 中最靠前的非 `fixedTab`)。
|
||||
3. 若仅剩固定 Tab 仍溢出:停止,允许极端窄屏轻微溢出。
|
||||
4. 窗口变窄导致溢出时 **不主动踢页**,仅在新开 Tab 时淘汰。
|
||||
5. 淘汰静默,不确认、不 toast。
|
||||
6. 门户路径下不再使用 `MAX_OPEN_TABS=10` 作为硬上限。
|
||||
|
||||
「一次性关闭」:WorkTab 提供关闭全部(保留首页)。
|
||||
|
||||
### 登录后自动打开收藏
|
||||
|
||||
```
|
||||
登录成功 → 菜单权限就绪 → GET 收藏列表(≤10,有序)
|
||||
→ 固定打开首页 Tab,路由落在首页
|
||||
→ 按收藏顺序依次 openTab(不切换 current)
|
||||
→ 每开一个后执行溢出淘汰(窄屏可能开不全,预期内)
|
||||
→ 最终 current / 路由停在首页
|
||||
```
|
||||
|
||||
补充:
|
||||
|
||||
| 项 | 约定 |
|
||||
|----|------|
|
||||
| path 无权限或路由无效 | 跳过,并可从收藏软删 |
|
||||
| 同用户刷新 | 优先恢复 WorkTab 本地 `opened`,**不再**按收藏全量重开 |
|
||||
| 冷登录 / 换用户 | 按收藏预开 |
|
||||
| 退出登录 | 清空会话 Tab;收藏留在服务端 |
|
||||
| 取消收藏 | 不自动关闭已打开的对应 Tab |
|
||||
|
||||
星标写入收藏成功后 **不**自动开 Tab;仅登录预开与用户点击卡片/其它导航会开 Tab。
|
||||
|
||||
---
|
||||
|
||||
## §4 收藏持久化与改造范围
|
||||
|
||||
### 表 `sys_user_menu_favorite`
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `id` | VARCHAR(64) UUID PK |
|
||||
| `user_id` | 当前用户 |
|
||||
| `menu_path` | 路由 path |
|
||||
| `order_num` | 顺序(越小越靠前;也可用 `create_time`) |
|
||||
| 公共字段 | `tenant_no`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` |
|
||||
|
||||
- 默认 `del_flag=0`;按 `tenant_no` + 当前用户隔离。
|
||||
- 有效收藏唯一:`(tenant_no, user_id, menu_path)`(软删后可再藏)。
|
||||
- 上限 10:服务端写入前校验。
|
||||
- DDL:`docs/sql/migrations/` 增量 + 基线评估。
|
||||
|
||||
### API(示意,均需鉴权)
|
||||
|
||||
| 方法 | 路径 | 行为 |
|
||||
|------|------|------|
|
||||
| GET | `/web/admin/menu-favorites` | 当前用户有序收藏列表 |
|
||||
| POST | `/web/admin/menu-favorites` | `{ path }`;已存在幂等;超 10 拒绝 |
|
||||
| DELETE | `/web/admin/menu-favorites` | 按 `path` 软删 |
|
||||
|
||||
- 不信任客户端 `user_id` / `tenant_no`。
|
||||
- 写入宜校验 path 仍在用户菜单权限内。
|
||||
- 标题由前端菜单树解析,不必强依赖接口回填。
|
||||
|
||||
路由注册:`backend/app/api/__init__.py` 与 `app/main.py`。
|
||||
|
||||
### 前端模块
|
||||
|
||||
| 模块 | 职责 |
|
||||
|------|------|
|
||||
| `api/.../menuFavorite.ts` | 三个接口 |
|
||||
| Pinia `menuFavorite` | 列表、toggle、上限提示 |
|
||||
| `views/portal/home` | 收藏区 + 分区卡片 |
|
||||
| 登录/路由初始化 | 拉收藏并预开 Tab |
|
||||
| `worktab` | 溢出淘汰;取消门户单页强制 |
|
||||
| `ArtPortalHeader` | 瘦身 |
|
||||
| `views/index` | 开 WorkTab;`homePath` → `/home` |
|
||||
|
||||
### 必做清单
|
||||
|
||||
1. 后端表 + model/crud/schema + API。
|
||||
2. 前端首页、收藏 store/API、星标 UI。
|
||||
3. 门户瘦顶栏 + 开 WorkTab + 固定首页 Tab。
|
||||
4. 溢出淘汰;关全部回首页。
|
||||
5. 登录预开收藏,落点首页。
|
||||
6. SQL migration;`homePath` / 权限放行 `/home`。
|
||||
7. 视需要更新 OpenSpec `ui-conventions` / 工程说明中门户导航描述。
|
||||
|
||||
### 可停用(门户引用)
|
||||
|
||||
- 头栏对 `ArtPortalModuleSelect`、`ArtPortalMegaMenu` 等的引用(组件文件可暂留)。
|
||||
|
||||
### 非目标
|
||||
|
||||
- 小程序、默认改回左侧菜单布局。
|
||||
- 首页塞工作台图表。
|
||||
- Tab 右键收藏、最近访问。
|
||||
|
||||
### 风险
|
||||
|
||||
| 风险 | 处理 |
|
||||
|------|------|
|
||||
| 窄屏收藏 10 个开不全 | 接受;溢出静默淘汰 |
|
||||
| 菜单 path 变更 | 预开失败则剔收藏 |
|
||||
| worktab 本地持久化 vs 收藏预开 | 冷登录预开;刷新恢复 opened |
|
||||
| 顶栏去掉后找入口 | 固定首页 Tab + Logo 回首页 |
|
||||
|
||||
### 验收
|
||||
|
||||
1. 登录落首页;顶栏无二/三级业务菜单。
|
||||
2. 卡片仅为可点叶子;不可点二级不展示。
|
||||
3. 星标收藏 ≤10;同账号跨浏览器(服务端)仍在。
|
||||
4. 再登录:收藏对应 Tab 已开,当前在首页。
|
||||
5. Tab 可单关/全关;全关后仅剩首页。
|
||||
6. Tab 条出现横向滚动后再开新页,最早可关 Tab 被静默关闭。
|
||||
|
||||
---
|
||||
|
||||
## 界面模式
|
||||
|
||||
- 菜单首页为新型启动器页,可对照工作台轻量入口骨架,但内容仅为卡片分区(非 P01 标准业务列表)。
|
||||
- 业务列表页本身不因本变更改模式编号。
|
||||
|
||||
## 实现注意
|
||||
|
||||
- 最小必要改动;门户路径收敛导航,避免无关重构。
|
||||
- 字段一致性:若新增实体,CRUD/`_row_to_dict`/前端按 `docs/dev-field-consistency.md` 对齐。
|
||||
- 完成后按仓库约定更新 graphify(backend / web-admin 各自改动范围)。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
// 清除临时存储
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user