Files
docs/superpowers/plans/2026-07-27-client-navigation.md

13 KiB
Raw Permalink Blame History

Client Web Navigation 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: 将 client-web 的扁平侧栏改为参考原型的两级业务分组导航,并修复移动端抽屉导航。

Architecture: 在 client-web 内新增带图标、权限和 children 的导航树与独立侧栏菜单组件。AppLayout.vue 只管理租户壳层、当前路由和移动抽屉状态;桌面与移动端复用同一份菜单组件和配置,不扩展共享包的单层 NavigationItem。

Tech Stack: Vue 3、TypeScript、Vue Router、Element Plus、Lucide Vue、Vite。


Task 1: 建立客户端导航树

Files:

  • Create: apps/client-web/src/navigation/clientNavigation.ts

  • Modify: apps/client-web/src/router/index.ts:1-33

  • Step 1: 定义导航节点类型和叶子展开工具

创建完整文件:

import type { Component } from 'vue'
import {
  Building2,
  ClipboardList,
  KeyRound,
  LayoutGrid,
  Settings,
  ShieldCheck,
  Truck,
  Users,
} from '@lucide/vue'

export interface ClientNavigationNode {
  key: string
  title: string
  icon: Component
  path?: string
  permission?: string
  children?: ClientNavigationNode[]
}

export interface ClientNavigationLeaf extends ClientNavigationNode {
  path: string
  permission: string
  children?: never
}
  • Step 2: 写入已确认的导航树

在同一文件写入:

export const clientNavigation: ClientNavigationNode[] = [
  { key: 'dashboard', title: '业务总览', icon: LayoutGrid, path: '/', permission: 'dashboard:view' },
  {
    key: 'first-leg',
    title: '头程业务',
    icon: Truck,
    children: [
      { key: 'first-leg-orders', title: '头程订单', icon: ClipboardList, path: '/orders/first-leg', permission: 'order:first-leg:list' },
    ],
  },
  {
    key: 'company',
    title: '企业管理',
    icon: Settings,
    children: [
      { key: 'company-profile', title: '企业资料', icon: Building2, path: '/company/profile', permission: 'company:profile:view' },
      { key: 'company-members', title: '企业成员', icon: Users, path: '/organization/members', permission: 'company:member:list' },
      { key: 'company-roles', title: '角色管理', icon: ShieldCheck, path: '/organization/roles', permission: 'company:role:list' },
      { key: 'company-permissions', title: '菜单权限', icon: KeyRound, path: '/organization/permissions', permission: 'company:permission:list' },
    ],
  },
]

export function getNavigationLeaves(nodes: ClientNavigationNode[]): ClientNavigationLeaf[] {
  return nodes.flatMap((node) => {
    if (node.children?.length) return getNavigationLeaves(node.children)
    if (!node.path || !node.permission) return []
    return [node as ClientNavigationLeaf]
  })
}

不要加入原型中尚未实现的一键导入、快速下单、海外仓或财务结算。

  • Step 3: 删除 router 中的扁平 clientNavigation 配置并改为从本地导航模块导出

删除 NavigationItem 类型导入和扁平数组,临时保留兼容导出,让尚未修改的布局仍能通过类型检查:

export { clientNavigation } from '@/navigation/clientNavigation'

Task 3 改造布局后删除这行兼容导出,使 router 最终只保留路由和权限元数据。

  • Step 4: 运行类型检查

Run: pnpm typecheck

Expected: 成功退出,0 个 TypeScript 错误;不要引入 any。

Task 2: 实现可复用客户端侧栏菜单组件

Files:

  • Create: apps/client-web/src/components/ClientSidebarMenu.vue

  • Step 1: 使用 Element Plus 菜单组件实现递归一级/二级渲染

组件脚本为:

<script setup lang="ts">
import { computed } from 'vue'
import { clientNavigation, type ClientNavigationNode } from '@/navigation/clientNavigation'

const props = defineProps<{ activePath: string }>()
const emit = defineEmits<{ navigate: [] }>()

function findParentKey(nodes: ClientNavigationNode[], path: string): string | undefined {
  for (const node of nodes) {
    if (node.children?.some((child) => child.path === path)) return node.key
    if (node.children?.length) {
      const parentKey = findParentKey(node.children, path)
      if (parentKey) return parentKey
    }
  }
}

const defaultOpeneds = computed(() => {
  const parentKey = findParentKey(clientNavigation, props.activePath)
  return parentKey ? [parentKey] : []
})

