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>
+87
View File
@@ -18,6 +18,16 @@ export interface RouteRecord {
group_name: string | null
}
export interface RouteTreeNode {
key: string
title: string
children?: RouteTreeNode[]
isLeaf?: boolean
routeId?: number
method?: string
fullPath?: string
}
export const useRouteGroupStore = defineStore('route-group', () => {
const groups = ref<RouteGroupRecord[]>([])
const routes = ref<RouteRecord[]>([])
@@ -70,6 +80,80 @@ export const useRouteGroupStore = defineStore('route-group', () => {
await api.put(`/api/v1/routes/${routeId}/group`, { group_id: groupId })
}
async function batchAssignRoutes(groupId: number, routeIds: number[]) {
await api.put(`/api/v1/route-groups/${groupId}/routes`, { route_ids: routeIds })
// 刻意获取全量路由(无过滤参数),因为 routeTree 需要完整数据构建树
await Promise.all([fetchGroups(), fetchRoutes()])
}
// 按首段资源名分组,避免同名叶节点与目录共存导致 UI 混乱
const routeTree = computed(() => {
const root: RouteTreeNode[] = []
const nodeMap = new Map<string, RouteTreeNode>()
function getOrCreateDir(parent: RouteTreeNode[], pathKey: string, title: string) {
let node = nodeMap.get(pathKey)
if (!node) {
node = { key: pathKey, title, children: [] }
nodeMap.set(pathKey, node)
parent.push(node)
}
return node
}
for (const route of routes.value) {
const relative = route.path.replace(/^\/api\/v1\//, '')
const segments = relative.split('/').filter(Boolean)
if (segments.length === 0) continue
const firstSeg = segments[0]!
const dirNode = getOrCreateDir(root, '/' + firstSeg, firstSeg)
if (segments.length === 1) {
dirNode.children!.push({
key: `route:${route.id}`,
title: firstSeg,
isLeaf: true,
routeId: route.id,
method: route.method,
fullPath: route.path,
})
} else {
let parentChildren = dirNode.children!
let pathSoFar = '/' + firstSeg
for (let i = 1; i < segments.length; i++) {
const seg = segments[i]!
pathSoFar += '/' + seg
const isLast = i === segments.length - 1
if (isLast) {
parentChildren.push({
key: `route:${route.id}`,
title: seg,
isLeaf: true,
routeId: route.id,
method: route.method,
fullPath: route.path,
})
} else {
const node = getOrCreateDir(parentChildren, pathSoFar, seg)
parentChildren = node.children!
}
}
}
}
return root
})
const checkedRouteKeys = computed(() => {
if (typeof selectedGroupId.value !== 'number') return []
return routes.value
.filter((r) => r.group_id === selectedGroupId.value)
.map((r) => `route:${r.id}`)
})
return {
groups,
routes,
@@ -82,5 +166,8 @@ export const useRouteGroupStore = defineStore('route-group', () => {
deleteGroup,
fetchRoutes,
assignRouteToGroup,
batchAssignRoutes,
routeTree,
checkedRouteKeys,
}
})