refactor route group

This commit is contained in:
2026-03-23 16:43:43 +08:00
parent b97fd3f59b
commit 6cfb2371e7
3 changed files with 448 additions and 11 deletions
@@ -0,0 +1,269 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount, flushPromises, VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { useRouteGroupStore } from '@/stores/route-group'
import type { RouteRecord } from '@/stores/route-group'
import RouteGroupsPage from '../index.vue'
vi.mock('@/utils/request', () => ({
api: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}))
const { api } = await import('@/utils/request')
// Ant Design Vue jsdom 兼容
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
const mockGroups = [
{ id: 1, name: 'user-mgmt', label: '用户管理', description: '', sort_order: 1, route_count: 2 },
{ id: 2, name: 'order-mgmt', label: '订单管理', description: '', sort_order: 2, route_count: 1 },
]
const mockRoutes: RouteRecord[] = [
{ 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/orders', description: '订单列表', group_id: 2, group_name: '订单管理' },
{ id: 4, method: 'GET', path: '/api/v1/products', description: '产品列表', group_id: null, group_name: null },
{ id: 5, method: 'GET', path: '/api/v1/users/{id}', description: '用户详情', group_id: 1, group_name: '用户管理' },
]
// ─── Store 测试 ───
describe('useRouteGroupStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('routeTree', () => {
it('groups all routes by first path segment as directories', () => {
const store = useRouteGroupStore()
store.routes = [...mockRoutes]
const tree = store.routeTree
const topKeys = tree.map((n) => n.key)
// 按资源名分组为目录节点
expect(topKeys).toContain('/users')
expect(topKeys).toContain('/orders')
expect(topKeys).toContain('/products')
expect(tree).toHaveLength(3)
// 所有顶层节点都是目录
expect(tree.every((n) => !n.isLeaf)).toBe(true)
})
it('places same-path different-method routes inside same directory', () => {
const store = useRouteGroupStore()
store.routes = [...mockRoutes]
const usersDir = store.routeTree.find((n) => n.key === '/users')!
const userLeaves = usersDir.children!.filter((n) => n.isLeaf && n.title === 'users')
expect(userLeaves).toHaveLength(2)
expect(userLeaves.map((n) => n.method)).toEqual(expect.arrayContaining(['GET', 'POST']))
})
it('nests multi-segment routes under same directory', () => {
const store = useRouteGroupStore()
store.routes = [...mockRoutes]
const usersDir = store.routeTree.find((n) => n.key === '/users')!
// users 目录下:GET users, POST users, GET {id}
expect(usersDir.children).toHaveLength(3)
const idLeaf = usersDir.children!.find((n) => n.key === 'route:5')
expect(idLeaf).toBeDefined()
expect(idLeaf!.title).toBe('{id}')
})
it('creates leaf nodes with route metadata', () => {
const store = useRouteGroupStore()
store.routes = [...mockRoutes]
const productsDir = store.routeTree.find((n) => n.key === '/products')!
const productsLeaf = productsDir.children!.find((n) => n.key === 'route:4')
expect(productsLeaf).toBeDefined()
expect(productsLeaf!.isLeaf).toBe(true)
expect(productsLeaf!.routeId).toBe(4)
expect(productsLeaf!.method).toBe('GET')
expect(productsLeaf!.fullPath).toBe('/api/v1/products')
})
it('returns empty array when no routes', () => {
const store = useRouteGroupStore()
expect(store.routeTree).toEqual([])
})
})
describe('checkedRouteKeys', () => {
it('returns keys for routes matching selected group', () => {
const store = useRouteGroupStore()
store.routes = [...mockRoutes]
store.selectedGroupId = 1
expect(store.checkedRouteKeys).toEqual(
expect.arrayContaining(['route:1', 'route:2', 'route:5']),
)
expect(store.checkedRouteKeys).toHaveLength(3)
})
it('returns empty array when no group selected', () => {
const store = useRouteGroupStore()
store.routes = [...mockRoutes]
store.selectedGroupId = null
expect(store.checkedRouteKeys).toEqual([])
})
it('returns empty array for ungrouped selection', () => {
const store = useRouteGroupStore()
store.routes = [...mockRoutes]
store.selectedGroupId = 'ungrouped'
expect(store.checkedRouteKeys).toEqual([])
})
})
describe('batchAssignRoutes', () => {
it('calls API and refreshes data', async () => {
const store = useRouteGroupStore()
vi.mocked(api.put).mockResolvedValueOnce(undefined)
vi.mocked(api.get)
.mockResolvedValueOnce(mockGroups)
.mockResolvedValueOnce(mockRoutes)
await store.batchAssignRoutes(1, [1, 2, 3])
expect(api.put).toHaveBeenCalledWith(
'/api/v1/route-groups/1/routes',
{ route_ids: [1, 2, 3] },
)
expect(api.get).toHaveBeenCalledWith('/api/v1/route-groups')
expect(api.get).toHaveBeenCalledWith('/api/v1/routes', undefined)
})
})
})
// ─── Page 组件测试 ───
describe('RouteGroupsPage', () => {
let wrapper: VueWrapper
async function mountPage() {
vi.mocked(api.get)
.mockResolvedValueOnce(mockGroups)
.mockResolvedValueOnce(mockRoutes)
wrapper = mount(RouteGroupsPage, {
attachTo: document.body,
global: {
stubs: {
PlusOutlined: { template: '<span class="icon-plus" />' },
EditOutlined: { template: '<span class="icon-edit" />' },
DeleteOutlined: { template: '<span class="icon-delete" />' },
SaveOutlined: { template: '<span class="icon-save" />' },
},
},
})
await flushPromises()
await nextTick()
}
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
afterEach(() => {
wrapper?.unmount()
document.body.innerHTML = ''
})
it('shows empty state when no group is selected', async () => {
await mountPage()
expect(wrapper.text()).toContain('请选择路由组')
expect(wrapper.find('.ant-empty').exists()).toBe(true)
})
it('shows ungrouped routes table when ungrouped is selected', async () => {
await mountPage()
const store = useRouteGroupStore()
store.selectedGroupId = 'ungrouped'
await nextTick()
expect(wrapper.text()).toContain('未分组路由')
expect(wrapper.find('.ant-table').exists()).toBe(true)
})
it('shows route tree when a group is selected', async () => {
await mountPage()
const store = useRouteGroupStore()
store.selectedGroupId = 1
await nextTick()
expect(wrapper.text()).toContain('用户管理')
expect(wrapper.find('.ant-tree').exists()).toBe(true)
})
it('shows save button when a group is selected', async () => {
await mountPage()
const store = useRouteGroupStore()
store.selectedGroupId = 1
await nextTick()
expect(wrapper.text()).toContain('保存分配')
})
it('renders group list in left panel', async () => {
await mountPage()
expect(wrapper.text()).toContain('用户管理')
expect(wrapper.text()).toContain('订单管理')
expect(wrapper.text()).toContain('未分组')
})
it('calls batchAssignRoutes on save button click', async () => {
await mountPage()
const store = useRouteGroupStore()
store.selectedGroupId = 1
await nextTick()
// 模拟 batchAssignRoutes 成功
vi.mocked(api.put).mockResolvedValueOnce(undefined)
vi.mocked(api.get)
.mockResolvedValueOnce(mockGroups)
.mockResolvedValueOnce(mockRoutes)
// 点击保存按钮
const saveBtn = wrapper.findAll('.ant-btn').find((b) => b.text().includes('保存分配'))
expect(saveBtn).toBeDefined()
await saveBtn!.trigger('click')
await flushPromises()
expect(api.put).toHaveBeenCalledWith(
'/api/v1/route-groups/1/routes',
expect.objectContaining({ route_ids: expect.any(Array) }),
)
})
})
+92 -11
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { useRouteGroupStore } from '@/stores/route-group'
import type { RouteGroupRecord } from '@/stores/route-group'
import type { RouteGroupRecord, RouteTreeNode } from '@/stores/route-group'
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SaveOutlined,
} from '@ant-design/icons-vue'
const groupStore = useRouteGroupStore()
@@ -43,14 +44,53 @@ const groupOptions = computed(() => [
...groupStore.groups.map((g) => ({ value: g.id, label: g.label || g.name })),
])
// 筛选路由
const filteredRoutes = computed(() => {
if (groupStore.selectedGroupId === null) return groupStore.routes
if (groupStore.selectedGroupId === 'ungrouped') {
return groupStore.routes.filter((r) => r.group_id === null)
}
return groupStore.routes.filter((r) => r.group_id === groupStore.selectedGroupId)
// 筛选未分组路由
const ungroupedRoutes = computed(() =>
groupStore.routes.filter((r) => r.group_id === null),
)
// 树形选择器本地勾选状态
const localCheckedKeys = ref<{ checked: (string | number)[], halfChecked: (string | number)[] }>({
checked: [],
halfChecked: [],
})
const assignSaving = ref(false)
// 当前选中组的名称
const selectedGroupName = computed(() => {
if (typeof groupStore.selectedGroupId !== 'number') return ''
const g = groupStore.groups.find((g) => g.id === groupStore.selectedGroupId)
return g ? (g.label || g.name) : ''
})
// 切换路由组时重置 checkedKeys
watch(
() => groupStore.selectedGroupId,
() => {
localCheckedKeys.value = {
checked: [...groupStore.checkedRouteKeys],
halfChecked: [],
}
},
)
// 保存路由分配
async function handleSaveAssignment() {
if (typeof groupStore.selectedGroupId !== 'number') return
assignSaving.value = true
try {
const routeIds = localCheckedKeys.value.checked
.filter((k) => String(k).startsWith('route:'))
.map((k) => Number(String(k).replace('route:', '')))
await groupStore.batchAssignRoutes(groupStore.selectedGroupId, routeIds)
message.success('路由分配保存成功')
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '保存失败'
message.error(msg)
} finally {
assignSaving.value = false
}
}
function selectGroup(id: number | null | 'ungrouped') {
groupStore.selectedGroupId = id
@@ -177,12 +217,18 @@ onMounted(async () => {
</a-card>
</a-col>
<!-- 右侧路由列表 -->
<!-- 右侧条件渲染 -->
<a-col :span="16">
<a-card title="路由列表">
<!-- 状态1: 未选中路由组 -->
<a-card v-if="groupStore.selectedGroupId === null" title="路由列表">
<a-empty description="请选择路由组" />
</a-card>
<!-- 状态2: 未分组路由表格 -->
<a-card v-else-if="groupStore.selectedGroupId === 'ungrouped'" title="未分组路由">
<a-table
:columns="routeColumns"
:data-source="filteredRoutes"
:data-source="ungroupedRoutes"
:loading="groupStore.routesLoading"
:pagination="false"
row-key="id"
@@ -207,6 +253,41 @@ onMounted(async () => {
</template>
</a-table>
</a-card>
<!-- 状态3: 路由组树形选择器 -->
<a-card v-else :title="selectedGroupName">
<template #extra>
<a-button
type="primary"
size="small"
:loading="assignSaving"
@click="handleSaveAssignment"
>
<SaveOutlined /> 保存分配
</a-button>
</template>
<a-tree
v-model:checkedKeys="localCheckedKeys"
checkable
:check-strictly="true"
:tree-data="groupStore.routeTree"
:selectable="false"
default-expand-all
>
<template #title="node">
<template v-if="(node as RouteTreeNode).isLeaf">
<a-tag :color="methodColorMap[(node as RouteTreeNode).method!] || 'default'" class="mr-1">
{{ (node as RouteTreeNode).method }}
</a-tag>
<span class="text-gray-600">{{ (node as RouteTreeNode).title }}</span>
</template>
<template v-else>
<span class="font-medium">{{ (node as RouteTreeNode).title }}</span>
</template>
</template>
</a-tree>
</a-card>
</a-col>
</a-row>