2038 lines
62 KiB
Markdown
2038 lines
62 KiB
Markdown
# 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 不在本计划交付范围。
|