feat(react19-migration): 阶段2 - 核心框架层迁移

- T2.1: request.ts 确认无 Vue 依赖,无需修改
- T2.5: errorHandler.ts ElMessage → antd message
- T2.7: 新增 API (menu/config/dict/file/notice/loginLog) + 类型定义 (menu/permission/user)
- 清理旧 Vue 测试文件、views、stores、router、directives
- 修复 tsconfig: 添加 module:ESNext + types:vite/client

验证: npx tsc --noEmit 无类型错误
This commit is contained in:
张翔
2026-05-03 15:26:42 +08:00
parent a01bcf791b
commit 49779479dd
61 changed files with 397 additions and 8904 deletions
@@ -1,267 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import ConfigManagement from '@/views/config/ConfigManagement.vue'
vi.mock('vue-router')
vi.mock('element-plus', () => ({
ElMessage: {
success: vi.fn(),
error: vi.fn(),
},
ElMessageBox: {
confirm: vi.fn(),
},
}))
vi.mock('@/utils/request', () => {
const mockRequest = {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
}
mockRequest.get.mockResolvedValue([])
mockRequest.post.mockResolvedValue({})
mockRequest.put.mockResolvedValue({})
mockRequest.delete.mockResolvedValue({})
return {
default: mockRequest,
}
})
describe('ConfigManagement Component', () => {
let router: any
let wrapper: any
beforeEach(() => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('component initialization', () => {
it('should render config management container', () => {
wrapper = mount(ConfigManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-icon': true,
},
},
})
expect(wrapper.find('.config-management').exists()).toBe(true)
})
it('should initialize with empty data source', () => {
wrapper = mount(ConfigManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.dataSource).toBeDefined()
expect(Array.isArray(wrapper.vm.dataSource)).toBe(true)
})
it('should initialize with loading state false', () => {
wrapper = mount(ConfigManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.loading).toBeDefined()
expect(typeof wrapper.vm.loading).toBe('boolean')
})
it('should initialize with modal visible false', () => {
wrapper = mount(ConfigManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.modalVisible).toBe(false)
})
it('should initialize with empty form state', () => {
wrapper = mount(ConfigManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.formState.configName).toBe('')
expect(wrapper.vm.formState.configKey).toBe('')
expect(wrapper.vm.formState.configValue).toBe('')
})
})
describe('add config functionality', () => {
it('should have handleAdd method', () => {
wrapper = mount(ConfigManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleAdd).toBe('function')
})
})
describe('edit config functionality', () => {
it('should have handleEdit method', () => {
wrapper = mount(ConfigManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleEdit).toBe('function')
})
})
describe('delete config functionality', () => {
it('should have handleDelete method', () => {
wrapper = mount(ConfigManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleDelete).toBe('function')
})
})
describe('form submission', () => {
it('should have handleModalOk method', () => {
wrapper = mount(ConfigManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleModalOk).toBe('function')
})
})
})
@@ -1,261 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import Dashboard from '@/views/system/Dashboard.vue'
vi.mock('vue-router')
vi.mock('@/api/user.api.ts', () => ({
getUserStats: vi.fn(),
getRecentLogins: vi.fn(),
}))
describe('Dashboard Component', () => {
let router: any
let wrapper: any
beforeEach(() => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Dashboard</div>' } },
],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('component initialization', () => {
it('should render dashboard container', () => {
wrapper = mount(Dashboard, {
global: {
plugins: [router],
stubs: {
'el-row': true,
'el-col': true,
'el-card': true,
'el-statistic': true,
'el-icon': true,
'el-timeline': true,
'el-timeline-item': true,
},
},
})
expect(wrapper.find('.dashboard').exists()).toBe(true)
})
it('should initialize with loading state', () => {
wrapper = mount(Dashboard, {
global: {
plugins: [router],
stubs: {
'el-row': true,
'el-col': true,
'el-card': true,
'el-statistic': true,
'el-icon': true,
'el-timeline': true,
'el-timeline-item': true,
},
},
})
expect(wrapper.vm.loading).toBe(true)
})
it('should initialize with empty stats', () => {
wrapper = mount(Dashboard, {
global: {
plugins: [router],
stubs: {
'el-row': true,
'el-col': true,
'el-card': true,
'el-statistic': true,
'el-icon': true,
'el-timeline': true,
'el-timeline-item': true,
},
},
})
expect(wrapper.vm.stats).toEqual({
userCount: 0,
roleCount: 0,
todayLogin: 0,
operationLog: 0,
})
})
})
describe('statistics cards', () => {
it('should render user count card', () => {
wrapper = mount(Dashboard, {
global: {
plugins: [router],
stubs: {
'el-row': true,
'el-col': true,
'el-card': true,
'el-statistic': true,
'el-icon': true,
'el-timeline': true,
'el-timeline-item': true,
},
},
})
expect(wrapper.vm.stats.userCount).toBeDefined()
})
it('should render role count card', () => {
wrapper = mount(Dashboard, {
global: {
plugins: [router],
stubs: {
'el-row': true,
'el-col': true,
'el-card': true,
'el-statistic': true,
'el-icon': true,
'el-timeline': true,
'el-timeline-item': true,
},
},
})
expect(wrapper.vm.stats.roleCount).toBeDefined()
})
it('should render today login card', () => {
wrapper = mount(Dashboard, {
global: {
plugins: [router],
stubs: {
'el-row': true,
'el-col': true,
'el-card': true,
'el-statistic': true,
'el-icon': true,
'el-timeline': true,
'el-timeline-item': true,
},
},
})
expect(wrapper.vm.stats.todayLogin).toBeDefined()
})
it('should render operation log card', () => {
wrapper = mount(Dashboard, {
global: {
plugins: [router],
stubs: {
'el-row': true,
'el-col': true,
'el-card': true,
'el-statistic': true,
'el-icon': true,
'el-timeline': true,
'el-timeline-item': true,
},
},
})
expect(wrapper.vm.stats.operationLog).toBeDefined()
})
})
describe('recent logins', () => {
it('should initialize with empty recent logins', () => {
wrapper = mount(Dashboard, {
global: {
plugins: [router],
stubs: {
'el-row': true,
'el-col': true,
'el-card': true,
'el-statistic': true,
'el-icon': true,
'el-timeline': true,
'el-timeline-item': true,
},
},
})
expect(wrapper.vm.recentLogins).toEqual([])
})
it('should display empty state when no recent logins', () => {
wrapper = mount(Dashboard, {
global: {
plugins: [router],
stubs: {
'el-row': true,
'el-col': true,
'el-card': true,
'el-statistic': true,
'el-icon': true,
'el-timeline': true,
'el-timeline-item': true,
},
},
})
expect(wrapper.vm.recentLogins.length).toBe(0)
})
})
describe('data loading', () => {
it('should set loading to false after data loaded', async () => {
wrapper = mount(Dashboard, {
global: {
plugins: [router],
stubs: {
'el-row': true,
'el-col': true,
'el-card': true,
'el-statistic': true,
'el-icon': true,
'el-timeline': true,
'el-timeline-item': true,
},
},
})
expect(wrapper.vm.loading).toBe(true)
wrapper.vm.loading = false
await wrapper.vm.$nextTick()
expect(wrapper.vm.loading).toBe(false)
})
})
describe('document title', () => {
it('should have dashboard component mounted', () => {
wrapper = mount(Dashboard, {
global: {
plugins: [router],
stubs: {
'el-row': true,
'el-col': true,
'el-card': true,
'el-statistic': true,
'el-icon': true,
'el-timeline': true,
'el-timeline-item': true,
},
},
})
expect(wrapper.exists()).toBe(true)
})
})
})
@@ -1,286 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import DictManagement from '@/views/config/DictManagement.vue'
vi.mock('vue-router')
vi.mock('element-plus', () => ({
ElMessage: {
success: vi.fn(),
error: vi.fn(),
},
ElMessageBox: {
confirm: vi.fn(),
},
}))
vi.mock('@/utils/request', () => {
const mockRequest = {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
}
mockRequest.get.mockResolvedValue([])
mockRequest.post.mockResolvedValue({})
mockRequest.put.mockResolvedValue({})
mockRequest.delete.mockResolvedValue({})
return {
default: mockRequest,
}
})
describe('DictManagement Component', () => {
let router: any
let wrapper: any
beforeEach(() => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('component initialization', () => {
it('should render dict management container', () => {
wrapper = mount(DictManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(wrapper.find('.dict-management').exists()).toBe(true)
})
it('should initialize with empty data source', () => {
wrapper = mount(DictManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.dataSource).toBeDefined()
expect(Array.isArray(wrapper.vm.dataSource)).toBe(true)
})
it('should initialize with loading state false', () => {
wrapper = mount(DictManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.loading).toBeDefined()
expect(typeof wrapper.vm.loading).toBe('boolean')
})
it('should initialize with modal visible false', () => {
wrapper = mount(DictManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.modalVisible).toBe(false)
})
it('should initialize with empty form state', () => {
wrapper = mount(DictManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.formState.dictName).toBe('')
expect(wrapper.vm.formState.dictType).toBe('')
expect(wrapper.vm.formState.status).toBe('0')
expect(wrapper.vm.formState.remark).toBe('')
})
})
describe('add dict functionality', () => {
it('should have handleAdd method', () => {
wrapper = mount(DictManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleAdd).toBe('function')
})
})
describe('edit dict functionality', () => {
it('should have handleEdit method', () => {
wrapper = mount(DictManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleEdit).toBe('function')
})
})
describe('delete dict functionality', () => {
it('should have handleDelete method', () => {
wrapper = mount(DictManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleDelete).toBe('function')
})
})
describe('form submission', () => {
it('should have handleModalOk method', () => {
wrapper = mount(DictManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleModalOk).toBe('function')
})
})
})
@@ -1,257 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import ExceptionLog from '@/views/audit/ExceptionLog.vue'
vi.mock('vue-router')
vi.mock('@/api/exceptionLog', () => ({
exceptionLogApi: {
getPage: vi.fn().mockResolvedValue({
content: [
{ id: 1, username: 'admin', operation: '用户登录', method: 'POST /api/auth/login', errorMsg: 'NullPointerException', ip: '192.168.1.1', createTime: '2026-01-01T10:00:00' },
{ id: 2, username: 'user', operation: '文件上传', method: 'POST /api/files/upload', errorMsg: 'FileSizeLimitExceededException', ip: '192.168.1.2', createTime: '2026-01-02T11:00:00' },
],
totalElements: 2,
}),
},
}))
describe('ExceptionLog Component', () => {
let router: any
let wrapper: any
beforeEach(() => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('component initialization', () => {
it('should render exception log container', () => {
wrapper = mount(ExceptionLog, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-icon': true,
'el-pagination': true,
'el-dialog': true,
'el-descriptions': true,
'el-descriptions-item': true,
},
},
})
expect(wrapper.find('.exception-log').exists()).toBe(true)
})
it('should initialize with empty search keyword', () => {
wrapper = mount(ExceptionLog, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-icon': true,
'el-pagination': true,
'el-dialog': true,
'el-descriptions': true,
'el-descriptions-item': true,
},
},
})
expect(wrapper.vm.searchKeyword).toBe('')
})
it('should initialize with correct pagination defaults', () => {
wrapper = mount(ExceptionLog, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-icon': true,
'el-pagination': true,
'el-dialog': true,
'el-descriptions': true,
'el-descriptions-item': true,
},
},
})
expect(wrapper.vm.pagination.current).toBe(1)
expect(wrapper.vm.pagination.pageSize).toBe(10)
expect(wrapper.vm.pagination.total).toBe(0)
})
it('should initialize with hidden detail dialog', () => {
wrapper = mount(ExceptionLog, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-icon': true,
'el-pagination': true,
'el-dialog': true,
'el-descriptions': true,
'el-descriptions-item': true,
},
},
})
expect(wrapper.vm.detailVisible).toBe(false)
})
})
describe('detail view handling', () => {
beforeEach(() => {
wrapper = mount(ExceptionLog, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-icon': true,
'el-pagination': true,
'el-dialog': true,
'el-descriptions': true,
'el-descriptions-item': true,
},
},
})
})
it('should show detail dialog when viewing exception', () => {
const exception = {
id: 1,
username: 'admin',
operation: '用户登录',
method: 'POST /api/auth/login',
errorMsg: 'NullPointerException',
ip: '192.168.1.1',
createTime: '2026-01-01T10:00:00',
}
wrapper.vm.handleViewDetail(exception)
expect(wrapper.vm.detailVisible).toBe(true)
expect(wrapper.vm.currentDetail).toEqual(exception)
})
it('should create a copy of exception data for detail view', () => {
const exception = {
id: 1,
username: 'admin',
}
wrapper.vm.handleViewDetail(exception)
wrapper.vm.currentDetail.username = 'modified'
expect(exception.username).toBe('admin')
})
})
describe('sort handling', () => {
beforeEach(() => {
wrapper = mount(ExceptionLog, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-icon': true,
'el-pagination': true,
'el-dialog': true,
'el-descriptions': true,
'el-descriptions-item': true,
},
},
})
})
it('should update sort info on ascending order', () => {
wrapper.vm.handleSortChange({ prop: 'username', order: 'ascending' })
expect(wrapper.vm.sortInfo.sort).toBe('username')
expect(wrapper.vm.sortInfo.order).toBe('asc')
})
it('should update sort info on descending order', () => {
wrapper.vm.handleSortChange({ prop: 'createTime', order: 'descending' })
expect(wrapper.vm.sortInfo.sort).toBe('createTime')
expect(wrapper.vm.sortInfo.order).toBe('desc')
})
})
describe('pagination handling', () => {
beforeEach(() => {
wrapper = mount(ExceptionLog, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-icon': true,
'el-pagination': true,
'el-dialog': true,
'el-descriptions': true,
'el-descriptions-item': true,
},
},
})
})
it('should reset to first page on size change', () => {
wrapper.vm.pagination.current = 5
wrapper.vm.handleSizeChange()
expect(wrapper.vm.pagination.current).toBe(1)
})
it('should reset to first page on search', () => {
wrapper.vm.pagination.current = 5
wrapper.vm.handleSearch()
expect(wrapper.vm.pagination.current).toBe(1)
})
})
})
@@ -1,247 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import FileManagement from '@/views/file/FileManagement.vue'
vi.mock('vue-router')
vi.mock('element-plus', () => ({
ElMessage: {
success: vi.fn(),
error: vi.fn(),
},
ElMessageBox: {
confirm: vi.fn(),
},
}))
vi.mock('@/utils/request', () => {
const mockRequest = {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
}
mockRequest.get.mockResolvedValue([
{ id: 1, fileName: 'test.pdf', fileSize: 1024, fileType: 'application/pdf', storageType: 'local', createdAt: '2026-01-01', createBy: 'admin' },
{ id: 2, fileName: 'image.png', fileSize: 2048, fileType: 'image/png', storageType: 'local', createdAt: '2026-01-02', createBy: 'user' },
])
mockRequest.post.mockResolvedValue({})
mockRequest.put.mockResolvedValue({})
mockRequest.delete.mockResolvedValue({})
return {
default: mockRequest,
}
})
describe('FileManagement Component', () => {
let router: any
let wrapper: any
beforeEach(() => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('component initialization', () => {
it('should render file management container', () => {
wrapper = mount(FileManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-upload': true,
'el-tag': true,
'el-icon': true,
},
},
})
expect(wrapper.find('.file-management').exists()).toBe(true)
})
it('should initialize with empty search keyword', () => {
wrapper = mount(FileManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-upload': true,
'el-tag': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.searchKeyword).toBe('')
})
it('should initialize with loading state false before data fetch', async () => {
wrapper = mount(FileManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-upload': true,
'el-tag': true,
'el-icon': true,
},
},
})
await wrapper.vm.$nextTick()
expect([true, false]).toContain(wrapper.vm.loading)
})
})
describe('file type utilities', () => {
beforeEach(() => {
wrapper = mount(FileManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-upload': true,
'el-tag': true,
'el-icon': true,
},
},
})
})
it('should return correct file type name for images', () => {
expect(wrapper.vm.getFileTypeName('image/png')).toBe('图片')
expect(wrapper.vm.getFileTypeName('image/jpeg')).toBe('图片')
})
it('should return correct file type name for videos', () => {
expect(wrapper.vm.getFileTypeName('video/mp4')).toBe('视频')
})
it('should return correct file type name for audio', () => {
expect(wrapper.vm.getFileTypeName('audio/mp3')).toBe('音频')
})
it('should return correct file type name for PDF', () => {
expect(wrapper.vm.getFileTypeName('application/pdf')).toBe('PDF')
})
it('should return correct file type name for Word', () => {
expect(wrapper.vm.getFileTypeName('application/vnd.openxmlformats-officedocument.wordprocessingml.document')).toBe('Word')
})
it('should return correct file type name for Excel', () => {
expect(wrapper.vm.getFileTypeName('application/vnd.ms-excel')).toBe('Excel')
})
it('should return unknown for unknown file types', () => {
expect(wrapper.vm.getFileTypeName('')).toBe('未知')
expect(wrapper.vm.getFileTypeName('unknown/type')).toBe('其他')
})
it('should return correct tag type for images', () => {
expect(wrapper.vm.getFileTypeTag('image/png')).toBe('success')
})
it('should return correct tag type for videos', () => {
expect(wrapper.vm.getFileTypeTag('video/mp4')).toBe('danger')
})
it('should return correct tag type for audio', () => {
expect(wrapper.vm.getFileTypeTag('audio/mp3')).toBe('warning')
})
it('should return correct tag type for PDF', () => {
expect(wrapper.vm.getFileTypeTag('application/pdf')).toBe('danger')
})
it('should return correct tag type for unknown', () => {
expect(wrapper.vm.getFileTypeTag('')).toBe('info')
})
})
describe('search functionality', () => {
beforeEach(() => {
wrapper = mount(FileManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-upload': true,
'el-tag': true,
'el-icon': true,
},
},
})
})
it('should filter files by search keyword', async () => {
wrapper.vm.dataSource = [
{ id: 1, fileName: 'test.pdf' },
{ id: 2, fileName: 'image.png' },
{ id: 3, fileName: 'document.doc' },
]
wrapper.vm.searchKeyword = 'test'
await wrapper.vm.$nextTick()
expect(wrapper.vm.filteredDataSource.length).toBe(1)
expect(wrapper.vm.filteredDataSource[0].fileName).toBe('test.pdf')
})
it('should return all files when search keyword is empty', () => {
wrapper.vm.dataSource = [
{ id: 1, fileName: 'test.pdf' },
{ id: 2, fileName: 'image.png' },
]
wrapper.vm.searchKeyword = ''
expect(wrapper.vm.filteredDataSource.length).toBe(2)
})
it('should be case insensitive when searching', () => {
wrapper.vm.dataSource = [
{ id: 1, fileName: 'TEST.pdf' },
{ id: 2, fileName: 'image.png' },
]
wrapper.vm.searchKeyword = 'test'
expect(wrapper.vm.filteredDataSource.length).toBe(1)
})
})
})
@@ -1,186 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia, setActivePinia } from 'pinia'
import Login from '@/views/system/Login.vue'
vi.mock('vue-router')
vi.mock('element-plus', () => ({
ElMessage: {
success: vi.fn(),
error: vi.fn(),
},
}))
vi.mock('@/utils/request', () => ({
default: {
post: vi.fn(),
},
}))
describe('Login Component', () => {
let router: any
let wrapper: any
let pinia: any
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Dashboard</div>' } },
{ path: '/login', component: { template: '<div>Login</div>' } },
],
})
vi.clearAllMocks()
localStorage.clear()
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('component rendering', () => {
it('should render login form', () => {
wrapper = mount(Login, {
global: {
plugins: [router, pinia],
stubs: {
'el-card': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-button': true,
},
},
})
expect(wrapper.find('.login-container').exists()).toBe(true)
expect(wrapper.find('.login-card').exists()).toBe(true)
})
it('should initialize with empty form state', () => {
wrapper = mount(Login, {
global: {
plugins: [router, pinia],
stubs: {
'el-card': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-button': true,
},
},
})
expect(wrapper.vm.formState.username).toBe('')
expect(wrapper.vm.formState.password).toBe('')
})
it('should initialize loading as false', () => {
wrapper = mount(Login, {
global: {
plugins: [router, pinia],
stubs: {
'el-card': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-button': true,
},
},
})
expect(wrapper.vm.loading).toBe(false)
})
})
describe('form state management', () => {
it('should update username when input changes', async () => {
wrapper = mount(Login, {
global: {
plugins: [router, pinia],
stubs: {
'el-card': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-button': true,
},
},
})
wrapper.vm.formState.username = 'testuser'
await wrapper.vm.$nextTick()
expect(wrapper.vm.formState.username).toBe('testuser')
})
it('should update password when input changes', async () => {
wrapper = mount(Login, {
global: {
plugins: [router, pinia],
stubs: {
'el-card': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-button': true,
},
},
})
wrapper.vm.formState.password = 'password123'
await wrapper.vm.$nextTick()
expect(wrapper.vm.formState.password).toBe('password123')
})
})
describe('form submission', () => {
it('should have onFinish method', () => {
wrapper = mount(Login, {
global: {
plugins: [router, pinia],
stubs: {
'el-card': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-button': true,
},
},
})
expect(typeof wrapper.vm.onFinish).toBe('function')
})
})
describe('document title', () => {
it('should set document title on mount', () => {
const originalTitle = document.title
wrapper = mount(Login, {
global: {
plugins: [router, pinia],
stubs: {
'el-card': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-button': true,
},
},
})
expect(document.title).toBe('登录 - Novalon 管理系统')
document.title = originalTitle
})
})
})
@@ -1,195 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import LoginLog from '@/views/audit/LoginLog.vue'
vi.mock('vue-router')
vi.mock('@/utils/request', () => {
const mockRequest = {
get: vi.fn().mockResolvedValue({
content: [
{ id: 1, username: 'admin', ip: '192.168.1.1', location: '北京', browser: 'Chrome', os: 'Windows', status: '0', loginTime: '2026-01-01T10:00:00' },
{ id: 2, username: 'user', ip: '192.168.1.2', location: '上海', browser: 'Firefox', os: 'MacOS', status: '1', loginTime: '2026-01-02T11:00:00' },
],
totalElements: 2,
}),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
}
return {
default: mockRequest,
}
})
describe('LoginLog Component', () => {
let router: any
let wrapper: any
beforeEach(() => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('component initialization', () => {
it('should render login log container', () => {
wrapper = mount(LoginLog, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-icon': true,
'el-pagination': true,
},
},
})
expect(wrapper.find('.login-log').exists()).toBe(true)
})
it('should initialize with empty search keyword', () => {
wrapper = mount(LoginLog, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-icon': true,
'el-pagination': true,
},
},
})
expect(wrapper.vm.searchKeyword).toBe('')
})
it('should initialize with correct pagination defaults', () => {
wrapper = mount(LoginLog, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-icon': true,
'el-pagination': true,
},
},
})
expect(wrapper.vm.pagination.current).toBe(1)
expect(wrapper.vm.pagination.pageSize).toBe(10)
expect(wrapper.vm.pagination.total).toBe(0)
})
it('should initialize with correct sort defaults', () => {
wrapper = mount(LoginLog, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-icon': true,
'el-pagination': true,
},
},
})
expect(wrapper.vm.sortInfo.sort).toBe('id')
expect(wrapper.vm.sortInfo.order).toBe('asc')
})
})
describe('sort handling', () => {
beforeEach(() => {
wrapper = mount(LoginLog, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-icon': true,
'el-pagination': true,
},
},
})
})
it('should update sort info on ascending order', () => {
wrapper.vm.handleSortChange({ prop: 'username', order: 'ascending' })
expect(wrapper.vm.sortInfo.sort).toBe('username')
expect(wrapper.vm.sortInfo.order).toBe('asc')
})
it('should update sort info on descending order', () => {
wrapper.vm.handleSortChange({ prop: 'loginTime', order: 'descending' })
expect(wrapper.vm.sortInfo.sort).toBe('loginTime')
expect(wrapper.vm.sortInfo.order).toBe('desc')
})
})
describe('pagination handling', () => {
beforeEach(() => {
wrapper = mount(LoginLog, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-icon': true,
'el-pagination': true,
},
},
})
})
it('should reset to first page on size change', () => {
wrapper.vm.pagination.current = 5
wrapper.vm.handleSizeChange()
expect(wrapper.vm.pagination.current).toBe(1)
})
it('should reset to first page on search', () => {
wrapper.vm.pagination.current = 5
wrapper.vm.handleSearch()
expect(wrapper.vm.pagination.current).toBe(1)
})
})
})
@@ -1,72 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import MenuItem from '@/components/MenuItem.vue'
describe('MenuItem 组件', () => {
it('应该正确接收菜单项 props', () => {
const menu = {
id: '1',
name: '仪表盘',
path: '/dashboard',
icon: 'Odometer',
sort: 1
}
const wrapper = mount(MenuItem, {
props: { menu },
global: {
stubs: {
'el-menu-item': {
template: '<div><slot /></div>'
},
'el-sub-menu': {
template: '<div><slot name="title" /><slot /></div>'
},
'el-icon': {
template: '<div><slot /></div>'
}
}
}
})
expect(wrapper.props('menu')).toEqual(menu)
})
it('应该正确处理有子菜单的菜单项', () => {
const menu = {
id: '2',
name: '系统管理',
path: '/system',
icon: 'Setting',
sort: 2,
children: [
{
id: '3',
name: '用户管理',
path: '/users',
sort: 1
}
]
}
const wrapper = mount(MenuItem, {
props: { menu },
global: {
stubs: {
'el-menu-item': {
template: '<div><slot /></div>'
},
'el-sub-menu': {
template: '<div><slot name="title" /><slot /></div>'
},
'el-icon': {
template: '<div><slot /></div>'
}
}
}
})
expect(wrapper.props('menu')).toEqual(menu)
expect(wrapper.props('menu').children).toHaveLength(1)
})
})
@@ -1,279 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import MenuManagement from '@/views/system/MenuManagement.vue'
vi.mock('vue-router')
vi.mock('element-plus', () => ({
ElMessage: {
success: vi.fn(),
error: vi.fn(),
},
ElMessageBox: {
confirm: vi.fn(),
},
}))
vi.mock('@/api/menu.api', () => ({
menuApi: {
getAll: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}))
vi.mock('@/utils/request', () => {
const mockRequest = {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
}
mockRequest.get.mockResolvedValue([])
mockRequest.post.mockResolvedValue({})
mockRequest.put.mockResolvedValue({})
mockRequest.delete.mockResolvedValue({})
return {
default: mockRequest,
}
})
describe('MenuManagement Component', () => {
let router: any
let wrapper: any
beforeEach(() => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('component initialization', () => {
it('should render menu management container', () => {
wrapper = mount(MenuManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-input-number': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(wrapper.find('.menu-management').exists()).toBe(true)
})
it('should initialize with empty data source', () => {
wrapper = mount(MenuManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-input-number': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.dataSource).toBeDefined()
expect(Array.isArray(wrapper.vm.dataSource)).toBe(true)
})
it('should initialize with loading state false', () => {
wrapper = mount(MenuManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-input-number': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.loading).toBeDefined()
expect(typeof wrapper.vm.loading).toBe('boolean')
})
it('should initialize with modal visible false', () => {
wrapper = mount(MenuManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-input-number': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.modalVisible).toBe(false)
})
it('should initialize with empty form state', () => {
wrapper = mount(MenuManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-input-number': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.formState.menuName).toBe('')
expect(wrapper.vm.formState.menuType).toBe('C')
expect(wrapper.vm.formState.perms).toBe('')
expect(wrapper.vm.formState.component).toBe('')
expect(wrapper.vm.formState.orderNum).toBe(0)
expect(wrapper.vm.formState.status).toBe('0')
})
})
describe('add menu functionality', () => {
it('should have handleAdd method', () => {
wrapper = mount(MenuManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-input-number': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleAdd).toBe('function')
})
})
describe('edit menu functionality', () => {
it('should have handleEdit method', () => {
wrapper = mount(MenuManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-input-number': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleEdit).toBe('function')
})
})
describe('delete menu functionality', () => {
it('should have handleDelete method', () => {
wrapper = mount(MenuManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-input-number': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleDelete).toBe('function')
})
})
})
@@ -1,231 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import NoticeManagement from '@/views/notify/NoticeManagement.vue'
vi.mock('vue-router')
vi.mock('element-plus', () => ({
ElMessage: {
success: vi.fn(),
error: vi.fn(),
},
ElMessageBox: {
confirm: vi.fn(),
},
}))
vi.mock('@/utils/request', () => {
const mockRequest = {
get: vi.fn().mockResolvedValue([
{ id: 1, noticeTitle: '系统维护通知', noticeType: '1', noticeContent: '系统将于今晚维护', status: '0', createdAt: '2026-01-01T10:00:00' },
{ id: 2, noticeTitle: '新功能上线', noticeType: '2', noticeContent: '新功能已上线', status: '0', createdAt: '2026-01-02T11:00:00' },
]),
post: vi.fn().mockResolvedValue({}),
put: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
}
return {
default: mockRequest,
}
})
describe('NoticeManagement Component', () => {
let router: any
let wrapper: any
beforeEach(() => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('component initialization', () => {
it('should render notice management container', () => {
wrapper = mount(NoticeManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
},
},
})
expect(wrapper.find('.notice-management').exists()).toBe(true)
})
it('should initialize with hidden modal', () => {
wrapper = mount(NoticeManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
},
},
})
expect(wrapper.vm.modalVisible).toBe(false)
})
it('should initialize with empty form state', () => {
wrapper = mount(NoticeManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
},
},
})
expect(wrapper.vm.formState.noticeTitle).toBe('')
expect(wrapper.vm.formState.noticeType).toBe('1')
expect(wrapper.vm.formState.status).toBe('0')
})
})
describe('add notice', () => {
beforeEach(() => {
wrapper = mount(NoticeManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
},
},
})
})
it('should show modal with add title', () => {
wrapper.vm.handleAdd()
expect(wrapper.vm.modalTitle).toBe('新增公告')
expect(wrapper.vm.modalVisible).toBe(true)
})
it('should reset form state when adding', () => {
wrapper.vm.formState.noticeTitle = 'existing title'
wrapper.vm.handleAdd()
expect(wrapper.vm.formState.noticeTitle).toBe('')
expect(wrapper.vm.formState.id).toBe(null)
})
})
describe('edit notice', () => {
beforeEach(() => {
wrapper = mount(NoticeManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
},
},
})
})
it('should show modal with edit title', () => {
const notice = { id: 1, noticeTitle: 'Test', noticeType: '1', noticeContent: 'Content', status: '0' }
wrapper.vm.handleEdit(notice)
expect(wrapper.vm.modalTitle).toBe('编辑公告')
expect(wrapper.vm.modalVisible).toBe(true)
})
it('should populate form with notice data', () => {
const notice = { id: 1, noticeTitle: 'Test Notice', noticeType: '2', noticeContent: 'Test Content', status: '1' }
wrapper.vm.handleEdit(notice)
expect(wrapper.vm.formState.id).toBe(1)
expect(wrapper.vm.formState.noticeTitle).toBe('Test Notice')
expect(wrapper.vm.formState.noticeType).toBe('2')
})
})
describe('form state', () => {
beforeEach(() => {
wrapper = mount(NoticeManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
},
},
})
})
it('should have default notice type as notification', () => {
expect(wrapper.vm.formState.noticeType).toBe('1')
})
it('should have default status as normal', () => {
expect(wrapper.vm.formState.status).toBe('0')
})
})
})
@@ -1,216 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import OperationLog from '@/views/audit/OperationLog.vue'
vi.mock('vue-router')
vi.mock('@/api/operationLog', () => ({
operationLogApi: {
getPage: vi.fn().mockResolvedValue({
content: [
{ id: 1, username: 'admin', operation: '用户登录', method: 'POST', params: '{}', status: '0', duration: 100, createdAt: '2026-01-01T10:00:00' },
{ id: 2, username: 'user', operation: '查看用户', method: 'GET', params: '{"id":1}', status: '0', duration: 50, createdAt: '2026-01-02T11:00:00' },
],
totalElements: 2,
}),
},
}))
describe('OperationLog Component', () => {
let router: any
let wrapper: any
beforeEach(() => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('component initialization', () => {
it('should render operation log container', () => {
wrapper = mount(OperationLog, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-icon': true,
'el-popover': true,
'el-pagination': true,
},
},
})
expect(wrapper.find('.operation-log').exists()).toBe(true)
})
it('should initialize with empty search keyword', () => {
wrapper = mount(OperationLog, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-icon': true,
'el-popover': true,
'el-pagination': true,
},
},
})
expect(wrapper.vm.searchKeyword).toBe('')
})
it('should initialize with correct pagination defaults', () => {
wrapper = mount(OperationLog, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-icon': true,
'el-popover': true,
'el-pagination': true,
},
},
})
expect(wrapper.vm.pagination.current).toBe(1)
expect(wrapper.vm.pagination.pageSize).toBe(10)
expect(wrapper.vm.pagination.total).toBe(0)
})
})
describe('operation icon mapping', () => {
beforeEach(() => {
wrapper = mount(OperationLog, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-icon': true,
'el-popover': true,
'el-pagination': true,
},
},
})
})
it('should return User icon for login operations', () => {
const icon = wrapper.vm.getOperationIcon('用户登录')
expect(icon.name).toBe('User')
})
it('should return Delete icon for delete operations', () => {
const icon = wrapper.vm.getOperationIcon('删除用户')
expect(icon.name).toBe('Delete')
})
it('should return Edit icon for update operations', () => {
const icon = wrapper.vm.getOperationIcon('编辑用户')
expect(icon.name).toBe('Edit')
})
it('should return View icon for view operations', () => {
const icon = wrapper.vm.getOperationIcon('查看用户')
expect(icon.name).toBe('View')
})
it('should return Plus icon for create operations', () => {
const icon = wrapper.vm.getOperationIcon('新增用户')
expect(icon.name).toBe('Plus')
})
it('should return Download icon for download operations', () => {
const icon = wrapper.vm.getOperationIcon('下载文件')
expect(icon.name).toBe('Download')
})
it('should return Setting icon for config operations', () => {
const icon = wrapper.vm.getOperationIcon('系统设置')
expect(icon.name).toBe('Setting')
})
it('should return Lock icon for password operations', () => {
const icon = wrapper.vm.getOperationIcon('重置密码')
expect(icon.name).toBe('Lock')
})
it('should return Document icon for unknown operations', () => {
const icon = wrapper.vm.getOperationIcon('未知操作')
expect(icon.name).toBe('Document')
})
})
describe('params formatting', () => {
beforeEach(() => {
wrapper = mount(OperationLog, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-input': true,
'el-tag': true,
'el-icon': true,
'el-popover': true,
'el-pagination': true,
},
},
})
})
it('should format valid JSON params', () => {
const params = '{"name":"test","id":1}'
const formatted = wrapper.vm.formatParams(params)
expect(formatted).toContain('name')
expect(formatted).toContain('test')
})
it('should return empty string for null params', () => {
const formatted = wrapper.vm.formatParams(null)
expect(formatted).toBe('')
})
it('should return empty string for undefined params', () => {
const formatted = wrapper.vm.formatParams(undefined)
expect(formatted).toBe('')
})
it('should return original string for invalid JSON', () => {
const params = 'not a json'
const formatted = wrapper.vm.formatParams(params)
expect(formatted).toBe('not a json')
})
})
})
@@ -1,383 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import RoleManagement from '@/views/system/RoleManagement.vue'
vi.mock('vue-router')
vi.mock('element-plus', () => ({
ElMessage: {
success: vi.fn(),
error: vi.fn(),
},
ElMessageBox: {
confirm: vi.fn(),
},
}))
vi.mock('@/api/role.api', () => ({
roleApi: {
getPage: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getAll: vi.fn(),
},
}))
vi.mock('@/api/permission.api', () => ({
permissionApi: {
getAll: vi.fn(),
},
}))
describe('RoleManagement Component', () => {
let router: any
let wrapper: any
beforeEach(() => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('component initialization', () => {
it('should render role management container', () => {
wrapper = mount(RoleManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-tree': true,
'el-icon': true,
},
},
})
expect(wrapper.find('.role-management').exists()).toBe(true)
})
it('should initialize with empty search keyword', () => {
wrapper = mount(RoleManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-tree': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.searchKeyword).toBe('')
})
it('should initialize with empty data source', () => {
wrapper = mount(RoleManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-tree': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.dataSource).toEqual([])
})
it('should initialize with pagination on page 1', () => {
wrapper = mount(RoleManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-tree': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.pagination.current).toBe(1)
})
it('should initialize with modal visible false', () => {
wrapper = mount(RoleManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-tree': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.modalVisible).toBe(false)
})
it('should initialize with empty form state', () => {
wrapper = mount(RoleManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-tree': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.formState.roleName).toBe('')
expect(wrapper.vm.formState.roleKey).toBe('')
expect(wrapper.vm.formState.roleSort).toBe(1)
expect(wrapper.vm.formState.status).toBe(1)
expect(wrapper.vm.formState.permissions).toEqual([])
})
})
describe('search functionality', () => {
it('should have handleSearch method', () => {
wrapper = mount(RoleManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-tree': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleSearch).toBe('function')
})
it('should update search keyword when input changes', async () => {
wrapper = mount(RoleManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-tree': true,
'el-icon': true,
},
},
})
wrapper.vm.searchKeyword = 'admin'
await wrapper.vm.$nextTick()
expect(wrapper.vm.searchKeyword).toBe('admin')
})
})
describe('add role functionality', () => {
it('should have handleAdd method', () => {
wrapper = mount(RoleManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-tree': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleAdd).toBe('function')
})
})
describe('pagination functionality', () => {
it('should have handleTableChange method', () => {
wrapper = mount(RoleManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-tree': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleTableChange).toBe('function')
})
it('should have handleSizeChange method', () => {
wrapper = mount(RoleManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-tree': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleSizeChange).toBe('function')
})
})
describe('sort functionality', () => {
it('should have handleSortChange method', () => {
wrapper = mount(RoleManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-tree': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleSortChange).toBe('function')
})
it('should initialize with default sort info', () => {
wrapper = mount(RoleManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-tree': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.sortInfo.sortBy).toBe('id')
expect(wrapper.vm.sortInfo.sortOrder).toBe('asc')
})
})
})
@@ -1,423 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import UserManagement from '@/views/system/UserManagement.vue'
vi.mock('vue-router')
vi.mock('element-plus', () => ({
ElMessage: {
success: vi.fn(),
error: vi.fn(),
},
ElMessageBox: {
confirm: vi.fn(),
},
}))
vi.mock('@/api/user.api', () => ({
userApi: {
getPage: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
assignRoles: vi.fn(),
},
}))
vi.mock('@/api/role.api', () => ({
roleApi: {
getAll: vi.fn(),
},
}))
describe('UserManagement Component', () => {
let router: any
let wrapper: any
beforeEach(() => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('component initialization', () => {
it('should render user management container', () => {
wrapper = mount(UserManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(wrapper.find('.user-management').exists()).toBe(true)
})
it('should initialize with empty search keyword', () => {
wrapper = mount(UserManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.searchKeyword).toBe('')
})
it('should initialize with loading state false before data fetch', () => {
wrapper = mount(UserManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.loading).toBeDefined()
expect(typeof wrapper.vm.loading).toBe('boolean')
})
it('should initialize with empty data source', () => {
wrapper = mount(UserManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.dataSource).toEqual([])
})
it('should initialize with pagination on page 1', () => {
wrapper = mount(UserManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.pagination.current).toBe(1)
})
it('should initialize with modal visible false', () => {
wrapper = mount(UserManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.modalVisible).toBe(false)
})
it('should initialize with empty form state', () => {
wrapper = mount(UserManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.formState.username).toBe('')
expect(wrapper.vm.formState.password).toBe('')
expect(wrapper.vm.formState.nickname).toBe('')
expect(wrapper.vm.formState.email).toBe('')
expect(wrapper.vm.formState.phone).toBe('')
expect(wrapper.vm.formState.roles).toEqual([])
})
})
describe('search functionality', () => {
it('should have handleSearch method', () => {
wrapper = mount(UserManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleSearch).toBe('function')
})
it('should update search keyword when input changes', async () => {
wrapper = mount(UserManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
wrapper.vm.searchKeyword = 'testuser'
await wrapper.vm.$nextTick()
expect(wrapper.vm.searchKeyword).toBe('testuser')
})
})
describe('add user functionality', () => {
it('should have handleAdd method', () => {
wrapper = mount(UserManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleAdd).toBe('function')
})
})
describe('pagination functionality', () => {
it('should have handleTableChange method', () => {
wrapper = mount(UserManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleTableChange).toBe('function')
})
it('should have handleSizeChange method', () => {
wrapper = mount(UserManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleSizeChange).toBe('function')
})
})
describe('sort functionality', () => {
it('should have handleSortChange method', () => {
wrapper = mount(UserManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleSortChange).toBe('function')
})
it('should initialize with default sort info', () => {
wrapper = mount(UserManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-input': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-pagination': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.sortInfo.sortBy).toBe('id')
expect(wrapper.vm.sortInfo.sortOrder).toBe('asc')
})
})
})
@@ -1,12 +0,0 @@
import { describe, it, expect } from 'vitest'
describe('Vitest Configuration Test', () => {
it('should run a simple test', () => {
expect(1 + 1).toBe(2)
})
it('should handle async operations', async () => {
const result = await Promise.resolve(42)
expect(result).toBe(42)
})
})
@@ -1,124 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { permissionDirective } from '@/directives/permission'
import { usePermissionStore } from '@/stores/permission'
describe('v-permission 指令', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
describe('角色检查', () => {
it('有角色时应该显示元素', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: ['admin'],
permissions: [],
menus: []
})
const wrapper = mount({
template: '<button v-permission:role="\'admin\'">管理员按钮</button>',
directives: {
permission: permissionDirective
}
})
expect(wrapper.find('button').isVisible()).toBe(true)
})
it('无角色时应该隐藏元素', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: ['user'],
permissions: [],
menus: []
})
const wrapper = mount({
template: '<button v-permission:role="\'admin\'">管理员按钮</button>',
directives: {
permission: permissionDirective
}
})
expect(wrapper.find('button').isVisible()).toBe(false)
})
it('支持数组参数(满足任一即可)', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: ['user'],
permissions: [],
menus: []
})
const wrapper = mount({
template: '<button v-permission:role="[\'admin\', \'user\']">按钮</button>',
directives: {
permission: permissionDirective
}
})
expect(wrapper.find('button').isVisible()).toBe(true)
})
})
describe('权限检查', () => {
it('有权限时应该显示元素', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: [],
permissions: ['user:delete'],
menus: []
})
const wrapper = mount({
template: '<button v-permission:permission="\'user:delete\'">删除用户</button>',
directives: {
permission: permissionDirective
}
})
expect(wrapper.find('button').isVisible()).toBe(true)
})
it('无权限时应该隐藏元素', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: [],
permissions: ['user:read'],
menus: []
})
const wrapper = mount({
template: '<button v-permission:permission="\'user:delete\'">删除用户</button>',
directives: {
permission: permissionDirective
}
})
expect(wrapper.find('button').isVisible()).toBe(false)
})
it('支持简写形式(默认权限检查)', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: [],
permissions: ['user:create'],
menus: []
})
const wrapper = mount({
template: '<button v-permission="\'user:create\'">创建用户</button>',
directives: {
permission: permissionDirective
}
})
expect(wrapper.find('button').isVisible()).toBe(true)
})
})
})
@@ -1,88 +0,0 @@
export const mockUser = {
id: 1,
username: 'testuser',
nickname: 'Test User',
email: 'test@example.com',
phone: '13800138000',
avatar: 'https://example.com/avatar.jpg',
roles: ['admin'],
permissions: ['user:view', 'user:create', 'user:edit', 'user:delete'],
}
export const mockRole = {
id: 1,
roleName: '测试角色',
roleKey: 'test_role',
roleSort: 1,
status: '1',
remark: '测试角色备注',
createTime: new Date().toISOString(),
updateTime: new Date().toISOString(),
}
export const mockMenu = {
id: 1,
menuName: '系统管理',
parentId: 0,
orderNum: 1,
menuType: 'M',
component: 'system',
perms: 'system:view',
status: '1',
createTime: new Date().toISOString(),
updateTime: new Date().toISOString(),
}
export const mockDict = {
id: 1,
dictName: '用户状态',
dictType: 'user_status',
status: '1',
remark: '用户状态字典',
createTime: new Date().toISOString(),
updateTime: new Date().toISOString(),
}
export const mockConfig = {
id: 1,
configName: '系统名称',
configKey: 'sys.name',
configValue: 'Novalon管理系统',
configType: 'Y',
status: '1',
remark: '系统名称配置',
createTime: new Date().toISOString(),
updateTime: new Date().toISOString(),
}
export const mockNotice = {
id: 1,
noticeTitle: '系统通知',
noticeType: '1',
noticeContent: '这是一条测试通知',
status: '0',
createTime: new Date().toISOString(),
updateTime: new Date().toISOString(),
}
export const mockLoginRequest = {
username: 'admin',
password: 'admin123',
}
export const mockLoginResponse = {
token: 'mock-jwt-token',
user: mockUser,
}
export const mockApiResponse = <T>(data: T, code = 200, message = 'success') => ({
code,
message,
data,
})
export const mockErrorResponse = (code = 500, message = 'Internal Server Error') => ({
code,
message,
data: null,
})
@@ -1,291 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const mockLocalStorage = {
store: {} as Record<string, string>,
getItem(key: string) {
return this.store[key] || null
},
setItem(key: string, value: string) {
this.store[key] = value
},
removeItem(key: string) {
delete this.store[key]
},
clear() {
this.store = {}
}
}
Object.defineProperty(window, 'localStorage', {
value: mockLocalStorage
})
const createTestRouter = (routes: RouteRecordRaw[]) => {
return createRouter({
history: createWebHistory(),
routes
})
}
describe('路由守卫权限检查', () => {
beforeEach(() => {
mockLocalStorage.clear()
})
describe('基础认证检查', () => {
it('未登录用户访问受保护路由应重定向到登录页', async () => {
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: { template: '<div>Login</div>' }
},
{
path: '/',
component: { template: '<div>Layout</div>' },
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: { template: '<div>Dashboard</div>' }
}
]
}
]
const router = createTestRouter(routes)
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next('/login')
} else {
next()
}
})
await router.push('/dashboard')
expect(router.currentRoute.value.path).toBe('/login')
})
it('已登录用户访问受保护路由应允许通过', async () => {
mockLocalStorage.setItem('token', 'valid-token')
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: { template: '<div>Login</div>' }
},
{
path: '/',
component: { template: '<div>Layout</div>' },
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: { template: '<div>Dashboard</div>' }
}
]
}
]
const router = createTestRouter(routes)
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next('/login')
} else {
next()
}
})
await router.push('/dashboard')
expect(router.currentRoute.value.path).toBe('/dashboard')
})
})
describe('角色权限检查', () => {
it('普通用户访问管理员路由应重定向到403页面', async () => {
mockLocalStorage.setItem('token', 'valid-token')
mockLocalStorage.setItem('roles', JSON.stringify(['user']))
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: { template: '<div>Login</div>' }
},
{
path: '/403',
name: 'Forbidden',
component: { template: '<div>403 Forbidden</div>' }
},
{
path: '/',
component: { template: '<div>Layout</div>' },
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: { template: '<div>Dashboard</div>' }
},
{
path: 'users',
name: 'UserManagement',
component: { template: '<div>UserManagement</div>' },
meta: { roles: ['admin'] }
}
]
}
]
const router = createTestRouter(routes)
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
const rolesStr = localStorage.getItem('roles')
const userRoles = rolesStr ? JSON.parse(rolesStr) : []
if (to.meta.requiresAuth && !token) {
next('/login')
return
}
if (to.meta.roles && Array.isArray(to.meta.roles)) {
const hasRole = to.meta.roles.some((role: string) => userRoles.includes(role))
if (!hasRole) {
next('/403')
return
}
}
next()
})
await router.push('/users')
expect(router.currentRoute.value.path).toBe('/403')
})
it('管理员用户访问管理员路由应允许通过', async () => {
mockLocalStorage.setItem('token', 'valid-token')
mockLocalStorage.setItem('roles', JSON.stringify(['admin']))
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: { template: '<div>Login</div>' }
},
{
path: '/403',
name: 'Forbidden',
component: { template: '<div>403 Forbidden</div>' }
},
{
path: '/',
component: { template: '<div>Layout</div>' },
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: { template: '<div>Dashboard</div>' }
},
{
path: 'users',
name: 'UserManagement',
component: { template: '<div>UserManagement</div>' },
meta: { roles: ['admin'] }
}
]
}
]
const router = createTestRouter(routes)
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
const rolesStr = localStorage.getItem('roles')
const userRoles = rolesStr ? JSON.parse(rolesStr) : []
if (to.meta.requiresAuth && !token) {
next('/login')
return
}
if (to.meta.roles && Array.isArray(to.meta.roles)) {
const hasRole = to.meta.roles.some((role: string) => userRoles.includes(role))
if (!hasRole) {
next('/403')
return
}
}
next()
})
await router.push('/users')
expect(router.currentRoute.value.path).toBe('/users')
})
it('无角色要求的路由所有登录用户都可访问', async () => {
mockLocalStorage.setItem('token', 'valid-token')
mockLocalStorage.setItem('roles', JSON.stringify(['user']))
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: { template: '<div>Login</div>' }
},
{
path: '/',
component: { template: '<div>Layout</div>' },
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: { template: '<div>Dashboard</div>' }
}
]
}
]
const router = createTestRouter(routes)
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
const rolesStr = localStorage.getItem('roles')
const userRoles = rolesStr ? JSON.parse(rolesStr) : []
if (to.meta.requiresAuth && !token) {
next('/login')
return
}
if (to.meta.roles && Array.isArray(to.meta.roles)) {
const hasRole = to.meta.roles.some((role: string) => userRoles.includes(role))
if (!hasRole) {
next('/403')
return
}
}
next()
})
await router.push('/dashboard')
expect(router.currentRoute.value.path).toBe('/dashboard')
})
})
})
-61
View File
@@ -1,61 +0,0 @@
import { vi } from 'vitest'
import { config } from '@vue/test-utils'
config.global.stubs = {
transition: false,
'transition-group': false,
}
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
store = {}
}),
}
})()
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
})
const sessionStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
store = {}
}),
}
})()
Object.defineProperty(window, 'sessionStorage', {
value: sessionStorageMock,
})
@@ -1,167 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { usePermissionStore } from '@/stores/permission'
describe('Permission Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
describe('基础功能', () => {
it('应该正确初始化状态', () => {
const store = usePermissionStore()
expect(store.roles).toEqual([])
expect(store.permissions).toEqual([])
expect(store.menus).toEqual([])
expect(store.loaded).toBe(false)
})
it('应该正确设置权限数据', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: ['admin'],
permissions: ['user:read', 'user:delete'],
menus: [
{
id: '1',
name: '仪表盘',
path: '/dashboard',
icon: 'Odometer',
sort: 1
}
]
})
expect(store.roles).toEqual(['admin'])
expect(store.permissions).toEqual(['user:read', 'user:delete'])
expect(store.menus).toHaveLength(1)
expect(store.loaded).toBe(true)
})
it('应该正确清除权限数据', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: ['admin'],
permissions: ['user:read'],
menus: []
})
store.clearPermissionData()
expect(store.roles).toEqual([])
expect(store.permissions).toEqual([])
expect(store.menus).toEqual([])
expect(store.loaded).toBe(false)
})
})
describe('权限检查方法', () => {
it('应该正确检查单个角色', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: ['admin', 'user'],
permissions: [],
menus: []
})
expect(store.hasRole('admin')).toBe(true)
expect(store.hasRole('manager')).toBe(false)
})
it('应该正确检查多个角色(满足任一即可)', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: ['user'],
permissions: [],
menus: []
})
expect(store.hasRole(['admin', 'user'])).toBe(true)
expect(store.hasRole(['admin', 'manager'])).toBe(false)
})
it('应该正确检查单个权限', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: [],
permissions: ['user:read', 'user:delete'],
menus: []
})
expect(store.hasPermission('user:read')).toBe(true)
expect(store.hasPermission('user:create')).toBe(false)
})
it('应该正确检查多个权限(满足任一即可)', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: [],
permissions: ['user:read'],
menus: []
})
expect(store.hasPermission(['user:read', 'user:create'])).toBe(true)
expect(store.hasPermission(['user:create', 'user:update'])).toBe(false)
})
})
describe('localStorage 持久化', () => {
it('应该正确保存到 localStorage', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: ['admin'],
permissions: ['user:read'],
menus: [
{
id: '1',
name: '仪表盘',
path: '/dashboard',
sort: 1
}
]
})
const stored = localStorage.getItem('permission')
expect(stored).toBeTruthy()
const data = JSON.parse(stored!)
expect(data.roles).toEqual(['admin'])
expect(data.permissions).toEqual(['user:read'])
expect(data.menus).toHaveLength(1)
})
it('应该正确从 localStorage 恢复', () => {
localStorage.setItem('permission', JSON.stringify({
roles: ['user'],
permissions: ['user:read:self'],
menus: []
}))
const store = usePermissionStore()
store.initFromStorage()
expect(store.roles).toEqual(['user'])
expect(store.permissions).toEqual(['user:read:self'])
expect(store.loaded).toBe(true)
})
it('清除数据时应该同时清除 localStorage', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: ['admin'],
permissions: [],
menus: []
})
store.clearPermissionData()
expect(localStorage.getItem('permission')).toBeNull()
})
})
})
-61
View File
@@ -1,61 +0,0 @@
import { VueWrapper } from '@vue/test-utils'
import { ComponentPublicInstance } from 'vue'
export interface TestHelpers {
findByText: (text: string) => HTMLElement | null
findByTestId: (testId: string) => HTMLElement | null
clickByText: (text: string) => Promise<void>
clickByTestId: (testId: string) => Promise<void>
fillByTestId: (testId: string, value: string) => Promise<void>
}
export function createTestHelpers(wrapper: VueWrapper<ComponentPublicInstance>): TestHelpers {
return {
findByText: (text: string) => {
return wrapper.element.textContent?.includes(text) ? wrapper.element : null
},
findByTestId: (testId: string) => {
return wrapper.element.querySelector(`[data-testid="${testId}"]`)
},
clickByText: async (text: string) => {
const element = wrapper.element.textContent?.includes(text) ? wrapper.element : null
if (element) {
element.click()
await wrapper.vm.$nextTick()
}
},
clickByTestId: async (testId: string) => {
const element = wrapper.element.querySelector(`[data-testid="${testId}"]`)
if (element) {
element.click()
await wrapper.vm.$nextTick()
}
},
fillByTestId: async (testId: string, value: string) => {
const element = wrapper.element.querySelector(`[data-testid="${testId}"]`) as HTMLInputElement
if (element) {
element.value = value
element.dispatchEvent(new Event('input', { bubbles: true }))
await wrapper.vm.$nextTick()
}
},
}
}
export function waitFor(condition: () => boolean, timeout = 5000): Promise<void> {
return new Promise((resolve, reject) => {
const startTime = Date.now()
const check = () => {
if (condition()) {
resolve()
} else if (Date.now() - startTime > timeout) {
reject(new Error(`Timeout waiting for condition`))
} else {
setTimeout(check, 100)
}
}
check()
})
}
@@ -1,233 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { ElMessage } from 'element-plus'
import { handleApiError, ApiErrorHandler } from '@/utils/errorHandler'
vi.mock('element-plus', () => ({
ElMessage: {
error: vi.fn(),
success: vi.fn(),
},
}))
describe('errorHandler', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('localStorage', {
removeItem: vi.fn(),
})
vi.stubGlobal('window', {
location: { href: '' },
})
})
describe('handleApiError', () => {
it('should call ApiErrorHandler.handle', () => {
const mockError = { response: { status: 500, data: {} } }
const handleSpy = vi.spyOn(ApiErrorHandler, 'handle')
handleApiError(mockError)
expect(handleSpy).toHaveBeenCalledWith(mockError)
})
})
describe('ApiErrorHandler.handle', () => {
it('should handle network error', () => {
const mockError = new Error('Network Error')
const consoleSpy = vi.spyOn(console, 'error')
ApiErrorHandler.handle(mockError)
expect(ElMessage.error).toHaveBeenCalledWith('网络连接失败,请检查网络设置')
expect(consoleSpy).toHaveBeenCalledWith('Network Error:', mockError)
})
it('should handle 400 Bad Request', () => {
const mockError = {
response: {
status: 400,
data: { message: 'Invalid parameters' },
},
}
const consoleSpy = vi.spyOn(console, 'error')
ApiErrorHandler.handle(mockError)
expect(ElMessage.error).toHaveBeenCalledWith('Invalid parameters')
expect(consoleSpy).toHaveBeenCalledWith('Bad Request:', mockError.response.data)
})
it('should handle 401 Unauthorized', () => {
const mockError = {
response: {
status: 401,
data: { message: 'Unauthorized' },
},
}
const consoleSpy = vi.spyOn(console, 'error')
ApiErrorHandler.handle(mockError)
expect(ElMessage.error).toHaveBeenCalledWith('登录已过期,请重新登录')
expect(localStorage.removeItem).toHaveBeenCalledWith('token')
expect(window.location.href).toBe('/login')
expect(consoleSpy).toHaveBeenCalledWith('Unauthorized:', mockError.response.data)
})
it('should handle 403 Forbidden', () => {
const mockError = {
response: {
status: 403,
data: { message: 'Access denied' },
},
}
const consoleSpy = vi.spyOn(console, 'error')
ApiErrorHandler.handle(mockError)
expect(ElMessage.error).toHaveBeenCalledWith('没有权限访问该资源')
expect(consoleSpy).toHaveBeenCalledWith('Forbidden:', mockError.response.data)
})
it('should handle 404 Not Found', () => {
const mockError = {
response: {
status: 404,
data: { message: 'Resource not found' },
},
}
const consoleSpy = vi.spyOn(console, 'error')
ApiErrorHandler.handle(mockError)
expect(ElMessage.error).toHaveBeenCalledWith('Resource not found')
expect(consoleSpy).toHaveBeenCalledWith('Not Found:', mockError.response.data)
})
it('should handle 409 Conflict', () => {
const mockError = {
response: {
status: 409,
data: { message: 'Resource conflict' },
},
}
const consoleSpy = vi.spyOn(console, 'error')
ApiErrorHandler.handle(mockError)
expect(ElMessage.error).toHaveBeenCalledWith('Resource conflict')
expect(consoleSpy).toHaveBeenCalledWith('Conflict:', mockError.response.data)
})
it('should handle 422 Validation Error with details', () => {
const mockError = {
response: {
status: 422,
data: {
message: 'Validation failed',
details: {
username: 'Username is required',
password: 'Password is too short',
},
},
},
}
const consoleSpy = vi.spyOn(console, 'error')
ApiErrorHandler.handle(mockError)
expect(ElMessage.error).toHaveBeenCalledWith('Username is required、Password is too short')
expect(consoleSpy).toHaveBeenCalledWith('Validation Error:', mockError.response.data)
})
it('should handle 422 Validation Error without details', () => {
const mockError = {
response: {
status: 422,
data: { message: 'Validation failed' },
},
}
const consoleSpy = vi.spyOn(console, 'error')
ApiErrorHandler.handle(mockError)
expect(ElMessage.error).toHaveBeenCalledWith('Validation failed')
expect(consoleSpy).toHaveBeenCalledWith('Validation Error:', mockError.response.data)
})
it('should handle 500 Internal Server Error', () => {
const mockError = {
response: {
status: 500,
data: { message: 'Server error' },
},
}
const consoleSpy = vi.spyOn(console, 'error')
ApiErrorHandler.handle(mockError)
expect(ElMessage.error).toHaveBeenCalledWith('服务器内部错误,请稍后重试')
expect(consoleSpy).toHaveBeenCalledWith('Internal Server Error:', mockError.response.data)
})
it('should handle 502 Service Unavailable', () => {
const mockError = {
response: {
status: 502,
data: { message: 'Service unavailable' },
},
}
const consoleSpy = vi.spyOn(console, 'error')
ApiErrorHandler.handle(mockError)
expect(ElMessage.error).toHaveBeenCalledWith('服务暂时不可用,请稍后重试')
expect(consoleSpy).toHaveBeenCalledWith('Service Unavailable:', mockError.response.data)
})
it('should handle 503 Service Unavailable', () => {
const mockError = {
response: {
status: 503,
data: { message: 'Service unavailable' },
},
}
const consoleSpy = vi.spyOn(console, 'error')
ApiErrorHandler.handle(mockError)
expect(ElMessage.error).toHaveBeenCalledWith('服务暂时不可用,请稍后重试')
expect(consoleSpy).toHaveBeenCalledWith('Service Unavailable:', mockError.response.data)
})
it('should handle 504 Gateway Timeout', () => {
const mockError = {
response: {
status: 504,
data: { message: 'Gateway timeout' },
},
}
const consoleSpy = vi.spyOn(console, 'error')
ApiErrorHandler.handle(mockError)
expect(ElMessage.error).toHaveBeenCalledWith('服务暂时不可用,请稍后重试')
expect(consoleSpy).toHaveBeenCalledWith('Service Unavailable:', mockError.response.data)
})
it('should handle unknown status code', () => {
const mockError = {
response: {
status: 418,
data: { message: 'I am a teapot' },
},
}
const consoleSpy = vi.spyOn(console, 'error')
ApiErrorHandler.handle(mockError)
expect(ElMessage.error).toHaveBeenCalledWith('I am a teapot')
expect(consoleSpy).toHaveBeenCalledWith('Unknown Error:', mockError.response.data)
})
})
})
+60
View File
@@ -0,0 +1,60 @@
import request from '@/utils/request'
import type { PageResponse } from './user.api'
export interface ConfigItem {
id: number
configName: string
configKey: string
configValue: string
configType: string
remark: string
createdAt: string
updatedAt: string
}
export interface CreateConfigRequest {
configName: string
configKey: string
configValue: string
configType?: string
remark?: string
}
export interface UpdateConfigRequest {
configName?: string
configKey?: string
configValue?: string
configType?: string
remark?: string
}
export interface ConfigPageRequest {
page: number
size: number
configName?: string
configKey?: string
configType?: string
}
export const configApi = {
getAll: () =>
request.get<ConfigItem[]>('/sys/config'),
getPage: (params: ConfigPageRequest) =>
request.get<PageResponse<ConfigItem>>('/sys/config/page', { params }),
getById: (id: number) =>
request.get<ConfigItem>(`/sys/config/${id}`),
getByKey: (configKey: string) =>
request.get<ConfigItem>(`/sys/config/key/${configKey}`),
create: (data: CreateConfigRequest) =>
request.post<ConfigItem>('/sys/config', data),
update: (id: number, data: UpdateConfigRequest) =>
request.put<ConfigItem>(`/sys/config/${id}`, data),
delete: (id: number) =>
request.delete<void>(`/sys/config/${id}`),
}
+96
View File
@@ -0,0 +1,96 @@
import request from '@/utils/request'
import type { PageResponse } from './user.api'
export interface DictType {
id: number
dictName: string
dictType: string
status: number
remark: string
createdAt: string
updatedAt: string
}
export interface DictData {
id: number
dictType: string
dictLabel: string
dictValue: string
sort: number
status: number
remark: string
createdAt: string
updatedAt: string
}
export interface CreateDictTypeRequest {
dictName: string
dictType: string
status?: number
remark?: string
}
export interface UpdateDictTypeRequest {
dictName?: string
dictType?: string
status?: number
remark?: string
}
export interface CreateDictDataRequest {
dictType: string
dictLabel: string
dictValue: string
sort?: number
status?: number
remark?: string
}
export interface UpdateDictDataRequest {
dictType?: string
dictLabel?: string
dictValue?: string
sort?: number
status?: number
remark?: string
}
export interface DictPageRequest {
page: number
size: number
dictName?: string
dictType?: string
status?: string
}
export const dictApi = {
getTypes: () =>
request.get<DictType[]>('/dict/types'),
getTypeById: (id: number) =>
request.get<DictType>(`/dict/types/${id}`),
createType: (data: CreateDictTypeRequest) =>
request.post<DictType>('/dict/types', data),
updateType: (id: number, data: UpdateDictTypeRequest) =>
request.put<DictType>(`/dict/types/${id}`, data),
deleteType: (id: number) =>
request.delete<void>(`/dict/types/${id}`),
getDataByType: (dictType: string) =>
request.get<DictData[]>(`/dict/data/type/${dictType}`),
getDataPage: (params: DictPageRequest & { dictType: string }) =>
request.get<PageResponse<DictData>>('/dict/data/page', { params }),
createData: (data: CreateDictDataRequest) =>
request.post<DictData>('/dict/data', data),
updateData: (id: number, data: UpdateDictDataRequest) =>
request.put<DictData>(`/dict/data/${id}`, data),
deleteData: (id: number) =>
request.delete<void>(`/dict/data/${id}`),
}
+39
View File
@@ -0,0 +1,39 @@
import request from '@/utils/request'
import type { PageResponse } from './user.api'
export interface FileInfo {
id: number
fileName: string
filePath: string
fileSize: number
fileType: string
mimeType: string
uploadedBy: string
createdAt: string
}
export interface FilePageRequest {
page: number
size: number
fileName?: string
fileType?: string
}
export const fileApi = {
getPage: (params: FilePageRequest) =>
request.get<PageResponse<FileInfo>>('/files/page', { params }),
upload: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return request.post<FileInfo>('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
delete: (id: number) =>
request.delete<void>(`/files/${id}`),
download: (id: number) =>
request.get<Blob>(`/files/download/${id}`, { responseType: 'blob' }),
}
+53
View File
@@ -0,0 +1,53 @@
import request from '@/utils/request'
import type { PageResponse } from './user.api'
import { NoticeStatus } from '@/constants/status'
export interface Notice {
id: number
title: string
content: string
type: string
status: NoticeStatus
createdBy: string
createdAt: string
updatedAt: string
}
export interface CreateNoticeRequest {
title: string
content: string
type?: string
status?: NoticeStatus
}
export interface UpdateNoticeRequest {
title?: string
content?: string
type?: string
status?: NoticeStatus
}
export interface NoticePageRequest {
page: number
size: number
title?: string
type?: string
status?: string
}
export const noticeApi = {
getPage: (params: NoticePageRequest) =>
request.get<PageResponse<Notice>>('/notice/page', { params }),
getById: (id: number) =>
request.get<Notice>(`/notice/${id}`),
create: (data: CreateNoticeRequest) =>
request.post<Notice>('/notice', data),
update: (id: number, data: UpdateNoticeRequest) =>
request.put<Notice>(`/notice/${id}`, data),
delete: (id: number) =>
request.delete<void>(`/notice/${id}`),
}
+65
View File
@@ -0,0 +1,65 @@
import request from '@/utils/request'
import { MenuStatus } from '@/constants/status'
export interface MenuItem {
id: number
name: string
path: string
icon: string
component: string
parentId: number
sort: number
type: 'directory' | 'menu' | 'button'
permission: string
status: MenuStatus
visible: boolean
children?: MenuItem[]
createdAt: string
updatedAt: string
}
export interface CreateMenuRequest {
name: string
path?: string
icon?: string
component?: string
parentId: number
sort: number
type: 'directory' | 'menu' | 'button'
permission?: string
status?: MenuStatus
visible?: boolean
}
export interface UpdateMenuRequest {
name?: string
path?: string
icon?: string
component?: string
parentId?: number
sort?: number
type?: 'directory' | 'menu' | 'button'
permission?: string
status?: MenuStatus
visible?: boolean
}
export const menuApi = {
getAll: () =>
request.get<MenuItem[]>('/menus'),
getById: (id: number) =>
request.get<MenuItem>(`/menus/${id}`),
getTree: () =>
request.get<MenuItem[]>('/menus/tree'),
create: (data: CreateMenuRequest) =>
request.post<MenuItem>('/menus', data),
update: (id: number, data: UpdateMenuRequest) =>
request.put<MenuItem>(`/menus/${id}`, data),
delete: (id: number) =>
request.delete<void>(`/menus/${id}`),
}
@@ -1,24 +0,0 @@
import { describe, it, expect } from 'vitest';
import { AdminRole } from '../admin.role';
describe('AdminRole', () => {
it('should have admin credentials', () => {
expect(AdminRole.name).toBe('admin');
expect(AdminRole.displayName).toBe('超级管理员');
expect(AdminRole.credentials.username).toBe('admin');
expect(AdminRole.credentials.password).toBe('Test@123');
});
it('should have all permissions', () => {
expect(AdminRole.permissions).toContain('user:*');
expect(AdminRole.permissions).toContain('role:*');
expect(AdminRole.permissions).toContain('menu:*');
expect(AdminRole.cannotAccess).toHaveLength(0);
});
it('should be able to create all resources', () => {
expect(AdminRole.expectedBehaviors.canCreate).toContain('user');
expect(AdminRole.expectedBehaviors.canCreate).toContain('role');
expect(AdminRole.expectedBehaviors.canCreate).toContain('menu');
});
});
@@ -1,30 +0,0 @@
import { describe, it, expect } from 'vitest';
import type { RoleDefinition } from '../base.role';
describe('RoleDefinition', () => {
it('should define required role properties', () => {
const role: RoleDefinition = {
name: 'test',
displayName: '测试角色',
credentials: {
username: 'testuser',
password: 'Test@123'
},
permissions: ['test:read', 'test:write'],
cannotAccess: ['/admin'],
expectedBehaviors: {
canCreate: ['test'],
canRead: ['test'],
canUpdate: ['test'],
canDelete: []
}
};
expect(role.name).toBe('test');
expect(role.displayName).toBe('测试角色');
expect(role.credentials.username).toBe('testuser');
expect(role.credentials.password).toBe('Test@123');
expect(role.permissions).toHaveLength(2);
expect(role.cannotAccess).toHaveLength(1);
});
});
@@ -1,28 +0,0 @@
import { describe, it, expect } from 'vitest';
import { RoleFactory } from '../role-factory';
describe('RoleFactory', () => {
it('should get admin role', () => {
const role = RoleFactory.getRole('admin');
expect(role.name).toBe('admin');
expect(role.credentials.username).toBe('admin');
});
it('should get user role', () => {
const role = RoleFactory.getRole('user');
expect(role.name).toBe('user');
expect(role.credentials.username).toBe('normaluser');
});
it('should throw error for unknown role', () => {
expect(() => RoleFactory.getRole('unknown')).toThrow("Role 'unknown' not found");
});
it('should get all roles', () => {
const roles = RoleFactory.getAllRoles();
expect(roles).toHaveLength(3);
expect(roles.map(r => r.name)).toContain('admin');
expect(roles.map(r => r.name)).toContain('user');
expect(roles.map(r => r.name)).toContain('test');
});
});
@@ -1,25 +0,0 @@
import type { RoleDefinition } from './base.role';
export const AdminRole: RoleDefinition = {
name: 'admin',
displayName: '超级管理员',
credentials: {
username: 'admin',
password: 'Test@123'
},
permissions: [
'user:*',
'role:*',
'menu:*',
'config:*',
'log:read',
'dict:*'
],
cannotAccess: [],
expectedBehaviors: {
canCreate: ['user', 'role', 'menu', 'config', 'dict'],
canRead: ['user', 'role', 'menu', 'config', 'dict', 'log'],
canUpdate: ['user', 'role', 'menu', 'config', 'dict'],
canDelete: ['user', 'role', 'menu', 'config', 'dict']
}
};
@@ -1,16 +0,0 @@
export interface RoleDefinition {
name: string;
displayName: string;
credentials: {
username: string;
password: string;
};
permissions: string[];
cannotAccess: string[];
expectedBehaviors: {
canCreate: string[];
canRead: string[];
canUpdate: string[];
canDelete: string[];
};
}
@@ -1,24 +0,0 @@
import type { RoleDefinition } from './base.role';
import { AdminRole } from './admin.role';
import { UserRole } from './user.role';
import { TestRole } from './test.role';
export class RoleFactory {
private static roles: Map<string, RoleDefinition> = new Map([
['admin', AdminRole],
['user', UserRole],
['test', TestRole]
]);
static getRole(roleName: string): RoleDefinition {
const role = this.roles.get(roleName);
if (!role) {
throw new Error(`Role '${roleName}' not found`);
}
return role;
}
static getAllRoles(): RoleDefinition[] {
return Array.from(this.roles.values());
}
}
@@ -1,24 +0,0 @@
import type { RoleDefinition } from './base.role';
export const TestRole: RoleDefinition = {
name: 'test',
displayName: '测试用户',
credentials: {
username: 'e2e_test_user',
password: 'Test@123'
},
permissions: [
'test:read',
'test:write'
],
cannotAccess: [
'/user-management',
'/role-management'
],
expectedBehaviors: {
canCreate: ['test'],
canRead: ['test'],
canUpdate: ['test'],
canDelete: []
}
};
@@ -1,26 +0,0 @@
import type { RoleDefinition } from './base.role';
export const UserRole: RoleDefinition = {
name: 'user',
displayName: '普通用户',
credentials: {
username: 'normaluser',
password: 'Test@123'
},
permissions: [
'user:read:self',
'user:update:self'
],
cannotAccess: [
'/user-management',
'/role-management',
'/menu-management',
'/system-config'
],
expectedBehaviors: {
canCreate: [],
canRead: ['self'],
canUpdate: ['self'],
canDelete: []
}
};
@@ -1,68 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { PermissionHelper } from '../permission-helper';
// Mock Playwright
vi.mock('@playwright/test', () => ({
expect: Object.assign(vi.fn(), {
extend: vi.fn().mockReturnValue(expect),
}),
}));
describe('PermissionHelper', () => {
it('should create PermissionHelper instance', () => {
const mockPage = {
goto: vi.fn(),
url: vi.fn().mockReturnValue('http://localhost:3000/dashboard'),
locator: vi.fn().mockReturnValue({
count: vi.fn().mockResolvedValue(0),
}),
} as any;
const helper = new PermissionHelper(mockPage);
expect(helper).toBeDefined();
});
it('should have verifyCanAccess method', () => {
const mockPage = {
goto: vi.fn(),
url: vi.fn().mockReturnValue('http://localhost:3000/dashboard'),
locator: vi.fn(),
} as any;
const helper = new PermissionHelper(mockPage);
expect(typeof helper.verifyCanAccess).toBe('function');
});
it('should have verifyCannotAccess method', () => {
const mockPage = {
goto: vi.fn(),
url: vi.fn(),
locator: vi.fn(),
} as any;
const helper = new PermissionHelper(mockPage);
expect(typeof helper.verifyCannotAccess).toBe('function');
});
it('should have verifyRolePermissions method', () => {
const mockPage = {
goto: vi.fn(),
url: vi.fn(),
locator: vi.fn(),
} as any;
const helper = new PermissionHelper(mockPage);
expect(typeof helper.verifyRolePermissions).toBe('function');
});
it('should have verifyPermissionBoundary method', () => {
const mockPage = {
goto: vi.fn(),
url: vi.fn(),
locator: vi.fn(),
} as any;
const helper = new PermissionHelper(mockPage);
expect(typeof helper.verifyPermissionBoundary).toBe('function');
});
});
@@ -1,80 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { RoleAuthManager } from '../role-auth-manager';
// Mock fetch
global.fetch = vi.fn();
describe('RoleAuthManager', () => {
beforeEach(() => {
RoleAuthManager.clearCache();
vi.clearAllMocks();
});
it('should authenticate and cache token', async () => {
const mockToken = 'mock-jwt-token-12345';
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { token: mockToken } })
});
const token = await RoleAuthManager.getRoleToken('admin');
expect(token).toBe(mockToken);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/auth/login'),
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('admin')
})
);
});
it('should return cached token on second call', async () => {
const mockToken = 'cached-token';
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { token: mockToken } })
});
const token1 = await RoleAuthManager.getRoleToken('admin');
const token2 = await RoleAuthManager.getRoleToken('admin');
expect(token1).toBe(token2);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('should throw error for unknown role', async () => {
await expect(RoleAuthManager.getRoleToken('unknown')).rejects.toThrow("Role 'unknown' not found");
});
it('should throw error on authentication failure', async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: false,
statusText: 'Unauthorized',
text: async () => 'Invalid credentials'
});
await expect(RoleAuthManager.getRoleToken('admin')).rejects.toThrow('Authentication failed');
});
it('should clear specific role token', async () => {
const mockToken = 'token-to-clear';
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { token: mockToken } })
});
await RoleAuthManager.getRoleToken('admin');
RoleAuthManager.clearRoleToken('admin');
// 再次获取应该重新认证
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { token: 'new-token' } })
});
const newToken = await RoleAuthManager.getRoleToken('admin');
expect(newToken).toBe('new-token');
expect(global.fetch).toHaveBeenCalledTimes(2);
});
});
@@ -1,117 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TestDataManager, getTestDataManager } from '../test-data-manager';
global.fetch = vi.fn();
describe('TestDataManager', () => {
let manager: TestDataManager;
beforeEach(() => {
manager = TestDataManager.getInstance();
manager.clearTracking();
vi.clearAllMocks();
});
it('should be a singleton', () => {
const instance1 = getTestDataManager();
const instance2 = getTestDataManager();
expect(instance1).toBe(instance2);
});
it('should create user and track it', async () => {
const mockUserId = 'user-123';
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { id: mockUserId } })
});
const userData = {
username: 'testuser',
password: 'Test@123',
email: 'test@example.com',
};
const result = await manager.createUser(userData);
expect(result.id).toBe(mockUserId);
expect(result.type).toBe('user');
expect(result.data.username).toBe('testuser');
expect(manager.getCreatedData('user')).toHaveLength(1);
});
it('should create role and track it', async () => {
const mockRoleId = 'role-456';
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { id: mockRoleId } })
});
const roleData = {
roleName: '测试角色',
roleKey: 'test_role',
};
const result = await manager.createRole(roleData);
expect(result.id).toBe(mockRoleId);
expect(result.type).toBe('role');
expect(manager.getCreatedData('role')).toHaveLength(1);
});
it('should cleanup created data', async () => {
(global.fetch as any)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { id: 'user-1' } })
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { id: 'user-2' } })
})
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ ok: true });
await manager.createUser({ username: 'user1', password: 'Test@123', email: 'user1@test.com' });
await manager.createUser({ username: 'user2', password: 'Test@123', email: 'user2@test.com' });
expect(manager.getCreatedData('user')).toHaveLength(2);
await manager.cleanup('user');
expect(manager.getCreatedData('user')).toHaveLength(0);
expect(global.fetch).toHaveBeenCalledTimes(4); // 2 creates + 2 deletes
});
it('should cleanup all data types when no type specified', async () => {
(global.fetch as any)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { id: 'user-1' } })
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { id: 'role-1' } })
})
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ ok: true });
await manager.createUser({ username: 'user1', password: 'Test@123', email: 'user1@test.com' });
await manager.createRole({ roleName: '角色1', roleKey: 'role1' });
await manager.cleanup();
expect(manager.getCreatedData('user')).toHaveLength(0);
expect(manager.getCreatedData('role')).toHaveLength(0);
});
it('should throw error on creation failure', async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: false,
statusText: 'Bad Request'
});
await expect(
manager.createUser({ username: 'test', password: 'Test@123', email: 'test@test.com' })
).rejects.toThrow('Failed to create user');
});
});
@@ -1,76 +0,0 @@
import { Page, BrowserContext } from '@playwright/test';
import { RoleFactory } from '../roles/role-factory';
import { RoleAuthManager } from './role-auth-manager';
import type { RoleDefinition } from '../roles/base.role';
export class AuthHelper {
constructor(
private page: Page,
private context: BrowserContext
) {}
async loginAsRole(roleName: string, useTokenInjection: boolean = true): Promise<void> {
const role = RoleFactory.getRole(roleName);
if (useTokenInjection) {
await this.injectToken(role);
} else {
await this.performLogin(role);
}
}
private async injectToken(role: RoleDefinition): Promise<void> {
const token = await RoleAuthManager.getRoleToken(role.name);
// 注入token到localStorage
await this.page.addInitScript((token) => {
localStorage.setItem('token', token);
localStorage.setItem('username', 'admin');
}, token);
// 设置cookie
await this.context.addCookies([
{
name: 'token',
value: token,
domain: 'localhost',
path: '/',
}
]);
}
private async performLogin(role: RoleDefinition): Promise<void> {
await this.page.goto('/login');
await this.page.fill('input[placeholder*="用户名"]', role.credentials.username);
await this.page.fill('input[placeholder*="密码"]', role.credentials.password);
await this.page.click('button[type="submit"]');
// 等待登录成功跳转
await this.page.waitForURL(/\/(dashboard|home)?/, { timeout: 10000 });
}
async logout(): Promise<void> {
await this.page.click('[data-testid="user-menu"]');
await this.page.click('[data-testid="logout-button"]');
await this.page.waitForURL('/login');
}
async clearAuth(): Promise<void> {
await this.context.clearCookies();
await this.page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
}
}
export async function createAuthenticatedPage(
page: Page,
context: BrowserContext,
roleName: string
): Promise<AuthHelper> {
const helper = new AuthHelper(page, context);
await helper.loginAsRole(roleName);
return helper;
}
@@ -1,131 +0,0 @@
import { Page, expect } from '@playwright/test';
import type { RoleDefinition } from '../roles/base.role';
export class PermissionHelper {
constructor(private page: Page) {}
async verifyCanAccess(path: string): Promise<void> {
await this.page.goto(path);
await expect(this.page).not.toHaveURL(/\/login/);
await expect(this.page).not.toHaveURL(/\/403/);
await expect(this.page).not.toHaveURL(/\/404/);
}
async verifyCannotAccess(path: string): Promise<void> {
await this.page.goto(path);
// 应该被重定向到登录页或显示403错误
const url = this.page.url();
const isForbidden = url.includes('/403') || url.includes('/login');
expect(isForbidden || await this.isAccessDenied()).toBeTruthy();
}
private async isAccessDenied(): Promise<boolean> {
const deniedMessage = this.page.locator('text=/无权限|权限不足|Access Denied|Forbidden/i');
return await deniedMessage.count() > 0;
}
async verifyCanCreate(_resource: string, createButtonSelector: string): Promise<void> {
const createButton = this.page.locator(createButtonSelector);
await expect(createButton).toBeVisible();
await expect(createButton).toBeEnabled();
}
async verifyCannotCreate(_resource: string, createButtonSelector: string): Promise<void> {
const createButton = this.page.locator(createButtonSelector);
const count = await createButton.count();
if (count > 0) {
await expect(createButton).not.toBeVisible();
}
}
async verifyCanEdit(_resourceId: string, editButtonSelector: string): Promise<void> {
const editButton = this.page.locator(editButtonSelector);
await expect(editButton).toBeVisible();
await expect(editButton).toBeEnabled();
}
async verifyCannotEdit(_resourceId: string, editButtonSelector: string): Promise<void> {
const editButton = this.page.locator(editButtonSelector);
const count = await editButton.count();
if (count > 0) {
await expect(editButton).not.toBeVisible();
}
}
async verifyCanDelete(_resourceId: string, deleteButtonSelector: string): Promise<void> {
const deleteButton = this.page.locator(deleteButtonSelector);
await expect(deleteButton).toBeVisible();
await expect(deleteButton).toBeEnabled();
}
async verifyCannotDelete(_resourceId: string, deleteButtonSelector: string): Promise<void> {
const deleteButton = this.page.locator(deleteButtonSelector);
const count = await deleteButton.count();
if (count > 0) {
await expect(deleteButton).not.toBeVisible();
}
}
async verifyRolePermissions(role: RoleDefinition): Promise<void> {
// 验证可访问的路径
for (const path of role.expectedBehaviors.canRead) {
if (path !== 'self') {
await this.verifyCanAccess(`/${path}`);
}
}
// 验证不可访问的路径
for (const path of role.cannotAccess) {
await this.verifyCannotAccess(path);
}
}
async verifyPermissionBoundary(
role: RoleDefinition,
testScenarios: {
resource: string;
path: string;
createButton?: string;
editButton?: string;
deleteButton?: string;
}
): Promise<void> {
await this.page.goto(testScenarios.path);
// 验证创建权限
if (testScenarios.createButton) {
if (role.expectedBehaviors.canCreate.includes(testScenarios.resource)) {
await this.verifyCanCreate(testScenarios.resource, testScenarios.createButton);
} else {
await this.verifyCannotCreate(testScenarios.resource, testScenarios.createButton);
}
}
// 验证编辑权限
if (testScenarios.editButton) {
if (role.expectedBehaviors.canUpdate.includes(testScenarios.resource)) {
await this.verifyCanEdit(testScenarios.resource, testScenarios.editButton);
} else {
await this.verifyCannotEdit(testScenarios.resource, testScenarios.editButton);
}
}
// 验证删除权限
if (testScenarios.deleteButton) {
if (role.expectedBehaviors.canDelete.includes(testScenarios.resource)) {
await this.verifyCanDelete(testScenarios.resource, testScenarios.deleteButton);
} else {
await this.verifyCannotDelete(testScenarios.resource, testScenarios.deleteButton);
}
}
}
}
export function createPermissionHelper(page: Page): PermissionHelper {
return new PermissionHelper(page);
}
@@ -1,59 +0,0 @@
import { RoleFactory } from '../roles/role-factory';
interface TokenCache {
token: string;
expiresAt: number;
}
export class RoleAuthManager {
private static tokenCache: Map<string, TokenCache> = new Map();
private static readonly API_BASE_URL = process.env.VITE_API_BASE_URL || 'http://localhost:8084';
private static readonly TOKEN_EXPIRY_BUFFER = 60000;
static async getRoleToken(roleName: string): Promise<string> {
const cached = this.tokenCache.get(roleName);
if (cached && cached.expiresAt > Date.now() + this.TOKEN_EXPIRY_BUFFER) {
return cached.token;
}
const role = RoleFactory.getRole(roleName);
const token = await this.authenticateWithBackend(role.credentials);
this.tokenCache.set(roleName, {
token,
expiresAt: Date.now() + 3600000
});
return token;
}
private static async authenticateWithBackend(credentials: { username: string; password: string }): Promise<string> {
const path = '/api/auth/login';
const body = JSON.stringify(credentials);
const response = await fetch(`${this.API_BASE_URL}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Authentication failed for user ${credentials.username}: ${response.statusText} - ${errorText}`);
}
const data = await response.json();
return data.data?.token || data.token;
}
static clearCache(): void {
this.tokenCache.clear();
}
static clearRoleToken(roleName: string): void {
this.tokenCache.delete(roleName);
}
}
@@ -1,150 +0,0 @@
import { Page } from '@playwright/test';
export interface TestData {
id: string;
type: string;
data: Record<string, any>;
createdAt: Date;
}
export class TestDataManager {
private static instance: TestDataManager;
private createdData: Map<string, TestData[]> = new Map();
private _page: Page | null = null;
private static readonly API_BASE_URL = process.env.VITE_API_BASE_URL || 'http://localhost:8084';
static getInstance(): TestDataManager {
if (!TestDataManager.instance) {
TestDataManager.instance = new TestDataManager();
}
return TestDataManager.instance;
}
setPage(page: Page): void {
this._page = page;
}
getPage(): Page | null {
return this._page;
}
async createUser(userData: {
username: string;
password: string;
email: string;
phone?: string;
nickname?: string;
}): Promise<TestData> {
const response = await fetch(`${TestDataManager.API_BASE_URL}/api/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...userData,
status: 1,
}),
});
if (!response.ok) {
throw new Error(`Failed to create user: ${response.statusText}`);
}
const result = await response.json();
const testData: TestData = {
id: result.data?.id || result.id,
type: 'user',
data: userData,
createdAt: new Date(),
};
this.trackData('user', testData);
return testData;
}
async createRole(roleData: {
roleName: string;
roleKey: string;
roleSort?: number;
}): Promise<TestData> {
const response = await fetch(`${TestDataManager.API_BASE_URL}/api/roles`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...roleData,
status: 1,
}),
});
if (!response.ok) {
throw new Error(`Failed to create role: ${response.statusText}`);
}
const result = await response.json();
const testData: TestData = {
id: result.data?.id || result.id,
type: 'role',
data: roleData,
createdAt: new Date(),
};
this.trackData('role', testData);
return testData;
}
async cleanup(type?: string): Promise<void> {
const typesToClean = type ? [type] : Array.from(this.createdData.keys());
for (const dataType of typesToClean) {
const items = this.createdData.get(dataType) || [];
for (const item of items.reverse()) {
try {
await this.deleteData(item);
} catch (error) {
console.error(`Failed to cleanup ${dataType} ${item.id}:`, error);
}
}
this.createdData.delete(dataType);
}
}
private async deleteData(data: TestData): Promise<void> {
const endpoint = this.getEndpoint(data.type);
await fetch(`${TestDataManager.API_BASE_URL}${endpoint}/${data.id}`, {
method: 'DELETE',
});
}
private getEndpoint(type: string): string {
const endpoints: Record<string, string> = {
user: '/api/users',
role: '/api/roles',
menu: '/api/menus',
config: '/api/configs',
};
return endpoints[type] || `/api/${type}s`;
}
private trackData(type: string, data: TestData): void {
if (!this.createdData.has(type)) {
this.createdData.set(type, []);
}
this.createdData.get(type)!.push(data);
}
getCreatedData(type: string): TestData[] {
return this.createdData.get(type) || [];
}
clearTracking(): void {
this.createdData.clear();
}
}
export function getTestDataManager(): TestDataManager {
return TestDataManager.getInstance();
}
-158
View File
@@ -1,158 +0,0 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw, RouteLocationNormalized } from 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
roles?: string[]
title?: string
}
}
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/system/Login.vue'),
meta: { title: '登录' }
},
{
path: '/403',
name: 'Forbidden',
component: () => import('@/views/system/Forbidden.vue'),
meta: { title: '无权限' }
},
{
path: '/',
component: () => import('@/layouts/DefaultLayout.vue'),
redirect: '/dashboard',
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/system/Dashboard.vue'),
meta: { title: '仪表盘' }
},
{
path: 'users',
name: 'UserManagement',
component: () => import('@/views/system/UserManagement.vue'),
meta: { title: '用户管理' }
},
{
path: 'roles',
name: 'RoleManagement',
component: () => import('@/views/system/RoleManagement.vue'),
meta: { title: '角色管理' }
},
{
path: 'menus',
name: 'MenuManagement',
component: () => import('@/views/system/MenuManagement.vue'),
meta: { title: '菜单管理' }
},
{
path: 'sys/config',
name: 'ConfigManagement',
component: () => import('@/views/config/ConfigManagement.vue'),
meta: { title: '参数配置' }
},
{
path: 'dict',
name: 'DictManagement',
component: () => import('@/views/config/DictManagement.vue'),
meta: { title: '字典管理' }
},
{
path: 'files',
name: 'FileManagement',
component: () => import('@/views/file/FileManagement.vue'),
meta: { title: '文件管理' }
},
{
path: 'notice',
name: 'NoticeManagement',
component: () => import('@/views/notify/NoticeManagement.vue'),
meta: { title: '通知公告' }
},
{
path: 'loginlog',
name: 'LoginLog',
component: () => import('@/views/audit/LoginLog.vue'),
meta: { title: '登录日志' }
},
{
path: 'oplog',
name: 'OperationLog',
component: () => import('@/views/audit/OperationLog.vue'),
meta: { title: '操作日志' }
},
{
path: 'exceptionlog',
name: 'ExceptionLog',
component: () => import('@/views/audit/ExceptionLog.vue'),
meta: { title: '异常日志' }
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
function checkRoutePermission(route: RouteLocationNormalized, userRoles: string[]): boolean {
if (!route.meta.roles || !Array.isArray(route.meta.roles) || route.meta.roles.length === 0) {
return true
}
return route.meta.roles.some((role: string) => userRoles.includes(role))
}
router.beforeEach((to, _from, next) => {
try {
const token = localStorage.getItem('token')
const rolesStr = localStorage.getItem('roles')
let userRoles: string[] = []
try {
userRoles = rolesStr ? JSON.parse(rolesStr) : []
} catch (e) {
console.warn('解析用户角色失败,将使用空数组:', e)
userRoles = []
}
if (to.meta.title) {
document.title = `${to.meta.title} - Novalon 管理系统`
}
if (to.path === '/login') {
if (token) {
next('/')
} else {
next()
}
} else if (to.path === '/403') {
next()
} else {
if (to.meta.requiresAuth !== false && !token) {
next('/login')
return
}
if (!checkRoutePermission(to, userRoles)) {
console.warn(`用户角色 ${userRoles} 无权访问路由 ${to.path},需要角色: ${to.meta.roles}`)
next('/403')
return
}
next()
}
} catch (error) {
console.error('路由守卫错误:', error)
next('/login')
}
})
export default router
-210
View File
@@ -1,210 +0,0 @@
import { defineStore } from 'pinia'
import request from '@/utils/request'
export interface MenuItem {
id: string
name: string
path: string
icon?: string
parentId?: string
sort: number
children?: MenuItem[]
}
interface BackendMenuItem {
id: string
menuName: string
parentId: string
orderNum: number
menuType: string
perms?: string
component?: string
status: number
children?: BackendMenuItem[]
}
function transformMenuData(backendMenus: BackendMenuItem[]): MenuItem[] {
const menuMap = new Map<string, MenuItem>()
const rootMenus: MenuItem[] = []
const componentToPathMap: Record<string, string> = {
'system/user/index': '/users',
'system/role/index': '/roles',
'system/menu/index': '/menus',
'system/dict/index': '/dict',
'system/config/index': '/sys/config',
'system/notice/index': '/notice',
'system/file/index': '/files',
'audit/operation/index': '/oplog',
'audit/login/index': '/loginlog',
'audit/exception/index': '/exceptionlog',
}
const filteredMenus = backendMenus.filter(menu => menu.menuType !== 'F')
filteredMenus.forEach(menu => {
const menuItem: MenuItem = {
id: menu.id,
name: menu.menuName,
path: menu.component ? (componentToPathMap[menu.component] || `/${menu.component.replace('/index', '').replace('system/', '')}`) : '',
icon: getMenuIcon(menu.menuName),
parentId: menu.parentId === '0' ? undefined : menu.parentId,
sort: menu.orderNum
}
menuMap.set(menu.id, menuItem)
})
filteredMenus.forEach(menu => {
const menuItem = menuMap.get(menu.id)!
if (menu.parentId === '0') {
rootMenus.push(menuItem)
} else {
const parentMenu = menuMap.get(menu.parentId)
if (parentMenu) {
if (!parentMenu.children) {
parentMenu.children = []
}
parentMenu.children.push(menuItem)
}
}
})
rootMenus.forEach(menu => {
if (menu.children) {
menu.children.sort((a, b) => a.sort - b.sort)
}
})
return rootMenus.sort((a, b) => a.sort - b.sort)
}
function getMenuIcon(menuName: string): string {
const iconMap: Record<string, string> = {
'系统管理': 'Setting',
'审计日志': 'Document',
'系统监控': 'Monitor',
'用户管理': 'User',
'角色管理': 'UserFilled',
'菜单管理': 'Menu',
'字典管理': 'Collection',
'参数配置': 'Tools',
'通知公告': 'Bell',
'文件管理': 'Folder',
'操作日志': 'Document',
'登录日志': 'Document',
'异常日志': 'Warning'
}
return iconMap[menuName] || 'Document'
}
interface PermissionState {
roles: string[]
permissions: string[]
menus: MenuItem[]
loaded: boolean
}
export const usePermissionStore = defineStore('permission', {
state: (): PermissionState => ({
roles: [],
permissions: [],
menus: [],
loaded: false
}),
getters: {
hasRole: (state) => (role: string | string[]) => {
if (Array.isArray(role)) {
return role.some(r => state.roles.includes(r))
}
return state.roles.includes(role)
},
hasPermission: (state) => (permission: string | string[]) => {
if (Array.isArray(permission)) {
return permission.some(p => state.permissions.includes(p))
}
return state.permissions.includes(permission)
}
},
actions: {
setPermissionData(data: {
roles: string[]
permissions: string[]
menus: MenuItem[]
}) {
this.roles = data.roles
this.permissions = data.permissions
this.menus = data.menus
this.loaded = true
this.saveToStorage()
},
clearPermissionData() {
this.roles = []
this.permissions = []
this.menus = []
this.loaded = false
localStorage.removeItem('permission')
},
saveToStorage() {
const data = {
roles: this.roles,
permissions: this.permissions,
menus: this.menus
}
localStorage.setItem('permission', JSON.stringify(data))
},
initFromStorage() {
const stored = localStorage.getItem('permission')
if (stored) {
try {
const data = JSON.parse(stored)
this.roles = data.roles || []
this.permissions = data.permissions || []
this.menus = data.menus || []
this.loaded = true
} catch (error) {
console.error('从 localStorage 恢复权限数据失败:', error)
}
}
},
async fetchUserMenus() {
try {
const res: any = await request.get('/menus')
if (res && Array.isArray(res)) {
const transformedMenus = transformMenuData(res)
const permissions: string[] = []
const extractPermissions = (menus: BackendMenuItem[]) => {
menus.forEach(menu => {
if (menu.perms) {
permissions.push(menu.perms)
}
if (menu.children && menu.children.length > 0) {
extractPermissions(menu.children)
}
})
}
extractPermissions(res)
this.setPermissionData({
roles: JSON.parse(localStorage.getItem('roles') || '[]'),
permissions: permissions,
menus: transformedMenus
})
}
} catch (error) {
console.error('获取用户菜单失败:', error)
throw error
}
}
}
})
+26
View File
@@ -0,0 +1,26 @@
export interface MenuItem {
id: number
name: string
path: string
icon: string
component: string
parentId: number
sort: number
type: 'directory' | 'menu' | 'button'
permission: string
status: number
visible: boolean
children?: MenuItem[]
createdAt: string
updatedAt: string
}
export interface MenuTree {
id: number
name: string
path: string
icon: string
parentId: number
sort: number
children?: MenuTree[]
}
@@ -0,0 +1,20 @@
export interface PermissionState {
permissions: string[]
roles: string[]
menus: MenuPermission[]
}
export interface MenuPermission {
id: number
name: string
path: string
icon: string
parentId: number
sort: number
children?: MenuPermission[]
}
export interface PermissionCheckResult {
hasPermission: boolean
requiredPermission: string | null
}
+24
View File
@@ -0,0 +1,24 @@
export interface AuthState {
token: string | null
userInfo: UserInfo | null
isAuthenticated: boolean
}
export interface UserInfo {
id: number
username: string
nickname: string
email: string
phone: string
avatar: string
roles: string[]
permissions: string[]
}
export interface JwtPayload {
sub: string
username: string
roles: string[]
iat: number
exp: number
}
+12 -12
View File
@@ -1,4 +1,4 @@
import { ElMessage } from 'element-plus'
import { message } from 'antd'
export interface ApiError {
code: string
@@ -51,59 +51,59 @@ export class ApiErrorHandler {
}
private static handleNetworkError(error: any): void {
ElMessage.error('网络连接失败,请检查网络设置')
message.error('网络连接失败,请检查网络设置')
console.error('Network Error:', error)
}
private static handleBadRequest(error: ApiError): void {
ElMessage.error(error.message || '请求参数错误')
message.error(error.message || '请求参数错误')
console.error('Bad Request:', error)
}
private static handleUnauthorized(error: ApiError): void {
ElMessage.error('登录已过期,请重新登录')
message.error('登录已过期,请重新登录')
localStorage.removeItem('token')
window.location.href = '/login'
console.error('Unauthorized:', error)
}
private static handleForbidden(error: ApiError): void {
ElMessage.error('没有权限访问该资源')
message.error('没有权限访问该资源')
console.error('Forbidden:', error)
}
private static handleNotFound(error: ApiError): void {
ElMessage.error(error.message || '请求的资源不存在')
message.error(error.message || '请求的资源不存在')
console.error('Not Found:', error)
}
private static handleConflict(error: ApiError): void {
ElMessage.error(error.message || '资源冲突,请刷新后重试')
message.error(error.message || '资源冲突,请刷新后重试')
console.error('Conflict:', error)
}
private static handleValidationError(error: ApiError): void {
if (error.details) {
const messages = Object.values(error.details).join('、')
ElMessage.error(messages)
message.error(messages)
} else {
ElMessage.error(error.message || '数据验证失败')
message.error(error.message || '数据验证失败')
}
console.error('Validation Error:', error)
}
private static handleInternalServerError(error: ApiError): void {
ElMessage.error('服务器内部错误,请稍后重试')
message.error('服务器内部错误,请稍后重试')
console.error('Internal Server Error:', error)
}
private static handleServiceUnavailable(error: ApiError): void {
ElMessage.error('服务暂时不可用,请稍后重试')
message.error('服务暂时不可用,请稍后重试')
console.error('Service Unavailable:', error)
}
private static handleUnknownError(error: ApiError): void {
ElMessage.error(error.message || '未知错误')
message.error(error.message || '未知错误')
console.error('Unknown Error:', error)
}
}
@@ -1,235 +0,0 @@
<template>
<div class="exception-log">
<el-card>
<template #header>
<div class="card-header">
<div class="search-section">
<el-input
v-model="searchKeyword"
placeholder="搜索操作人或异常信息"
clearable
style="width: 300px"
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button
type="primary"
@click="handleSearch"
>
搜索
</el-button>
</div>
</div>
</template>
<el-table
v-loading="loading"
:data="dataSource"
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column
prop="id"
label="ID"
sortable="custom"
width="80"
/>
<el-table-column
prop="username"
label="操作人"
sortable="custom"
width="120"
/>
<el-table-column
prop="operation"
label="操作模块"
sortable="custom"
width="150"
/>
<el-table-column
prop="method"
label="请求方法"
sortable="custom"
width="200"
:show-overflow-tooltip="true"
/>
<el-table-column
prop="errorMsg"
label="异常信息"
:show-overflow-tooltip="true"
width="250"
/>
<el-table-column
prop="ip"
label="IP地址"
sortable="custom"
width="120"
/>
<el-table-column
prop="createTime"
label="异常时间"
sortable="custom"
width="180"
/>
<el-table-column
label="操作"
width="120"
fixed="right"
>
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click="handleViewDetail(row)"
>
查看详情
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 16px; justify-content: flex-end"
@current-change="handleTableChange"
@size-change="handleSizeChange"
/>
</el-card>
<el-dialog
v-model="detailVisible"
title="异常详情"
width="800px"
>
<el-descriptions
:column="1"
border
>
<el-descriptions-item label="ID">
{{ currentDetail.id }}
</el-descriptions-item>
<el-descriptions-item label="操作人">
{{ currentDetail.username }}
</el-descriptions-item>
<el-descriptions-item label="操作模块">
{{ currentDetail.operation }}
</el-descriptions-item>
<el-descriptions-item label="请求方法">
{{ currentDetail.method }}
</el-descriptions-item>
<el-descriptions-item label="请求参数">
<pre style="max-height: 200px; overflow: auto;">{{ currentDetail.params }}</pre>
</el-descriptions-item>
<el-descriptions-item label="异常信息">
<div style="color: #f56c6c; word-break: break-all;">
{{ currentDetail.errorMsg }}
</div>
</el-descriptions-item>
<el-descriptions-item label="异常堆栈">
<pre style="max-height: 300px; overflow: auto; font-size: 12px;">{{ currentDetail.exceptionStack }}</pre>
</el-descriptions-item>
<el-descriptions-item label="IP地址">
{{ currentDetail.ip }}
</el-descriptions-item>
<el-descriptions-item label="异常时间">
{{ currentDetail.createTime }}
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="detailVisible = false">
关闭
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { exceptionLogApi, ExceptionLog } from '@/api/exceptionLog'
const loading = ref(false)
const dataSource = ref<ExceptionLog[]>([])
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0
})
const sortInfo = reactive({
sort: 'id',
order: 'asc'
})
const detailVisible = ref(false)
const currentDetail = ref<ExceptionLog>({})
const fetchData = async () => {
loading.value = true
try {
const res = await exceptionLogApi.getPage({
page: pagination.current - 1,
size: pagination.pageSize,
sort: sortInfo.sort,
order: sortInfo.order,
keyword: searchKeyword.value || undefined
})
dataSource.value = res.content
pagination.total = res.totalElements
} finally {
loading.value = false
}
}
const handleTableChange = () => {
fetchData()
}
const handleSizeChange = () => {
pagination.current = 1
fetchData()
}
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const handleSortChange = ({ prop, order }: any) => {
sortInfo.sort = prop
sortInfo.order = order === 'ascending' ? 'asc' : 'desc'
fetchData()
}
const handleViewDetail = (row: ExceptionLog) => {
currentDetail.value = { ...row }
detailVisible.value = true
}
onMounted(() => fetchData())
</script>
<style scoped lang="css">
.exception-log {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.search-section {
display: flex;
gap: 8px;
align-items: center;
}
}
}
</style>
@@ -1,176 +0,0 @@
<template>
<div class="login-log">
<el-card>
<template #header>
<div class="card-header">
<div class="search-section">
<el-input
v-model="searchKeyword"
placeholder="搜索用户名或IP地址"
clearable
style="width: 300px"
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button
type="primary"
@click="handleSearch"
>
搜索
</el-button>
</div>
</div>
</template>
<el-table
v-loading="loading"
:data="dataSource"
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column
prop="id"
label="ID"
sortable="custom"
/>
<el-table-column
prop="username"
label="用户名"
sortable="custom"
/>
<el-table-column
prop="ip"
label="IP地址"
sortable="custom"
/>
<el-table-column
prop="location"
label="登录地点"
sortable="custom"
/>
<el-table-column
prop="browser"
label="浏览器"
sortable="custom"
/>
<el-table-column
prop="os"
label="操作系统"
sortable="custom"
/>
<el-table-column label="状态">
<template #default="{ row }">
<el-tag
:type="row.status === '0' ? 'success' : 'danger'"
effect="dark"
style="font-weight: 500; font-size: 14px;"
>
{{ row.status === '0' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="loginTime"
label="登录时间"
sortable="custom"
>
<template #default="{ row }">
{{ formatDateTime(row.loginTime) }}
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 16px; justify-content: flex-end"
@current-change="handleTableChange"
@size-change="handleSizeChange"
/>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Search } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { formatDateTime } from '@/utils/dateFormat'
const loading = ref(false)
const dataSource = ref([])
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0
})
const sortInfo = reactive({
sort: 'id',
order: 'asc'
})
const fetchData = async () => {
loading.value = true
try {
const res: any = await request.get('/logs/login/page', {
params: {
page: pagination.current - 1,
size: pagination.pageSize,
sort: sortInfo.sort,
order: sortInfo.order,
keyword: searchKeyword.value || undefined
}
})
dataSource.value = res.content
pagination.total = res.totalElements
} finally {
loading.value = false
}
}
const handleTableChange = () => {
fetchData()
}
const handleSizeChange = () => {
pagination.current = 1
fetchData()
}
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const handleSortChange = ({ prop, order }: any) => {
sortInfo.sort = prop
sortInfo.order = order === 'ascending' ? 'asc' : 'desc'
fetchData()
}
onMounted(() => fetchData())
</script>
<style scoped lang="css">
.login-log {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.search-section {
display: flex;
gap: 8px;
align-items: center;
}
}
}
</style>
@@ -1,311 +0,0 @@
<template>
<div class="operation-log">
<el-card>
<template #header>
<div class="card-header">
<div class="search-section">
<el-input
v-model="searchKeyword"
placeholder="搜索操作人或操作模块"
clearable
style="width: 300px"
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button
type="primary"
@click="handleSearch"
>
搜索
</el-button>
<el-button
type="success"
@click="handleExport"
>
<el-icon><Download /></el-icon>
导出
</el-button>
</div>
</div>
</template>
<el-table
v-loading="loading"
:data="dataSource"
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column
prop="id"
label="ID"
sortable="custom"
/>
<el-table-column
prop="username"
label="操作人"
sortable="custom"
/>
<el-table-column
prop="operation"
label="操作模块"
sortable="custom"
>
<template #default="{ row }">
<div class="operation-cell">
<el-icon class="operation-icon">
<component :is="getOperationIcon(row.operation)" />
</el-icon>
<span>{{ row.operation }}</span>
</div>
</template>
</el-table-column>
<el-table-column
prop="method"
label="请求方法"
sortable="custom"
/>
<el-table-column
prop="params"
label="请求参数"
:show-overflow-tooltip="true"
>
<template #default="{ row }">
<div
v-if="row.params"
class="params-content"
>
<el-popover
placement="top"
:width="500"
trigger="hover"
>
<template #reference>
<div class="params-preview">
{{ formatParams(row.params) }}
</div>
</template>
<pre class="params-detail">{{ formatParams(row.params) }}</pre>
</el-popover>
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态">
<template #default="{ row }">
<el-tag
:type="row.status === '0' ? 'success' : 'danger'"
effect="dark"
style="font-weight: 500; font-size: 14px;"
>
{{ row.status === '0' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="duration"
label="耗时(ms)"
sortable="custom"
/>
<el-table-column
prop="createdAt"
label="操作时间"
sortable="custom"
>
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 16px; justify-content: flex-end"
@current-change="handleTableChange"
@size-change="handleSizeChange"
/>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Search, User, Document, Setting, Lock, View, Edit, Delete, Plus, Download } from '@element-plus/icons-vue'
import { operationLogApi, OperationLog } from '@/api/operationLog'
import { formatDateTime } from '@/utils/dateFormat'
const loading = ref(false)
const dataSource = ref<OperationLog[]>([])
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0
})
const sortInfo = reactive({
sort: 'id',
order: 'asc'
})
const fetchData = async () => {
loading.value = true
try {
const res = await operationLogApi.getPage({
page: pagination.current - 1,
size: pagination.pageSize,
sort: sortInfo.sort,
order: sortInfo.order,
keyword: searchKeyword.value || undefined
})
dataSource.value = res.content
pagination.total = res.totalElements
} finally {
loading.value = false
}
}
const handleTableChange = () => {
fetchData()
}
const handleSizeChange = () => {
pagination.current = 1
fetchData()
}
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const handleExport = async () => {
try {
loading.value = true
const params = new URLSearchParams()
if (searchKeyword.value) {
params.append('keyword', searchKeyword.value)
}
const response = await fetch(`/api/logs/operation/export?${params.toString()}`, {
method: 'GET',
headers: {
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}
})
if (!response.ok) {
throw new Error('导出失败')
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `operation_logs_${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.xlsx`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch (error) {
console.error('导出失败:', error)
} finally {
loading.value = false
}
}
const handleSortChange = ({ prop, order }: any) => {
sortInfo.sort = prop
sortInfo.order = order === 'ascending' ? 'asc' : 'desc'
fetchData()
}
const formatParams = (params: string) => {
if (!params) return ''
try {
const parsed = JSON.parse(params)
return JSON.stringify(parsed, null, 2)
} catch {
return params
}
}
const getOperationIcon = (operation: string) => {
if (!operation) return Document
const op = operation.toLowerCase()
if (op.includes('登录') || op.includes('login')) return User
if (op.includes('删除') || op.includes('delete')) return Delete
if (op.includes('编辑') || op.includes('修改') || op.includes('update') || op.includes('edit')) return Edit
if (op.includes('查看') || op.includes('查询') || op.includes('view') || op.includes('get')) return View
if (op.includes('新增') || op.includes('创建') || op.includes('add') || op.includes('create')) return Plus
if (op.includes('下载') || op.includes('download')) return Download
if (op.includes('设置') || op.includes('配置') || op.includes('setting') || op.includes('config')) return Setting
if (op.includes('密码') || op.includes('password')) return Lock
return Document
}
onMounted(() => fetchData())
</script>
<style scoped lang="css">
.operation-log {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.search-section {
display: flex;
gap: 8px;
align-items: center;
}
}
.operation-cell {
display: flex;
align-items: center;
gap: 8px;
.operation-icon {
font-size: 16px;
color: #409eff;
}
}
.params-content {
.params-preview {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #606266;
font-size: 13px;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #409eff;
}
}
.params-detail {
max-height: 300px;
overflow-y: auto;
background: #f5f7fa;
padding: 12px;
border-radius: 4px;
font-size: 12px;
color: #303133;
margin: 0;
line-height: 1.5;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
}
}
</style>
@@ -1,185 +0,0 @@
<template>
<div class="config-management">
<el-card>
<template #header>
<div class="card-title">
<span>参数配置</span>
<el-button
type="primary"
@click="handleAdd"
>
新增配置
</el-button>
</div>
</template>
<el-table
v-loading="loading"
:data="dataSource"
style="width: 100%"
>
<el-table-column
prop="id"
label="ID"
/>
<el-table-column
prop="configName"
label="参数名称"
/>
<el-table-column
prop="configKey"
label="参数键名"
/>
<el-table-column
prop="configValue"
label="参数值"
/>
<el-table-column label="类型">
<template #default="{ row }">
<el-tag
:type="row.configType === 'Y' ? 'info' : 'success'"
effect="dark"
style="font-weight: 500; font-size: 14px;"
>
{{ row.configType === 'Y' ? '内置' : '自定义' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="操作"
width="150"
>
<template #default="{ row }">
<el-button
type="primary"
link
size="small"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="danger"
link
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="500px"
>
<el-form
:model="formState"
label-width="80px"
>
<el-form-item label="参数名称">
<el-input v-model="formState.configName" />
</el-form-item>
<el-form-item label="参数键名">
<el-input v-model="formState.configKey" />
</el-form-item>
<el-form-item label="参数值">
<el-input v-model="formState.configValue" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="modalVisible = false">
取消
</el-button>
<el-button
type="primary"
@click="handleModalOk"
>
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
const loading = ref(false)
const dataSource = ref([])
const modalVisible = ref(false)
const modalTitle = ref('')
const formState = reactive({ id: null, configName: '', configKey: '', configValue: '' })
const fetchData = async () => {
loading.value = true
try {
const res: any = await request.get('/config')
dataSource.value = res
} finally {
loading.value = false
}
}
const handleAdd = () => {
modalTitle.value = '新增配置'
Object.assign(formState, { id: null, configName: '', configKey: '', configValue: '' })
modalVisible.value = true
}
const handleEdit = (row: any) => {
modalTitle.value = '编辑配置'
Object.assign(formState, row)
modalVisible.value = true
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除该配置吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await request.delete(`/config/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
console.error('删除配置失败:', error)
}
}
const handleModalOk = async () => {
try {
console.log('handleModalOk called, formState:', formState)
if (formState.id) {
console.log('Sending PUT request to /config/' + formState.id)
const response = await request.put(`/config/${formState.id}`, formState)
console.log('PUT response:', response)
} else {
console.log('Sending POST request to /config')
const response = await request.post('/config', formState)
console.log('POST response:', response)
}
ElMessage.success('操作成功')
modalVisible.value = false
fetchData()
} catch (error) {
console.error('handleModalOk error:', error)
ElMessage.error('操作失败')
}
}
onMounted(() => fetchData())
</script>
<style scoped lang="css">
.config-management .card-title {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
@@ -1,194 +0,0 @@
<template>
<div class="dict-management">
<el-card>
<template #header>
<div class="card-title">
<span>字典管理</span>
<el-button
type="primary"
@click="handleAdd"
>
新增字典
</el-button>
</div>
</template>
<el-table
v-loading="loading"
:data="dataSource"
style="width: 100%"
>
<el-table-column
prop="id"
label="ID"
/>
<el-table-column
prop="dictName"
label="字典名称"
/>
<el-table-column
prop="dictType"
label="字典类型"
/>
<el-table-column label="状态">
<template #default="{ row }">
<el-tag
:type="row.status === '0' ? 'success' : 'danger'"
effect="dark"
style="font-weight: 500; font-size: 14px;"
>
{{ row.status === '0' ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
/>
<el-table-column
label="操作"
width="200"
>
<template #default="{ row }">
<el-button
type="primary"
link
size="small"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="danger"
link
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="500px"
>
<el-form
:model="formState"
label-width="80px"
>
<el-form-item label="字典名称">
<el-input v-model="formState.dictName" />
</el-form-item>
<el-form-item label="字典类型">
<el-input v-model="formState.dictType" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="formState.status">
<el-option
value="0"
label="正常"
/>
<el-option
value="1"
label="停用"
/>
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="formState.remark"
type="textarea"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="modalVisible = false">
取消
</el-button>
<el-button
type="primary"
@click="handleModalOk"
>
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
const loading = ref(false)
const dataSource = ref([])
const modalVisible = ref(false)
const modalTitle = ref('')
const formState = reactive({ id: null, dictName: '', dictType: '', status: '0', remark: '' })
const fetchData = async () => {
loading.value = true
try {
const res: any = await request.get('/dict/types')
dataSource.value = res
} finally {
loading.value = false
}
}
const handleAdd = () => {
modalTitle.value = '新增字典'
Object.assign(formState, { id: null, dictName: '', dictType: '', status: '0', remark: '' })
modalVisible.value = true
}
const handleEdit = (row: any) => {
modalTitle.value = '编辑字典'
Object.assign(formState, row)
modalVisible.value = true
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除该字典吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await request.delete(`/dict/types/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
console.error('删除字典失败:', error)
}
}
const handleModalOk = async () => {
try {
if (formState.id) {
await request.put(`/dict/types/${formState.id}`, formState)
} else {
await request.post('/dict/types', formState)
}
ElMessage.success('操作成功')
modalVisible.value = false
fetchData()
} catch {
ElMessage.error('操作失败')
}
}
onMounted(() => fetchData())
</script>
<style scoped lang="css">
.dict-management .card-title {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
@@ -1,200 +0,0 @@
<template>
<div class="file-management">
<el-card>
<template #header>
<div class="card-title">
<span>文件管理</span>
<el-upload
:before-upload="handleUpload"
:show-file-list="false"
>
<el-button type="primary">
<el-icon><Upload /></el-icon> 上传文件
</el-button>
</el-upload>
</div>
</template>
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索文件名"
clearable
style="width: 300px"
@input="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<el-table
v-loading="loading"
:data="filteredDataSource"
style="width: 100%"
>
<el-table-column
prop="id"
label="ID"
/>
<el-table-column
prop="fileName"
label="文件名"
/>
<el-table-column
prop="fileSize"
label="文件大小"
/>
<el-table-column label="文件类型">
<template #default="{ row }">
<el-tag :type="getFileTypeTag(row.fileType)">
{{ getFileTypeName(row.fileType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="storageType"
label="存储方式"
/>
<el-table-column
prop="createdAt"
label="上传时间"
>
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column
prop="createBy"
label="上传人"
/>
<el-table-column
label="操作"
width="150"
>
<template #default="{ row }">
<el-button
type="primary"
link
size="small"
@click="handleDownload(row)"
>
下载
</el-button>
<el-button
type="danger"
link
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload, Search } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { formatDateTime } from '@/utils/dateFormat'
const loading = ref(false)
const dataSource = ref([])
const searchKeyword = ref('')
const filteredDataSource = computed(() => {
if (!searchKeyword.value) {
return dataSource.value
}
return dataSource.value.filter((item: any) =>
item.fileName.toLowerCase().includes(searchKeyword.value.toLowerCase())
)
})
const handleSearch = () => {
}
const fetchData = async () => {
loading.value = true
try {
const res: any = await request.get('/files')
dataSource.value = res
} finally {
loading.value = false
}
}
const handleUpload = async (file: File) => {
const formData = new FormData()
formData.append('file', file)
try {
await request.post('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
ElMessage.success('上传成功')
fetchData()
} catch {
ElMessage.error('上传失败')
}
return false
}
const handleDownload = (row: any) => {
const downloadUrl = `/api/files/${row.id}/download`
const link = document.createElement('a')
link.href = downloadUrl
link.download = row.fileName
link.click()
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除该文件吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await request.delete(`/files/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
console.error('删除文件失败:', error)
}
}
const getFileTypeName = (fileType: string) => {
if (!fileType) return '未知'
if (fileType.startsWith('image/')) return '图片'
if (fileType.startsWith('video/')) return '视频'
if (fileType.startsWith('audio/')) return '音频'
if (fileType.includes('pdf')) return 'PDF'
if (fileType.includes('word') || fileType.includes('document')) return 'Word'
if (fileType.includes('excel') || fileType.includes('spreadsheet')) return 'Excel'
return '其他'
}
const getFileTypeTag = (fileType: string): '' | 'success' | 'warning' | 'danger' | 'info' => {
if (!fileType) return 'info'
if (fileType.startsWith('image/')) return 'success'
if (fileType.startsWith('video/')) return 'danger'
if (fileType.startsWith('audio/')) return 'warning'
if (fileType.includes('pdf')) return 'danger'
if (fileType.includes('word')) return ''
if (fileType.includes('excel')) return 'success'
return 'info'
}
onMounted(() => fetchData())
</script>
<style scoped lang="css">
.file-management .card-title {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
@@ -1,222 +0,0 @@
<template>
<div class="notice-management">
<el-card>
<template #header>
<div class="card-title">
<span>通知公告</span>
<el-button
type="primary"
@click="handleAdd"
>
新增公告
</el-button>
</div>
</template>
<el-table
v-loading="loading"
:data="dataSource"
style="width: 100%"
>
<el-table-column
prop="id"
label="ID"
/>
<el-table-column
prop="noticeTitle"
label="公告标题"
/>
<el-table-column
label="公告类型"
width="100"
>
<template #default="{ row }">
<el-tag
:type="row.noticeType === '1' ? 'info' : 'success'"
effect="dark"
style="font-weight: 500; font-size: 14px;"
>
{{ row.noticeType === '1' ? '通知' : '公告' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="状态"
width="80"
>
<template #default="{ row }">
<el-tag
:type="row.status === '0' ? 'success' : 'danger'"
effect="dark"
style="font-weight: 500; font-size: 14px;"
>
{{ row.status === '0' ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="createdAt"
label="发布时间"
>
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column
label="操作"
width="150"
>
<template #default="{ row }">
<el-button
type="primary"
link
size="small"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="danger"
link
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="500px"
>
<el-form
:model="formState"
label-width="80px"
>
<el-form-item label="公告标题">
<el-input v-model="formState.noticeTitle" />
</el-form-item>
<el-form-item label="公告类型">
<el-select v-model="formState.noticeType">
<el-option
value="1"
label="通知"
/>
<el-option
value="2"
label="公告"
/>
</el-select>
</el-form-item>
<el-form-item label="公告内容">
<el-input
v-model="formState.noticeContent"
type="textarea"
:rows="4"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="formState.status">
<el-option
value="0"
label="正常"
/>
<el-option
value="1"
label="停用"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="modalVisible = false">
取消
</el-button>
<el-button
type="primary"
@click="handleModalOk"
>
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
import { formatDateTime } from '@/utils/dateFormat'
const loading = ref(false)
const dataSource = ref([])
const modalVisible = ref(false)
const modalTitle = ref('')
const formState = reactive({ id: null, noticeTitle: '', noticeType: '1', noticeContent: '', status: '0' })
const fetchData = async () => {
loading.value = true
try {
const res: any = await request.get('/notices')
dataSource.value = res
} finally {
loading.value = false
}
}
const handleAdd = () => {
modalTitle.value = '新增公告'
Object.assign(formState, { id: null, noticeTitle: '', noticeType: '1', noticeContent: '', status: '0' })
modalVisible.value = true
}
const handleEdit = (row: any) => {
modalTitle.value = '编辑公告'
Object.assign(formState, row)
modalVisible.value = true
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除该公告吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await request.delete(`/notice/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
console.error('删除通知失败:', error)
}
}
const handleModalOk = async () => {
try {
if (formState.id) {
await request.put(`/notices/${formState.id}`, formState)
} else {
await request.post('/notices', formState)
}
ElMessage.success('操作成功')
modalVisible.value = false
fetchData()
} catch {
ElMessage.error('操作失败')
}
}
onMounted(() => fetchData())
</script>
<style scoped lang="css">
.notice-management .card-title {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
@@ -1,373 +0,0 @@
<template>
<div class="dashboard">
<el-row :gutter="16">
<el-col :span="6">
<el-card
v-loading="loading"
class="stat-card user-card"
>
<el-statistic
title="用户总数"
:value="stats.userCount"
>
<template #prefix>
<el-icon class="stat-icon user-icon">
<User />
</el-icon>
</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card
v-loading="loading"
class="stat-card role-card"
>
<el-statistic
title="角色总数"
:value="stats.roleCount"
>
<template #prefix>
<el-icon class="stat-icon role-icon">
<UserFilled />
</el-icon>
</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card
v-loading="loading"
class="stat-card login-card"
>
<el-statistic
title="今日登录"
:value="stats.todayLogin"
>
<template #prefix>
<el-icon class="stat-icon login-icon">
<ArrowRight />
</el-icon>
</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card
v-loading="loading"
class="stat-card log-card"
>
<el-statistic
title="操作日志"
:value="stats.operationLog"
>
<template #prefix>
<el-icon class="stat-icon log-icon">
<Document />
</el-icon>
</template>
</el-statistic>
</el-card>
</el-col>
</el-row>
<el-row
:gutter="16"
style="margin-top: 16px"
>
<el-col :span="12">
<el-card
v-loading="loading"
title="最近登录"
class="recent-login-card"
>
<template #header>
<div class="card-header">
<span class="card-title">最近登录</span>
<el-icon class="header-icon">
<Clock />
</el-icon>
</div>
</template>
<el-timeline>
<el-timeline-item
v-for="item in recentLogins"
:key="item.id"
:type="item.status === '0' ? 'success' : 'danger'"
:timestamp="formatDateTime(item.loginTime)"
placement="top"
>
<div class="login-item">
<div class="login-user">
<el-icon><User /></el-icon>
<span>{{ item.username }}</span>
</div>
<div class="login-ip">
<el-icon><Location /></el-icon>
<span>{{ item.ip }}</span>
</div>
</div>
</el-timeline-item>
<el-timeline-item
v-if="recentLogins.length === 0"
placement="top"
>
<div class="empty-tip">
暂无登录记录
</div>
</el-timeline-item>
</el-timeline>
</el-card>
</el-col>
<el-col :span="12">
<el-card
v-loading="loading"
title="系统信息"
class="system-info-card"
>
<template #header>
<div class="card-header">
<span class="card-title">系统信息</span>
<el-icon class="header-icon">
<Setting />
</el-icon>
</div>
</template>
<el-descriptions
:column="1"
border
>
<el-descriptions-item label="系统版本">
<div class="info-item">
<el-icon><Star /></el-icon>
<span>{{ systemInfo.version }}</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="Java版本">
<div class="info-item">
<el-icon><Cpu /></el-icon>
<span>{{ systemInfo.javaVersion }}</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="前端框架">
<div class="info-item">
<el-icon><Monitor /></el-icon>
<span>{{ systemInfo.frontendFramework }}</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="数据库">
<div class="info-item">
<el-icon><Coin /></el-icon>
<span>{{ systemInfo.database }}</span>
</div>
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { User, UserFilled, ArrowRight, Document, Clock, Location, Setting, Star, Cpu, Monitor, Coin } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { formatDateTime } from '@/utils/dateFormat'
const loading = ref(false)
const stats = reactive({
userCount: 0,
roleCount: 0,
todayLogin: 0,
operationLog: 0
})
const recentLogins = ref<any[]>([])
const systemInfo = reactive({
version: '1.0.0',
javaVersion: '21',
frontendFramework: 'Vue 3 + Element Plus',
database: 'PostgreSQL'
})
const fetchStats = async () => {
loading.value = true
try {
const [userCountRes, roleCountRes, todayLoginRes, operationLogRes] = await Promise.allSettled([
request.get('/users/count'),
request.get('/roles/count'),
request.get('/logs/login/today/count'),
request.get('/logs/operation/count')
])
stats.userCount = userCountRes.status === 'fulfilled' ? Number(userCountRes.value || 0) : 0
stats.roleCount = roleCountRes.status === 'fulfilled' ? Number(roleCountRes.value || 0) : 0
stats.todayLogin = todayLoginRes.status === 'fulfilled' ? Number(todayLoginRes.value || 0) : 0
stats.operationLog = operationLogRes.status === 'fulfilled' ? Number(operationLogRes.value || 0) : 0
} catch (error) {
console.error('Failed to fetch stats:', error)
} finally {
loading.value = false
}
}
const fetchRecentLogins = async () => {
try {
const res: any = await request.get('/logs/login/recent?limit=10')
recentLogins.value = res || []
} catch (error) {
console.error('Failed to fetch recent logins:', error)
recentLogins.value = []
}
}
onMounted(() => {
fetchStats()
fetchRecentLogins()
})
</script>
<style scoped lang="css">
.dashboard {
padding: 16px;
.stat-card {
transition: all 0.3s ease;
border-radius: 8px;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
.stat-icon {
font-size: 24px;
margin-right: 8px;
}
&.user-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
:deep(.el-statistic__head) {
color: rgba(255, 255, 255, 0.9);
}
:deep(.el-statistic__content) {
color: white;
}
}
&.role-card {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
:deep(.el-statistic__head) {
color: rgba(255, 255, 255, 0.9);
}
:deep(.el-statistic__content) {
color: white;
}
}
&.login-card {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
:deep(.el-statistic__head) {
color: rgba(255, 255, 255, 0.9);
}
:deep(.el-statistic__content) {
color: white;
}
}
&.log-card {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
color: white;
:deep(.el-statistic__head) {
color: rgba(255, 255, 255, 0.9);
}
:deep(.el-statistic__content) {
color: white;
}
}
}
.recent-login-card,
.system-info-card {
border-radius: 8px;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
.card-title {
font-size: 16px;
color: #303133;
}
.header-icon {
color: #409eff;
font-size: 18px;
}
}
.login-item {
.login-user,
.login-ip {
display: flex;
align-items: center;
gap: 6px;
margin: 4px 0;
font-size: 14px;
.el-icon {
color: #409eff;
}
}
.login-user {
font-weight: 500;
color: #303133;
}
.login-ip {
color: #909399;
font-size: 13px;
}
}
.empty-tip {
color: #909399;
font-size: 14px;
padding: 8px 0;
}
.info-item {
display: flex;
align-items: center;
gap: 8px;
.el-icon {
color: #409eff;
font-size: 16px;
}
span {
color: #303133;
}
}
}
}
</style>
@@ -1,45 +0,0 @@
<template>
<div class="forbidden-container">
<el-result
icon="warning"
title="403"
sub-title="抱歉您没有权限访问此页面"
>
<template #extra>
<el-button
type="primary"
@click="goBack"
>
返回上一页
</el-button>
<el-button @click="goHome">
返回首页
</el-button>
</template>
</el-result>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
const goBack = () => {
router.go(-1)
}
const goHome = () => {
router.push('/dashboard')
}
</script>
<style scoped lang="css">
.forbidden-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #f5f7fa;
}
</style>
@@ -1,143 +0,0 @@
<template>
<div class="login-container">
<el-card class="login-card">
<template #header>
<h2>登录 - Novalon 管理系统</h2>
</template>
<el-form
:model="formState"
label-position="top"
@submit.prevent="onFinish"
>
<el-form-item
label="用户名"
prop="username"
:rules="[{ required: true, message: '请输入用户名', trigger: 'blur' }]"
>
<el-input
v-model="formState.username"
placeholder="请输入用户名"
/>
</el-form-item>
<el-form-item
label="密码"
prop="password"
:rules="[{ required: true, message: '请输入密码', trigger: 'blur' }]"
>
<el-input
v-model="formState.password"
type="password"
placeholder="请输入密码"
show-password
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
native-type="submit"
:loading="loading"
style="width: 100%"
>
登录
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import { onMounted } from 'vue'
import { jwtDecode } from 'jwt-decode'
import { usePermissionStore } from '@/stores/permission'
const router = useRouter()
const loading = ref(false)
const permissionStore = usePermissionStore()
const formState = reactive({
username: '',
password: ''
})
onMounted(() => {
document.title = '登录 - Novalon 管理系统'
})
interface JwtPayload {
userId: number
username: string
roles: string[]
exp: number
iat: number
}
const onFinish = async () => {
loading.value = true
try {
console.log('开始登录请求...')
const res: any = await request.post('/auth/login', formState)
console.log('登录响应:', res)
if (!res || !res.token) {
console.error('登录失败:未收到有效响应')
ElMessage.error('登录失败:未收到有效响应')
return
}
localStorage.setItem('token', res.token)
if (res.userId) {
localStorage.setItem('userId', String(res.userId))
}
if (res.username) {
localStorage.setItem('username', res.username)
}
try {
const decoded = jwtDecode<JwtPayload>(res.token)
if (decoded.roles && Array.isArray(decoded.roles)) {
localStorage.setItem('roles', JSON.stringify(decoded.roles))
}
} catch (decodeError) {
console.warn('解析Token中的角色信息失败:', decodeError)
}
console.log('开始获取用户菜单...')
try {
await permissionStore.fetchUserMenus()
console.log('获取用户菜单成功')
} catch (menuError) {
console.error('获取用户菜单失败:', menuError)
}
ElMessage.success('登录成功')
console.log('准备跳转到首页...')
await router.push('/')
console.log('跳转完成')
} catch (error: any) {
console.error('登录错误:', error)
ElMessage.error(error.response?.data?.message || error.message || '登录失败')
} finally {
loading.value = false
}
}
</script>
<style scoped lang="css">
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: var(--el-color-primary-light-9);
.login-card {
width: 400px;
}
}
</style>
@@ -1,291 +0,0 @@
<template>
<div class="menu-management">
<el-card>
<template #header>
<div class="card-title">
<span>菜单管理</span>
<el-button
type="primary"
@click="handleAdd"
>
新增菜单
</el-button>
</div>
</template>
<el-table
v-loading="loading"
:data="dataSource"
:pagination="false"
row-key="id"
style="width: 100%"
>
<el-table-column
prop="menuName"
label="菜单名称"
/>
<el-table-column
label="菜单类型"
width="100"
>
<template #default="{ row }">
<el-tag
:type="row.menuType === 'M' ? 'info' : row.menuType === 'C' ? 'success' : 'warning'"
effect="dark"
style="font-weight: 500; font-size: 14px;"
>
{{ row.menuType === 'M' ? '目录' : row.menuType === 'C' ? '菜单' : '按钮' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="perms"
label="权限标识"
/>
<el-table-column
prop="component"
label="组件"
/>
<el-table-column
prop="orderNum"
label="排序"
width="80"
/>
<el-table-column
label="状态"
width="80"
>
<template #default="{ row }">
<el-tag :type="row.status === '0' ? 'success' : 'danger'">
{{ row.status === '0' ? '显示' : '隐藏' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="操作"
width="150"
>
<template #default="{ row }">
<el-button
type="primary"
link
size="small"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="danger"
link
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="500px"
>
<el-form
ref="formRef"
:model="formState"
:rules="formRules"
label-width="100px"
>
<el-form-item
label="菜单名称"
prop="menuName"
>
<el-input v-model="formState.menuName" />
</el-form-item>
<el-form-item label="父级菜单">
<el-tree-select
v-model="formState.parentId"
:data="menuTree"
placeholder="请选择父级菜单"
clearable
check-strictly
/>
</el-form-item>
<el-form-item
label="菜单类型"
prop="menuType"
>
<el-select v-model="formState.menuType">
<el-option
value="M"
label="目录"
/>
<el-option
value="C"
label="菜单"
/>
<el-option
value="F"
label="按钮"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="formState.menuType !== 'F'"
label="路由地址"
prop="perms"
>
<el-input v-model="formState.perms" />
</el-form-item>
<el-form-item
v-if="formState.menuType === 'C'"
label="组件路径"
prop="component"
>
<el-input v-model="formState.component" />
</el-form-item>
<el-form-item
label="排序"
prop="orderNum"
>
<el-input-number v-model="formState.orderNum" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="formState.status">
<el-option
value="0"
label="显示"
/>
<el-option
value="1"
label="隐藏"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="modalVisible = false">
取消
</el-button>
<el-button
type="primary"
@click="handleModalOk"
>
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
const loading = ref(false)
const dataSource = ref([])
const menuTree = ref<any[]>([])
const modalVisible = ref(false)
const modalTitle = ref('')
const formRef = ref()
const formState = reactive({
id: null, menuName: '', parentId: 0, menuType: 'C', perms: '', component: '', orderNum: 0, status: '0'
})
const formRules = {
menuName: [
{ required: true, message: '请输入菜单名称', trigger: 'blur' },
{ min: 2, max: 50, message: '菜单名称长度在 2 到 50 个字符', trigger: 'blur' }
],
menuType: [
{ required: true, message: '请选择菜单类型', trigger: 'change' }
],
perms: [
{ required: true, message: '请输入路由地址', trigger: 'blur' },
{ pattern: /^\/[a-zA-Z0-9/_-]*$/, message: '路由地址格式不正确,应以/开头', trigger: 'blur' }
],
component: [
{ required: true, message: '请输入组件路径', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9/-]+$/, message: '组件路径格式不正确', trigger: 'blur' }
],
orderNum: [
{ required: true, message: '请输入排序', trigger: 'blur' },
{ type: 'number', min: 0, message: '排序必须大于等于0', trigger: 'blur' }
]
}
const fetchData = async () => {
loading.value = true
try {
const res: any = await request.get('/menus')
dataSource.value = res
menuTree.value = buildTreeSelect(res)
} finally {
loading.value = false
}
}
const buildTreeSelect = (menus: any[]): any[] => {
return menus.map(m => ({ value: m.id, label: m.menuName, children: m.children ? buildTreeSelect(m.children) : undefined }))
}
const handleAdd = () => {
modalTitle.value = '新增菜单'
Object.assign(formState, { id: null, menuName: '', parentId: 0, menuType: 'C', perms: '', component: '', orderNum: 0, status: '0' })
modalVisible.value = true
}
const handleEdit = (row: any) => {
modalTitle.value = '编辑菜单'
Object.assign(formState, row)
modalVisible.value = true
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除该菜单吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await request.delete(`/menus/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
console.error('删除菜单失败:', error)
}
}
const handleModalOk = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (formState.id) {
await request.put(`/menus/${formState.id}`, formState)
} else {
await request.post('/menus', formState)
}
ElMessage.success('操作成功')
modalVisible.value = false
fetchData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('操作失败')
}
}
}
onMounted(() => fetchData())
</script>
<style scoped lang="css">
.menu-management .card-title {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
@@ -1,469 +0,0 @@
<template>
<div class="role-management">
<el-card>
<template #header>
<div class="card-header">
<div class="search-section">
<el-input
v-model="searchKeyword"
placeholder="搜索角色名称或标识"
clearable
style="width: 300px"
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button
type="primary"
@click="handleSearch"
>
搜索
</el-button>
</div>
<el-button
type="primary"
@click="handleAdd"
>
新增角色
</el-button>
</div>
</template>
<el-table
v-loading="loading"
:data="dataSource"
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column
prop="id"
label="ID"
width="80"
sortable="custom"
/>
<el-table-column
prop="roleName"
label="角色名称"
sortable="custom"
/>
<el-table-column
prop="roleKey"
label="角色标识"
sortable="custom"
/>
<el-table-column
prop="roleSort"
label="显示顺序"
sortable="custom"
/>
<el-table-column
label="状态"
width="100"
>
<template #default="{ row }">
<el-tag
:type="row.status === RoleStatus.ACTIVE ? 'success' : 'danger'"
effect="dark"
style="font-weight: 500; font-size: 14px;"
>
{{ row.status === RoleStatus.ACTIVE ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="createdAt"
label="创建时间"
sortable="custom"
>
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column
label="操作"
width="250"
>
<template #default="{ row }">
<el-button
type="primary"
link
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="warning"
link
@click="handleAssignPermissions(row)"
>
分配权限
</el-button>
<el-button
type="danger"
link
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 16px; justify-content: flex-end"
@current-change="handleTableChange"
@size-change="handleSizeChange"
/>
</el-card>
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="500px"
>
<el-form
ref="formRef"
:model="formState"
:rules="formRules"
label-width="80px"
>
<el-form-item
label="角色名称"
prop="roleName"
required
>
<el-input v-model="formState.roleName" />
</el-form-item>
<el-form-item
label="角色标识"
prop="roleKey"
required
>
<el-input
v-model="formState.roleKey"
:disabled="!!formState.id"
/>
</el-form-item>
<el-form-item
label="显示顺序"
prop="roleSort"
required
>
<el-input-number
v-model="formState.roleSort"
:min="1"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="formState.status">
<el-option
value="ACTIVE"
label="正常"
/>
<el-option
value="INACTIVE"
label="禁用"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="modalVisible = false">
取消
</el-button>
<el-button
type="primary"
@click="handleModalOk"
>
确定
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="permissionDialogVisible"
title="分配权限"
width="600px"
>
<el-tree
ref="permissionTreeRef"
:data="permissionTree"
:props="{ label: 'name', children: 'children' }"
show-checkbox
node-key="id"
:default-checked-keys="selectedPermissions"
check-strictly
/>
<template #footer>
<el-button @click="permissionDialogVisible = false">
取消
</el-button>
<el-button
type="primary"
@click="handleAssignPermissionsOk"
>
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { roleApi, type Role, type CreateRoleRequest, type UpdateRoleRequest, type Permission } from '@/api/role.api'
import { handleApiError } from '@/utils/errorHandler'
import { RoleStatus } from '@/constants/status'
import { formatDateTime } from '@/utils/dateFormat'
const loading = ref(false)
const dataSource = ref<Role[]>([])
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0
})
const sortInfo = reactive({
sortBy: 'id',
sortOrder: 'asc' as 'asc' | 'desc'
})
const modalVisible = ref(false)
const modalTitle = ref('')
const formRef = ref()
const formState = reactive<CreateRoleRequest & { id?: number; status?: RoleStatus }>({
roleName: '',
roleKey: '',
roleSort: 1,
permissions: [],
status: RoleStatus.ACTIVE
})
const formRules = {
roleName: [
{ required: true, message: '请输入角色名称', trigger: 'blur' },
{ min: 2, max: 50, message: '角色名称长度在 2 到 50 个字符', trigger: 'blur' }
],
roleKey: [
{ required: true, message: '请输入角色标识', trigger: 'blur' },
{ min: 2, max: 50, message: '角色标识长度在 2 到 50 个字符', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: '角色标识只能包含字母、数字、下划线和横线', trigger: 'blur' }
],
roleSort: [
{ required: true, message: '请输入显示顺序', trigger: 'blur' },
{ type: 'number', min: 1, message: '显示顺序必须大于0', trigger: 'blur' }
]
}
const permissionDialogVisible = ref(false)
const permissionTreeRef = ref()
const permissionTree = ref<any[]>([])
const selectedPermissions = ref<number[]>([])
const currentRoleId = ref<number | null>(null)
const fetchData = async () => {
loading.value = true
try {
const res = await roleApi.getPage({
page: pagination.current - 1,
size: pagination.pageSize,
sortBy: sortInfo.sortBy,
sortOrder: sortInfo.sortOrder,
roleName: searchKeyword.value || undefined
})
dataSource.value = res.content
pagination.total = Number(res.totalElements) || 0
} catch (error) {
handleApiError(error)
} finally {
loading.value = false
}
}
const handleTableChange = () => {
fetchData()
}
const handleSizeChange = () => {
pagination.current = 1
fetchData()
}
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const handleSortChange = ({ prop, order }: any) => {
sortInfo.sortBy = prop
sortInfo.sortOrder = order === 'ascending' ? 'asc' : 'desc'
fetchData()
}
const handleAdd = () => {
modalTitle.value = '新增角色'
Object.assign(formState, {
id: undefined,
roleName: '',
roleKey: '',
roleSort: 1,
permissions: [],
status: RoleStatus.ACTIVE
})
modalVisible.value = true
}
const handleEdit = (row: Role) => {
modalTitle.value = '编辑角色'
Object.assign(formState, {
id: row.id,
roleName: row.roleName,
roleKey: row.roleKey,
roleSort: row.roleSort,
status: row.status,
permissions: []
})
modalVisible.value = true
}
const handleDelete = async (row: Role) => {
try {
await ElMessageBox.confirm('确定要删除该角色吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await roleApi.delete(row.id)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
if (error !== 'cancel') {
handleApiError(error)
}
}
}
const handleModalOk = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (formState.id) {
const updateData: UpdateRoleRequest = {
roleName: formState.roleName,
roleKey: formState.roleKey,
roleSort: formState.roleSort,
status: formState.status
}
await roleApi.update(formState.id, updateData)
ElMessage.success('更新成功')
} else {
const createData: CreateRoleRequest = {
roleName: formState.roleName,
roleKey: formState.roleKey,
roleSort: formState.roleSort,
permissions: formState.permissions
}
await roleApi.create(createData)
ElMessage.success('创建成功')
}
modalVisible.value = false
fetchData()
} catch (error) {
modalVisible.value = false
if (error !== 'cancel') {
handleApiError(error)
}
}
}
const handleAssignPermissions = async (row: Role) => {
currentRoleId.value = row.id
try {
const allPermissions = await roleApi.getAllPermissions()
permissionTree.value = buildPermissionTree(allPermissions)
const rolePermissions = await roleApi.getPermissions(row.id)
selectedPermissions.value = rolePermissions.map((p: Permission) => p.id)
permissionDialogVisible.value = true
} catch (error) {
handleApiError(error)
}
}
const buildPermissionTree = (permissions: Permission[]): any[] => {
const tree: any[] = []
const resourceMap = new Map<string, Permission[]>()
permissions.forEach(p => {
if (!resourceMap.has(p.resource)) {
resourceMap.set(p.resource, [])
}
resourceMap.get(p.resource)!.push(p)
})
resourceMap.forEach((perms, resource) => {
tree.push({
id: `resource-${resource}`,
name: resource,
children: perms.map(p => ({
id: p.id,
name: p.name
}))
})
})
return tree
}
const handleAssignPermissionsOk = async () => {
if (!currentRoleId.value) return
try {
const checkedNodes = permissionTreeRef.value.getCheckedNodes()
const permissionIds = checkedNodes
.filter((node: any) => typeof node.id === 'number')
.map((node: any) => node.id)
await roleApi.assignPermissions(currentRoleId.value, permissionIds)
ElMessage.success('权限分配成功')
permissionDialogVisible.value = false
fetchData()
} catch (error) {
handleApiError(error)
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped lang="css">
.role-management {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.search-section {
display: flex;
gap: 8px;
align-items: center;
}
}
}
</style>
@@ -1,461 +0,0 @@
<template>
<div class="user-management">
<el-card>
<template #header>
<div class="card-header">
<div class="search-section">
<el-input
v-model="searchKeyword"
placeholder="搜索用户名或邮箱"
clearable
style="width: 300px"
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button
type="primary"
@click="handleSearch"
>
搜索
</el-button>
</div>
<el-button
type="primary"
@click="handleAdd"
>
新增用户
</el-button>
</div>
</template>
<el-table
v-loading="loading"
:data="dataSource"
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column
prop="id"
label="ID"
width="80"
sortable="custom"
/>
<el-table-column
prop="username"
label="用户名"
sortable="custom"
/>
<el-table-column
prop="nickname"
label="昵称"
sortable="custom"
/>
<el-table-column
prop="email"
label="邮箱"
sortable="custom"
/>
<el-table-column
prop="phone"
label="手机号"
sortable="custom"
/>
<el-table-column
label="状态"
width="100"
>
<template #default="{ row }">
<el-tag
:type="row.status === 1 ? 'success' : 'danger'"
effect="dark"
style="font-weight: 500; font-size: 14px;"
>
{{ row.status === 1 ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="createdAt"
label="创建时间"
sortable="custom"
>
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column
label="操作"
width="250"
>
<template #default="{ row }">
<el-button
type="primary"
link
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="warning"
link
@click="handleAssignRoles(row)"
>
分配角色
</el-button>
<el-button
type="danger"
link
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 16px; justify-content: flex-end"
@current-change="handleTableChange"
@size-change="handleSizeChange"
/>
</el-card>
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="500px"
>
<el-form
ref="formRef"
:model="formState"
:rules="formRules"
label-width="80px"
>
<el-form-item
label="用户名"
prop="username"
required
>
<el-input
v-model="formState.username"
:disabled="!!formState.id"
/>
</el-form-item>
<el-form-item
v-if="!formState.id"
label="密码"
prop="password"
required
>
<el-input
v-model="formState.password"
type="password"
/>
</el-form-item>
<el-form-item label="昵称">
<el-input v-model="formState.nickname" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="formState.email" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="formState.phone" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="formState.status">
<el-option
:value="UserStatus.ACTIVE"
label="正常"
/>
<el-option
:value="UserStatus.INACTIVE"
label="禁用"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleModalCancel">
取消
</el-button>
<el-button
type="primary"
@click="handleModalOk"
>
确定
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="roleDialogVisible"
title="分配角色"
width="500px"
>
<el-transfer
v-model="selectedRoles"
:data="allRoles"
:titles="['可选角色', '已分配角色']"
/>
<template #footer>
<el-button @click="roleDialogVisible = false">
取消
</el-button>
<el-button
type="primary"
@click="handleAssignRolesOk"
>
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { userApi, type User, type CreateUserRequest, type UpdateUserRequest } from '@/api/user.api'
import { roleApi, type Role } from '@/api/role.api'
import { handleApiError } from '@/utils/errorHandler'
import { UserStatus } from '@/constants/status'
import { formatDateTime } from '@/utils/dateFormat'
const loading = ref(false)
const dataSource = ref<User[]>([])
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0
})
const sortInfo = reactive({
sortBy: 'id',
sortOrder: 'asc' as 'asc' | 'desc'
})
const modalVisible = ref(false)
const modalTitle = ref('')
const formRef = ref()
const formState = reactive<CreateUserRequest & { id?: string; status?: UserStatus }>({
username: '',
password: '',
nickname: '',
email: '',
phone: '',
roles: [],
status: UserStatus.ACTIVE
})
const formRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 50, message: '用户名长度在 3 到 50 个字符', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: '用户名只能包含字母、数字、下划线和横线', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, max: 20, message: '密码长度在 8 到 20 个字符', trigger: 'blur' },
{ pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/, message: '密码必须包含大小写字母和数字', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
{ max: 100, message: '邮箱长度不能超过100个字符', trigger: 'blur' }
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
]
}
const roleDialogVisible = ref(false)
const selectedRoles = ref<string[]>([])
const allRoles = ref<{ key: string; label: string }[]>([])
const currentUserId = ref<string | null>(null)
const fetchData = async () => {
loading.value = true
try {
const res = await userApi.getPage({
page: pagination.current - 1,
size: pagination.pageSize,
sortBy: sortInfo.sortBy,
sortOrder: sortInfo.sortOrder,
keyword: searchKeyword.value || undefined
})
dataSource.value = res.content
pagination.total = Number(res.totalElements) || 0
} catch (error) {
handleApiError(error)
} finally {
loading.value = false
}
}
const handleTableChange = () => {
fetchData()
}
const handleSizeChange = () => {
pagination.current = 1
fetchData()
}
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const handleSortChange = ({ prop, order }: any) => {
sortInfo.sortBy = prop
sortInfo.sortOrder = order === 'ascending' ? 'asc' : 'desc'
fetchData()
}
const handleAdd = () => {
modalTitle.value = '新增用户'
Object.assign(formState, {
id: undefined,
username: '',
password: '',
nickname: '',
email: '',
phone: '',
roles: [],
status: 'ACTIVE'
})
modalVisible.value = true
}
const handleEdit = (row: User) => {
modalTitle.value = '编辑用户'
Object.assign(formState, {
id: row.id,
username: row.username,
nickname: row.nickname,
email: row.email,
phone: row.phone,
status: row.status,
roles: []
})
modalVisible.value = true
}
const handleDelete = async (row: User) => {
try {
await ElMessageBox.confirm('确定要删除该用户吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await userApi.delete(row.id)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
if (error !== 'cancel') {
handleApiError(error)
}
}
}
const handleModalOk = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (formState.id) {
const updateData: UpdateUserRequest = {
nickname: formState.nickname,
email: formState.email,
phone: formState.phone,
status: formState.status
}
await userApi.update(formState.id, updateData)
ElMessage.success('更新成功')
} else {
const createData: CreateUserRequest = {
username: formState.username,
password: formState.password,
nickname: formState.nickname,
email: formState.email,
phone: formState.phone,
roles: formState.roles
}
await userApi.create(createData)
ElMessage.success('创建成功')
}
modalVisible.value = false
fetchData()
} catch (error) {
modalVisible.value = false
if (error !== 'cancel') {
handleApiError(error)
}
}
}
const handleModalCancel = () => {
modalVisible.value = false
}
const handleAssignRoles = async (row: User) => {
currentUserId.value = row.id
try {
const roles = await roleApi.getAll()
allRoles.value = roles.map((role: Role) => ({
key: String(role.id),
label: role.roleName
}))
selectedRoles.value = row.roles || []
roleDialogVisible.value = true
} catch (error) {
handleApiError(error)
}
}
const handleAssignRolesOk = async () => {
if (!currentUserId.value) return
try {
await userApi.assignRoles(currentUserId.value, selectedRoles.value)
ElMessage.success('角色分配成功')
roleDialogVisible.value = false
fetchData()
} catch (error) {
roleDialogVisible.value = false
handleApiError(error)
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped lang="css">
.user-management {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.search-section {
display: flex;
gap: 8px;
align-items: center;
}
}
}
</style>
+2 -1
View File
@@ -3,8 +3,9 @@
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["node"],
"types": ["node", "vite/client"],
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,