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