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

397 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<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`:
```vue
<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: 增加移动菜单状态和路由联动**
脚本增加以下状态与清理逻辑:
```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
<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: 给移动菜单按钮补齐语义和行为**
根节点、遮罩和菜单按钮使用:
```vue
<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` 规则:
```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 回退证据。