rbac-permission-interface-impl
This commit is contained in:
@@ -14,13 +14,20 @@ const emit = defineEmits<{
|
|||||||
success: []
|
success: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
interface RoleOption {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
const roles = ref<RoleOption[]>([])
|
||||||
const formState = reactive({
|
const formState = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
email: '',
|
email: '',
|
||||||
status: 1 as number,
|
status: 1 as number,
|
||||||
|
role_id: undefined as number | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const rules = computed<Record<string, Rule[]>>(() => {
|
const rules = computed<Record<string, Rule[]>>(() => {
|
||||||
@@ -46,20 +53,33 @@ const rules = computed<Record<string, Rule[]>>(() => {
|
|||||||
|
|
||||||
const title = computed(() => (props.mode === 'create' ? '新建用户' : '编辑用户'))
|
const title = computed(() => (props.mode === 'create' ? '新建用户' : '编辑用户'))
|
||||||
|
|
||||||
|
async function fetchRoles() {
|
||||||
|
try {
|
||||||
|
const data = await api.get<RoleOption[]>('/api/v1/roles')
|
||||||
|
roles.value = data
|
||||||
|
} catch {
|
||||||
|
roles.value = []
|
||||||
|
message.warning('获取角色列表失败,角色选择不可用')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.open,
|
() => props.open,
|
||||||
(val) => {
|
(val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
|
fetchRoles()
|
||||||
if (props.mode === 'edit' && props.userData) {
|
if (props.mode === 'edit' && props.userData) {
|
||||||
formState.username = props.userData.username
|
formState.username = props.userData.username
|
||||||
formState.email = props.userData.email
|
formState.email = props.userData.email
|
||||||
formState.status = props.userData.status
|
formState.status = props.userData.status
|
||||||
|
formState.role_id = props.userData.role_id
|
||||||
formState.password = ''
|
formState.password = ''
|
||||||
} else {
|
} else {
|
||||||
formState.username = ''
|
formState.username = ''
|
||||||
formState.password = ''
|
formState.password = ''
|
||||||
formState.email = ''
|
formState.email = ''
|
||||||
formState.status = 1
|
formState.status = 1
|
||||||
|
formState.role_id = undefined
|
||||||
}
|
}
|
||||||
// 清除上一次的校验状态
|
// 清除上一次的校验状态
|
||||||
nextTick(() => formRef.value?.clearValidate())
|
nextTick(() => formRef.value?.clearValidate())
|
||||||
@@ -76,20 +96,36 @@ async function handleSubmit() {
|
|||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
|
let userId: number
|
||||||
if (props.mode === 'create') {
|
if (props.mode === 'create') {
|
||||||
await api.post('/api/v1/users', {
|
const created = await api.post<{ id: number }>('/api/v1/users', {
|
||||||
username: formState.username,
|
username: formState.username,
|
||||||
password: formState.password,
|
password: formState.password,
|
||||||
email: formState.email,
|
email: formState.email,
|
||||||
status: formState.status,
|
status: formState.status,
|
||||||
})
|
})
|
||||||
|
userId = created.id
|
||||||
} else {
|
} else {
|
||||||
await api.put(`/api/v1/users/${props.userData!.id}`, {
|
await api.put(`/api/v1/users/${props.userData!.id}`, {
|
||||||
username: formState.username,
|
username: formState.username,
|
||||||
email: formState.email,
|
email: formState.email,
|
||||||
status: formState.status,
|
status: formState.status,
|
||||||
})
|
})
|
||||||
|
userId = props.userData!.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 角色分配(独立 try-catch,用户信息保存不受影响)
|
||||||
|
const needsRoleUpdate = props.mode === 'create'
|
||||||
|
? !!formState.role_id
|
||||||
|
: formState.role_id && formState.role_id !== props.userData!.role_id
|
||||||
|
if (needsRoleUpdate) {
|
||||||
|
try {
|
||||||
|
await api.put(`/api/v1/users/${userId}/role`, { role_id: formState.role_id })
|
||||||
|
} catch {
|
||||||
|
message.warning('用户已保存,但角色分配失败,请稍后在用户列表中重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
message.success('操作成功')
|
message.success('操作成功')
|
||||||
emit('update:open', false)
|
emit('update:open', false)
|
||||||
emit('success')
|
emit('success')
|
||||||
@@ -130,6 +166,13 @@ function handleCancel() {
|
|||||||
<a-select-option :value="0">禁用</a-select-option>
|
<a-select-option :value="0">禁用</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="角色" name="role_id">
|
||||||
|
<a-select v-model:value="formState.role_id" placeholder="请选择角色" allow-clear>
|
||||||
|
<a-select-option v-for="role in roles" :key="role.id" :value="role.id">
|
||||||
|
{{ role.name }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface MenuItem {
|
|||||||
key: string
|
key: string
|
||||||
icon: Component
|
icon: Component
|
||||||
label: string
|
label: string
|
||||||
|
adminOnly?: boolean
|
||||||
children?: MenuItem[]
|
children?: MenuItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ watch(() => route.path, initOpenKeys)
|
|||||||
// 导航菜单配置
|
// 导航菜单配置
|
||||||
const menuItems: MenuItem[] = [
|
const menuItems: MenuItem[] = [
|
||||||
{ key: '/', icon: DashboardOutlined, label: '首页' },
|
{ key: '/', icon: DashboardOutlined, label: '首页' },
|
||||||
{ key: '/users', icon: UserOutlined, label: '用户管理' },
|
{ key: '/users', icon: UserOutlined, label: '用户管理', adminOnly: true },
|
||||||
{ key: '/products', icon: ShoppingOutlined, label: '产品管理' },
|
{ key: '/products', icon: ShoppingOutlined, label: '产品管理' },
|
||||||
{
|
{
|
||||||
key: 'orders-group',
|
key: 'orders-group',
|
||||||
@@ -74,14 +75,19 @@ const menuItems: MenuItem[] = [
|
|||||||
{ key: '/refund-items', icon: UnorderedListOutlined, label: '退款子项' },
|
{ key: '/refund-items', icon: UnorderedListOutlined, label: '退款子项' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/mq-status', icon: MonitorOutlined, label: '队列监控' },
|
{ key: '/mq-status', icon: MonitorOutlined, label: '队列监控', adminOnly: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const filteredMenuItems = computed(() =>
|
||||||
|
menuItems.filter((item) => !item.adminOnly || userStore.isAdmin),
|
||||||
|
)
|
||||||
|
|
||||||
const username = computed(() => userStore.username || 'admin')
|
const username = computed(() => userStore.username || 'admin')
|
||||||
|
|
||||||
const handleMenuClick = ({ key }: { key: string }) => {
|
const handleMenuClick = ({ key }: { key: string | number }) => {
|
||||||
if (key.startsWith('/')) {
|
const path = String(key)
|
||||||
router.push(key)
|
if (path.startsWith('/')) {
|
||||||
|
router.push(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +174,7 @@ const breadcrumbItems = computed(() => {
|
|||||||
theme="dark"
|
theme="dark"
|
||||||
@click="handleMenuClick"
|
@click="handleMenuClick"
|
||||||
>
|
>
|
||||||
<template v-for="item in menuItems" :key="item.key">
|
<template v-for="item in filteredMenuItems" :key="item.key">
|
||||||
<a-sub-menu v-if="item.children" :key="item.key">
|
<a-sub-menu v-if="item.children" :key="item.key">
|
||||||
<template #icon><component :is="item.icon" /></template>
|
<template #icon><component :is="item.icon" /></template>
|
||||||
<template #title>{{ item.label }}</template>
|
<template #title>{{ item.label }}</template>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ setTokenGetter(() => useUserStore().token)
|
|||||||
|
|
||||||
// 路由守卫
|
// 路由守卫
|
||||||
const authWhitelist = ['/login', '/register']
|
const authWhitelist = ['/login', '/register']
|
||||||
|
const adminOnlyPaths = ['/users', '/mq-status']
|
||||||
|
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
const { useUserStore } = await import('./stores/user')
|
const { useUserStore } = await import('./stores/user')
|
||||||
@@ -54,6 +55,12 @@ router.beforeEach(async (to) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 角色权限检查:非 admin 不能访问受限路由
|
||||||
|
if (adminOnlyPaths.includes(to.path) && !userStore.isAdmin) {
|
||||||
|
message.error('无权访问')
|
||||||
|
return '/'
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { mount, flushPromises } from '@vue/test-utils'
|
|||||||
import { nextTick } from 'vue'
|
import { nextTick } from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import { useUserManageStore, type UserRecord } from '@/stores/user-manage'
|
import { useUserManageStore, type UserRecord } from '@/stores/user-manage'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
// jsdom 不支持 matchMedia,Ant Design Vue 响应式布局需要
|
// jsdom 不支持 matchMedia,Ant Design Vue 响应式布局需要
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
@@ -184,6 +185,10 @@ describe('Users Page', () => {
|
|||||||
const { api } = await import('@/utils/request')
|
const { api } = await import('@/utils/request')
|
||||||
vi.mocked(api.get).mockResolvedValue(mockPaginatedResponse)
|
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')
|
const { default: UsersPage } = await import('../index.vue')
|
||||||
|
|
||||||
wrapper = mount(UsersPage, {
|
wrapper = mount(UsersPage, {
|
||||||
@@ -239,6 +244,40 @@ describe('Users Page', () => {
|
|||||||
expect(redTags.length).toBeGreaterThanOrEqual(1)
|
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 () => {
|
it('opens detail drawer when clicking view button', async () => {
|
||||||
const { api } = await mountPage()
|
const { api } = await mountPage()
|
||||||
vi.mocked(api.get).mockResolvedValueOnce(mockUsers[0])
|
vi.mocked(api.get).mockResolvedValueOnce(mockUsers[0])
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { mount, flushPromises } from '@vue/test-utils'
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
import { nextTick } from 'vue'
|
import { nextTick } from 'vue'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
import UserFormModal from '@/components/UserFormModal.vue'
|
import UserFormModal from '@/components/UserFormModal.vue'
|
||||||
|
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
@@ -19,6 +20,7 @@ Object.defineProperty(window, 'matchMedia', {
|
|||||||
|
|
||||||
vi.mock('@/utils/request', () => ({
|
vi.mock('@/utils/request', () => ({
|
||||||
api: {
|
api: {
|
||||||
|
get: vi.fn(),
|
||||||
post: vi.fn(),
|
post: vi.fn(),
|
||||||
put: vi.fn(),
|
put: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -82,10 +84,10 @@ describe('UserFormModal', () => {
|
|||||||
expect(labels).toContain('密码')
|
expect(labels).toContain('密码')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows all four form fields', async () => {
|
it('shows all five form fields', async () => {
|
||||||
await mountModal()
|
await mountModal()
|
||||||
const formItems = queryBody('.ant-form-item')
|
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 () => {
|
it('emits update:open false on cancel', async () => {
|
||||||
@@ -113,10 +115,10 @@ describe('UserFormModal', () => {
|
|||||||
expect(labels).not.toContain('密码')
|
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 })
|
await mountModal({ mode: 'edit', userData: mockUserData })
|
||||||
const formItems = queryBody('.ant-form-item')
|
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 () => {
|
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', () => {
|
describe('form submission', () => {
|
||||||
it('calls api.post for create mode', async () => {
|
it('calls api.post for create mode', async () => {
|
||||||
const { api } = await import('@/utils/request')
|
const { api } = await import('@/utils/request')
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { api } from '@/utils/request'
|
import { api } from '@/utils/request'
|
||||||
import { useUserManageStore, type UserRecord } from '@/stores/user-manage'
|
import { useUserManageStore, type UserRecord } from '@/stores/user-manage'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
import UserFormModal from '@/components/UserFormModal.vue'
|
import UserFormModal from '@/components/UserFormModal.vue'
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
|
|
||||||
const store = useUserManageStore()
|
const store = useUserManageStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
// Detail drawer
|
// Detail drawer
|
||||||
const drawerVisible = ref(false)
|
const drawerVisible = ref(false)
|
||||||
@@ -24,6 +26,7 @@ const columns = [
|
|||||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||||
{ title: '用户名', dataIndex: 'username' },
|
{ title: '用户名', dataIndex: 'username' },
|
||||||
{ title: '邮箱', dataIndex: 'email' },
|
{ title: '邮箱', dataIndex: 'email' },
|
||||||
|
{ title: '角色', dataIndex: 'role_name', width: 120 },
|
||||||
{ title: '状态', dataIndex: 'status', width: 100 },
|
{ title: '状态', dataIndex: 'status', width: 100 },
|
||||||
{ title: '创建时间', dataIndex: 'created_at', width: 180 },
|
{ title: '创建时间', dataIndex: 'created_at', width: 180 },
|
||||||
{ title: '操作', key: 'action', width: 200 },
|
{ title: '操作', key: 'action', width: 200 },
|
||||||
@@ -146,7 +149,7 @@ function formatTime(time: string) {
|
|||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
<a-card>
|
<a-card>
|
||||||
<div class="mb-4">
|
<div v-if="userStore.isAdmin" class="mb-4">
|
||||||
<a-button type="primary" @click="handleCreate">
|
<a-button type="primary" @click="handleCreate">
|
||||||
<template #icon><PlusOutlined /></template>
|
<template #icon><PlusOutlined /></template>
|
||||||
新建用户
|
新建用户
|
||||||
@@ -161,7 +164,10 @@ function formatTime(time: string) {
|
|||||||
row-key="id"
|
row-key="id"
|
||||||
>
|
>
|
||||||
<template #bodyCell="{ column, record }">
|
<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'">
|
<a-tag :color="record.status === 1 ? 'green' : 'red'">
|
||||||
{{ record.status === 1 ? '启用' : '禁用' }}
|
{{ record.status === 1 ? '启用' : '禁用' }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
@@ -174,10 +180,11 @@ function formatTime(time: string) {
|
|||||||
<a-button type="link" size="small" @click="handleViewDetail(record as UserRecord)">
|
<a-button type="link" size="small" @click="handleViewDetail(record as UserRecord)">
|
||||||
查看
|
查看
|
||||||
</a-button>
|
</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-button>
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
|
v-if="userStore.isAdmin"
|
||||||
:title="`确定要${record.status === 1 ? '禁用' : '启用'}该用户吗?`"
|
:title="`确定要${record.status === 1 ? '禁用' : '启用'}该用户吗?`"
|
||||||
@confirm="handleToggleStatus(record as UserRecord)"
|
@confirm="handleToggleStatus(record as UserRecord)"
|
||||||
>
|
>
|
||||||
@@ -220,7 +227,7 @@ function formatTime(time: string) {
|
|||||||
{{ currentUser.status === 1 ? '启用' : '禁用' }}
|
{{ currentUser.status === 1 ? '启用' : '禁用' }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</a-descriptions-item>
|
</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="创建时间">
|
<a-descriptions-item label="创建时间">
|
||||||
{{ formatTime(currentUser.created_at) }}
|
{{ formatTime(currentUser.created_at) }}
|
||||||
</a-descriptions-item>
|
</a-descriptions-item>
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useUserStore } from '../user'
|
||||||
|
|
||||||
|
describe('Permission: user store 权限判断', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isAdmin', () => {
|
||||||
|
it('administrator 角色返回 true', () => {
|
||||||
|
const store = useUserStore()
|
||||||
|
store.setUser({ id: 1, username: 'admin', email: 'a@a.com', role: 'administrator', status: 1 })
|
||||||
|
|
||||||
|
expect(store.isAdmin).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('developer 角色返回 false', () => {
|
||||||
|
const store = useUserStore()
|
||||||
|
store.setUser({ id: 2, username: 'dev', email: 'd@d.com', role: 'developer', status: 1 })
|
||||||
|
|
||||||
|
expect(store.isAdmin).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accessor 角色返回 false', () => {
|
||||||
|
const store = useUserStore()
|
||||||
|
store.setUser({ id: 3, username: 'acc', email: 'c@c.com', role: 'accessor', status: 1 })
|
||||||
|
|
||||||
|
expect(store.isAdmin).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未登录(user 为 null)返回 false', () => {
|
||||||
|
const store = useUserStore()
|
||||||
|
|
||||||
|
expect(store.isAdmin).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hasPermission', () => {
|
||||||
|
it('admin 对 write 操作返回 true', () => {
|
||||||
|
const store = useUserStore()
|
||||||
|
store.setUser({ id: 1, username: 'admin', email: 'a@a.com', role: 'administrator', status: 1 })
|
||||||
|
|
||||||
|
expect(store.hasPermission('write')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('admin 对 read 操作返回 true', () => {
|
||||||
|
const store = useUserStore()
|
||||||
|
store.setUser({ id: 1, username: 'admin', email: 'a@a.com', role: 'administrator', status: 1 })
|
||||||
|
|
||||||
|
expect(store.hasPermission('read')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('非 admin 对 write 操作返回 false', () => {
|
||||||
|
const store = useUserStore()
|
||||||
|
store.setUser({ id: 2, username: 'dev', email: 'd@d.com', role: 'developer', status: 1 })
|
||||||
|
|
||||||
|
expect(store.hasPermission('write')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('非 admin 对 read 操作返回 true', () => {
|
||||||
|
const store = useUserStore()
|
||||||
|
store.setUser({ id: 3, username: 'acc', email: 'c@c.com', role: 'accessor', status: 1 })
|
||||||
|
|
||||||
|
expect(store.hasPermission('read')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未登录时 write 返回 false', () => {
|
||||||
|
const store = useUserStore()
|
||||||
|
|
||||||
|
expect(store.hasPermission('write')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未登录时 read 返回 true', () => {
|
||||||
|
const store = useUserStore()
|
||||||
|
|
||||||
|
expect(store.hasPermission('read')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('角色切换响应', () => {
|
||||||
|
it('用户角色从 accessor 切换为 administrator 后 isAdmin 变为 true', () => {
|
||||||
|
const store = useUserStore()
|
||||||
|
store.setUser({ id: 1, username: 'u', email: 'u@u.com', role: 'accessor', status: 1 })
|
||||||
|
expect(store.isAdmin).toBe(false)
|
||||||
|
|
||||||
|
store.setUser({ id: 1, username: 'u', email: 'u@u.com', role: 'administrator', status: 1 })
|
||||||
|
expect(store.isAdmin).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logout 后 isAdmin 恢复为 false', () => {
|
||||||
|
const store = useUserStore()
|
||||||
|
store.setUser({ id: 1, username: 'a', email: 'a@a.com', role: 'administrator', status: 1 })
|
||||||
|
expect(store.isAdmin).toBe(true)
|
||||||
|
|
||||||
|
store.logout()
|
||||||
|
expect(store.isAdmin).toBe(false)
|
||||||
|
expect(store.hasPermission('write')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -41,7 +41,7 @@ describe('useUserStore', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('restores user info from localStorage', () => {
|
it('restores user info from localStorage', () => {
|
||||||
const savedUser = { id: 1, username: 'admin', email: 'a@b.com', role: 'admin', status: 1 }
|
const savedUser = { id: 1, username: 'admin', email: 'a@b.com', role: 'administrator', status: 1 }
|
||||||
localStorage.setItem('user', JSON.stringify(savedUser))
|
localStorage.setItem('user', JSON.stringify(savedUser))
|
||||||
|
|
||||||
const store = useUserStore()
|
const store = useUserStore()
|
||||||
@@ -136,7 +136,7 @@ describe('useUserStore', () => {
|
|||||||
it('clears all state and localStorage', () => {
|
it('clears all state and localStorage', () => {
|
||||||
const store = useUserStore()
|
const store = useUserStore()
|
||||||
store.setToken('h.p.s', 'r.p.s')
|
store.setToken('h.p.s', 'r.p.s')
|
||||||
store.setUser({ id: 1, username: 'a', email: 'a@a.com', role: 'admin', status: 1 })
|
store.setUser({ id: 1, username: 'a', email: 'a@a.com', role: 'administrator', status: 1 })
|
||||||
|
|
||||||
store.logout()
|
store.logout()
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ describe('useUserStore', () => {
|
|||||||
it('resets _remember so subsequent setUser does not persist', () => {
|
it('resets _remember so subsequent setUser does not persist', () => {
|
||||||
const store = useUserStore()
|
const store = useUserStore()
|
||||||
store.setToken('h.p.s', 'r.p.s', true)
|
store.setToken('h.p.s', 'r.p.s', true)
|
||||||
store.setUser({ id: 1, username: 'a', email: 'a@a.com', role: 'admin', status: 1 })
|
store.setUser({ id: 1, username: 'a', email: 'a@a.com', role: 'administrator', status: 1 })
|
||||||
expect(localStorage.getItem('user')).not.toBeNull()
|
expect(localStorage.getItem('user')).not.toBeNull()
|
||||||
|
|
||||||
store.logout()
|
store.logout()
|
||||||
@@ -166,7 +166,7 @@ describe('useUserStore', () => {
|
|||||||
describe('fetchCurrentUser', () => {
|
describe('fetchCurrentUser', () => {
|
||||||
it('fetches user from API and updates store', async () => {
|
it('fetches user from API and updates store', async () => {
|
||||||
const { api } = await import('@/utils/request')
|
const { api } = await import('@/utils/request')
|
||||||
const mockUser = { id: 1, username: 'fetched', email: 'f@f.com', role: 'admin', status: 1 }
|
const mockUser = { id: 1, username: 'fetched', email: 'f@f.com', role: 'administrator', status: 1 }
|
||||||
vi.mocked(api.get).mockResolvedValueOnce(mockUser)
|
vi.mocked(api.get).mockResolvedValueOnce(mockUser)
|
||||||
|
|
||||||
const store = useUserStore()
|
const store = useUserStore()
|
||||||
@@ -255,7 +255,7 @@ describe('useUserStore', () => {
|
|||||||
describe('computed properties', () => {
|
describe('computed properties', () => {
|
||||||
it('isAdmin returns true for admin role', () => {
|
it('isAdmin returns true for admin role', () => {
|
||||||
const store = useUserStore()
|
const store = useUserStore()
|
||||||
store.setUser({ id: 1, username: 'a', email: 'a@a.com', role: 'admin', status: 1 })
|
store.setUser({ id: 1, username: 'a', email: 'a@a.com', role: 'administrator', status: 1 })
|
||||||
|
|
||||||
expect(store.isAdmin).toBe(true)
|
expect(store.isAdmin).toBe(true)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface UserRecord {
|
|||||||
email: string
|
email: string
|
||||||
status: number
|
status: number
|
||||||
role_id: number
|
role_id: number
|
||||||
|
role?: { id: number; name: string }
|
||||||
ext: Record<string, unknown> | null
|
ext: Record<string, unknown> | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
|||||||
@@ -21,9 +21,14 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
const t = token.value
|
const t = token.value
|
||||||
return !!t && t.split('.').length === 3
|
return !!t && t.split('.').length === 3
|
||||||
})
|
})
|
||||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
const isAdmin = computed(() => user.value?.role === 'administrator')
|
||||||
const username = computed(() => user.value?.username || '')
|
const username = computed(() => user.value?.username || '')
|
||||||
|
|
||||||
|
function hasPermission(action: 'read' | 'write'): boolean {
|
||||||
|
if (isAdmin.value) return true
|
||||||
|
return action === 'read'
|
||||||
|
}
|
||||||
|
|
||||||
function setToken(accessToken: string, newRefreshToken: string, remember = true) {
|
function setToken(accessToken: string, newRefreshToken: string, remember = true) {
|
||||||
token.value = accessToken
|
token.value = accessToken
|
||||||
refreshToken.value = newRefreshToken
|
refreshToken.value = newRefreshToken
|
||||||
@@ -97,6 +102,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
setToken,
|
setToken,
|
||||||
setUser,
|
setUser,
|
||||||
fetchCurrentUser,
|
fetchCurrentUser,
|
||||||
|
hasPermission,
|
||||||
logout,
|
logout,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
changePassword,
|
changePassword,
|
||||||
|
|||||||
Reference in New Issue
Block a user