update roles
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoleManageStore } from '@/stores/role-manage'
|
||||
import type { RoleRecord, RouteOverride } from '@/stores/role-manage'
|
||||
import { useRouteGroupStore } from '@/stores/route-group'
|
||||
import type { RouteRecord } from '@/stores/route-group'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
role: RoleRecord | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const roleStore = useRoleManageStore()
|
||||
const groupStore = useRouteGroupStore()
|
||||
|
||||
// 状态
|
||||
const activeTab = ref('groups')
|
||||
const loadingData = ref(false)
|
||||
const savingGroups = ref(false)
|
||||
const savingOverrides = ref(false)
|
||||
|
||||
// Tab 1: 路由组授权
|
||||
const checkedGroupIds = ref<number[]>([])
|
||||
|
||||
// Tab 2: 单条路由覆盖
|
||||
const overrides = ref<RouteOverride[]>([])
|
||||
const allRoutes = ref<RouteRecord[]>([])
|
||||
const addingRouteId = ref<number | null>(null)
|
||||
|
||||
// method 颜色映射
|
||||
const methodColorMap: Record<string, string> = {
|
||||
GET: 'green',
|
||||
POST: 'blue',
|
||||
PUT: 'orange',
|
||||
DELETE: 'red',
|
||||
PATCH: 'cyan',
|
||||
}
|
||||
|
||||
const overrideColumns = [
|
||||
{ title: '方法', key: 'method', width: 80 },
|
||||
{ title: '路径', dataIndex: 'path', key: 'path' },
|
||||
{ title: '允许', key: 'allow', width: 80 },
|
||||
{ title: '操作', key: 'action', width: 80 },
|
||||
]
|
||||
|
||||
// 可添加为覆盖的路由(排除已添加的)
|
||||
const availableRoutes = computed(() => {
|
||||
const existingIds = new Set(overrides.value.map((o) => o.route_id))
|
||||
return allRoutes.value.filter((r) => !existingIds.has(r.id))
|
||||
})
|
||||
|
||||
const routeSelectOptions = computed(() =>
|
||||
availableRoutes.value.map((r) => ({
|
||||
value: r.id,
|
||||
label: `${r.method} ${r.path}`,
|
||||
})),
|
||||
)
|
||||
|
||||
function handleClose() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
activeTab.value = 'groups'
|
||||
checkedGroupIds.value = []
|
||||
overrides.value = []
|
||||
allRoutes.value = []
|
||||
addingRouteId.value = null
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!props.role) return
|
||||
loadingData.value = true
|
||||
try {
|
||||
const [groupIds, roleOverrides, , ] = await Promise.allSettled([
|
||||
roleStore.fetchRoleRouteGroups(props.role.id),
|
||||
roleStore.fetchRoleOverrides(props.role.id),
|
||||
groupStore.fetchGroups(),
|
||||
groupStore.fetchRoutes(),
|
||||
])
|
||||
checkedGroupIds.value = groupIds.status === 'fulfilled' ? groupIds.value : []
|
||||
overrides.value = roleOverrides.status === 'fulfilled' ? roleOverrides.value : []
|
||||
allRoutes.value = groupStore.routes
|
||||
} finally {
|
||||
loadingData.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGroups() {
|
||||
if (!props.role) return
|
||||
savingGroups.value = true
|
||||
try {
|
||||
await roleStore.setRoleRouteGroups(props.role.id, checkedGroupIds.value)
|
||||
message.success('路由组授权保存成功')
|
||||
emit('saved')
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : '保存失败'
|
||||
message.error(msg)
|
||||
} finally {
|
||||
savingGroups.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function addOverride() {
|
||||
if (addingRouteId.value === null) return
|
||||
const route = allRoutes.value.find((r) => r.id === addingRouteId.value)
|
||||
if (!route) return
|
||||
overrides.value.push({
|
||||
route_id: route.id,
|
||||
method: route.method,
|
||||
path: route.path,
|
||||
allow: true,
|
||||
})
|
||||
addingRouteId.value = null
|
||||
}
|
||||
|
||||
function removeOverride(index: number) {
|
||||
overrides.value.splice(index, 1)
|
||||
}
|
||||
|
||||
async function saveOverrides() {
|
||||
if (!props.role) return
|
||||
savingOverrides.value = true
|
||||
try {
|
||||
await roleStore.setRoleOverrides(props.role.id, overrides.value)
|
||||
message.success('路由覆盖保存成功')
|
||||
emit('saved')
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : '保存失败'
|
||||
message.error(msg)
|
||||
} finally {
|
||||
savingOverrides.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(val) => {
|
||||
if (val) {
|
||||
loadData()
|
||||
} else {
|
||||
resetState()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="`配置权限 - ${role?.description || role?.name || ''}`"
|
||||
width="720px"
|
||||
:footer="null"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<a-spin :spinning="loadingData">
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
<!-- Tab 1: 路由组授权 -->
|
||||
<a-tab-pane key="groups" tab="路由组授权">
|
||||
<a-checkbox-group v-model:value="checkedGroupIds" class="w-full">
|
||||
<a-row :gutter="[8, 8]">
|
||||
<a-col v-for="group in groupStore.groups" :key="group.id" :span="8">
|
||||
<a-checkbox :value="group.id">
|
||||
{{ group.label || group.name }}
|
||||
<span class="text-gray-400 text-xs">({{ group.route_count }} 条路由)</span>
|
||||
</a-checkbox>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-checkbox-group>
|
||||
<div class="mt-4 text-right">
|
||||
<a-button type="primary" :loading="savingGroups" @click="saveGroups">
|
||||
保存组授权
|
||||
</a-button>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- Tab 2: 单条路由覆盖 -->
|
||||
<a-tab-pane key="overrides" tab="单条路由覆盖">
|
||||
<div class="mb-3 flex gap-2">
|
||||
<a-select
|
||||
:value="addingRouteId ?? undefined"
|
||||
:options="routeSelectOptions"
|
||||
placeholder="选择路由添加覆盖"
|
||||
show-search
|
||||
:filter-option="(input: string, option: unknown) =>
|
||||
((option as { label: string }).label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
"
|
||||
style="flex: 1"
|
||||
allow-clear
|
||||
@change="(val: unknown) => { addingRouteId = (val as number) ?? null }"
|
||||
/>
|
||||
<a-button type="primary" :disabled="addingRouteId === null" @click="addOverride">
|
||||
添加覆盖
|
||||
</a-button>
|
||||
</div>
|
||||
<a-table
|
||||
:columns="overrideColumns"
|
||||
:data-source="overrides"
|
||||
:pagination="false"
|
||||
row-key="route_id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'method'">
|
||||
<a-tag :color="methodColorMap[record.method] || 'default'">
|
||||
{{ record.method }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'allow'">
|
||||
<a-switch v-model:checked="record.allow" size="small" />
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" danger @click="removeOverride(index)">
|
||||
移除
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
<div class="mt-4 text-right">
|
||||
<a-button type="primary" :loading="savingOverrides" @click="saveOverrides">
|
||||
保存覆盖
|
||||
</a-button>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,271 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { message } from 'ant-design-vue'
|
||||
import RolePermissionModal from '@/components/RolePermissionModal.vue'
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
})
|
||||
|
||||
vi.mock('@/utils/request', () => ({
|
||||
api: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { api } from '@/utils/request'
|
||||
|
||||
const mockRole = {
|
||||
id: 2,
|
||||
name: 'editor',
|
||||
description: '编辑者',
|
||||
user_count: 3,
|
||||
route_group_count: 2,
|
||||
}
|
||||
|
||||
const mockGroups = [
|
||||
{ id: 1, name: 'user-mgmt', label: '用户管理', description: '', sort_order: 1, route_count: 5 },
|
||||
{ id: 2, name: 'product-mgmt', label: '产品管理', description: '', sort_order: 2, route_count: 3 },
|
||||
{ id: 3, name: 'order-mgmt', label: '订单管理', description: '', sort_order: 3, route_count: 4 },
|
||||
]
|
||||
|
||||
const mockRoutes = [
|
||||
{ id: 1, method: 'GET', path: '/api/v1/users', description: '用户列表', group_id: 1, group_name: '用户管理' },
|
||||
{ id: 2, method: 'POST', path: '/api/v1/users', description: '创建用户', group_id: 1, group_name: '用户管理' },
|
||||
{ id: 3, method: 'GET', path: '/api/v1/products', description: '产品列表', group_id: 2, group_name: '产品管理' },
|
||||
]
|
||||
|
||||
const mockOverrides = [
|
||||
{ route_id: 1, method: 'GET', path: '/api/v1/users', allow: true },
|
||||
]
|
||||
|
||||
function setupMocks(overrides: {
|
||||
groupIds?: number[]
|
||||
roleOverrides?: typeof mockOverrides
|
||||
} = {}) {
|
||||
vi.mocked(api.get).mockImplementation((url: string) => {
|
||||
if (url.match(/\/roles\/\d+\/route-groups/)) return Promise.resolve(overrides.groupIds ?? [1, 2]) as never
|
||||
if (url.match(/\/roles\/\d+\/route-overrides/)) return Promise.resolve(overrides.roleOverrides ?? mockOverrides) as never
|
||||
if (url === '/api/v1/route-groups') return Promise.resolve(mockGroups) as never
|
||||
if (url === '/api/v1/routes') return Promise.resolve(mockRoutes) as never
|
||||
return Promise.resolve([]) as never
|
||||
})
|
||||
vi.mocked(api.put).mockResolvedValue(undefined as never)
|
||||
}
|
||||
|
||||
describe('RolePermissionModal', () => {
|
||||
let wrapper: ReturnType<typeof mount>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountModal(props = {}) {
|
||||
wrapper = mount(RolePermissionModal, {
|
||||
props: {
|
||||
open: true,
|
||||
role: mockRole,
|
||||
...props,
|
||||
},
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function queryBody(selector: string) {
|
||||
return document.body.querySelectorAll(selector)
|
||||
}
|
||||
|
||||
describe('Modal 渲染', () => {
|
||||
it('标题包含角色名', async () => {
|
||||
setupMocks()
|
||||
await mountModal()
|
||||
const title = document.body.querySelector('.ant-modal-title')
|
||||
expect(title?.textContent).toContain('编辑者')
|
||||
})
|
||||
|
||||
it('显示两个 tab', async () => {
|
||||
setupMocks()
|
||||
await mountModal()
|
||||
const tabs = queryBody('.ant-tabs-tab')
|
||||
expect(tabs.length).toBe(2)
|
||||
expect(tabs[0]?.textContent).toContain('路由组授权')
|
||||
expect(tabs[1]?.textContent).toContain('单条路由覆盖')
|
||||
})
|
||||
|
||||
it('open=false 不渲染内容', async () => {
|
||||
setupMocks()
|
||||
await mountModal({ open: false })
|
||||
const modal = document.body.querySelector('.ant-modal')
|
||||
expect(modal).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tab 1: 路由组授权', () => {
|
||||
it('加载路由组 checkbox', async () => {
|
||||
setupMocks()
|
||||
await mountModal()
|
||||
const checkboxes = queryBody('.ant-checkbox-wrapper')
|
||||
expect(checkboxes.length).toBe(3)
|
||||
expect(checkboxes[0]?.textContent).toContain('用户管理')
|
||||
expect(checkboxes[1]?.textContent).toContain('产品管理')
|
||||
expect(checkboxes[2]?.textContent).toContain('订单管理')
|
||||
})
|
||||
|
||||
it('预勾选已授权的路由组', async () => {
|
||||
setupMocks({ groupIds: [1, 2] })
|
||||
await mountModal()
|
||||
const checked = queryBody('.ant-checkbox-wrapper-checked')
|
||||
expect(checked.length).toBe(2)
|
||||
})
|
||||
|
||||
it('保存组授权调用正确 API', async () => {
|
||||
setupMocks({ groupIds: [1] })
|
||||
await mountModal()
|
||||
|
||||
const saveBtn = Array.from(queryBody('.ant-btn-primary')).find(
|
||||
(btn) => btn.textContent?.includes('保存组授权'),
|
||||
) as HTMLElement
|
||||
expect(saveBtn).toBeTruthy()
|
||||
saveBtn.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.mocked(api.put)).toHaveBeenCalledWith(
|
||||
'/api/v1/roles/2/route-groups',
|
||||
{ group_ids: [1] },
|
||||
)
|
||||
})
|
||||
|
||||
it('保存成功显示 success message', async () => {
|
||||
setupMocks({ groupIds: [1] })
|
||||
const spy = vi.spyOn(message, 'success')
|
||||
await mountModal()
|
||||
|
||||
const saveBtn = Array.from(queryBody('.ant-btn-primary')).find(
|
||||
(btn) => btn.textContent?.includes('保存组授权'),
|
||||
) as HTMLElement
|
||||
saveBtn.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('路由组授权保存成功')
|
||||
})
|
||||
|
||||
it('保存失败显示 error message', async () => {
|
||||
setupMocks({ groupIds: [1] })
|
||||
vi.mocked(api.put).mockRejectedValueOnce(new Error('保存出错'))
|
||||
const spy = vi.spyOn(message, 'error')
|
||||
await mountModal()
|
||||
|
||||
const saveBtn = Array.from(queryBody('.ant-btn-primary')).find(
|
||||
(btn) => btn.textContent?.includes('保存组授权'),
|
||||
) as HTMLElement
|
||||
saveBtn.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('保存出错')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tab 2: 单条路由覆盖', () => {
|
||||
async function switchToOverridesTab() {
|
||||
const tabs = queryBody('.ant-tabs-tab')
|
||||
const overrideTab = tabs[1] as HTMLElement
|
||||
overrideTab.click()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
it('加载覆盖列表表格', async () => {
|
||||
setupMocks()
|
||||
await mountModal()
|
||||
await switchToOverridesTab()
|
||||
|
||||
const rows = queryBody('.ant-table-tbody tr')
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('method tag 使用正确颜色', async () => {
|
||||
setupMocks()
|
||||
await mountModal()
|
||||
await switchToOverridesTab()
|
||||
|
||||
const tags = queryBody('.ant-table-tbody .ant-tag')
|
||||
if (tags.length > 0) {
|
||||
expect(tags[0]?.className).toContain('green')
|
||||
}
|
||||
})
|
||||
|
||||
it('移除覆盖按钮可用', async () => {
|
||||
setupMocks()
|
||||
await mountModal()
|
||||
await switchToOverridesTab()
|
||||
|
||||
const removeBtn = Array.from(queryBody('.ant-btn')).find(
|
||||
(btn) => btn.textContent?.includes('移除'),
|
||||
)
|
||||
expect(removeBtn).toBeTruthy()
|
||||
})
|
||||
|
||||
it('保存覆盖调用正确 API', async () => {
|
||||
setupMocks()
|
||||
await mountModal()
|
||||
await switchToOverridesTab()
|
||||
|
||||
const saveBtn = Array.from(queryBody('.ant-btn-primary')).find(
|
||||
(btn) => btn.textContent?.includes('保存覆盖'),
|
||||
) as HTMLElement
|
||||
expect(saveBtn).toBeTruthy()
|
||||
saveBtn.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.mocked(api.put)).toHaveBeenCalledWith(
|
||||
'/api/v1/roles/2/route-overrides',
|
||||
{ overrides: mockOverrides },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('生命周期', () => {
|
||||
it('打开时触发数据加载', async () => {
|
||||
setupMocks()
|
||||
await mountModal()
|
||||
|
||||
expect(vi.mocked(api.get)).toHaveBeenCalled()
|
||||
const calls = vi.mocked(api.get).mock.calls.map((c) => c[0])
|
||||
expect(calls.some((url) => url.includes('/route-groups'))).toBe(true)
|
||||
expect(calls.some((url) => url.includes('/route-overrides'))).toBe(true)
|
||||
})
|
||||
|
||||
it('API 错误不阻塞渲染', async () => {
|
||||
vi.mocked(api.get).mockRejectedValue(new Error('网络错误'))
|
||||
await mountModal()
|
||||
|
||||
const modal = document.body.querySelector('.ant-modal')
|
||||
expect(modal).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
KeyOutlined,
|
||||
TeamOutlined,
|
||||
ApartmentOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
@@ -75,6 +77,8 @@ const menuItems: MenuItem[] = [
|
||||
{ key: '/refund-items', icon: UnorderedListOutlined, label: '退款子项' },
|
||||
],
|
||||
},
|
||||
{ key: '/roles', icon: TeamOutlined, label: '角色管理', adminOnly: true },
|
||||
{ key: '/route-groups', icon: ApartmentOutlined, label: '路由组管理', adminOnly: true },
|
||||
{ key: '/mq-status', icon: MonitorOutlined, label: '队列监控', adminOnly: true },
|
||||
]
|
||||
|
||||
@@ -106,6 +110,8 @@ const breadcrumbItems = computed(() => {
|
||||
'/order-items': '订单子项',
|
||||
'/refunds': '退款列表',
|
||||
'/refund-items': '退款子项',
|
||||
'/roles': '角色管理',
|
||||
'/route-groups': '路由组管理',
|
||||
'/mq-status': '队列监控',
|
||||
'/profile': '个人信息',
|
||||
'/profile/password': '修改密码',
|
||||
|
||||
Reference in New Issue
Block a user