diff --git a/frontend/src/pages/route-groups/__tests__/index.spec.ts b/frontend/src/pages/route-groups/__tests__/index.spec.ts
new file mode 100644
index 0000000..4183d84
--- /dev/null
+++ b/frontend/src/pages/route-groups/__tests__/index.spec.ts
@@ -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: '' },
+ EditOutlined: { template: '' },
+ DeleteOutlined: { template: '' },
+ SaveOutlined: { template: '' },
+ },
+ },
+ })
+
+ 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) }),
+ )
+ })
+})
diff --git a/frontend/src/pages/route-groups/index.vue b/frontend/src/pages/route-groups/index.vue
index cfc5196..56e6b76 100644
--- a/frontend/src/pages/route-groups/index.vue
+++ b/frontend/src/pages/route-groups/index.vue
@@ -1,10 +1,11 @@