rbac-permission-interface-impl

This commit is contained in:
2026-03-19 08:44:32 +08:00
parent 1040e3ecfd
commit 1f66b261f4
10 changed files with 292 additions and 21 deletions
@@ -4,6 +4,7 @@ import { mount, flushPromises } from '@vue/test-utils'
import { nextTick } from 'vue'
import { message } from 'ant-design-vue'
import { useUserManageStore, type UserRecord } from '@/stores/user-manage'
import { useUserStore } from '@/stores/user'
// jsdom 不支持 matchMediaAnt Design Vue 响应式布局需要
Object.defineProperty(window, 'matchMedia', {
@@ -184,6 +185,10 @@ describe('Users Page', () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockResolvedValue(mockPaginatedResponse)
// 设置 admin 用户以显示操作按钮
const userStore = useUserStore()
userStore.setUser({ id: 1, username: 'admin', email: 'a@a.com', role: 'administrator', status: 1 })
const { default: UsersPage } = await import('../index.vue')
wrapper = mount(UsersPage, {
@@ -239,6 +244,40 @@ describe('Users Page', () => {
expect(redTags.length).toBeGreaterThanOrEqual(1)
})
it('hides create/edit/toggle buttons for non-admin user', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockResolvedValue(mockPaginatedResponse)
// 设置非 admin 用户
const userStore = useUserStore()
userStore.setUser({ id: 2, username: 'viewer', email: 'v@v.com', role: 'accessor', status: 1 })
const { default: UsersPage } = await import('../index.vue')
wrapper = mount(UsersPage, {
attachTo: document.body,
global: {
stubs: {
PlusOutlined: { template: '<span />' },
SearchOutlined: { template: '<span />' },
ReloadOutlined: { template: '<span />' },
UserFormModal: { template: '<div class="form-modal-stub" />' },
},
},
})
await flushPromises()
await nextTick()
const buttons = Array.from(document.body.querySelectorAll('.ant-btn'))
const buttonTexts = buttons.map((b) => b.textContent?.trim())
expect(buttonTexts.some((t) => t?.includes('新建用户'))).toBe(false)
expect(buttonTexts.some((t) => t?.includes('编辑'))).toBe(false)
expect(buttonTexts.some((t) => t?.includes('禁用'))).toBe(false)
// 查看按钮仍可见
expect(buttonTexts.some((t) => t?.includes('查看'))).toBe(true)
})
it('opens detail drawer when clicking view button', async () => {
const { api } = await mountPage()
vi.mocked(api.get).mockResolvedValueOnce(mockUsers[0])
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { nextTick } from 'vue'
import { message } from 'ant-design-vue'
import UserFormModal from '@/components/UserFormModal.vue'
Object.defineProperty(window, 'matchMedia', {
@@ -19,6 +20,7 @@ Object.defineProperty(window, 'matchMedia', {
vi.mock('@/utils/request', () => ({
api: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
},
@@ -82,10 +84,10 @@ describe('UserFormModal', () => {
expect(labels).toContain('密码')
})
it('shows all four form fields', async () => {
it('shows all five form fields', async () => {
await mountModal()
const formItems = queryBody('.ant-form-item')
expect(formItems).toHaveLength(4) // username, password, email, status
expect(formItems).toHaveLength(5) // username, password, email, status, role
})
it('emits update:open false on cancel', async () => {
@@ -113,10 +115,10 @@ describe('UserFormModal', () => {
expect(labels).not.toContain('密码')
})
it('shows three form fields (no password)', async () => {
it('shows four form fields (no password)', async () => {
await mountModal({ mode: 'edit', userData: mockUserData })
const formItems = queryBody('.ant-form-item')
expect(formItems).toHaveLength(3) // username, email, status
expect(formItems).toHaveLength(4) // username, email, status, role
})
it('prefills form with user data', async () => {
@@ -133,6 +135,64 @@ describe('UserFormModal', () => {
})
})
describe('edge cases', () => {
it('shows warning when role list fetch fails', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockRejectedValueOnce(new Error('Network error'))
const warnSpy = vi.spyOn(message, 'warning').mockImplementation(() => ({}) as never)
// open=false then switch to true triggers fetchRoles
const w = await mountModal({ open: false })
await w.setProps({ open: true })
await flushPromises()
await nextTick()
expect(warnSpy).toHaveBeenCalledWith('获取角色列表失败,角色选择不可用')
warnSpy.mockRestore()
})
it('shows warning when user created but role assignment fails', async () => {
const { api } = await import('@/utils/request')
// fetchRoles succeeds
vi.mocked(api.get).mockResolvedValueOnce([{ id: 1, name: 'administrator' }])
// user create succeeds
vi.mocked(api.post).mockResolvedValueOnce({ id: 99 })
// role assignment fails
vi.mocked(api.put).mockRejectedValueOnce(new Error('Role assign failed'))
const warnSpy = vi.spyOn(message, 'warning').mockImplementation(() => ({}) as never)
const successSpy = vi.spyOn(message, 'success').mockImplementation(() => ({}) as never)
const w = await mountModal({ open: false })
await w.setProps({ open: true })
await flushPromises()
await nextTick()
// Set role_id via component internals
const vm = w.vm as unknown as { formState: { username: string; password: string; email: string; role_id: number | undefined } }
vm.formState.username = 'newuser'
vm.formState.password = 'pass123456'
vm.formState.email = 'new@test.com'
vm.formState.role_id = 1
await nextTick()
// Submit
const okBtn = document.body.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement
okBtn?.click()
await flushPromises()
await nextTick()
await flushPromises()
// Role assignment warning shown but overall success emitted
expect(warnSpy).toHaveBeenCalledWith('用户已保存,但角色分配失败,请稍后在用户列表中重试')
expect(successSpy).toHaveBeenCalledWith('操作成功')
expect(w.emitted('success')).toBeTruthy()
warnSpy.mockRestore()
successSpy.mockRestore()
})
})
describe('form submission', () => {
it('calls api.post for create mode', async () => {
const { api } = await import('@/utils/request')
+11 -4
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { api } from '@/utils/request'
import { useUserManageStore, type UserRecord } from '@/stores/user-manage'
import { useUserStore } from '@/stores/user'
import UserFormModal from '@/components/UserFormModal.vue'
import {
PlusOutlined,
@@ -9,6 +10,7 @@ import {
} from '@ant-design/icons-vue'
const store = useUserManageStore()
const userStore = useUserStore()
// Detail drawer
const drawerVisible = ref(false)
@@ -24,6 +26,7 @@ const columns = [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '用户名', dataIndex: 'username' },
{ title: '邮箱', dataIndex: 'email' },
{ title: '角色', dataIndex: 'role_name', width: 120 },
{ title: '状态', dataIndex: 'status', width: 100 },
{ title: '创建时间', dataIndex: 'created_at', width: 180 },
{ title: '操作', key: 'action', width: 200 },
@@ -146,7 +149,7 @@ function formatTime(time: string) {
<!-- Table -->
<a-card>
<div class="mb-4">
<div v-if="userStore.isAdmin" class="mb-4">
<a-button type="primary" @click="handleCreate">
<template #icon><PlusOutlined /></template>
新建用户
@@ -161,7 +164,10 @@ function formatTime(time: string) {
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'status'">
<template v-if="column.dataIndex === 'role_name'">
{{ record.role?.name || '-' }}
</template>
<template v-else-if="column.dataIndex === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'red'">
{{ record.status === 1 ? '启用' : '禁用' }}
</a-tag>
@@ -174,10 +180,11 @@ function formatTime(time: string) {
<a-button type="link" size="small" @click="handleViewDetail(record as UserRecord)">
查看
</a-button>
<a-button type="link" size="small" @click="handleEdit(record as UserRecord)">
<a-button v-if="userStore.isAdmin" type="link" size="small" @click="handleEdit(record as UserRecord)">
编辑
</a-button>
<a-popconfirm
v-if="userStore.isAdmin"
:title="`确定要${record.status === 1 ? '禁用' : '启用'}该用户吗?`"
@confirm="handleToggleStatus(record as UserRecord)"
>
@@ -220,7 +227,7 @@ function formatTime(time: string) {
{{ currentUser.status === 1 ? '启用' : '禁用' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="角色 ID">{{ currentUser.role_id }}</a-descriptions-item>
<a-descriptions-item label="角色">{{ currentUser.role?.name || '-' }}</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ formatTime(currentUser.created_at) }}
</a-descriptions-item>