function handleSelect() {
  emit('navigate')
}
</script>
  • Step 2: 计算当前路由所在分组

模板使用同一导航树,并把当前路由所在父分组传给 default-openeds:

<template>
  <el-menu
    class="client-nav-menu"
    :default-active="activePath"
    :default-openeds="defaultOpeneds"
    router
    @select="handleSelect"
  >
    <template v-for="item in clientNavigation" :key="item.key">
      <el-sub-menu v-if="item.children?.length" :index="item.key">
        <template #title>
          <component :is="item.icon" :size="18" :stroke-width="1.9" />
          <span>{{ item.title }}</span>
        </template>
        <el-menu-item v-for="child in item.children" :key="child.key" :index="child.path || child.key">
          <component :is="child.icon" :size="17" :stroke-width="1.9" />
          <template #title>{{ child.title }}</template>
        </el-menu-item>
      </el-sub-menu>
      <el-menu-item v-else :index="item.path || item.key">
        <component :is="item.icon" :size="18" :stroke-width="1.9" />
        <template #title>{{ item.title }}</template>
      </el-menu-item>
    </template>
  </el-menu>
</template>
  • Step 3: 暴露菜单选择事件

确认 handleSelect() 只 emit navigate;路由跳转由 el-menu router 完成,父布局只负责关闭移动抽屉。

  • Step 4: 运行类型检查

Run: pnpm typecheck

Expected: 组件 props、事件和导航节点类型全部通过严格检查。

Task 3: 接入 AppLayout 的移动抽屉状态

Files:

  • Modify: apps/client-web/src/layouts/AppLayout.vue:1-75

  • Step 1: 增加移动菜单状态和路由联动

脚本增加以下状态与清理逻辑:

import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { X } from '@lucide/vue'
import ClientSidebarMenu from '@/components/ClientSidebarMenu.vue'

const mobileMenuOpen = ref(false)
let previousBodyOverflow = ''

function closeMobileMenu() {
  mobileMenuOpen.value = false
}

function handleEscape(event: KeyboardEvent) {
  if (event.key === 'Escape') closeMobileMenu()
}

watch(() => route.fullPath, closeMobileMenu)
watch(mobileMenuOpen, (open) => {
  if (open) {
    previousBodyOverflow = document.body.style.overflow
    document.body.style.overflow = 'hidden'
  } else {
    document.body.style.overflow = previousBodyOverflow
  }
})

onMounted(() => document.addEventListener('keydown', handleEscape))
onBeforeUnmount(() => {
  document.removeEventListener('keydown', handleEscape)
  document.body.style.overflow = previousBodyOverflow
})
  • Step 2: 替换内联 v-for 侧栏链接

删除 clientNavigation、六个菜单图标和 icons 数组的布局导入;删除 router 中的临时兼容导出。侧栏替换为:

<aside id="client-sidebar" class="client-sidebar" aria-label="客户工作台导航">
  <div class="client-sidebar-mobile-header">
    <strong>功能导航</strong>
    <button type="button" aria-label="关闭导航" @click="closeMobileMenu"><X :size="20" /></button>
  </div>
  <nav>
    <ClientSidebarMenu :active-path="route.path" @navigate="closeMobileMenu" />
  </nav>
  <div class="sidebar-help"><strong>需要帮助?</strong><span>联系您的专属客户经理</span></div>
</aside>
  • Step 3: 给移动菜单按钮补齐语义和行为

根节点、遮罩和菜单按钮使用:

<div class="client-shell" :class="{ 'mobile-menu-open': mobileMenuOpen }">
  <button v-if="mobileMenuOpen" class="client-sidebar-scrim" type="button" aria-label="关闭导航" @click="closeMobileMenu" />
  <!-- existing header and sidebar -->
  <button type="button" aria-label="打开导航" aria-controls="client-sidebar" :aria-expanded="mobileMenuOpen" @click="mobileMenuOpen = true">
    <Menu :size="20" />
  </button>
</div>
  • Step 4: 添加遮罩和移动关闭按钮

确认遮罩位于侧栏之前、侧栏 z-index 高于遮罩;关闭按钮只在移动断点可见。

  • Step 5: 运行类型检查

Run: pnpm typecheck

Expected: 通过;退出登录确认取消时保持现有页面。

Task 4: 调整 client-web 导航样式和响应式行为

Files:

  • Modify: apps/client-web/src/styles/index.css:20-75, 220-270

  • Step 1: 将侧栏改为可滚动的分组菜单容器

