|
|
@@ -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 误入。
|
|
|
+
|