feat: 新增监控页面、部门管理占位与单元测试
- 新增系统监控模块(在线用户、定时任务、数据监控、服务器监控、缓存监控) - 新增部门管理占位页面 - 路由注册新增模块与懒加载 - DefaultLayout 侧边菜单与布局优化 - 新增前端单元测试与后端 RoleUpdateRequest 测试
This commit is contained in:
+68
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import type { RawMenuItem } from '@/api/menu'
|
||||||
|
|
||||||
|
const menuTypeMap: Record<string, 'directory' | 'menu' | 'button'> = {
|
||||||
|
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<string, string> = {
|
||||||
|
'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<string, string> = {
|
||||||
|
'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> = {}): 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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(<MemoryRouter>{ui}</MemoryRouter>)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AuthGuard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render children when authenticated', () => {
|
||||||
|
mockUseAuthStore.mockImplementation((selector: any) =>
|
||||||
|
selector({ isAuthenticated: true })
|
||||||
|
)
|
||||||
|
renderWithRouter(
|
||||||
|
<AuthGuard>
|
||||||
|
<div>Protected Page</div>
|
||||||
|
</AuthGuard>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Protected Page')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should redirect to /login when not authenticated', () => {
|
||||||
|
mockUseAuthStore.mockImplementation((selector: any) =>
|
||||||
|
selector({ isAuthenticated: false })
|
||||||
|
)
|
||||||
|
renderWithRouter(
|
||||||
|
<AuthGuard>
|
||||||
|
<div>Protected Page</div>
|
||||||
|
</AuthGuard>
|
||||||
|
)
|
||||||
|
expect(screen.queryByText('Protected Page')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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(<ChartContainer onInit={onInit} />)
|
||||||
|
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(<ChartContainer onInit={onInit} onDestroy={onDestroy} />)
|
||||||
|
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(<ChartContainer onInit={onInit} />)
|
||||||
|
expect(() => unmount()).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply custom style', () => {
|
||||||
|
const onInit = vi.fn()
|
||||||
|
const { container } = render(
|
||||||
|
<ChartContainer onInit={onInit} style={{ backgroundColor: 'red' }} />
|
||||||
|
)
|
||||||
|
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(<ChartContainer onInit={onInit} />)
|
||||||
|
const div = container.firstChild as HTMLElement
|
||||||
|
expect(div.style.width).toBe('100%')
|
||||||
|
expect(div.style.height).toBe('100%')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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(
|
||||||
|
<PermissionGuard permission="system:user:list">
|
||||||
|
<div>Protected Content</div>
|
||||||
|
</PermissionGuard>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Protected Content')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render fallback when user lacks permission', () => {
|
||||||
|
mockUsePermissionStore.mockImplementation((selector: any) =>
|
||||||
|
selector({ hasPermission: () => false, hasRole: () => false })
|
||||||
|
)
|
||||||
|
render(
|
||||||
|
<PermissionGuard permission="system:user:list" fallback={<div>No Access</div>}>
|
||||||
|
<div>Protected Content</div>
|
||||||
|
</PermissionGuard>
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
<PermissionGuard permission="system:user:list">
|
||||||
|
<div>Protected Content</div>
|
||||||
|
</PermissionGuard>
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
<PermissionGuard role="admin" type="role">
|
||||||
|
<div>Admin Content</div>
|
||||||
|
</PermissionGuard>
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
<PermissionGuard>
|
||||||
|
<div>Always Visible</div>
|
||||||
|
</PermissionGuard>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Always Visible')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
|
|
||||||
|
const localStorageMock = (() => {
|
||||||
|
let store: Record<string, string> = {}
|
||||||
|
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 })
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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('-')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<ApiError>): 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -13,6 +13,11 @@ import {
|
|||||||
AuditOutlined,
|
AuditOutlined,
|
||||||
FileSearchOutlined,
|
FileSearchOutlined,
|
||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
|
DesktopOutlined,
|
||||||
|
ScheduleOutlined,
|
||||||
|
DatabaseOutlined,
|
||||||
|
CloudServerOutlined,
|
||||||
|
CloudOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { usePermissionStore } from '@/stores/usePermissionStore'
|
import { usePermissionStore } from '@/stores/usePermissionStore'
|
||||||
import type { MenuItem } from '@/api/menu'
|
import type { MenuItem } from '@/api/menu'
|
||||||
@@ -35,6 +40,11 @@ const iconMap: Record<string, React.ReactNode> = {
|
|||||||
loginlog: <AuditOutlined />,
|
loginlog: <AuditOutlined />,
|
||||||
oplog: <FileSearchOutlined />,
|
oplog: <FileSearchOutlined />,
|
||||||
exceptionlog: <WarningOutlined />,
|
exceptionlog: <WarningOutlined />,
|
||||||
|
'monitor/online': <DesktopOutlined />,
|
||||||
|
'monitor/job': <ScheduleOutlined />,
|
||||||
|
'monitor/data': <DatabaseOutlined />,
|
||||||
|
'monitor/server': <CloudServerOutlined />,
|
||||||
|
'monitor/cache': <CloudOutlined />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathMap: Record<string, string> = {
|
const pathMap: Record<string, string> = {
|
||||||
@@ -49,6 +59,11 @@ const pathMap: Record<string, string> = {
|
|||||||
loginlog: '/loginlog',
|
loginlog: '/loginlog',
|
||||||
oplog: '/oplog',
|
oplog: '/oplog',
|
||||||
exceptionlog: '/exceptionlog',
|
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[] {
|
function convertMenus(items: MenuItem[]): AntMenuItem[] {
|
||||||
|
|||||||
@@ -1,15 +1,88 @@
|
|||||||
import { Suspense } from 'react'
|
import { Suspense, useMemo } from 'react'
|
||||||
import { Outlet } from 'react-router'
|
import { Outlet, useNavigate, useLocation } from 'react-router'
|
||||||
import { ProLayout } from '@ant-design/pro-components'
|
import { ProLayout } from '@ant-design/pro-components'
|
||||||
import { Spin } from 'antd'
|
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 { useAppStore } from '@/stores/useAppStore'
|
||||||
import { useAuthStore } from '@/stores/useAuthStore'
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
import { usePermissionStore } from '@/stores/usePermissionStore'
|
||||||
import HeaderRight from './HeaderRight'
|
import HeaderRight from './HeaderRight'
|
||||||
|
import type { MenuItem } from '@/api/menu'
|
||||||
|
|
||||||
|
const iconMap: Record<string, React.ReactNode> = {
|
||||||
|
dashboard: <DashboardOutlined />,
|
||||||
|
user: <UserOutlined />,
|
||||||
|
users: <UserOutlined />,
|
||||||
|
role: <TeamOutlined />,
|
||||||
|
roles: <TeamOutlined />,
|
||||||
|
menu: <MenuOutlined />,
|
||||||
|
menus: <MenuOutlined />,
|
||||||
|
config: <SettingOutlined />,
|
||||||
|
dict: <BookOutlined />,
|
||||||
|
file: <FileOutlined />,
|
||||||
|
files: <FileOutlined />,
|
||||||
|
notice: <NotificationOutlined />,
|
||||||
|
loginlog: <AuditOutlined />,
|
||||||
|
oplog: <FileSearchOutlined />,
|
||||||
|
exceptionlog: <WarningOutlined />,
|
||||||
|
monitor: <MonitorOutlined />,
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
export default function DefaultLayout() {
|
||||||
const collapsed = useAppStore((s) => s.collapsed)
|
const collapsed = useAppStore((s) => s.collapsed)
|
||||||
const toggleCollapsed = useAppStore((s) => s.toggleCollapsed)
|
const toggleCollapsed = useAppStore((s) => s.toggleCollapsed)
|
||||||
const username = useAuthStore((s) => s.username)
|
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: <DashboardOutlined /> },
|
||||||
|
...convertToProRoutes(menus),
|
||||||
|
],
|
||||||
|
}), [menus])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProLayout
|
<ProLayout
|
||||||
@@ -20,7 +93,18 @@ export default function DefaultLayout() {
|
|||||||
onCollapse={toggleCollapsed}
|
onCollapse={toggleCollapsed}
|
||||||
fixSiderbar
|
fixSiderbar
|
||||||
fixedHeader
|
fixedHeader
|
||||||
menuItemRender={(item, dom) => <a onClick={() => item.onClick?.()}>{dom}</a>}
|
route={route}
|
||||||
|
location={{ pathname: location.pathname }}
|
||||||
|
menuItemRender={(item, dom) => (
|
||||||
|
<a
|
||||||
|
onClick={() => {
|
||||||
|
if (item.path) navigate(item.path)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dom}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
subMenuItemRender={(_item, dom) => <span>{dom}</span>}
|
||||||
headerTitleRender={(logo, title) => (
|
headerTitleRender={(logo, title) => (
|
||||||
<a onClick={toggleCollapsed} style={{ cursor: 'pointer' }}>
|
<a onClick={toggleCollapsed} style={{ cursor: 'pointer' }}>
|
||||||
{logo}{title}
|
{logo}{title}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Result
|
||||||
|
icon={<ToolOutlined />}
|
||||||
|
title="缓存监控"
|
||||||
|
subTitle="该功能正在开发中,敬请期待"
|
||||||
|
extra={<Button type="primary" onClick={() => navigate('/dashboard')}>返回首页</Button>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Result
|
||||||
|
icon={<ToolOutlined />}
|
||||||
|
title="数据监控"
|
||||||
|
subTitle="该功能正在开发中,敬请期待"
|
||||||
|
extra={<Button type="primary" onClick={() => navigate('/dashboard')}>返回首页</Button>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Result
|
||||||
|
icon={<ToolOutlined />}
|
||||||
|
title="定时任务"
|
||||||
|
subTitle="该功能正在开发中,敬请期待"
|
||||||
|
extra={<Button type="primary" onClick={() => navigate('/dashboard')}>返回首页</Button>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Result
|
||||||
|
icon={<ToolOutlined />}
|
||||||
|
title="在线用户"
|
||||||
|
subTitle="该功能正在开发中,敬请期待"
|
||||||
|
extra={<Button type="primary" onClick={() => navigate('/dashboard')}>返回首页</Button>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Result
|
||||||
|
icon={<ToolOutlined />}
|
||||||
|
title="服务监控"
|
||||||
|
subTitle="该功能正在开发中,敬请期待"
|
||||||
|
extra={<Button type="primary" onClick={() => navigate('/dashboard')}>返回首页</Button>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Result
|
||||||
|
icon={<ToolOutlined />}
|
||||||
|
title="部门管理"
|
||||||
|
subTitle="该功能正在开发中,敬请期待"
|
||||||
|
extra={<Button type="primary" onClick={() => navigate('/dashboard')}>返回首页</Button>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
UserManagement,
|
UserManagement,
|
||||||
RoleManagement,
|
RoleManagement,
|
||||||
MenuManagement,
|
MenuManagement,
|
||||||
|
DeptManagement,
|
||||||
ConfigManagement,
|
ConfigManagement,
|
||||||
DictManagement,
|
DictManagement,
|
||||||
FileManagement,
|
FileManagement,
|
||||||
@@ -14,6 +15,11 @@ import {
|
|||||||
LoginLog,
|
LoginLog,
|
||||||
OperationLog,
|
OperationLog,
|
||||||
ExceptionLog,
|
ExceptionLog,
|
||||||
|
OnlineUsers,
|
||||||
|
ScheduledTasks,
|
||||||
|
DataMonitor,
|
||||||
|
ServerMonitor,
|
||||||
|
CacheMonitor,
|
||||||
Forbidden,
|
Forbidden,
|
||||||
} from './routes'
|
} from './routes'
|
||||||
|
|
||||||
@@ -36,6 +42,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'users', element: <UserManagement /> },
|
{ path: 'users', element: <UserManagement /> },
|
||||||
{ path: 'roles', element: <RoleManagement /> },
|
{ path: 'roles', element: <RoleManagement /> },
|
||||||
{ path: 'menus', element: <MenuManagement /> },
|
{ path: 'menus', element: <MenuManagement /> },
|
||||||
|
{ path: 'sys/dept', element: <DeptManagement /> },
|
||||||
{ path: 'sys/config', element: <ConfigManagement /> },
|
{ path: 'sys/config', element: <ConfigManagement /> },
|
||||||
{ path: 'dict', element: <DictManagement /> },
|
{ path: 'dict', element: <DictManagement /> },
|
||||||
{ path: 'files', element: <FileManagement /> },
|
{ path: 'files', element: <FileManagement /> },
|
||||||
@@ -43,6 +50,11 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'loginlog', element: <LoginLog /> },
|
{ path: 'loginlog', element: <LoginLog /> },
|
||||||
{ path: 'oplog', element: <OperationLog /> },
|
{ path: 'oplog', element: <OperationLog /> },
|
||||||
{ path: 'exceptionlog', element: <ExceptionLog /> },
|
{ path: 'exceptionlog', element: <ExceptionLog /> },
|
||||||
|
{ path: 'monitor/online', element: <OnlineUsers /> },
|
||||||
|
{ path: 'monitor/job', element: <ScheduledTasks /> },
|
||||||
|
{ path: 'monitor/data', element: <DataMonitor /> },
|
||||||
|
{ path: 'monitor/server', element: <ServerMonitor /> },
|
||||||
|
{ path: 'monitor/cache', element: <CacheMonitor /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const Dashboard = lazy(() => import('@/pages/dashboard'))
|
|||||||
const UserManagement = lazy(() => import('@/pages/system/user'))
|
const UserManagement = lazy(() => import('@/pages/system/user'))
|
||||||
const RoleManagement = lazy(() => import('@/pages/system/role'))
|
const RoleManagement = lazy(() => import('@/pages/system/role'))
|
||||||
const MenuManagement = lazy(() => import('@/pages/system/menu'))
|
const MenuManagement = lazy(() => import('@/pages/system/menu'))
|
||||||
|
const DeptManagement = lazy(() => import('@/pages/system/dept'))
|
||||||
const ConfigManagement = lazy(() => import('@/pages/config/config'))
|
const ConfigManagement = lazy(() => import('@/pages/config/config'))
|
||||||
const DictManagement = lazy(() => import('@/pages/config/dict'))
|
const DictManagement = lazy(() => import('@/pages/config/dict'))
|
||||||
const FileManagement = lazy(() => import('@/pages/file'))
|
const FileManagement = lazy(() => import('@/pages/file'))
|
||||||
@@ -12,8 +13,13 @@ const NoticeManagement = lazy(() => import('@/pages/notify'))
|
|||||||
const LoginLog = lazy(() => import('@/pages/log/login'))
|
const LoginLog = lazy(() => import('@/pages/log/login'))
|
||||||
const OperationLog = lazy(() => import('@/pages/log/op'))
|
const OperationLog = lazy(() => import('@/pages/log/op'))
|
||||||
const ExceptionLog = lazy(() => import('@/pages/log/ex'))
|
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 Forbidden = lazy(() => import('@/pages/403'))
|
||||||
const DefaultLayout = lazy(() => import('@/layouts/DefaultLayout/index'))
|
const DefaultLayout = lazy(() => import('@/layouts/DefaultLayout/index'))
|
||||||
|
|
||||||
export { authLoader } from './guards'
|
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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user