diff --git a/frontend/src/components/RolePermissionModal.vue b/frontend/src/components/RolePermissionModal.vue new file mode 100644 index 0000000..b09b576 --- /dev/null +++ b/frontend/src/components/RolePermissionModal.vue @@ -0,0 +1,233 @@ + + + + + + + + + + + + + {{ group.label || group.name }} + ({{ group.route_count }} 条路由) + + + + + + + 保存组授权 + + + + + + + + { addingRouteId = (val as number) ?? null }" + /> + + 添加覆盖 + + + + + + + {{ record.method }} + + + + + + + + 移除 + + + + + + + 保存覆盖 + + + + + + + diff --git a/frontend/src/components/__tests__/RolePermissionModal.spec.ts b/frontend/src/components/__tests__/RolePermissionModal.spec.ts new file mode 100644 index 0000000..09ed6b9 --- /dev/null +++ b/frontend/src/components/__tests__/RolePermissionModal.spec.ts @@ -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 + + 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() + }) + }) +}) diff --git a/frontend/src/components/layouts/MainLayout.vue b/frontend/src/components/layouts/MainLayout.vue index e39014c..6958d3f 100644 --- a/frontend/src/components/layouts/MainLayout.vue +++ b/frontend/src/components/layouts/MainLayout.vue @@ -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': '修改密码', diff --git a/frontend/src/main.ts b/frontend/src/main.ts index ecc1d1c..6f056ed 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -26,7 +26,7 @@ setTokenGetter(() => useUserStore().token) // 路由守卫 const authWhitelist = ['/login', '/register'] -const adminOnlyPaths = ['/users', '/mq-status'] +const adminOnlyPaths = ['/users', '/mq-status', '/roles', '/route-groups'] router.beforeEach(async (to) => { const { useUserStore } = await import('./stores/user') diff --git a/frontend/src/pages/roles/index.vue b/frontend/src/pages/roles/index.vue new file mode 100644 index 0000000..49dd50a --- /dev/null +++ b/frontend/src/pages/roles/index.vue @@ -0,0 +1,67 @@ + + + + + + + + 全部 + {{ record.route_group_count }} + + + + 配置权限 + + + + + + + + diff --git a/frontend/src/pages/route-groups/index.vue b/frontend/src/pages/route-groups/index.vue new file mode 100644 index 0000000..cfc5196 --- /dev/null +++ b/frontend/src/pages/route-groups/index.vue @@ -0,0 +1,235 @@ + + + + + + + + + + 新建 + + + + + + 未分组 + 未归入任何路由组的路由 + + + + + + + + {{ group.label || group.name }} + {{ group.route_count ?? 0 }} 条路由 + + + + + + + + + + + + + + + + + + + + + + + {{ record.method }} + + + + handleGroupChange(record.id, val as number | null)" + /> + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/pages/users/index.vue b/frontend/src/pages/users/index.vue index 598a7c0..4223f5a 100644 --- a/frontend/src/pages/users/index.vue +++ b/frontend/src/pages/users/index.vue @@ -22,14 +22,21 @@ const modalOpen = ref(false) const modalMode = ref<'create' | 'edit'>('create') const editingUser = ref(null) +// Role assign modal +const roleModalOpen = ref(false) +const roleAssigning = ref(false) +const assigningUser = ref(null) +const selectedRoleId = ref(undefined) +const roleOptions = ref<{ value: number; label: string }[]>([]) + const columns = [ { title: 'ID', dataIndex: 'id', width: 80 }, { title: '用户名', dataIndex: 'username' }, { title: '邮箱', dataIndex: 'email' }, - { title: '角色', dataIndex: 'role_name', width: 120 }, + { title: '角色', key: 'role', width: 120 }, { title: '状态', dataIndex: 'status', width: 100 }, { title: '创建时间', dataIndex: 'created_at', width: 180 }, - { title: '操作', key: 'action', width: 200 }, + { title: '操作', key: 'action', width: 280 }, ] onMounted(() => { @@ -93,6 +100,35 @@ function handleModalSuccess() { store.fetchUsers() } +async function handleAssignRole(record: UserRecord) { + assigningUser.value = record + selectedRoleId.value = record.role_id || undefined + roleModalOpen.value = true + try { + const roles = await api.get<{ id: number; name: string; description: string }[]>('/api/v1/roles') + roleOptions.value = roles.map((r) => ({ value: r.id, label: r.description || r.name })) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : '获取角色列表失败' + message.error(msg) + } +} + +async function submitRoleAssign() { + if (!assigningUser.value || !selectedRoleId.value) return + roleAssigning.value = true + try { + await api.put(`/api/v1/users/${assigningUser.value.id}/role`, { role_id: selectedRoleId.value }) + message.success('角色分配成功') + roleModalOpen.value = false + store.fetchUsers() + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : '分配失败' + message.error(msg) + } finally { + roleAssigning.value = false + } +} + function formatTime(time: string) { return time ? time.replace('T', ' ').substring(0, 19) : '-' } @@ -164,8 +200,9 @@ function formatTime(time: string) { row-key="id" > - - {{ record.role?.name || '-' }} + + {{ record.role.name }} + - @@ -183,6 +220,14 @@ function formatTime(time: string) { 编辑 + + 分配角色 + + + + + + 用户:{{ assigningUser?.username }} + + + + + + + { + const roles = ref([]) + const loading = ref(false) + + async function fetchRoles() { + loading.value = true + try { + const data = await api.get('/api/v1/roles') + roles.value = data + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : '获取角色列表失败' + message.error(msg) + } finally { + loading.value = false + } + } + + async function fetchRoleRouteGroups(roleId: number) { + const data = await api.get(`/api/v1/roles/${roleId}/route-groups`) + return data + } + + async function setRoleRouteGroups(roleId: number, groupIds: number[]) { + await api.put(`/api/v1/roles/${roleId}/route-groups`, { group_ids: groupIds }) + } + + async function fetchRoleOverrides(roleId: number) { + const data = await api.get(`/api/v1/roles/${roleId}/route-overrides`) + return data + } + + async function setRoleOverrides(roleId: number, overrides: RouteOverride[]) { + await api.put(`/api/v1/roles/${roleId}/route-overrides`, { overrides }) + } + + return { + roles, + loading, + fetchRoles, + fetchRoleRouteGroups, + setRoleRouteGroups, + fetchRoleOverrides, + setRoleOverrides, + } +}) diff --git a/frontend/src/stores/route-group.ts b/frontend/src/stores/route-group.ts new file mode 100644 index 0000000..1e30b5a --- /dev/null +++ b/frontend/src/stores/route-group.ts @@ -0,0 +1,86 @@ +import { api } from '@/utils/request' + +export interface RouteGroupRecord { + id: number + name: string + label: string + description: string + sort_order: number + route_count: number +} + +export interface RouteRecord { + id: number + method: string + path: string + description: string + group_id: number | null + group_name: string | null +} + +export const useRouteGroupStore = defineStore('route-group', () => { + const groups = ref([]) + const routes = ref([]) + const loading = ref(false) + const routesLoading = ref(false) + const selectedGroupId = ref(null) + + async function fetchGroups() { + loading.value = true + try { + const data = await api.get('/api/v1/route-groups') + groups.value = data + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : '获取路由组列表失败' + message.error(msg) + } finally { + loading.value = false + } + } + + async function createGroup(data: Partial) { + await api.post('/api/v1/route-groups', data) + await fetchGroups() + } + + async function updateGroup(id: number, data: Partial) { + await api.put(`/api/v1/route-groups/${id}`, data) + await fetchGroups() + } + + async function deleteGroup(id: number) { + await api.delete(`/api/v1/route-groups/${id}`) + await fetchGroups() + } + + async function fetchRoutes(params?: { group_id?: number | null }) { + routesLoading.value = true + try { + const data = await api.get('/api/v1/routes', params as Record) + routes.value = data + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : '获取路由列表失败' + message.error(msg) + } finally { + routesLoading.value = false + } + } + + async function assignRouteToGroup(routeId: number, groupId: number | null) { + await api.put(`/api/v1/routes/${routeId}/group`, { group_id: groupId }) + } + + return { + groups, + routes, + loading, + routesLoading, + selectedGroupId, + fetchGroups, + createGroup, + updateGroup, + deleteGroup, + fetchRoutes, + assignRouteToGroup, + } +})
+ 用户:{{ assigningUser?.username }} +