# 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, ): 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 ``` 将 `AdminSidebarMenu.vue` 改为: ```vue ``` 更新 `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(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() 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 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 ``` 创建 `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: '
首页
' } }, { 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: '' } }, }, }) 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([]) 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, 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(options: { key: string label: ComputedRef isOpen: Ref model: T requiredPermission: ComputedRef }) { 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: '
', }, 'el-dropdown-menu': { template: '
' }, 'el-dropdown-item': { props: ['command', 'disabled'], template: '', }, } 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 ``` - [ ] **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: '', } 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: '' }, }, }, }) } 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: '
' }, meta: { title: '工作台', permission: 'dashboard:view' } }, { path: 'system/users', name: 'users', component: { template: '
' }, meta: { title: '内部用户', permission: 'system:user:list' } }, { path: '403', name: 'forbidden', component: { template: '
' }, 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(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
登录身份:{{ auth.user?.name }} {{ auth.user?.account }}
``` 4. 用户信息前插入: ```vue

{{ announcement }}

``` - [ ] **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 新增客户 审核 保存客户 ``` 详情抽屉中的“通过审核”增加 `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 批量更新 创建订单 保存草稿 {{ form.id ? '保存修改' : '创建并提交' }} ``` 表格“处理”、详情“更新状态”和状态对话框“确认更新”均增加 `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

目录仅用于组织结构且独立授权;停用目录不会级联停用已启用的菜单和操作。

``` 保留内置角色禁止删除和停用的既有约束。 - [ ] **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 不在本计划交付范围。