refactor(frontend): 重命名前端项目为 gym-manage-web

This commit is contained in:
张翔
2026-04-17 18:37:45 +08:00
parent deb961c427
commit 45bb89fc7f
140 changed files with 2 additions and 2 deletions
+6
View File
@@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script setup lang="ts">
</script>
@@ -0,0 +1,72 @@
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)
})
})
@@ -0,0 +1,124 @@
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)
})
})
})
@@ -0,0 +1,291 @@
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')
})
})
})
@@ -0,0 +1,167 @@
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()
})
})
})
+44
View File
@@ -0,0 +1,44 @@
import request from '@/utils/request'
export interface LoginRequest {
username: string
password: string
}
export interface LoginResponse {
token: string
user: UserInfo
}
export interface UserInfo {
id: number
username: string
nickname: string
email: string
phone: string
avatar: string
roles: string[]
permissions: string[]
}
export interface UpdatePasswordRequest {
oldPassword: string
newPassword: string
}
export const authApi = {
login: (data: LoginRequest) =>
request.post<LoginResponse>('/auth/login', data),
logout: () =>
request.post<void>('/auth/logout'),
getCurrentUser: () =>
request.get<UserInfo>('/auth/current'),
updatePassword: (data: UpdatePasswordRequest) =>
request.put<void>('/auth/password', data),
refreshToken: () =>
request.post<LoginResponse>('/auth/refresh'),
}
+39
View File
@@ -0,0 +1,39 @@
import request from '@/utils/request'
export interface ExceptionLog {
id?: number
username?: string
operation?: string
method?: string
params?: string
errorMsg?: string
exceptionStack?: string
ip?: string
createTime?: string
}
export interface PageResponse<T> {
content: T[]
totalPages: number
totalElements: number
currentPage: number
size: number
}
export const exceptionLogApi = {
getAll: () => request.get<ExceptionLog[]>('/logs/exception'),
getById: (id: number) => request.get<ExceptionLog>(`/logs/exception/${id}`),
getPage: (params: {
page?: number
size?: number
sort?: string
order?: string
keyword?: string
}) => request.get<PageResponse<ExceptionLog>>('/logs/exception/page', { params }),
getCount: () => request.get<number>('/logs/exception/count'),
create: (data: Partial<ExceptionLog>) => request.post<ExceptionLog>('/logs/exception', data)
}
+41
View File
@@ -0,0 +1,41 @@
import request from '@/utils/request'
export interface OperationLog {
id?: number
username?: string
operation?: string
method?: string
params?: string
result?: string
ip?: string
duration?: number
status?: string
errorMsg?: string
createdAt?: string
}
export interface PageResponse<T> {
content: T[]
totalPages: number
totalElements: number
currentPage: number
size: number
}
export const operationLogApi = {
getAll: () => request.get<OperationLog[]>('/logs/operation'),
getById: (id: number) => request.get<OperationLog>(`/logs/operation/${id}`),
getPage: (params: {
page?: number
size?: number
sort?: string
order?: string
keyword?: string
}) => request.get<PageResponse<OperationLog>>('/logs/operation/page', { params }),
getCount: () => request.get<number>('/logs/operation/count'),
create: (data: Partial<OperationLog>) => request.post<OperationLog>('/logs/operation', data)
}
+82
View File
@@ -0,0 +1,82 @@
import request from '@/utils/request'
import type { PageResponse } from './user.api'
import { RoleStatus } from '@/constants/status'
export interface Role {
id: number
roleName: string
roleKey: string
roleSort: number
status: RoleStatus
permissions: Permission[]
createdAt: string
updatedAt: string
}
export interface Permission {
id: number
name: string
code: string
resource: string
action: string
}
export interface CreateRoleRequest {
roleName: string
roleKey: string
roleSort: number
permissions: number[]
}
export interface UpdateRoleRequest {
roleName?: string
roleKey?: string
roleSort?: number
status?: RoleStatus
permissions?: number[]
}
export interface RolePageRequest {
page: number
size: number
roleName?: string
roleKey?: string
status?: string
sortBy?: string
sortOrder?: 'asc' | 'desc'
}
export const roleApi = {
getAll: () =>
request.get<Role[]>('/roles'),
getPage: (params: RolePageRequest) =>
request.get<PageResponse<Role>>('/roles/page', { params }),
getById: (id: number) =>
request.get<Role>(`/roles/${id}`),
create: (data: CreateRoleRequest) =>
request.post<Role>('/roles', data),
update: (id: number, data: UpdateRoleRequest) =>
request.put<Role>(`/roles/${id}`, data),
delete: (id: number) =>
request.delete<void>(`/roles/${id}`),
batchDelete: (ids: number[]) =>
request.post<void>('/roles/batch-delete', { ids }),
updateStatus: (id: number, status: 'ACTIVE' | 'INACTIVE') =>
request.put<void>(`/roles/${id}/status`, { status }),
assignPermissions: (id: number, permissionIds: number[]) =>
request.post<void>(`/roles/${id}/permissions`, { permissionIds }),
getPermissions: (id: number) =>
request.get<Permission[]>(`/roles/${id}/permissions`),
getAllPermissions: () =>
request.get<Permission[]>('/permissions'),
}
+86
View File
@@ -0,0 +1,86 @@
import request from '@/utils/request'
import { UserStatus } from '@/constants/status'
export interface User {
id: number
username: string
nickname: string
email: string
phone: string
avatar: string
status: UserStatus
roles: number[]
createdAt: string
updatedAt: string
}
export interface CreateUserRequest {
username: string
password: string
nickname: string
email: string
phone: string
roles?: number[]
}
export interface UpdateUserRequest {
nickname?: string
email?: string
phone?: string
avatar?: string
status?: UserStatus
roles?: number[]
}
export interface UserPageRequest {
page: number
size: number
keyword?: string
username?: string
nickname?: string
status?: string
sortBy?: string
sortOrder?: 'asc' | 'desc'
}
export interface PageResponse<T> {
content: T[]
totalElements: number
totalPages: number
size: number
number: number
first: boolean
last: boolean
}
export const userApi = {
getAll: () =>
request.get<User[]>('/users'),
getPage: (params: UserPageRequest) =>
request.get<PageResponse<User>>('/users/page', { params }),
getById: (id: number) =>
request.get<User>(`/users/${id}`),
create: (data: CreateUserRequest) =>
request.post<User>('/users', data),
update: (id: number, data: UpdateUserRequest) =>
request.put<User>(`/users/${id}`, data),
delete: (id: number) =>
request.delete<void>(`/users/${id}`),
batchDelete: (ids: number[]) =>
request.post<void>('/users/batch-delete', { ids }),
resetPassword: (id: number) =>
request.post<void>(`/users/${id}/reset-password`),
updateStatus: (id: number, status: UserStatus) =>
request.put<void>(`/users/${id}/status`, { status }),
assignRoles: (id: number, roleIds: number[]) =>
request.post<void>(`/users/${id}/roles`, { roleIds }),
}
+92
View File
@@ -0,0 +1,92 @@
:root {
--el-color-primary: #409eff;
--el-color-primary-light-9: #53a8ff;
--el-color-primary-light-3: #79bbff;
--el-color-primary-dark-2: #337ecc;
--el-color-success: #67c23a;
--el-color-success-light-9: #85ce61;
--el-color-success-light-3: #a0daee;
--el-color-success-dark-2: #529b2e;
--el-color-warning: #e6a23c;
--el-color-warning-light-9: #ebb563;
--el-color-warning-light-3: #f0c78a;
--el-color-warning-dark-2: #b88230;
--el-color-danger: #f56c6c;
--el-color-danger-light-9: #f78989;
--el-color-danger-light-3: #dd6161;
--el-color-danger-dark-2: #c45656;
--el-color-info: #909399;
--el-color-info-light-9: #a6a9ad;
--el-color-info-light-3: #c8c9cc;
--el-color-info-dark-2: #73767a;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.el-message {
--el-message-bg-color: var(--el-color-success-dark-2);
--el-message-border-color: var(--el-color-success-dark-2);
--el-message-text-color: #ffffff;
font-weight: 500;
font-size: 14px;
}
.el-message--success {
--el-message-bg-color: var(--el-color-success-dark-2);
--el-message-border-color: var(--el-color-success-dark-2);
--el-message-text-color: #ffffff;
}
.el-message--error {
--el-message-bg-color: var(--el-color-danger-dark-2);
--el-message-border-color: var(--el-color-danger-dark-2);
--el-message-text-color: #ffffff;
}
.el-message--warning {
--el-message-bg-color: var(--el-color-warning-dark-2);
--el-message-border-color: var(--el-color-warning-dark-2);
--el-message-text-color: #ffffff;
}
.el-message--info {
--el-message-bg-color: var(--el-color-info-dark-2);
--el-message-border-color: var(--el-color-info-dark-2);
--el-message-text-color: #ffffff;
}
.el-tag.el-tag--light {
color: #ffffff !important;
background-color: var(--el-color-danger-light-9);
border-color: var(--el-color-danger-light-9);
}
.el-tag.el-tag--light.el-tag--success {
background-color: var(--el-color-success-light-9);
border-color: var(--el-color-success-light-9);
}
.el-tag.el-tag--light.el-tag--warning {
background-color: var(--el-color-warning-light-9);
border-color: var(--el-color-warning-light-9);
}
.el-tag.el-tag--light.el-tag--info {
background-color: var(--el-color-info-light-9);
border-color: var(--el-color-info-light-9);
}
.el-tag.el-tag--light.el-tag--danger {
background-color: var(--el-color-danger-light-9);
border-color: var(--el-color-danger-light-9);
}
@@ -0,0 +1,43 @@
<template>
<el-sub-menu
v-if="menu.children && menu.children.length > 0"
:index="String(menu.id)"
>
<template #title>
<el-icon v-if="menu.icon">
<component :is="iconComponents[menu.icon]" />
</el-icon>
<span>{{ menu.name }}</span>
</template>
<menu-item
v-for="child in menu.children"
:key="child.id"
:menu="child"
/>
</el-sub-menu>
<el-menu-item
v-else
:index="menu.path"
>
<el-icon v-if="menu.icon">
<component :is="iconComponents[menu.icon]" />
</el-icon>
<span>{{ menu.name }}</span>
</el-menu-item>
</template>
<script setup lang="ts">
import type { MenuItem as MenuItemType } from '@/stores/permission'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { markRaw, type Component } from 'vue'
const iconComponents: Record<string, Component> = {}
Object.keys(ElementPlusIconsVue).forEach(key => {
iconComponents[key] = markRaw(ElementPlusIconsVue[key as keyof typeof ElementPlusIconsVue])
})
defineProps<{
menu: MenuItemType
}>()
</script>
+87
View File
@@ -0,0 +1,87 @@
/**
* 系统状态值常量定义
*
* 统一前后端状态值,避免不一致导致的功能问题
*
* @author 张翔
* @date 2026-03-24
*/
/**
* 用户状态枚举
*/
export enum UserStatus {
/** 正常 */
ACTIVE = 1,
/** 禁用 */
INACTIVE = 0,
/** 锁定 */
LOCKED = 2
}
/**
* 角色状态枚举
*/
export enum RoleStatus {
/** 正常 */
ACTIVE = 1,
/** 禁用 */
INACTIVE = 0
}
/**
* 菜单状态枚举
*/
export enum MenuStatus {
/** 正常 */
ACTIVE = 1,
/** 禁用 */
INACTIVE = 0
}
/**
* 通知状态枚举
*/
export enum NoticeStatus {
/** 正常 */
ACTIVE = '1',
/** 禁用 */
INACTIVE = '0'
}
/**
* 状态值映射工具类
*/
export class StatusHelper {
/**
* 判断状态是否为正常
*/
static isActive(status: number | string): boolean {
return status === 1 || status === '1' || status === 'ACTIVE'
}
/**
* 判断状态是否为禁用
*/
static isInactive(status: number | string): boolean {
return status === 0 || status === '0' || status === 'INACTIVE'
}
/**
* 获取状态显示文本
*/
static getStatusText(status: number | string): string {
if (this.isActive(status)) return '正常'
if (this.isInactive(status)) return '禁用'
return '未知'
}
/**
* 获取状态标签类型
*/
static getStatusType(status: number | string): 'success' | 'danger' | 'warning' {
if (this.isActive(status)) return 'success'
if (this.isInactive(status)) return 'danger'
return 'warning'
}
}
@@ -0,0 +1,33 @@
import type { Directive, DirectiveBinding } from 'vue'
import { usePermissionStore } from '@/stores/permission'
export const permissionDirective: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const permissionStore = usePermissionStore()
const { arg, value } = binding
const checkType = arg || 'permission'
if (!value) {
console.warn('v-permission 指令需要提供权限值')
el.style.display = 'none'
return
}
let hasAccess = false
if (checkType === 'role') {
hasAccess = permissionStore.hasRole(value)
} else if (checkType === 'permission') {
hasAccess = permissionStore.hasPermission(value)
} else {
console.warn(`未知的权限检查类型: ${checkType}`)
el.style.display = 'none'
return
}
if (!hasAccess) {
el.style.display = 'none'
}
}
}
@@ -0,0 +1,166 @@
<template>
<el-container class="default-layout">
<el-aside
:width="collapsed ? '64px' : '200px'"
class="aside"
>
<div class="logo">
<span v-if="!collapsed">Novalon</span>
<span v-else>N</span>
</div>
<el-menu
:default-active="activeMenu"
class="menu"
:collapse="collapsed"
background-color="#f5f7fa"
text-color="#606266"
active-text-color="#409eff"
router
>
<div v-if="menuTree.length === 0" style="padding: 20px; text-align: center; color: #999;">
菜单加载中...
</div>
<menu-item
v-for="menu in menuTree"
:key="menu.id"
:menu="menu"
/>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<el-icon
class="trigger"
@click="collapsed = !collapsed"
>
<Fold v-if="!collapsed" />
<Expand v-else />
</el-icon>
<div class="header-right">
<el-dropdown @command="handleCommand">
<el-avatar :size="32">
{{ username }}
</el-avatar>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
个人中心
</el-dropdown-item>
<el-dropdown-item
command="logout"
divided
>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="content">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Fold, Expand } from '@element-plus/icons-vue'
import { usePermissionStore } from '@/stores/permission'
import MenuItem from '@/components/MenuItem.vue'
const router = useRouter()
const route = useRoute()
const collapsed = ref(false)
const username = ref(localStorage.getItem('username') || 'Admin')
const permissionStore = usePermissionStore()
const activeMenu = computed(() => route.path)
const menuTree = computed(() => {
return permissionStore.menus
})
const handleCommand = (command: string) => {
if (command === 'profile') {
router.push('/profile')
} else if (command === 'logout') {
permissionStore.clearPermissionData()
localStorage.clear()
router.push('/login')
}
}
onMounted(async () => {
const token = localStorage.getItem('token')
if (!token) {
router.push('/login')
} else if (!permissionStore.loaded) {
permissionStore.initFromStorage()
if (!permissionStore.loaded || permissionStore.menus.length === 0) {
try {
await permissionStore.fetchUserMenus()
} catch (error) {
console.error('获取用户菜单失败:', error)
}
}
}
})
</script>
<style scoped lang="css">
.default-layout {
min-height: 100vh;
}
.aside {
background-color: #f5f7fa;
transition: width 0.3s;
overflow: hidden;
}
.logo {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
color: #303133;
font-size: 20px;
font-weight: bold;
}
.menu {
border-right: none;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 16px;
background: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
.trigger {
font-size: 18px;
cursor: pointer;
transition: color 0.3s;
&:hover { color: #409eff; }
}
.header-right {
display: flex;
align-items: center;
}
}
.content {
margin: 16px;
padding: 16px;
background: #fff;
min-height: calc(100vh - 96px);
}
</style>
+22
View File
@@ -0,0 +1,22 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import router from './router'
import App from './App.vue'
import './assets/styles.css'
import { permissionDirective } from './directives/permission'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(ElementPlus, {
locale: zhCn,
})
app.directive('permission', permissionDirective)
app.mount('#app')
@@ -0,0 +1,24 @@
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');
});
});
@@ -0,0 +1,30 @@
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);
});
});
@@ -0,0 +1,28 @@
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');
});
});
@@ -0,0 +1,25 @@
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']
}
};
@@ -0,0 +1,16 @@
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[];
};
}
@@ -0,0 +1,24 @@
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());
}
}
@@ -0,0 +1,24 @@
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: []
}
};
@@ -0,0 +1,26 @@
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: []
}
};
@@ -0,0 +1,68 @@
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');
});
});
@@ -0,0 +1,79 @@
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'
});
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);
});
});
@@ -0,0 +1,117 @@
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');
});
});
@@ -0,0 +1,76 @@
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;
}
@@ -0,0 +1,131 @@
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);
}
@@ -0,0 +1,59 @@
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);
}
}
@@ -0,0 +1,150 @@
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
@@ -0,0 +1,158 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } 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
@@ -0,0 +1,210 @@
import { defineStore } from 'pinia'
import request from '@/utils/request'
export interface MenuItem {
id: number
name: string
path: string
icon?: string
parentId?: number
sort: number
children?: MenuItem[]
}
interface BackendMenuItem {
id: number
menuName: string
parentId: number
orderNum: number
menuType: string
perms?: string
component?: string
status: number
children?: BackendMenuItem[]
}
function transformMenuData(backendMenus: BackendMenuItem[]): MenuItem[] {
const menuMap = new Map<number, 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
}
}
}
})
@@ -0,0 +1,267 @@
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')
})
})
})
@@ -0,0 +1,261 @@
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)
})
})
})
@@ -0,0 +1,286 @@
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')
})
})
})
@@ -0,0 +1,257 @@
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)
})
})
})
@@ -0,0 +1,247 @@
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)
})
})
})
@@ -0,0 +1,186 @@
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
})
})
})
@@ -0,0 +1,195 @@
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)
})
})
})
@@ -0,0 +1,279 @@
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')
})
})
})
@@ -0,0 +1,231 @@
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')
})
})
})
@@ -0,0 +1,216 @@
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')
})
})
})
@@ -0,0 +1,383 @@
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')
})
})
})
@@ -0,0 +1,423 @@
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')
})
})
})
+12
View File
@@ -0,0 +1,12 @@
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)
})
})
+88
View File
@@ -0,0 +1,88 @@
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,
})
+61
View File
@@ -0,0 +1,61 @@
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,
})
+61
View File
@@ -0,0 +1,61 @@
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()
})
}
@@ -0,0 +1,233 @@
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)
})
})
})
+44
View File
@@ -0,0 +1,44 @@
import { format, parseISO } from 'date-fns'
import { zhCN } from 'date-fns/locale'
export function formatDateTime(date: string | Date | null | undefined): string {
if (!date) {
return '-'
}
try {
const dateObj = typeof date === 'string' ? parseISO(date) : date
return format(dateObj, 'yyyy-MM-dd HH:mm:ss', { locale: zhCN })
} catch (error) {
console.error('时间格式化失败:', error)
return '-'
}
}
export function formatDate(date: string | Date | null | undefined): string {
if (!date) {
return '-'
}
try {
const dateObj = typeof date === 'string' ? parseISO(date) : date
return format(dateObj, 'yyyy-MM-dd', { locale: zhCN })
} catch (error) {
console.error('日期格式化失败:', error)
return '-'
}
}
export function formatTime(date: string | Date | null | undefined): string {
if (!date) {
return '-'
}
try {
const dateObj = typeof date === 'string' ? parseISO(date) : date
return format(dateObj, 'HH:mm:ss', { locale: zhCN })
} catch (error) {
console.error('时间格式化失败:', error)
return '-'
}
}
+113
View File
@@ -0,0 +1,113 @@
import { ElMessage } from 'element-plus'
export interface ApiError {
code: string
message: string
details?: Record<string, any>
timestamp: string
path: string
}
export class ApiErrorHandler {
static handle(error: any): void {
if (!error.response) {
this.handleNetworkError(error)
return
}
const { status, data } = error.response
const apiError = data as ApiError
switch (status) {
case 400:
this.handleBadRequest(apiError)
break
case 401:
this.handleUnauthorized(apiError)
break
case 403:
this.handleForbidden(apiError)
break
case 404:
this.handleNotFound(apiError)
break
case 409:
this.handleConflict(apiError)
break
case 422:
this.handleValidationError(apiError)
break
case 500:
this.handleInternalServerError(apiError)
break
case 502:
case 503:
case 504:
this.handleServiceUnavailable(apiError)
break
default:
this.handleUnknownError(apiError)
}
}
private static handleNetworkError(error: any): void {
ElMessage.error('网络连接失败,请检查网络设置')
console.error('Network Error:', error)
}
private static handleBadRequest(error: ApiError): void {
ElMessage.error(error.message || '请求参数错误')
console.error('Bad Request:', error)
}
private static handleUnauthorized(error: ApiError): void {
ElMessage.error('登录已过期,请重新登录')
localStorage.removeItem('token')
window.location.href = '/login'
console.error('Unauthorized:', error)
}
private static handleForbidden(error: ApiError): void {
ElMessage.error('没有权限访问该资源')
console.error('Forbidden:', error)
}
private static handleNotFound(error: ApiError): void {
ElMessage.error(error.message || '请求的资源不存在')
console.error('Not Found:', error)
}
private static handleConflict(error: ApiError): void {
ElMessage.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)
} else {
ElMessage.error(error.message || '数据验证失败')
}
console.error('Validation Error:', error)
}
private static handleInternalServerError(error: ApiError): void {
ElMessage.error('服务器内部错误,请稍后重试')
console.error('Internal Server Error:', error)
}
private static handleServiceUnavailable(error: ApiError): void {
ElMessage.error('服务暂时不可用,请稍后重试')
console.error('Service Unavailable:', error)
}
private static handleUnknownError(error: ApiError): void {
ElMessage.error(error.message || '未知错误')
console.error('Unknown Error:', error)
}
}
export const handleApiError = (error: any): void => {
ApiErrorHandler.handle(error)
}
+57
View File
@@ -0,0 +1,57 @@
import { usePermissionStore } from '@/stores/permission'
export interface PermissionMapping {
[key: string]: string | string[]
}
const permissionMapping: PermissionMapping = {
'GET /users': 'system:user:list',
'POST /users': 'system:user:add',
'PUT /users': 'system:user:edit',
'DELETE /users': 'system:user:remove',
'GET /roles': 'system:role:list',
'POST /roles': 'system:role:add',
'PUT /roles': 'system:role:edit',
'DELETE /roles': 'system:role:remove',
'GET /menus': 'system:menu:list',
'POST /menus': 'system:menu:add',
'PUT /menus': 'system:menu:edit',
'DELETE /menus': 'system:menu:remove',
'GET /dict': 'system:dict:list',
'POST /dict': 'system:dict:add',
'PUT /dict': 'system:dict:edit',
'DELETE /dict': 'system:dict:remove',
'GET /sys/config': 'system:config:list',
'POST /sys/config': 'system:config:add',
'PUT /sys/config': 'system:config:edit',
'DELETE /sys/config': 'system:config:remove',
'GET /files': 'system:file:list',
'POST /files': 'system:file:upload',
'DELETE /files': 'system:file:delete',
}
export function checkApiPermission(method: string, url: string): boolean {
const permissionStore = usePermissionStore()
const key = `${method.toUpperCase()} ${url.split('?')[0]}`
const requiredPermission = permissionMapping[key]
if (!requiredPermission) {
return true
}
if (key === 'GET /menus') {
return true
}
if (Array.isArray(requiredPermission)) {
return requiredPermission.some(p => permissionStore.hasPermission(p))
}
return permissionStore.hasPermission(requiredPermission)
}
export function getRequiredPermission(method: string, url: string): string | string[] | null {
const key = `${method.toUpperCase()} ${url.split('?')[0]}`
return permissionMapping[key] || null
}
+68
View File
@@ -0,0 +1,68 @@
import axios, { AxiosRequestConfig } from 'axios'
import { generateSignatureHeaders } from './signature'
import { checkApiPermission } from './permission'
const request = axios.create({
baseURL: '/api',
timeout: 10000
})
request.interceptors.request.use(
(config: AxiosRequestConfig) => {
const token = localStorage.getItem('token')
if (token) {
config.headers = config.headers || {}
config.headers.Authorization = `Bearer ${token}`
}
const method = config.method?.toUpperCase() || 'GET'
let url = config.url || ''
const body = config.data
if (config.params && Object.keys(config.params).length > 0) {
const queryParams = new URLSearchParams()
Object.entries(config.params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
queryParams.append(key, String(value))
}
})
const queryString = queryParams.toString()
if (queryString) {
url += (url.includes('?') ? '&' : '?') + queryString
}
}
const fullPath = `/api${url.startsWith('/') ? url : '/' + url}`
const signatureHeaders = generateSignatureHeaders(method, fullPath, body)
config.headers = config.headers || {}
Object.assign(config.headers, signatureHeaders)
if (!checkApiPermission(method, url)) {
const error = new Error('无权限访问此接口')
;(error as any).response = {
status: 403,
data: { message: '无权限访问此接口' }
}
return Promise.reject(error)
}
return config
},
(error) => Promise.reject(error)
)
request.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login'
}
}
return Promise.reject(error)
}
)
export default request
+96
View File
@@ -0,0 +1,96 @@
import CryptoJS from 'crypto-js'
const SIGNATURE_SECRET = import.meta.env.VITE_SIGNATURE_SECRET || 'NovalonManageSystemSecretKey2026'
export interface SignatureHeaders {
'X-Signature': string
'X-Timestamp': string
'X-Nonce': string
}
export function generateSignature(
method: string,
path: string,
query: string = '',
body: string = '',
timestamp: number,
nonce: string
): string {
const stringToSign = buildStringToSign(method, path, query, '', timestamp, nonce)
const signature = CryptoJS.HmacSHA256(stringToSign, SIGNATURE_SECRET)
const signatureBase64 = CryptoJS.enc.Base64.stringify(signature)
return signatureBase64
}
export function generateSignatureHeaders(
method: string,
url: string,
body?: any
): SignatureHeaders {
const timestamp = Date.now()
const nonce = generateNonce()
const { path, query } = parseUrl(url)
const bodyString = body ? JSON.stringify(body) : ''
const signature = generateSignature(
method.toUpperCase(),
path,
query || '',
bodyString,
timestamp,
nonce
)
return {
'X-Signature': signature,
'X-Timestamp': timestamp.toString(),
'X-Nonce': nonce
}
}
function buildStringToSign(
method: string,
path: string,
query: string,
body: string,
timestamp: number,
nonce: string
): string {
return [
method,
path,
query || '',
body || '',
timestamp.toString(),
nonce
].join('\n')
}
function generateNonce(): string {
const timestamp = Date.now().toString(36)
const randomPart = Math.random().toString(36).substring(2, 15)
return `${timestamp}-${randomPart}`
}
function parseUrl(url: string): { path: string; query: string } {
if (url.startsWith('http://') || url.startsWith('https://')) {
const urlObj = new URL(url)
return {
path: urlObj.pathname,
query: urlObj.search.substring(1)
}
}
const queryIndex = url.indexOf('?')
if (queryIndex === -1) {
return { path: url, query: '' }
}
return {
path: url.substring(0, queryIndex),
query: url.substring(queryIndex + 1)
}
}
@@ -0,0 +1,235 @@
<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>
+176
View File
@@ -0,0 +1,176 @@
<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>
@@ -0,0 +1,311 @@
<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>
@@ -0,0 +1,179 @@
<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 {
if (formState.id) {
await request.put(`/config/${formState.id}`, formState)
} else {
await request.post('/config', formState)
}
ElMessage.success('操作成功')
modalVisible.value = false
fetchData()
} catch {
ElMessage.error('操作失败')
}
}
onMounted(() => fetchData())
</script>
<style scoped lang="css">
.config-management .card-title {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
@@ -0,0 +1,194 @@
<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/${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>
@@ -0,0 +1,200 @@
<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>
@@ -0,0 +1,222 @@
<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>
@@ -0,0 +1,373 @@
<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' ? (userCountRes.value || 0) : 0
stats.roleCount = roleCountRes.status === 'fulfilled' ? (roleCountRes.value || 0) : 0
stats.todayLogin = todayLoginRes.status === 'fulfilled' ? (todayLoginRes.value || 0) : 0
stats.operationLog = operationLogRes.status === 'fulfilled' ? (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>
@@ -0,0 +1,45 @@
<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>
+136
View File
@@ -0,0 +1,136 @@
<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 {
const res: any = await request.post('/auth/login', formState)
if (!res || !res.token) {
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)
}
try {
await permissionStore.fetchUserMenus()
} catch (menuError) {
console.error('获取用户菜单失败:', menuError)
}
ElMessage.success('登录成功')
await router.push('/')
} 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>
@@ -0,0 +1,291 @@
<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>
@@ -0,0 +1,469 @@
<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,
name: searchKeyword.value || undefined
})
dataSource.value = res.content
pagination.total = res.totalElements
} 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>
@@ -0,0 +1,460 @@
<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, StatusHelper } 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?: number; 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<number[]>([])
const allRoles = ref<{ key: number; label: string }[]>([])
const currentUserId = ref<number | 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 = res.totalElements
} 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: 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) {
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>