# 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: 定义导航节点类型和叶子展开工具**
创建完整文件:
```ts
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: 写入已确认的导航树**
在同一文件写入:
```ts
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` 类型导入和扁平数组,临时保留兼容导出,让尚未修改的布局仍能通过类型检查:
```ts
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 菜单组件实现递归一级/二级渲染**
组件脚本为:
```vue
```
- [ ] **Step 2: 计算当前路由所在分组**
模板使用同一导航树,并把当前路由所在父分组传给 `default-openeds`:
```vue
```
- [ ] **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: 增加移动菜单状态和路由联动**
脚本增加以下状态与清理逻辑:
```ts
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 中的临时兼容导出。侧栏替换为:
```vue
```
- [ ] **Step 3: 给移动菜单按钮补齐语义和行为**
根节点、遮罩和菜单按钮使用:
```vue
```
- [ ] **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` 规则:
```css
.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: 增加焦点与触控反馈**
增加:
```css
.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; }`,加入:
```css
.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; }`,并加入:
```css
.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 回退证据。