Files
docs/superpowers/plans/2026-07-28-admin-static-rbac-preview.md

2038 lines
62 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.
# Admin Static RBAC Preview 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:** 在 Admin 静态原型中完成可切换角色的 RBAC 预览,使菜单、路由和已映射操作即时反映启用权限,同时提供 403、未保存内容保护和完整无障碍反馈。
**Architecture:** 新增 Admin 专属权限预览 Store,以现有 `adminData.roles` 和 `adminData.permissions` 为唯一数据源;导航过滤与路由判断保持纯函数,布局只负责协调角色切换、响应式路由修正和身份展示。业务页面直接使用统一 `can()` 控制已确认的按钮权限,并通过轻量未保存内容协调器登记表单状态,不复制表单数据、不调用网络接口。
**Tech Stack:** Vue 3、TypeScript、Pinia、Vue Router、Element Plus、Lucide Vue、Vitest、Vue Test Utils、Vite。
---
## 实施约束
- 目标项目仅为 `apps/admin-web`。
- 不修改 `apps/admin-web/src/api/http.ts`,不增加 Java API、响应包络、分页或错误码定义。
- 不实现 `dataScope` 数据过滤;界面必须持续说明“数据范围未模拟”。
- 不增加依赖;使用当前 `package.json` 已声明的工具。
- 依赖仍保存在 `apps/admin-web`,仓库根目录不增加 Node 工作区文件。
- 当前目录不是 Git 仓库,因此各任务末尾使用验证检查点,不编造 `git commit`。如果执行前仓库已初始化 Git,可按任务标题分别提交。
## 文件结构
### 新建
- `apps/admin-web/src/stores/accessPreview.ts`:演示角色生命周期、启用权限派生、候选角色预览和菜单回退。
- `apps/admin-web/src/stores/accessPreview.test.ts`:Store 生命周期、停用权限、失效角色、零角色和数据范围边界。
- `apps/admin-web/src/components/AdminSidebarMenuNode.vue`:递归渲染任意层级导航节点。
- `apps/admin-web/src/components/RolePreviewSwitcher.vue`:顶栏角色预览入口和准确的可访问名称。
- `apps/admin-web/src/components/RolePreviewSwitcher.test.ts`:角色列表、当前角色、事件和可访问标签。
- `apps/admin-web/src/router/accessDecision.ts`:登录、权限、403 来源和候选角色目标的纯决策函数。
- `apps/admin-web/src/router/accessDecision.test.ts`:路由判断、内部来源清洗、403 恢复和回退目标。
- `apps/admin-web/src/router/index.test.ts`:工作台权限元数据和 403 登录态子路由配置。
- `apps/admin-web/src/router/meta.d.ts`:补充 Vue Router 的 Admin 路由元数据类型。
- `apps/admin-web/src/views/ForbiddenView.vue`:登录态布局内的静态 403 页面。
- `apps/admin-web/src/views/ForbiddenView.test.ts`:有/无可访问菜单两种恢复状态。
- `apps/admin-web/src/stores/unsavedWork.ts`:只保存脏状态、所需权限和表单标签的离开保护协调器。
- `apps/admin-web/src/stores/unsavedWork.test.ts`:阻塞条件与清理行为。
- `apps/admin-web/src/composables/useUnsavedWorkRegistration.ts`:业务表单登记、基线捕获和卸载清理。
- `apps/admin-web/src/composables/useUnsavedWorkRegistration.test.ts`:字段变化、关闭和卸载行为。
- `apps/admin-web/src/layouts/AppLayout.test.ts`:角色切换、取消、replace、403 恢复、退出重置和身份来源。
- `apps/admin-web/src/views/AccessControlledActions.test.ts`:七个操作权限域的代表性按钮。
### 修改
- `apps/admin-web/src/navigation/adminNavigation.ts`:增加不可变递归过滤和确定性首个叶子函数。
- `apps/admin-web/src/navigation/adminNavigation.test.ts`:增加空目录、停用目录语义和三层导航测试。
- `apps/admin-web/src/components/AdminSidebarMenu.vue`:接收过滤后的导航数组并委托递归节点渲染。
- `apps/admin-web/src/components/AdminSidebarMenu.test.ts`:改为显式传入导航并验证三层结构。
- `apps/admin-web/src/router/index.ts`:工作台权限、403 子路由、认证优先和权限守卫。
- `apps/admin-web/src/layouts/AppLayout.vue`:过滤菜单、角色切换、离开保护、路由再校验、身份标签和 live region。
- `apps/admin-web/src/views/LoginView.vue`:登录成功后初始化权限预览。
- `apps/admin-web/src/views/CustomersView.vue`:客户创建/审核权限和编辑器脏状态。
- `apps/admin-web/src/views/FirstLegOrdersView.vue`:订单创建/状态权限和编辑器脏状态。
- `apps/admin-web/src/views/InternalUsersView.vue`:内部用户维护权限和编辑器脏状态。
- `apps/admin-web/src/views/RolesView.vue`:角色授权权限、目录语义说明和编辑器脏状态。
- `apps/admin-web/src/views/PermissionsView.vue`:权限维护权限、目录停用说明和编辑器脏状态。
- `apps/admin-web/src/styles/index.css`:顶栏、角色入口、403、隐藏播报区、320px/390px 和 200% 缩放退化。
- `apps/admin-web/README.md`:补充静态 RBAC 预览范围与运行验收说明。
## Task 1:导航纯函数与递归侧栏
**Files:**
- Modify: `apps/admin-web/src/navigation/adminNavigation.ts`
- Modify: `apps/admin-web/src/navigation/adminNavigation.test.ts`
- Create: `apps/admin-web/src/components/AdminSidebarMenuNode.vue`
- Modify: `apps/admin-web/src/components/AdminSidebarMenu.vue`
- Modify: `apps/admin-web/src/components/AdminSidebarMenu.test.ts`
- [ ] **Step 1:先写导航过滤失败测试**
在 `adminNavigation.test.ts` 增加以下测试。目录没有自身授权门;只要后代叶子有权限就保留目录,首个回退页面严格按配置深度优先顺序确定:
```ts
import {
adminNavigation,
filterAdminNavigation,
getFirstNavigationLeaf,
getNavigationLeaves,
type AdminNavigationNode,
} from './adminNavigation'
it('filters leaves recursively without mutating the source tree', () => {
const original = structuredClone(adminNavigation.map(({ key, children }) => ({
key,
children: children?.map((child) => child.key),
})))
const filtered = filterAdminNavigation(adminNavigation, new Set([
'dashboard:view',
'system:permission:list',
]))
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
'/',
'/system/permissions',
])
expect(filtered.map((item) => item.key)).toEqual(['dashboard', 'system'])
expect(adminNavigation.map(({ key, children }) => ({
key,
children: children?.map((child) => child.key),
}))).toEqual(original)
})
it('uses configuration order for the first accessible deep leaf', () => {
const nested: AdminNavigationNode[] = [{
key: 'operations',
title: '运营中心',
icon: adminNavigation[0].icon,
children: [{
key: 'first-group',
title: '第一组',
icon: adminNavigation[0].icon,
children: [{
key: 'deep-page',
title: '深层页面',
icon: adminNavigation[0].icon,
path: '/deep',
permission: 'deep:view',
}],
}],
}]
const filtered = filterAdminNavigation(nested, new Set(['deep:view']))
expect(getFirstNavigationLeaf(filtered)?.path).toBe('/deep')
expect(filtered[0].children?.[0].children?.[0].key).toBe('deep-page')
})
```
- [ ] **Step 2:运行导航测试并确认失败**
Run:
```bash
cd apps/admin-web
pnpm test -- src/navigation/adminNavigation.test.ts
```
Expected: FAIL,提示 `filterAdminNavigation` 和 `getFirstNavigationLeaf` 尚未导出。
- [ ] **Step 3:增加不可变递归过滤函数**
在 `adminNavigation.ts` 的 `getNavigationLeaves` 后加入:
```ts
export function filterAdminNavigation(
nodes: AdminNavigationNode[],
permissionCodes: ReadonlySet<string>,
): AdminNavigationNode[] {
return nodes.flatMap((node) => {
if (node.children?.length) {
const children = filterAdminNavigation(node.children, permissionCodes)
return children.length ? [{ ...node, children }] : []
}
if (!node.path || !node.permission || !permissionCodes.has(node.permission)) return []
return [{ ...node }]
})
}
export function getFirstNavigationLeaf(
nodes: AdminNavigationNode[],
): AdminNavigationLeaf | null {
return getNavigationLeaves(nodes)[0] ?? null
}
```
- [ ] **Step 4:将侧栏改为显式数据输入和递归节点**
创建 `AdminSidebarMenuNode.vue`:
```vue
<script setup lang="ts">
import type { AdminNavigationNode } from '@/navigation/adminNavigation'
defineOptions({ name: 'AdminSidebarMenuNode' })
defineProps<{
node: AdminNavigationNode
}>()
</script>
<template>
<el-sub-menu v-if="node.children?.length" :index="node.key">
<template #title>
<component :is="node.icon" :size="19" :stroke-width="1.8" />
<span>{{ node.title }}</span>
</template>
<AdminSidebarMenuNode
v-for="child in node.children"
:key="child.key"
:node="child"
/>
</el-sub-menu>
<el-menu-item v-else :index="node.path || node.key">
<component :is="node.icon" :size="19" :stroke-width="1.8" />
<template #title>{{ node.title }}</template>
</el-menu-item>
</template>
```
将 `AdminSidebarMenu.vue` 改为:
```vue
<script setup lang="ts">
import AdminSidebarMenuNode from '@/components/AdminSidebarMenuNode.vue'
import type { AdminNavigationNode } from '@/navigation/adminNavigation'
defineProps<{
items: AdminNavigationNode[]
activePath: string
collapsed: boolean
}>()
</script>
<template>
<el-menu
class="side-nav-menu"
:default-active="activePath"
:collapse="collapsed"
router
>
<AdminSidebarMenuNode
v-for="item in items"
:key="item.key"
:node="item"
/>
</el-menu>
</template>
```
更新 `AdminSidebarMenu.test.ts` 的两处 props,均显式传入 `items: adminNavigation`;再增加一条只传工作台和菜单权限页的测试,断言页面中不出现“客户管理”。
再加入三层导航数据的递归渲染测试:
```ts
it('renders a third-level navigation leaf', () => {
const nested = [{
key: 'operations',
title: '运营中心',
icon: adminNavigation[0].icon,
children: [{
key: 'shipment',
title: '运输作业',
icon: adminNavigation[0].icon,
children: [{
key: 'tracking',
title: '轨迹管理',
icon: adminNavigation[0].icon,
path: '/tracking',
permission: 'tracking:list',
}],
}],
}]
const wrapper = mount(AdminSidebarMenu, {
props: { items: nested, activePath: '/tracking', collapsed: false },
global: { stubs },
})
expect(wrapper.findAll('[data-kind="group"]')).toHaveLength(2)
expect(wrapper.get('[data-index="/tracking"]').text()).toContain('轨迹管理')
})
```
- [ ] **Step 5:运行导航和侧栏测试**
Run:
```bash
cd apps/admin-web
pnpm test -- src/navigation/adminNavigation.test.ts src/components/AdminSidebarMenu.test.ts
```
Expected: 两个测试文件全部 PASS。
- [ ] **Step 6:记录验证检查点**
记录 Task 1 修改文件和测试输出;不要修改 `dist/` 或生成声明文件。
## Task 2:权限预览 Store 与生命周期
**Files:**
- Create: `apps/admin-web/src/stores/accessPreview.ts`
- Create: `apps/admin-web/src/stores/accessPreview.test.ts`
- [ ] **Step 1:写 Store 生命周期失败测试**
创建 `accessPreview.test.ts`:
```ts
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { useAccessPreviewStore } from './accessPreview'
import { useAdminDataStore } from './adminData'
describe('access preview store', () => {
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
})
it('defaults to the enabled super administrator and exposes five fixtures', () => {
const access = useAccessPreviewStore()
access.initializeAfterLogin()
expect(access.enabledRoles).toHaveLength(5)
expect(access.currentRole?.code).toBe('SUPER_ADMIN')
expect(access.can('system:permission:write')).toBe(true)
})
it('excludes disabled permission resources even when the role keeps the id', () => {
const data = useAdminDataStore()
const access = useAccessPreviewStore()
data.savePermission({
...data.permissions.find((item) => item.id === 'P200')!,
status: '停用',
})
access.initializeAfterLogin()
access.commitRole('R003')
expect(access.can('first-leg:list')).toBe(false)
expect(access.firstAccessiblePath).toBe('/')
})
it('does not cascade a disabled directory to enabled leaf permissions', () => {
const data = useAdminDataStore()
const access = useAccessPreviewStore()
data.savePermission({
...data.permissions.find((item) => item.id === 'P010')!,
status: '停用',
})
access.initializeAfterLogin()
access.commitRole('R003')
expect(access.can('business')).toBe(false)
expect(access.can('first-leg:list')).toBe(true)
expect(access.filteredNavigation.flatMap((item) => item.children ?? []).map((item) => item.path))
.toContain('/orders/first-leg')
})
it('reacts to authorization edits and a current role becoming disabled', async () => {
const data = useAdminDataStore()
const access = useAccessPreviewStore()
access.initializeAfterLogin()
access.commitRole('R003')
data.saveRole({
...data.roles.find((item) => item.id === 'R003')!,
permissionIds: ['P001'],
})
expect(access.can('first-leg:status')).toBe(false)
data.saveRole({ ...data.roles.find((item) => item.id === 'R003')!, status: '停用' })
await nextTick()
expect(access.currentRole?.id).toBe('R001')
})
it('falls back when the persisted role is disabled and clears on logout', () => {
const data = useAdminDataStore()
localStorage.setItem('cc-admin-preview-role', 'R003')
data.saveRole({ ...data.roles.find((item) => item.id === 'R003')!, status: '停用' })
const access = useAccessPreviewStore()
access.initializeAfterLogin()
expect(access.currentRole?.id).toBe('R001')
access.reset()
expect(access.currentRole).toBeNull()
expect(localStorage.getItem('cc-admin-preview-role')).toBeNull()
})
it('returns an empty preview when no enabled role exists', () => {
const data = useAdminDataStore()
data.roles.forEach((role) => { role.status = '停用' })
const access = useAccessPreviewStore()
access.initializeAfterLogin()
expect(access.currentRole).toBeNull()
expect(access.permissionCodes.size).toBe(0)
expect(access.firstAccessiblePath).toBeNull()
})
it('does not recreate a default preview after logout', () => {
const data = useAdminDataStore()
const access = useAccessPreviewStore()
access.initializeAfterLogin()
access.reset()
data.saveRole({ ...data.roles.find((item) => item.id === 'R003')!, status: '停用' })
expect(access.currentRole).toBeNull()
expect(localStorage.getItem('cc-admin-preview-role')).toBeNull()
})
it('does not change mock records when switching data scopes', () => {
const data = useAdminDataStore()
const access = useAccessPreviewStore()
access.initializeAfterLogin()
const customerIds = data.customers.map((item) => item.id)
const orderIds = data.orders.map((item) => item.id)
access.commitRole('R005')
expect(data.customers.map((item) => item.id)).toEqual(customerIds)
expect(data.orders.map((item) => item.id)).toEqual(orderIds)
})
})
```
- [ ] **Step 2:运行测试确认 Store 尚不存在**
Run:
```bash
cd apps/admin-web
pnpm test -- src/stores/accessPreview.test.ts
```
Expected: FAIL,模块 `@/stores/accessPreview` 无法解析。
- [ ] **Step 3:创建权限预览 Store**
创建 `accessPreview.ts`:
```ts
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import {
adminNavigation,
filterAdminNavigation,
getFirstNavigationLeaf,
} from '@/navigation/adminNavigation'
import { useAdminDataStore } from '@/stores/adminData'
export const PREVIEW_ROLE_KEY = 'cc-admin-preview-role'
export const useAccessPreviewStore = defineStore('admin-access-preview', () => {
const data = useAdminDataStore()
const selectedRoleId = ref<string | null>(null)
const initialized = ref(false)
const enabledRoles = computed(() => data.roles.filter((role) => role.status === '启用'))
const currentRole = computed(() => (
enabledRoles.value.find((role) => role.id === selectedRoleId.value) ?? null
))
function permissionCodesFor(roleId: string | null) {
const role = enabledRoles.value.find((item) => item.id === roleId)
if (!role) return new Set<string>()
const grantedIds = new Set(role.permissionIds)
return new Set(data.permissions
.filter((permission) => permission.status === '启用' && grantedIds.has(permission.id))
.map((permission) => permission.code))
}
const permissionCodes = computed(() => permissionCodesFor(selectedRoleId.value))
const permissionSignature = computed(() => [...permissionCodes.value].sort().join('|'))
const filteredNavigation = computed(() => (
filterAdminNavigation(adminNavigation, permissionCodes.value)
))
const firstAccessiblePath = computed(() => (
getFirstNavigationLeaf(filteredNavigation.value)?.path ?? null
))
function resolveDefaultRoleId() {
return enabledRoles.value.find((role) => role.code === 'SUPER_ADMIN')?.id
?? enabledRoles.value[0]?.id
?? null
}
function persist(roleId: string | null) {
if (roleId) localStorage.setItem(PREVIEW_ROLE_KEY, roleId)
else localStorage.removeItem(PREVIEW_ROLE_KEY)
}
function initializeAfterLogin() {
initialized.value = true
const persisted = localStorage.getItem(PREVIEW_ROLE_KEY)
const nextId = enabledRoles.value.some((role) => role.id === persisted)
? persisted
: resolveDefaultRoleId()
selectedRoleId.value = nextId
persist(nextId)
}
function commitRole(roleId: string) {
if (!enabledRoles.value.some((role) => role.id === roleId)) return false
selectedRoleId.value = roleId
persist(roleId)
return true
}
function previewRole(roleId: string) {
const codes = permissionCodesFor(roleId)
const navigation = filterAdminNavigation(adminNavigation, codes)
return {
codes,
navigation,
firstPath: getFirstNavigationLeaf(navigation)?.path ?? null,
}
}
function can(permissionCode: string) {
return permissionCodes.value.has(permissionCode)
}
function reset() {
initialized.value = false
selectedRoleId.value = null
persist(null)
}
watch(
() => enabledRoles.value.map((role) => role.id).join('|'),
() => {
if (!initialized.value) return
if (enabledRoles.value.some((role) => role.id === selectedRoleId.value)) return
const nextId = resolveDefaultRoleId()
selectedRoleId.value = nextId
persist(nextId)
},
)
return {
selectedRoleId,
enabledRoles,
currentRole,
permissionCodes,
permissionSignature,
filteredNavigation,
firstAccessiblePath,
permissionCodesFor,
initializeAfterLogin,
commitRole,
previewRole,
can,
reset,
}
})
```
- [ ] **Step 4:运行 Store 与导航测试**
Run:
```bash
cd apps/admin-web
pnpm test -- src/stores/accessPreview.test.ts src/navigation/adminNavigation.test.ts
```
Expected: 两个测试文件全部 PASS。
- [ ] **Step 5:记录验证检查点**
确认 Store 没有读取或修改 `auth.user.roles`,也没有根据 `dataScope` 过滤客户或订单。
## Task 3:路由纯决策、工作台权限和 403
**Files:**
- Create: `apps/admin-web/src/router/accessDecision.ts`
- Create: `apps/admin-web/src/router/accessDecision.test.ts`
- Create: `apps/admin-web/src/router/index.test.ts`
- Create: `apps/admin-web/src/router/meta.d.ts`
- Modify: `apps/admin-web/src/router/index.ts`
- Create: `apps/admin-web/src/views/ForbiddenView.vue`
- Create: `apps/admin-web/src/views/ForbiddenView.test.ts`
- [ ] **Step 1:写路由决策失败测试**
创建 `accessDecision.test.ts`:
```ts
import { describe, expect, it } from 'vitest'
import {
decideRouteAccess,
resolvePreviewTarget,
sanitizeInternalPath,
} from './accessDecision'
describe('route access decisions', () => {
it('checks authentication before permissions', () => {
expect(decideRouteAccess({
authenticated: false,
requiresAuth: true,
fullPath: '/system/users',
permission: 'system:user:list',
hasPermission: () => false,
})).toEqual({ type: 'login', redirect: '/system/users' })
})
it('sends an authenticated unauthorized route to 403', () => {
expect(decideRouteAccess({
authenticated: true,
requiresAuth: true,
fullPath: '/system/users?status=正常',
permission: 'system:user:list',
hasPermission: () => false,
})).toEqual({ type: 'forbidden', from: '/system/users?status=正常' })
})
it('allows 403 without a business permission', () => {
expect(decideRouteAccess({
authenticated: true,
requiresAuth: true,
fullPath: '/403',
hasPermission: () => false,
})).toEqual({ type: 'allow' })
})
it('rejects external-looking source values', () => {
expect(sanitizeInternalPath('//example.com')).toBe('/')
expect(sanitizeInternalPath('https://example.com')).toBe('/')
expect(sanitizeInternalPath('/customers?page=2')).toBe('/customers?page=2')
})
it('recovers from 403 and replaces a route that loses access', () => {
expect(resolvePreviewTarget({
currentPath: '/403',
currentPermission: undefined,
candidateCodes: new Set(['customer:list']),
firstPath: '/customers',
})).toBe('/customers')
expect(resolvePreviewTarget({
currentPath: '/system/users',
currentPermission: 'system:user:list',
candidateCodes: new Set(['dashboard:view']),
firstPath: '/',
})).toBe('/')
})
})
```
- [ ] **Step 2:运行测试确认纯函数不存在**
Run:
```bash
cd apps/admin-web
pnpm test -- src/router/accessDecision.test.ts
```
Expected: FAIL,模块 `./accessDecision` 无法解析。
- [ ] **Step 3:创建路由纯决策函数**
创建 `accessDecision.ts`:
```ts
export interface RouteAccessInput {
authenticated: boolean
requiresAuth: boolean
fullPath: string
permission?: string
hasPermission: (permissionCode: string) => boolean
}
export type RouteAccessDecision =
| { type: 'allow' }
| { type: 'login'; redirect: string }
| { type: 'forbidden'; from: string }
export function sanitizeInternalPath(value: unknown) {
if (typeof value !== 'string') return '/'
if (!value.startsWith('/') || value.startsWith('//')) return '/'
return value
}
export function decideRouteAccess(input: RouteAccessInput): RouteAccessDecision {
if (input.requiresAuth && !input.authenticated) {
return { type: 'login', redirect: sanitizeInternalPath(input.fullPath) }
}
if (input.permission && !input.hasPermission(input.permission)) {
return { type: 'forbidden', from: sanitizeInternalPath(input.fullPath) }
}
return { type: 'allow' }
}
export interface PreviewTargetInput {
currentPath: string
currentPermission?: string
candidateCodes: ReadonlySet<string>
firstPath: string | null
}
export function resolvePreviewTarget(input: PreviewTargetInput) {
if (input.currentPath === '/403') return input.firstPath ?? '/403'
if (input.currentPermission && !input.candidateCodes.has(input.currentPermission)) {
return input.firstPath ?? '/403'
}
return null
}
```
- [ ] **Step 4:声明路由元数据并接入守卫**
创建 `meta.d.ts`:
```ts
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
title?: string
description?: string
requiresAuth?: boolean
permission?: string
}
}
```
在 `router/index.ts`:
1. 给工作台 meta 增加 `permission: 'dashboard:view'`。
2. 在 AppLayout children 中增加:
```ts
{
path: '403',
name: 'forbidden',
component: () => import('@/views/ForbiddenView.vue'),
meta: { title: '无访问权限', description: '当前演示角色无法访问该页面' },
},
```
3. 将守卫权限部分替换为:
```ts
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return { name: 'login', query: { redirect: sanitizeInternalPath(to.fullPath) } }
}
if (to.name === 'login' && auth.isAuthenticated) return { name: 'dashboard' }
if (!auth.isAuthenticated) return true
const access = useAccessPreviewStore()
access.initializeAfterLogin()
const decision = decideRouteAccess({
authenticated: auth.isAuthenticated,
requiresAuth: Boolean(to.meta.requiresAuth),
fullPath: to.fullPath,
permission: to.meta.permission,
hasPermission: access.can,
})
if (decision.type === 'forbidden') {
return { name: 'forbidden', query: { from: decision.from }, replace: true }
}
return true
```
同时导入 `useAccessPreviewStore`、`decideRouteAccess` 和 `sanitizeInternalPath`。
创建 `router/index.test.ts` 锁定配置:
```ts
import { describe, expect, it } from 'vitest'
import router from './index'
describe('admin router permission metadata', () => {
it('protects the dashboard with dashboard:view', () => {
const dashboard = router.getRoutes().find((route) => route.name === 'dashboard')
expect(dashboard?.meta.permission).toBe('dashboard:view')
})
it('keeps 403 inside the authenticated layout without a business permission', () => {
const forbidden = router.getRoutes().find((route) => route.name === 'forbidden')
const resolved = router.resolve('/403')
expect(forbidden?.path).toBe('/403')
expect(forbidden?.meta.permission).toBeUndefined()
expect(resolved.matched.some((record) => record.meta.requiresAuth)).toBe(true)
})
})
```
- [ ] **Step 5:创建静态 403 页面与组件测试**
创建 `ForbiddenView.vue`:
```vue
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ShieldX } from '@lucide/vue'
import { sanitizeInternalPath } from '@/router/accessDecision'
import { useAccessPreviewStore } from '@/stores/accessPreview'
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const sourcePath = computed(() => sanitizeInternalPath(route.query.from))
async function returnToAccessiblePage() {
if (access.firstAccessiblePath) await router.replace(access.firstAccessiblePath)
}
</script>
<template>
<section class="forbidden-state" aria-labelledby="forbidden-title">
<div class="forbidden-icon"><ShieldX :size="28" /></div>
<p class="section-kicker">权限预览</p>
<h2 id="forbidden-title">当前演示角色无权访问此页面</h2>
<p>权限预览:{{ access.currentRole?.name || '无可用角色' }}</p>
<p class="forbidden-source">来源:{{ sourcePath }}</p>
<el-button
v-if="access.firstAccessiblePath"
type="primary"
@click="returnToAccessiblePage"
>
返回可访问首页
</el-button>
<p v-else>请使用顶栏入口切换演示角色。</p>
</section>
</template>
```
创建 `ForbiddenView.test.ts`:
```ts
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter, type Router } from 'vue-router'
import ForbiddenView from './ForbiddenView.vue'
import { useAccessPreviewStore } from '@/stores/accessPreview'
import { useAdminDataStore } from '@/stores/adminData'
let pinia: Pinia
let router: Router
beforeEach(async () => {
localStorage.clear()
pinia = createPinia()
setActivePinia(pinia)
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>首页</div>' } },
{ path: '/403', component: ForbiddenView },
],
})
await router.push('/403?from=%2Fsystem%2Fusers')
await router.isReady()
})
describe('ForbiddenView', () => {
it('returns to the first accessible page', async () => {
const access = useAccessPreviewStore()
access.initializeAfterLogin()
const wrapper = mount(ForbiddenView, {
global: {
plugins: [pinia, router],
stubs: { 'el-button': { template: '<button><slot /></button>' } },
},
})
expect(wrapper.text()).toContain('来源:/system/users')
await wrapper.get('button').trigger('click')
expect(router.currentRoute.value.path).toBe('/')
})
it('shows role switching guidance when no enabled role exists', async () => {
useAdminDataStore().roles.forEach((role) => { role.status = '停用' })
const access = useAccessPreviewStore()
access.initializeAfterLogin()
await router.replace('/403?from=%2F%2Fexample.com')
const wrapper = mount(ForbiddenView, { global: { plugins: [pinia, router] } })
expect(wrapper.text()).toContain('来源:/')
expect(wrapper.text()).toContain('请使用顶栏入口切换演示角色')
})
})
```
- [ ] **Step 6:运行路由与 403 测试**
Run:
```bash
cd apps/admin-web
pnpm test -- src/router/accessDecision.test.ts src/router/index.test.ts src/views/ForbiddenView.test.ts
```
Expected: 三个测试文件全部 PASS。
- [ ] **Step 7:记录验证检查点**
确认 `/403` 是 AppLayout 子路由、没有业务权限元数据,来源值只显示为文本且不会用于外部跳转。
## Task 4:未保存内容协调器
**Files:**
- Create: `apps/admin-web/src/stores/unsavedWork.ts`
- Create: `apps/admin-web/src/stores/unsavedWork.test.ts`
- Create: `apps/admin-web/src/composables/useUnsavedWorkRegistration.ts`
- Create: `apps/admin-web/src/composables/useUnsavedWorkRegistration.test.ts`
- [ ] **Step 1:写阻塞条件失败测试**
创建 `unsavedWork.test.ts`:
```ts
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useUnsavedWorkStore } from './unsavedWork'
describe('unsaved work store', () => {
beforeEach(() => setActivePinia(createPinia()))
it('blocks when leaving the route or losing the required operation', () => {
const store = useUnsavedWorkStore()
store.upsert({
key: 'customer-editor',
label: '新增客户',
dirty: true,
requiredPermission: 'customer:create',
})
expect(store.findBlocking(new Set(['customer:create']), false)).toEqual([])
expect(store.findBlocking(new Set(), false).map((item) => item.label)).toEqual(['新增客户'])
expect(store.findBlocking(new Set(['customer:create']), true).map((item) => item.label)).toEqual(['新增客户'])
})
it('ignores clean entries and removes closed forms', () => {
const store = useUnsavedWorkStore()
store.upsert({ key: 'role-editor', label: '配置角色', dirty: false, requiredPermission: 'system:role:grant' })
expect(store.findBlocking(new Set(), true)).toEqual([])
store.remove('role-editor')
expect(store.entries).toEqual([])
})
})
```
- [ ] **Step 2:运行测试确认协调器不存在**
Run:
```bash
cd apps/admin-web
pnpm test -- src/stores/unsavedWork.test.ts
```
Expected: FAIL,模块 `./unsavedWork` 无法解析。
- [ ] **Step 3:创建离开保护 Store**
创建 `unsavedWork.ts`:
```ts
import { ref } from 'vue'
import { defineStore } from 'pinia'
export interface UnsavedWorkEntry {
key: string
label: string
dirty: boolean
requiredPermission: string | null
}
export const useUnsavedWorkStore = defineStore('admin-unsaved-work', () => {
const entries = ref<UnsavedWorkEntry[]>([])
function upsert(entry: UnsavedWorkEntry) {
const index = entries.value.findIndex((item) => item.key === entry.key)
if (index >= 0) entries.value[index] = entry
else entries.value.push(entry)
}
function remove(key: string) {
entries.value = entries.value.filter((item) => item.key !== key)
}
function findBlocking(permissionCodes: ReadonlySet<string>, willLeave: boolean) {
return entries.value.filter((entry) => (
entry.dirty
&& (willLeave || Boolean(
entry.requiredPermission && !permissionCodes.has(entry.requiredPermission),
))
))
}
function clear() {
entries.value = []
}
return { entries, upsert, remove, findBlocking, clear }
})
```
- [ ] **Step 4:创建通用表单登记 composable**
创建 `useUnsavedWorkRegistration.ts`:
```ts
import { onScopeDispose, watch, type ComputedRef, type Ref } from 'vue'
import { useUnsavedWorkStore } from '@/stores/unsavedWork'
export function useUnsavedWorkRegistration<T extends object>(options: {
key: string
label: ComputedRef<string>
isOpen: Ref<boolean>
model: T
requiredPermission: ComputedRef<string | null>
}) {
const store = useUnsavedWorkStore()
let baseline = ''
const snapshot = () => JSON.stringify(options.model)
function sync() {
if (!options.isOpen.value) {
store.remove(options.key)
return
}
store.upsert({
key: options.key,
label: options.label.value,
dirty: snapshot() !== baseline,
requiredPermission: options.requiredPermission.value,
})
}
function captureBaseline() {
baseline = snapshot()
sync()
}
watch(
[options.isOpen, snapshot, options.label, options.requiredPermission],
sync,
{ flush: 'sync' },
)
onScopeDispose(() => store.remove(options.key))
return { captureBaseline }
}
```
创建 `useUnsavedWorkRegistration.test.ts`:
```ts
import { computed, defineComponent, h, nextTick, reactive, ref } from 'vue'
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { mount } from '@vue/test-utils'
import { useUnsavedWorkRegistration } from './useUnsavedWorkRegistration'
import { useUnsavedWorkStore } from '@/stores/unsavedWork'
describe('useUnsavedWorkRegistration', () => {
beforeEach(() => setActivePinia(createPinia()))
it('tracks real changes and removes the entry when the form closes', async () => {
const open = ref(false)
const form = reactive({ name: '' })
let captureBaseline = () => {}
const Harness = defineComponent({
setup() {
const registration = useUnsavedWorkRegistration({
key: 'test-form',
label: computed(() => '测试表单'),
isOpen: open,
model: form,
requiredPermission: computed(() => 'demo:write'),
})
captureBaseline = registration.captureBaseline
return () => h('div')
},
})
const wrapper = mount(Harness)
const store = useUnsavedWorkStore()
captureBaseline()
open.value = true
await nextTick()
expect(store.entries[0]?.dirty).toBe(false)
form.name = '已修改'
await nextTick()
expect(store.entries[0]?.dirty).toBe(true)
open.value = false
await nextTick()
expect(store.entries).toEqual([])
wrapper.unmount()
})
})
```
- [ ] **Step 5:运行协调器测试**
Run:
```bash
cd apps/admin-web
pnpm test -- src/stores/unsavedWork.test.ts src/composables/useUnsavedWorkRegistration.test.ts
```
Expected: 两个测试文件全部 PASS。
- [ ] **Step 6:记录验证检查点**
确认 Store 只保存标签、脏状态和权限编码,不保存客户、订单、用户、角色或权限表单内容。
## Task 5:角色切换器、布局协调和认证生命周期
**Files:**
- Create: `apps/admin-web/src/components/RolePreviewSwitcher.vue`
- Create: `apps/admin-web/src/components/RolePreviewSwitcher.test.ts`
- Modify: `apps/admin-web/src/layouts/AppLayout.vue`
- Create: `apps/admin-web/src/layouts/AppLayout.test.ts`
- Modify: `apps/admin-web/src/views/LoginView.vue`
- [ ] **Step 1:写角色切换器失败测试**
创建 `RolePreviewSwitcher.test.ts`:
```ts
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { mount } from '@vue/test-utils'
import RolePreviewSwitcher from './RolePreviewSwitcher.vue'
import { useAdminDataStore } from '@/stores/adminData'
const stubs = {
'el-dropdown': {
name: 'ElDropdown',
emits: ['command'],
template: '<div><slot /><slot name="dropdown" /></div>',
},
'el-dropdown-menu': { template: '<div><slot /></div>' },
'el-dropdown-item': {
props: ['command', 'disabled'],
template: '<button :disabled="disabled"><slot /></button>',
},
}
describe('RolePreviewSwitcher', () => {
beforeEach(() => setActivePinia(createPinia()))
it('labels preview state and emits a selected enabled role', async () => {
const roles = useAdminDataStore().roles
const wrapper = mount(RolePreviewSwitcher, {
props: { roles, currentRole: roles.find((role) => role.id === 'R003')! },
global: { stubs },
})
expect(wrapper.get('[data-testid="role-preview-trigger"]').attributes('aria-label'))
.toBe('权限预览:操作员,数据范围未模拟')
expect(wrapper.text()).toContain('权限预览:操作员')
expect(wrapper.text()).toContain('仅预览菜单、页面和操作;数据范围未模拟')
wrapper.getComponent({ name: 'ElDropdown' }).vm.$emit('command', 'R005')
expect(wrapper.emitted('select')).toEqual([['R005']])
})
})
```
- [ ] **Step 2:运行测试确认组件不存在**
Run:
```bash
cd apps/admin-web
pnpm test -- src/components/RolePreviewSwitcher.test.ts
```
Expected: FAIL,组件文件无法解析。
- [ ] **Step 3:创建角色预览切换器**
创建 `RolePreviewSwitcher.vue`:
```vue
<script setup lang="ts">
import { UserRoundCog } from '@lucide/vue'
import type { Role } from '@/types/admin'
defineProps<{
roles: Role[]
currentRole: Role | null
}>()
const emit = defineEmits<{
select: [roleId: string]
}>()
</script>
<template>
<div class="role-preview-control">
<el-dropdown trigger="click" @command="emit('select', String($event))">
<button
class="role-preview-trigger"
data-testid="role-preview-trigger"
type="button"
:aria-label="`权限预览:${currentRole?.name || '无可用角色'},数据范围未模拟`"
:title="currentRole?.name || '无可用角色'"
>
<UserRoundCog :size="18" />
<span class="role-preview-label">权限预览:</span>
<strong class="role-preview-name">{{ currentRole?.name || '无可用角色' }}</strong>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="role in roles"
:key="role.id"
:command="role.id"
:disabled="role.id === currentRole?.id"
:data-role-id="role.id"
>
{{ role.name }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<span class="role-preview-scope" tabindex="0">
仅预览菜单、页面和操作;数据范围未模拟
</span>
</div>
</template>
```
- [ ] **Step 4:为布局写角色切换和退出失败测试**
`AppLayout.test.ts` 使用 memory router、Pinia 和可触发选择事件的角色组件 stub。测试基线如下:
```ts
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter, type Router } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import AppLayout from './AppLayout.vue'
import { useAccessPreviewStore } from '@/stores/accessPreview'
import { useAdminDataStore } from '@/stores/adminData'
import { useAuthStore } from '@/stores/auth'
import { useUnsavedWorkStore } from '@/stores/unsavedWork'
const mockedConfirm = vi.spyOn(ElMessageBox, 'confirm')
let pinia: Pinia
let router: Router
const roleSwitcherStub = {
name: 'RolePreviewSwitcher',
emits: ['select'],
template: '<button data-testid="switch-r003" @click="$emit(\'select\', \'R003\')">切换角色</button>',
}
async function mountLayout(path = '/') {
await router.push(path)
await router.isReady()
return mount(AppLayout, {
global: {
plugins: [pinia, router],
stubs: {
AdminSidebarMenu: true,
RolePreviewSwitcher: roleSwitcherStub,
RouterView: true,
'el-button': { template: '<button v-bind="$attrs"><slot /></button>' },
},
},
})
}
beforeEach(async () => {
localStorage.clear()
pinia = createPinia()
setActivePinia(pinia)
await useAuthStore().login('admin', '123456')
useAccessPreviewStore().initializeAfterLogin()
router = createRouter({
history: createMemoryHistory(),
routes: [{
path: '/',
component: AppLayout,
meta: { requiresAuth: true },
children: [
{ path: '', name: 'dashboard', component: { template: '<div />' }, meta: { title: '工作台', permission: 'dashboard:view' } },
{ path: 'system/users', name: 'users', component: { template: '<div />' }, meta: { title: '内部用户', permission: 'system:user:list' } },
{ path: '403', name: 'forbidden', component: { template: '<div />' }, meta: { title: '无访问权限' } },
],
}],
})
mockedConfirm.mockReset()
mockedConfirm.mockResolvedValue('confirm' as never)
})
describe('AppLayout access preview', () => {
it('separates login identity and preview state', async () => {
const wrapper = await mountLayout()
expect(wrapper.text()).toContain('登录身份:平台管理员')
expect(wrapper.text()).toContain('admin')
})
it('switches roles, replaces inaccessible routes, and recovers from 403', async () => {
const access = useAccessPreviewStore()
const wrapper = await mountLayout('/system/users')
await wrapper.get('[data-testid="switch-r003"]').trigger('click')
await flushPromises()
expect(access.currentRole?.id).toBe('R003')
expect(router.currentRoute.value.path).toBe('/')
await router.replace('/403')
access.commitRole('R005')
await nextTick()
expect(router.currentRoute.value.path).toBe('/')
})
it('keeps the current role when the dirty-work confirmation is cancelled', async () => {
const access = useAccessPreviewStore()
access.commitRole('R005')
useUnsavedWorkStore().upsert({
key: 'customer-editor',
label: '新增客户',
dirty: true,
requiredPermission: 'customer:create',
})
mockedConfirm.mockRejectedValueOnce(new Error('cancel'))
const wrapper = await mountLayout()
await wrapper.get('[data-testid="switch-r003"]').trigger('click')
await flushPromises()
expect(access.currentRole?.id).toBe('R005')
})
it('reacts to live permission changes and resets preview state on logout', async () => {
const access = useAccessPreviewStore()
const data = useAdminDataStore()
const auth = useAuthStore()
const wrapper = await mountLayout('/system/users')
const replace = vi.spyOn(router, 'replace')
data.savePermission({ ...data.permissions.find((item) => item.id === 'P300')!, status: '停用' })
await nextTick()
await flushPromises()
expect(replace).toHaveBeenCalledWith('/')
await wrapper.get('[aria-label="退出登录"]').trigger('click')
await flushPromises()
expect(access.currentRole).toBeNull()
expect(auth.isAuthenticated).toBe(false)
})
it('recovers from 403 when effective access changes', async () => {
const data = useAdminDataStore()
const dashboardPermission = data.permissions.find((item) => item.id === 'P001')!
data.savePermission({ ...dashboardPermission, status: '停用' })
await mountLayout('/403')
data.savePermission({ ...dashboardPermission, status: '启用' })
await nextTick()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/')
})
})
```
- [ ] **Step 5:在布局接入两阶段切换和路由修正**
在 `AppLayout.vue` 脚本中增加 Store、组件和纯函数导入,并建立:
```ts
const access = useAccessPreviewStore()
const unsaved = useUnsavedWorkStore()
const correctingRoute = ref(false)
const announcement = ref('')
const pageHeading = ref<HTMLElement | null>(null)
access.initializeAfterLogin()
async function replaceAndFocus(path: string) {
if (route.fullPath !== path) await router.replace(path)
await nextTick()
pageHeading.value?.focus()
}
async function requestRoleSwitch(roleId: string) {
if (roleId === access.currentRole?.id) return
const candidate = access.previewRole(roleId)
const target = resolvePreviewTarget({
currentPath: route.path,
currentPermission: route.meta.permission,
candidateCodes: candidate.codes,
firstPath: candidate.firstPath,
})
const blockers = unsaved.findBlocking(candidate.codes, Boolean(target && target !== route.fullPath))
if (blockers.length) {
try {
await ElMessageBox.confirm(
`切换后将离开或无法保存“${blockers.map((item) => item.label).join('、')}”,是否继续?`,
'切换权限预览角色',
{ confirmButtonText: '继续切换', cancelButtonText: '保留当前编辑', type: 'warning' },
)
} catch {
return
}
}
correctingRoute.value = true
try {
access.commitRole(roleId)
if (target) await replaceAndFocus(target)
} finally {
correctingRoute.value = false
}
const roleName = access.currentRole?.name || '无可用角色'
announcement.value = target
? `已切换至${roleName},当前页面为${String(route.meta.title || '无访问权限')}`
: `已切换至${roleName}`
ElMessage.success(`已切换至“${roleName}”,当前仅为静态权限预览`)
}
watch(
() => `${access.currentRole?.id || ''}:${access.permissionSignature}`,
async () => {
if (correctingRoute.value) return
const target = resolvePreviewTarget({
currentPath: route.path,
currentPermission: route.meta.permission,
candidateCodes: access.permissionCodes,
firstPath: access.firstAccessiblePath,
})
if (!target || target === route.fullPath) return
correctingRoute.value = true
try {
await replaceAndFocus(target)
} finally {
correctingRoute.value = false
}
announcement.value = `权限已更新,当前页面为${String(route.meta.title || '无访问权限')}`
},
)
```
现有 `handleLogout` 的确认分支改为:
```ts
access.reset()
unsaved.clear()
auth.logout()
await router.replace('/login')
```
模板中完成四项替换:
1. 侧栏传入 `:items="access.filteredNavigation"`。
2. `h1` 增加 `ref="pageHeading" tabindex="-1"`。
3. 用户信息替换为以下明确结构:
```vue
<div
class="user-copy"
:aria-label="`登录身份:${auth.user?.name || ''},账号:${auth.user?.account || ''}`"
>
<strong>登录身份:{{ auth.user?.name }}</strong>
<span>{{ auth.user?.account }}</span>
</div>
```
4. 用户信息前插入:
```vue
<RolePreviewSwitcher
:roles="access.enabledRoles"
:current-role="access.currentRole"
@select="requestRoleSwitch"
/>
<p class="sr-only" aria-live="polite" aria-atomic="true">{{ announcement }}</p>
```
- [ ] **Step 6:登录成功初始化权限预览**
`LoginView.vue` 导入并创建 `const access = useAccessPreviewStore()`,在 `await auth.login(...)` 后、跳转前调用:
```ts
access.initializeAfterLogin()
```
路由守卫中的相同调用是刷新登录态的幂等兜底,不会在未登录时持久化角色。
- [ ] **Step 7:运行组件和布局测试**
Run:
```bash
cd apps/admin-web
pnpm test -- src/components/RolePreviewSwitcher.test.ts src/layouts/AppLayout.test.ts
```
Expected: 两个测试文件全部 PASS。
- [ ] **Step 8:记录验证检查点**
确认角色取消切换时没有先写 Store;确认 403 切换到有菜单角色后恢复;确认退出先清预览再清认证。
## Task 6:客户与头程订单操作权限和脏状态
**Files:**
- Modify: `apps/admin-web/src/views/CustomersView.vue`
- Modify: `apps/admin-web/src/views/FirstLegOrdersView.vue`
- Create: `apps/admin-web/src/views/AccessControlledActions.test.ts`
- [ ] **Step 1:先写业务操作权限失败测试**
在 `AccessControlledActions.test.ts` 使用真实 Element Plus 组件挂载页面;先加入完整测试基线:
```ts
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { enableAutoUnmount, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import ElementPlus from 'element-plus'
import CustomersView from './CustomersView.vue'
import FirstLegOrdersView from './FirstLegOrdersView.vue'
import InternalUsersView from './InternalUsersView.vue'
import RolesView from './RolesView.vue'
import PermissionsView from './PermissionsView.vue'
import { useAccessPreviewStore } from '@/stores/accessPreview'
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
let pinia: Pinia
beforeAll(() => vi.stubGlobal('ResizeObserver', ResizeObserverStub))
afterAll(() => vi.unstubAllGlobals())
enableAutoUnmount(afterEach)
beforeEach(() => {
localStorage.clear()
pinia = createPinia()
setActivePinia(pinia)
})
const mountOptions = () => ({
attachTo: document.body,
global: { plugins: [pinia, ElementPlus] },
})
```
在该基线上加入:
```ts
describe('access-controlled business actions', () => {
it('shows audit but hides customer creation for the operations manager', async () => {
const access = useAccessPreviewStore()
access.initializeAfterLogin()
access.commitRole('R002')
const customers = mount(CustomersView, mountOptions())
await nextTick()
expect(customers.text()).not.toContain('新增客户')
expect(customers.text()).toContain('审核')
})
it('hides order creation and status actions for finance', async () => {
const access = useAccessPreviewStore()
access.initializeAfterLogin()
access.commitRole('R004')
const orders = mount(FirstLegOrdersView, mountOptions())
await nextTick()
expect(orders.text()).not.toContain('创建订单')
expect(orders.text()).not.toContain('批量更新')
expect(orders.text()).not.toContain('处理')
})
it('shows customer creation without granting order status updates to sales', async () => {
const access = useAccessPreviewStore()
access.initializeAfterLogin()
access.commitRole('R005')
const customers = mount(CustomersView, mountOptions())
const orders = mount(FirstLegOrdersView, mountOptions())
await nextTick()
expect(customers.text()).toContain('新增客户')
expect(orders.text()).not.toContain('更新状态')
})
})
```
- [ ] **Step 2:运行测试确认按钮尚未受控**
Run:
```bash
cd apps/admin-web
pnpm test -- src/views/AccessControlledActions.test.ts
```
Expected: FAIL,当前角色无权限时仍能看到创建或状态按钮。
- [ ] **Step 3:接入客户权限和表单登记**
`CustomersView.vue` 增加:
```ts
const access = useAccessPreviewStore()
const customerEditorLabel = computed(() => form.id ? '编辑客户' : '新增客户')
const customerEditorPermission = computed(() => form.id ? null : 'customer:create')
const { captureBaseline: captureCustomerBaseline } = useUnsavedWorkRegistration({
key: 'customer-editor',
label: customerEditorLabel,
isOpen: editorOpen,
model: form,
requiredPermission: customerEditorPermission,
})
```
`openCreate` 和 `openEdit` 在 `Object.assign` 后、打开抽屉前调用 `captureCustomerBaseline()`。模板加入以下准确条件:
同时监听创建权限;用户确认角色切换后如果同一路由仍可访问但创建权限消失,关闭新增抽屉并丢弃未保存的新客户:
```ts
watch(() => access.can('customer:create'), (allowed) => {
if (!allowed && editorOpen.value && !form.id) editorOpen.value = false
})
```
```vue
<el-button v-if="access.can('customer:create')" type="primary" @click="openCreate">
<Plus :size="16" />新增客户
</el-button>
<el-button
v-if="row.status === '待审核' && access.can('customer:audit')"
link
type="success"
@click="auditCustomer(row as Customer)"
>
<ShieldCheck :size="15" />审核
</el-button>
<el-button
v-if="form.id || access.can('customer:create')"
type="primary"
@click="saveCustomer"
>
保存客户
</el-button>
```
详情抽屉中的“通过审核”增加 `access.can('customer:audit')`;客户编辑与启停保持规格约定,不增加新权限编码。
- [ ] **Step 4:接入订单权限和表单登记**
`FirstLegOrdersView.vue` 增加:
```ts
const access = useAccessPreviewStore()
const orderEditorLabel = computed(() => form.id ? '编辑头程订单' : '创建头程订单')
const orderEditorPermission = computed(() => form.id ? null : 'first-leg:create')
const { captureBaseline: captureOrderBaseline } = useUnsavedWorkRegistration({
key: 'first-leg-order-editor',
label: orderEditorLabel,
isOpen: editorOpen,
model: form,
requiredPermission: orderEditorPermission,
})
```
`openCreate` 和 `openEdit` 捕获表单基线。模板规则固定为:
同时监听创建权限并在确认切换后关闭失去保存权限的新订单抽屉:
```ts
watch(() => access.can('first-leg:create'), (allowed) => {
if (!allowed && editorOpen.value && !form.id) editorOpen.value = false
})
```
```vue
<el-button
v-if="access.can('first-leg:status')"
:disabled="selectedRows.length === 0"
@click="openStatusDialog(selectedRows)"
>
<ClipboardCheck :size="16" />批量更新
</el-button>
<el-button v-if="access.can('first-leg:create')" type="primary" @click="openCreate">
<Plus :size="16" />创建订单
</el-button>
<el-button
v-if="!form.id && access.can('first-leg:create')"
@click="saveOrder('草稿')"
>
保存草稿
</el-button>
<el-button
v-if="form.id || access.can('first-leg:create')"
type="primary"
@click="saveOrder('待审核')"
>
{{ form.id ? '保存修改' : '创建并提交' }}
</el-button>
```
表格“处理”、详情“更新状态”和状态对话框“确认更新”均增加 `access.can('first-leg:status')`;订单编辑保持无新增编码的现状。
- [ ] **Step 5:运行业务操作和协调器测试**
Run:
```bash
cd apps/admin-web
pnpm test -- src/views/AccessControlledActions.test.ts src/composables/useUnsavedWorkRegistration.test.ts
```
Expected: 两个测试文件全部 PASS。
- [ ] **Step 6:记录验证检查点**
确认“创建”受控但既有记录编辑未被错误隐藏;确认审核和状态更新使用各自稳定编码。
## Task 7:系统管理操作权限、目录语义与脏状态
**Files:**
- Modify: `apps/admin-web/src/views/InternalUsersView.vue`
- Modify: `apps/admin-web/src/views/RolesView.vue`
- Modify: `apps/admin-web/src/views/PermissionsView.vue`
- Modify: `apps/admin-web/src/views/AccessControlledActions.test.ts`
- [ ] **Step 1:扩展系统操作失败测试**
在 `AccessControlledActions.test.ts` 加入:
```ts
describe('access-controlled system actions', () => {
it('hides system maintenance actions from a business role', async () => {
const access = useAccessPreviewStore()
access.initializeAfterLogin()
access.commitRole('R002')
const users = mount(InternalUsersView, mountOptions())
const roles = mount(RolesView, mountOptions())
const permissions = mount(PermissionsView, mountOptions())
await nextTick()
expect(users.text()).not.toContain('新增用户')
expect(users.text()).not.toContain('重置密码')
expect(roles.text()).not.toContain('新增角色')
expect(roles.text()).not.toContain('配置')
expect(permissions.text()).not.toContain('新增权限')
expect(permissions.text()).not.toContain('添加下级')
})
it('shows all system maintenance domains to the super administrator', async () => {
const access = useAccessPreviewStore()
access.initializeAfterLogin()
const users = mount(InternalUsersView, mountOptions())
const roles = mount(RolesView, mountOptions())
const permissions = mount(PermissionsView, mountOptions())
await nextTick()
expect(users.text()).toContain('新增用户')
expect(users.text()).toContain('重置密码')
expect(roles.text()).toContain('新增角色')
expect(roles.text()).toContain('配置')
expect(permissions.text()).toContain('新增权限')
expect(permissions.text()).toContain('添加下级')
})
})
```
- [ ] **Step 2:运行测试确认系统按钮尚未受控**
Run:
```bash
cd apps/admin-web
pnpm test -- src/views/AccessControlledActions.test.ts
```
Expected: FAIL,R002 仍能看到系统维护操作。
- [ ] **Step 3:内部用户页统一使用 `system:user:write`**
增加 access Store 和以下登记:
```ts
const userEditorLabel = computed(() => form.id ? '编辑内部用户' : '新增内部用户')
const userEditorPermission = computed(() => 'system:user:write')
const { captureBaseline: captureUserBaseline } = useUnsavedWorkRegistration({
key: 'internal-user-editor',
label: userEditorLabel,
isOpen: editorOpen,
model: form,
requiredPermission: userEditorPermission,
})
```
在新增、编辑、重置密码、启停和保存按钮上增加:
```vue
v-if="access.can('system:user:write')"
```
`openCreate` 和 `openEdit` 捕获基线。
- [ ] **Step 4:角色页统一使用 `system:role:grant`**
增加 access Store 和角色表单登记,requiredPermission 固定返回 `system:role:grant`。新增、配置、启停、删除和保存按钮均增加:
```vue
v-if="access.can('system:role:grant')"
```
把权限树说明替换为:
```vue
<p>目录仅用于组织结构且独立授权;停用目录不会级联停用已启用的菜单和操作。</p>
```
保留内置角色禁止删除和停用的既有约束。
- [ ] **Step 5:权限页统一使用 `system:permission:write`**
增加 access Store 和权限表单登记,requiredPermission 固定返回 `system:permission:write`。新增、编辑、添加下级、启停和保存按钮均增加:
```vue
v-if="access.can('system:permission:write')"
```
`toggleStatus` 的确认文案按目录类型分支:
```ts
const directoryNotice = row.type === '目录' && target === '停用'
? '目录仅用于组织结构,停用不会级联影响已启用的下级菜单和操作。'
: ''
await ElMessageBox.confirm(
`确认${target}“${row.name}”吗?${directoryNotice}`,
'权限状态',
{ confirmButtonText: `确认${target}`, cancelButtonText: '取消', type: 'warning' },
)
```
- [ ] **Step 6:运行系统操作、角色树和权限树测试**
Run:
```bash
cd apps/admin-web
pnpm test -- src/views/AccessControlledActions.test.ts src/views/RolesView.test.ts src/utils/permissionTree.test.ts
```
Expected: 三个测试文件全部 PASS。
- [ ] **Step 7:记录验证检查点**
确认目录停用不改变后代记录状态;确认页面只隐藏已映射操作,没有创造新的权限编码。
## Task 8:样式、响应式、文档与完整验收
**Files:**
- Modify: `apps/admin-web/src/styles/index.css`
- Modify: `apps/admin-web/README.md`
- Verify: `apps/admin-web/src/**/*.test.ts`
- [ ] **Step 1:补充顶栏、403 和辅助样式**
在 `styles/index.css` 增加:
```css
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.role-preview-control {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.role-preview-trigger {
display: flex;
min-width: 0;
min-height: 44px;
max-width: 240px;
align-items: center;
gap: 6px;
padding: 0 10px;
border: 1px solid var(--cc-border);
border-radius: 8px;
background: #fff;
color: #344054;
cursor: pointer;
}
.role-preview-trigger:focus-visible {
outline: 2px solid var(--cc-primary);
outline-offset: 2px;
}
.role-preview-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.role-preview-scope {
max-width: 260px;
color: var(--cc-muted);
font-size: 10px;
text-align: right;
}
.forbidden-state {
display: flex;
min-height: 480px;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 36px 20px;
border: 1px solid var(--cc-border);
border-radius: 8px;
background: #fff;
text-align: center;
}
.forbidden-icon {
display: grid;
width: 56px;
height: 56px;
place-items: center;
margin-bottom: 16px;
border-radius: 12px;
background: #fef3f2;
color: #b42318;
}
.forbidden-source {
max-width: 100%;
overflow-wrap: anywhere;
color: var(--cc-muted);
font-size: 12px;
}
```
在现有移动端 media query 中加入:
```css
@media (max-width: 720px) {
.role-preview-control { margin-left: auto; }
.role-preview-label, .role-preview-scope { display: none; }
.role-preview-trigger { width: 44px; padding: 0; justify-content: center; }
.role-preview-name { position: absolute; width: 1px; height: 1px; overflow: hidden; }
}
@media (max-width: 360px) {
.app-header { gap: 6px; padding-inline: 8px; }
.header-user { gap: 4px; }
.page-heading h1 { max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
```
- [ ] **Step 2:更新 Admin README**
增加“静态权限预览”小节:
```markdown
## 静态权限预览
登录后可在顶栏切换启用的模拟角色,菜单、页面和已映射操作会按本地 Pinia 权限数据更新。该能力仅用于前端原型验收,不模拟数据范围,也不替代 Java 服务端鉴权。
刷新会保留当前演示角色;退出登录会清除预览角色。当前实现不会调用尚未确定的 Java 业务接口。
```
- [ ] **Step 3:运行全部自动化验证**
Run:
```bash
cd apps/admin-web
pnpm test
pnpm typecheck
pnpm build
```
Expected:
- Vitest 所有测试文件 PASS,0 failed。
- `vue-tsc --noEmit` 退出码 0。
- Vite build 退出码 0,产物仍只写入 `apps/admin-web/dist`。
- [ ] **Step 4:启动 Admin 并完成一次成组浏览器验收**
Run:
```bash
cd apps/admin-web
pnpm dev
```
在同一批验收中检查桌面、390px、320px 和 200% 缩放:
1. 登录后默认超级管理员,顶栏分别显示登录身份和权限预览。
2. R002 隐藏新增客户但保留审核;R003 只保留工作台和订单;R004 无订单状态按钮;R005 有新增客户。
3. 直接访问无权限地址进入 `/403?from=%2Fsystem%2Fusers`,来源只显示文本。
4. 在 `/403` 切换到有权限角色后进入配置顺序中的第一个叶子。
5. 修改表单后切换到会离开页面或失去保存权限的角色:取消保持内容,确认后 replace 跳转。
6. 使用 Tab、Enter/Space、方向键和 Escape 完成角色选择;焦点可见。
7. 自动跳转后焦点位于页面主标题,读屏可获得 polite 状态播报。
8. 320px 下角色入口、菜单按钮和退出按钮不重叠;长角色名不撑破顶栏。
9. DevTools Network 中没有因权限预览产生的业务 API 请求,控制台无错误。
- [ ] **Step 5:执行一次 Impeccable 检测和最终确认**
对最终 Admin 页面运行一次 Impeccable 检测;把有效告警与桌面/窄屏实测合并成一次修复批次,然后只做一次确认回归。不要把 Markdown 规格扫描结果当作页面验收证据。
- [ ] **Step 6:记录最终检查点**
记录测试数量、typecheck/build 退出码、四种视口结果、Impeccable 结果和剩余风险。明确当前仍是 Pinia 静态数据,Java API 与服务端 RBAC 不在本计划交付范围。