From 2df2ef08088197d1ebe1f086849a90a1a7b9f51b Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Sat, 8 Aug 2026 12:06:49 +0800 Subject: [PATCH] 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 --- .../2026-08-08-portal-home-launcher-tabs.md | 1216 +++++++++++++++++ ...-08-08-portal-home-launcher-tabs-design.md | 251 ++++ web-admin/src/api/menuFavorite.ts | 28 + .../core/layouts/art-portal-header/index.vue | 29 +- .../core/layouts/art-work-tab/index.vue | 25 +- web-admin/src/router/core/MenuProcessor.ts | 55 +- web-admin/src/router/guards/beforeEach.ts | 30 +- web-admin/src/router/index.ts | 4 +- web-admin/src/router/modules/sqxp.ts | 275 ++-- web-admin/src/store/modules/menu.ts | 6 +- web-admin/src/store/modules/menuFavorite.ts | 59 + web-admin/src/store/modules/user.ts | 8 +- web-admin/src/store/modules/worktab.ts | 59 +- .../src/utils/navigation/preopen-favorites.ts | 80 ++ .../src/utils/router/portal-home-cards.ts | 85 ++ web-admin/src/views/index/index.vue | 22 +- web-admin/src/views/portal/home/index.vue | 167 +++ 17 files changed, 2242 insertions(+), 157 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-08-portal-home-launcher-tabs.md create mode 100644 docs/superpowers/specs/2026-08-08-portal-home-launcher-tabs-design.md create mode 100644 web-admin/src/api/menuFavorite.ts create mode 100644 web-admin/src/store/modules/menuFavorite.ts create mode 100644 web-admin/src/utils/navigation/preopen-favorites.ts create mode 100644 web-admin/src/utils/router/portal-home-cards.ts create mode 100644 web-admin/src/views/portal/home/index.vue diff --git a/docs/superpowers/plans/2026-08-08-portal-home-launcher-tabs.md b/docs/superpowers/plans/2026-08-08-portal-home-launcher-tabs.md new file mode 100644 index 000000000..9476068e9 --- /dev/null +++ b/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() + + 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({ + url: '/web/admin/menu-favorites' + }) +} + +export function fetchAddMenuFavorite(path: string) { + return request.post({ + 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([]) + 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 + + + + + +``` + +星标优先改用 `@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 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 误入。 + diff --git a/docs/superpowers/specs/2026-08-08-portal-home-launcher-tabs-design.md b/docs/superpowers/specs/2026-08-08-portal-home-launcher-tabs-design.md new file mode 100644 index 000000000..9db5af2ce --- /dev/null +++ b/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 各自改动范围)。 + diff --git a/web-admin/src/api/menuFavorite.ts b/web-admin/src/api/menuFavorite.ts new file mode 100644 index 000000000..6fc865489 --- /dev/null +++ b/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({ + url: '/web/admin/menu-favorites' + }) +} + +export function fetchAddMenuFavorite(path: string) { + return request.post({ + url: '/web/admin/menu-favorites', + data: { path } + }) +} + +export function fetchRemoveMenuFavorite(path: string) { + return request.delete<{ path: string }>({ + url: '/web/admin/menu-favorites', + data: { path } + }) +} diff --git a/web-admin/src/components/core/layouts/art-portal-header/index.vue b/web-admin/src/components/core/layouts/art-portal-header/index.vue index a35d1498f..2dafd4ab0 100644 --- a/web-admin/src/components/core/layouts/art-portal-header/index.vue +++ b/web-admin/src/components/core/layouts/art-portal-header/index.vue @@ -1,4 +1,4 @@ - +