Kaynağa Gözat

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>
zlwu 2 ay önce
ebeveyn
işleme
2df2ef0808

+ 1216 - 0
docs/superpowers/plans/2026-08-08-portal-home-launcher-tabs.md

@@ -0,0 +1,1216 @@
+# 门户菜单首页启动器 + 多页签 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 将 web-admin 门户主导航从顶栏二/三级菜单改为「菜单首页卡片启动器 + WorkTab 多页签」,并支持后端持久化的菜单收藏(≤10)及登录预开。
+
+**Architecture:** 后端新增 `sys_user_menu_favorite` + `/web/admin/menu-favorites` API;前端新增 `/home` 启动器页与 `menuFavorite` store;门户瘦顶栏并强制开启 WorkTab;Tab 溢出用 DOM 测量淘汰最早可关页(替代固定上限);冷登录按收藏预开 Tab 且落点首页。
+
+**Tech Stack:** FastAPI + SQLAlchemy + MySQL;Vue 3 + Pinia + vue-router(hash)+ Element Plus;Fresh Mint UI。
+
+**Spec:** `docs/superpowers/specs/2026-08-08-portal-home-launcher-tabs-design.md`
+
+---
+
+## File map
+
+| Path | Responsibility |
+|------|----------------|
+| `docs/sql/migrations/2026-08-08-sys-user-menu-favorite.sql` | DDL |
+| `backend/app/models/models.py` | `SysUserMenuFavorite` |
+| `backend/app/schemas/menu_favorite.py` | Pydantic bodies |
+| `backend/app/crud/menu_favorite.py` | list / add / soft-delete / cap 10 |
+| `backend/app/api/admin.py`(或独立 `menu_favorite.py` 挂到 admin) | HTTP 路由 |
+| `backend/tests/test_menu_favorite.py` | API/CRUD 测试(可测纯函数 + 可选 DB) |
+| `web-admin/src/utils/router/portal-home-cards.ts` | 一级分区 + 叶子卡片收集 |
+| `web-admin/src/api/menuFavorite.ts` | 前端 API |
+| `web-admin/src/store/modules/menuFavorite.ts` | 收藏状态 |
+| `web-admin/src/views/portal/home/index.vue` | 菜单首页 UI |
+| `web-admin/src/router/routes/staticRoutes.ts` | 注册 `/home`(Layout 子路由) |
+| `web-admin/src/router/index.ts` | `HOME_PAGE_PATH = '/home'` |
+| `web-admin/src/store/modules/menu.ts` | homePath 以常量优先 |
+| `web-admin/src/views/index/index.vue` | 开 WorkTab,取消单页强制 |
+| `web-admin/src/components/core/layouts/art-portal-header/index.vue` | 瘦顶栏 |
+| `web-admin/src/store/modules/worktab.ts` | `openTab` activate 选项;溢出淘汰;门户不用 MAX_OPEN_TABS |
+| `web-admin/src/components/core/layouts/art-work-tab/index.vue` | 测量溢出并触发淘汰;门户隐藏「固定」 |
+| `web-admin/src/router/guards/beforeEach.ts`(或菜单就绪后钩子) | 冷登录预开收藏 |
+| `web-admin/src/App.vue` | 如需注册「收藏」「首页」竖条标题 |
+
+---
+
+### Task 1: DDL + SQLAlchemy model
+
+**Files:**
+- Create: `docs/sql/migrations/2026-08-08-sys-user-menu-favorite.sql`
+- Modify: `backend/app/models/models.py`(在 `SysUserPost` 附近追加模型)
+
+- [ ] **Step 1: 写 migration SQL**
+
+```sql
+-- 用户菜单收藏(门户首页启动器)
+-- 幂等:表已存在则跳过
+
+CREATE TABLE IF NOT EXISTS `sys_user_menu_favorite` (
+  `id` varchar(64) NOT NULL COMMENT '主键ID',
+  `user_id` varchar(64) NOT NULL COMMENT '用户ID',
+  `menu_path` varchar(512) NOT NULL COMMENT '菜单路由 path',
+  `tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
+  `order_num` int(11) DEFAULT NULL COMMENT '排序号(越小越靠前)',
+  `remark` varchar(512) DEFAULT NULL COMMENT '备注',
+  `create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
+  `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
+  `create_time` datetime NOT NULL COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
+  `update_time` datetime NOT NULL COMMENT '更新时间',
+  `del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标记 0正常 1已删除',
+  PRIMARY KEY (`id`),
+  KEY `idx_fav_user_tenant` (`user_id`, `tenant_no`, `del_flag`),
+  KEY `idx_fav_path` (`menu_path`(191))
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户菜单收藏';
+```
+
+- [ ] **Step 2: 追加 model**
+
+在 `backend/app/models/models.py` 增加(字段顺序:id → 业务 → 公共字段):
+
+```python
+class SysUserMenuFavorite(Base):
+    """用户菜单收藏 sys_user_menu_favorite"""
+
+    __tablename__ = "sys_user_menu_favorite"
+
+    id = Column(String(64), primary_key=True, comment="主键ID")
+    user_id = Column(String(64), nullable=False, comment="用户ID")
+    menu_path = Column(String(512), nullable=False, comment="菜单路由 path")
+    tenant_no = Column(String(64), nullable=False, default="shanxia2.0", comment="租户编号")
+    order_num = Column(Integer, nullable=True, comment="排序号")
+    remark = Column(String(512), nullable=True, comment="备注")
+    create_by = Column(String(64), nullable=True, comment="创建人id")
+    create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
+    create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
+    update_by = Column(String(64), nullable=True, comment="更新人id")
+    update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
+    del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
+```
+
+- [ ] **Step 3: 本地执行 migration**(开发库)
+
+Run: 用现有 DB 客户端或 `mysql ... < docs/sql/migrations/2026-08-08-sys-user-menu-favorite.sql`  
+Expected: 表创建成功或已存在。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docs/sql/migrations/2026-08-08-sys-user-menu-favorite.sql backend/app/models/models.py
+git commit -m "$(cat <<'EOF'
+feat: add sys_user_menu_favorite table for portal menu favorites
+
+EOF
+)"
+```
+
+---
+
+### Task 2: Backend CRUD + API
+
+**Files:**
+- Create: `backend/app/schemas/menu_favorite.py`
+- Create: `backend/app/crud/menu_favorite.py`
+- Modify: `backend/app/api/admin.py`(文件末尾追加三个端点;`admin` router 已挂 `/web/admin`)
+- Create: `backend/tests/test_menu_favorite_crud.py`
+
+- [ ] **Step 1: Schema**
+
+```python
+# backend/app/schemas/menu_favorite.py
+from pydantic import BaseModel, Field
+
+
+class MenuFavoriteCreateBody(BaseModel):
+    path: str = Field(..., min_length=1, max_length=512)
+
+
+class MenuFavoriteDeleteBody(BaseModel):
+    path: str = Field(..., min_length=1, max_length=512)
+```
+
+- [ ] **Step 2: 写失败单测(上限与 path 规范化)**
+
+```python
+# backend/tests/test_menu_favorite_crud.py
+from app.crud.menu_favorite import normalize_menu_path, MAX_FAVORITES
+
+
+def test_normalize_menu_path_strips_and_requires_slash():
+    assert normalize_menu_path(" /crm/customer ") == "/crm/customer"
+    assert normalize_menu_path("crm/customer") == "/crm/customer"
+
+
+def test_max_favorites_constant():
+    assert MAX_FAVORITES == 10
+```
+
+- [ ] **Step 3: Run 确认失败(模块不存在)**
+
+Run: `cd backend && source venv/bin/activate && pytest tests/test_menu_favorite_crud.py -v`  
+Expected: FAIL import error
+
+- [ ] **Step 4: 实现 crud**
+
+```python
+# backend/app/crud/menu_favorite.py
+"""用户菜单收藏 CRUD"""
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+from typing import Any, Dict, List, Optional
+
+from sqlalchemy.orm import Session
+
+from app.models.models import SysUserMenuFavorite
+
+MAX_FAVORITES = 10
+
+
+def normalize_menu_path(path: str) -> str:
+    p = (path or "").strip()
+    if not p:
+        return ""
+    if not p.startswith("/"):
+        p = "/" + p
+    return p
+
+
+def _row_to_dict(row: SysUserMenuFavorite) -> Dict[str, Any]:
+    return {
+        "id": row.id,
+        "path": row.menu_path or "",
+        "orderNum": row.order_num,
+        "createTime": row.create_time.isoformat(sep=" ", timespec="seconds") if row.create_time else None,
+    }
+
+
+def list_favorites(
+    db: Session,
+    *,
+    user_id: str,
+    tenant_no: str,
+) -> List[Dict[str, Any]]:
+    rows = (
+        db.query(SysUserMenuFavorite)
+        .filter(
+            SysUserMenuFavorite.del_flag == 0,
+            SysUserMenuFavorite.user_id == user_id,
+            SysUserMenuFavorite.tenant_no == tenant_no,
+        )
+        .order_by(
+            SysUserMenuFavorite.order_num.asc(),
+            SysUserMenuFavorite.create_time.asc(),
+        )
+        .all()
+    )
+    return [_row_to_dict(r) for r in rows]
+
+
+def count_active(db: Session, *, user_id: str, tenant_no: str) -> int:
+    return (
+        db.query(SysUserMenuFavorite)
+        .filter(
+            SysUserMenuFavorite.del_flag == 0,
+            SysUserMenuFavorite.user_id == user_id,
+            SysUserMenuFavorite.tenant_no == tenant_no,
+        )
+        .count()
+    )
+
+
+def find_active(
+    db: Session, *, user_id: str, tenant_no: str, menu_path: str
+) -> Optional[SysUserMenuFavorite]:
+    return (
+        db.query(SysUserMenuFavorite)
+        .filter(
+            SysUserMenuFavorite.del_flag == 0,
+            SysUserMenuFavorite.user_id == user_id,
+            SysUserMenuFavorite.tenant_no == tenant_no,
+            SysUserMenuFavorite.menu_path == menu_path,
+        )
+        .first()
+    )
+
+
+def add_favorite(
+    db: Session,
+    *,
+    user_id: str,
+    tenant_no: str,
+    menu_path: str,
+    create_dept_id: str = "",
+) -> Dict[str, Any]:
+    path = normalize_menu_path(menu_path)
+    if not path:
+        raise ValueError("菜单路径无效")
+
+    existing = find_active(db, user_id=user_id, tenant_no=tenant_no, menu_path=path)
+    if existing:
+        return _row_to_dict(existing)
+
+    if count_active(db, user_id=user_id, tenant_no=tenant_no) >= MAX_FAVORITES:
+        raise ValueError(f"最多收藏 {MAX_FAVORITES} 个菜单")
+
+    now = datetime.now()
+    # order_num:取当前最大 + 1,保证新收藏排在末尾
+    max_order = (
+        db.query(SysUserMenuFavorite.order_num)
+        .filter(
+            SysUserMenuFavorite.del_flag == 0,
+            SysUserMenuFavorite.user_id == user_id,
+            SysUserMenuFavorite.tenant_no == tenant_no,
+        )
+        .order_by(SysUserMenuFavorite.order_num.desc())
+        .first()
+    )
+    next_order = (max_order[0] if max_order and max_order[0] is not None else 0) + 1
+
+    row = SysUserMenuFavorite(
+        id=str(uuid.uuid4()),
+        user_id=user_id,
+        menu_path=path,
+        tenant_no=tenant_no,
+        order_num=next_order,
+        create_by=user_id,
+        create_dept_id=create_dept_id or "",
+        create_time=now,
+        update_by=user_id,
+        update_time=now,
+        del_flag=0,
+    )
+    db.add(row)
+    db.commit()
+    db.refresh(row)
+    return _row_to_dict(row)
+
+
+def soft_delete_favorite(
+    db: Session,
+    *,
+    user_id: str,
+    tenant_no: str,
+    menu_path: str,
+) -> bool:
+    path = normalize_menu_path(menu_path)
+    row = find_active(db, user_id=user_id, tenant_no=tenant_no, menu_path=path)
+    if not row:
+        return False
+    row.del_flag = 1
+    row.update_by = user_id
+    row.update_time = datetime.now()
+    db.commit()
+    return True
+```
+
+- [ ] **Step 5: 再跑单测**
+
+Run: `pytest tests/test_menu_favorite_crud.py -v`  
+Expected: PASS
+
+- [ ] **Step 6: 在 `admin.py` 追加路由**
+
+在文件合适位置 import `menu_favorite` crud 与 schemas,并添加:
+
+```python
+@router.get("/menu-favorites")
+def list_menu_favorites(
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    user_id = user.get("user_id")
+    tenant_no = user.get("tenant_no") or "shanxia2.0"
+    if not user_id:
+        return error_response(401, "用户无效")
+    from app.crud import menu_favorite as fav_svc
+
+    return success_response(fav_svc.list_favorites(db, user_id=user_id, tenant_no=tenant_no))
+
+
+@router.post("/menu-favorites")
+def create_menu_favorite(
+    body: MenuFavoriteCreateBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    from app.crud import menu_favorite as fav_svc
+
+    user_id = user.get("user_id")
+    tenant_no = user.get("tenant_no") or "shanxia2.0"
+    if not user_id:
+        return error_response(401, "用户无效")
+    try:
+        data = fav_svc.add_favorite(
+            db,
+            user_id=user_id,
+            tenant_no=tenant_no,
+            menu_path=body.path,
+            create_dept_id=str(user.get("dept_id") or ""),
+        )
+        return success_response(data)
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.delete("/menu-favorites")
+def delete_menu_favorite(
+    body: MenuFavoriteDeleteBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    from app.crud import menu_favorite as fav_svc
+
+    user_id = user.get("user_id")
+    tenant_no = user.get("tenant_no") or "shanxia2.0"
+    if not user_id:
+        return error_response(401, "用户无效")
+    fav_svc.soft_delete_favorite(
+        db, user_id=user_id, tenant_no=tenant_no, menu_path=body.path
+    )
+    return success_response({"path": body.path})
+```
+
+在 `admin.py` 顶部增加:`from app.schemas.menu_favorite import MenuFavoriteCreateBody, MenuFavoriteDeleteBody`。`error_response` 签名以 `app.core.response` 为准。
+
+- [ ] **Step 7: 手动冒烟(需登录 token)**
+
+```bash
+# GET /api/web/admin/menu-favorites
+# POST {"path":"/crm/customer"}
+# DELETE body {"path":"/crm/customer"}
+```
+
+Expected: code 200;第 11 次 POST 返回业务错误文案含「最多收藏」。
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add backend/app/schemas/menu_favorite.py backend/app/crud/menu_favorite.py backend/app/api/admin.py backend/tests/test_menu_favorite_crud.py
+git commit -m "$(cat <<'EOF'
+feat: add menu favorites API under /web/admin/menu-favorites
+
+EOF
+)"
+```
+
+---
+
+### Task 3: 前端叶子卡片工具函数
+
+**Files:**
+- Create: `web-admin/src/utils/router/portal-home-cards.ts`
+- Create: `web-admin/src/utils/router/portal-home-cards.spec.ts`(若仓库无 vitest,则改为 `backend` 不测、前端用临时 node assert 或跳过单测,在首页联调验证;**优先**检查 `web-admin/package.json` 是否有 `vitest`/`test` 脚本——有则写 vitest,无则本 Task 只写纯函数 + 手写断言脚本 `node --experimental-vm-modules` 可省略,改为在 Step 中用 TypeScript 导出并在注释列出用例,实现后用控制台临时调用验证)
+
+本仓库 web-admin 若无单测框架:**跳过自动化测试文件**,在 Step 2 用函数实现,Step 3 对照下表手工推演 3 个用例。
+
+- [ ] **Step 1: 实现 `collectPortalHomeSections`**
+
+```typescript
+// web-admin/src/utils/router/portal-home-cards.ts
+import type { AppRouteRecord } from '@/types/router'
+import { filterPortalTopModules } from '@/utils/router/portal-menu'
+import {
+  filterSidebarRoutes,
+  isSidebarNavigableRoute
+} from '@/utils/router/sidebar-menu-filter'
+import { resolveMegaPanelL3Links } from '@/utils/router/portal-menu'
+
+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)
+    }
+    // 二级自身可导航且已有子链接时,resolveMegaPanelL3Links 不会把二级本身加入;
+    // 若二级可导航且有 children,额外补一张二级卡(设计 §2)
+    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
+}
+```
+
+- [ ] **Step 2: 手工核对用例**
+
+1. L2 不可导航 + 2 个 L3 → 仅 2 张 L3 卡,无 L2。  
+2. L2 可导航无子 → 1 张 L2 卡。  
+3. `isHide` 节点不出现。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/utils/router/portal-home-cards.ts
+git commit -m "$(cat <<'EOF'
+feat: add portal home card leaf collector utilities
+
+EOF
+)"
+```
+
+---
+
+### Task 4: 前端 API + menuFavorite store
+
+**Files:**
+- Create: `web-admin/src/api/menuFavorite.ts`
+- Create: `web-admin/src/store/modules/menuFavorite.ts`
+
+- [ ] **Step 1: API**
+
+```typescript
+// web-admin/src/api/menuFavorite.ts
+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 }
+  })
+}
+```
+
+
+- [ ] **Step 2: Store**
+
+```typescript
+// web-admin/src/store/modules/menuFavorite.ts
+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 toggleFavorite(path: string) {
+    if (!path) return
+    if (isFavorite(path)) {
+      await fetchRemoveMenuFavorite(path)
+      items.value = items.value.filter((i) => i.path !== 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, clear }
+})
+```
+
+- [ ] **Step 3: 在 `user` store 登出清理处调用 `useMenuFavoriteStore().clear()`**(与 worktab 清空并列)
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web-admin/src/api/menuFavorite.ts web-admin/src/store/modules/menuFavorite.ts web-admin/src/store/modules/user.ts
+git commit -m "$(cat <<'EOF'
+feat: add menuFavorite API client and Pinia store
+
+EOF
+)"
+```
+
+---
+
+### Task 5: 菜单首页页面
+
+**Files:**
+- Create: `web-admin/src/views/portal/home/index.vue`
+- Modify: `web-admin/src/App.vue`(`panelTitleBarTextRules` 增加需要竖条的一级标题文案,若自动规则不够)
+
+- [ ] **Step 1: 实现首页**
+
+结构要点(Fresh Mint,非仪表盘):
+
+```vue
+<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)"
+          >
+            ★
+          </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)"
+          >
+            {{ favStore.isFavorite(card.path) ? '★' : '☆' }}
+          </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 { 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 })
+      .filter(Boolean)
+  )
+
+  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;
+    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>
+```
+
+星标优先改用 `@element-plus/icons-vue` 的 `Star` / `StarFilled`(符合按钮图标规范);纯 emoji 仅作结构示意。
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add web-admin/src/views/portal/home/index.vue web-admin/src/App.vue
+git commit -m "$(cat <<'EOF'
+feat: add portal home launcher page with favorite stars
+
+EOF
+)"
+```
+
+---
+
+### Task 6: 注册 `/home` 与 homePath
+
+**Files:**
+- Modify: `web-admin/src/router/index.ts` — `HOME_PAGE_PATH = '/home'`
+- Modify: `web-admin/src/router/routes/staticRoutes.ts` — Layout + `/home` 子路由
+- Modify: `web-admin/src/store/modules/menu.ts` — `setMenuList` 始终优先 `HOME_PAGE_PATH`
+- Modify: `web-admin/src/store/modules/worktab.ts` — `shouldIgnoreTab` **不要**忽略 `/home`;确保首页可 `fixedTab`
+
+- [ ] **Step 1: `HOME_PAGE_PATH`**
+
+```typescript
+// web-admin/src/router/index.ts
+export const HOME_PAGE_PATH = '/home'
+```
+
+- [ ] **Step 2: staticRoutes 增加布局子路由**
+
+在 `staticRoutes` 数组中增加(注意:静态路由在未登录也可匹配——`beforeEach` 已对需登录页拦截;`/home` 必须登录。确认 `isStaticRoute` 不会让未登录直接进首页;若会,将 `/home` 改为登录后动态注入,见备选)。
+
+**推荐备选(更安全):** 不放 staticRoutes,而在动态路由注册完成后(`RouteRegistry` / `MenuProcessor`)始终 `router.addRoute` 一条 Layout+`/home`,或在 `setMenuList` 前向 list **unshift** 隐藏模块:
+
+```typescript
+{
+  path: '/home-root',
+  name: 'PortalHomeRoot',
+  component: '/index/index', // 或 RoutesAlias.Layout 字符串,按项目动态路由约定
+  meta: { title: '首页', isHide: true },
+  children: [
+    {
+      path: '/home',
+      name: 'PortalHome',
+      component: '/portal/home/index',
+      meta: { title: '首页', fixedTab: true, isHide: true }
+    }
+  ]
+}
+```
+
+实现时对照 `sqxp.ts` 里其它一级模块的 `component` 写法(字符串 path vs 懒加载),保持一致。`filterPortalTopModules` / `isHide` 确保首页不进卡片分区。
+
+- [ ] **Step 3: `setMenuList`**
+
+```typescript
+const setMenuList = (list: AppRouteRecord[]) => {
+  menuList.value = list
+  if (HOME_PAGE_PATH) {
+    setHomePath(HOME_PAGE_PATH)
+  } else {
+    setHomePath(getPortalDefaultHomePath(list) || getFirstMenuPath(list))
+  }
+}
+```
+
+- [ ] **Step 4: 登录后访问 `/#/home` 应渲染启动器**
+
+Expected: 有布局壳 + 首页内容;`homePath` 为 `/home`。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web-admin/src/router/index.ts web-admin/src/router/routes/staticRoutes.ts web-admin/src/store/modules/menu.ts web-admin/src/router/core/MenuProcessor.ts
+git commit -m "$(cat <<'EOF'
+feat: register portal /home route and set as homePath
+
+EOF
+)"
+```
+
+---
+
+### Task 7: 瘦顶栏 + 开启 WorkTab
+
+**Files:**
+- Modify: `web-admin/src/components/core/layouts/art-portal-header/index.vue`
+- Modify: `web-admin/src/views/index/index.vue`
+- Modify: `web-admin/src/config/setting.ts` — 视需要默认 `showWorkTab: true`(门户 onMounted 强制 true 即可)
+
+- [ ] **Step 1: 门户头只留品牌 + 用户**
+
+`art-portal-header/index.vue`:删除 `ArtPortalModuleSelect`、`ArtPortalMegaMenu` 模板与 import;保留 Logo、`toHome`、全屏、`ArtUserMenu`。
+
+- [ ] **Step 2: `views/index/index.vue` onMounted**
+
+将:
+
+```typescript
+settingStore.setWorkTab(false)
+worktabStore.enforceSinglePageTabs()
+```
+
+改为:
+
+```typescript
+settingStore.setWorkTab(true)
+// 确保首页固定 tab 存在:若 opened 无 /home,则 openTab 首页且 fixedTab
+```
+
+并实现一小段:用 `useCommon().homePath` 打开固定首页 Tab(若尚无)。**不要**再调用 `enforceSinglePageTabs`。
+
+- [ ] **Step 3: 目视验收**
+
+- 顶栏无一级下拉/二级平铺。  
+- Logo 回 `/home`。  
+- WorkTab 条可见。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web-admin/src/components/core/layouts/art-portal-header/index.vue web-admin/src/views/index/index.vue
+git commit -m "$(cat <<'EOF'
+feat: slim portal header and re-enable WorkTab for portal layout
+
+EOF
+)"
+```
+
+---
+
+### Task 8: WorkTab 溢出淘汰 + openTab(activate)
+
+**Files:**
+- Modify: `web-admin/src/store/modules/worktab.ts`
+- Modify: `web-admin/src/components/core/layouts/art-work-tab/index.vue`
+
+说明:现有 WorkTab 用 `transform: translateX` 平移,不一定出现浏览器原生滚动条。溢出判定用:
+
+`tabsEl.scrollWidth > scrollWrap.clientWidth`(或 `offsetWidth`),与「出现横向滚动需求」等价。
+
+- [ ] **Step 1: 扩展 `openTab`**
+
+```typescript
+type OpenTabOptions = { activate?: boolean }
+
+const openTab = (tab: WorkTab, options: OpenTabOptions = {}): void => {
+  const activate = options.activate !== false
+  // ... existing ignore / singlePage 逻辑 ...
+
+  if (existingIndex === -1) {
+    // 门户多页签:不再调用 ensureCapacityForNewTab()(固定 10)
+    // 改为 push 后由 UI 调度溢出淘汰
+    if (!singlePage) {
+      // skip ensureCapacityForNewTab when portal multi-tab
+    } else {
+      // keep single-page behavior
+    }
+    // push/splice as today
+    if (activate) {
+      current.value = newTab
+    } else if (!current.value.path) {
+      current.value = newTab
+    }
+  } else {
+    // update existing; only set current if activate
+    if (activate) {
+      current.value = opened.value[existingIndex]
+    }
+  }
+}
+```
+
+抽取 `isPortalMultiTabMode()`:`menuType === PORTAL && showWorkTab`(从 settingStore 读)。门户下跳过 `MAX_OPEN_TABS`;非门户可保留原上限行为。
+
+- [ ] **Step 2: 暴露淘汰调度**
+
+```typescript
+const overflowCheckHandler = ref<null | (() => boolean)>(null)
+
+const registerOverflowChecker = (fn: (() => boolean) | null) => {
+  overflowCheckHandler.value = fn
+}
+
+const evictUntilNoOverflow = () => {
+  const checker = overflowCheckHandler.value
+  if (!checker) return
+  let guard = 0
+  while (checker() && guard++ < 50) {
+    if (!evictOldestClosableTab()) break
+  }
+  // 若淘汰了当前页,切到最后一个 opened 或 home
+  if (current.value.path && findTabIndex(current.value.path) === -1) {
+    const fallback = opened.value[opened.value.length - 1]
+    if (fallback) {
+      current.value = fallback
+      router.push(fallback.path)
+    }
+  }
+}
+```
+
+- [ ] **Step 3: ArtWorkTab 注册测量**
+
+```typescript
+const isOverflowing = () => {
+  const wrap = scrollRef.value
+  const ul = tabsRef.value
+  if (!wrap || !ul) return false
+  return ul.scrollWidth > wrap.clientWidth + 1
+}
+
+onMounted(() => {
+  worktabStore.registerOverflowChecker(isOverflowing)
+})
+onUnmounted(() => {
+  worktabStore.registerOverflowChecker(null)
+})
+
+watch(
+  () => worktabStore.opened.length,
+  async () => {
+    await nextTick()
+    worktabStore.evictUntilNoOverflow()
+  }
+)
+```
+
+注意:现有组件用 translateX,`ul.scrollWidth` 仍应反映内容全宽;`wrap.clientWidth` 为可见槽宽。若测量不准,改为对 `.worktab-scroll-wrap` 与内部 `ul` 的 `getBoundingClientRect().width` 比较。
+
+窗口 resize:**不**主动淘汰(设计 §3)。
+
+- [ ] **Step 4: 门户右键菜单隐藏「固定/取消固定」**
+
+在 `art-work-tab` 组装 `menuItems` 时,若 `menuType === PORTAL`,过滤掉 fixed 相关项。
+
+- [ ] **Step 5: 关全部落点**
+
+确认 `resolveEmptyTabsPath` / closeAll 使用 `homePath`(`/home`),不再用 `getPortalDefaultPathForRoute` 落到业务页。修改 `worktab.ts` 中 `resolveEmptyTabsPath`:门户下直接 `homePath`。
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add web-admin/src/store/modules/worktab.ts web-admin/src/components/core/layouts/art-work-tab/index.vue
+git commit -m "$(cat <<'EOF'
+feat: evict oldest WorkTab on overflow and support silent pre-open
+
+EOF
+)"
+```
+
+---
+
+### Task 9: 冷登录预开收藏 Tab
+
+**Files:**
+- Modify: `web-admin/src/router/guards/beforeEach.ts`(动态路由初始化成功后)
+- 或新建 `web-admin/src/utils/navigation/preopen-favorites.ts` 并在守卫末尾调用
+
+- [ ] **Step 1: 预开工具**
+
+```typescript
+// web-admin/src/utils/navigation/preopen-favorites.ts
+import { 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'
+import { HOME_PAGE_PATH } from '@/router'
+
+const PREOPEN_FLAG_KEY = 'portal-favorites-preopened-user'
+
+export async function preopenFavoriteTabsIfNeeded(userId: string) {
+  if (!userId) return
+  // 同用户刷新:若 worktab 已有非首页业务 tab,跳过
+  const worktab = useWorktabStore()
+  const hasBusinessTabs = worktab.opened.some(
+    (t) => t.path && t.path !== HOME_PAGE_PATH && !t.fixedTab
+  )
+  const last = sessionStorage.getItem(PREOPEN_FLAG_KEY)
+  if (last === userId && hasBusinessTabs) {
+    return
+  }
+
+  const favStore = useMenuFavoriteStore()
+  await favStore.loadFavorites()
+  const menuStore = useMenuStore()
+  const sections = collectPortalHomeSections(menuStore.menuList || [])
+
+  // 确保首页固定 tab
+  worktab.openTab(
+    {
+      path: HOME_PAGE_PATH,
+      title: '首页',
+      name: 'PortalHome',
+      fixedTab: true,
+      keepAlive: true
+    },
+    { activate: true }
+  )
+
+  for (const item of favStore.items) {
+    const card = findHomeCardByPath(sections, item.path)
+    if (!card) {
+      await favStore.toggleFavorite(item.path) // 无效则取消;或单独调用 remove API
+      continue
+    }
+    const resolved = router.resolve(card.path)
+    if (resolved.matched.length === 0) {
+      continue
+    }
+    worktab.openTab(
+      {
+        path: card.path,
+        title: card.title,
+        name: String(resolved.meta?.name || resolved.name || ''),
+        fixedTab: false,
+        keepAlive: true
+      },
+      { activate: false }
+    )
+  }
+
+  // 落点首页
+  if (router.currentRoute.value.path !== HOME_PAGE_PATH) {
+    await router.replace(HOME_PAGE_PATH)
+  }
+  sessionStorage.setItem(PREOPEN_FLAG_KEY, userId)
+}
+```
+
+无效收藏删除:不要用 `toggleFavorite`(若已不在列表会变成新增)。应调用 `fetchRemoveMenuFavorite` + 从 store 移除。实现时改正。
+
+- [ ] **Step 2: 在动态路由初始化完成且首次进入系统时调用**
+
+在 `beforeEach.ts` 中菜单/路由 ready 之后、`next()` 前或后 `await preopenFavoriteTabsIfNeeded(userId)`。仅冷登录:可用 `sessionStorage` 标记;**换用户**时清除标记(login/logout)。
+
+同用户 **刷新**:worktab persist 已恢复 `opened` → `hasBusinessTabs` 为 true → 跳过预开(符合设计)。
+
+- [ ] **Step 3: 验收**
+
+1. 清空站点数据后登录:首页 + 收藏 Tab 已开,当前在首页。  
+2. 刷新:Tab 保持上次,不重复叠加。  
+3. 换账号:按新账号收藏预开。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web-admin/src/utils/navigation/preopen-favorites.ts web-admin/src/router/guards/beforeEach.ts
+git commit -m "$(cat <<'EOF'
+feat: preopen favorited menu tabs after cold login
+
+EOF
+)"
+```
+
+---
+
+### Task 10: 首页 Tab 固定展示 + 文档/图谱收尾
+
+**Files:**
+- Modify: `web-admin/src/components/core/layouts/art-work-tab/index.vue` — 确保 `fixedTab` 首页无关闭钮(已有)
+- Modify: 路由进入 `/home` 时 `openTab` 带 `fixedTab: true`(守卫或 page content 现有逻辑)
+- 可选:`docs/web-admin-ui-spec-fresh-mint.md` 或 OpenSpec `ui-conventions` 增加一句门户主导航改为首页启动器(最小段落)
+- Run: `./scripts/update-graph.sh all`(或分别 backend / frontend)
+
+- [ ] **Step 1: 追踪现有「打开 worktab」守卫逻辑**,保证访问 `/home` 时写入 `fixedTab: true`,标题「首页」。
+
+- [ ] **Step 2: 手工完整验收(对照设计验收清单 6 条)**
+
+1. 登录落首页;顶栏无二/三级。  
+2. 卡片拍平正确。  
+3. 星标 ≤10,跨浏览器同账号仍在。  
+4. 再登录预开收藏,当前首页。  
+5. 单关/全关,全关剩首页。  
+6. 多开至 Tab 槽溢出后再开,最早可关页消失。
+
+- [ ] **Step 3: 更新 graphify**
+
+```bash
+./scripts/update-graph.sh all
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A
+git commit -m "$(cat <<'EOF'
+docs: note portal home launcher nav and refresh graphify
+
+EOF
+)"
+```
+
+(若无 docs/graph 变更则只提交实际改动文件。)
+
+---
+
+## Spec coverage checklist
+
+| Spec 项 | Task |
+|---------|------|
+| 瘦顶栏、无二/三级 | Task 7 |
+| WorkTab 开启、首页固定 | Task 6–7, 10 |
+| 卡片拍平规则 | Task 3, 5 |
+| 收藏区置顶、星标、上限 10 | Task 2, 4, 5 |
+| 后端持久化 | Task 1–2 |
+| 溢出淘汰、静默 | Task 8 |
+| 冷登录预开、落点首页、刷新不重开 | Task 9 |
+| 关全部回首页 | Task 8 Step 5 |
+| 门户隐藏 Tab 固定 | Task 8 Step 4 |
+| 非目标(小程序/侧栏/最近访问) | 不实现 |
+
+## Self-review notes
+
+- 溢出:以内容宽 > 槽宽为准,不依赖原生 scrollbar 可见性(与现 translateX 实现兼容)。  
+- `openTab({ activate: false })` 必须在预开路径使用,避免最后落在收藏最后一项。  
+- 无效收藏删除勿误用 toggle。  
+- `/home` 注册方式优先动态注入 + `isHide`,避免未登录 static 误入。
+

+ 251 - 0
docs/superpowers/specs/2026-08-08-portal-home-launcher-tabs-design.md

@@ -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 各自改动范围)。
+

+ 28 - 0
web-admin/src/api/menuFavorite.ts

@@ -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 }
+  })
+}

+ 8 - 21
web-admin/src/components/core/layouts/art-portal-header/index.vue

@@ -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 {

+ 24 - 1
web-admin/src/components/core/layouts/art-work-tab/index.vue

@@ -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
web-admin/src/router/core/MenuProcessor.ts

@@ -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
web-admin/src/router/guards/beforeEach.ts

@@ -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
web-admin/src/router/index.ts

@@ -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
web-admin/src/router/modules/sqxp.ts

@@ -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
web-admin/src/store/modules/menu.ts

@@ -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))
+    }
   }
 
   /**

+ 59 - 0
web-admin/src/store/modules/menuFavorite.ts

@@ -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
web-admin/src/store/modules/user.ts

@@ -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
web-admin/src/store/modules/worktab.ts

@@ -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,
 

+ 80 - 0
web-admin/src/utils/navigation/preopen-favorites.ts

@@ -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)
+}

+ 85 - 0
web-admin/src/utils/router/portal-home-cards.ts

@@ -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
web-admin/src/views/index/index.vue

@@ -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 - 0
web-admin/src/views/portal/home/index.vue

@@ -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>