diff --git a/novalon-manage-api/manage-sys/src/test/java/cn/novalon/manage/sys/dto/request/RoleUpdateRequestTest.java b/novalon-manage-api/manage-sys/src/test/java/cn/novalon/manage/sys/dto/request/RoleUpdateRequestTest.java new file mode 100644 index 0000000..ebabe46 --- /dev/null +++ b/novalon-manage-api/manage-sys/src/test/java/cn/novalon/manage/sys/dto/request/RoleUpdateRequestTest.java @@ -0,0 +1,68 @@ +package cn.novalon.manage.sys.dto.request; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class RoleUpdateRequestTest { + + private static Validator validator; + + @BeforeAll + static void setUp() { + try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) { + validator = factory.getValidator(); + } + } + + @Test + void testRoleSortGreaterThanZero() { + RoleUpdateRequest request = new RoleUpdateRequest(); + request.setRoleSort(1); + + var violations = validator.validateProperty(request, "roleSort"); + assertTrue(violations.isEmpty(), "roleSort=1 should pass validation"); + } + + @Test + void testRoleSortZeroFails() { + RoleUpdateRequest request = new RoleUpdateRequest(); + request.setRoleSort(0); + + var violations = validator.validateProperty(request, "roleSort"); + assertFalse(violations.isEmpty(), "roleSort=0 should fail validation"); + assertEquals("显示顺序必须大于0", violations.iterator().next().getMessage()); + } + + @Test + void testRoleSortNegativeFails() { + RoleUpdateRequest request = new RoleUpdateRequest(); + request.setRoleSort(-1); + + var violations = validator.validateProperty(request, "roleSort"); + assertFalse(violations.isEmpty(), "roleSort=-1 should fail validation"); + assertEquals("显示顺序必须大于0", violations.iterator().next().getMessage()); + } + + @Test + void testRoleSortNullPasses() { + RoleUpdateRequest request = new RoleUpdateRequest(); + request.setRoleSort(null); + + var violations = validator.validateProperty(request, "roleSort"); + assertTrue(violations.isEmpty(), "roleSort=null should pass validation (optional field)"); + } + + @Test + void testRoleSortLargeValue() { + RoleUpdateRequest request = new RoleUpdateRequest(); + request.setRoleSort(Integer.MAX_VALUE); + + var violations = validator.validateProperty(request, "roleSort"); + assertTrue(violations.isEmpty(), "roleSort=MAX_VALUE should pass validation"); + } +} diff --git a/novalon-manage-web/src/__tests__/api/fileApi.test.ts b/novalon-manage-web/src/__tests__/api/fileApi.test.ts new file mode 100644 index 0000000..b81107d --- /dev/null +++ b/novalon-manage-web/src/__tests__/api/fileApi.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { FileInfo } from '@/api/file' + +vi.mock('@/utils/request', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +import request from '@/utils/request' + +const mockFiles: FileInfo[] = [ + { + id: 1, + fileName: 'report.pdf', + filePath: '/uploads/report.pdf', + fileSize: '1024000', + fileType: 'application/pdf', + storageType: 'local', + createBy: 'admin', + createdAt: '2026-01-01T00:00:00', + }, + { + id: 2, + fileName: 'photo.jpg', + filePath: '/uploads/photo.jpg', + fileSize: '512000', + fileType: 'image/jpeg', + storageType: 'local', + createBy: 'user1', + createdAt: '2026-01-02T00:00:00', + }, + { + id: 3, + fileName: 'data.xlsx', + filePath: '/uploads/data.xlsx', + fileSize: '256000', + fileType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + storageType: 'local', + createBy: 'admin', + createdAt: '2026-01-03T00:00:00', + }, +] + +describe('fileApi.getPage (client-side pagination)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return paginated results without filters', async () => { + vi.mocked(request.get).mockResolvedValueOnce(mockFiles) + + const { fileApi } = await import('@/api/file') + const result = await fileApi.getPage({ page: 0, size: 2 }) + + expect(result.content).toHaveLength(2) + expect(result.totalElements).toBe(3) + expect(result.totalPages).toBe(2) + expect(result.first).toBe(true) + expect(result.last).toBe(false) + }) + + it('should return second page correctly', async () => { + vi.mocked(request.get).mockResolvedValueOnce(mockFiles) + + const { fileApi } = await import('@/api/file') + const result = await fileApi.getPage({ page: 1, size: 2 }) + + expect(result.content).toHaveLength(1) + expect(result.content[0].id).toBe(3) + expect(result.first).toBe(false) + expect(result.last).toBe(true) + }) + + it('should filter by fileName', async () => { + vi.mocked(request.get).mockResolvedValueOnce(mockFiles) + + const { fileApi } = await import('@/api/file') + const result = await fileApi.getPage({ page: 0, size: 10, fileName: 'photo' }) + + expect(result.content).toHaveLength(1) + expect(result.content[0].fileName).toBe('photo.jpg') + }) + + it('should filter by fileType', async () => { + vi.mocked(request.get).mockResolvedValueOnce(mockFiles) + + const { fileApi } = await import('@/api/file') + const result = await fileApi.getPage({ page: 0, size: 10, fileType: 'image/jpeg' }) + + expect(result.content).toHaveLength(1) + expect(result.content[0].fileName).toBe('photo.jpg') + }) + + it('should return empty when no match', async () => { + vi.mocked(request.get).mockResolvedValueOnce(mockFiles) + + const { fileApi } = await import('@/api/file') + const result = await fileApi.getPage({ page: 0, size: 10, fileName: 'nonexistent' }) + + expect(result.content).toHaveLength(0) + expect(result.totalElements).toBe(0) + }) + + it('should handle empty data', async () => { + vi.mocked(request.get).mockResolvedValueOnce([]) + + const { fileApi } = await import('@/api/file') + const result = await fileApi.getPage({ page: 0, size: 10 }) + + expect(result.content).toHaveLength(0) + expect(result.totalElements).toBe(0) + expect(result.totalPages).toBe(0) + }) +}) + +describe('FileInfo interface', () => { + it('should have correct field names matching backend response', () => { + const file: FileInfo = { + id: 1, + fileName: 'test.pdf', + filePath: '/uploads/test.pdf', + fileSize: '1024', + fileType: 'application/pdf', + storageType: 'local', + createBy: 'admin', + createdAt: '2026-01-01T00:00:00', + } + expect(file.fileName).toBe('test.pdf') + expect(file.createBy).toBe('admin') + expect(file.fileType).toBe('application/pdf') + expect(file.fileSize).toBe('1024') + }) +}) diff --git a/novalon-manage-web/src/__tests__/api/menu.test.ts b/novalon-manage-web/src/__tests__/api/menu.test.ts new file mode 100644 index 0000000..354ce2e --- /dev/null +++ b/novalon-manage-web/src/__tests__/api/menu.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect } from 'vitest' +import type { RawMenuItem } from '@/api/menu' + +const menuTypeMap: Record = { + M: 'directory', + C: 'menu', + F: 'button', +} + +function buildPath(raw: RawMenuItem): string { + if (raw.menuType === 'M') return '' + if (raw.menuType === 'F') return '' + const perm = raw.perms || '' + const pathMap: Record = { + 'system:user:list': '/users', + 'system:role:list': '/roles', + 'system:menu:list': '/menus', + 'system:dept:list': '/sys/dept', + 'system:dict:list': '/dict', + 'system:config:list': '/sys/config', + 'system:notice:list': '/notice', + 'system:file:list': '/files', + 'audit:login:list': '/loginlog', + 'audit:login-log:list': '/loginlog', + 'audit:operation:list': '/oplog', + 'audit:operation-log:list': '/oplog', + 'audit:exception:list': '/exceptionlog', + 'audit:exception-log:list': '/exceptionlog', + 'monitor:online:list': '/monitor/online', + 'monitor:job:list': '/monitor/job', + 'monitor:data:list': '/monitor/data', + 'monitor:server:list': '/monitor/server', + 'monitor:cache:list': '/monitor/cache', + } + return pathMap[perm] || '' +} + +function inferIcon(raw: RawMenuItem): string { + const perm = raw.perms || '' + const iconMap: Record = { + 'system:user:list': 'user', + 'system:role:list': 'role', + 'system:menu:list': 'menu', + 'system:dept:list': 'menu', + 'system:dict:list': 'dict', + 'system:config:list': 'config', + 'system:notice:list': 'notice', + 'system:file:list': 'file', + 'audit:login:list': 'loginlog', + 'audit:login-log:list': 'loginlog', + 'audit:operation:list': 'oplog', + 'audit:operation-log:list': 'oplog', + 'audit:exception:list': 'exceptionlog', + 'audit:exception-log:list': 'exceptionlog', + 'monitor:online:list': 'user', + 'monitor:job:list': 'menu', + 'monitor:data:list': 'config', + 'monitor:server:list': 'config', + 'monitor:cache:list': 'config', + } + return iconMap[perm] || '' +} + +function makeRaw(overrides: Partial = {}): RawMenuItem { + return { + id: '1', + createBy: null, + updateBy: null, + createdAt: '2026-01-01T00:00:00', + updatedAt: '2026-01-01T00:00:00', + deletedAt: null, + menuName: '测试菜单', + parentId: '0', + orderNum: 1, + menuType: 'C', + perms: null, + component: null, + status: 0, + children: [], + ...overrides, + } +} + +describe('menu utils', () => { + describe('buildPath', () => { + it('should return empty string for directory type (M)', () => { + const raw = makeRaw({ menuType: 'M' }) + expect(buildPath(raw)).toBe('') + }) + + it('should return empty string for button type (F)', () => { + const raw = makeRaw({ menuType: 'F' }) + expect(buildPath(raw)).toBe('') + }) + + it('should map system:user:list to /users', () => { + const raw = makeRaw({ menuType: 'C', perms: 'system:user:list' }) + expect(buildPath(raw)).toBe('/users') + }) + + it('should map system:role:list to /roles', () => { + const raw = makeRaw({ menuType: 'C', perms: 'system:role:list' }) + expect(buildPath(raw)).toBe('/roles') + }) + + it('should map system:dept:list to /sys/dept', () => { + const raw = makeRaw({ menuType: 'C', perms: 'system:dept:list' }) + expect(buildPath(raw)).toBe('/sys/dept') + }) + + it('should map audit:login:list to /loginlog', () => { + const raw = makeRaw({ menuType: 'C', perms: 'audit:login:list' }) + expect(buildPath(raw)).toBe('/loginlog') + }) + + it('should map audit:login-log:list to /loginlog (alternate key)', () => { + const raw = makeRaw({ menuType: 'C', perms: 'audit:login-log:list' }) + expect(buildPath(raw)).toBe('/loginlog') + }) + + it('should map audit:operation:list to /oplog', () => { + const raw = makeRaw({ menuType: 'C', perms: 'audit:operation:list' }) + expect(buildPath(raw)).toBe('/oplog') + }) + + it('should map audit:exception:list to /exceptionlog', () => { + const raw = makeRaw({ menuType: 'C', perms: 'audit:exception:list' }) + expect(buildPath(raw)).toBe('/exceptionlog') + }) + + it('should map monitor:online:list to /monitor/online', () => { + const raw = makeRaw({ menuType: 'C', perms: 'monitor:online:list' }) + expect(buildPath(raw)).toBe('/monitor/online') + }) + + it('should map monitor:cache:list to /monitor/cache', () => { + const raw = makeRaw({ menuType: 'C', perms: 'monitor:cache:list' }) + expect(buildPath(raw)).toBe('/monitor/cache') + }) + + it('should return empty string for unknown permission', () => { + const raw = makeRaw({ menuType: 'C', perms: 'unknown:perm:list' }) + expect(buildPath(raw)).toBe('') + }) + + it('should return empty string when perms is null', () => { + const raw = makeRaw({ menuType: 'C', perms: null }) + expect(buildPath(raw)).toBe('') + }) + }) + + describe('inferIcon', () => { + it('should return user icon for system:user:list', () => { + const raw = makeRaw({ perms: 'system:user:list' }) + expect(inferIcon(raw)).toBe('user') + }) + + it('should return role icon for system:role:list', () => { + const raw = makeRaw({ perms: 'system:role:list' }) + expect(inferIcon(raw)).toBe('role') + }) + + it('should return loginlog icon for audit:login:list', () => { + const raw = makeRaw({ perms: 'audit:login:list' }) + expect(inferIcon(raw)).toBe('loginlog') + }) + + it('should return empty string for unknown permission', () => { + const raw = makeRaw({ perms: 'unknown:perm:list' }) + expect(inferIcon(raw)).toBe('') + }) + + it('should return empty string when perms is null', () => { + const raw = makeRaw({ perms: null }) + expect(inferIcon(raw)).toBe('') + }) + }) + + describe('menuTypeMap', () => { + it('should map M to directory', () => { + expect(menuTypeMap['M']).toBe('directory') + }) + + it('should map C to menu', () => { + expect(menuTypeMap['C']).toBe('menu') + }) + + it('should map F to button', () => { + expect(menuTypeMap['F']).toBe('button') + }) + }) +}) diff --git a/novalon-manage-web/src/__tests__/api/noticeApi.test.ts b/novalon-manage-web/src/__tests__/api/noticeApi.test.ts new file mode 100644 index 0000000..8d2f54e --- /dev/null +++ b/novalon-manage-web/src/__tests__/api/noticeApi.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Notice } from '@/api/loginLog' +import { NoticeStatus } from '@/constants/status' + +vi.mock('@/utils/request', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +import request from '@/utils/request' + +const mockNotices: Notice[] = [ + { + id: 1, + noticeTitle: '系统维护通知', + noticeContent: '系统将于今晚维护', + noticeType: '1', + status: NoticeStatus.ACTIVE, + createBy: 'admin', + createdAt: '2026-01-01T00:00:00', + updatedAt: '2026-01-01T00:00:00', + }, + { + id: 2, + noticeTitle: '版本更新公告', + noticeContent: '新版本已发布', + noticeType: '2', + status: NoticeStatus.ACTIVE, + createBy: 'admin', + createdAt: '2026-01-02T00:00:00', + updatedAt: '2026-01-02T00:00:00', + }, + { + id: 3, + noticeTitle: '测试通知', + noticeContent: '这是一条草稿通知', + noticeType: '1', + status: NoticeStatus.INACTIVE, + createBy: 'user1', + createdAt: '2026-01-03T00:00:00', + updatedAt: '2026-01-03T00:00:00', + }, +] + +describe('noticeApi.getPage (client-side pagination)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return paginated results without filters', async () => { + vi.mocked(request.get).mockResolvedValueOnce(mockNotices) + + const { noticeApi } = await import('@/api/loginLog') + const result = await noticeApi.getPage({ page: 0, size: 2 }) + + expect(result.content).toHaveLength(2) + expect(result.totalElements).toBe(3) + expect(result.totalPages).toBe(2) + expect(result.first).toBe(true) + expect(result.last).toBe(false) + expect(result.number).toBe(0) + expect(result.size).toBe(2) + }) + + it('should return second page correctly', async () => { + vi.mocked(request.get).mockResolvedValueOnce(mockNotices) + + const { noticeApi } = await import('@/api/loginLog') + const result = await noticeApi.getPage({ page: 1, size: 2 }) + + expect(result.content).toHaveLength(1) + expect(result.content[0].id).toBe(3) + expect(result.first).toBe(false) + expect(result.last).toBe(true) + }) + + it('should filter by title', async () => { + vi.mocked(request.get).mockResolvedValueOnce(mockNotices) + + const { noticeApi } = await import('@/api/loginLog') + const result = await noticeApi.getPage({ page: 0, size: 10, title: '维护' }) + + expect(result.content).toHaveLength(1) + expect(result.content[0].noticeTitle).toBe('系统维护通知') + }) + + it('should filter by type', async () => { + vi.mocked(request.get).mockResolvedValueOnce(mockNotices) + + const { noticeApi } = await import('@/api/loginLog') + const result = await noticeApi.getPage({ page: 0, size: 10, type: '2' }) + + expect(result.content).toHaveLength(1) + expect(result.content[0].noticeType).toBe('2') + }) + + it('should filter by status', async () => { + vi.mocked(request.get).mockResolvedValueOnce(mockNotices) + + const { noticeApi } = await import('@/api/loginLog') + const result = await noticeApi.getPage({ page: 0, size: 10, status: String(NoticeStatus.INACTIVE) }) + + expect(result.content).toHaveLength(1) + expect(result.content[0].status).toBe(NoticeStatus.INACTIVE) + }) + + it('should return empty page when page exceeds data', async () => { + vi.mocked(request.get).mockResolvedValueOnce(mockNotices) + + const { noticeApi } = await import('@/api/loginLog') + const result = await noticeApi.getPage({ page: 10, size: 2 }) + + expect(result.content).toHaveLength(0) + expect(result.totalElements).toBe(3) + }) + + it('should handle empty data', async () => { + vi.mocked(request.get).mockResolvedValueOnce([]) + + const { noticeApi } = await import('@/api/loginLog') + const result = await noticeApi.getPage({ page: 0, size: 10 }) + + expect(result.content).toHaveLength(0) + expect(result.totalElements).toBe(0) + expect(result.totalPages).toBe(0) + expect(result.first).toBe(true) + expect(result.last).toBe(true) + }) +}) diff --git a/novalon-manage-web/src/__tests__/api/roleApi.test.ts b/novalon-manage-web/src/__tests__/api/roleApi.test.ts new file mode 100644 index 0000000..8f56404 --- /dev/null +++ b/novalon-manage-web/src/__tests__/api/roleApi.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/utils/request', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +import request from '@/utils/request' +import { roleApi } from '@/api/role.api' + +describe('roleApi', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('create', () => { + it('should send POST /roles with role data', async () => { + const mockRole = { id: 1, roleName: '测试角色', roleKey: 'test_role', roleSort: 1, status: 1, permissions: [], createdAt: '', updatedAt: '' } + vi.mocked(request.post).mockResolvedValue(mockRole) + + const data = { roleName: '测试角色', roleKey: 'test_role', roleSort: 1, permissions: [] } + const result = await roleApi.create(data) + + expect(request.post).toHaveBeenCalledWith('/roles', data) + expect(result).toEqual(mockRole) + }) + + it('should send roleSort >= 1 for valid role creation', async () => { + const mockRole = { id: 2, roleName: '角色2', roleKey: 'role_2', roleSort: 1, status: 1, permissions: [], createdAt: '', updatedAt: '' } + vi.mocked(request.post).mockResolvedValue(mockRole) + + const data = { roleName: '角色2', roleKey: 'role_2', roleSort: 1, permissions: [] } + await roleApi.create(data) + + expect(request.post).toHaveBeenCalledWith('/roles', expect.objectContaining({ roleSort: 1 })) + }) + }) + + describe('getPage', () => { + it('should send GET /roles/page with pagination params', async () => { + const mockResponse = { content: [], totalElements: 0, totalPages: 0, size: 10, number: 0 } + vi.mocked(request.get).mockResolvedValue(mockResponse) + + const params = { page: 0, size: 10 } + const result = await roleApi.getPage(params) + + expect(request.get).toHaveBeenCalledWith('/roles/page', { params }) + expect(result).toEqual(mockResponse) + }) + }) + + describe('update', () => { + it('should send PUT /roles/:id with update data', async () => { + const mockRole = { id: 1, roleName: '更新角色', roleKey: 'updated', roleSort: 2, status: 1, permissions: [], createdAt: '', updatedAt: '' } + vi.mocked(request.put).mockResolvedValue(mockRole) + + const data = { roleName: '更新角色', roleSort: 2 } + const result = await roleApi.update(1, data) + + expect(request.put).toHaveBeenCalledWith('/roles/1', data) + expect(result).toEqual(mockRole) + }) + }) + + describe('delete', () => { + it('should send DELETE /roles/:id', async () => { + vi.mocked(request.delete).mockResolvedValue(undefined) + + await roleApi.delete(1) + + expect(request.delete).toHaveBeenCalledWith('/roles/1') + }) + }) + + describe('getAllPermissions', () => { + it('should send GET /permissions', async () => { + const mockPermissions = [ + { id: 1, name: '用户查看', code: 'system:user:list', resource: 'user', action: 'list' }, + { id: 2, name: '角色查看', code: 'system:role:list', resource: 'role', action: 'list' }, + ] + vi.mocked(request.get).mockResolvedValue(mockPermissions) + + const result = await roleApi.getAllPermissions() + + expect(request.get).toHaveBeenCalledWith('/permissions') + expect(result).toEqual(mockPermissions) + }) + }) + + describe('assignPermissions', () => { + it('should send POST /roles/:id/permissions with permission IDs', async () => { + vi.mocked(request.post).mockResolvedValue(undefined) + + await roleApi.assignPermissions(1, [1, 2, 3]) + + expect(request.post).toHaveBeenCalledWith('/roles/1/permissions', { permissionIds: [1, 2, 3] }) + }) + }) + + describe('updateStatus', () => { + it('should send PUT /roles/:id/status with status', async () => { + vi.mocked(request.put).mockResolvedValue(undefined) + + await roleApi.updateStatus(1, 'INACTIVE') + + expect(request.put).toHaveBeenCalledWith('/roles/1/status', { status: 'INACTIVE' }) + }) + }) +}) diff --git a/novalon-manage-web/src/__tests__/components/AuthGuard.test.tsx b/novalon-manage-web/src/__tests__/components/AuthGuard.test.tsx new file mode 100644 index 0000000..29b4768 --- /dev/null +++ b/novalon-manage-web/src/__tests__/components/AuthGuard.test.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import AuthGuard from '@/components/AuthGuard' + +vi.mock('@/stores/useAuthStore', () => ({ + useAuthStore: vi.fn(), +})) + +import { useAuthStore } from '@/stores/useAuthStore' + +const mockUseAuthStore = vi.mocked(useAuthStore) + +function renderWithRouter(ui: React.ReactElement) { + return render({ui}) +} + +describe('AuthGuard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render children when authenticated', () => { + mockUseAuthStore.mockImplementation((selector: any) => + selector({ isAuthenticated: true }) + ) + renderWithRouter( + +
Protected Page
+
+ ) + expect(screen.getByText('Protected Page')).toBeInTheDocument() + }) + + it('should redirect to /login when not authenticated', () => { + mockUseAuthStore.mockImplementation((selector: any) => + selector({ isAuthenticated: false }) + ) + renderWithRouter( + +
Protected Page
+
+ ) + expect(screen.queryByText('Protected Page')).not.toBeInTheDocument() + }) +}) diff --git a/novalon-manage-web/src/__tests__/components/ChartContainer.test.tsx b/novalon-manage-web/src/__tests__/components/ChartContainer.test.tsx new file mode 100644 index 0000000..08a72a2 --- /dev/null +++ b/novalon-manage-web/src/__tests__/components/ChartContainer.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect, vi } from 'vitest' +import { render } from '@testing-library/react' +import ChartContainer from '@/components/ChartContainer' + +describe('ChartContainer', () => { + it('should call onInit with container element on mount', () => { + const onInit = vi.fn() + render() + expect(onInit).toHaveBeenCalledTimes(1) + expect(onInit).toHaveBeenCalledWith(expect.any(HTMLElement)) + }) + + it('should call onDestroy on unmount', () => { + const onDestroy = vi.fn() + const onInit = vi.fn() + const { unmount } = render() + expect(onDestroy).not.toHaveBeenCalled() + unmount() + expect(onDestroy).toHaveBeenCalledTimes(1) + }) + + it('should not crash when onDestroy is not provided', () => { + const onInit = vi.fn() + const { unmount } = render() + expect(() => unmount()).not.toThrow() + }) + + it('should apply custom style', () => { + const onInit = vi.fn() + const { container } = render( + + ) + const div = container.firstChild as HTMLElement + expect(div.style.backgroundColor).toBe('red') + }) + + it('should have default width and height 100%', () => { + const onInit = vi.fn() + const { container } = render() + const div = container.firstChild as HTMLElement + expect(div.style.width).toBe('100%') + expect(div.style.height).toBe('100%') + }) +}) diff --git a/novalon-manage-web/src/__tests__/components/PermissionGuard.test.tsx b/novalon-manage-web/src/__tests__/components/PermissionGuard.test.tsx new file mode 100644 index 0000000..93205e3 --- /dev/null +++ b/novalon-manage-web/src/__tests__/components/PermissionGuard.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import PermissionGuard from '@/components/PermissionGuard' + +vi.mock('@/stores/usePermissionStore', () => ({ + usePermissionStore: vi.fn(), +})) + +import { usePermissionStore } from '@/stores/usePermissionStore' + +const mockUsePermissionStore = vi.mocked(usePermissionStore) + +describe('PermissionGuard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render children when user has permission', () => { + mockUsePermissionStore.mockImplementation((selector: any) => + selector({ hasPermission: (p: string) => p === 'system:user:list', hasRole: () => false }) + ) + render( + +
Protected Content
+
+ ) + expect(screen.getByText('Protected Content')).toBeInTheDocument() + }) + + it('should render fallback when user lacks permission', () => { + mockUsePermissionStore.mockImplementation((selector: any) => + selector({ hasPermission: () => false, hasRole: () => false }) + ) + render( + No Access}> +
Protected Content
+
+ ) + expect(screen.queryByText('Protected Content')).not.toBeInTheDocument() + expect(screen.getByText('No Access')).toBeInTheDocument() + }) + + it('should render null fallback by default when no permission', () => { + mockUsePermissionStore.mockImplementation((selector: any) => + selector({ hasPermission: () => false, hasRole: () => false }) + ) + const { container } = render( + +
Protected Content
+
+ ) + expect(container.innerHTML).toBe('') + }) + + it('should check role when type is role', () => { + mockUsePermissionStore.mockImplementation((selector: any) => + selector({ hasPermission: () => false, hasRole: (r: string) => r === 'admin' }) + ) + render( + +
Admin Content
+
+ ) + expect(screen.getByText('Admin Content')).toBeInTheDocument() + }) + + it('should render children when no permission or role specified', () => { + mockUsePermissionStore.mockImplementation((selector: any) => + selector({ hasPermission: () => false, hasRole: () => false }) + ) + render( + +
Always Visible
+
+ ) + expect(screen.getByText('Always Visible')).toBeInTheDocument() + }) +}) diff --git a/novalon-manage-web/src/__tests__/hooks/useAntV.test.ts b/novalon-manage-web/src/__tests__/hooks/useAntV.test.ts new file mode 100644 index 0000000..92f211d --- /dev/null +++ b/novalon-manage-web/src/__tests__/hooks/useAntV.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useAntV } from '@/hooks/useAntV' + +class MockChart { + container: HTMLElement + options: any + destroyed = false + constructor(container: HTMLElement, options?: any) { + this.container = container + this.options = options + } + changeData(data: any[]) { + this.options = { ...this.options, data } + } + destroy() { + this.destroyed = true + } +} + +describe('useAntV', () => { + it('should initialize chart via initChart', () => { + const { result } = renderHook(() => useAntV(MockChart, { theme: 'dark' })) + const container = document.createElement('div') + act(() => { + result.current.initChart(container) + }) + expect(result.current.chartRef.current).toBeInstanceOf(MockChart) + }) + + it('should destroy previous chart when re-initializing', () => { + const { result } = renderHook(() => useAntV(MockChart)) + const container1 = document.createElement('div') + const container2 = document.createElement('div') + act(() => { + result.current.initChart(container1) + }) + const firstChart = result.current.chartRef.current as MockChart + act(() => { + result.current.initChart(container2) + }) + expect(firstChart.destroyed).toBe(true) + expect(result.current.chartRef.current).not.toBe(firstChart) + }) + + it('should update data via updateData', () => { + const { result } = renderHook(() => useAntV(MockChart)) + const container = document.createElement('div') + act(() => { + result.current.initChart(container) + }) + act(() => { + result.current.updateData([{ x: 1, y: 2 }]) + }) + const chart = result.current.chartRef.current as MockChart + expect(chart.options.data).toEqual([{ x: 1, y: 2 }]) + }) + + it('should auto-destroy chart on unmount', () => { + const { result, unmount } = renderHook(() => useAntV(MockChart)) + const container = document.createElement('div') + act(() => { + result.current.initChart(container) + }) + const chart = result.current.chartRef.current as MockChart + unmount() + expect(chart.destroyed).toBe(true) + }) + + it('should not auto-destroy when autoDestroy is false', () => { + const { result, unmount } = renderHook(() => + useAntV(MockChart, undefined, { autoDestroy: false }) + ) + const container = document.createElement('div') + act(() => { + result.current.initChart(container) + }) + const chart = result.current.chartRef.current as MockChart + unmount() + expect(chart.destroyed).toBe(false) + }) +}) diff --git a/novalon-manage-web/src/__tests__/hooks/usePermission.test.ts b/novalon-manage-web/src/__tests__/hooks/usePermission.test.ts new file mode 100644 index 0000000..54d6ab5 --- /dev/null +++ b/novalon-manage-web/src/__tests__/hooks/usePermission.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' + +vi.mock('@/stores/usePermissionStore', () => ({ + usePermissionStore: vi.fn(), +})) + +import { usePermissionStore } from '@/stores/usePermissionStore' +import { usePermission } from '@/hooks/usePermission' + +const mockUsePermissionStore = vi.mocked(usePermissionStore) + +describe('usePermission', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return hasPermission, hasRole, permissions, roles', () => { + const mockState = { + hasPermission: (p: string) => p === 'system:user:list', + hasRole: (r: string) => r === 'admin', + permissions: ['system:user:list', 'system:user:add'], + roles: ['admin'], + } + mockUsePermissionStore.mockImplementation((selector: any) => selector(mockState)) + + const { result } = renderHook(() => usePermission()) + expect(result.current.hasPermission('system:user:list')).toBe(true) + expect(result.current.hasPermission('system:role:list')).toBe(false) + expect(result.current.hasRole('admin')).toBe(true) + expect(result.current.hasRole('user')).toBe(false) + expect(result.current.permissions).toEqual(['system:user:list', 'system:user:add']) + expect(result.current.roles).toEqual(['admin']) + }) + + it('should return empty arrays when no permissions', () => { + const mockState = { + hasPermission: () => false, + hasRole: () => false, + permissions: [], + roles: [], + } + mockUsePermissionStore.mockImplementation((selector: any) => selector(mockState)) + + const { result } = renderHook(() => usePermission()) + expect(result.current.permissions).toEqual([]) + expect(result.current.roles).toEqual([]) + }) +}) diff --git a/novalon-manage-web/src/__tests__/router/authLoader.test.ts b/novalon-manage-web/src/__tests__/router/authLoader.test.ts new file mode 100644 index 0000000..e891b1c --- /dev/null +++ b/novalon-manage-web/src/__tests__/router/authLoader.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { redirect } from 'react-router' + +const mockInitFromStorage = vi.fn() +const mockLogout = vi.fn() +const mockFetchUserMenus = vi.fn(() => Promise.resolve()) +const mockInitFromStoragePerm = vi.fn(() => false) + +let mockAuthState: any = { + initialized: false, + isAuthenticated: false, + initFromStorage: mockInitFromStorage, + logout: mockLogout, +} + +let mockPermState: any = { + loaded: false, + initFromStorage: mockInitFromStoragePerm, + fetchUserMenus: mockFetchUserMenus, +} + +vi.mock('@/stores/useAuthStore', () => ({ + useAuthStore: { + getState: vi.fn(() => mockAuthState), + }, +})) + +vi.mock('@/stores/usePermissionStore', () => ({ + usePermissionStore: { + getState: vi.fn(() => mockPermState), + }, +})) + +import { authLoader } from '@/router/guards' + +function isRedirectToLogin(result: any) { + if (result === null) return false + return result.status === 302 && result.headers.get('Location') === '/login' +} + +describe('authLoader', () => { + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + mockAuthState = { + initialized: false, + isAuthenticated: false, + initFromStorage: mockInitFromStorage, + logout: mockLogout, + } + mockPermState = { + loaded: false, + initFromStorage: mockInitFromStoragePerm, + fetchUserMenus: mockFetchUserMenus, + } + }) + + it('should redirect to /login when no token in localStorage', async () => { + const result = await authLoader() + expect(isRedirectToLogin(result)).toBe(true) + }) + + it('should redirect to /login when initFromStorage sets isAuthenticated to false', async () => { + localStorage.setItem('token', 'valid-token') + + mockAuthState = { + initialized: false, + isAuthenticated: false, + initFromStorage: mockInitFromStorage.mockImplementation(() => { + mockAuthState = { + initialized: true, + isAuthenticated: false, + initFromStorage: mockInitFromStorage, + logout: mockLogout, + } + }), + logout: mockLogout, + } + + const result = await authLoader() + expect(isRedirectToLogin(result)).toBe(true) + }) + + it('should return null when authenticated after initFromStorage', async () => { + localStorage.setItem('token', 'valid-token') + + let callCount = 0 + mockInitFromStorage.mockImplementation(() => { + mockAuthState = { + initialized: true, + isAuthenticated: true, + initFromStorage: mockInitFromStorage, + logout: mockLogout, + } + }) + + mockAuthState = { + initialized: false, + isAuthenticated: false, + initFromStorage: mockInitFromStorage, + logout: mockLogout, + } + + mockPermState = { + loaded: true, + initFromStorage: mockInitFromStoragePerm, + fetchUserMenus: mockFetchUserMenus, + } + + const result = await authLoader() + expect(result).toBeNull() + }) + + it('should call initFromStorage when not initialized', async () => { + localStorage.setItem('token', 'valid-token') + + mockInitFromStorage.mockImplementation(() => { + mockAuthState = { + initialized: true, + isAuthenticated: true, + initFromStorage: mockInitFromStorage, + logout: mockLogout, + } + }) + + mockAuthState = { + initialized: false, + isAuthenticated: false, + initFromStorage: mockInitFromStorage, + logout: mockLogout, + } + + mockPermState = { + loaded: true, + initFromStorage: mockInitFromStoragePerm, + fetchUserMenus: mockFetchUserMenus, + } + + await authLoader() + expect(mockInitFromStorage).toHaveBeenCalled() + }) + + it('should not call initFromStorage when already initialized', async () => { + localStorage.setItem('token', 'valid-token') + + mockAuthState = { + initialized: true, + isAuthenticated: true, + initFromStorage: mockInitFromStorage, + logout: mockLogout, + } + + mockPermState = { + loaded: true, + initFromStorage: mockInitFromStoragePerm, + fetchUserMenus: mockFetchUserMenus, + } + + await authLoader() + expect(mockInitFromStorage).not.toHaveBeenCalled() + }) + + it('should logout and redirect when fetchUserMenus fails', async () => { + localStorage.setItem('token', 'valid-token') + + mockAuthState = { + initialized: true, + isAuthenticated: true, + initFromStorage: mockInitFromStorage, + logout: mockLogout, + } + + mockPermState = { + loaded: false, + initFromStorage: mockInitFromStoragePerm.mockReturnValue(false), + fetchUserMenus: mockFetchUserMenus.mockRejectedValue(new Error('Network error')), + } + + const result = await authLoader() + expect(mockLogout).toHaveBeenCalled() + expect(isRedirectToLogin(result)).toBe(true) + }) + + it('should re-fetch auth state after initFromStorage to get latest isAuthenticated', async () => { + localStorage.setItem('token', 'valid-token') + + let getStateCallCount = 0 + const { useAuthStore } = await import('@/stores/useAuthStore') + + mockInitFromStorage.mockImplementation(() => { + mockAuthState = { + initialized: true, + isAuthenticated: true, + initFromStorage: mockInitFromStorage, + logout: mockLogout, + } + }) + + mockAuthState = { + initialized: false, + isAuthenticated: false, + initFromStorage: mockInitFromStorage, + logout: mockLogout, + } + + mockPermState = { + loaded: true, + initFromStorage: mockInitFromStoragePerm, + fetchUserMenus: mockFetchUserMenus, + } + + const result = await authLoader() + expect(result).toBeNull() + expect(mockInitFromStorage).toHaveBeenCalled() + }) +}) diff --git a/novalon-manage-web/src/__tests__/router/routes.test.ts b/novalon-manage-web/src/__tests__/router/routes.test.ts new file mode 100644 index 0000000..727f720 --- /dev/null +++ b/novalon-manage-web/src/__tests__/router/routes.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest' +import { router } from '@/router' + +describe('router configuration', () => { + const routePaths = [ + 'dashboard', + 'users', + 'roles', + 'menus', + 'sys/dept', + 'sys/config', + 'dict', + 'files', + 'notice', + 'loginlog', + 'oplog', + 'exceptionlog', + 'monitor/online', + 'monitor/job', + 'monitor/data', + 'monitor/server', + 'monitor/cache', + ] + + it('should have login route', () => { + const loginRoute = router.routes.find((r) => r.path === '/login') + expect(loginRoute).toBeDefined() + }) + + it('should have 403 route', () => { + const forbiddenRoute = router.routes.find((r) => r.path === '/403') + expect(forbiddenRoute).toBeDefined() + }) + + it('should have root layout route with children', () => { + const rootRoute = router.routes.find((r) => r.path === '/') + expect(rootRoute).toBeDefined() + expect(rootRoute?.children).toBeDefined() + expect(rootRoute?.children?.length).toBeGreaterThan(0) + }) + + it('should have all expected route paths defined', () => { + const rootRoute = router.routes.find((r) => r.path === '/') + const childPaths = rootRoute?.children?.map((c) => c.path) || [] + for (const path of routePaths) { + expect(childPaths).toContain(path) + } + }) + + it('should have index redirect to dashboard', () => { + const rootRoute = router.routes.find((r) => r.path === '/') + const indexRoute = rootRoute?.children?.find((c) => c.index === true) + expect(indexRoute).toBeDefined() + }) + + it('should have dept management route', () => { + const rootRoute = router.routes.find((r) => r.path === '/') + const deptRoute = rootRoute?.children?.find((c) => c.path === 'sys/dept') + expect(deptRoute).toBeDefined() + expect(deptRoute?.element).toBeDefined() + }) + + it('should have all monitor routes', () => { + const rootRoute = router.routes.find((r) => r.path === '/') + const childPaths = rootRoute?.children?.map((c) => c.path) || [] + const monitorPaths = [ + 'monitor/online', + 'monitor/job', + 'monitor/data', + 'monitor/server', + 'monitor/cache', + ] + for (const path of monitorPaths) { + expect(childPaths).toContain(path) + } + }) +}) diff --git a/novalon-manage-web/src/__tests__/setup.ts b/novalon-manage-web/src/__tests__/setup.ts new file mode 100644 index 0000000..e20db4a --- /dev/null +++ b/novalon-manage-web/src/__tests__/setup.ts @@ -0,0 +1,17 @@ +import '@testing-library/jest-dom/vitest' + +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + get length() { return Object.keys(store).length }, + key: (index: number) => Object.keys(store)[index] ?? null, + } +})() + +if (!globalThis.localStorage || typeof globalThis.localStorage.clear !== 'function') { + Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) +} diff --git a/novalon-manage-web/src/__tests__/stores/useAuthStore.test.ts b/novalon-manage-web/src/__tests__/stores/useAuthStore.test.ts new file mode 100644 index 0000000..9213cc5 --- /dev/null +++ b/novalon-manage-web/src/__tests__/stores/useAuthStore.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockFetchUserMenus = vi.fn(() => Promise.resolve()) +const mockClearPermissionData = vi.fn() + +vi.mock('@/api/auth.api', () => ({ + authApi: { + login: vi.fn(() => Promise.resolve({ + token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGVzIjpbImFkbWluIl0sImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.test', + })), + }, +})) + +vi.mock('@/stores/usePermissionStore', () => ({ + usePermissionStore: { + getState: vi.fn(() => ({ + fetchUserMenus: mockFetchUserMenus, + clearPermissionData: mockClearPermissionData, + })), + }, +})) + +import { useAuthStore } from '@/stores/useAuthStore' + +describe('useAuthStore', () => { + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + useAuthStore.setState({ + token: null, userId: null, username: null, nickname: null, + roles: [], permissions: [], isAuthenticated: false, initialized: false, + }) + }) + + describe('login', () => { + it('should set auth state on successful login', async () => { + await useAuthStore.getState().login('admin', 'password') + const state = useAuthStore.getState() + expect(state.isAuthenticated).toBe(true) + expect(state.username).toBe('admin') + expect(state.roles).toEqual(['admin']) + expect(state.token).toBeTruthy() + }) + + it('should persist token and user info to localStorage', async () => { + await useAuthStore.getState().login('admin', 'password') + expect(localStorage.getItem('token')).toBeTruthy() + expect(localStorage.getItem('username')).toBe('admin') + expect(localStorage.getItem('roles')).toBe(JSON.stringify(['admin'])) + }) + + it('should call fetchUserMenus after login', async () => { + await useAuthStore.getState().login('admin', 'password') + expect(mockFetchUserMenus).toHaveBeenCalled() + }) + }) + + describe('logout', () => { + it('should clear auth state', () => { + useAuthStore.setState({ token: 'test', isAuthenticated: true, username: 'admin', roles: ['admin'] }) + useAuthStore.getState().logout() + const state = useAuthStore.getState() + expect(state.token).toBeNull() + expect(state.isAuthenticated).toBe(false) + expect(state.username).toBeNull() + expect(state.roles).toEqual([]) + }) + + it('should clear localStorage', () => { + localStorage.setItem('token', 'test') + localStorage.setItem('username', 'admin') + localStorage.setItem('roles', '["admin"]') + localStorage.setItem('permission', '{}') + useAuthStore.getState().logout() + expect(localStorage.getItem('token')).toBeNull() + expect(localStorage.getItem('username')).toBeNull() + expect(localStorage.getItem('roles')).toBeNull() + expect(localStorage.getItem('permission')).toBeNull() + }) + }) + + describe('initFromStorage', () => { + it('should restore state from valid token', () => { + const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGVzIjpbImFkbWluIl0sImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.test' + localStorage.setItem('token', token) + useAuthStore.getState().initFromStorage() + const state = useAuthStore.getState() + expect(state.isAuthenticated).toBe(true) + expect(state.username).toBe('admin') + }) + + it('should do nothing when no token in localStorage', () => { + useAuthStore.getState().initFromStorage() + const state = useAuthStore.getState() + expect(state.isAuthenticated).toBe(false) + }) + + it('should logout when token is expired', () => { + const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGVzIjpbImFkbWluIl0sImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDAwMDAxfQ.test' + localStorage.setItem('token', expiredToken) + useAuthStore.getState().initFromStorage() + const state = useAuthStore.getState() + expect(state.isAuthenticated).toBe(false) + }) + }) + + describe('setInitialized', () => { + it('should set initialized flag', () => { + useAuthStore.getState().setInitialized(true) + expect(useAuthStore.getState().initialized).toBe(true) + }) + }) +}) diff --git a/novalon-manage-web/src/__tests__/stores/usePermissionStore.test.ts b/novalon-manage-web/src/__tests__/stores/usePermissionStore.test.ts new file mode 100644 index 0000000..ac4b11a --- /dev/null +++ b/novalon-manage-web/src/__tests__/stores/usePermissionStore.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockMenus = [ + { + id: 1, name: '系统管理', path: '/system', icon: 'setting', component: '', + parentId: 0, sort: 1, type: 'directory' as const, permission: '', status: 1, + visible: true, children: [ + { + id: 2, name: '用户管理', path: '/users', icon: 'user', component: 'system/user', + parentId: 1, sort: 1, type: 'menu' as const, permission: 'system:user:list', + status: 1, visible: true, children: [ + { + id: 3, name: '用户新增', path: '', icon: '', component: '', + parentId: 2, sort: 1, type: 'button' as const, permission: 'system:user:add', + status: 1, visible: true, + }, + ], + }, + ], + }, +] + +vi.mock('@/api/menu', () => ({ + menuApi: { + getTree: vi.fn(() => Promise.resolve(mockMenus)), + }, +})) + +import { usePermissionStore } from '@/stores/usePermissionStore' + +describe('usePermissionStore', () => { + beforeEach(() => { + localStorage.clear() + usePermissionStore.setState({ roles: [], permissions: [], menus: [], loaded: false }) + }) + + describe('fetchUserMenus', () => { + it('should fetch menus and extract permissions', async () => { + localStorage.setItem('roles', JSON.stringify(['admin'])) + await usePermissionStore.getState().fetchUserMenus() + const state = usePermissionStore.getState() + expect(state.menus.length).toBeGreaterThan(0) + expect(state.permissions).toContain('system:user:list') + expect(state.permissions).toContain('system:user:add') + expect(state.roles).toEqual(['admin']) + expect(state.loaded).toBe(true) + }) + + it('should persist permissions to localStorage', async () => { + localStorage.setItem('roles', JSON.stringify(['admin'])) + await usePermissionStore.getState().fetchUserMenus() + const stored = localStorage.getItem('permission') + expect(stored).toBeTruthy() + const parsed = JSON.parse(stored!) + expect(parsed.permissions).toContain('system:user:list') + }) + }) + + describe('hasPermission', () => { + it('should return true when user has the permission', () => { + usePermissionStore.setState({ permissions: ['system:user:list', 'system:user:add'] }) + expect(usePermissionStore.getState().hasPermission('system:user:list')).toBe(true) + }) + + it('should return false when user lacks the permission', () => { + usePermissionStore.setState({ permissions: ['system:role:list'] }) + expect(usePermissionStore.getState().hasPermission('system:user:list')).toBe(false) + }) + + it('should return true when user has wildcard permission', () => { + usePermissionStore.setState({ permissions: ['*'] }) + expect(usePermissionStore.getState().hasPermission('system:user:list')).toBe(true) + }) + }) + + describe('hasRole', () => { + it('should return true when user has the role', () => { + usePermissionStore.setState({ roles: ['admin', 'editor'] }) + expect(usePermissionStore.getState().hasRole('admin')).toBe(true) + }) + + it('should return false when user lacks the role', () => { + usePermissionStore.setState({ roles: ['editor'] }) + expect(usePermissionStore.getState().hasRole('admin')).toBe(false) + }) + + it('should return true when user has admin role (super admin)', () => { + usePermissionStore.setState({ roles: ['admin'] }) + expect(usePermissionStore.getState().hasRole('any-role')).toBe(true) + }) + }) + + describe('clearPermissionData', () => { + it('should reset all permission state', () => { + usePermissionStore.setState({ roles: ['admin'], permissions: ['system:user:list'], loaded: true }) + usePermissionStore.getState().clearPermissionData() + const state = usePermissionStore.getState() + expect(state.roles).toEqual([]) + expect(state.permissions).toEqual([]) + expect(state.loaded).toBe(false) + }) + + it('should clear localStorage permission data', () => { + localStorage.setItem('permission', JSON.stringify({ permissions: ['test'] })) + usePermissionStore.getState().clearPermissionData() + expect(localStorage.getItem('permission')).toBeNull() + }) + }) + + describe('initFromStorage', () => { + it('should restore state from localStorage', () => { + localStorage.setItem('permission', JSON.stringify({ permissions: ['system:user:list'], menus: [] })) + localStorage.setItem('roles', JSON.stringify(['admin'])) + const result = usePermissionStore.getState().initFromStorage() + expect(result).toBe(true) + expect(usePermissionStore.getState().permissions).toContain('system:user:list') + expect(usePermissionStore.getState().loaded).toBe(true) + }) + + it('should return false when no data in localStorage', () => { + const result = usePermissionStore.getState().initFromStorage() + expect(result).toBe(false) + }) + + it('should return false when localStorage data is invalid', () => { + localStorage.setItem('permission', 'invalid-json') + const result = usePermissionStore.getState().initFromStorage() + expect(result).toBe(false) + }) + }) +}) diff --git a/novalon-manage-web/src/__tests__/utils/dateFormat.test.ts b/novalon-manage-web/src/__tests__/utils/dateFormat.test.ts new file mode 100644 index 0000000..b0b9997 --- /dev/null +++ b/novalon-manage-web/src/__tests__/utils/dateFormat.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest' +import { formatDateTime, formatDate, formatTime } from '@/utils/dateFormat' + +describe('dateFormat', () => { + describe('formatDateTime', () => { + it('should format ISO date string to yyyy-MM-dd HH:mm:ss', () => { + const result = formatDateTime('2026-05-03T14:30:00') + expect(result).toBe('2026-05-03 14:30:00') + }) + + it('should format Date object', () => { + const date = new Date(2026, 4, 3, 14, 30, 0) + const result = formatDateTime(date) + expect(result).toContain('2026') + expect(result).toContain('14:30:00') + }) + + it('should return "-" for null', () => { + expect(formatDateTime(null)).toBe('-') + }) + + it('should return "-" for undefined', () => { + expect(formatDateTime(undefined)).toBe('-') + }) + + it('should return "-" for empty string', () => { + expect(formatDateTime('')).toBe('-') + }) + }) + + describe('formatDate', () => { + it('should format ISO date string to yyyy-MM-dd', () => { + const result = formatDate('2026-05-03T14:30:00') + expect(result).toBe('2026-05-03') + }) + + it('should return "-" for null', () => { + expect(formatDate(null)).toBe('-') + }) + }) + + describe('formatTime', () => { + it('should format ISO date string to HH:mm:ss', () => { + const result = formatTime('2026-05-03T14:30:00') + expect(result).toBe('14:30:00') + }) + + it('should return "-" for undefined', () => { + expect(formatTime(undefined)).toBe('-') + }) + }) +}) diff --git a/novalon-manage-web/src/__tests__/utils/errorHandler.test.ts b/novalon-manage-web/src/__tests__/utils/errorHandler.test.ts new file mode 100644 index 0000000..0e91f36 --- /dev/null +++ b/novalon-manage-web/src/__tests__/utils/errorHandler.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ApiErrorHandler, handleApiError } from '@/utils/errorHandler' +import type { ApiError } from '@/utils/errorHandler' + +vi.mock('antd', () => ({ + message: { error: vi.fn() }, +})) + +import { message } from 'antd' + +function makeError(status: number, data?: Partial): any { + return { response: { status, data: { message: 'Error', ...data } } } +} + +describe('errorHandler', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should handle network error (no response)', () => { + ApiErrorHandler.handle(new Error('Network Error')) + expect(message.error).toHaveBeenCalledWith('网络连接失败,请检查网络设置') + }) + + it('should handle 400 Bad Request', () => { + ApiErrorHandler.handle(makeError(400, { message: '参数错误' })) + expect(message.error).toHaveBeenCalledWith('参数错误') + }) + + it('should handle 401 Unauthorized', () => { + Object.defineProperty(window, 'location', { value: { href: '', pathname: '/dashboard' }, writable: true }) + ApiErrorHandler.handle(makeError(401)) + expect(message.error).toHaveBeenCalledWith('登录已过期,请重新登录') + expect(localStorage.getItem('token')).toBeNull() + }) + + it('should handle 403 Forbidden', () => { + ApiErrorHandler.handle(makeError(403)) + expect(message.error).toHaveBeenCalledWith('没有权限访问该资源') + }) + + it('should handle 404 Not Found', () => { + ApiErrorHandler.handle(makeError(404, { message: '资源不存在' })) + expect(message.error).toHaveBeenCalledWith('资源不存在') + }) + + it('should handle 409 Conflict', () => { + ApiErrorHandler.handle(makeError(409)) + expect(message.error).toHaveBeenCalledWith('Error') + }) + + it('should handle 422 Validation Error with details', () => { + ApiErrorHandler.handle(makeError(422, { details: { name: '名称必填', email: '邮箱格式错误' } })) + expect(message.error).toHaveBeenCalledWith('名称必填、邮箱格式错误') + }) + + it('should handle 500 Internal Server Error', () => { + ApiErrorHandler.handle(makeError(500)) + expect(message.error).toHaveBeenCalledWith('服务器内部错误,请稍后重试') + }) + + it('should handle 502/503/504 Service Unavailable', () => { + ApiErrorHandler.handle(makeError(503)) + expect(message.error).toHaveBeenCalledWith('服务暂时不可用,请稍后重试') + }) + + it('should handle unknown status code', () => { + ApiErrorHandler.handle(makeError(418, { message: 'I am a teapot' })) + expect(message.error).toHaveBeenCalledWith('I am a teapot') + }) + + it('handleApiError should delegate to ApiErrorHandler', () => { + handleApiError(makeError(400, { message: 'test' })) + expect(message.error).toHaveBeenCalledWith('test') + }) +}) diff --git a/novalon-manage-web/src/__tests__/utils/permission.test.ts b/novalon-manage-web/src/__tests__/utils/permission.test.ts new file mode 100644 index 0000000..01c133c --- /dev/null +++ b/novalon-manage-web/src/__tests__/utils/permission.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { checkApiPermission, getRequiredPermission } from '@/utils/permission' + +describe('permission', () => { + beforeEach(() => { + localStorage.clear() + }) + + describe('checkApiPermission', () => { + it('should return true when no permission mapping exists', () => { + expect(checkApiPermission('GET', '/unknown-api')).toBe(true) + }) + + it('should return true when no permission stored in localStorage', () => { + expect(checkApiPermission('GET', '/users')).toBe(true) + }) + + it('should return true when user has the required permission', () => { + localStorage.setItem('permission', JSON.stringify({ permissions: ['system:user:list'] })) + expect(checkApiPermission('GET', '/users')).toBe(true) + }) + + it('should return false when user lacks the required permission', () => { + localStorage.setItem('permission', JSON.stringify({ permissions: ['system:role:list'] })) + expect(checkApiPermission('POST', '/users')).toBe(false) + }) + + it('should return true for GET /menus (public)', () => { + localStorage.setItem('permission', JSON.stringify({ permissions: [] })) + expect(checkApiPermission('GET', '/menus')).toBe(true) + }) + + it('should handle URL with numeric ID by matching base pattern', () => { + localStorage.setItem('permission', JSON.stringify({ permissions: ['system:user:edit'] })) + expect(checkApiPermission('PUT', '/users/123')).toBe(true) + }) + + it('should return true when localStorage parse fails', () => { + localStorage.setItem('permission', 'invalid-json') + expect(checkApiPermission('GET', '/users')).toBe(true) + }) + }) + + describe('getRequiredPermission', () => { + it('should return permission for known API', () => { + expect(getRequiredPermission('GET', '/users')).toBe('system:user:list') + }) + + it('should return null for unknown API', () => { + expect(getRequiredPermission('GET', '/unknown')).toBeNull() + }) + + it('should return permission for POST /users', () => { + expect(getRequiredPermission('POST', '/users')).toBe('system:user:add') + }) + }) +}) diff --git a/novalon-manage-web/src/__tests__/utils/request.test.ts b/novalon-manage-web/src/__tests__/utils/request.test.ts new file mode 100644 index 0000000..9eb1905 --- /dev/null +++ b/novalon-manage-web/src/__tests__/utils/request.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/utils/signature', () => ({ + generateSignatureHeaders: vi.fn(() => ({ + 'X-Signature': 'test-sig', + 'X-Timestamp': '1234567890', + 'X-Nonce': 'test-nonce', + })), +})) + +vi.mock('@/utils/permission', () => ({ + checkApiPermission: vi.fn(() => true), +})) + +import request from '@/utils/request' +import { generateSignatureHeaders } from '@/utils/signature' +import { checkApiPermission } from '@/utils/permission' + +describe('request', () => { + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + }) + + it('should attach token to Authorization header when token exists', async () => { + localStorage.setItem('token', 'test-jwt-token') + const config = { headers: {} as any, method: 'get', url: '/users' } + const interceptor = (request as any).interceptors.request.handlers[0] + const result = await interceptor.fulfilled(config) + expect(result.headers.Authorization).toBe('Bearer test-jwt-token') + }) + + it('should not attach Authorization header when no token', async () => { + const config = { headers: {} as any, method: 'get', url: '/users' } + const interceptor = (request as any).interceptors.request.handlers[0] + const result = await interceptor.fulfilled(config) + expect(result.headers.Authorization).toBeUndefined() + }) + + it('should add signature headers to request', async () => { + const config = { headers: {} as any, method: 'get', url: '/users' } + const interceptor = (request as any).interceptors.request.handlers[0] + const result = await interceptor.fulfilled(config) + expect(result.headers['X-Signature']).toBe('test-sig') + expect(result.headers['X-Timestamp']).toBe('1234567890') + expect(result.headers['X-Nonce']).toBe('test-nonce') + }) + + it('should call generateSignatureHeaders with correct args', async () => { + const config = { headers: {} as any, method: 'POST', url: '/users', data: { name: 'test' } } + const interceptor = (request as any).interceptors.request.handlers[0] + await interceptor.fulfilled(config) + expect(generateSignatureHeaders).toHaveBeenCalledWith('POST', '/api/users', { name: 'test' }) + }) + + it('should reject when checkApiPermission returns false', async () => { + vi.mocked(checkApiPermission).mockReturnValueOnce(false) + const config = { headers: {} as any, method: 'get', url: '/users' } + const interceptor = (request as any).interceptors.request.handlers[0] + await expect(interceptor.fulfilled(config)).rejects.toThrow('无权限访问此接口') + }) + + it('should unwrap response.data in response interceptor', () => { + const interceptor = (request as any).interceptors.response.handlers[0] + const response = { data: { id: 1, name: 'test' } } + const result = interceptor.fulfilled(response) + expect(result).toEqual({ id: 1, name: 'test' }) + }) + + it('should clear token and redirect on 401 response', async () => { + localStorage.setItem('token', 'old-token') + const originalLocation = window.location + Object.defineProperty(window, 'location', { value: { href: '', pathname: '/dashboard' }, writable: true }) + const interceptor = (request as any).interceptors.response.handlers[0] + const error = { response: { status: 401 } } + await expect(interceptor.rejected(error)).rejects.toEqual(error) + expect(localStorage.getItem('token')).toBeNull() + Object.defineProperty(window, 'location', { value: originalLocation, writable: true }) + }) + + it('should not redirect on 401 if already on login page', async () => { + const originalLocation = window.location + Object.defineProperty(window, 'location', { value: { href: '', pathname: '/login' }, writable: true }) + const interceptor = (request as any).interceptors.response.handlers[0] + const error = { response: { status: 401 } } + await expect(interceptor.rejected(error)).rejects.toEqual(error) + expect(window.location.href).toBe('') + Object.defineProperty(window, 'location', { value: originalLocation, writable: true }) + }) +}) diff --git a/novalon-manage-web/src/__tests__/utils/signature.test.ts b/novalon-manage-web/src/__tests__/utils/signature.test.ts new file mode 100644 index 0000000..953be5a --- /dev/null +++ b/novalon-manage-web/src/__tests__/utils/signature.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import { generateSignature, generateSignatureHeaders } from '@/utils/signature' + +describe('signature', () => { + describe('generateSignature', () => { + it('should generate a consistent signature for the same inputs', () => { + const sig1 = generateSignature('GET', '/api/users', '', '', 1234567890, 'test-nonce') + const sig2 = generateSignature('GET', '/api/users', '', '', 1234567890, 'test-nonce') + expect(sig1).toBe(sig2) + }) + + it('should generate different signatures for different methods', () => { + const sigGet = generateSignature('GET', '/api/users', '', '', 1234567890, 'test-nonce') + const sigPost = generateSignature('POST', '/api/users', '', '', 1234567890, 'test-nonce') + expect(sigGet).not.toBe(sigPost) + }) + + it('should generate different signatures for different paths', () => { + const sig1 = generateSignature('GET', '/api/users', '', '', 1234567890, 'test-nonce') + const sig2 = generateSignature('GET', '/api/roles', '', '', 1234567890, 'test-nonce') + expect(sig1).not.toBe(sig2) + }) + + it('should generate different signatures for different timestamps', () => { + const sig1 = generateSignature('GET', '/api/users', '', '', 1234567890, 'test-nonce') + const sig2 = generateSignature('GET', '/api/users', '', '', 1234567891, 'test-nonce') + expect(sig1).not.toBe(sig2) + }) + + it('should return a Base64 encoded string', () => { + const sig = generateSignature('GET', '/api/users', '', '', 1234567890, 'test-nonce') + expect(sig).toMatch(/^[A-Za-z0-9+/]+=*$/) + }) + }) + + describe('generateSignatureHeaders', () => { + it('should return headers with X-Signature, X-Timestamp, X-Nonce', () => { + const headers = generateSignatureHeaders('GET', '/api/users') + expect(headers).toHaveProperty('X-Signature') + expect(headers).toHaveProperty('X-Timestamp') + expect(headers).toHaveProperty('X-Nonce') + }) + + it('should have numeric X-Timestamp', () => { + const headers = generateSignatureHeaders('GET', '/api/users') + expect(Number(headers['X-Timestamp'])).not.toBeNaN() + }) + + it('should have non-empty X-Nonce', () => { + const headers = generateSignatureHeaders('GET', '/api/users') + expect(headers['X-Nonce'].length).toBeGreaterThan(0) + }) + }) + + describe('URL parsing (via generateSignatureHeaders)', () => { + it('should handle relative URL without query', () => { + const headers = generateSignatureHeaders('GET', '/api/users') + expect(headers['X-Signature']).toBeTruthy() + }) + + it('should handle relative URL with query', () => { + const headers = generateSignatureHeaders('GET', '/api/users?page=1&size=10') + expect(headers['X-Signature']).toBeTruthy() + }) + + it('should handle absolute URL', () => { + const headers = generateSignatureHeaders('GET', 'http://localhost:8080/api/users?id=1') + expect(headers['X-Signature']).toBeTruthy() + }) + }) +}) diff --git a/novalon-manage-web/src/layouts/DefaultLayout/SideMenu.tsx b/novalon-manage-web/src/layouts/DefaultLayout/SideMenu.tsx index 60b7d3e..09c56e2 100644 --- a/novalon-manage-web/src/layouts/DefaultLayout/SideMenu.tsx +++ b/novalon-manage-web/src/layouts/DefaultLayout/SideMenu.tsx @@ -13,6 +13,11 @@ import { AuditOutlined, FileSearchOutlined, WarningOutlined, + DesktopOutlined, + ScheduleOutlined, + DatabaseOutlined, + CloudServerOutlined, + CloudOutlined, } from '@ant-design/icons' import { usePermissionStore } from '@/stores/usePermissionStore' import type { MenuItem } from '@/api/menu' @@ -35,6 +40,11 @@ const iconMap: Record = { loginlog: , oplog: , exceptionlog: , + 'monitor/online': , + 'monitor/job': , + 'monitor/data': , + 'monitor/server': , + 'monitor/cache': , } const pathMap: Record = { @@ -49,6 +59,11 @@ const pathMap: Record = { loginlog: '/loginlog', oplog: '/oplog', exceptionlog: '/exceptionlog', + 'monitor/online': '/monitor/online', + 'monitor/job': '/monitor/job', + 'monitor/data': '/monitor/data', + 'monitor/server': '/monitor/server', + 'monitor/cache': '/monitor/cache', } function convertMenus(items: MenuItem[]): AntMenuItem[] { diff --git a/novalon-manage-web/src/layouts/DefaultLayout/index.tsx b/novalon-manage-web/src/layouts/DefaultLayout/index.tsx index 9289cc8..3004492 100644 --- a/novalon-manage-web/src/layouts/DefaultLayout/index.tsx +++ b/novalon-manage-web/src/layouts/DefaultLayout/index.tsx @@ -1,15 +1,88 @@ -import { Suspense } from 'react' -import { Outlet } from 'react-router' +import { Suspense, useMemo } from 'react' +import { Outlet, useNavigate, useLocation } from 'react-router' import { ProLayout } from '@ant-design/pro-components' import { Spin } from 'antd' +import { + DashboardOutlined, + UserOutlined, + TeamOutlined, + MenuOutlined, + SettingOutlined, + BookOutlined, + FileOutlined, + NotificationOutlined, + AuditOutlined, + FileSearchOutlined, + WarningOutlined, + MonitorOutlined, +} from '@ant-design/icons' import { useAppStore } from '@/stores/useAppStore' import { useAuthStore } from '@/stores/useAuthStore' +import { usePermissionStore } from '@/stores/usePermissionStore' import HeaderRight from './HeaderRight' +import type { MenuItem } from '@/api/menu' + +const iconMap: Record = { + dashboard: , + user: , + users: , + role: , + roles: , + menu: , + menus: , + config: , + dict: , + file: , + files: , + notice: , + loginlog: , + oplog: , + exceptionlog: , + monitor: , +} + +interface ProLayoutRoute { + path: string + name: string + icon?: React.ReactNode + routes?: ProLayoutRoute[] +} + +function convertToProRoutes(items: MenuItem[]): ProLayoutRoute[] { + return items + .filter((item) => item.type !== 'button' && item.visible !== false) + .map((item) => { + const icon = iconMap[item.icon] || iconMap[item.permission] + const route: ProLayoutRoute = { + path: item.path || `/custom/${item.id}`, + name: item.name, + icon, + } + if (item.children?.length) { + const childRoutes = convertToProRoutes(item.children) + if (childRoutes.length > 0) { + route.routes = childRoutes + } + } + return route + }) +} export default function DefaultLayout() { const collapsed = useAppStore((s) => s.collapsed) const toggleCollapsed = useAppStore((s) => s.toggleCollapsed) const username = useAuthStore((s) => s.username) + const menus = usePermissionStore((s) => s.menus) + const navigate = useNavigate() + const location = useLocation() + + const route = useMemo(() => ({ + path: '/', + routes: [ + { path: '/dashboard', name: '仪表盘', icon: }, + ...convertToProRoutes(menus), + ], + }), [menus]) return ( item.onClick?.()}>{dom}} + route={route} + location={{ pathname: location.pathname }} + menuItemRender={(item, dom) => ( + { + if (item.path) navigate(item.path) + }} + > + {dom} + + )} + subMenuItemRender={(_item, dom) => {dom}} headerTitleRender={(logo, title) => ( {logo}{title} diff --git a/novalon-manage-web/src/pages/monitor/cache/index.tsx b/novalon-manage-web/src/pages/monitor/cache/index.tsx new file mode 100644 index 0000000..d382b1e --- /dev/null +++ b/novalon-manage-web/src/pages/monitor/cache/index.tsx @@ -0,0 +1,18 @@ +import { Result, Button } from 'antd' +import { ToolOutlined } from '@ant-design/icons' +import { useNavigate } from 'react-router' + +export default function CacheMonitor() { + const navigate = useNavigate() + + return ( +
+ } + title="缓存监控" + subTitle="该功能正在开发中,敬请期待" + extra={} + /> +
+ ) +} diff --git a/novalon-manage-web/src/pages/monitor/data/index.tsx b/novalon-manage-web/src/pages/monitor/data/index.tsx new file mode 100644 index 0000000..9c012fa --- /dev/null +++ b/novalon-manage-web/src/pages/monitor/data/index.tsx @@ -0,0 +1,18 @@ +import { Result, Button } from 'antd' +import { ToolOutlined } from '@ant-design/icons' +import { useNavigate } from 'react-router' + +export default function DataMonitor() { + const navigate = useNavigate() + + return ( +
+ } + title="数据监控" + subTitle="该功能正在开发中,敬请期待" + extra={} + /> +
+ ) +} diff --git a/novalon-manage-web/src/pages/monitor/job/index.tsx b/novalon-manage-web/src/pages/monitor/job/index.tsx new file mode 100644 index 0000000..323546e --- /dev/null +++ b/novalon-manage-web/src/pages/monitor/job/index.tsx @@ -0,0 +1,18 @@ +import { Result, Button } from 'antd' +import { ToolOutlined } from '@ant-design/icons' +import { useNavigate } from 'react-router' + +export default function ScheduledTasks() { + const navigate = useNavigate() + + return ( +
+ } + title="定时任务" + subTitle="该功能正在开发中,敬请期待" + extra={} + /> +
+ ) +} diff --git a/novalon-manage-web/src/pages/monitor/online/index.tsx b/novalon-manage-web/src/pages/monitor/online/index.tsx new file mode 100644 index 0000000..f88109b --- /dev/null +++ b/novalon-manage-web/src/pages/monitor/online/index.tsx @@ -0,0 +1,18 @@ +import { Result, Button } from 'antd' +import { ToolOutlined } from '@ant-design/icons' +import { useNavigate } from 'react-router' + +export default function OnlineUsers() { + const navigate = useNavigate() + + return ( +
+ } + title="在线用户" + subTitle="该功能正在开发中,敬请期待" + extra={} + /> +
+ ) +} diff --git a/novalon-manage-web/src/pages/monitor/server/index.tsx b/novalon-manage-web/src/pages/monitor/server/index.tsx new file mode 100644 index 0000000..5547fe9 --- /dev/null +++ b/novalon-manage-web/src/pages/monitor/server/index.tsx @@ -0,0 +1,18 @@ +import { Result, Button } from 'antd' +import { ToolOutlined } from '@ant-design/icons' +import { useNavigate } from 'react-router' + +export default function ServerMonitor() { + const navigate = useNavigate() + + return ( +
+ } + title="服务监控" + subTitle="该功能正在开发中,敬请期待" + extra={} + /> +
+ ) +} diff --git a/novalon-manage-web/src/pages/system/dept/index.tsx b/novalon-manage-web/src/pages/system/dept/index.tsx new file mode 100644 index 0000000..f21178e --- /dev/null +++ b/novalon-manage-web/src/pages/system/dept/index.tsx @@ -0,0 +1,18 @@ +import { Result, Button } from 'antd' +import { ToolOutlined } from '@ant-design/icons' +import { useNavigate } from 'react-router' + +export default function DeptManagement() { + const navigate = useNavigate() + + return ( +
+ } + title="部门管理" + subTitle="该功能正在开发中,敬请期待" + extra={} + /> +
+ ) +} diff --git a/novalon-manage-web/src/router/index.tsx b/novalon-manage-web/src/router/index.tsx index 5d425ba..b5f789f 100644 --- a/novalon-manage-web/src/router/index.tsx +++ b/novalon-manage-web/src/router/index.tsx @@ -7,6 +7,7 @@ import { UserManagement, RoleManagement, MenuManagement, + DeptManagement, ConfigManagement, DictManagement, FileManagement, @@ -14,6 +15,11 @@ import { LoginLog, OperationLog, ExceptionLog, + OnlineUsers, + ScheduledTasks, + DataMonitor, + ServerMonitor, + CacheMonitor, Forbidden, } from './routes' @@ -36,6 +42,7 @@ export const router = createBrowserRouter([ { path: 'users', element: }, { path: 'roles', element: }, { path: 'menus', element: }, + { path: 'sys/dept', element: }, { path: 'sys/config', element: }, { path: 'dict', element: }, { path: 'files', element: }, @@ -43,6 +50,11 @@ export const router = createBrowserRouter([ { path: 'loginlog', element: }, { path: 'oplog', element: }, { path: 'exceptionlog', element: }, + { path: 'monitor/online', element: }, + { path: 'monitor/job', element: }, + { path: 'monitor/data', element: }, + { path: 'monitor/server', element: }, + { path: 'monitor/cache', element: }, ], }, ]) diff --git a/novalon-manage-web/src/router/routes.ts b/novalon-manage-web/src/router/routes.ts index 0682f56..7bbb85e 100644 --- a/novalon-manage-web/src/router/routes.ts +++ b/novalon-manage-web/src/router/routes.ts @@ -5,6 +5,7 @@ const Dashboard = lazy(() => import('@/pages/dashboard')) const UserManagement = lazy(() => import('@/pages/system/user')) const RoleManagement = lazy(() => import('@/pages/system/role')) const MenuManagement = lazy(() => import('@/pages/system/menu')) +const DeptManagement = lazy(() => import('@/pages/system/dept')) const ConfigManagement = lazy(() => import('@/pages/config/config')) const DictManagement = lazy(() => import('@/pages/config/dict')) const FileManagement = lazy(() => import('@/pages/file')) @@ -12,8 +13,13 @@ const NoticeManagement = lazy(() => import('@/pages/notify')) const LoginLog = lazy(() => import('@/pages/log/login')) const OperationLog = lazy(() => import('@/pages/log/op')) const ExceptionLog = lazy(() => import('@/pages/log/ex')) +const OnlineUsers = lazy(() => import('@/pages/monitor/online')) +const ScheduledTasks = lazy(() => import('@/pages/monitor/job')) +const DataMonitor = lazy(() => import('@/pages/monitor/data')) +const ServerMonitor = lazy(() => import('@/pages/monitor/server')) +const CacheMonitor = lazy(() => import('@/pages/monitor/cache')) const Forbidden = lazy(() => import('@/pages/403')) const DefaultLayout = lazy(() => import('@/layouts/DefaultLayout/index')) export { authLoader } from './guards' -export { DefaultLayout, Login, Dashboard, UserManagement, RoleManagement, MenuManagement, ConfigManagement, DictManagement, FileManagement, NoticeManagement, LoginLog, OperationLog, ExceptionLog, Forbidden } +export { DefaultLayout, Login, Dashboard, UserManagement, RoleManagement, MenuManagement, DeptManagement, ConfigManagement, DictManagement, FileManagement, NoticeManagement, LoginLog, OperationLog, ExceptionLog, OnlineUsers, ScheduledTasks, DataMonitor, ServerMonitor, CacheMonitor, Forbidden }