用以下菜单变量与结构样式替换原 .client-sidebar nav a 规则:

.client-sidebar { overflow: hidden; }
.client-sidebar nav { flex: 1; min-height: 0; overflow-y: auto; }
.client-nav-menu {
  border-right: 0;
  --el-menu-bg-color: transparent;
  --el-menu-text-color: #52616b;
  --el-menu-hover-bg-color: #f2f7f6;
  --el-menu-active-color: var(--primary);
}
.client-nav-menu .el-menu-item,
.client-nav-menu .el-sub-menu__title { height: 44px; margin: 3px 0; border-radius: 7px; line-height: 44px; }
.client-nav-menu .el-menu-item.is-active { background: #e9f4f2; color: var(--primary); font-weight: 650; }
.client-nav-menu .el-sub-menu .el-menu-item { min-width: 0; padding-left: 46px !important; }
.client-nav-menu .el-menu-item svg,
.client-nav-menu .el-sub-menu__title svg { flex: 0 0 auto; margin-right: 11px; }
.client-nav-menu .el-menu-item span,
.client-nav-menu .el-sub-menu__title span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  • Step 2: 增加焦点与触控反馈

增加:

.client-nav-menu .el-menu-item:focus-visible,
.client-nav-menu .el-sub-menu__title:focus-visible,
.client-page-heading button:focus-visible,
.client-sidebar-mobile-header button:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}
.client-sidebar-mobile-header { display: none; }
.client-sidebar-scrim { display: none; }
  • Step 3: 实现移动抽屉与遮罩

在现有 @media (max-width: 720px) 中删除 .client-sidebar { display: none; },加入:

.client-sidebar {
  display: flex;
  width: min(286px, 86vw);
  transform: translateX(-100%);
  transition: transform .2s ease;
}
.mobile-menu-open .client-sidebar { transform: translateX(0); }
.client-sidebar-scrim {
  position: fixed;
  inset: 68px 0 0 0;
  z-index: 18;
  display: block;
  border: 0;
  background: rgba(23, 50, 77, .46);
}
.client-sidebar-mobile-header {
  display: flex;
  min-height: 52px;
  align-items: center;
  justify-content: space-between;
  padding: 0 14px;
  border-bottom: 1px solid var(--border);
}
.client-sidebar-mobile-header button { display: grid; width: 44px; height: 44px; place-items: center; border: 0; background: transparent; color: var(--navy); }
  • Step 4: 防止移动端横向溢出

保留 html, body, #app { min-width: 320px; },并加入:

.client-shell { overflow-x: hidden; }
.top-header, .client-main { max-width: 100%; }

在 320px 浏览器检查中确认 document.documentElement.scrollWidth === document.documentElement.clientWidth。

  • Step 5: 运行构建

Run: pnpm build

Expected: vue-tsc --noEmit 与 vite build 均成功。

Task 5: 完成浏览器与 Impeccable 验收

Files:

  • Verify: apps/client-web/src/navigation/clientNavigation.ts

  • Verify: apps/client-web/src/components/ClientSidebarMenu.vue

  • Verify: apps/client-web/src/layouts/AppLayout.vue

  • Verify: apps/client-web/src/styles/index.css

  • Step 1: 启动客户端开发服务器

Run: pnpm dev

Expected: 输出 http://localhost:5174/;若端口被占用,记录实际端口并使用该端口验收。

  • Step 2: 验证桌面菜单

使用预填账号 customer / 123456 登录,确认只有“业务总览”直接一级可达;“头程业务”和“企业管理”可展开;四个企业入口和头程订单都能到达原有路由;当前路由高亮正确。

  • Step 3: 验证移动菜单

在 720px、390px、320px 视口确认:点击汉堡按钮打开抽屉,遮罩出现;点击二级菜单后抽屉关闭并完成路由切换;点击遮罩和按 Escape 均可关闭;页面无横向溢出。

  • Step 4: 运行 Impeccable 机械检测

Run: node C:\Users\BY\.codex\skills\impeccable\scripts\detect.mjs --json src\layouts\AppLayout.vue src\components\ClientSidebarMenu.vue src\navigation\clientNavigation.ts

Expected: 0 条未解释告警;若有告警,逐条处理或在交付说明中记录明确原因。

  • Step 5: 复跑质量门禁

Run: pnpm typecheck; pnpm build

Expected: 两个命令均成功后再声称完成。浏览器不可用时必须说明采用源码与 HTTP 回退证据。