docs: initialize documentation repository

This commit is contained in:
2026-07-31 10:43:58 +08:00
commit 1ec28f7cfc
72 changed files with 48272 additions and 0 deletions
@@ -0,0 +1,552 @@
# Admin Grouped 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:** Replace the flat admin sidebar with maintainable two-level navigation while preserving every existing static page and interaction.
**Architecture:** Keep Vue Router responsible only for routes and move admin menu metadata into a local navigation module. Render the configuration through a focused Element Plus sidebar component, while all business pages continue to use the current Pinia mock store and make no Java API calls.
**Tech Stack:** Vue 3, TypeScript, Vue Router, Pinia, Element Plus, Lucide Vue, Vitest, Vue Test Utils, jsdom, pnpm
---
The repository is not a Git worktree. Commit steps are intentionally omitted; each green test/build checkpoint is the recovery boundary.
## File map
- Create `apps/admin-web/vitest.config.ts`: admin-only unit-test configuration.
- Create `apps/admin-web/src/navigation/adminNavigation.ts`: admin-local two-level menu types and data.
- Create `apps/admin-web/src/navigation/adminNavigation.test.ts`: navigation structure and uniqueness contract.
- Create `apps/admin-web/src/components/AdminSidebarMenu.vue`: Element Plus menu renderer.
- Create `apps/admin-web/src/components/AdminSidebarMenu.test.ts`: grouped labels, paths and collapsed-state behavior.
- Modify `apps/admin-web/src/layouts/AppLayout.vue`: delegate menu rendering to `AdminSidebarMenu`.
- Modify `apps/admin-web/src/router/index.ts`: stop exporting flat menu metadata.
- Modify `apps/admin-web/src/styles/index.css`: style Element Plus menu within the existing navy sidebar.
- Modify `apps/admin-web/package.json` and `apps/admin-web/pnpm-lock.yaml`: add admin-only test dependencies and script.
- Modify `apps/admin-web/README.md`: document grouped menu and test command.
- Modify `apps/admin-web/AGENTS.md`: add the new admin-local test quality gate.
### Task 1: Install the admin-only test harness
**Files:**
- Modify: `apps/admin-web/package.json`
- Modify: `apps/admin-web/pnpm-lock.yaml`
- Create: `apps/admin-web/vitest.config.ts`
- [ ] **Step 1: Install test dependencies inside admin-web**
Run:
```powershell
cd C:\Users\BY\Desktop\code\chongchong-global\apps\admin-web
pnpm add -D vitest @vue/test-utils jsdom
```
Expected: only `apps/admin-web/package.json`, `apps/admin-web/pnpm-lock.yaml` and `apps/admin-web/node_modules` change.
- [ ] **Step 2: Add the test script**
Add to `scripts` in `apps/admin-web/package.json`:
```json
"test": "vitest run"
```
- [ ] **Step 3: Add the Vitest configuration**
Create `apps/admin-web/vitest.config.ts`:
```ts
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
environment: 'jsdom',
include: ['src/**/*.test.ts'],
clearMocks: true,
},
})
```
- [ ] **Step 4: Verify the empty harness**
Run:
```powershell
pnpm test -- --passWithNoTests
```
Expected: PASS with no test files and no dependency-resolution errors.
### Task 2: Define the grouped navigation with TDD
**Files:**
- Create: `apps/admin-web/src/navigation/adminNavigation.test.ts`
- Create: `apps/admin-web/src/navigation/adminNavigation.ts`
- [ ] **Step 1: Write the failing navigation contract**
Create `apps/admin-web/src/navigation/adminNavigation.test.ts`:
```ts
import { describe, expect, it } from 'vitest'
import { adminNavigation, getNavigationLeaves } from './adminNavigation'
describe('adminNavigation', () => {
it('keeps only the dashboard as a direct first-level page', () => {
expect(adminNavigation.map((item) => item.title)).toEqual([
'工作台',
'业务管理',
'系统管理',
])
expect(adminNavigation[0].path).toBe('/')
expect(adminNavigation[1].children?.map((item) => item.title)).toEqual([
'客户管理',
'头程订单',
])
expect(adminNavigation[2].children?.map((item) => item.title)).toEqual([
'内部用户',
'角色管理',
'菜单权限',
])
})
it('assigns every page a unique path and permission code', () => {
const leaves = getNavigationLeaves(adminNavigation)
const paths = leaves.map((item) => item.path)
const permissions = leaves.map((item) => item.permission)
expect(paths).toEqual([
'/',
'/customers',
'/orders/first-leg',
'/system/users',
'/system/roles',
'/system/permissions',
])
expect(new Set(paths).size).toBe(paths.length)
expect(new Set(permissions).size).toBe(permissions.length)
expect(leaves.every((item) => Boolean(item.icon))).toBe(true)
})
})
```
- [ ] **Step 2: Run the test and verify RED**
Run:
```powershell
pnpm test -- src/navigation/adminNavigation.test.ts
```
Expected: FAIL because `src/navigation/adminNavigation.ts` does not exist.
- [ ] **Step 3: Implement the minimal local navigation model**
Create `apps/admin-web/src/navigation/adminNavigation.ts`:
```ts
import type { Component } from 'vue'
import {
BriefcaseBusiness,
Building2,
ClipboardList,
KeyRound,
LayoutDashboard,
Settings,
ShieldCheck,
Users,
} from '@lucide/vue'
export interface AdminNavigationNode {
key: string
title: string
icon: Component
path?: string
permission?: string
children?: AdminNavigationNode[]
}
export interface AdminNavigationLeaf extends AdminNavigationNode {
path: string
permission: string
children?: never
}
export const adminNavigation: AdminNavigationNode[] = [
{
key: 'dashboard',
title: '工作台',
icon: LayoutDashboard,
path: '/',
permission: 'dashboard:view',
},
{
key: 'business',
title: '业务管理',
icon: BriefcaseBusiness,
children: [
{ key: 'customers', title: '客户管理', icon: Building2, path: '/customers', permission: 'customer:list' },
{ key: 'first-leg-orders', title: '头程订单', icon: ClipboardList, path: '/orders/first-leg', permission: 'first-leg:list' },
],
},
{
key: 'system',
title: '系统管理',
icon: Settings,
children: [
{ key: 'internal-users', title: '内部用户', icon: Users, path: '/system/users', permission: 'system:user:list' },
{ key: 'roles', title: '角色管理', icon: ShieldCheck, path: '/system/roles', permission: 'system:role:list' },
{ key: 'permissions', title: '菜单权限', icon: KeyRound, path: '/system/permissions', permission: 'system:permission:list' },
],
},
]
export function getNavigationLeaves(nodes: AdminNavigationNode[]): AdminNavigationLeaf[] {
return nodes.flatMap((node) => {
if (node.children?.length) return getNavigationLeaves(node.children)
if (!node.path || !node.permission) return []
return [node as AdminNavigationLeaf]
})
}
```
- [ ] **Step 4: Run the test and verify GREEN**
Run:
```powershell
pnpm test -- src/navigation/adminNavigation.test.ts
```
Expected: 2 tests PASS.
### Task 3: Render the two-level menu with TDD
**Files:**
- Create: `apps/admin-web/src/components/AdminSidebarMenu.test.ts`
- Create: `apps/admin-web/src/components/AdminSidebarMenu.vue`
- [ ] **Step 1: Write the failing sidebar component test**
Create `apps/admin-web/src/components/AdminSidebarMenu.test.ts`:
```ts
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import AdminSidebarMenu from './AdminSidebarMenu.vue'
const stubs = {
'el-menu': {
props: ['defaultActive', 'collapse'],
template: '<nav :data-active="defaultActive" :data-collapsed="String(collapse)"><slot /></nav>',
},
'el-sub-menu': {
props: ['index'],
template: '<section data-kind="group" :data-index="index"><header><slot name="title" /></header><slot /></section>',
},
'el-menu-item': {
props: ['index'],
template: '<a data-kind="item" :data-index="index"><slot /></a>',
},
}
describe('AdminSidebarMenu', () => {
it('renders grouped page entries and forwards the active route', () => {
const wrapper = mount(AdminSidebarMenu, {
props: { activePath: '/system/users', collapsed: false },
global: { stubs },
})
expect(wrapper.get('nav').attributes('data-active')).toBe('/system/users')
expect(wrapper.findAll('[data-kind="group"] header').map((node) => node.text())).toEqual([
'业务管理',
'系统管理',
])
expect(wrapper.findAll('[data-kind="item"]').map((node) => node.attributes('data-index'))).toEqual([
'/',
'/customers',
'/orders/first-leg',
'/system/users',
'/system/roles',
'/system/permissions',
])
})
it('forwards desktop collapse state to Element Plus', () => {
const wrapper = mount(AdminSidebarMenu, {
props: { activePath: '/', collapsed: true },
global: { stubs },
})
expect(wrapper.get('nav').attributes('data-collapsed')).toBe('true')
})
})
```
- [ ] **Step 2: Run the component test and verify RED**
Run:
```powershell
pnpm test -- src/components/AdminSidebarMenu.test.ts
```
Expected: FAIL because `AdminSidebarMenu.vue` does not exist.
- [ ] **Step 3: Implement the minimal menu component**
Create `apps/admin-web/src/components/AdminSidebarMenu.vue`:
```vue
<script setup lang="ts">
import { adminNavigation } from '@/navigation/adminNavigation'
defineProps<{
activePath: string
collapsed: boolean
}>()
</script>
<template>
<el-menu
class="side-nav-menu"
:default-active="activePath"
:collapse="collapsed"
:default-openeds="['business', 'system']"
router
>
<template v-for="item in adminNavigation" :key="item.key">
<el-sub-menu v-if="item.children?.length" :index="item.key">
<template #title>
<component :is="item.icon" :size="19" :stroke-width="1.8" />
<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.8" />
<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="19" :stroke-width="1.8" />
<template #title>{{ item.title }}</template>
</el-menu-item>
</template>
</el-menu>
</template>
```
- [ ] **Step 4: Run both focused tests and verify GREEN**
Run:
```powershell
pnpm test -- src/navigation/adminNavigation.test.ts src/components/AdminSidebarMenu.test.ts
```
Expected: 4 tests PASS.
### Task 4: Integrate the component into AppLayout
**Files:**
- Modify: `apps/admin-web/src/layouts/AppLayout.vue`
- Modify: `apps/admin-web/src/router/index.ts`
- [ ] **Step 1: Simplify AppLayout imports and state**
Remove the six page icon imports, `adminNavigation`, and `iconMap`. Import the focused component:
```ts
import AdminSidebarMenu from '@/components/AdminSidebarMenu.vue'
```
Add the computed collapse state so the mobile drawer never renders the desktop collapsed menu:
```ts
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
```
- [ ] **Step 2: Replace the flat RouterLink loop**
Replace the contents of `<nav class="side-nav" aria-label="主导航">` with:
```vue
<AdminSidebarMenu :active-path="route.path" :collapsed="menuCollapsed" />
```
Keep the existing route watcher so selecting a menu item closes the mobile drawer.
- [ ] **Step 3: Remove flat navigation metadata from the router**
Delete the `NavigationItem` import and `adminNavigation` array from `apps/admin-web/src/router/index.ts`. Do not change any route path, name, component or permission metadata.
- [ ] **Step 4: Verify all unit tests remain green**
Run:
```powershell
pnpm test
```
Expected: 4 tests PASS with no warnings.
### Task 5: Adapt the existing sidebar styling
**Files:**
- Modify: `apps/admin-web/src/styles/index.css`
- [ ] **Step 1: Replace the flat link styles**
Replace `.side-nav`, `.side-nav-item` and its hover/active rules with:
```css
.side-nav { flex: 1; min-height: 0; overflow-y: auto; padding: 14px 10px; }
.side-nav-menu {
width: 100%;
border-right: 0 !important;
background: transparent !important;
--el-menu-bg-color: transparent;
--el-menu-text-color: #bdc7d7;
--el-menu-active-color: var(--cc-navy);
--el-menu-hover-bg-color: rgba(255,255,255,.08);
}
.side-nav-menu.el-menu--collapse { width: 56px; }
.side-nav-menu .el-menu-item,
.side-nav-menu .el-sub-menu__title {
height: 44px;
margin: 3px 0;
border-radius: 8px;
line-height: 44px;
}
.side-nav-menu .el-menu-item:hover,
.side-nav-menu .el-sub-menu__title:hover { color: #fff; }
.side-nav-menu .el-menu-item.is-active { background: #fff; color: var(--cc-navy); font-weight: 650; }
.side-nav-menu .el-sub-menu .el-menu-item { min-width: 0; padding-left: 48px !important; }
.side-nav-menu .el-menu-item svg,
.side-nav-menu .el-sub-menu__title svg { flex: 0 0 auto; margin-right: 12px; }
```
- [ ] **Step 2: Update the mobile override**
Remove mobile rules that reference `.side-nav-item` and add:
```css
@media (max-width: 720px) {
.side-nav-menu,
.side-nav-menu.el-menu--collapse { width: 100%; }
}
```
Do not alter the existing sidebar transform, scrim, 44px touch targets or reduced-motion behavior.
- [ ] **Step 3: Run static verification**
Run:
```powershell
pnpm test
pnpm typecheck
pnpm build
```
Expected: tests, Vue TypeScript checks and production build all PASS.
### Task 6: Browser and Impeccable verification
**Files:**
- Modify only if a verified defect is found: `apps/admin-web/src/components/AdminSidebarMenu.vue`, `apps/admin-web/src/layouts/AppLayout.vue`, `apps/admin-web/src/styles/index.css`
- [ ] **Step 1: Start the admin development server**
Run:
```powershell
pnpm dev
```
Expected: admin is available at `http://localhost:5173`.
- [ ] **Step 2: Verify desktop expanded and collapsed states in one browser pass**
Check:
- Workbench remains a direct entry.
- Business management contains customers and first-leg orders.
- System management contains users, roles and permissions.
- Active route remains highlighted after direct URL navigation.
- Collapsing the sidebar exposes grouped entries through Element Plus popovers.
- Existing customer, order, user, role and permission interactions still work.
- [ ] **Step 3: Verify the 390px mobile state in the same pass**
Check:
- The menu button opens the drawer.
- Both groups can expand and every leaf is reachable.
- Selecting a leaf closes the drawer.
- Clicking the right-side scrim closes the drawer.
- The page itself has no horizontal viewport overflow.
- [ ] **Step 4: Run the Impeccable detector once**
Run:
```powershell
node C:\Users\BY\.codex\skills\impeccable\scripts\detect.mjs --json src/components/AdminSidebarMenu.vue src/layouts/AppLayout.vue src/styles/index.css
```
Expected: no unverified detector findings remain. Fix verified findings in one batch, then perform at most one confirmation browser pass.
### Task 7: Update project documentation and complete verification
**Files:**
- Modify: `apps/admin-web/README.md`
- Modify: `apps/admin-web/AGENTS.md`
- [ ] **Step 1: Document tests and grouped navigation**
Add:
````markdown
测试:
```bash
pnpm test
```
后台菜单按“业务管理”和“系统管理”分组;当前页面使用 Pinia 模拟数据,不请求尚未确定的 Java 业务接口。
````
Update the quality-gate section of `apps/admin-web/AGENTS.md` to include `pnpm test`, and replace the obsolete statement that the project has no automated test script with a requirement to run unit tests for navigation or other tested behavior changes.
- [ ] **Step 2: Run final verification from admin-web**
Run:
```powershell
pnpm test
pnpm typecheck
pnpm build
```
Expected: all commands exit with code 0 and `apps/admin-web/dist` is refreshed.
- [ ] **Step 3: Confirm repository isolation**
Run from the repository root:
```powershell
Get-ChildItem -Force -File | Where-Object { $_.Name -in @('package.json','pnpm-workspace.yaml','pnpm-lock.yaml') }
```
Expected: no Node package or workspace files exist at the repository root.
@@ -0,0 +1,316 @@
# Admin Permission Tree Sync 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:** Make permission management and role authorization use the same grouped, recursive static permission hierarchy as the admin sidebar while preserving all existing local interactions.
**Architecture:** Add one pure permission-tree utility that builds arbitrary-depth trees, preserves ancestors during filtering, sorts siblings, and safely traverses descendants. Keep the sidebar and permission data independent, then verify their menu permission codes agree in tests; both views consume the utility, while Pinia remains the static data source and no Java API contract is invented.
**Tech Stack:** Vue 3, TypeScript, Pinia, Element Plus, Vitest, Vue Test Utils, Vite
---
### Task 1: Lock the recursive tree contract with tests
**Files:**
- Create: `apps/admin-web/src/utils/permissionTree.test.ts`
- Create: `apps/admin-web/src/utils/permissionTree.ts`
- [ ] **Step 1: Write failing tests for recursive structure and sibling sorting**
Create fixtures with a directory, menu, and button in deliberately unsorted input order. Import the wished-for `buildPermissionTree` function and assert that it returns all three levels and sorts each sibling group by `sort`.
```ts
import { describe, expect, it } from 'vitest'
import type { Permission } from '@/types/admin'
import { buildPermissionTree } from '@/utils/permissionTree'
const permissions: Permission[] = [
{ id: 'action-b', parentId: 'menu', name: '操作 B', code: 'demo:b', type: '按钮', path: '', method: '-', status: '启用', sort: 20 },
{ id: 'menu', parentId: 'directory', name: '页面', code: 'demo:list', type: '菜单', path: '/demo', method: '-', status: '启用', sort: 10 },
{ id: 'directory', parentId: null, name: '目录', code: 'demo', type: '目录', path: '', method: '-', status: '启用', sort: 10 },
{ id: 'action-a', parentId: 'menu', name: '操作 A', code: 'demo:a', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
]
it('builds arbitrary-depth trees and sorts siblings', () => {
const tree = buildPermissionTree(permissions)
expect(tree[0]?.children?.[0]?.children?.map((node) => node.id)).toEqual(['action-a', 'action-b'])
})
```
- [ ] **Step 2: Run the focused test and verify RED**
Run: `pnpm test -- src/utils/permissionTree.test.ts`
Expected: FAIL because `@/utils/permissionTree` does not exist.
- [ ] **Step 3: Add failing tests for ancestor-preserving search and cycle-safe descendants**
Add assertions that a matcher for `demo:a` retains `directory -> menu -> action-a`, and that `getDescendantIds` returns each reachable child once even when malformed fixture data includes a cycle.
```ts
import { buildPermissionTree, getDescendantIds } from '@/utils/permissionTree'
it('keeps the ancestor chain when a deep descendant matches', () => {
const tree = buildPermissionTree(permissions, (item) => item.code === 'demo:a')
expect(tree.map((node) => node.id)).toEqual(['directory'])
expect(tree[0]?.children?.[0]?.children?.map((node) => node.id)).toEqual(['action-a'])
})
it('collects descendants once when malformed data contains a cycle', () => {
const malformed = [
...permissions,
{ id: 'cycle-a', parentId: 'cycle-b', name: 'A', code: 'cycle:a', type: '目录', path: '', method: '-', status: '启用', sort: 1 },
{ id: 'cycle-b', parentId: 'cycle-a', name: 'B', code: 'cycle:b', type: '目录', path: '', method: '-', status: '启用', sort: 2 },
] satisfies Permission[]
expect(getDescendantIds(malformed, 'cycle-a')).toEqual(['cycle-b'])
})
```
- [ ] **Step 4: Implement the minimal recursive utility**
Create `permissionTree.ts` with a `PermissionTreeNode` type, a sorted parent index, path-local cycle protection for tree construction, ancestor-preserving matching, and breadth-first descendant traversal whose visited set starts with the selected node.
```ts
import type { Permission } from '@/types/admin'
export type PermissionTreeNode = Permission & { children?: PermissionTreeNode[] }
export type PermissionMatcher = (permission: Permission) => boolean
function groupByParent(permissions: Permission[]) {
const childrenByParent = new Map<string | null, Permission[]>()
permissions.forEach((permission) => {
const siblings = childrenByParent.get(permission.parentId) ?? []
siblings.push(permission)
childrenByParent.set(permission.parentId, siblings)
})
childrenByParent.forEach((siblings) => siblings.sort((left, right) => left.sort - right.sort))
return childrenByParent
}
export function buildPermissionTree(permissions: Permission[], matches: PermissionMatcher = () => true): PermissionTreeNode[] {
const childrenByParent = groupByParent(permissions)
const buildNode = (permission: Permission, ancestors: Set<string>): PermissionTreeNode | null => {
if (ancestors.has(permission.id)) return null
const nextAncestors = new Set(ancestors).add(permission.id)
const children = (childrenByParent.get(permission.id) ?? [])
.map((child) => buildNode(child, nextAncestors))
.filter((child): child is PermissionTreeNode => child !== null)
if (!matches(permission) && children.length === 0) return null
return { ...permission, ...(children.length ? { children } : {}) }
}
return (childrenByParent.get(null) ?? [])
.map((permission) => buildNode(permission, new Set()))
.filter((node): node is PermissionTreeNode => node !== null)
}
export function getDescendantIds(permissions: Permission[], permissionId: string): string[] {
const childrenByParent = groupByParent(permissions)
const visited = new Set([permissionId])
const descendants: string[] = []
const queue = [...(childrenByParent.get(permissionId) ?? [])]
while (queue.length) {
const current = queue.shift()
if (!current || visited.has(current.id)) continue
visited.add(current.id)
descendants.push(current.id)
queue.push(...(childrenByParent.get(current.id) ?? []))
}
return descendants
}
```
- [ ] **Step 5: Run the focused test and verify GREEN**
Run: `pnpm test -- src/utils/permissionTree.test.ts`
Expected: all permission-tree utility tests pass with no warnings.
### Task 2: Restructure the static permission and role fixtures
**Files:**
- Modify: `apps/admin-web/src/stores/adminData.ts`
- Create: `apps/admin-web/src/stores/adminData.test.ts`
- [ ] **Step 1: Write failing tests for the grouped hierarchy**
Activate a fresh Pinia instance, read `useAdminDataStore()`, build its tree, and assert the roots are `工作台`, `业务管理`, and `系统管理`; assert `业务管理` owns the customer/order pages and `系统管理` owns the three system pages.
```ts
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useAdminDataStore } from '@/stores/adminData'
import { buildPermissionTree } from '@/utils/permissionTree'
beforeEach(() => setActivePinia(createPinia()))
it('groups page permissions under business and system directories', () => {
const tree = buildPermissionTree(useAdminDataStore().permissions)
expect(tree.map((node) => node.name)).toEqual(['工作台', '业务管理', '系统管理'])
expect(tree[1]?.children?.map((node) => node.name)).toEqual(['客户管理', '头程订单'])
expect(tree[2]?.children?.map((node) => node.name)).toEqual(['内部用户', '角色管理', '菜单权限'])
})
```
- [ ] **Step 2: Write a failing navigation consistency test**
Compare all `菜单` permission codes with `getNavigationLeaves(adminNavigation)` and assert both sets are equal, proving visible routes and static permission resources cannot silently drift.
```ts
it('keeps menu permission codes aligned with navigation leaves', () => {
const store = useAdminDataStore()
const menuCodes = store.permissions.filter((item) => item.type === '菜单').map((item) => item.code).sort()
const navigationCodes = getNavigationLeaves(adminNavigation).map((item) => item.permission).sort()
expect(menuCodes).toEqual(navigationCodes)
})
```
- [ ] **Step 3: Run store tests and verify RED**
Run: `pnpm test -- src/stores/adminData.test.ts`
Expected: FAIL because current permissions expose five flat roots and contain redundant view nodes.
- [ ] **Step 4: Replace the permission fixtures**
Use stable IDs and the confirmed hierarchy:
```text
P001 工作台 (菜单)
P010 业务管理 (目录)
P100 客户管理 (菜单)
P102 新增客户 (按钮)
P103 审核客户 (按钮)
P200 头程订单 (菜单)
P202 创建订单 (按钮)
P203 更新状态 (按钮)
P020 系统管理 (目录)
P300 内部用户 (菜单)
P302 维护用户 (按钮)
P400 角色管理 (菜单)
P402 角色授权 (按钮)
P500 菜单权限 (菜单)
P502 维护权限 (按钮)
```
Menu nodes keep their real route paths and route permission codes. Directory and button nodes use an empty path and `method: '-'`; do not add `接口` fixtures or `/api/...` paths before the Java contract exists.
- [ ] **Step 5: Update all five role permission ID lists**
Keep `R001` mapped to all resources. Include grouping directory IDs for the other roles and grant only the confirmed menu/action resources described by the design document.
```ts
R002: P001, P010, P100, P103, P200, P203
R003: P001, P010, P200, P202, P203
R004: P001, P010, P200
R005: P001, P010, P100, P102, P200
```
- [ ] **Step 6: Run store and full tests and verify GREEN**
Run: `pnpm test -- src/stores/adminData.test.ts`
Expected: grouped hierarchy and navigation consistency tests pass.
Run: `pnpm test`
Expected: all existing navigation/sidebar tests and new tests pass.
### Task 3: Integrate the recursive utility into both views
**Files:**
- Modify: `apps/admin-web/src/views/PermissionsView.vue`
- Modify: `apps/admin-web/src/views/RolesView.vue`
- Read before UI edits: `C:/Users/BY/.codex/skills/impeccable/reference/audit.md`
- Read before UI edits: `C:/Users/BY/.codex/skills/impeccable/reference/craft-floor.md`
- [ ] **Step 1: Replace the permission page's local two-level builder**
Import `buildPermissionTree` and `getDescendantIds`. Compute `treeRows` with `buildPermissionTree(store.permissions, matches)` so filters preserve full ancestor chains. Compute a blocked ID set from `form.id` plus all descendants and exclude those resources from `parentOptions`.
```ts
const treeRows = computed(() => buildPermissionTree(store.permissions, matches))
const blockedParentIds = computed(() => new Set(form.id ? [form.id, ...getDescendantIds(store.permissions, form.id)] : []))
const parentOptions = computed(() => store.permissions.filter((item) =>
['目录', '菜单'].includes(item.type) && !blockedParentIds.value.has(item.id),
))
```
- [ ] **Step 2: Update permission-page copy for static API scope**
Keep all existing interactions, but change copy that implies interface resources are already active. The table description should say API permissions will be added after the service contract is fixed, the fourth metric should show `0` interface permissions as reserved, and path labels/placeholders should distinguish routes from future interfaces without fabricating endpoints.
- [ ] **Step 3: Replace the role page's local two-level builder**
Import `buildPermissionTree`, map its recursive nodes to the Element Plus node shape through one recursive mapper, and keep `default-expand-all`, checked keys, half-checked keys, and save behavior unchanged.
```ts
function toRolePermissionNode(node: PermissionTreeNode): RolePermissionTreeNode {
return {
id: node.id,
label: node.name,
code: node.code,
...(node.children?.length ? { children: node.children.map(toRolePermissionNode) } : {}),
}
}
const permissionTree = computed(() => buildPermissionTree(store.permissions).map(toRolePermissionNode))
```
- [ ] **Step 4: Correct role permission summaries**
Count `菜单` items only as menus and count `按钮` plus future `接口` items as operations. Do not count `目录` nodes as menu entries.
```ts
const menuCount = store.permissions.filter((item) => row.permissionIds.includes(item.id) && item.type === '菜单').length
const actionCount = store.permissions.filter((item) => row.permissionIds.includes(item.id) && ['按钮', '接口'].includes(item.type)).length
```
- [ ] **Step 5: Run tests and type checking**
Run: `pnpm test`
Expected: all tests pass.
Run: `pnpm typecheck`
Expected: Vue and TypeScript checks exit successfully with no errors.
### Task 4: Verify production and real interactions
**Files:**
- Verify: `apps/admin-web/src/views/PermissionsView.vue`
- Verify: `apps/admin-web/src/views/RolesView.vue`
- [ ] **Step 1: Run the production build**
Run: `pnpm build`
Expected: `vue-tsc --noEmit` and `vite build` both exit with code 0; output stays within `apps/admin-web/dist`.
- [ ] **Step 2: Start or reuse the admin development server**
Run: `pnpm dev`
Expected: Vite serves the admin application on an available local port, normally `http://localhost:5173`.
- [ ] **Step 3: Perform one bounded browser inspection pass**
At desktop width, inspect `/system/permissions` and verify three roots, deep search ancestor preservation, editing, adding a child, and exclusion of self/descendants in the parent selector. Open `/system/roles`, configure a role, and verify the grouped authorization tree, checked/half-checked state, and corrected summary.
At 390px width, verify both pages have no incoherent overlap, drawers remain operable, the tree hierarchy is readable, and table horizontal scrolling remains available.
- [ ] **Step 4: Run Impeccable detector once**
Run the detector command documented by the loaded Impeccable audit reference against the modified admin UI. Fix only actionable findings caused by this change in one batch.
- [ ] **Step 5: Perform at most one confirmation pass**
Recheck only the affected routes and any fixed findings. Stop after this pass per the bounded Impeccable verification rule.
- [ ] **Step 6: Record final verification evidence**
Report exact test counts, type-check/build exit status, browser paths and viewport sizes, and any remaining risk. State explicitly that `src/api/http.ts` remains only the transport/auth wrapper and domain APIs are deferred until the Java contract is known.
> Git commits are intentionally omitted: `C:/Users/BY/Desktop/code/chongchong-global` is not a Git repository, so worktrees, commits, merges, and pull requests are unavailable.
@@ -0,0 +1,396 @@
# 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 回退证据。
@@ -0,0 +1,259 @@
# PDA 拣货静态页面 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:** 将 PDA 拣货 HTML 原型完整迁移为 Android PDA 可用的 uni-app 本地交互页面。
**Architecture:** 首页承载登录态和拣货态,业务数据与 UI 状态保留在页面中;可独立验证的状态计算放在 `utils/picking-state.js`。不引入后端、状态管理库、厂商 SDK 或新 npm 依赖。
**Tech Stack:** uni-app、Vue Options API、SCSS、Node.js 内置测试运行器
---
当前目录没有 Git 元数据,因此计划不包含提交步骤;本机也没有 HBuilderX 且项目没有 `package.json`,不得声称 App 构建已通过。
### Task 1: 用失败测试锁定拣货状态规则
**Files:**
- Create: `apps/pda-uniapp/tests/picking-state.test.mjs`
- Create: `apps/pda-uniapp/tests/page-contract.test.mjs`
- Create later: `apps/pda-uniapp/utils/picking-state.js`
- [ ] **Step 1: 编写状态测试**
测试通过 `data:` URL 加载 ES module,使源文件继续使用 uni-app 兼容的 `export` 语法且无需新增 `package.json`。
```js
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const source = await readFile(new URL('../utils/picking-state.js', import.meta.url), 'utf8')
const state = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`)
test('createItemStates initializes every item as pending', () => {
assert.deepEqual(state.createItemStates([{ id: 1 }, { id: 2 }]), { 1: 'pending', 2: 'pending' })
})
test('getPickSummary reproduces prototype submit rules', () => {
assert.equal(state.getPickSummary([{ id: 1 }, { id: 2 }], { 1: 'pending', 2: 'pending' }).canSubmit, false)
assert.equal(state.getPickSummary([{ id: 1 }, { id: 2 }], { 1: 'picked', 2: 'pending' }).buttonText,
'提交拣货完成(1件待拣将视为未拣)')
})
test('submitStates turns pending items into skipped', () => {
assert.deepEqual(state.submitStates([{ id: 1 }, { id: 2 }], { 1: 'picked', 2: 'pending' }), {
states: { 1: 'picked', 2: 'skipped' },
autoSkippedIds: [2],
})
})
```
同时创建页面契约测试,锁定自定义导航、最小权限和原型工作流入口:
```js
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
test('page configuration uses the custom prototype header', async () => {
const pages = JSON.parse(await readFile(new URL('../pages.json', import.meta.url), 'utf8'))
assert.equal(pages.pages[0].style.navigationStyle, 'custom')
})
test('static prototype does not request unused Android permissions', async () => {
const manifest = await readFile(new URL('../manifest.json', import.meta.url), 'utf8')
assert.doesNotMatch(manifest, /android\.permission\.(VIBRATE|ACCESS_NETWORK_STATE)/)
})
test('page contains every required workflow entry', async () => {
const page = await readFile(new URL('../pages/index/index.vue', import.meta.url), 'utf8')
for (const text of ['登录', '确认拣出', '部分拣货', '跳拣', '提交拣货完成', '下一箱']) {
assert.match(page, new RegExp(text))
}
})
```
- [ ] **Step 2: 运行测试并确认正确失败**
Run: `node --test tests/*.test.mjs`(工作目录 `apps/pda-uniapp`)
Expected: FAIL;状态测试因 `utils/picking-state.js` 尚不存在,页面契约测试因当前仍是官方示例页、原生导航和未使用权限而失败。
### Task 2: 实现纯状态模块
**Files:**
- Create: `apps/pda-uniapp/utils/picking-state.js`
- Test: `apps/pda-uniapp/tests/picking-state.test.mjs`
- [ ] **Step 1: 实现原型状态计算**
```js
export const ITEM_STATUS = Object.freeze({
PENDING: 'pending',
PICKED: 'picked',
PARTIAL: 'partial',
SKIPPED: 'skipped',
})
export function createItemStates(items) {
return items.reduce((states, item) => {
states[item.id] = ITEM_STATUS.PENDING
return states
}, {})
}
export function getPickSummary(items, states) {
const summary = { total: items.length, picked: 0, partial: 0, skipped: 0, pending: 0 }
items.forEach((item) => { summary[states[item.id] || ITEM_STATUS.PENDING] += 1 })
summary.processed = summary.picked + summary.partial + summary.skipped
summary.progress = summary.total ? Math.round(summary.processed / summary.total * 100) : 0
summary.canSubmit = summary.total > 0 && (summary.pending === 0 || summary.picked > 0 || summary.partial > 0)
summary.buttonText = summary.pending > 0 && (summary.picked > 0 || summary.partial > 0)
? `提交拣货完成(${summary.pending}件待拣将视为未拣)`
: '提交拣货完成'
return summary
}
export function submitStates(items, states) {
const nextStates = { ...states }
const autoSkippedIds = []
items.forEach((item) => {
if ((nextStates[item.id] || ITEM_STATUS.PENDING) === ITEM_STATUS.PENDING) {
nextStates[item.id] = ITEM_STATUS.SKIPPED
autoSkippedIds.push(item.id)
}
})
return { states: nextStates, autoSkippedIds }
}
```
- [ ] **Step 2: 运行状态测试**
Run: `node --test tests/picking-state.test.mjs`
Expected: 3 tests passed, 0 failed。
### Task 3: 迁移原型页面与本地交互
**Files:**
- Modify: `apps/pda-uniapp/pages/index/index.vue`
- Import: `apps/pda-uniapp/utils/picking-state.js`
- [ ] **Step 1: 建立页面状态和演示数据**
页面使用 Options API,并导入状态模块:
```js
import { ITEM_STATUS, createItemStates, getPickSummary, submitStates } from '@/utils/picking-state.js'
export default {
data() {
return {
currentUser: null,
currentBoxIndex: 0,
itemStates: {},
partialQuantities: {},
completedBoxes: {},
scanValue: '',
activeOverlay: '',
boxDropdownOpen: false,
toast: { visible: false, message: '', type: '' },
}
},
}
```
演示账号、三组箱号、货品和五个跳拣原因逐项复制自 `docs/悦/PDA拣货/pda-picking.html`。
- [ ] **Step 2: 实现登录态和拣货态模板**
模板必须包含以下层级和原型文案:
```vue
<template>
<view class="page-shell">
<view v-if="!currentUser" class="login-page"><!-- 登录表单与快捷账号 --></view>
<view v-else class="work-page"><!-- 自定义头部、箱号选择、扫码、任务列表、统计与提交栏 --></view>
<view v-if="activeOverlay" class="modal-overlay"><!-- 当前业务弹层 --></view>
<view v-if="toast.visible" class="app-toast">{{ toast.message }}</view>
</view>
</template>
```
- [ ] **Step 3: 实现原型交互方法**
实现并绑定 `login`、`quickLogin`、`logout`、`selectBox`、`handleScan`、`pickItem`、`undoItem`、`openPartial`、`confirmPartial`、`openSkip`、`confirmSkip`、`openBoxList`、`submitPicking`、`nextBox`、`closeOverlay`、`restoreScanFocus` 和 `onBackPress`。
扫码输入使用 `:focus="scanFocused"` 配合短暂置 false/true 恢复焦点;部分数量输入打开时接管焦点。系统返回依次关闭弹层、下拉并执行统一退出逻辑。
- [ ] **Step 4: 完整迁移原型样式**
样式使用原型色值和密度:
```scss
$primary: #1a73e8;
$success: #0d9488;
$danger: #dc2626;
$warning: #f59e0b;
$page-bg: #f5f7fa;
$text: #1f2937;
$text-secondary: #6b7280;
$border: #e5e7eb;
```
覆盖 `320–480px` 常见 PDA 宽度、状态栏安全区、底部导航栏安全区、固定提交栏和软键盘场景。不得引入新视觉方向。
### Task 4: 修正 Android 页面配置与权限
**Files:**
- Modify: `apps/pda-uniapp/pages.json`
- Modify: `apps/pda-uniapp/manifest.json`
- Modify: `apps/pda-uniapp/App.vue`
- Modify: `apps/pda-uniapp/README.md`
- [ ] **Step 1: 使用自定义导航并统一页面背景**
```json
{
"path": "pages/index/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "集装箱拣货"
}
}
```
`App.vue` 移除官方示例日志与 `.example-info`,全局页面背景改为 `#f5f7fa`。
- [ ] **Step 2: 移除当前未使用的手工权限**
`manifest.json` 的 Android `permissions` 改为空数组;本阶段不使用网络、震动、相机或存储。
- [ ] **Step 3: 记录可执行验证命令和剩余风险**
README 记录 `node --test tests/picking-state.test.mjs`,并说明本机无 HBuilderX,Android App 构建与真机扫描头尚未验证。
### Task 5: 自动检查与原型验收
**Files:**
- Verify: `apps/pda-uniapp/tests/page-contract.test.mjs`
- Verify: `apps/pda-uniapp/pages/index/index.vue`
- Verify: `apps/pda-uniapp/pages.json`
- Verify: `apps/pda-uniapp/manifest.json`
- [ ] **Step 1: 运行全部自动检查**
Run: `node --test tests/*.test.mjs`
Expected: 所有测试通过,0 failed。
- [ ] **Step 2: 对照原型逐项验收**
检查登录、快捷账号、仓库选择、三个箱号、有效/无效/重复扫码、确认拣出、部分数量校验、跳拣原因、撤销、提交启用规则、完成统计、返回查看、下一箱、弹层关闭和系统返回。
- [ ] **Step 3: 运行机械 UI 检测并记录限制**
Run: `node C:\Users\BY\.codex\skills\impeccable\scripts\detect.mjs --json apps/pda-uniapp/pages/index/index.vue apps/pda-uniapp/App.vue`
Expected: 不存在阻塞级设计规则问题。Android 构建、真机截图、扫描头、弱网和前后台切换因当前环境缺少 HBuilderX 与目标设备,必须在交付说明中列为未验证。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
# Admin 导航分组与静态交互设计
## 目标
将 admin 当前全部平铺的一级菜单改为可扩展的二级分组导航,同时保留现有静态页面和 Pinia 交互。Java 接口及响应结构尚未确定,本阶段不提前设计领域 API 契约。
## 范围
本次只处理 `apps/admin-web`:
- 调整侧边栏信息架构与折叠交互。
- 保留现有页面、路由地址和模拟数据行为。
- 增加 admin 项目内部测试,不在仓库根目录增加 Node 配置或依赖。
不处理 client、小程序、APP、PDA 或 Java 服务;不猜测最终后端 URL、响应信封、业务错误码或分页格式。
## 导航结构
采用两级分组,工作台保留为直接入口:
```text
工作台
业务管理
├── 客户管理
└── 头程订单
系统管理
├── 内部用户
├── 角色管理
└── 菜单权限
```
侧边栏使用 Element Plus 的菜单和子菜单能力,以获得稳定的展开、折叠、当前路由高亮和折叠态浮层。页面路由地址保持不变。
菜单配置放在 `src/navigation/adminNavigation.ts`,使用 admin 本地的 `AdminNavigationNode` 类型,避免修改共享包中的单层 `NavigationItem` 并影响 client。节点包含唯一标识、标题、图标、路径、权限编码和可选子节点。图标跟随节点配置,不再依赖 `iconMap[index]` 的数组位置。
当前静态阶段显示全部菜单,不根据模拟角色执行权限过滤,因为现有登录用户没有后端返回的权限编码集合。节点保留权限编码字段;后端权限契约确定后,再以叶子菜单为单位过滤,并只显示至少包含一个可访问子菜单的分组。
移动端继续使用现有抽屉式侧栏:选择页面后自动关闭,点击右侧遮罩关闭。桌面折叠时由 Element Plus 展示子菜单浮层,避免二级入口丢失。
## 静态数据与交互
客户、订单、内部用户、角色和权限页面继续使用 `useAdminDataStore`:
- 查询、筛选和分页在前端执行。
- 新增、编辑、审核、启停和状态流转直接修改 Pinia 内存数据。
- 刷新页面后恢复初始模拟数据,不增加持久化。
- 页面不发起真实业务网络请求。
该策略用于先验收页面结构和操作流程,不伪装成已完成后端联调。
## API 当前边界
保留现有 `src/api/http.ts` 作为 Axios 基础实例,仅包含基础地址、超时和 admin Token 注入。当前不新增客户、订单、用户、角色或权限领域 API,不定义响应信封解包,不实现业务错误转换,也不在 401 拦截器中直接引用 Router 或 Pinia Store。
后端提供接口文档后,再单独确定以下内容:
- URL 前缀和版本规则。
- 统一响应信封及成功业务码。
- 分页参数和分页结果结构。
- 业务错误码与用户提示映射。
- 401 处理方式;若需要全局退出,使用应用启动层注册的 `onUnauthorized` 回调,避免请求层与 Router、Store 循环依赖。
- 按认证、客户、头程订单、内部用户、角色和权限拆分领域模块。
真实接口接入后,由 Store 调用领域 API,并负责加载状态、错误状态和数据缓存。页面继续读取 Store,不直接调用 Axios。
## 测试与验收
在 `apps/admin-web` 内增加 Vitest,不影响其他项目。测试覆盖:
- 导航只有工作台是直接一级入口,业务管理和系统管理拥有预期子菜单。
- 每个叶子菜单的路径和权限编码唯一。
- 菜单图标由节点自身定义,不依赖数组下标。
- 静态阶段不因权限字段存在而隐藏任何菜单。
完成后执行:
```bash
cd apps/admin-web
pnpm test
pnpm typecheck
pnpm build
```
浏览器验收桌面展开态、桌面折叠态和 390px 移动端,确认二级菜单可访问、当前页面正确高亮、移动侧栏可关闭,并且原有业务交互不回归。
## 完成标准
- admin 菜单不再全部平铺在一级。
- 新增业务模块时可以放入既有分组或新增分组,不需要改写侧栏组件。
- 当前六个页面入口全部可达且路由不变。
- 静态页面的筛选、表单、详情和状态操作继续可用。
- 页面不会请求不存在的 Java 业务接口,也不会固化未经确认的响应结构。
- admin 的测试、类型检查和生产构建全部通过。
@@ -0,0 +1,100 @@
# Admin 权限树分组同步设计
## 目标
让权限管理和角色授权中的资源层级与已完成的 admin 侧边栏一致,并支持后续继续增加更深层级,而不需要修改页面的树构建逻辑。
## 方案选择
有三种可选方式:
1. 在权限页面和角色页面分别编写递归逻辑。改动直接,但会产生两份容易漂移的实现。
2. 使用共享的权限树构建器。两个页面消费同一棵树,筛选、排序和层级行为一致,作为本次采用方案。
3. 直接从侧边栏导航生成权限树。可以保证菜单一致,但导航不包含按钮和接口权限,且未来后端动态权限尚未确定,不适合当前阶段。
## 权限层级
静态权限数据调整为:
```text
工作台
业务管理
├── 客户管理
│ ├── 新增客户
│ └── 审核客户
└── 头程订单
├── 创建订单
└── 更新状态
系统管理
├── 内部用户
│ └── 维护用户
├── 角色管理
│ └── 角色授权
└── 菜单权限
└── 维护权限
```
“业务管理”和“系统管理”是目录;“工作台、客户管理、头程订单、内部用户、角色管理、菜单权限”是菜单;具体操作是按钮权限。菜单本身使用现有路由权限编码,例如 `customer:list` 和 `system:role:list`,不再额外保留同义的“查看客户”“查看角色”节点。
接口权限待 Java 接口契约确定后再补充。本次不根据静态 URL 猜测接口资源;现有按钮权限上的临时 `/api/...` 路径和 HTTP 方法一并清空,按钮只保留稳定的权限编码。
## 共享树构建器
在 `src/utils/permissionTree.ts` 提供纯函数:
- 根据 `parentId` 递归构建任意深度的权限树。
- 同级按 `sort` 排序。
- 接收可选匹配函数;子节点匹配时保留完整祖先链。
- 防止异常循环引用造成无限递归。
- 提供后代 ID 查询,编辑上级资源时排除自身及全部后代,避免创建循环。
权限管理页面使用完整 `Permission` 节点,以便编辑、启停和添加下级。角色页面将同一结果映射为 Element Plus 树需要的 `id`、`label`、`code` 和 `children`。
## 静态角色授权
同步更新五个模拟角色的 `permissionIds`:
- 超级管理员拥有全部资源。
- 运营主管拥有工作台、业务管理、客户审核、订单查看和状态处理。
- 操作员拥有工作台及头程订单创建、处理权限。
- 财务拥有工作台及头程订单查看权限。
- 销售拥有工作台、客户维护及头程订单查看权限。
分组目录 ID 一并保留在角色授权中,以确保回显和父子半选状态明确。
## 页面行为
权限管理:
- 默认展开完整三级树。
- 搜索命中按钮时仍显示所属菜单及目录。
- 新增下级、编辑、启停继续使用现有静态交互。
- 编辑上级资源时不能选择自身或自己的后代。
角色管理:
- 授权抽屉展示相同的目录、菜单和按钮层级。
- 保存时继续合并已选和半选节点。
- 权限摘要按菜单与操作分别计数,不把目录计入菜单数量。
## 测试与验收
使用 admin 现有 Vitest:
- 先写失败测试固定三级结构和同级排序。
- 验证搜索命中深层节点时保留祖先链。
- 验证循环引用不会导致递归失控。
- 验证后代 ID 查询能阻止把节点移动到自己的后代下。
- 验证静态权限编码与导航叶子权限编码一致。
最后执行 `pnpm test`、`pnpm typecheck`、`pnpm build`,并在浏览器检查权限管理页面和角色授权抽屉的桌面及移动端层级展示。UI 改动完成后运行一次 Impeccable 检测。
## 完成标准
- 权限管理和角色授权不再显示五个独立一级业务目录。
- 两个页面使用同一个递归树构建器。
- 搜索、排序、授权回显和静态编辑交互保持可用。
- 不新增真实 API 调用或猜测后端响应格式。
- 测试、类型检查、构建及浏览器验收通过。
@@ -0,0 +1,168 @@
# Client Web 导航分级与移动侧栏设计
## 背景
`apps/client-web` 当前把业务总览、企业资料、企业成员、角色管理、菜单权限和头程订单全部渲染为同级侧栏链接。权威原型 `docs/悦/头程下单/柏悦客户端.html` 采用“一级业务域 + 二级具体任务”的导航结构,现有实现没有延续这一信息架构,也无法承接后续业务扩展。
Impeccable 验收还发现一个阻断性问题:720px 以下侧栏被隐藏,但页面上的菜单按钮没有打开移动导航的行为,导致移动端无法切换模块。
## 目标
- 将客户工作台的平铺菜单改为可扩展的两级分组导航。
- 保留现有路由、页面、文案、品牌颜色与 Element Plus 技术体系。
- 修复移动端导航不可用的问题。
- 让菜单节点显式携带图标与权限信息,避免依赖数组下标。
- 为后续真实权限集合接入保留清晰边界,不在本次伪造授权数据。
## 非目标
- 不新增原型中尚未实现的一键导入、快速下单、海外仓或财务结算入口。
- 不改变现有路由地址,不拆分当前 `ModuleView.vue` 页面。
- 不照搬原型的紫色渐变视觉,不重做登录、注册或业务内容页。
- 不实现服务端权限校验,也不把前端菜单隐藏当作安全控制。
- 不新增测试框架;当前项目尚未声明测试脚本。
## 导航信息架构
采用两级分组,业务总览保留直接入口:
```text
业务总览 /
头程业务 可展开分组
└── 头程订单 /orders/first-leg
企业管理 可展开分组
├── 企业资料 /company/profile
├── 企业成员 /organization/members
├── 角色管理 /organization/roles
└── 菜单权限 /organization/permissions
```
分组遵循原型的结构原则,但只呈现当前真实存在的客户端路由。后续功能只有在页面与路由实现后,才加入对应业务分组。
## 组件与数据结构
### 导航配置
新增客户端本地导航配置 `src/navigation/clientNavigation.ts`。定义客户端专用的 `ClientNavigationNode`,包含:
- 稳定 `key`;
- 用户可见 `title`;
- Lucide 图标组件;
- 可选 `path`;
- 可选 `permission`;
- 可选 `children`。
共享包中的 `NavigationItem` 仍保持单层公共模型,避免为了单个应用的壳层结构扩大跨项目契约。
### 侧栏菜单组件
新增独立的客户端侧栏菜单组件,使用 Element Plus 的 `el-menu`、`el-sub-menu` 与 `el-menu-item`:
- `active-path` 由当前路由提供;
- 父分组可展开和收起;
- 当前路由所在分组默认展开;
- 叶子菜单负责实际路由跳转;
- 图标来自节点配置,不依赖渲染顺序;
- 桌面与移动端复用同一组件和同一份导航数据。
本轮不额外实现展开状态持久化;Element Plus 在当前会话中维护用户开合状态,且当前路由所在分组始终可识别。持久化不是修复平铺结构与移动阻断所必需的能力。
### 应用壳层
`AppLayout.vue` 继续负责品牌、租户、用户和页面标题,同时增加:
- `mobileMenuOpen` 状态;
- 移动菜单按钮的 `aria-expanded`;
- 遮罩关闭;
- 路由切换后自动关闭;
- Escape 键关闭;
- 打开时阻止背景页面滚动;
- 退出确认取消时不产生未处理异常。
桌面侧栏保持固定宽度和现有绿色服务型视觉,不引入后台的深色侧栏,也不增加桌面折叠模式。
## 交互与状态流
### 桌面端
1. 路由加载后,菜单以 `route.path` 高亮对应叶子。
2. “业务总览”为直接入口。
3. “头程业务”和“企业管理”为分组触发器。
4. 当前页面属于某分组时,该分组保持展开并显示父级激活语义。
5. 用户可以独立展开或收起其他分组。
### 移动端
1. 720px 以下侧栏移出视口,而不是使用 `display: none` 永久移除。
2. 点击页面标题前的菜单按钮打开抽屉并显示遮罩。
3. 点击遮罩、按 Escape 或选择任一叶子路由后关闭抽屉。
4. 抽屉打开时禁止背景滚动,关闭或组件卸载时恢复。
5. 抽屉沿用完整租户导航,不维护第二份移动菜单。
### 权限
导航节点继续携带权限编码。当前模拟用户没有可靠的权限集合,因此静态阶段显示全部已实现菜单。真实认证接口返回权限集合后,再增加叶子过滤;父分组没有可访问子项时隐藏。服务端必须继续执行租户与权限校验。
## 视觉与可访问性
- 保留现有 `--primary`、`--navy`、`--border` 和 `--muted` 设计令牌。
- 一级分组与二级叶子通过缩进、字号、颜色和激活背景形成清晰层级。
- 分组触发器与移动菜单按钮使用真实可聚焦控件。
- 保持可见 `:focus-visible` 状态,不只依赖 hover 或颜色表达当前状态。
- 菜单触控目标不低于约 44px。
- 二级菜单文本允许单行省略,避免长租户业务名称破坏侧栏宽度。
- 侧栏导航区域允许纵向滚动;底部帮助区保持可达。
- 320px、720px 与桌面宽度均不能依赖水平滚动完成导航。
## 错误与边界处理
- 用户取消退出确认时保持当前页面,不产生控制台未处理异常。
- 移动抽屉在快速路由切换、浏览器后退和组件卸载后不得残留锁定滚动状态。
- 不存在或被移除的路由继续走现有兜底重定向。
- 当前不虚构客户经理电话、微信或帮助中心链接;帮助区保留为信息提示,后续有真实渠道后再做可执行入口。
- 权限为空或数据尚未加载时,本轮不提前隐藏菜单,避免模拟阶段出现无法恢复的空侧栏。
## 实施范围
预计修改或新增:
- `apps/client-web/src/navigation/clientNavigation.ts`
- `apps/client-web/src/components/ClientSidebarMenu.vue`
- `apps/client-web/src/layouts/AppLayout.vue`
- `apps/client-web/src/router/index.ts`
- `apps/client-web/src/styles/index.css`
仅当实现需要声明自动导入组件时,依赖现有插件自动生成声明,不手工编辑 `auto-imports.d.ts` 或 `components.d.ts`。
## 验收与验证
### 自动验证
在 `apps/client-web` 执行:
```bash
pnpm typecheck
pnpm build
```
项目当前没有 test 或 lint 脚本,因此不声称相应检查通过,也不为本次导航改动引入整套测试设施。
### Impeccable 验收
- 运行一次机械检测,目标覆盖本次变更的 Vue 文件。
- 桌面检查:一级分组、二级缩进、当前路由高亮、可见焦点和侧栏滚动。
- 720px 检查:断点切换时导航仍可达。
- 320px 检查:抽屉、遮罩、触控目标与页面内容不发生水平溢出。
- 浏览器可用时检查菜单打开、选择路由、遮罩关闭和 Escape 关闭;若浏览器不可用,明确记录回退证据,不伪装成已完成视觉实测。
## 完成标准
- 侧栏不再把全部功能放在一级菜单。
- 当前六个页面入口全部可达,路由地址不变。
- 桌面端当前页面与父分组状态清晰。
- 720px 以下菜单按钮能够打开和关闭真实导航抽屉。
- 导航图标不会因菜单调序或新增分组而错位。
- 未实现的原型功能不会作为无效入口出现。
- 类型检查、生产构建与 Impeccable 检测无未解释失败。
@@ -0,0 +1,105 @@
# PDA 拣货静态页面设计
## 目标
在 `apps/pda-uniapp` 中将 `docs/悦/PDA拣货/pda-picking.html` 转换为可运行在 Android PDA 上的 uni-app 页面。原型是唯一视觉和交互依据;实现保留原型文案、布局、颜色、页面密度、演示数据和本地状态流。
本次不接后端、不实现真实鉴权、不接厂商扫描 SDK、不实现持久化或离线队列。
## 实现方案
采用单页面作业流:登录态与拣货态均由 `pages/index/index.vue` 承载,使用 Vue 本地响应式状态切换,而不拆成多个路由。原因如下:
- 原型本身是连续的单页状态机,登录后立即进入同一作业上下文。
- 本阶段没有后端、深链、权限路由或跨页面数据恢复需求。
- 单页面可避免箱号、货品状态和弹层状态在路由之间同步,保持改动小而聚焦。
为避免页面文件承担过多重复结构,可将货品卡片和通用弹层拆成局部业务组件;演示数据与状态转换仍由页面统一管理。当前不引入状态管理库或新依赖。
## 页面结构
### 登录态
- 蓝色渐变背景、系统标识、`柏悦物流 · PDA拣货` 标题和副标题。
- 工号、密码、作业仓库三个字段。
- 普通登录按钮和三个演示账号快捷登录按钮。
- 空工号、空密码和账号密码错误使用原型文案提示。
- 登录成功后显示欢迎提示并进入拣货态;退出后清空登录表单与作业状态。
### 拣货态
- 顶部蓝色作业栏:页面标题、仓库、退出、当前箱号、订单号和人员身份。
- `pages.json` 将首页设置为 `navigationStyle: "custom"`,不显示当前标题为 `uni-app` 的原生导航栏,避免与原型作业栏重复;页面自行处理状态栏安全区。
- 箱号选择器:显示全部演示箱号与订单号,切换时重置当前箱的未提交本地操作。
- 扫码区:输入或扫描 SKU 后回车确认;匹配当前箱中待拣 SKU 时标记已拣,错误码显示失败提示。登录进入拣货态、成功或失败扫码、确认拣出、撤销以及关闭任一弹层后,都应把焦点恢复到扫码输入框;打开需要输入数量的部分拣货弹层时,焦点临时交给数量输入框。成功扫码后清空输入值,无效码和重复扫描已处理 SKU 时保留明确错误反馈,并在反馈后继续接收下一次扫描。
- 箱号与订单信息卡。
- 拣货进度、货品列表、集装箱完成/待拣统计和底部提交栏。
- 底部提交栏固定在可视区域底部,并避让 Android 导航栏安全区。
- 提交按钮初始禁用;当所有货品均已处理时启用并显示“提交拣货完成”;当至少存在一件已拣或部分拣货、同时仍有待拣货品时启用并显示“提交拣货完成(N件待拣将视为未拣)”;仅发生部分跳拣且仍有待拣货品时保持禁用。
### 货品状态
每个货品使用以下本地状态之一:
- `pending`:待拣,提供确认拣出、部分拣货、跳拣。
- `picked`:已拣,显示完成标签与撤销入口。
- `partial`:部分拣货,显示实际数量、目标数量与撤销入口。
- `skipped`:跳拣或提交时自动未拣,显示原因与撤销入口。
待拣货品排在已处理货品之前。进度按已拣、部分拣和跳拣的已处理数量计算。
## 弹层与反馈
- 跳拣弹层:单选原型中的五个原因,未选择时禁止确认并提示。
- 部分拣货弹层:显示 SKU、描述、总量、单位、快捷比例和数量输入;数量必须大于 0 且小于需求总量。
- 集装箱列表弹层:分别展示已完成与待拣箱,允许从待拣列表切换箱号。
- 完成弹层:展示已拣、部分拣、跳拣/未拣统计,并提供返回查看和下一箱。
- 短反馈沿用原型顶部提示条及成功、警告、错误配色。
Android 系统返回键按以下优先级处理:关闭当前弹层,关闭箱号下拉;若处于拣货态且无覆盖层,则调用与“退出”按钮相同的重置流程返回登录态;登录态交由系统退出页面。
## 本地数据与状态
保留原型中的三个箱号、订单号、货品、库位、单位、演示账号和跳拣原因。页面维护:
- 当前用户与仓库;
- 当前箱号索引;
- 当前箱内各 SKU 状态;
- 部分拣货数量;
- 已完成箱号集合;
- 当前下拉、弹层、目标货品和提示条状态;
- 扫码输入值。
提交时,未处理货品按原型标记为“未拣出(提交时自动标记)”,保存当前箱的最终演示状态并更新集装箱统计。刷新、退出或重启应用后允许数据重置,因为本阶段没有持久化要求。
## 视觉与响应式
- 颜色遵循原型:主色 `#1a73e8`、成功 `#0d9488`、危险 `#dc2626`、警告 `#f59e0b`、页面背景 `#f5f7fa`。
- 保留 12px 圆角、浅阴影、蓝色当前货品边线、状态色和卡片密度。
- 使用 uni-app 支持的 `rpx`、百分比和安全区变量适配 Android PDA,以 `360 × 640 dp` 为主要检查尺寸,兼容约 `320–480 dp` 宽度。
- 不引入新字体、图片生成素材、第三方图标库或新的视觉语言。
- 图标优先使用简单 CSS/文本符号或现有静态资源,避免依赖网页专属 SVG 注入方式。
- 本阶段不使用网络、震动、相机或存储能力,因此实现时移除 `manifest.json` 中手工声明的 `VIBRATE` 和 `ACCESS_NETWORK_STATE` 权限;未来接入对应能力时再按明确用途恢复最小权限。
## 边界状态
- 登录加载态:当前纯本地操作无需异步加载,不添加虚假等待动画。
- 空态:保留“该箱号暂无拣货任务”“暂无已拣完成的集装箱”“所有集装箱已拣完成”等原型语义。
- 错误态:覆盖登录校验、空扫码、无效 SKU、未选跳拣原因、无效部分数量和最后一箱提示。
- 重复操作:已处理货品不再匹配扫码;撤销后恢复为待拣。
- 权限态:本阶段没有服务端权限;人员角色仅作演示展示,不据此隐藏功能。
- 软键盘:输入区与部分拣货输入不得被键盘永久遮挡,关闭弹层后恢复扫码输入上下文。
## 验证
仓库当前没有项目根级依赖清单、可核实的构建脚本或自动化测试设施,因此不新增或编造测试命令。实现后进行以下可核实检查:
1. 静态检查 `pages.json`、Vue 模板、脚本和样式是否符合现有 uni-app 结构。
2. 若当前环境可用 HBuilderX/uni-app 运行能力,则以 Android App 方式运行;否则明确记录未执行构建。
3. 在常见 PDA 尺寸核对登录、主作业、弹层、底部固定栏和软键盘遮挡。
4. 手工走通快捷登录、箱号切换、有效/无效/重复扫码、确认拣出、部分拣货、跳拣、撤销、提前提交、完整提交和下一箱,并核对每次关键操作后的扫码焦点。
5. 检查提交按钮在初始、仅部分跳拣、存在已拣且仍有待拣、全部处理四种状态下的启用状态和文案。
6. 检查系统返回对弹层、下拉、拣货态和登录态的处理,并确认返回登录态时执行完整状态重置。
7. 检查首页未出现重复原生导航栏,并确认 Android 配置不再手工声明当前未使用的网络与震动权限。
真实扫描速度、连续扫描、低电量、断网、前后台切换、厂商扫描头和正式打包发布不属于本次静态实现的可验证范围,需在设备与工具链确定后补测。
@@ -0,0 +1,217 @@
# Admin 静态 RBAC 权限预览设计
## 背景
管理后台已经具备内部用户、角色、权限资源和分组菜单的静态交互,但角色授权结果尚未作用到登录后的菜单、路由和操作按钮。路由中的 `meta.permission` 目前只是元数据,无法演示角色权限是否真正生效。
本阶段建立一个可操作的静态 RBAC 演示闭环,用于前端原型验收。它不代表真实服务端鉴权,也不定义 Java API、Token 内容、权限响应结构或错误码。
## 目标
- 在管理后台顶部切换初始模拟数据中的五个启用角色,并继续支持后续新增或停用角色后的动态列表。
- 根据角色授权实时过滤侧边栏菜单。
- 阻止静态原型访问无权限路由,并提供清晰的无权限页面。
- 对已经存在稳定权限编码的操作按钮进行显示控制。
- 角色授权数据发生变化时,权限预览同步更新。
- 保留现有登录、页面交互、响应式布局和独立前端工程结构。
## 不在本次范围
- 不调用真实登录、用户、角色、菜单或权限接口。
- 不修改 `src/api/http.ts` 的请求或响应约定。
- 不新增或猜测 Java API 路径、响应包络、分页结构和错误码。
- 不把前端菜单隐藏或路由跳转描述为安全边界;服务端仍需在后续接口阶段执行最终鉴权。
- 不为当前静态权限模型中没有定义的操作擅自增加权限编码。
- 不模拟角色的 `dataScope` 数据范围,不因切换“全部数据”“本部门数据”或“本人数据”角色而过滤客户、订单等模拟记录。
- 不处理企业客户端、官网、小程序、APP 或 PDA 的权限体系。
## 方案选择
### 采用方案:顶部切换演示角色
在后台顶栏提供“演示角色”切换入口,角色变化后菜单、路由和明确映射的操作按钮立即更新。该方式不要求反复退出登录,适合静态原型展示和权限配置验收。
### 未采用方案
- 使用不同账号重新登录:更接近真实登录流程,但重复退出登录会降低原型验收效率,且当前没有服务端身份契约。
- 只控制菜单和路由:改动较少,但无法验证现有按钮权限是否产生可见效果。
## 架构边界
### 权限预览状态
新增 admin 专属 Pinia 状态层,职责仅限静态权限预览:
- 保存当前演示角色 ID。
- 从 `useAdminDataStore` 中读取当前启用角色和权限资源。
- 根据角色的 `permissionIds` 计算权限编码集合,但只纳入 `status === '启用'` 的权限资源;角色仍保留已停用权限 ID 时,不授予对应访问权。
- 提供 `can(permissionCode)` 判断。
- 提供当前角色、可选角色及第一个可访问菜单。
- 提供登录成功后的初始化、退出时重置和两阶段角色切换动作;页面和布局不直接写入持久化角色 ID。
认证状态仍由 `src/stores/auth.ts` 管理。权限预览状态不写入 Token,不修改 `CurrentUser` 的稳定共享模型,也不在页面中复制认证逻辑。
演示角色 ID 使用 admin 项目专属的 `localStorage` 键保存,以便刷新后保留预览上下文。保存值失效、角色被删除或角色被停用时,回退到启用的超级管理员;如果超级管理员不可用,则回退到第一个启用角色。此回退只服务静态原型,不代表后端策略。
预览状态与认证状态采用明确的生命周期边界:
- 页面刷新保留当前演示角色。
- 退出操作先调用权限预览 Store 的重置动作,清除内存状态与持久化演示角色 ID,再调用 `auth.logout()`;认证 Store 不承担预览状态职责。
- 登录成功后由登录流程调用权限预览 Store 的初始化动作,默认选择启用的超级管理员;超级管理员不可用时选择第一个启用角色。未登录阶段不自动初始化或重新持久化默认角色。
- 顶栏固定区分两个来源:`登录身份:{auth.user.name}({auth.user.account})` 与 `权限预览:{previewRole.name}`。认证角色 `auth.user.roles` 不作为无标签副标题展示,也不被预览状态读取或改写。
- `localStorage` 在同源标签页之间共享,但本期不监听 `storage` 事件做实时跨标签同步;其他标签页刷新后读取最新持久化角色。
### 权限数据流
```text
adminData.roles + adminData.permissions
↓
静态权限预览 Store
↓ ↓ ↓
侧边栏 路由决策 页面操作
↓
未保存内容保护
```
角色授权编辑保存到 `adminData.roles` 后,权限编码集合通过计算属性立即更新,不复制第二份角色或权限数据。
## 菜单与路由行为
### 菜单过滤
在现有 `adminNavigation` 基础上增加纯函数过滤:
- 叶子菜单只在其 `permission` 存在于当前权限编码集合时保留。
- 目录仅作为导航组织节点,其自身启停状态不级联授予或撤销后代权限;叶子菜单只判断自身对应的启用权限资源。
- 任意层级目录在递归过滤后没有任何子菜单时隐藏。
- 菜单原始配置保持不可变,过滤函数返回新节点。
- 工作台继续使用 `dashboard:view`,与其他菜单采用相同规则。
- “第一个可访问菜单”固定为过滤后 `adminNavigation` 配置顺序中的第一个叶子,采用深度优先遍历,不使用权限资源的 `sort` 决定回退目标。
工作台路由必须显式声明 `meta.permission: 'dashboard:view'`。所有受导航权限控制的叶子页面都应同时具有对应路由权限元数据,不能只过滤菜单而遗漏直接地址访问。
`AdminSidebarMenu` 接收过滤后的导航数据,不直接读取 Pinia,并通过递归菜单节点支持后续新增更深层级;权限来源与具体渲染层级不耦合。
### 路由守卫
登录校验继续先于权限校验执行:
1. 未登录访问受保护页面时跳转登录页。
2. 已登录且目标路由声明 `meta.permission` 时,使用静态权限预览判断访问权。
3. 无权限的直接访问跳转到静态 `/403` 页面,使用 `query.from` 保留经过校验的站内来源路径用于纯文本提示;只接受以单个 `/` 开头的内部路径,不把该值作为外部跳转地址。
4. `/403` 本身需要登录,但不要求业务权限,避免重定向循环。
5. 404 重定向保持现状。
为便于单元测试,路由权限决策下沉为不依赖浏览器导航的纯函数;Vue Router 守卫只负责读取状态并执行决策结果。
除导航守卫外,登录态布局还需要响应式监听当前有效权限集合、当前演示角色有效性和当前路由。当角色授权被修改、当前角色被停用或删除、权限资源被停用等变化导致当前页面失去访问权时,立即重新校验当前路由:优先跳转到回退后角色的第一个可访问叶子菜单;没有可访问菜单时进入 `/403`。
导航守卫负责首次进入和显式导航,登录态布局只负责已渲染页面上的响应式权限变化;两者调用同一个纯路由决策函数。布局执行修正前比较目标与当前 `fullPath`,使用单次导航锁避免并发修正,并统一调用 `router.replace`,使浏览器后退不会再次回到刚被拒绝的地址。目标已经相同或当前已处于无菜单可恢复的 `/403` 时不重复导航。
### 主动切换角色
用户从顶栏切换角色采用“请求—确认—提交”两阶段流程,不在下拉选择事件中直接覆盖当前角色:
- 先根据候选角色计算候选权限、当前页面可访问性和回退目标。
- 当前页面的新增/编辑抽屉或对话框存在已修改但未保存的内容,且切换会离开当前路由或撤销该表单保存所需的操作权限时,显示确认框,明确候选角色和将离开的页面;取消后角色、路由和表单内容均保持不变。
- 各业务页面通过 admin 专属的轻量离开保护协调器登记 `{ dirty, requiredPermission, label }`;只在字段实际变化后标记 dirty,关闭或成功保存时注销,不把业务表单数据复制到全局 Store。
- 用户确认或没有受影响的未保存内容后,才提交演示角色 ID;需要修正路由时统一使用 `router.replace`。
- 顶栏随后显示新的演示角色名称,并显示 `已切换至“{角色名}”,当前仅为静态权限预览`。
- 当前页面仍可访问时保持原路由。
- 当前页面不再可访问时,跳转到该角色第一个可访问菜单。
- 如果切换角色时当前页面是 `/403`,只要新角色存在可访问菜单,就始终跳转到第一个可访问叶子菜单,不把 `/403` 视为应继续停留的业务页面。
- 如果角色没有任何菜单权限,进入 `/403`,显示“当前演示角色暂无可访问菜单”。
- 自动跳转完成后将焦点移到新页面主标题,并通过 `aria-live="polite"` 播报当前预览角色与目标页面;仅停留原页面时也播报角色变化。
## 操作权限映射
本次只控制现有静态模型中已有明确编码的操作:
| 页面 | 操作 | 权限编码 |
|---|---|---|
| 客户管理 | 新增客户 | `customer:create` |
| 客户管理 | 审核客户、详情中的通过审核 | `customer:audit` |
| 头程订单 | 创建订单、创建并提交、保存新草稿 | `first-leg:create` |
| 头程订单 | 单笔处理、批量更新、更新状态 | `first-leg:status` |
| 内部用户 | 新增、编辑、重置密码、启停、保存 | `system:user:write` |
| 角色管理 | 新增、配置、启停、删除、保存 | `system:role:grant` |
| 菜单权限 | 新增、编辑、添加下级、启停、保存 | `system:permission:write` |
详情、查询、筛选和已有页面级导出继续由对应菜单权限覆盖。客户编辑/启停、订单编辑等没有独立稳定操作编码的功能暂不额外限制;待 Java 权限契约确定后再细分,避免静态原型创造错误的长期编码。
页面通过同一个权限预览 Store 的 `can` 方法控制明确映射的操作,不创建页面私有权限判断逻辑。无权限操作不渲染,避免用户误以为按钮可用后才收到拒绝。
## 顶栏交互
- 在现有用户信息和退出按钮之间增加“演示角色”下拉入口。
- 使用 Lucide 图标和 Element Plus 现有组件,不引入新依赖。
- 下拉只展示启用角色,并标记当前角色。
- 桌面端持续显示 `权限预览:{角色名}`,并提供“仅预览菜单、页面和操作;数据范围未模拟”的可聚焦说明;登录用户区域显示 `登录身份:{姓名}({账号})`,两者不共用“角色”副标题。
- 窄屏至少保留可识别图标、`权限预览:{角色名},数据范围未模拟` 的可访问名称和 44px 触控区域;长角色名称使用省略与完整 tooltip,不挤压退出按钮。
- 角色下拉支持 Tab 聚焦、Enter/Space 打开、方向键移动、Enter 选择和 Escape 关闭,并保持可见焦点。
- 入口和反馈文案始终包含“演示”或“预览”,避免被理解为真实用户换号或数据范围切换。
## 无权限页面
新增轻量静态 403 页面:
- 说明当前演示角色无权访问目标页面。
- 显示当前演示角色名称。
- 来源路径只作为经过校验、可截断换行的纯文本显示,不生成“返回来源地址”链接。
- 当前角色存在可访问菜单时,提供“返回可访问首页”操作。
- 当前角色没有任何可访问菜单时,提示用户使用顶栏入口切换演示角色。
- 不使用夸张插画或与现有后台不一致的视觉语言。
- 页面作为登录态布局的子路由呈现,使顶栏角色切换始终可用。
## 状态与边界处理
- 角色切换只修改本地预览状态,不修改内部用户的真实 `roleIds`。
- 停用或删除当前演示角色时自动执行回退,不保留悬空角色 ID。
- 停用权限资源会立即从当前角色的有效权限编码集合中移除,即使该权限 ID 仍存在于角色授权数据中。
- 停用目录资源只改变该目录资源自身的有效编码,不级联改变启用后代的菜单、路由或操作权限;权限管理页的停用确认明确说明这一点。
- 超级管理员通过角色现有的全部 `permissionIds` 获得权限,不在代码中使用绕过所有检查的特殊布尔值。
- 角色拥有目录权限但没有任何叶子菜单权限时,不显示空目录。
- 如果异常模拟数据中不存在任何启用角色,当前角色为空、有效权限集合为空并进入 `/403`;正常页面继续保护内置超级管理员,避免产生该状态。
- 多角色合并不在本次预览范围;预览入口一次选择一个角色,真实用户多角色合并规则等待后端契约。
- 静态权限预览不静默回退到远程数据,也不发起网络请求。
## 测试策略
使用 admin 现有 Vitest,按测试驱动方式实现:
- 权限预览 Store:两阶段角色选择、权限编码派生、停用权限资源不授予访问权、失效角色回退、零启用角色、角色授权变更实时生效、退出登录重置且刷新保留、未登录时不重新初始化默认角色。
- 导航过滤:隐藏无权限叶子、目录停用不级联后代、递归移除空目录、保持原配置不可变、按 `adminNavigation` 深度优先顺序确定第一个可访问菜单。
- 路由配置与决策:工作台显式声明 `dashboard:view`;覆盖未登录、已登录有权限、已登录无权限、403 免业务权限四种分支。
- 当前路由再校验:角色授权减少、角色停用或删除、权限资源停用后,当前无权限页面通过 `router.replace` 跳转到第一个可访问叶子或 `/403`;覆盖目标相同、导航锁和浏览器后退不返回被拒页面。
- 侧边栏组件:只渲染传入的过滤结果,并覆盖三层导航数据以证明递归渲染不受当前两层结构限制。
- 顶栏角色切换:当前角色回显、准确的登录身份/权限预览标签、窄屏可访问名称、键盘操作、长角色名,以及从 `/403` 切换到有权限角色后恢复到第一个可访问菜单。
- 未保存内容保护:覆盖无修改直接切换、有修改但权限不受影响、有修改且将离开或失去保存权限、取消保持全部状态、确认后提交角色并 replace 跳转。
- 无障碍动态状态:覆盖成功切换和自动跳转的 polite 播报、跳转后主标题焦点与 Escape 关闭角色菜单。
- 状态来源与生命周期:用户姓名/账号来自认证 Store,演示角色来自权限预览 Store;退出清除预览角色,重新登录使用默认启用角色。
- 页面操作:至少覆盖每个操作权限域的一项代表按钮,确认无权限时不渲染。
- 数据范围边界:角色切换不改变模拟记录集合,界面持续说明 `dataScope` 未模拟。
交付前运行:
```bash
pnpm test
pnpm typecheck
pnpm build
```
浏览器验收覆盖桌面、390px 和项目约定的 320px 最小宽度,并检查 200% 浏览器缩放:角色切换、菜单变化、未保存内容确认/取消、自动跳转、直接访问 403、代表性按钮显示与隐藏、键盘完整操作、可见焦点、动态播报、顶栏不重叠或裁切以及控制台错误。UI 修改完成后运行一次 Impeccable 检测。
## 完成标准
- 初始模拟数据中的五个启用角色可在顶栏切换,后续角色新增、停用或删除后列表与回退实时更新。
- 菜单、路由和已映射操作与当前角色的启用权限资源一致,停用权限不产生访问权。
- 权限预览明确不包含 `dataScope`,角色切换不改变模拟记录,桌面和窄屏均持续提供这一说明。
- 无权限直接访问有明确 403 反馈,不出现循环跳转。
- 角色授权、角色有效性或权限资源状态变化能即时影响当前预览;当前路由失去权限时立即修正到第一个可访问叶子或 `/403`。
- 目录停用语义固定为不级联后代;递归菜单过滤与渲染支持后续增加更深菜单层级。
- 角色切换不会静默丢失已修改表单;取消保持原角色、路由和内容,确认后使用 replace 完成必要跳转。
- 从 `/403` 切换到具有菜单权限的角色后能恢复到可访问页面。
- 刷新保留演示角色,退出登录清除预览状态,重新登录采用默认启用角色;`登录身份` 与 `权限预览` 的标签、数据来源和视觉层级清晰分离。
- 键盘、读屏播报、焦点管理、320px/390px、200% 缩放与长角色名称均通过验收。
- 不产生任何真实 API 请求或新的后端契约假设。
- admin 项目测试、类型检查、构建、桌面/移动端浏览器验收及 Impeccable 检测通过。