# 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 回退证据。