feat(admin): 添加用户管理相关文件

添加用户管理视图、API和状态管理文件
This commit is contained in:
张翔
2026-03-28 14:37:29 +08:00
commit 08ea5fbe98
1643 changed files with 255646 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
<template>
<a-config-provider :locale="antLocale">
<router-view />
</a-config-provider>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import zhCN from 'ant-design-vue/es/locale/zh_CN';
import enUS from 'ant-design-vue/es/locale/en_US';
const { locale } = useI18n();
const antLocale = computed(() => {
return locale.value === 'zh-CN' ? zhCN : enUS;
});
</script>
<style scoped>
</style>
@@ -0,0 +1,61 @@
import request from '@/utils/request';
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
token: string;
user: {
id: number;
username: string;
email: string;
phone: string;
status: string;
createBy: string;
updateBy: string;
createdAt: string;
updatedAt: string;
};
}
export interface RegisterRequest {
username: string;
password: string;
email: string;
phone: string;
}
export interface SysLoginResponse {
token: string;
user: {
id: number;
username: string;
email: string;
phone: string;
status: string;
createBy: string;
updateBy: string;
createdAt: string;
updatedAt: string;
};
}
export const authApi = {
login: (data: LoginRequest) => {
return request.post<SysLoginResponse>('/sys/auth/login', data);
},
logout: () => {
return request.post('/sys/auth/logout');
},
register: (data: RegisterRequest) => {
return request.post('/sys/auth/register', data);
},
refreshToken: (token: string) => {
return request.post<SysLoginResponse>(`/sys/auth/refresh/${token}`);
}
};
@@ -0,0 +1,4 @@
export * from './auth';
export * from './menu';
export * from './role';
export * from './user';
@@ -0,0 +1,85 @@
import request from '@/utils/request';
export interface Menu {
id?: number;
parentId?: number;
name: string;
code: string;
type?: 'MENU' | 'BUTTON';
path?: string;
component?: string;
icon?: string;
sort?: number;
sortOrder?: number;
status?: string;
description?: string;
children?: Menu[];
createdAt?: string;
updatedAt?: string;
}
export interface MenuTree extends Menu {
children?: MenuTree[];
}
export interface MenuQueryParams {
name?: string;
code?: string;
type?: 'MENU' | 'BUTTON';
status?: 'ENABLED' | 'DISABLED';
current?: number;
size?: number;
}
export interface MenuPageResponse {
records: Menu[];
total: number;
current: number;
size: number;
}
export const menuApi = {
getMenus: (params?: MenuQueryParams) => {
return request.get<MenuPageResponse>('/sys/menu', { params });
},
getMenuTree: () => {
return request.get<MenuTree[]>('/sys/menu/tree');
},
getMenuById: (id: number) => {
return request.get<Menu>(`/sys/menu/${id}`);
},
createMenu: (data: Menu) => {
return request.post<Menu>('/sys/menu', data);
},
updateMenu: (id: number, data: Menu) => {
return request.put<Menu>(`/sys/menu/${id}`, data);
},
deleteMenu: (id: number) => {
return request.delete(`/sys/menu/${id}`);
},
getMenusByRole: (roleId: number) => {
return request.get<Menu[]>(`/sys/role/${roleId}/menus`);
},
assignMenusToRole: (roleId: number, menuIds: number[]) => {
return request.post(`/sys/role/${roleId}/menus`, { menuIds });
},
assignMenuToRole: (data: { roleId: number; menuIds: number[] }) => {
return request.post('/sys/menu/assign', data);
},
getMenusByUserId: (userId: number) => {
return request.get<Menu[]>(`/sys/menu/user/${userId}`);
},
removeMenuFromRole: (roleId: number, menuId: number) => {
return request.delete(`/sys/menu/role/${roleId}/menu/${menuId}`);
}
};
@@ -0,0 +1,16 @@
import request from '@/utils/request';
import type { OperationLog, OperationLogQueryParams, PageResult, Result } from '@/types';
export const operationLogApi = {
query: (params: OperationLogQueryParams) => {
return request.post<Result<PageResult<OperationLog>>>('/sys/operationLog/query', params);
},
getById: (id: number) => {
return request.get<Result<OperationLog>>(`/sys/operationLog/${id}`);
},
delete: (id: number) => {
return request.delete<Result<void>>(`/sys/operationLog/${id}`);
}
};
@@ -0,0 +1,67 @@
import request from '@/utils/request';
export interface Role {
id?: number;
roleName: string;
roleCode: string;
description?: string;
createTime?: string;
updateTime?: string;
}
export interface RoleQuery {
roleName?: string;
roleCode?: string;
page?: number;
size?: number;
}
export interface RoleResponse {
code: string;
message: string;
data: Role[];
}
export interface RoleDetailResponse {
code: string;
message: string;
data: Role;
}
export const roleApi = {
getRoles: (params?: RoleQuery) => {
return request.get<RoleResponse>('/sys/role', { params });
},
getRoleById: (id: number) => {
return request.get<RoleDetailResponse>(`/sys/role/${id}`);
},
createRole: (data: Role) => {
return request.post<RoleDetailResponse>('/sys/role', data);
},
updateRole: (data: Role) => {
return request.put<RoleDetailResponse>('/sys/role', data);
},
deleteRole: (id: number) => {
return request.delete<RoleDetailResponse>(`/sys/role/${id}`);
},
getRoleByRoleKey: (roleKey: string) => {
return request.get<RoleDetailResponse>(`/sys/role/roleKey/${roleKey}`);
},
assignRoleToUser: (data: { userId: number; roleIds: number[] }) => {
return request.post('/sys/role/assign', data);
},
getUserRoles: (userId: number) => {
return request.get<RoleResponse>(`/sys/role/user/${userId}`);
},
removeUserRole: (userId: number, roleId: number) => {
return request.delete(`/sys/role/user/${userId}/role/${roleId}`);
}
};
@@ -0,0 +1,65 @@
import request from '@/utils/request'
export interface Role {
id?: number
roleName: string
roleCode: string
description?: string
createTime?: string
updateTime?: string
}
export interface RoleQuery {
roleName?: string
roleCode?: string
page?: number
size?: number
}
export interface RoleResponse {
code: string
message: string
data: Role[]
}
export interface RoleDetailResponse {
code: string
message: string
data: Role
}
export const getRoles = (params?: RoleQuery) => {
return request.get<Role[]>('/sys/role', { params })
}
export const getRoleById = (id: number) => {
return request.get<Role>(`/sys/role/${id}`)
}
export const createRole = (data: Role) => {
return request.post<Role>('/sys/role', data)
}
export const updateRole = (data: Role) => {
return request.put<Role>('/sys/role', data)
}
export const deleteRole = (id: number) => {
return request.delete<boolean>(`/sys/role/${id}`)
}
export const getRoleByRoleKey = (roleKey: string) => {
return request.get<Role>(`/sys/role/roleKey/${roleKey}`)
}
export const assignRoleToUser = (userId: number, roleIds: number[]) => {
return request.post('/sys/role/assign', { userId, roleIds })
}
export const getUserRoles = (userId: number) => {
return request.get<Role[]>(`/sys/role/user/${userId}`)
}
export const removeUserRole = (userId: number, roleId: number) => {
return request.delete(`/sys/role/user/${userId}/role/${roleId}`)
}
@@ -0,0 +1,55 @@
import request from '@/utils/request';
export interface User {
id?: number;
username: string;
password?: string;
email: string;
phone?: string;
status?: 'ENABLED' | 'DISABLED';
createBy?: string;
updateBy?: string;
createdAt?: string;
updatedAt?: string;
}
export interface UserQueryParams {
username?: string;
email?: string;
status?: 'ENABLED' | 'DISABLED';
current?: number;
size?: number;
}
export interface UserPageResponse {
records: User[];
total: number;
current: number;
size: number;
}
export const userApi = {
getUsers: (params?: UserQueryParams) => {
return request.get<UserPageResponse>('/sys/user', { params });
},
getUserById: (id: number) => {
return request.get<User>(`/sys/user/${id}`);
},
getUserByUsername: (username: string) => {
return request.get<User>(`/sys/user/username/${username}`);
},
createUser: (data: User) => {
return request.post<User>('/sys/user', data);
},
updateUser: (id: number, data: User) => {
return request.put<User>(`/sys/user/${id}`, data);
},
deleteUser: (id: number) => {
return request.delete(`/sys/user/${id}`);
}
};
@@ -0,0 +1,271 @@
<template>
<a-layout-header class="header">
<div class="header-left">
<a-button
type="text"
class="collapse-btn"
@click="toggleSidebar"
>
<template #icon>
<MenuUnfoldOutlined v-if="collapsed" />
<MenuFoldOutlined v-else />
</template>
</a-button>
<a-breadcrumb class="breadcrumb">
<a-breadcrumb-item>
<HomeOutlined />
{{ t('layout.home') }}
</a-breadcrumb-item>
<a-breadcrumb-item v-for="item in breadcrumbItems" :key="item.path">
{{ item.title }}
</a-breadcrumb-item>
</a-breadcrumb>
</div>
<div class="header-right">
<a-space :size="16">
<a-button
type="text"
class="action-btn"
@click="toggleTheme"
>
<template #icon>
<SunOutlined v-if="!isDarkMode" />
<MoonOutlined v-else />
</template>
</a-button>
<a-button
type="text"
class="action-btn"
@click="toggleLanguage"
>
<template #icon>
<GlobalOutlined />
</template>
</a-button>
<a-badge :count="notificationCount" :offset="[-5, 5]">
<a-button type="text" class="action-btn">
<template #icon>
<BellOutlined />
</template>
</a-button>
</a-badge>
<a-dropdown placement="bottomRight">
<div class="user-info">
<a-avatar :size="32" class="user-avatar">
<template #icon>
<UserOutlined />
</template>
</a-avatar>
<span class="user-name">{{ user?.username || t('layout.userCenter') }}</span>
<DownOutlined class="dropdown-icon" />
</div>
<template #overlay>
<a-menu>
<a-menu-item key="profile" @click="handleProfile">
<UserOutlined />
{{ t('layout.userCenter') }}
</a-menu-item>
<a-menu-item key="settings" @click="handleSettings">
<SettingOutlined />
{{ t('menu.settings') }}
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout" @click="handleLogout">
<LogoutOutlined />
{{ t('layout.logout') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-space>
</div>
</a-layout-header>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAppStore } from '@/stores/app.store';
import { useAuthStore } from '@/stores/auth.store';
import {
MenuUnfoldOutlined,
MenuFoldOutlined,
HomeOutlined,
BellOutlined,
UserOutlined,
SettingOutlined,
LogoutOutlined,
DownOutlined,
SunOutlined,
MoonOutlined,
GlobalOutlined
} from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
const emit = defineEmits<{
toggleSidebar: [];
}>();
const router = useRouter();
const route = useRoute();
const { t, locale } = useI18n();
const appStore = useAppStore();
const authStore = useAuthStore();
const collapsed = computed(() => appStore.sidebarCollapsed);
const isDarkMode = computed(() => appStore.isDarkMode);
const user = computed(() => authStore.user);
const notificationCount = ref(3);
const breadcrumbItems = computed(() => {
const routeMap: Record<string, { title: string; path: string }> = {
'/dashboard': { title: t('menu.dashboard'), path: '/dashboard' },
'/users': { title: t('menu.userManagement'), path: '/users' },
'/roles': { title: t('menu.roleManagement'), path: '/roles' },
'/menus': { title: t('menu.menuManagement'), path: '/menus' },
'/permissions': { title: t('menu.permissionManagement'), path: '/permissions' },
'/charts': { title: t('menu.chartDisplay'), path: '/charts' },
'/reports': { title: t('menu.reportStatistics'), path: '/reports' }
};
const currentRoute = routeMap[route.path];
return currentRoute ? [currentRoute] : [];
});
function toggleSidebar() {
emit('toggleSidebar');
appStore.toggleSidebar();
}
function toggleTheme() {
const newTheme = isDarkMode.value ? 'light' : 'dark';
appStore.setTheme(newTheme);
message.success(newTheme === 'dark' ? t('settings.darkTheme') : t('settings.lightTheme'));
}
function toggleLanguage() {
const newLocale = locale.value === 'zh-CN' ? 'en-US' : 'zh-CN';
locale.value = newLocale;
localStorage.setItem('locale', newLocale);
message.success(newLocale === 'zh-CN' ? '已切换到中文' : 'Switched to English');
}
function handleProfile() {
router.push('/profile');
}
function handleSettings() {
router.push('/settings');
}
async function handleLogout() {
try {
await authStore.logout();
message.success(t('auth.logoutSuccess'));
router.push('/login');
} catch (error) {
message.error(t('auth.logoutFailed'));
}
}
</script>
<style scoped>
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
z-index: 10;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.collapse-btn {
font-size: 18px;
color: rgba(0, 0, 0, 0.65);
transition: all 0.3s;
}
.collapse-btn:hover {
color: #1890ff;
background: rgba(24, 144, 255, 0.1);
}
.breadcrumb {
display: flex;
align-items: center;
}
.header-right {
display: flex;
align-items: center;
}
.action-btn {
font-size: 18px;
color: rgba(0, 0, 0, 0.65);
transition: all 0.3s;
}
.action-btn:hover {
color: #1890ff;
background: rgba(24, 144, 255, 0.1);
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
}
.user-info:hover {
background: rgba(24, 144, 255, 0.1);
}
.user-avatar {
background: #1890ff;
}
.user-name {
font-size: 14px;
color: rgba(0, 0, 0, 0.85);
font-weight: 500;
}
.dropdown-icon {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
:deep(.ant-breadcrumb-link) {
display: flex;
align-items: center;
gap: 4px;
color: rgba(0, 0, 0, 0.65);
transition: color 0.3s;
}
:deep(.ant-breadcrumb-link:hover) {
color: #1890ff;
}
:deep(.ant-breadcrumb-separator) {
color: rgba(0, 0, 0, 0.45);
}
</style>
@@ -0,0 +1,289 @@
<template>
<a-layout-sider
v-model:collapsed="collapsed"
:trigger="null"
collapsible
:width="240"
:collapsed-width="64"
class="sidebar"
:class="{ 'sidebar-collapsed': collapsed }"
>
<div class="sidebar-logo">
<template v-if="!collapsed">
<span class="logo-text">管理系统</span>
</template>
<template v-else>
<span class="logo-icon">M</span>
</template>
</div>
<a-menu
v-model:selectedKeys="selectedKeys"
v-model:openKeys="openKeys"
:mode="mode"
:theme="theme"
:inline-collapsed="collapsed"
:items="menuItems"
class="sidebar-menu"
@select="handleMenuSelect"
/>
</a-layout-sider>
</template>
<script setup lang="ts">
import { ref, computed, watch, h, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useAppStore } from "@/stores/app.store";
import { useAuthStore } from "@/stores/auth.store";
import type { ItemType } from "ant-design-vue";
import {
DashboardOutlined,
UserOutlined,
LockOutlined,
MenuOutlined,
SettingOutlined,
TeamOutlined,
SafetyOutlined,
ApartmentOutlined,
FileTextOutlined,
BarChartOutlined,
PieChartOutlined,
LineChartOutlined,
RightOutlined,
} from "@ant-design/icons-vue";
interface Props {
mode?: "vertical" | "inline";
theme?: "light" | "dark";
}
const props = withDefaults(defineProps<Props>(), {
mode: "inline",
theme: "dark",
});
const emit = defineEmits<{
menuSelect: [key: string];
}>();
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const authStore = useAuthStore();
const collapsed = computed(() => appStore.sidebarCollapsed);
const selectedKeys = ref<string[]>([]);
const openKeys = ref<string[]>([]);
const iconMap: Record<string, any> = {
DashboardOutlined,
UserOutlined,
LockOutlined,
MenuOutlined,
SettingOutlined,
TeamOutlined,
SafetyOutlined,
ApartmentOutlined,
FileTextOutlined,
BarChartOutlined,
PieChartOutlined,
LineChartOutlined,
RightOutlined,
};
function getIconComponent(iconName: string) {
return iconMap[iconName] || MenuOutlined;
}
function hasPermission(permission: string | undefined): boolean {
if (!permission) return true;
const userPermissions = authStore.user?.permissions || [];
return userPermissions.includes(permission);
}
function convertRouteToMenuItem(route: any): ItemType | null {
if (route.meta?.hidden) return null;
if (route.meta?.permission && !hasPermission(route.meta.permission)) {
return null;
}
const item: any = {
key: route.name as string,
label: route.meta?.title || route.name,
icon: route.meta?.icon ? h(getIconComponent(route.meta.icon)) : undefined,
};
if (route.children && route.children.length > 0) {
const children = route.children
.map((child: any) => convertRouteToMenuItem(child))
.filter((child: ItemType | null) => child !== null);
if (children.length > 0) {
item.children = children;
}
}
return item;
}
const menuItems = computed<ItemType[]>(() => {
const routes = router.getRoutes();
const mainLayout = routes.find((r) => r.path === "/");
if (!mainLayout || !mainLayout.children) {
return [];
}
const menuRoutes = mainLayout.children.filter((r) => !r.meta?.hidden);
const items = menuRoutes
.map((route) => convertRouteToMenuItem(route))
.filter((item): item is ItemType => item !== null);
return items;
});
function handleMenuSelect({ key }: { key: string }) {
emit("menuSelect", key);
const targetRoute = router.getRoutes().find((r) => r.name === key);
if (targetRoute) {
router.push(targetRoute.path);
}
}
function updateSelectedKeys() {
const currentRoute = router.getRoutes().find((r) => r.path === route.path);
if (currentRoute) {
selectedKeys.value = [currentRoute.name as string];
if (currentRoute.path !== "/" && currentRoute.path !== "/dashboard") {
const parentRoute = router
.getRoutes()
.find(
(r) =>
r.children &&
r.children.some((child: any) => child.path === currentRoute.path)
);
if (parentRoute) {
if (!openKeys.value.includes(parentRoute.name as string)) {
openKeys.value = [...openKeys.value, parentRoute.name as string];
}
}
}
}
}
watch(
() => route.path,
() => {
updateSelectedKeys();
},
{ immediate: true }
);
onMounted(() => {
updateSelectedKeys();
});
</script>
<style scoped>
.sidebar {
background: #001529;
overflow: hidden;
transition: all 0.2s;
}
.sidebar-collapsed {
width: 64px !important;
}
.sidebar-logo {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
margin: 16px;
border-radius: 8px;
transition: all 0.2s;
}
.logo-text {
font-size: 20px;
font-weight: 600;
color: #fff;
letter-spacing: 1px;
}
.logo-icon {
font-size: 24px;
font-weight: 700;
color: #1890ff;
}
.sidebar-menu {
border-right: none;
height: calc(100vh - 96px);
overflow-y: auto;
overflow-x: hidden;
}
.sidebar-menu::-webkit-scrollbar {
width: 6px;
}
.sidebar-menu::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
.sidebar-menu::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
:deep(.ant-menu-item) {
margin: 4px 8px;
border-radius: 6px;
transition: all 0.2s;
}
:deep(.ant-menu-submenu) {
margin: 4px 8px;
border-radius: 6px;
}
:deep(.ant-menu-item-selected) {
background: #1890ff !important;
}
:deep(.ant-menu-item:hover) {
background: rgba(24, 144, 255, 0.2);
}
:deep(.ant-menu-submenu-title:hover) {
background: rgba(255, 255, 255, 0.1);
}
:deep(.ant-menu-submenu-open > .ant-menu-submenu-title) {
background: rgba(255, 255, 255, 0.1);
}
:deep(.ant-menu-item-icon) {
font-size: 16px;
transition: all 0.2s;
}
:deep(.ant-menu-submenu-arrow) {
transition: transform 0.2s;
}
:deep(
.ant-menu-submenu-open > .ant-menu-submenu-title .ant-menu-submenu-arrow
) {
transform: rotate(180deg);
}
</style>
@@ -0,0 +1,17 @@
import { createI18n } from 'vue-i18n';
import zhCN from './locales/zh-CN';
import enUS from './locales/en-US';
const messages = {
'zh-CN': zhCN,
'en-US': enUS
};
const i18n = createI18n({
legacy: false,
locale: localStorage.getItem('locale') || 'zh-CN',
fallbackLocale: 'zh-CN',
messages
});
export default i18n;
@@ -0,0 +1,267 @@
export default {
common: {
confirm: 'Confirm',
cancel: 'Cancel',
save: 'Save',
delete: 'Delete',
edit: 'Edit',
add: 'Add',
search: 'Search',
reset: 'Reset',
view: 'View',
submit: 'Submit',
close: 'Close',
back: 'Back',
loading: 'Loading...',
success: 'Operation successful',
error: 'Operation failed',
warning: 'Warning',
info: 'Info',
yes: 'Yes',
no: 'No',
enable: 'Enable',
disable: 'Disable',
status: 'Status',
action: 'Action',
createTime: 'Create Time',
updateTime: 'Update Time',
creator: 'Creator',
updater: 'Updater',
remark: 'Remark',
description: 'Description',
name: 'Name',
code: 'Code',
sort: 'Sort',
total: 'Total {count} items',
pleaseSelect: 'Please select',
pleaseInput: 'Please input',
required: 'Required',
optional: 'Optional'
},
auth: {
login: 'Login',
logout: 'Logout',
username: 'Username',
password: 'Password',
rememberMe: 'Remember me',
forgotPassword: 'Forgot password',
loginSuccess: 'Login successful',
loginFailed: 'Login failed',
logoutSuccess: 'Logout successful',
logoutFailed: 'Logout failed',
usernamePlaceholder: 'Please enter username',
passwordPlaceholder: 'Please enter password',
usernameRequired: 'Please enter username',
passwordRequired: 'Please enter password',
invalidCredentials: 'Invalid username or password',
tokenExpired: 'Session expired, please login again',
notLoggedIn: 'Please login first',
permissionDenied: 'You do not have permission to access this page'
},
menu: {
dashboard: 'Dashboard',
systemManagement: 'System Management',
userManagement: 'User Management',
roleManagement: 'Role Management',
menuManagement: 'Menu Management',
permissionManagement: 'Permission Management',
dataAnalysis: 'Data Analysis',
chartDisplay: 'Chart Display',
reportStatistics: 'Report Statistics',
settings: 'Settings',
profile: 'Profile'
},
user: {
userList: 'User List',
addUser: 'Add User',
editUser: 'Edit User',
deleteUser: 'Delete User',
viewUser: 'View User',
userInfo: 'User Info',
username: 'Username',
email: 'Email',
phone: 'Phone',
avatar: 'Avatar',
roles: 'Roles',
assignRoles: 'Assign Roles',
deleteConfirm: 'Are you sure you want to delete this user?',
deleteSuccess: 'User deleted successfully',
deleteFailed: 'Failed to delete user',
addSuccess: 'User added successfully',
addFailed: 'Failed to add user',
updateSuccess: 'User updated successfully',
updateFailed: 'Failed to update user',
fetchSuccess: 'User list fetched successfully',
fetchFailed: 'Failed to fetch user list',
status: 'Status',
enabled: 'Enabled',
disabled: 'Disabled'
},
role: {
roleList: 'Role List',
addRole: 'Add Role',
editRole: 'Edit Role',
deleteRole: 'Delete Role',
viewRole: 'View Role',
roleInfo: 'Role Info',
roleName: 'Role Name',
roleKey: 'Role Key',
roleDescription: 'Role Description',
assignPermissions: 'Assign Permissions',
deleteConfirm: 'Are you sure you want to delete this role?',
deleteSuccess: 'Role deleted successfully',
deleteFailed: 'Failed to delete role',
addSuccess: 'Role added successfully',
addFailed: 'Failed to add role',
updateSuccess: 'Role updated successfully',
updateFailed: 'Failed to update role',
fetchSuccess: 'Role list fetched successfully',
fetchFailed: 'Failed to fetch role list',
status: 'Status',
enabled: 'Enabled',
disabled: 'Disabled'
},
menuManagement: {
menuList: 'Menu List',
addMenu: 'Add Menu',
editMenu: 'Edit Menu',
deleteMenu: 'Delete Menu',
viewMenu: 'View Menu',
menuInfo: 'Menu Info',
menuName: 'Menu Name',
menuCode: 'Menu Code',
menuPath: 'Menu Path',
menuIcon: 'Menu Icon',
parentMenu: 'Parent Menu',
sortOrder: 'Sort Order',
deleteConfirm: 'Are you sure you want to delete this menu?',
deleteSuccess: 'Menu deleted successfully',
deleteFailed: 'Failed to delete menu',
addSuccess: 'Menu added successfully',
addFailed: 'Failed to add menu',
updateSuccess: 'Menu updated successfully',
updateFailed: 'Failed to update menu',
fetchSuccess: 'Menu list fetched successfully',
fetchFailed: 'Failed to fetch menu list',
status: 'Status',
enabled: 'Enabled',
disabled: 'Disabled',
addChildMenu: 'Add Child Menu'
},
permission: {
permissionList: 'Permission List',
assignPermissions: 'Assign Permissions',
savePermissions: 'Save Permissions',
permissionInfo: 'Permission Info',
roleName: 'Role Name',
roleList: 'Role List',
permissionAssignment: 'Permission Assignment',
selectRole: 'Please select role',
saveSuccess: 'Permissions saved successfully',
saveFailed: 'Failed to save permissions',
fetchSuccess: 'Permission list fetched successfully',
fetchFailed: 'Failed to fetch permission list',
selectRoleHint: 'Please select a role to view and assign permissions',
permissionHint: 'Check menu items to assign permissions. Child menus will automatically inherit parent menu permissions'
},
dashboard: {
welcome: 'Welcome back',
overview: 'Overview',
statistics: 'Statistics',
recentUsers: 'Recent Users',
systemStatus: 'System Status',
totalUsers: 'Total Users',
totalRoles: 'Total Roles',
totalMenus: 'Total Menus',
activeUsers: 'Active Users',
systemHealth: 'System Health',
cpuUsage: 'CPU Usage',
memoryUsage: 'Memory Usage',
diskUsage: 'Disk Usage',
networkTraffic: 'Network Traffic',
lastLoginTime: 'Last Login Time',
lastLoginIp: 'Last Login IP',
accountStatus: 'Account Status',
createTime: 'Create Time'
},
settings: {
systemSettings: 'System Settings',
basicSettings: 'Basic Settings',
securitySettings: 'Security Settings',
notificationSettings: 'Notification Settings',
languageSettings: 'Language Settings',
themeSettings: 'Theme Settings',
systemName: 'System Name',
systemLogo: 'System Logo',
systemDescription: 'System Description',
changePassword: 'Change Password',
oldPassword: 'Old Password',
newPassword: 'New Password',
confirmPassword: 'Confirm Password',
passwordNotMatch: 'Passwords do not match',
passwordChanged: 'Password changed successfully',
passwordChangeFailed: 'Failed to change password',
enableNotification: 'Enable Notification',
notificationEmail: 'Notification Email',
notificationPhone: 'Notification Phone',
language: 'Language',
theme: 'Theme',
lightTheme: 'Light Theme',
darkTheme: 'Dark Theme',
autoTheme: 'Auto Theme',
saveSuccess: 'Settings saved successfully',
saveFailed: 'Failed to save settings'
},
layout: {
collapseSidebar: 'Collapse Sidebar',
expandSidebar: 'Expand Sidebar',
toggleTheme: 'Toggle Theme',
notifications: 'Notifications',
userCenter: 'User Center',
logout: 'Logout',
home: 'Home',
searchPlaceholder: 'Search...'
},
validation: {
required: '{field} is required',
minLength: '{field} must be at least {min} characters',
maxLength: '{field} must not exceed {max} characters',
pattern: '{field} format is incorrect',
email: 'Please enter a valid email address',
phone: 'Please enter a valid phone number',
url: 'Please enter a valid URL',
number: 'Please enter a valid number',
integer: 'Please enter a valid integer',
positive: 'Please enter a positive number',
range: '{field} must be between {min} and {max}'
},
error: {
networkError: 'Network error, please check your connection',
serverError: 'Server error, please try again later',
requestTimeout: 'Request timeout, please try again later',
unknownError: 'Unknown error',
notFound: 'Requested resource not found',
forbidden: 'You do not have permission to access this resource',
unauthorized: 'Unauthorized, please login first'
},
message: {
operationSuccess: 'Operation successful',
operationFailed: 'Operation failed',
saveSuccess: 'Saved successfully',
saveFailed: 'Failed to save',
deleteSuccess: 'Deleted successfully',
deleteFailed: 'Failed to delete',
updateSuccess: 'Updated successfully',
updateFailed: 'Failed to update',
fetchSuccess: 'Data fetched successfully',
fetchFailed: 'Failed to fetch data',
uploadSuccess: 'Uploaded successfully',
uploadFailed: 'Failed to upload',
downloadSuccess: 'Downloaded successfully',
downloadFailed: 'Failed to download',
importSuccess: 'Imported successfully',
importFailed: 'Failed to import',
exportSuccess: 'Exported successfully',
exportFailed: 'Failed to export'
}
};
@@ -0,0 +1,267 @@
export default {
common: {
confirm: '确认',
cancel: '取消',
save: '保存',
delete: '删除',
edit: '编辑',
add: '新增',
search: '搜索',
reset: '重置',
view: '查看',
submit: '提交',
close: '关闭',
back: '返回',
loading: '加载中...',
success: '操作成功',
error: '操作失败',
warning: '警告',
info: '提示',
yes: '是',
no: '否',
enable: '启用',
disable: '禁用',
status: '状态',
action: '操作',
createTime: '创建时间',
updateTime: '更新时间',
creator: '创建人',
updater: '更新人',
remark: '备注',
description: '描述',
name: '名称',
code: '编码',
sort: '排序',
total: '共 {count} 条',
pleaseSelect: '请选择',
pleaseInput: '请输入',
required: '必填项',
optional: '选填项'
},
auth: {
login: '登录',
logout: '退出登录',
username: '用户名',
password: '密码',
rememberMe: '记住我',
forgotPassword: '忘记密码',
loginSuccess: '登录成功',
loginFailed: '登录失败',
logoutSuccess: '退出登录成功',
logoutFailed: '退出登录失败',
usernamePlaceholder: '请输入用户名',
passwordPlaceholder: '请输入密码',
usernameRequired: '请输入用户名',
passwordRequired: '请输入密码',
invalidCredentials: '用户名或密码错误',
tokenExpired: '登录已过期,请重新登录',
notLoggedIn: '请先登录',
permissionDenied: '您没有权限访问该页面'
},
menu: {
dashboard: '仪表盘',
systemManagement: '系统管理',
userManagement: '用户管理',
roleManagement: '角色管理',
menuManagement: '菜单管理',
permissionManagement: '权限管理',
dataAnalysis: '数据分析',
chartDisplay: '图表展示',
reportStatistics: '报表统计',
settings: '设置',
profile: '个人中心'
},
user: {
userList: '用户列表',
addUser: '新增用户',
editUser: '编辑用户',
deleteUser: '删除用户',
viewUser: '查看用户',
userInfo: '用户信息',
username: '用户名',
email: '邮箱',
phone: '手机号',
avatar: '头像',
roles: '角色',
assignRoles: '分配角色',
deleteConfirm: '确定要删除该用户吗?',
deleteSuccess: '删除用户成功',
deleteFailed: '删除用户失败',
addSuccess: '新增用户成功',
addFailed: '新增用户失败',
updateSuccess: '更新用户成功',
updateFailed: '更新用户失败',
fetchSuccess: '获取用户列表成功',
fetchFailed: '获取用户列表失败',
status: '状态',
enabled: '启用',
disabled: '禁用'
},
role: {
roleList: '角色列表',
addRole: '新增角色',
editRole: '编辑角色',
deleteRole: '删除角色',
viewRole: '查看角色',
roleInfo: '角色信息',
roleName: '角色名称',
roleKey: '角色标识',
roleDescription: '角色描述',
assignPermissions: '分配权限',
deleteConfirm: '确定要删除该角色吗?',
deleteSuccess: '删除角色成功',
deleteFailed: '删除角色失败',
addSuccess: '新增角色成功',
addFailed: '新增角色失败',
updateSuccess: '更新角色成功',
updateFailed: '更新角色失败',
fetchSuccess: '获取角色列表成功',
fetchFailed: '获取角色列表失败',
status: '状态',
enabled: '启用',
disabled: '禁用'
},
menuManagement: {
menuList: '菜单列表',
addMenu: '新增菜单',
editMenu: '编辑菜单',
deleteMenu: '删除菜单',
viewMenu: '查看菜单',
menuInfo: '菜单信息',
menuName: '菜单名称',
menuCode: '菜单标识',
menuPath: '菜单路径',
menuIcon: '菜单图标',
parentMenu: '父菜单',
sortOrder: '排序',
deleteConfirm: '确定要删除该菜单吗?',
deleteSuccess: '删除菜单成功',
deleteFailed: '删除菜单失败',
addSuccess: '新增菜单成功',
addFailed: '新增菜单失败',
updateSuccess: '更新菜单成功',
updateFailed: '更新菜单失败',
fetchSuccess: '获取菜单列表成功',
fetchFailed: '获取菜单列表失败',
status: '状态',
enabled: '启用',
disabled: '禁用',
addChildMenu: '新增子菜单'
},
permission: {
permissionList: '权限列表',
assignPermissions: '分配权限',
savePermissions: '保存权限',
permissionInfo: '权限信息',
roleName: '角色名称',
roleList: '角色列表',
permissionAssignment: '权限分配',
selectRole: '请选择角色',
saveSuccess: '保存权限成功',
saveFailed: '保存权限失败',
fetchSuccess: '获取权限列表成功',
fetchFailed: '获取权限列表失败',
selectRoleHint: '请选择角色以查看和分配权限',
permissionHint: '勾选菜单项即为分配该权限,子菜单会自动继承父菜单的权限'
},
dashboard: {
welcome: '欢迎回来',
overview: '概览',
statistics: '统计',
recentUsers: '最近用户',
systemStatus: '系统状态',
totalUsers: '总用户数',
totalRoles: '总角色数',
totalMenus: '总菜单数',
activeUsers: '活跃用户',
systemHealth: '系统健康度',
cpuUsage: 'CPU 使用率',
memoryUsage: '内存使用率',
diskUsage: '磁盘使用率',
networkTraffic: '网络流量',
lastLoginTime: '最后登录时间',
lastLoginIp: '最后登录IP',
accountStatus: '账号状态',
createTime: '创建时间'
},
settings: {
systemSettings: '系统设置',
basicSettings: '基本设置',
securitySettings: '安全设置',
notificationSettings: '通知设置',
languageSettings: '语言设置',
themeSettings: '主题设置',
systemName: '系统名称',
systemLogo: '系统Logo',
systemDescription: '系统描述',
changePassword: '修改密码',
oldPassword: '旧密码',
newPassword: '新密码',
confirmPassword: '确认密码',
passwordNotMatch: '两次输入的密码不一致',
passwordChanged: '密码修改成功',
passwordChangeFailed: '密码修改失败',
enableNotification: '启用通知',
notificationEmail: '通知邮箱',
notificationPhone: '通知手机号',
language: '语言',
theme: '主题',
lightTheme: '浅色主题',
darkTheme: '深色主题',
autoTheme: '自动主题',
saveSuccess: '保存设置成功',
saveFailed: '保存设置失败'
},
layout: {
collapseSidebar: '收起侧边栏',
expandSidebar: '展开侧边栏',
toggleTheme: '切换主题',
notifications: '通知',
userCenter: '个人中心',
logout: '退出登录',
home: '首页',
searchPlaceholder: '搜索...'
},
validation: {
required: '{field}不能为空',
minLength: '{field}长度不能少于{min}个字符',
maxLength: '{field}长度不能超过{max}个字符',
pattern: '{field}格式不正确',
email: '请输入正确的邮箱地址',
phone: '请输入正确的手机号码',
url: '请输入正确的URL地址',
number: '请输入有效的数字',
integer: '请输入有效的整数',
positive: '请输入正数',
range: '{field}必须在{min}到{max}之间'
},
error: {
networkError: '网络错误,请检查网络连接',
serverError: '服务器错误,请稍后重试',
requestTimeout: '请求超时,请稍后重试',
unknownError: '未知错误',
notFound: '请求的资源不存在',
forbidden: '没有权限访问该资源',
unauthorized: '未授权,请先登录'
},
message: {
operationSuccess: '操作成功',
operationFailed: '操作失败',
saveSuccess: '保存成功',
saveFailed: '保存失败',
deleteSuccess: '删除成功',
deleteFailed: '删除失败',
updateSuccess: '更新成功',
updateFailed: '更新失败',
fetchSuccess: '获取数据成功',
fetchFailed: '获取数据失败',
uploadSuccess: '上传成功',
uploadFailed: '上传失败',
downloadSuccess: '下载成功',
downloadFailed: '下载失败',
importSuccess: '导入成功',
importFailed: '导入失败',
exportSuccess: '导出成功',
exportFailed: '导出失败'
}
};
@@ -0,0 +1,229 @@
<template>
<a-layout class="layout">
<a-layout-sider
v-model:collapsed="collapsed"
:trigger="null"
collapsible
:width="240"
:collapsed-width="64"
class="layout-sider"
>
<div class="logo">
<template v-if="!collapsed">
<span>管理系统</span>
</template>
<template v-else>
<span class="logo-icon">M</span>
</template>
</div>
<a-menu
v-model:selectedKeys="selectedKeys"
v-model:openKeys="openKeys"
mode="inline"
theme="dark"
:inline-collapsed="collapsed"
>
<a-menu-item key="dashboard" @click="handleMenuClick('/dashboard')">
<template #icon>
<DashboardOutlined />
</template>
<span>仪表盘</span>
</a-menu-item>
<a-sub-menu key="system">
<template #icon>
<SettingOutlined />
</template>
<template #title>系统管理</template>
<a-menu-item key="users" @click="handleMenuClick('/users')">
<template #icon>
<UserOutlined />
</template>
<span>用户管理</span>
</a-menu-item>
<a-menu-item key="roles" @click="handleMenuClick('/roles')">
<template #icon>
<LockOutlined />
</template>
<span>角色管理</span>
</a-menu-item>
<a-menu-item key="menus" @click="handleMenuClick('/menus')">
<template #icon>
<MenuOutlined />
</template>
<span>菜单管理</span>
</a-menu-item>
</a-sub-menu>
</a-menu>
</a-layout-sider>
<a-layout>
<a-layout-header class="header">
<div class="header-left">
<MenuUnfoldOutlined
v-if="collapsed"
class="trigger"
@click="toggleSidebar"
/>
<MenuFoldOutlined
v-else
class="trigger"
@click="toggleSidebar"
/>
<a-breadcrumb class="breadcrumb">
<a-breadcrumb-item>首页</a-breadcrumb-item>
<a-breadcrumb-item v-for="item in breadcrumbItems" :key="item">
{{ item }}
</a-breadcrumb-item>
</a-breadcrumb>
</div>
<div class="header-right">
<a-space>
<a-button type="text" :icon="h(BellOutlined)" />
<a-dropdown>
<a class="ant-dropdown-link" @click.prevent>
<UserOutlined />
<span class="user-name">{{ user?.username || '用户' }}</span>
<DownOutlined />
</a>
<template #overlay>
<a-menu>
<a-menu-item key="profile">
<UserOutlined />
个人中心
</a-menu-item>
<a-menu-item key="settings">
<SettingOutlined />
设置
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout" @click="handleLogout">
<LogoutOutlined />
退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-space>
</div>
</a-layout-header>
<a-layout-content class="content">
<div class="content-wrapper">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</a-layout-content>
</a-layout>
</a-layout>
</template>
<script setup lang="ts">
import { ref, computed, h } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import { useAuthStore, useAppStore } from '../stores'
import {
DashboardOutlined,
SettingOutlined,
UserOutlined,
LockOutlined,
MenuOutlined,
MenuUnfoldOutlined,
MenuFoldOutlined,
DownOutlined,
LogoutOutlined,
BellOutlined
} from '@ant-design/icons-vue'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const appStore = useAppStore()
const collapsed = computed(() => appStore.sidebarCollapsed)
const selectedKeys = ref<string[]>(['dashboard'])
const openKeys = ref<string[]>(['system'])
const user = computed(() => authStore.user)
const breadcrumbItems = computed(() => {
const pathMap: Record<string, string> = {
'/dashboard': '仪表盘',
'/users': '用户管理',
'/roles': '角色管理',
'/menus': '菜单管理'
}
const items = []
const currentPath = route.path
if (pathMap[currentPath]) {
items.push(pathMap[currentPath])
}
return items
})
function toggleSidebar() {
appStore.toggleSidebar()
}
function handleMenuClick(path: string) {
router.push(path)
}
async function handleLogout() {
try {
await authStore.logout()
message.success('退出登录成功')
router.push('/login')
} catch (error) {
message.error('退出登录失败')
}
}
</script>
<style scoped lang="scss">
.layout {
min-height: 100vh;
.logo {
height: 32px;
margin: 16px;
color: white;
font-size: 18px;
font-weight: bold;
text-align: center;
}
.header {
background: white;
padding: 0 16px;
display: flex;
justify-content: space-between;
align-items: center;
.header-left {
.trigger {
font-size: 18px;
cursor: pointer;
transition: color 0.3s;
&:hover {
color: #1890ff;
}
}
}
.header-right {
.ant-dropdown-link {
cursor: pointer;
}
}
}
.content {
margin: 16px;
padding: 24px;
background: white;
min-height: 280px;
}
}
</style>
+34
View File
@@ -0,0 +1,34 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'
import router from './router'
import App from './App.vue'
import './styles/index.scss'
import i18n from './i18n'
import { PerformanceMonitor } from './utils/performance'
const app = createApp(App)
// 初始化性能监控
const perfMonitor = PerformanceMonitor.getInstance();
perfMonitor.init();
app.use(createPinia())
app.use(router)
app.use(Antd)
app.use(i18n)
app.mount('#app')
// 页面加载完成后记录性能数据
window.addEventListener('load', () => {
// 可以在这里发送性能数据到监控服务
setTimeout(() => {
const metrics = perfMonitor.getMetrics();
console.log('应用性能指标:', metrics);
// 如果需要,可以发送到监控服务
// sendPerformanceMetrics(metrics);
}, 0);
});
@@ -0,0 +1,124 @@
# Mock API 服务系统
基于 OpenAPI 规范生成模拟响应数据,用于前端开发过程中的数据模拟。
## 概述
Mock API 服务系统旨在为前端开发提供一个完整的模拟后端环境,使前端开发可以独立于后端进行,提高开发效率。
## 功能特性
### 1. Mock 数据生成器
- `generateMockUser()`: 生成符合规范的用户模拟数据
- `generateMockRole()`: 生成符合规范的角色模拟数据
- `generateMockMenu()`: 生成符合规范的菜单模拟数据
- `generatePageResult()`: 生成分页结果
- `successResponse()`: 生成成功响应
- `errorResponse()`: 生成错误响应
- `randomDelay()`: 生成随机网络延迟
### 2. Mock 服务器
- 模拟完整的 CRUD 操作
- 支持所有主要的 API 端点
- 模拟认证流程
- 支持查询参数和分页
### 3. Mock 拦截器
- 自动拦截 API 请求
- 在开发环境中自动启用
- 可随时切换到真实 API
## API 端点支持
### 用户管理
- `GET /sys/user` - 获取用户列表
- `POST /sys/user` - 创建用户
- `PUT /sys/user` - 更新用户
- `GET /sys/user/{id}` - 获取用户详情
- `DELETE /sys/user/{id}` - 删除用户
- `GET /sys/user/username/{username}` - 根据用户名获取用户
### 角色管理
- `GET /sys/role` - 获取角色列表
- `POST /sys/role` - 创建角色
- `PUT /sys/role` - 更新角色
- `GET /sys/role/{id}` - 获取角色详情
- `DELETE /sys/role/{id}` - 删除角色
- `GET /sys/role/roleKey/{roleKey}` - 根据角色键获取角色
- `GET /sys/role/user/{userId}` - 获取用户的角色
### 菜单管理
- `GET /sys/menu` - 获取菜单列表
- `POST /sys/menu` - 创建菜单
- `PUT /sys/menu` - 更新菜单
- `GET /sys/menu/{id}` - 获取菜单详情
- `DELETE /sys/menu/{id}` - 删除菜单
- `GET /sys/menu/user/{userId}` - 获取用户的菜单
### 认证
- `POST /sys/auth/login` - 用户登录
- `POST /sys/auth/logout` - 用户登出
- `POST /sys/auth/refresh/{token}` - 刷新令牌
## 使用方法
### 自动启用
在开发环境中,Mock 系统会自动启用,无需任何配置。
### 手动控制
可以通过环境变量控制 Mock 系统:
```bash
# 启用 Mock(开发环境默认)
VITE_MOCK_ENABLED=true
# 禁用 Mock
VITE_MOCK_ENABLED=false
```
### 模拟数据定制
如果需要特定的模拟数据,可以直接使用 Mock 数据生成器:
```typescript
import { generateMockUser, successResponse } from "@/mocks/mock-data";
// 生成特定用户
const user = generateMockUser(123);
// 生成成功响应
const response = successResponse(user, "User retrieved successfully");
```
## 测试覆盖
Mock 系统已全面测试,覆盖以下场景:
1. **正常请求** - 所有 API 端点的正常操作
2. **异常处理** - 错误响应、无效输入等
3. **边界情况** - 空结果、大页码、特殊参数等
4. **数据一致性** - 响应格式与真实 API 保持一致
## 无缝切换
当后端 API 准备就绪时,只需:
1. 设置 `VITE_MOCK_ENABLED=false`
2. 确保后端服务运行在正确的地址
3. 前端代码无需任何修改即可连接真实 API
## 开发注意事项
- Mock 数据遵循与真实 API 相同的数据结构
- 响应格式与真实 API 完全一致
- HTTP 状态码模拟真实行为
- 网络延迟模拟真实用户体验
@@ -0,0 +1,8 @@
/**
* Mock API服务系统
* 基于OpenAPI规范生成模拟响应数据
* 用于前端开发过程中的数据模拟
*/
export * from './mock-data';
export * from './mock-server';
@@ -0,0 +1,134 @@
/**
* Mock数据生成器
* 基于API文档定义生成符合规范的模拟数据
*/
import type { Result, User, Role, Menu, OperationLog } from '@/types';
// 生成模拟用户数据
export const generateMockUser = (id?: number): User => {
const userId = id || Math.floor(Math.random() * 10000) + 1;
return {
id: userId,
username: `user${userId}`,
email: `user${userId}@example.com`,
phone: `138${Math.random().toString().substring(2, 11).padEnd(11, '0').substring(0, 9)}`,
nickname: `昵称${userId}`,
status: Math.random() > 0.5 ? 'ENABLED' : 'DISABLED',
remark: `用户${userId}的备注`,
createBy: 'admin',
updateBy: 'admin',
createdAt: new Date(Date.now() - Math.random() * 10000000000).toISOString(),
updatedAt: new Date().toISOString(),
permissions: ['user:view', 'user:edit'],
roles: []
};
};
// 生成模拟角色数据
export const generateMockRole = (id?: number): Role => {
const roleId = id || Math.floor(Math.random() * 1000) + 1;
return {
id: roleId,
name: `角色${roleId}`,
roleKey: `role${roleId}`,
description: `角色${roleId}的描述`,
status: Math.random() > 0.5 ? 'ENABLED' : 'DISABLED',
sortOrder: Math.floor(Math.random() * 100),
remark: `角色${roleId}的备注`,
menuIds: [],
createdAt: new Date(Date.now() - Math.random() * 10000000000).toISOString(),
updatedAt: new Date().toISOString()
};
};
// 生成模拟菜单数据
export const generateMockMenu = (id?: number, parentId?: number): Menu => {
const menuId = id || Math.floor(Math.random() * 1000) + 1;
return {
id: menuId,
name: `菜单${menuId}`,
code: `menu${menuId}`,
path: `/menu${menuId}`,
icon: 'MenuOutlined',
parentId: parentId || 0,
sortOrder: Math.floor(Math.random() * 100),
status: Math.random() > 0.5 ? 'ENABLED' : 'DISABLED',
component: `views/Menu${menuId}.vue`,
redirect: '',
description: `菜单${menuId}的描述`,
children: [],
createdAt: new Date(Date.now() - Math.random() * 10000000000).toISOString(),
updatedAt: new Date().toISOString()
};
};
// 生成模拟操作日志数据
export const generateMockOperationLog = (id?: number): OperationLog => {
const logId = id || Math.floor(Math.random() * 10000) + 1;
const modules = ['用户管理', '角色管理', '菜单管理', '权限管理', '操作日志', '系统设置'];
const operations = ['查询', '新增', '修改', '删除', '导出', '导入', '登录', '登出'];
const methods = ['GET', 'POST', 'PUT', 'DELETE'];
const paths = ['/sys/user/query', '/sys/user/create', '/sys/user/update', '/sys/user/delete', '/sys/role/query', '/sys/role/create', '/sys/menu/query', '/sys/operationLog/query'];
const statuses = ['success', 'error'];
const status = statuses[Math.floor(Math.random() * statuses.length)];
const isError = status === 'error';
return {
id: logId,
userId: Math.floor(Math.random() * 10) + 1,
username: `user${Math.floor(Math.random() * 10) + 1}`,
module: modules[Math.floor(Math.random() * modules.length)],
operation: operations[Math.floor(Math.random() * operations.length)],
method: methods[Math.floor(Math.random() * methods.length)],
path: paths[Math.floor(Math.random() * paths.length)],
params: JSON.stringify({
page: Math.floor(Math.random() * 10) + 1,
pageSize: 10,
username: `user${Math.floor(Math.random() * 10) + 1}`
}),
ip: `192.168.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
status,
errorMsg: isError ? '操作失败:权限不足或参数错误' : undefined,
duration: Math.floor(Math.random() * 1000) + 10,
createdAt: new Date(Date.now() - Math.random() * 10000000000).toISOString()
};
};
// 生成分页结果
export const generatePageResult = <T>(data: T[], current: number = 1, pageSize: number = 10) => {
const start = (current - 1) * pageSize;
const end = start + pageSize;
const records = data.slice(start, end);
return {
records,
total: data.length,
current,
size: pageSize
};
};
// 模拟成功响应
export const successResponse = <T>(data: T, message: string = 'Success'): Result<T> => {
return {
code: '200',
message,
data
};
};
// 模拟错误响应
export const errorResponse = <T = any>(code: string = '500', message: string = 'Error'): Result<T> => {
return {
code,
message,
data: null as unknown as T
};
};
// 生成随机延迟
export const randomDelay = (min: number = 200, max: number = 800) => {
const delay = Math.random() * (max - min) + min;
return new Promise(resolve => setTimeout(resolve, delay));
};
@@ -0,0 +1,123 @@
/**
* Mock拦截器
* 用于拦截API请求并返回Mock数据
* 可以在开发环境中启用或禁用
*/
import { mockServer } from './mock-server';
// 存储原始的axios实例方法
let originalGet: any;
let originalPost: any;
let originalPut: any;
let originalDelete: any;
// 检查是否启用Mock模式
const isMockEnabled = () => {
const env = import.meta.env.MODE;
// development-local 和 test 环境使用 Mockdevelopment 和 prod 环境使用真实API
const useMock = env === 'development-local' || env === 'test';
const forceMock = import.meta.env.VITE_MOCK_ENABLED === 'true';
return useMock || forceMock;
};
// 拦截请求方法
const mockRequest = async (method: string, url: string, data?: any, config?: any) => {
if (!isMockEnabled()) {
// 如果未启用Mock模式,调用原始方法
throw new Error('Mock not enabled');
}
// 提取URL参数
const urlParams = new URLSearchParams(config?.params);
const params = Object.fromEntries(urlParams.entries());
// 处理请求并返回axios响应格式
const mockData = await mockServer.handleRequest(url, method, data, params);
return {
data: mockData,
status: 200,
statusText: 'OK',
headers: {},
config: {}
};
};
// 安装Mock拦截器
export const installMockInterceptor = (requestInstance: any) => {
if (!isMockEnabled()) {
console.log('Mock is disabled');
return;
}
console.log('Installing mock interceptor...');
// 保存原始方法
originalGet = requestInstance.get;
originalPost = requestInstance.post;
originalPut = requestInstance.put;
originalDelete = requestInstance.delete;
// 替换为Mock方法
requestInstance.get = async (url: string, config?: any) => {
try {
return await mockRequest('GET', url, undefined, config);
} catch (error) {
// 如果Mock处理失败,调用原始方法
return originalGet.call(requestInstance, url, config);
}
};
requestInstance.post = async (url: string, data?: any, config?: any) => {
try {
return await mockRequest('POST', url, data, config);
} catch (error) {
// 如果Mock处理失败,调用原始方法
return originalPost.call(requestInstance, url, data, config);
}
};
requestInstance.put = async (url: string, data?: any, config?: any) => {
try {
return await mockRequest('PUT', url, data, config);
} catch (error) {
// 如果Mock处理失败,调用原始方法
return originalPut.call(requestInstance, url, data, config);
}
};
requestInstance.delete = async (url: string, config?: any) => {
try {
return await mockRequest('DELETE', url, undefined, config);
} catch (error) {
// 如果Mock处理失败,调用原始方法
return originalDelete.call(requestInstance, url, config);
}
};
};
// 卸载Mock拦截器
export const uninstallMockInterceptor = (requestInstance: any) => {
if (originalGet) {
requestInstance.get = originalGet;
}
if (originalPost) {
requestInstance.post = originalPost;
}
if (originalPut) {
requestInstance.put = originalPut;
}
if (originalDelete) {
requestInstance.delete = originalDelete;
}
};
// 切换Mock模式
export const toggleMock = (requestInstance: any, enabled: boolean) => {
if (enabled) {
installMockInterceptor(requestInstance);
} else {
uninstallMockInterceptor(requestInstance);
}
};
@@ -0,0 +1,605 @@
/**
* Mock服务器
* 拦截API请求并返回模拟数据
* 支持正常请求、异常处理和边界情况
*/
import { generateMockUser, generateMockRole, generateMockMenu, generateMockOperationLog, successResponse, errorResponse, randomDelay, generatePageResult } from './mock-data';
import type { User, Role, Menu, LoginResponse, OperationLog } from '@/types';
// 模拟数据存储
class MockDB {
private users: User[] = [];
private roles: Role[] = [];
private menus: Menu[] = [];
private operationLogs: OperationLog[] = [];
private tokens: Map<string, { userId: number; exp: number }> = new Map();
constructor() {
// 初始化一些默认数据
this.initDefaultData();
}
private initDefaultData() {
// 创建默认用户
for (let i = 1; i <= 10; i++) {
this.users.push(generateMockUser(i));
}
// 创建默认角色
for (let i = 1; i <= 5; i++) {
this.roles.push(generateMockRole(i));
}
// 创建默认菜单 - 包含dashboard菜单
this.menus.push({
id: 1,
name: 'Dashboard',
code: 'dashboard',
path: '/dashboard',
icon: 'DashboardOutlined',
parentId: 0,
sortOrder: 1,
status: 'ENABLED',
component: 'views/dashboard.vue',
redirect: '',
description: 'Dashboard页面',
children: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
});
// 创建其他默认菜单
for (let i = 2; i <= 8; i++) {
this.menus.push(generateMockMenu(i, i > 2 ? Math.floor(i / 2) : 0));
}
// 创建默认操作日志
for (let i = 1; i <= 50; i++) {
this.operationLogs.push(generateMockOperationLog(i));
}
}
// 用户相关操作
getUsers(page?: number, size?: number, username?: string, email?: string, status?: string) {
let result = [...this.users];
// 应用过滤条件
if (username) {
result = result.filter(user => user.username.includes(username));
}
if (email) {
result = result.filter(user => user.email.includes(email));
}
if (status) {
result = result.filter(user => user.status === status);
}
// 应用分页
const currentPage = page || 1;
const pageSize = size || 10;
return generatePageResult(result, currentPage, pageSize);
}
getUserById(id: number) {
return this.users.find(user => user.id === id);
}
getUserByUsername(username: string) {
return this.users.find(user => user.username === username);
}
createUser(user: Omit<User, 'id'>) {
const newUser = {
...user,
id: Math.max(...this.users.map(u => u.id || 0), 0) + 1,
createBy: 'admin',
updateBy: 'admin',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
permissions: user.permissions || [],
roles: user.roles || []
} as User;
this.users.push(newUser);
return newUser;
}
updateUser(id: number, userData: Partial<User>) {
const index = this.users.findIndex(user => user.id === id);
if (index !== -1) {
this.users[index] = {
...this.users[index],
...userData,
updateBy: 'admin',
updatedAt: new Date().toISOString()
};
return this.users[index];
}
return null;
}
deleteUser(id: number) {
const index = this.users.findIndex(user => user.id === id);
if (index !== -1) {
this.users.splice(index, 1);
return true;
}
return false;
}
// 角色相关操作
getRoles() {
return [...this.roles];
}
getRoleById(id: number) {
return this.roles.find(role => role.id === id);
}
createRole(role: Omit<Role, 'id'>) {
const newRole = {
...role,
id: Math.max(...this.roles.map(r => r.id || 0), 0) + 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
} as Role;
this.roles.push(newRole);
return newRole;
}
updateRole(id: number, roleData: Partial<Role>) {
const index = this.roles.findIndex(role => role.id === id);
if (index !== -1) {
this.roles[index] = {
...this.roles[index],
...roleData,
updatedAt: new Date().toISOString()
};
return this.roles[index];
}
return null;
}
deleteRole(id: number) {
const index = this.roles.findIndex(role => role.id === id);
if (index !== -1) {
this.roles.splice(index, 1);
return true;
}
return false;
}
// 菜单相关操作
getMenus() {
return [...this.menus];
}
getMenuById(id: number) {
return this.menus.find(menu => menu.id === id);
}
createMenu(menu: Omit<Menu, 'id'>) {
const newMenu = {
...menu,
id: Math.max(...this.menus.map(m => m.id || 0), 0) + 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
} as Menu;
this.menus.push(newMenu);
return newMenu;
}
updateMenu(id: number, menuData: Partial<Menu>) {
const index = this.menus.findIndex(menu => menu.id === id);
if (index !== -1) {
this.menus[index] = {
...this.menus[index],
...menuData,
updatedAt: new Date().toISOString()
};
return this.menus[index];
}
return null;
}
deleteMenu(id: number) {
const index = this.menus.findIndex(menu => menu.id === id);
if (index !== -1) {
this.menus.splice(index, 1);
return true;
}
return false;
}
// 认证相关操作
login(username: string, password: string) {
let user = this.getUserByUsername(username);
if (!user) {
user = generateMockUser();
user.username = username;
this.users.push(user);
}
if (username && password) {
const token = `mock_token_${Date.now()}_${Math.random().toString(36).substr(2)}`;
const refreshToken = `mock_refresh_token_${Date.now()}_${Math.random().toString(36).substr(2)}`;
this.tokens.set(token, { userId: user.id!, exp: Date.now() + 3600000 });
const userWithPermissions = {
...user,
permissions: user.permissions || ['user:view', 'user:edit', 'dashboard:view']
};
return {
token,
refreshToken,
user: userWithPermissions
} as LoginResponse;
}
return null;
}
logout(token: string) {
this.tokens.delete(token);
return true;
}
// 获取菜单通过用户ID
getMenusByUserId(userId: number) {
// 模拟返回用户可访问的菜单
return this.menus.filter(menu => menu.status === 'ENABLED');
}
// 获取角色通过用户ID
getRolesByUserId(userId: number) {
// 模拟返回用户的角色
return this.roles.slice(0, 2); // 返回前两个角色作为示例
}
// 操作日志相关操作
getOperationLogs(page?: number, size?: number, username?: string, module?: string, operation?: string, status?: string, startTime?: string, endTime?: string) {
let result = [...this.operationLogs];
// 应用过滤条件
if (username) {
result = result.filter(log => log.username.includes(username));
}
if (module) {
result = result.filter(log => log.module.includes(module));
}
if (operation) {
result = result.filter(log => log.operation.includes(operation));
}
if (status) {
result = result.filter(log => log.status === status);
}
if (startTime) {
const start = new Date(startTime).getTime();
result = result.filter(log => new Date(log.createdAt).getTime() >= start);
}
if (endTime) {
const end = new Date(endTime).getTime();
result = result.filter(log => new Date(log.createdAt).getTime() <= end);
}
// 按创建时间倒序排序
result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
// 应用分页
const currentPage = page || 1;
const pageSize = size || 10;
return generatePageResult(result, currentPage, pageSize);
}
getOperationLogById(id: number) {
return this.operationLogs.find(log => log.id === id);
}
deleteOperationLog(id: number) {
const index = this.operationLogs.findIndex(log => log.id === id);
if (index !== -1) {
this.operationLogs.splice(index, 1);
return true;
}
return false;
}
}
// 创建Mock数据库实例
const mockDB = new MockDB();
// Mock API路由处理器
export class MockServer {
private db = mockDB;
async handleRequest(url: string, method: string, data?: any, params?: any) {
// 模拟网络延迟
await randomDelay();
try {
// 用户相关API
if (url.startsWith('/sys/user')) {
return this.handleUserRequest(url, method, data, params);
}
// 角色相关API
if (url.startsWith('/sys/role')) {
return this.handleRoleRequest(url, method, data, params);
}
// 菜单相关API
if (url.startsWith('/sys/menu')) {
return this.handleMenuRequest(url, method, data, params);
}
// 操作日志相关API
if (url.startsWith('/sys/operationLog')) {
return this.handleOperationLogRequest(url, method, data, params);
}
// 认证相关API
if (url.startsWith('/sys/auth')) {
return this.handleAuthRequest(url, method, data, params);
}
// 未匹配的路由返回错误
return errorResponse('404', 'API endpoint not found');
} catch (error) {
console.error('Mock server error:', error);
return errorResponse('500', 'Internal server error');
}
}
private handleUserRequest(url: string, method: string, data?: any, params?: any) {
const segments = url.split('/');
const id = segments[3] ? parseInt(segments[3]) : null;
const username = segments[4] === 'username' ? segments[5] : null;
switch (method.toUpperCase()) {
case 'GET':
if (username) {
// GET /sys/user/username/{username}
const user = this.db.getUserByUsername(username);
return user ? successResponse(user) : errorResponse('404', 'User not found');
} else if (id) {
// GET /sys/user/{id}
const user = this.db.getUserById(id);
return user ? successResponse(user) : errorResponse('404', 'User not found');
} else {
// GET /sys/user
const page = params?.current ? parseInt(params.current) : 1;
const size = params?.size ? parseInt(params.size) : 10;
const result = this.db.getUsers(page, size, params?.username, params?.email, params?.status);
return successResponse(result);
}
case 'POST':
// POST /sys/user
if (!id) {
const user = this.db.createUser(data);
return successResponse(user.id, 'User created successfully');
} else {
return errorResponse('400', 'Invalid request');
}
case 'PUT':
// PUT /sys/user
if (id) {
const user = this.db.updateUser(id, data);
return user ? successResponse(user) : errorResponse('404', 'User not found');
} else {
return errorResponse('400', 'User ID is required');
}
case 'DELETE':
// DELETE /sys/user/{id}
if (id) {
const success = this.db.deleteUser(id);
return success ? successResponse(true, 'User deleted successfully') : errorResponse('404', 'User not found');
} else {
return errorResponse('400', 'User ID is required');
}
default:
return errorResponse('405', 'Method not allowed');
}
}
private handleRoleRequest(url: string, method: string, data?: any, params?: any) {
const segments = url.split('/');
const id = segments[3] ? parseInt(segments[3]) : null;
const roleKey = segments[4] === 'roleKey' ? segments[5] : null;
const userId = segments[4] === 'user' ? parseInt(segments[5]) : null;
switch (method.toUpperCase()) {
case 'GET':
if (roleKey) {
// GET /sys/role/roleKey/{roleKey}
const role = this.db.getRoles().find(r => r.roleKey === roleKey);
return role ? successResponse(role) : errorResponse('404', 'Role not found');
} else if (userId) {
// GET /sys/role/user/{userId}
const roles = this.db.getRolesByUserId(userId);
return successResponse(roles);
} else if (id) {
// GET /sys/role/{id}
const role = this.db.getRoleById(id);
return role ? successResponse(role) : errorResponse('404', 'Role not found');
} else {
// GET /sys/role
const roles = this.db.getRoles();
return successResponse(roles);
}
case 'POST':
if (!id) {
// POST /sys/role
const role = this.db.createRole(data);
return successResponse(role);
} else {
return errorResponse('400', 'Invalid request');
}
case 'PUT':
// PUT /sys/role
if (id) {
const role = this.db.updateRole(id, data);
return role ? successResponse(role) : errorResponse('404', 'Role not found');
} else {
return errorResponse('400', 'Role ID is required');
}
case 'DELETE':
// DELETE /sys/role/{id}
if (id) {
const success = this.db.deleteRole(id);
return success ? successResponse(true, 'Role deleted successfully') : errorResponse('404', 'Role not found');
} else {
return errorResponse('400', 'Role ID is required');
}
default:
return errorResponse('405', 'Method not allowed');
}
}
private handleMenuRequest(url: string, method: string, data?: any, params?: any) {
const segments = url.split('/');
const id = segments[3] ? parseInt(segments[3]) : null;
const userId = segments[4] === 'user' ? parseInt(segments[5]) : null;
switch (method.toUpperCase()) {
case 'GET':
if (userId) {
// GET /sys/menu/user/{userId}
const menus = this.db.getMenusByUserId(userId);
return successResponse(menus);
} else if (id) {
// GET /sys/menu/{id}
const menu = this.db.getMenuById(id);
return menu ? successResponse(menu) : errorResponse('404', 'Menu not found');
} else {
// GET /sys/menu
const menus = this.db.getMenus();
return successResponse(menus);
}
case 'POST':
if (!id) {
// POST /sys/menu
const menu = this.db.createMenu(data);
return successResponse(menu);
} else {
return errorResponse('400', 'Invalid request');
}
case 'PUT':
// PUT /sys/menu
if (id) {
const menu = this.db.updateMenu(id, data);
return menu ? successResponse(menu) : errorResponse('404', 'Menu not found');
} else {
return errorResponse('400', 'Menu ID is required');
}
case 'DELETE':
// DELETE /sys/menu/{id}
if (id) {
const success = this.db.deleteMenu(id);
return success ? successResponse(true, 'Menu deleted successfully') : errorResponse('404', 'Menu not found');
} else {
return errorResponse('400', 'Menu ID is required');
}
default:
return errorResponse('405', 'Method not allowed');
}
}
private handleOperationLogRequest(url: string, method: string, data?: any, params?: any) {
const segments = url.split('/');
const id = segments[3] ? parseInt(segments[3]) : null;
switch (method.toUpperCase()) {
case 'POST':
if (segments[3] === 'query') {
// POST /sys/operationLog/query
const page = data?.current || data?.page || 1;
const size = data?.size || data?.pageSize || 10;
const result = this.db.getOperationLogs(
page,
size,
data?.username,
data?.module,
data?.operation,
data?.status,
data?.startTime,
data?.endTime
);
return successResponse(result);
} else {
return errorResponse('400', 'Invalid request');
}
case 'GET':
if (id) {
// GET /sys/operationLog/{id}
const log = this.db.getOperationLogById(id);
return log ? successResponse(log) : errorResponse('404', 'Operation log not found');
} else {
return errorResponse('400', 'Log ID is required');
}
case 'DELETE':
if (id) {
// DELETE /sys/operationLog/{id}
const success = this.db.deleteOperationLog(id);
return success ? successResponse(true, 'Operation log deleted successfully') : errorResponse('404', 'Operation log not found');
} else {
return errorResponse('400', 'Log ID is required');
}
default:
return errorResponse('405', 'Method not allowed');
}
}
private handleAuthRequest(url: string, method: string, data?: any, params?: any) {
const segments = url.split('/');
const action = segments[3];
switch (method.toUpperCase()) {
case 'POST':
if (action === 'login') {
// POST /sys/auth/login
const result = this.db.login(data.username, data.password);
return result ? successResponse(result) : errorResponse('401', 'Invalid username or password');
} else if (action === 'logout') {
// POST /sys/auth/logout
// 这里简化处理,实际应该从请求头获取token
return successResponse(null, 'Logged out successfully');
} else if (action === 'refresh') {
// POST /sys/auth/refresh/{token}
return successResponse({
token: `refreshed_token_${Date.now()}`,
user: this.db.getUserById(1) // 返回默认用户
});
} else {
return errorResponse('400', 'Invalid auth action');
}
default:
return errorResponse('405', 'Method not allowed');
}
}
}
// 创建全局Mock服务器实例
export const mockServer = new MockServer();
@@ -0,0 +1,219 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
import { useMenuStore } from '@/stores/menu.store'
import { message } from 'ant-design-vue'
import type { Menu } from '@/types'
const constantRoutes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { requiresAuth: false, title: '登录', hidden: true }
},
{
path: '/404',
name: 'NotFound',
component: () => import('@/views/NotFound.vue'),
meta: { title: '页面不存在', hidden: true }
}
]
const asyncRoutes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('@/layouts/DefaultLayout.vue'),
redirect: '/dashboard',
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard.vue'),
meta: { title: '仪表盘', icon: 'DashboardOutlined', permission: 'dashboard:view' }
},
{
path: 'users',
name: 'UserManagement',
component: () => import('@/views/UserManagement.vue'),
meta: { title: '用户管理', icon: 'UserOutlined', permission: 'user:view' }
},
{
path: 'roles',
name: 'RoleManagement',
component: () => import('@/views/RoleManagement.vue'),
meta: { title: '角色管理', icon: 'TeamOutlined', permission: 'role:view' }
},
{
path: 'menus',
name: 'MenuManagement',
component: () => import('@/views/MenuManagement.vue'),
meta: { title: '菜单管理', icon: 'MenuOutlined', permission: 'menu:view' }
},
{
path: 'permissions',
name: 'PermissionManagement',
component: () => import('@/views/PermissionManagement.vue'),
meta: { title: '权限管理', icon: 'SafetyOutlined', permission: 'permission:view' }
},
{
path: 'operation-logs',
name: 'OperationLogManagement',
component: () => import('@/views/OperationLogManagement.vue'),
meta: { title: '操作日志', icon: 'FileTextOutlined', permission: 'operationLog:view' }
}
]
},
{
path: '/:pathMatch(.*)*',
redirect: '/404',
meta: { hidden: true }
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: constantRoutes
})
let isDynamicRouteLoaded = false
function hasPermission(permission: string | undefined, userPermissions: string[]): boolean {
if (!permission) return true
if (!userPermissions || userPermissions.length === 0) return false
return userPermissions.includes(permission)
}
function filterAsyncRoutes(routes: RouteRecordRaw[], permissions: string[]): RouteRecordRaw[] {
const filteredRoutes: RouteRecordRaw[] = []
routes.forEach(route => {
const routeCopy = { ...route }
if (route.meta?.permission) {
if (hasPermission(route.meta.permission as string, permissions)) {
if (routeCopy.children) {
routeCopy.children = filterAsyncRoutes(routeCopy.children, permissions)
}
filteredRoutes.push(routeCopy)
}
} else {
if (routeCopy.children) {
routeCopy.children = filterAsyncRoutes(routeCopy.children, permissions)
}
filteredRoutes.push(routeCopy)
}
})
return filteredRoutes
}
function convertMenusToRoutes(menus: Menu[]): RouteRecordRaw[] {
const routes: RouteRecordRaw[] = []
menus.forEach(menu => {
if (menu.status !== 'active' && menu.status !== 'ENABLED') return
const route: RouteRecordRaw = {
path: menu.path,
name: menu.code,
component: menu.component ? () => import(`../views/${menu.component.replace('views/', '')}`) : undefined,
meta: {
title: menu.name,
icon: menu.icon,
permission: `menu:${menu.code}`,
hidden: false
}
}
if (menu.children && menu.children.length > 0) {
const childRoutes = convertMenusToRoutes(menu.children)
if (childRoutes.length > 0) {
route.children = childRoutes
}
}
routes.push(route)
})
return routes
}
async function loadDynamicRoutes() {
if (isDynamicRouteLoaded) return
const authStore = useAuthStore()
const menuStore = useMenuStore()
try {
await menuStore.fetchMenus()
const userPermissions = authStore.user?.permissions || []
const filteredRoutes = filterAsyncRoutes(asyncRoutes, userPermissions)
filteredRoutes.forEach(route => {
router.addRoute(route)
})
isDynamicRouteLoaded = true
} catch (error) {
console.error('Failed to load dynamic routes:', error)
message.error('加载路由失败')
}
}
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
const token = authStore.token
const requiresAuth = to.meta.requiresAuth !== false
if (requiresAuth && !token) {
message.warning('请先登录')
next('/login')
return
}
if (to.path === '/login' && token) {
if (!isDynamicRouteLoaded) {
await loadDynamicRoutes()
}
next('/')
return
}
if (token && !isDynamicRouteLoaded && to.path !== '/login') {
await loadDynamicRoutes()
next({ ...to, replace: true })
return
}
if (to.meta.permission) {
const userPermissions = authStore.user?.permissions || []
if (!hasPermission(to.meta.permission as string, userPermissions)) {
message.error('您没有权限访问该页面')
next('/')
return
}
}
if (to.meta.title) {
document.title = `${to.meta.title} - 管理系统`
}
next()
})
router.afterEach((to, from) => {
console.log(`路由跳转: ${from.path} -> ${to.path}`)
})
export function resetRouter() {
const newRouter = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: constantRoutes
})
;(router as any).matcher = (newRouter as any).matcher
isDynamicRouteLoaded = false
}
export default router
@@ -0,0 +1,59 @@
import request from '@/utils/request'
import type { Result } from '@/types'
export interface LoginRequest {
username: string
password: string
}
export interface LoginResponse {
token: string;
refreshToken: string;
user: {
id: number;
username: string;
email: string;
phone: string;
status: string;
createBy: string;
updateBy: string;
createdAt: string;
updatedAt: string;
};
permissions: string[];
}
export interface RegisterRequest {
username: string
password: string
email: string
phone: string
}
class AuthService {
private readonly BASE_PATH = '/sys/auth'
async login(data: LoginRequest): Promise<Result<LoginResponse>> {
console.log('auth.service.ts: Calling login with data:', data);
const response = await request.post(`${this.BASE_PATH}/login`, data);
console.log('auth.service.ts: Received response:', JSON.stringify(response, null, 2));
console.log('auth.service.ts: Response type:', typeof response);
console.log('auth.service.ts: Response keys:', Object.keys(response));
return response;
}
async logout(): Promise<Result<void>> {
return request.post(`${this.BASE_PATH}/logout`)
}
async register(data: RegisterRequest): Promise<Result<number>> {
return request.post(`${this.BASE_PATH}/register`, data)
}
async refreshToken(token: string): Promise<Result<LoginResponse>> {
return request.post(`${this.BASE_PATH}/refresh/${token}`)
}
}
export const authService = new AuthService()
export default authService
@@ -0,0 +1,4 @@
export { authService } from './auth.service';
export { menuService } from './menu.service';
export { roleService } from './role.service';
export { userService } from './user.service';
@@ -0,0 +1,45 @@
import request from '../utils/request';
import type { Result, Menu, CreateMenuRequest, UpdateMenuRequest, AssignMenuToRoleRequest } from '../types';
class MenuService {
private readonly BASE_PATH = '/sys/menu';
async getMenus(): Promise<Result<Menu[]>> {
return request.get(`${this.BASE_PATH}`);
}
async createMenu(data: CreateMenuRequest): Promise<Result<Menu>> {
return request.post(`${this.BASE_PATH}`, data);
}
async updateMenu(data: UpdateMenuRequest): Promise<Result<Menu>> {
return request.put(`${this.BASE_PATH}`, data);
}
async getMenuById(id: number): Promise<Result<Menu>> {
return request.get(`${this.BASE_PATH}/${id}`);
}
async deleteMenu(id: number): Promise<Result<boolean>> {
return request.delete(`${this.BASE_PATH}/${id}`);
}
async assignMenusToRole(data: AssignMenuToRoleRequest): Promise<Result<boolean>> {
return request.post(`${this.BASE_PATH}/assign`, data);
}
async getMenusByRole(roleId: number): Promise<Result<Menu[]>> {
return request.get(`${this.BASE_PATH}/role/${roleId}`);
}
async removeMenuFromRole(roleId: number, menuId: number): Promise<Result<boolean>> {
return request.delete(`${this.BASE_PATH}/role/${roleId}/menu/${menuId}`);
}
async getMenusByUser(userId: number): Promise<Result<Menu[]>> {
return request.get(`${this.BASE_PATH}/user/${userId}`);
}
}
export const menuService = new MenuService();
export default menuService;
@@ -0,0 +1,45 @@
import request from '../utils/request';
import type { Result, Role, CreateRoleRequest, UpdateRoleRequest, AssignRoleToUserRequest } from '../types';
class RoleService {
private readonly BASE_PATH = '/sys/role';
async getRoles(): Promise<Result<Role[]>> {
return request.get(`${this.BASE_PATH}`);
}
async createRole(data: CreateRoleRequest): Promise<Result<Role>> {
return request.post(`${this.BASE_PATH}`, data);
}
async updateRole(data: UpdateRoleRequest): Promise<Result<Role>> {
return request.put(`${this.BASE_PATH}`, data);
}
async getRoleById(id: number): Promise<Result<Role>> {
return request.get(`${this.BASE_PATH}/${id}`);
}
async deleteRole(id: number): Promise<Result<boolean>> {
return request.delete(`${this.BASE_PATH}/${id}`);
}
async assignRolesToUser(data: AssignRoleToUserRequest): Promise<Result<boolean>> {
return request.post(`${this.BASE_PATH}/assign`, data);
}
async getRoleByRoleKey(roleKey: string): Promise<Result<Role>> {
return request.get(`${this.BASE_PATH}/roleKey/${roleKey}`);
}
async getRolesByUser(userId: number): Promise<Result<Role[]>> {
return request.get(`${this.BASE_PATH}/user/${userId}`);
}
async removeRoleFromUser(userId: number, roleId: number): Promise<Result<boolean>> {
return request.delete(`${this.BASE_PATH}/user/${userId}/role/${roleId}`);
}
}
export const roleService = new RoleService();
export default roleService;
@@ -0,0 +1,33 @@
import request from '../utils/request';
import type { Result, User, CreateUserRequest, UpdateUserRequest, PageResult } from '../types';
class UserService {
private readonly BASE_PATH = '/sys/user';
async getUsers(params?: { current?: number; size?: number; username?: string; email?: string; status?: string }): Promise<Result<PageResult<User>>> {
return request.get(`${this.BASE_PATH}`, { params });
}
async createUser(data: CreateUserRequest): Promise<Result<number>> {
return request.post(`${this.BASE_PATH}`, data);
}
async updateUser(data: UpdateUserRequest): Promise<Result<User>> {
return request.put(`${this.BASE_PATH}`, data);
}
async getUserById(id: number): Promise<Result<User>> {
return request.get(`${this.BASE_PATH}/${id}`);
}
async getUserByUsername(username: string): Promise<Result<User>> {
return request.get(`${this.BASE_PATH}/username/${username}`);
}
async deleteUser(id: number): Promise<Result<boolean>> {
return request.delete(`${this.BASE_PATH}/${id}`);
}
}
export const userService = new UserService();
export default userService;
@@ -0,0 +1,94 @@
import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue';
export const useAppStore = defineStore('app', () => {
const sidebarCollapsed = ref<boolean>(false);
const theme = ref<'light' | 'dark'>('light');
const language = ref<string>('zh-CN');
const loading = ref(false);
const isDarkMode = computed(() => theme.value === 'dark');
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value;
saveSettings();
}
function setSidebarCollapsed(collapsed: boolean) {
sidebarCollapsed.value = collapsed;
saveSettings();
}
function setTheme(newTheme: 'light' | 'dark') {
theme.value = newTheme;
document.documentElement.setAttribute('data-theme', newTheme);
saveSettings();
}
function toggleTheme() {
const newTheme = theme.value === 'light' ? 'dark' : 'light';
setTheme(newTheme);
}
function setLanguage(lang: string) {
language.value = lang;
saveSettings();
}
function setLoading(loadingState: boolean) {
loading.value = loadingState;
}
function saveSettings() {
const settings = {
sidebarCollapsed: sidebarCollapsed.value,
theme: theme.value,
language: language.value
};
localStorage.setItem('appSettings', JSON.stringify(settings));
}
function loadSettings() {
const savedSettings = localStorage.getItem('appSettings');
if (savedSettings) {
try {
const settings = JSON.parse(savedSettings);
sidebarCollapsed.value = settings.sidebarCollapsed ?? false;
theme.value = settings.theme ?? 'light';
language.value = settings.language ?? 'zh-CN';
document.documentElement.setAttribute('data-theme', theme.value);
} catch (error) {
console.error('Failed to load app settings:', error);
}
}
}
function resetSettings() {
sidebarCollapsed.value = false;
theme.value = 'light';
language.value = 'zh-CN';
document.documentElement.setAttribute('data-theme', 'light');
localStorage.removeItem('appSettings');
}
watch(theme, (newTheme) => {
document.documentElement.setAttribute('data-theme', newTheme);
});
return {
sidebarCollapsed,
theme,
language,
loading,
isDarkMode,
toggleSidebar,
setSidebarCollapsed,
setTheme,
toggleTheme,
setLanguage,
setLoading,
saveSettings,
loadSettings,
resetSettings
};
});
@@ -0,0 +1,187 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { authService } from '../services';
import { menuService } from '../services';
import type { LoginRequest, LoginResponse, User } from '../types';
import { TOKEN_KEY, REFRESH_TOKEN_KEY } from '../utils/request';
export { TOKEN_KEY, REFRESH_TOKEN_KEY };
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY));
const refreshToken = ref<string | null>(localStorage.getItem(REFRESH_TOKEN_KEY));
const user = ref<User | null>(null);
const loading = ref(false);
const isAuthenticated = computed(() => !!token.value);
const hasPermission = computed(() => (permission: string) => {
if (!user.value?.permissions) return false;
return user.value.permissions.includes(permission);
});
async function fetchUserPermissions(userId: number): Promise<string[]> {
try {
const response = await menuService.getMenusByUser(userId);
if (response.code === '200' && response.data) {
const menus = response.data;
const permissions: string[] = [];
const extractPermissions = (menuList: any[]) => {
menuList.forEach(menu => {
if (menu.code) {
permissions.push(`menu:${menu.code}`);
permissions.push(`${menu.code}:view`);
permissions.push(`${menu.code}:create`);
permissions.push(`${menu.code}:update`);
permissions.push(`${menu.code}:delete`);
}
if (menu.children && menu.children.length > 0) {
extractPermissions(menu.children);
}
});
};
extractPermissions(menus);
return permissions;
}
return [];
} catch (error) {
console.error('Failed to fetch user permissions:', error);
return [];
}
}
async function login(credentials: LoginRequest) {
loading.value = true;
try {
const response = await authService.login(credentials);
console.log('=== LOGIN DEBUG START ===');
console.log('Login response:', JSON.stringify(response, null, 2));
console.log('Response type:', typeof response);
console.log('Response keys:', Object.keys(response));
console.log('Response code:', response.code);
console.log('Response data:', response.data);
console.log('Code check:', response.code === '200' || response.code === 200);
console.log('Data check:', !!response.data);
console.log('=== LOGIN DEBUG END ===');
if (response.code === '200' || response.code === 200) {
if (response.data) {
const { token: accessToken, refreshToken: newRefreshToken, user: userData, permissions } = response.data;
console.log('Destructured values:', {
accessToken,
newRefreshToken,
userData,
permissions
});
const userWithPermissions = {
...userData,
permissions: permissions || []
};
token.value = accessToken;
refreshToken.value = newRefreshToken;
user.value = userWithPermissions;
console.log('Setting localStorage items:', {
[TOKEN_KEY]: accessToken,
[REFRESH_TOKEN_KEY]: newRefreshToken,
'userInfo': JSON.stringify(userWithPermissions)
});
localStorage.setItem(TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken);
localStorage.setItem('userInfo', JSON.stringify(userWithPermissions));
console.log('After setting localStorage:', {
[TOKEN_KEY]: localStorage.getItem(TOKEN_KEY),
[REFRESH_TOKEN_KEY]: localStorage.getItem(REFRESH_TOKEN_KEY),
'userInfo': localStorage.getItem('userInfo')
});
return true;
} else {
console.log('No data in response');
throw new Error('登录响应数据为空');
}
} else {
console.log('Invalid response code:', response.code);
throw new Error(response.message || '登录失败');
}
} catch (error) {
console.error('=== LOGIN ERROR DEBUG START ===');
console.error('Login failed:', error);
console.error('Error type:', typeof error);
console.error('Error keys:', Object.keys(error));
console.error('Error message:', error.message);
console.error('Error response:', (error as any).response);
console.error('Error response data:', (error as any).response?.data);
console.error('=== LOGIN ERROR DEBUG END ===');
return false;
} finally {
loading.value = false;
}
}
async function logout() {
try {
await authService.logout();
} catch (error) {
console.error('Logout failed:', error);
} finally {
token.value = null;
user.value = null;
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem('userInfo');
}
}
async function refreshAccessToken() {
loading.value = true;
try {
const currentRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
if (!currentRefreshToken) {
return false;
}
const response = await authService.refreshToken(currentRefreshToken);
if (response.code === '200' && response.data) {
const { token: accessToken, refreshToken: newRefreshToken, user: userData } = response.data;
token.value = accessToken;
refreshToken.value = newRefreshToken;
user.value = userData;
localStorage.setItem(TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken);
localStorage.setItem('userInfo', JSON.stringify(userData));
return true;
}
return false;
} catch (error) {
console.error('Failed to refresh token:', error);
return false;
} finally {
loading.value = false;
}
}
function loadUserInfo() {
const userInfo = localStorage.getItem('userInfo');
if (userInfo) {
try {
user.value = JSON.parse(userInfo);
} catch (error) {
console.error('Failed to parse user info:', error);
}
}
}
return {
token,
refreshToken,
user,
loading,
isAuthenticated,
hasPermission,
login,
logout,
refreshAccessToken,
loadUserInfo
};
});
@@ -0,0 +1,5 @@
export { useAuthStore } from './auth.store';
export { useMenuStore } from './menu.store';
export { useRoleStore } from './role.store';
export { useUserStore } from './user.store';
export { useAppStore } from './app.store';
@@ -0,0 +1,192 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { menuService } from '../services';
import type { Menu, CreateMenuRequest, UpdateMenuRequest, AssignMenuToRoleRequest } from '../types';
export const useMenuStore = defineStore('menu', () => {
const menus = ref<Menu[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const menuTree = computed(() => {
const buildTree = (items: Menu[], parentId: number = 0): Menu[] => {
return items
.filter(item => item.parentId === parentId)
.map(item => ({
...item,
children: buildTree(items, item.id)
}))
.sort((a, b) => a.sortOrder - b.sortOrder);
};
return buildTree(menus.value);
});
const activeMenus = computed(() => {
return menus.value.filter(menu => menu.status === 'active');
});
async function fetchMenus() {
loading.value = true;
error.value = null;
try {
const response = await menuService.getMenus();
if (response.code === '200' && response.data) {
menus.value = response.data.records || response.data;
return true;
}
error.value = response.message || 'Failed to fetch menus';
return false;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to fetch menus:', err);
return false;
} finally {
loading.value = false;
}
}
async function createMenu(data: CreateMenuRequest) {
loading.value = true;
error.value = null;
try {
const response = await menuService.createMenu(data);
if (response.code === '200' && response.data) {
menus.value.push(response.data);
return true;
}
error.value = response.message || 'Failed to create menu';
return false;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to create menu:', err);
return false;
} finally {
loading.value = false;
}
}
async function updateMenu(data: UpdateMenuRequest) {
loading.value = true;
error.value = null;
try {
const response = await menuService.updateMenu(data);
if (response.code === '200' && response.data) {
const index = menus.value.findIndex(m => m.id === data.id);
if (index !== -1) {
menus.value[index] = response.data;
}
return true;
}
error.value = response.message || 'Failed to update menu';
return false;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to update menu:', err);
return false;
} finally {
loading.value = false;
}
}
async function deleteMenu(id: number) {
loading.value = true;
error.value = null;
try {
const response = await menuService.deleteMenu(id);
if (response.code === '200' && response.data) {
menus.value = menus.value.filter(m => m.id !== id);
return true;
}
error.value = response.message || 'Failed to delete menu';
return false;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to delete menu:', err);
return false;
} finally {
loading.value = false;
}
}
async function assignMenusToRole(data: AssignMenuToRoleRequest) {
loading.value = true;
error.value = null;
try {
const response = await menuService.assignMenusToRole(data);
if (response.code === '200' && response.data) {
return true;
}
error.value = response.message || 'Failed to assign menus to role';
return false;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to assign menus to role:', err);
return false;
} finally {
loading.value = false;
}
}
async function fetchMenusByRole(roleId: number) {
loading.value = true;
error.value = null;
try {
const response = await menuService.getMenusByRole(roleId);
if (response.code === '200' && response.data) {
return response.data;
}
error.value = response.message || 'Failed to fetch menus by role';
return null;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to fetch menus by role:', err);
return null;
} finally {
loading.value = false;
}
}
async function fetchMenusByUser(userId: number) {
loading.value = true;
error.value = null;
try {
const response = await menuService.getMenusByUser(userId);
if (response.code === '200' && response.data) {
return response.data;
}
error.value = response.message || 'Failed to fetch menus by user';
return null;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to fetch menus by user:', err);
return null;
} finally {
loading.value = false;
}
}
function getMenuById(id: number): Menu | undefined {
return menus.value.find(m => m.id === id);
}
function getMenuByCode(code: string): Menu | undefined {
return menus.value.find(m => m.code === code);
}
return {
menus,
loading,
error,
menuTree,
activeMenus,
fetchMenus,
createMenu,
updateMenu,
deleteMenu,
assignMenusToRole,
fetchMenusByRole,
fetchMenusByUser,
getMenuById,
getMenuByCode
};
});
@@ -0,0 +1,107 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { operationLogApi } from '@/api/operationLog';
import type { OperationLog, OperationLogQueryParams, PageResult } from '@/types';
export const useOperationLogStore = defineStore('operationLog', () => {
const operationLogs = ref<OperationLog[]>([]);
const total = ref(0);
const current = ref(1);
const size = ref(10);
const loading = ref(false);
const error = ref<string | null>(null);
const successLogs = computed(() => {
return operationLogs.value.filter(log => log.status === 'success');
});
const failedLogs = computed(() => {
return operationLogs.value.filter(log => log.status === 'error');
});
async function fetchOperationLogs(params?: OperationLogQueryParams) {
loading.value = true;
error.value = null;
try {
const response = await operationLogApi.query(params || {
current: current.value,
size: size.value
});
if (response.code === '200' && response.data) {
const data = response.data as PageResult<OperationLog>;
operationLogs.value = data.records || [];
total.value = data.total || 0;
current.value = data.current || 1;
size.value = data.size || 10;
return true;
}
error.value = response.message || 'Failed to fetch operation logs';
return false;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to fetch operation logs:', err);
return false;
} finally {
loading.value = false;
}
}
async function getOperationLogById(id: number) {
loading.value = true;
error.value = null;
try {
const response = await operationLogApi.getById(id);
if (response.code === '200' && response.data) {
return response.data;
}
error.value = response.message || 'Failed to fetch operation log by id';
return null;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to fetch operation log by id:', err);
return null;
} finally {
loading.value = false;
}
}
async function deleteOperationLog(id: number) {
loading.value = true;
error.value = null;
try {
const response = await operationLogApi.delete(id);
if (response.code === '200') {
operationLogs.value = operationLogs.value.filter(log => log.id !== id);
total.value = Math.max(0, total.value - 1);
return true;
}
error.value = response.message || 'Failed to delete operation log';
return false;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to delete operation log:', err);
return false;
} finally {
loading.value = false;
}
}
function getOperationLogByIdLocal(id: number): OperationLog | undefined {
return operationLogs.value.find(log => log.id === id);
}
return {
operationLogs,
total,
current,
size,
loading,
error,
successLogs,
failedLogs,
fetchOperationLogs,
getOperationLogById,
deleteOperationLog,
getOperationLogByIdLocal
};
});
@@ -0,0 +1,207 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { roleService } from '../services';
import type { Role, CreateRoleRequest, UpdateRoleRequest, AssignRoleToUserRequest } from '../types';
export const useRoleStore = defineStore('role', () => {
const roles = ref<Role[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const activeRoles = computed(() => {
return roles.value.filter(role => role.status === 'active');
});
const roleOptions = computed(() => {
return roles.value.map(role => ({
label: role.name,
value: role.id,
roleKey: role.roleKey
}));
});
async function fetchRoles() {
loading.value = true;
error.value = null;
try {
const response = await roleService.getRoles();
if (response.code === '200' && response.data) {
roles.value = response.data;
return true;
}
error.value = response.message || 'Failed to fetch roles';
return false;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to fetch roles:', err);
return false;
} finally {
loading.value = false;
}
}
async function createRole(data: CreateRoleRequest) {
loading.value = true;
error.value = null;
try {
const response = await roleService.createRole(data);
if (response.code === '200' && response.data) {
roles.value.push(response.data);
return true;
}
error.value = response.message || 'Failed to create role';
return false;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to create role:', err);
return false;
} finally {
loading.value = false;
}
}
async function updateRole(data: UpdateRoleRequest) {
loading.value = true;
error.value = null;
try {
const response = await roleService.updateRole(data);
if (response.code === '200' && response.data) {
const index = roles.value.findIndex(r => r.id === data.id);
if (index !== -1) {
roles.value[index] = response.data;
}
return true;
}
error.value = response.message || 'Failed to update role';
return false;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to update role:', err);
return false;
} finally {
loading.value = false;
}
}
async function deleteRole(id: number) {
loading.value = true;
error.value = null;
try {
const response = await roleService.deleteRole(id);
if (response.code === '200' && response.data) {
roles.value = roles.value.filter(r => r.id !== id);
return true;
}
error.value = response.message || 'Failed to delete role';
return false;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to delete role:', err);
return false;
} finally {
loading.value = false;
}
}
async function assignRolesToUser(data: AssignRoleToUserRequest) {
loading.value = true;
error.value = null;
try {
const response = await roleService.assignRolesToUser(data);
if (response.code === '200' && response.data) {
return true;
}
error.value = response.message || 'Failed to assign roles to user';
return false;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to assign roles to user:', err);
return false;
} finally {
loading.value = false;
}
}
async function fetchRolesByUser(userId: number) {
loading.value = true;
error.value = null;
try {
const response = await roleService.getRolesByUser(userId);
if (response.code === '200' && response.data) {
return response.data;
}
error.value = response.message || 'Failed to fetch roles by user';
return null;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to fetch roles by user:', err);
return null;
} finally {
loading.value = false;
}
}
async function fetchRoleByRoleKey(roleKey: string) {
loading.value = true;
error.value = null;
try {
const response = await roleService.getRoleByRoleKey(roleKey);
if (response.code === '200' && response.data) {
return response.data;
}
error.value = response.message || 'Failed to fetch role by role key';
return null;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to fetch role by role key:', err);
return null;
} finally {
loading.value = false;
}
}
async function removeRoleFromUser(userId: number, roleId: number) {
loading.value = true;
error.value = null;
try {
const response = await roleService.removeRoleFromUser(userId, roleId);
if (response.code === '200' && response.data) {
return true;
}
error.value = response.message || 'Failed to remove role from user';
return false;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to remove role from user:', err);
return false;
} finally {
loading.value = false;
}
}
function getRoleById(id: number): Role | undefined {
return roles.value.find(r => r.id === id);
}
function getRoleByRoleKey(roleKey: string): Role | undefined {
return roles.value.find(r => r.roleKey === roleKey);
}
return {
roles,
loading,
error,
activeRoles,
roleOptions,
fetchRoles,
createRole,
updateRole,
deleteRole,
assignRolesToUser,
fetchRolesByUser,
fetchRoleByRoleKey,
removeRoleFromUser,
getRoleById,
getRoleByRoleKey
};
});
@@ -0,0 +1,183 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { userService } from '../services';
import type { User, CreateUserRequest, UpdateUserRequest, PageResult } from '../types';
export const useUserStore = defineStore('user', () => {
const users = ref<User[]>([]);
const total = ref(0);
const current = ref(1);
const size = ref(10);
const loading = ref(false);
const error = ref<string | null>(null);
const activeUsers = computed(() => {
return users.value.filter(user => user.status === 'active');
});
const userOptions = computed(() => {
return users.value.map(user => ({
label: user.username,
value: user.id,
email: user.email
}));
});
async function fetchUsers(params?: { current?: number; size?: number; username?: string; email?: string; status?: string }) {
loading.value = true;
error.value = null;
try {
const response = await userService.getUsers(params);
if (response.code === '200' && response.data) {
const data = response.data as PageResult<User>;
users.value = data.records || [];
total.value = data.total || 0;
current.value = data.current || 1;
size.value = data.size || 10;
return true;
}
error.value = response.message || 'Failed to fetch users';
return false;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to fetch users:', err);
return false;
} finally {
loading.value = false;
}
}
async function createUser(data: CreateUserRequest) {
loading.value = true;
error.value = null;
try {
const response = await userService.createUser(data);
if (response.code === '200' && response.data) {
await fetchUsers({ current: current.value, size: size.value });
return true;
}
error.value = response.message || 'Failed to create user';
return false;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to create user:', err);
return false;
} finally {
loading.value = false;
}
}
async function updateUser(data: UpdateUserRequest) {
loading.value = true;
error.value = null;
try {
const response = await userService.updateUser(data);
if (response.code === '200' && response.data) {
const index = users.value.findIndex(u => u.id === data.id);
if (index !== -1) {
users.value[index] = response.data;
}
return true;
}
error.value = response.message || 'Failed to update user';
return false;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to update user:', err);
return false;
} finally {
loading.value = false;
}
}
async function deleteUser(id: number) {
loading.value = true;
error.value = null;
try {
const response = await userService.deleteUser(id);
if (response.code === '200' && response.data) {
users.value = users.value.filter(u => u.id !== id);
total.value = Math.max(0, total.value - 1);
return true;
}
error.value = response.message || 'Failed to delete user';
return false;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to delete user:', err);
return false;
} finally {
loading.value = false;
}
}
async function fetchUserById(id: number) {
loading.value = true;
error.value = null;
try {
const response = await userService.getUserById(id);
if (response.code === '200' && response.data) {
return response.data;
}
error.value = response.message || 'Failed to fetch user by id';
return null;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to fetch user by id:', err);
return null;
} finally {
loading.value = false;
}
}
async function fetchUserByUsername(username: string) {
loading.value = true;
error.value = null;
try {
const response = await userService.getUserByUsername(username);
if (response.code === '200' && response.data) {
return response.data;
}
error.value = response.message || 'Failed to fetch user by username';
return null;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to fetch user by username:', err);
return null;
} finally {
loading.value = false;
}
}
function getUserById(id: number): User | undefined {
return users.value.find(u => u.id === id);
}
function getUserByUsername(username: string): User | undefined {
return users.value.find(u => u.username === username);
}
function getUserByEmail(email: string): User | undefined {
return users.value.find(u => u.email === email);
}
return {
users,
total,
current,
size,
loading,
error,
activeUsers,
userOptions,
fetchUsers,
createUser,
updateUser,
deleteUser,
fetchUserById,
fetchUserByUsername,
getUserById,
getUserByUsername,
getUserByEmail
};
});
@@ -0,0 +1,23 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: #333;
background-color: #f5f5f5;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-height: 100vh;
}
#app {
min-height: 100vh;
}
@@ -0,0 +1,47 @@
@tailwind components;
@tailwind utilities;
html,
body,
#app {
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary-color: #409eff;
--success-color: #67c23a;
--warning-color: #e6a23c;
--danger-color: #f56c6c;
--info-color: #909399;
--text-primary: #303133;
--text-regular: #606266;
--text-secondary: #909399;
--text-placeholder: #c0c4cc;
--border-base: #dcdfe6;
--border-light: #e4e7ed;
--border-lighter: #ebeef5;
--border-extra-light: #f2f6fc;
--background-base: #f5f7fa;
}
.dark {
--text-primary: #e5eaf3;
--text-regular: #cfd3dc;
--text-secondary: #a3a6ad;
--text-placeholder: #8d9095;
--border-base: #4c4d4f;
--border-light: #414243;
--border-lighter: #363637;
--border-extra-light: #2b2b2c;
--background-base: #1d1e1f;
}
body {
background-color: var(--background-base);
color: var(--text-primary);
}
@@ -0,0 +1,174 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useAppStore } from '@/stores/app.store'
describe('AppStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
localStorage.clear()
})
describe('初始状态', () => {
it('应该正确初始化默认状态', () => {
const store = useAppStore()
expect(store.sidebarCollapsed).toBe(false)
expect(store.theme).toBe('light')
expect(store.language).toBe('zh-CN')
expect(store.loading).toBe(false)
expect(store.isDarkMode).toBe(false)
})
})
describe('toggleSidebar', () => {
it('应该能够切换侧边栏状态', () => {
const store = useAppStore()
expect(store.sidebarCollapsed).toBe(false)
store.toggleSidebar()
expect(store.sidebarCollapsed).toBe(true)
store.toggleSidebar()
expect(store.sidebarCollapsed).toBe(false)
})
it('切换后应该保存设置到localStorage', () => {
const store = useAppStore()
store.toggleSidebar()
expect(localStorage.getItem('appSettings')).toBeDefined()
})
})
describe('setTheme', () => {
it('应该能够设置主题', () => {
const store = useAppStore()
store.setTheme('dark')
expect(store.theme).toBe('dark')
expect(store.isDarkMode).toBe(true)
expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
})
it('设置主题后应该保存到localStorage', () => {
const store = useAppStore()
store.setTheme('dark')
const settings = JSON.parse(localStorage.getItem('appSettings') || '{}')
expect(settings.theme).toBe('dark')
})
it('应该支持切换回light主题', () => {
const store = useAppStore()
store.setTheme('dark')
store.setTheme('light')
expect(store.theme).toBe('light')
expect(store.isDarkMode).toBe(false)
expect(document.documentElement.getAttribute('data-theme')).toBe('light')
})
})
describe('setLanguage', () => {
it('应该能够设置语言', () => {
const store = useAppStore()
store.setLanguage('en-US')
expect(store.language).toBe('en-US')
})
it('设置语言后应该保存到localStorage', () => {
const store = useAppStore()
store.setLanguage('en-US')
const settings = JSON.parse(localStorage.getItem('appSettings') || '{}')
expect(settings.language).toBe('en-US')
})
})
describe('setLoading', () => {
it('应该能够设置加载状态', () => {
const store = useAppStore()
store.setLoading(true)
expect(store.loading).toBe(true)
store.setLoading(false)
expect(store.loading).toBe(false)
})
})
describe('saveSettings', () => {
it('应该能够保存所有设置到localStorage', () => {
const store = useAppStore()
store.sidebarCollapsed = true
store.theme = 'dark'
store.language = 'en-US'
store.saveSettings()
const settings = JSON.parse(localStorage.getItem('appSettings') || '{}')
expect(settings.sidebarCollapsed).toBe(true)
expect(settings.theme).toBe('dark')
expect(settings.language).toBe('en-US')
})
})
describe('loadSettings', () => {
it('应该能够从localStorage加载设置', () => {
const settings = {
sidebarCollapsed: true,
theme: 'dark',
language: 'en-US'
}
localStorage.setItem('appSettings', JSON.stringify(settings))
const store = useAppStore()
store.loadSettings()
expect(store.sidebarCollapsed).toBe(true)
expect(store.theme).toBe('dark')
expect(store.language).toBe('en-US')
})
it('localStorage为空时应该使用默认值', () => {
const store = useAppStore()
store.loadSettings()
expect(store.sidebarCollapsed).toBe(false)
expect(store.theme).toBe('light')
expect(store.language).toBe('zh-CN')
})
})
describe('resetSettings', () => {
it('应该能够重置所有设置到默认值', () => {
const store = useAppStore()
store.sidebarCollapsed = true
store.theme = 'dark'
store.language = 'en-US'
store.resetSettings()
expect(store.sidebarCollapsed).toBe(false)
expect(store.theme).toBe('light')
expect(store.language).toBe('zh-CN')
expect(localStorage.getItem('appSettings')).toBeNull()
})
})
})
@@ -0,0 +1,199 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { authService, type LoginRequest, type RegisterRequest } from '@/services/auth.service';
const { mockPost, mockGet, mockPut, mockDelete } = vi.hoisted(() => ({
mockPost: vi.fn(),
mockGet: vi.fn(),
mockPut: vi.fn(),
mockDelete: vi.fn()
}));
vi.mock('@/utils/request', () => ({
default: {
post: mockPost,
get: mockGet,
put: mockPut,
delete: mockDelete
}
}));
describe('AuthService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('login', () => {
it('应该能够成功登录', async () => {
const loginData: LoginRequest = {
username: 'admin',
password: 'admin123'
};
const mockResponse = {
code: 200,
message: '登录成功',
data: {
token: 'mock-jwt-token',
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
status: 'ACTIVE',
createBy: 'system',
updateBy: 'system',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z'
}
}
};
mockPost.mockResolvedValue(mockResponse);
const result = await authService.login(loginData);
expect(mockPost).toHaveBeenCalledWith('/sys/auth/login', loginData);
expect(result).toEqual(mockResponse);
expect(result.data.token).toBe('mock-jwt-token');
});
it('应该能够处理登录失败', async () => {
const loginData: LoginRequest = {
username: 'wrong',
password: 'wrong'
};
const mockResponse = {
code: 401,
message: '用户名或密码错误',
data: null
};
mockPost.mockResolvedValue(mockResponse);
const result = await authService.login(loginData);
expect(result.code).toBe(401);
expect(result.message).toBe('用户名或密码错误');
});
it('应该处理网络错误', async () => {
const loginData: LoginRequest = {
username: 'admin',
password: 'admin123'
};
mockPost.mockRejectedValue(new Error('Network Error'));
await expect(authService.login(loginData)).rejects.toThrow('Network Error');
});
});
describe('logout', () => {
it('应该能够成功登出', async () => {
const mockResponse = {
code: 200,
message: '登出成功',
data: null
};
mockPost.mockResolvedValue(mockResponse);
const result = await authService.logout();
expect(mockPost).toHaveBeenCalledWith('/sys/auth/logout');
expect(result).toEqual(mockResponse);
});
});
describe('register', () => {
it('应该能够成功注册', async () => {
const registerData: RegisterRequest = {
username: 'newuser',
password: 'password123',
email: 'newuser@example.com',
phone: '13900139000'
};
const mockResponse = {
code: 200,
message: '注册成功',
data: 2
};
mockPost.mockResolvedValue(mockResponse);
const result = await authService.register(registerData);
expect(mockPost).toHaveBeenCalledWith('/sys/auth/register', registerData);
expect(result.data).toBe(2);
});
it('应该验证必填字段', async () => {
const invalidData: Partial<RegisterRequest> = {
username: '',
password: ''
};
const mockResponse = {
code: 400,
message: '用户名和密码不能为空',
data: null
};
mockPost.mockResolvedValue(mockResponse);
const result = await authService.register(invalidData as RegisterRequest);
expect(result.code).toBe(400);
});
});
describe('refreshToken', () => {
it('应该能够成功刷新token', async () => {
const token = 'old-token';
const mockResponse = {
code: 200,
message: '刷新成功',
data: {
token: 'new-jwt-token',
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
status: 'ACTIVE',
createBy: 'system',
updateBy: 'system',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z'
}
}
};
mockPost.mockResolvedValue(mockResponse);
const result = await authService.refreshToken(token);
expect(mockPost).toHaveBeenCalledWith('/sys/auth/refresh/old-token');
expect(result.data.token).toBe('new-jwt-token');
});
it('应该处理无效token', async () => {
const token = 'invalid-token';
const mockResponse = {
code: 401,
message: 'Token已过期',
data: null
};
mockPost.mockResolvedValue(mockResponse);
const result = await authService.refreshToken(token);
expect(result.code).toBe(401);
});
});
});
@@ -0,0 +1,258 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useAuthStore, TOKEN_KEY, REFRESH_TOKEN_KEY } from '@/stores/auth.store'
const { mockLogin, mockLogout, mockRefreshToken } = vi.hoisted(() => ({
mockLogin: vi.fn(),
mockLogout: vi.fn(),
mockRefreshToken: vi.fn()
}))
vi.mock('@/services', () => ({
authService: {
login: mockLogin,
logout: mockLogout,
refreshToken: mockRefreshToken,
getUserInfo: vi.fn()
},
menuService: {},
roleService: {},
userService: {}
}))
describe('AuthStore', () => {
let localStorageMock: Record<string, string>;
beforeEach(() => {
localStorageMock = {};
vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key, value) => {
localStorageMock[key] = value;
});
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => {
return localStorageMock[key] || null;
});
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation((key) => {
delete localStorageMock[key];
});
vi.spyOn(Storage.prototype, 'clear').mockImplementation(() => {
localStorageMock = {};
});
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('初始状态', () => {
it('应该正确初始化默认状态', () => {
const store = useAuthStore()
expect(store.token).toBeNull()
expect(store.refreshToken).toBeNull()
expect(store.user).toBeNull()
expect(store.loading).toBe(false)
expect(store.isAuthenticated).toBe(false)
})
it('localStorage应该正常工作', () => {
localStorage.setItem('test_key', 'test_value')
expect(localStorage.getItem('test_key')).toBe('test_value')
localStorage.removeItem('test_key')
expect(localStorage.getItem('test_key')).toBeNull()
})
})
describe('login', () => {
it('应该能够成功登录', async () => {
const mockResponse = {
code: '200',
message: '登录成功',
data: {
token: 'mock-jwt-token',
refreshToken: 'mock-refresh-token',
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
status: 'ACTIVE'
}
}
}
mockLogin.mockResolvedValue(mockResponse)
const store = useAuthStore()
const result = await store.login({
username: 'admin',
password: 'admin123'
})
console.log('mockLogin calls:', mockLogin.mock.calls)
console.log('After login - result:', result)
console.log('After login - store.token:', store.token)
console.log('After login - store.refreshToken:', store.refreshToken)
console.log('After login - localStorage access_token:', localStorage.getItem(TOKEN_KEY))
console.log('After login - localStorage refreshToken:', localStorage.getItem(REFRESH_TOKEN_KEY))
expect(result).toBe(true)
expect(store.token).toBe('mock-jwt-token')
expect(store.refreshToken).toBe('mock-refresh-token')
expect(store.user).toEqual({
...mockResponse.data.user,
permissions: expect.any(Array)
})
expect(store.isAuthenticated).toBe(true)
expect(store.loading).toBe(false)
expect(localStorage.getItem(TOKEN_KEY)).toBe('mock-jwt-token')
expect(localStorage.getItem(REFRESH_TOKEN_KEY)).toBe('mock-refresh-token')
})
it('登录失败时应该返回false', async () => {
const mockResponse = {
code: '401',
message: '用户名或密码错误',
data: null
}
mockLogin.mockResolvedValue(mockResponse)
const store = useAuthStore()
const result = await store.login({
username: 'wrong',
password: 'wrong'
})
expect(result).toBe(false)
expect(store.token).toBeNull()
expect(store.user).toBeNull()
expect(store.isAuthenticated).toBe(false)
})
it('登录异常时应该返回false', async () => {
mockLogin.mockRejectedValue(new Error('Network error'))
const store = useAuthStore()
const result = await store.login({
username: 'admin',
password: 'admin123'
})
expect(result).toBe(false)
expect(store.loading).toBe(false)
})
})
describe('logout', () => {
it('应该能够成功登出', async () => {
const mockResponse = {
code: '200',
message: '登出成功',
data: null
}
mockLogout.mockResolvedValue(mockResponse)
localStorage.setItem(TOKEN_KEY, 'test-token')
localStorage.setItem(REFRESH_TOKEN_KEY, 'test-refresh-token')
localStorage.setItem('userInfo', JSON.stringify({ id: 1 }))
const store = useAuthStore()
store.token = 'test-token'
store.user = { id: 1, username: 'admin', email: 'admin@example.com', phone: '13800138000', status: 'ACTIVE' }
await store.logout()
expect(store.token).toBeNull()
expect(store.user).toBeNull()
expect(store.isAuthenticated).toBe(false)
expect(localStorage.getItem(TOKEN_KEY)).toBeNull()
expect(localStorage.getItem(REFRESH_TOKEN_KEY)).toBeNull()
expect(localStorage.getItem('userInfo')).toBeNull()
})
it('登出异常时也应该清除本地状态', async () => {
localStorage.setItem(TOKEN_KEY, 'test-token')
mockLogout.mockRejectedValue(new Error('Network error'))
const store = useAuthStore()
store.token = 'test-token'
await store.logout()
expect(store.token).toBeNull()
expect(localStorage.getItem(TOKEN_KEY)).toBeNull()
})
})
describe('refreshAccessToken', () => {
it('应该能够成功刷新token', async () => {
localStorage.setItem(REFRESH_TOKEN_KEY, 'old-refresh-token')
const mockResponse = {
code: '200',
message: '刷新成功',
data: {
token: 'new-jwt-token',
refreshToken: 'new-refresh-token',
user: { id: 1, username: 'admin', email: 'admin@example.com', phone: '13800138000', status: 'ACTIVE' }
}
}
mockRefreshToken.mockResolvedValue(mockResponse)
const store = useAuthStore()
store.refreshToken = 'old-refresh-token'
const result = await store.refreshAccessToken()
expect(result).toBe(true)
expect(store.token).toBe('new-jwt-token')
expect(store.refreshToken).toBe('new-refresh-token')
expect(store.user).toEqual({ id: 1, username: 'admin', email: 'admin@example.com', phone: '13800138000', status: 'ACTIVE' })
})
it('刷新失败时应该返回false', async () => {
const mockResponse = {
code: '401',
message: '刷新token已过期',
data: null
}
mockRefreshToken.mockResolvedValue(mockResponse)
const store = useAuthStore()
const result = await store.refreshAccessToken()
expect(result).toBe(false)
})
})
describe('loadUserInfo', () => {
it('应该能够加载用户信息', () => {
const mockUser = {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
status: 'ACTIVE'
}
localStorage.setItem('userInfo', JSON.stringify(mockUser))
const store = useAuthStore()
store.loadUserInfo()
expect(store.user).toEqual(mockUser)
})
it('加载失败时不应该更新用户信息', () => {
localStorage.setItem('userInfo', 'invalid-json')
const store = useAuthStore()
store.loadUserInfo()
expect(store.user).toBeNull()
})
})
})
@@ -0,0 +1,231 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, VueWrapper } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createI18n } from 'vue-i18n';
import Header from '@/components/Header.vue';
import { useAppStore } from '@/stores/app.store';
import { useAuthStore } from '@/stores/auth.store';
const mockPush = vi.fn();
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>();
return {
...actual,
useRouter: () => ({
push: mockPush
}),
useRoute: () => ({
path: '/dashboard'
})
};
});
vi.mock('ant-design-vue', () => ({
message: {
success: vi.fn(),
error: vi.fn()
}
}));
describe('Header 组件', () => {
let wrapper: VueWrapper;
let appStore: ReturnType<typeof useAppStore>;
let authStore: ReturnType<typeof useAuthStore>;
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
messages: {
'zh-CN': {
layout: {
home: '首页',
userCenter: '个人中心',
logout: '退出登录'
},
menu: {
dashboard: '仪表盘',
userManagement: '用户管理',
roleManagement: '角色管理',
menuManagement: '菜单管理',
permissionManagement: '权限管理',
chartDisplay: '图表展示',
reportStatistics: '报表统计',
settings: '系统设置'
},
settings: {
darkTheme: '已切换到深色主题',
lightTheme: '已切换到浅色主题'
},
auth: {
logoutSuccess: '退出登录成功',
logoutFailed: '退出登录失败'
}
},
'en-US': {
layout: {
home: 'Home',
userCenter: 'User Center',
logout: 'Logout'
},
menu: {
dashboard: 'Dashboard',
userManagement: 'User Management',
roleManagement: 'Role Management',
menuManagement: 'Menu Management',
permissionManagement: 'Permission Management',
chartDisplay: 'Chart Display',
reportStatistics: 'Report Statistics',
settings: 'Settings'
},
settings: {
darkTheme: 'Switched to dark theme',
lightTheme: 'Switched to light theme'
},
auth: {
logoutSuccess: 'Logout successful',
logoutFailed: 'Logout failed'
}
}
}
});
beforeEach(() => {
setActivePinia(createPinia());
appStore = useAppStore();
authStore = useAuthStore();
authStore.user = {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
status: 'ACTIVE'
};
wrapper = mount(Header, {
global: {
plugins: [i18n],
stubs: {
'a-layout-header': true,
'a-button': true,
'a-breadcrumb': true,
'a-breadcrumb-item': true,
'a-space': true,
'a-badge': true,
'a-dropdown': true,
'a-avatar': true,
'a-menu': true,
'a-menu-item': true,
'a-menu-divider': true,
'MenuUnfoldOutlined': true,
'MenuFoldOutlined': true,
'HomeOutlined': true,
'BellOutlined': true,
'UserOutlined': true,
'SettingOutlined': true,
'LogoutOutlined': true,
'DownOutlined': true,
'SunOutlined': true,
'MoonOutlined': true,
'GlobalOutlined': true
}
}
});
vi.clearAllMocks();
});
describe('初始状态', () => {
it('应该正确渲染组件', () => {
expect(wrapper.exists()).toBe(true);
});
it('应该显示用户名', () => {
expect(wrapper.vm.user?.username).toBe('admin');
});
});
describe('侧边栏切换', () => {
it('应该触发侧边栏切换事件', async () => {
await wrapper.vm.toggleSidebar();
expect(wrapper.emitted('toggleSidebar')).toBeTruthy();
});
it('应该更新侧边栏折叠状态', async () => {
const initialCollapsed = appStore.sidebarCollapsed;
await wrapper.vm.toggleSidebar();
expect(appStore.sidebarCollapsed).toBe(!initialCollapsed);
});
});
describe('主题切换', () => {
it('应该能够切换主题', async () => {
const initialTheme = appStore.isDarkMode;
await wrapper.vm.toggleTheme();
expect(appStore.isDarkMode).toBe(!initialTheme);
});
it('切换到深色主题时应该显示成功消息', async () => {
appStore.setTheme('light');
await wrapper.vm.toggleTheme();
expect(appStore.isDarkMode).toBe(true);
});
it('切换到浅色主题时应该显示成功消息', async () => {
appStore.setTheme('dark');
await wrapper.vm.toggleTheme();
expect(appStore.isDarkMode).toBe(false);
});
});
describe('语言切换', () => {
it('应该能够切换语言', async () => {
const { locale } = i18n.global;
const initialLocale = locale.value;
await wrapper.vm.toggleLanguage();
expect(locale.value).not.toBe(initialLocale);
});
it('应该保存语言设置到localStorage', async () => {
await wrapper.vm.toggleLanguage();
expect(localStorage.getItem('locale')).toBeTruthy();
});
});
describe('用户菜单操作', () => {
it('点击个人中心应该跳转到个人中心页面', async () => {
await wrapper.vm.handleProfile();
expect(mockPush).toHaveBeenCalledWith('/profile');
});
it('点击设置应该跳转到设置页面', async () => {
await wrapper.vm.handleSettings();
expect(mockPush).toHaveBeenCalledWith('/settings');
});
it('点击退出登录应该调用登出方法', async () => {
authStore.logout = vi.fn().mockResolvedValue(true);
await wrapper.vm.handleLogout();
expect(authStore.logout).toHaveBeenCalled();
});
it('退出登录成功后应该跳转到登录页', async () => {
authStore.logout = vi.fn().mockResolvedValue(true);
await wrapper.vm.handleLogout();
expect(mockPush).toHaveBeenCalledWith('/login');
});
});
describe('面包屑导航', () => {
it('应该显示首页面包屑', () => {
expect(wrapper.vm.breadcrumbItems).toHaveLength(1);
});
it('应该根据当前路由显示正确的面包屑', () => {
const items = wrapper.vm.breadcrumbItems;
expect(items[0].title).toBe('仪表盘');
expect(items[0].path).toBe('/dashboard');
});
});
});
@@ -0,0 +1,30 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
describe('localStorage 测试', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it('应该能够设置和获取localStorage值', () => {
localStorage.setItem('access_token', 'test-token');
expect(localStorage.getItem('access_token')).toBe('test-token');
});
it('应该能够删除localStorage值', () => {
localStorage.setItem('access_token', 'test-token');
localStorage.removeItem('access_token');
expect(localStorage.getItem('access_token')).toBeNull();
});
it('应该能够清除所有localStorage值', () => {
localStorage.setItem('access_token', 'test-token');
localStorage.setItem('refreshToken', 'test-refresh-token');
localStorage.clear();
expect(localStorage.getItem('access_token')).toBeNull();
expect(localStorage.getItem('refreshToken')).toBeNull();
});
});
@@ -0,0 +1,329 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { menuService } from '@/services/menu.service';
const { mockGet, mockPost, mockPut, mockDelete } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPost: vi.fn(),
mockPut: vi.fn(),
mockDelete: vi.fn()
}));
vi.mock('@/utils/request', () => ({
default: {
get: mockGet,
post: mockPost,
put: mockPut,
delete: mockDelete
}
}));
describe('MenuService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getMenus', () => {
it('应该能够获取菜单列表', async () => {
const mockResponse = {
code: 200,
message: '获取成功',
data: [
{
id: 1,
name: '系统管理',
code: 'SYSTEM',
type: 'MENU',
path: '/system',
icon: 'SettingOutlined',
parentId: null,
sortOrder: 1,
status: 'ENABLED'
},
{
id: 2,
name: '用户管理',
code: 'USER',
type: 'MENU',
path: '/system/user',
icon: 'UserOutlined',
parentId: 1,
sortOrder: 1,
status: 'ENABLED'
}
]
};
mockGet.mockResolvedValue(mockResponse);
const result = await menuService.getMenus();
expect(mockGet).toHaveBeenCalledWith('/sys/menu');
expect(result.data).toBeInstanceOf(Array);
expect(result.data.length).toBe(2);
});
});
describe('createMenu', () => {
it('应该能够创建新菜单', async () => {
const newMenu = {
name: '测试菜单',
code: 'TEST_MENU',
type: 'MENU',
path: '/test',
icon: 'TestOutlined',
parentId: null,
sortOrder: 100,
status: 'ENABLED'
};
const mockResponse = {
code: 200,
message: '创建成功',
data: {
id: 10,
...newMenu
}
};
mockPost.mockResolvedValue(mockResponse);
const result = await menuService.createMenu(newMenu);
expect(mockPost).toHaveBeenCalledWith('/sys/menu', newMenu);
expect(result.data.id).toBe(10);
});
it('应该验证必填字段', async () => {
const invalidMenu = {
name: '',
code: ''
};
const mockResponse = {
code: 400,
message: '菜单名称和编码不能为空',
data: null
};
mockPost.mockResolvedValue(mockResponse);
const result = await menuService.createMenu(invalidMenu);
expect(result.code).toBe(400);
});
});
describe('updateMenu', () => {
it('应该能够更新菜单', async () => {
const updateMenu = {
id: 1,
name: '更新后的菜单',
code: 'SYSTEM',
type: 'MENU',
path: '/system',
icon: 'SettingOutlined',
parentId: null,
sortOrder: 1,
status: 'ENABLED'
};
const mockResponse = {
code: 200,
message: '更新成功',
data: updateMenu
};
mockPut.mockResolvedValue(mockResponse);
const result = await menuService.updateMenu(updateMenu);
expect(mockPut).toHaveBeenCalledWith('/sys/menu', updateMenu);
expect(result.data.name).toBe('更新后的菜单');
});
});
describe('getMenuById', () => {
it('应该能够根据ID获取菜单详情', async () => {
const menuId = 1;
const mockResponse = {
code: 200,
message: '获取成功',
data: {
id: 1,
name: '系统管理',
code: 'SYSTEM',
type: 'MENU',
path: '/system',
icon: 'SettingOutlined',
parentId: null,
sortOrder: 1,
status: 'ENABLED'
}
};
mockGet.mockResolvedValue(mockResponse);
const result = await menuService.getMenuById(menuId);
expect(mockGet).toHaveBeenCalledWith('/sys/menu/1');
expect(result.data.id).toBe(menuId);
});
it('应该处理不存在的菜单ID', async () => {
const menuId = 999;
const mockResponse = {
code: 404,
message: '菜单不存在',
data: null
};
mockGet.mockResolvedValue(mockResponse);
const result = await menuService.getMenuById(menuId);
expect(result.code).toBe(404);
});
});
describe('deleteMenu', () => {
it('应该能够删除菜单', async () => {
const menuId = 1;
const mockResponse = {
code: 200,
message: '删除成功',
data: true
};
mockDelete.mockResolvedValue(mockResponse);
const result = await menuService.deleteMenu(menuId);
expect(mockDelete).toHaveBeenCalledWith('/sys/menu/1');
expect(result.data).toBe(true);
});
it('应该处理删除有子菜单的菜单', async () => {
const menuId = 1;
const mockResponse = {
code: 400,
message: '该菜单下存在子菜单,无法删除',
data: false
};
mockDelete.mockResolvedValue(mockResponse);
const result = await menuService.deleteMenu(menuId);
expect(result.code).toBe(400);
expect(result.data).toBe(false);
});
});
describe('assignMenusToRole', () => {
it('应该能够为角色分配菜单权限', async () => {
const assignData = {
roleId: 1,
menuIds: [1, 2, 3]
};
const mockResponse = {
code: 200,
message: '分配成功',
data: true
};
mockPost.mockResolvedValue(mockResponse);
const result = await menuService.assignMenusToRole(assignData);
expect(mockPost).toHaveBeenCalledWith('/sys/menu/assign', assignData);
expect(result.data).toBe(true);
});
});
describe('getMenusByRole', () => {
it('应该能够获取角色的菜单权限', async () => {
const roleId = 1;
const mockResponse = {
code: 200,
message: '获取成功',
data: [
{
id: 1,
name: '系统管理',
code: 'SYSTEM',
type: 'MENU',
path: '/system',
icon: 'SettingOutlined',
parentId: null,
sortOrder: 1,
status: 'ENABLED'
}
]
};
mockGet.mockResolvedValue(mockResponse);
const result = await menuService.getMenusByRole(roleId);
expect(mockGet).toHaveBeenCalledWith('/sys/menu/role/1');
expect(result.data).toBeInstanceOf(Array);
});
});
describe('removeMenuFromRole', () => {
it('应该能够移除角色的菜单权限', async () => {
const roleId = 1;
const menuId = 2;
const mockResponse = {
code: 200,
message: '移除成功',
data: true
};
mockDelete.mockResolvedValue(mockResponse);
const result = await menuService.removeMenuFromRole(roleId, menuId);
expect(mockDelete).toHaveBeenCalledWith('/sys/menu/role/1/menu/2');
expect(result.data).toBe(true);
});
});
describe('getMenusByUser', () => {
it('应该能够获取用户的菜单权限', async () => {
const userId = 1;
const mockResponse = {
code: 200,
message: '获取成功',
data: [
{
id: 1,
name: '系统管理',
code: 'SYSTEM',
type: 'MENU',
path: '/system',
icon: 'SettingOutlined',
parentId: null,
sortOrder: 1,
status: 'ENABLED'
}
]
};
mockGet.mockResolvedValue(mockResponse);
const result = await menuService.getMenusByUser(userId);
expect(mockGet).toHaveBeenCalledWith('/sys/menu/user/1');
expect(result.data).toBeInstanceOf(Array);
});
});
});
@@ -0,0 +1,322 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useMenuStore } from '@/stores/menu.store'
const { mockGetMenus, mockCreateMenu, mockUpdateMenu, mockDeleteMenu, mockAssignMenusToRole, mockGetMenusByRole, mockGetMenusByUser } = vi.hoisted(() => ({
mockGetMenus: vi.fn(),
mockCreateMenu: vi.fn(),
mockUpdateMenu: vi.fn(),
mockDeleteMenu: vi.fn(),
mockAssignMenusToRole: vi.fn(),
mockGetMenusByRole: vi.fn(),
mockGetMenusByUser: vi.fn()
}))
vi.mock('@/services/menu.service', () => ({
menuService: {
getMenus: mockGetMenus,
createMenu: mockCreateMenu,
updateMenu: mockUpdateMenu,
deleteMenu: mockDeleteMenu,
assignMenusToRole: mockAssignMenusToRole,
getMenusByRole: mockGetMenusByRole,
getMenusByUser: mockGetMenusByUser
}
}))
describe('MenuStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('初始状态', () => {
it('应该正确初始化默认状态', () => {
const store = useMenuStore()
expect(store.menus).toEqual([])
expect(store.loading).toBe(false)
expect(store.error).toBeNull()
})
it('menuTree应该正确构建树形结构', () => {
const store = useMenuStore()
store.menus = [
{ id: 1, name: '系统管理', code: 'system', parentId: 0, sortOrder: 1, path: '/system', icon: 'SettingOutlined', type: 'menu', status: 'active', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
{ id: 2, name: '用户管理', code: 'user', parentId: 1, sortOrder: 1, path: '/system/user', icon: 'UserOutlined', type: 'menu', status: 'active', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
{ id: 3, name: '角色管理', code: 'role', parentId: 1, sortOrder: 2, path: '/system/role', icon: 'TeamOutlined', type: 'menu', status: 'active', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
{ id: 4, name: '首页', code: 'dashboard', parentId: 0, sortOrder: 0, path: '/dashboard', icon: 'HomeOutlined', type: 'menu', status: 'active', createdAt: '2024-01-01', updatedAt: '2024-01-01' }
]
const tree = store.menuTree
expect(tree).toHaveLength(2)
expect(tree[0].name).toBe('首页')
expect(tree[0].sortOrder).toBe(0)
expect(tree[1].name).toBe('系统管理')
expect(tree[1].children).toHaveLength(2)
expect(tree[1].children![0].name).toBe('用户管理')
expect(tree[1].children![1].name).toBe('角色管理')
})
})
describe('fetchMenus', () => {
it('应该能够成功获取菜单列表', async () => {
const mockResponse = {
code: '200',
message: '获取成功',
data: [
{ id: 1, name: '系统管理', code: 'system', parentId: 0, sortOrder: 1, path: '/system', icon: 'SettingOutlined', type: 'menu', status: 'active', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
{ id: 2, name: '用户管理', code: 'user', parentId: 1, sortOrder: 1, path: '/system/user', icon: 'UserOutlined', type: 'menu', status: 'active', createdAt: '2024-01-01', updatedAt: '2024-01-01' }
]
}
mockGetMenus.mockResolvedValue(mockResponse)
const store = useMenuStore()
const result = await store.fetchMenus()
expect(result).toBe(true)
expect(store.menus).toHaveLength(2)
expect(store.loading).toBe(false)
expect(store.error).toBeNull()
})
it('获取失败时应该设置错误信息', async () => {
const mockResponse = {
code: '500',
message: '服务器错误',
data: null
}
mockGetMenus.mockResolvedValue(mockResponse)
const store = useMenuStore()
const result = await store.fetchMenus()
expect(result).toBe(false)
expect(store.error).toBe('服务器错误')
expect(store.loading).toBe(false)
})
it('异常时应该设置错误信息', async () => {
mockGetMenus.mockRejectedValue(new Error('Network error'))
const store = useMenuStore()
const result = await store.fetchMenus()
expect(result).toBe(false)
expect(store.error).toBe('Network error')
})
})
describe('getMenuById', () => {
it('应该能够成功获取单个菜单', () => {
const store = useMenuStore()
store.menus = [
{ id: 1, name: '系统管理', code: 'system', path: '/system', parentId: 0, type: 'menu', status: 'active', icon: 'SettingOutlined', sortOrder: 1, createdAt: '2024-01-01', updatedAt: '2024-01-01' },
{ id: 2, name: '用户管理', code: 'user', path: '/system/user', parentId: 1, type: 'menu', status: 'active', icon: 'UserOutlined', sortOrder: 1, createdAt: '2024-01-01', updatedAt: '2024-01-01' }
]
const menu = store.getMenuById(1)
expect(menu).toBeDefined()
expect(menu?.id).toBe(1)
expect(menu?.name).toBe('系统管理')
})
it('当菜单不存在时应该返回undefined', () => {
const store = useMenuStore()
store.menus = [
{ id: 1, name: '系统管理', code: 'system', path: '/system', parentId: 0, type: 'menu', status: 'active', icon: 'SettingOutlined', sortOrder: 1, createdAt: '2024-01-01', updatedAt: '2024-01-01' }
]
const menu = store.getMenuById(999)
expect(menu).toBeUndefined()
})
})
describe('getMenuByCode', () => {
it('应该能够成功获取单个菜单', () => {
const store = useMenuStore()
store.menus = [
{ id: 1, name: '系统管理', code: 'system', path: '/system', parentId: 0, type: 'menu', status: 'active', icon: 'SettingOutlined', sortOrder: 1, createdAt: '2024-01-01', updatedAt: '2024-01-01' },
{ id: 2, name: '用户管理', code: 'user', path: '/system/user', parentId: 1, type: 'menu', status: 'active', icon: 'UserOutlined', sortOrder: 1, createdAt: '2024-01-01', updatedAt: '2024-01-01' }
]
const menu = store.getMenuByCode('system')
expect(menu).toBeDefined()
expect(menu?.id).toBe(1)
expect(menu?.name).toBe('系统管理')
})
it('当菜单不存在时应该返回undefined', () => {
const store = useMenuStore()
store.menus = [
{ id: 1, name: '系统管理', code: 'system', path: '/system', parentId: 0, type: 'menu', status: 'active', icon: 'SettingOutlined', sortOrder: 1, createdAt: '2024-01-01', updatedAt: '2024-01-01' }
]
const menu = store.getMenuByCode('nonexistent')
expect(menu).toBeUndefined()
})
})
describe('createMenu', () => {
it('应该能够成功创建菜单', async () => {
const newMenu = {
name: '权限管理',
code: 'permission',
parentId: 1,
sortOrder: 3,
path: '/system/permission',
icon: 'SafetyOutlined',
type: 'menu',
status: 'active'
}
const mockResponse = {
code: '200',
message: '创建成功',
data: { id: 5, ...newMenu, createdAt: '2024-01-01', updatedAt: '2024-01-01' }
}
mockCreateMenu.mockResolvedValue(mockResponse)
const store = useMenuStore()
const result = await store.createMenu(newMenu)
expect(result).toBe(true)
expect(mockCreateMenu).toHaveBeenCalledWith(newMenu)
})
it('创建失败时应该返回false', async () => {
const mockResponse = {
code: '400',
message: '菜单代码已存在',
data: null
}
mockCreateMenu.mockResolvedValue(mockResponse)
const store = useMenuStore()
const result = await store.createMenu({ name: '系统管理', code: 'system', parentId: 0, sortOrder: 1, path: '/system', icon: 'SettingOutlined', type: 'menu', status: 'active' })
expect(result).toBe(false)
})
})
describe('updateMenu', () => {
it('应该能够成功更新菜单', async () => {
const updateMenu = {
id: 1,
name: '系统设置',
code: 'system',
parentId: 0,
sortOrder: 1,
path: '/system',
icon: 'SettingOutlined',
type: 'menu',
status: 'active'
}
const mockResponse = {
code: '200',
message: '更新成功',
data: updateMenu
}
mockUpdateMenu.mockResolvedValue(mockResponse)
const store = useMenuStore()
const result = await store.updateMenu(updateMenu)
expect(result).toBe(true)
expect(mockUpdateMenu).toHaveBeenCalledWith(updateMenu)
})
})
describe('deleteMenu', () => {
it('应该能够成功删除菜单', async () => {
const mockResponse = {
code: '200',
message: '删除成功',
data: true
}
mockDeleteMenu.mockResolvedValue(mockResponse)
const store = useMenuStore()
const result = await store.deleteMenu(1)
expect(result).toBe(true)
expect(mockDeleteMenu).toHaveBeenCalledWith(1)
})
})
describe('assignMenusToRole', () => {
it('应该能够成功为角色分配菜单', async () => {
const mockResponse = {
code: '200',
message: '分配成功',
data: true
}
mockAssignMenusToRole.mockResolvedValue(mockResponse)
const store = useMenuStore()
const result = await store.assignMenusToRole({ roleId: 1, menuIds: [1, 2, 3] })
expect(result).toBe(true)
expect(mockAssignMenusToRole).toHaveBeenCalledWith({ roleId: 1, menuIds: [1, 2, 3] })
})
})
describe('fetchMenusByRole', () => {
it('应该能够成功获取角色菜单', async () => {
const mockMenus = [
{ id: 1, name: '系统管理', code: 'system', parentId: 0, sortOrder: 1, path: '/system', icon: 'SettingOutlined', type: 'menu', status: 'active', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
{ id: 2, name: '用户管理', code: 'user', parentId: 1, sortOrder: 1, path: '/system/user', icon: 'UserOutlined', type: 'menu', status: 'active', createdAt: '2024-01-01', updatedAt: '2024-01-01' }
]
const mockResponse = {
code: '200',
message: '获取成功',
data: mockMenus
}
mockGetMenusByRole.mockResolvedValue(mockResponse)
const store = useMenuStore()
const menus = await store.fetchMenusByRole(1)
expect(menus).toEqual(mockMenus)
expect(mockGetMenusByRole).toHaveBeenCalledWith(1)
})
})
describe('fetchMenusByUser', () => {
it('应该能够成功获取用户菜单', async () => {
const mockMenus = [
{ id: 1, name: '系统管理', code: 'system', parentId: 0, sortOrder: 1, path: '/system', icon: 'SettingOutlined', type: 'menu', status: 'active', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
{ id: 2, name: '用户管理', code: 'user', parentId: 1, sortOrder: 1, path: '/system/user', icon: 'UserOutlined', type: 'menu', status: 'active', createdAt: '2024-01-01', updatedAt: '2024-01-01' }
]
const mockResponse = {
code: '200',
message: '获取成功',
data: mockMenus
}
mockGetMenusByUser.mockResolvedValue(mockResponse)
const store = useMenuStore()
const menus = await store.fetchMenusByUser(1)
expect(menus).toEqual(mockMenus)
expect(mockGetMenusByUser).toHaveBeenCalledWith(1)
})
})
})
@@ -0,0 +1,200 @@
/**
* Mock系统测试
* 验证Mock系统覆盖正常请求、异常和边界情况
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import request from '@/utils/request';
import type { Result } from '@/types';
import { mockServer } from '@/mocks/mock-server';
import { generateMockUser, generateMockRole, generateMockMenu, successResponse, errorResponse } from '@/mocks/mock-data';
describe('Mock System Tests', () => {
// 测试正常请求情况
describe('Normal Requests', () => {
it('should handle user listing successfully', async () => {
// 测试获取用户列表
const response: Result<any> = await mockServer.handleRequest('/sys/user', 'GET', undefined, {});
expect(response.code).toBe('200');
expect(response.data).toBeDefined();
expect(Array.isArray(response.data.records)).toBe(true);
expect(response.data.total).toBeGreaterThanOrEqual(0);
});
it('should handle user creation successfully', async () => {
const newUser = {
username: 'testuser',
email: 'test@example.com',
phone: '13800138000',
status: 'ENABLED'
};
const response: Result<any> = await mockServer.handleRequest('/sys/user', 'POST', newUser, {});
expect(response.code).toBe('200');
expect(response.data).toBeDefined();
});
it('should handle role listing successfully', async () => {
const response: Result<any> = await mockServer.handleRequest('/sys/role', 'GET', undefined, {});
expect(response.code).toBe('200');
expect(response.data).toBeDefined();
expect(Array.isArray(response.data)).toBe(true);
});
it('should handle menu listing successfully', async () => {
const response: Result<any> = await mockServer.handleRequest('/sys/menu', 'GET', undefined, {});
expect(response.code).toBe('200');
expect(response.data).toBeDefined();
expect(Array.isArray(response.data)).toBe(true);
});
it('should handle user login successfully', async () => {
const loginData = {
username: 'admin',
password: 'password'
};
const response: Result<any> = await mockServer.handleRequest('/sys/auth/login', 'POST', loginData, {});
expect(response.code).toBe('200');
expect(response.data).toBeDefined();
expect(response.data.token).toBeDefined();
expect(response.data.user).toBeDefined();
});
});
// 测试异常处理情况
describe('Error Handling', () => {
it('should handle invalid user ID gracefully', async () => {
const invalidId = 999999; // 假设这个ID不存在
const response: Result<any> = await mockServer.handleRequest(`/sys/user/${invalidId}`, 'GET', undefined, {});
expect(response.code).not.toBe('200'); // 应该不是成功状态
});
it('should handle invalid role ID gracefully', async () => {
const invalidId = 999999; // 假设这个ID不存在
const response: Result<any> = await mockServer.handleRequest(`/sys/role/${invalidId}`, 'GET', undefined, {});
expect(response.code).not.toBe('200'); // 应该不是成功状态
});
});
// 测试边界情况
describe('Boundary Conditions', () => {
it('should handle empty query parameters', async () => {
const response: Result<any> = await mockServer.handleRequest('/sys/user', 'GET', undefined, {
current: 1,
size: 10
});
expect(response.code).toBe('200');
expect(response.data).toBeDefined();
});
it('should handle large page numbers', async () => {
const response: Result<any> = await mockServer.handleRequest('/sys/user', 'GET', undefined, {
current: 9999,
size: 10
});
expect(response.code).toBe('200');
expect(response.data).toBeDefined();
});
it('should handle empty search results', async () => {
const response: Result<any> = await mockServer.handleRequest('/sys/user', 'GET', undefined, {
username: 'nonexistentuser12345',
current: 1,
size: 10
});
expect(response.code).toBe('200');
expect(response.data).toBeDefined();
expect(Array.isArray(response.data.records)).toBe(true);
});
});
// 测试Mock数据生成器
describe('Mock Data Generators', () => {
it('should generate valid user data', () => {
const user = generateMockUser();
expect(user).toBeDefined();
expect(user.id).toBeDefined();
expect(user.username).toBeDefined();
expect(user.email).toBeDefined();
expect(user.status).toBeDefined();
});
it('should generate valid role data', () => {
const role = generateMockRole();
expect(role).toBeDefined();
expect(role.id).toBeDefined();
expect(role.name).toBeDefined();
expect(role.roleKey).toBeDefined();
expect(role.status).toBeDefined();
});
it('should generate valid menu data', () => {
const menu = generateMockMenu();
expect(menu).toBeDefined();
expect(menu.id).toBeDefined();
expect(menu.name).toBeDefined();
expect(menu.path).toBeDefined();
expect(menu.status).toBeDefined();
});
it('should create success response correctly', () => {
const testData = { id: 1, name: 'test' };
const response = successResponse(testData, 'Test success');
expect(response.code).toBe('200');
expect(response.message).toBe('Test success');
expect(response.data).toEqual(testData);
});
it('should create error response correctly', () => {
const response = errorResponse('404', 'Not found');
expect(response.code).toBe('404');
expect(response.message).toBe('Not found');
expect(response.data).toBeDefined(); // 应该是undefined,但类型兼容
});
});
// 测试Mock服务器功能
describe('Mock Server Functionality', () => {
it('should handle various HTTP methods', async () => {
// 测试GET
const getResponse = await mockServer.handleRequest('/sys/user', 'GET');
expect(getResponse.code).toBe('200');
// 测试POST
const postData = {
username: 'newuser',
email: 'newuser@example.com',
status: 'ENABLED'
};
const postResponse = await mockServer.handleRequest('/sys/user', 'POST', postData);
expect(postResponse.code).toBe('200');
// 测试PUT
const putData = { username: 'updateduser' };
const putResponse = await mockServer.handleRequest('/sys/user/1', 'PUT', putData);
expect(putResponse.code).toBe('200');
// 测试DELETE
const deleteResponse = await mockServer.handleRequest('/sys/user/1', 'DELETE');
expect(deleteResponse.code).toBe('200');
});
});
});
console.log('Mock system tests prepared');
@@ -0,0 +1,245 @@
/**
* Mock系统单元测试
* 专门测试Mock系统的各个组件功能
*/
import { describe, it, expect } from 'vitest';
import { mockServer } from '@/mocks/mock-server';
import { generateMockUser, generateMockRole, generateMockMenu, successResponse, errorResponse, randomDelay, generatePageResult } from '@/mocks/mock-data';
describe('Mock System Unit Tests', () => {
// 测试Mock数据生成器
describe('Mock Data Generators', () => {
it('should generate valid user data', () => {
const user = generateMockUser();
expect(user).toBeDefined();
expect(user.id).toBeDefined();
expect(user.username).toBeDefined();
expect(user.email).toBeDefined();
expect(user.status).toBeDefined();
expect(['ENABLED', 'DISABLED']).toContain(user.status);
});
it('should generate valid role data', () => {
const role = generateMockRole();
expect(role).toBeDefined();
expect(role.id).toBeDefined();
expect(role.name).toBeDefined();
expect(role.roleKey).toBeDefined();
expect(role.status).toBeDefined();
expect(['ENABLED', 'DISABLED']).toContain(role.status);
});
it('should generate valid menu data', () => {
const menu = generateMockMenu();
expect(menu).toBeDefined();
expect(menu.id).toBeDefined();
expect(menu.name).toBeDefined();
expect(menu.path).toBeDefined();
expect(menu.status).toBeDefined();
expect(['ENABLED', 'DISABLED']).toContain(menu.status);
});
it('should generate user with specific ID', () => {
const userId = 123;
const user = generateMockUser(userId);
expect(user.id).toBe(userId);
});
it('should create success response correctly', () => {
const testData = { id: 1, name: 'test' };
const response = successResponse(testData, 'Test success');
expect(response.code).toBe('200');
expect(response.message).toBe('Test success');
expect(response.data).toEqual(testData);
});
it('should create error response correctly', () => {
const response = errorResponse('404', 'Not found');
expect(response.code).toBe('404');
expect(response.message).toBe('Not found');
expect(response.data).toBeDefined();
});
it('should generate paginated results', () => {
const mockData = Array.from({ length: 25 }, (_, i) => ({ id: i + 1, name: `item${i + 1}` }));
const pageResult = generatePageResult(mockData, 2, 10);
expect(pageResult.current).toBe(2);
expect(pageResult.size).toBe(10);
expect(pageResult.total).toBe(25);
expect(pageResult.records.length).toBe(10); // 第2页应该有10条记录
expect(pageResult.records[0].id).toBe(11); // 第11条记录
});
it('should handle delay function', async () => {
const start = Date.now();
await randomDelay(100, 200); // 延迟100-200毫秒
const end = Date.now();
expect(end - start).toBeGreaterThanOrEqual(100);
});
});
// 测试Mock服务器功能
describe('Mock Server Functionality', () => {
it('should handle user listing', async () => {
const result = await mockServer.handleRequest('/sys/user', 'GET');
expect(result.code).toBe('200');
expect(result.data).toBeDefined();
expect(result.data.records).toBeDefined();
expect(Array.isArray(result.data.records)).toBe(true);
});
it('should handle user creation', async () => {
const userData = {
username: 'testuser',
email: 'test@example.com',
phone: '13800138000',
status: 'ENABLED'
};
const result = await mockServer.handleRequest('/sys/user', 'POST', userData);
expect(result.code).toBe('200');
expect(result.data).toBeDefined();
});
it('should handle user details retrieval', async () => {
// 先创建一个用户
const userData = {
username: 'detailtest',
email: 'detailtest@example.com',
phone: '13800138000',
status: 'ENABLED'
};
const createResult = await mockServer.handleRequest('/sys/user', 'POST', userData);
expect(createResult.code).toBe('200');
// 然后获取用户详情
const userId = createResult.data;
const getResult = await mockServer.handleRequest(`/sys/user/${userId}`, 'GET');
expect(getResult.code).toBe('200');
expect(getResult.data).toBeDefined();
expect(getResult.data.username).toBe('detailtest');
});
it('should handle role operations', async () => {
// 获取角色列表
const listResult = await mockServer.handleRequest('/sys/role', 'GET');
expect(listResult.code).toBe('200');
expect(Array.isArray(listResult.data)).toBe(true);
// 创建角色
const roleData = {
name: 'Test Role',
roleKey: 'test_role',
description: 'A test role',
status: 'ENABLED'
};
const createResult = await mockServer.handleRequest('/sys/role', 'POST', roleData);
expect(createResult.code).toBe('200');
expect(createResult.data).toBeDefined();
});
it('should handle menu operations', async () => {
// 获取菜单列表
const listResult = await mockServer.handleRequest('/sys/menu', 'GET');
expect(listResult.code).toBe('200');
expect(Array.isArray(listResult.data)).toBe(true);
// 创建菜单
const menuData = {
name: 'Test Menu',
code: 'test_menu',
path: '/test',
status: 'ENABLED'
};
const createResult = await mockServer.handleRequest('/sys/menu', 'POST', menuData);
expect(createResult.code).toBe('200');
expect(createResult.data).toBeDefined();
});
it('should handle authentication', async () => {
const loginData = {
username: 'admin',
password: 'password'
};
const result = await mockServer.handleRequest('/sys/auth/login', 'POST', loginData);
expect(result.code).toBe('200');
expect(result.data).toBeDefined();
expect(result.data.token).toBeDefined();
expect(result.data.user).toBeDefined();
});
it('should handle invalid endpoints', async () => {
const result = await mockServer.handleRequest('/invalid/endpoint', 'GET');
expect(result.code).not.toBe('200');
});
it('should handle unsupported methods', async () => {
const result = await mockServer.handleRequest('/sys/user', 'PATCH');
expect(result.code).toBe('405'); // Method not allowed
});
it('should handle user search with parameters', async () => {
const result = await mockServer.handleRequest('/sys/user', 'GET', undefined, {
username: 'admin',
current: 1,
size: 10
});
expect(result.code).toBe('200');
expect(result.data).toBeDefined();
});
});
// 测试边界条件
describe('Boundary Conditions', () => {
it('should handle large page numbers gracefully', async () => {
const result = await mockServer.handleRequest('/sys/user', 'GET', undefined, {
current: 999999, // 非常大的页码
size: 10
});
expect(result.code).toBe('200');
expect(result.data).toBeDefined();
// 即使页码很大,也应该返回有效的响应结构
});
it('should handle empty search queries', async () => {
const result = await mockServer.handleRequest('/sys/user', 'GET', undefined, {
username: '', // 空搜索
current: 1,
size: 10
});
expect(result.code).toBe('200');
expect(result.data).toBeDefined();
});
it('should handle non-existent user IDs', async () => {
const result = await mockServer.handleRequest('/sys/user/999999', 'GET'); // 假设此ID不存在
// 根据实现,这可能返回404或空结果,具体取决于Mock实现
expect(result.code).toBeDefined();
});
});
});
console.log('Mock unit tests completed');
@@ -0,0 +1,315 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { roleService } from '@/services/role.service';
const { mockGet, mockPost, mockPut, mockDelete } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPost: vi.fn(),
mockPut: vi.fn(),
mockDelete: vi.fn()
}));
vi.mock('@/utils/request', () => ({
default: {
get: mockGet,
post: mockPost,
put: mockPut,
delete: mockDelete
}
}));
describe('RoleService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getRoles', () => {
it('应该能够获取角色列表', async () => {
const mockResponse = {
code: 200,
message: '获取成功',
data: [
{
id: 1,
name: '管理员',
roleKey: 'ADMIN',
description: '系统管理员',
status: 'ENABLED'
},
{
id: 2,
name: '普通用户',
roleKey: 'USER',
description: '普通用户',
status: 'ENABLED'
}
]
};
mockGet.mockResolvedValue(mockResponse);
const result = await roleService.getRoles();
expect(mockGet).toHaveBeenCalledWith('/sys/role');
expect(result.data).toBeInstanceOf(Array);
expect(result.data.length).toBe(2);
});
});
describe('createRole', () => {
it('应该能够创建新角色', async () => {
const newRole = {
name: '测试角色',
roleKey: 'TEST_ROLE',
description: '测试角色描述',
status: 'ENABLED'
};
const mockResponse = {
code: 200,
message: '创建成功',
data: {
id: 3,
...newRole
}
};
mockPost.mockResolvedValue(mockResponse);
const result = await roleService.createRole(newRole);
expect(mockPost).toHaveBeenCalledWith('/sys/role', newRole);
expect(result.data.id).toBe(3);
});
it('应该验证必填字段', async () => {
const invalidRole = {
name: '',
roleKey: ''
};
const mockResponse = {
code: 400,
message: '角色名称和角色编码不能为空',
data: null
};
mockPost.mockResolvedValue(mockResponse);
const result = await roleService.createRole(invalidRole);
expect(result.code).toBe(400);
});
});
describe('updateRole', () => {
it('应该能够更新角色', async () => {
const updateRole = {
id: 1,
name: '更新后的角色',
roleKey: 'ADMIN',
description: '更新后的描述',
status: 'ENABLED'
};
const mockResponse = {
code: 200,
message: '更新成功',
data: updateRole
};
mockPut.mockResolvedValue(mockResponse);
const result = await roleService.updateRole(updateRole);
expect(mockPut).toHaveBeenCalledWith('/sys/role', updateRole);
expect(result.data.name).toBe('更新后的角色');
});
});
describe('getRoleById', () => {
it('应该能够根据ID获取角色详情', async () => {
const roleId = 1;
const mockResponse = {
code: 200,
message: '获取成功',
data: {
id: 1,
name: '管理员',
roleKey: 'ADMIN',
description: '系统管理员',
status: 'ENABLED'
}
};
mockGet.mockResolvedValue(mockResponse);
const result = await roleService.getRoleById(roleId);
expect(mockGet).toHaveBeenCalledWith('/sys/role/1');
expect(result.data.id).toBe(roleId);
});
it('应该处理不存在的角色ID', async () => {
const roleId = 999;
const mockResponse = {
code: 404,
message: '角色不存在',
data: null
};
mockGet.mockResolvedValue(mockResponse);
const result = await roleService.getRoleById(roleId);
expect(result.code).toBe(404);
});
});
describe('deleteRole', () => {
it('应该能够删除角色', async () => {
const roleId = 1;
const mockResponse = {
code: 200,
message: '删除成功',
data: true
};
mockDelete.mockResolvedValue(mockResponse);
const result = await roleService.deleteRole(roleId);
expect(mockDelete).toHaveBeenCalledWith('/sys/role/1');
expect(result.data).toBe(true);
});
it('应该处理删除有关联用户的角色', async () => {
const roleId = 1;
const mockResponse = {
code: 400,
message: '该角色下存在用户,无法删除',
data: false
};
mockDelete.mockResolvedValue(mockResponse);
const result = await roleService.deleteRole(roleId);
expect(result.code).toBe(400);
expect(result.data).toBe(false);
});
});
describe('assignRolesToUser', () => {
it('应该能够为用户分配角色', async () => {
const assignData = {
userId: 1,
roleIds: [1, 2]
};
const mockResponse = {
code: 200,
message: '分配成功',
data: true
};
mockPost.mockResolvedValue(mockResponse);
const result = await roleService.assignRolesToUser(assignData);
expect(mockPost).toHaveBeenCalledWith('/sys/role/assign', assignData);
expect(result.data).toBe(true);
});
});
describe('getRoleByRoleKey', () => {
it('应该能够根据角色编码获取角色', async () => {
const roleKey = 'ADMIN';
const mockResponse = {
code: 200,
message: '获取成功',
data: {
id: 1,
name: '管理员',
roleKey: 'ADMIN',
description: '系统管理员',
status: 'ENABLED'
}
};
mockGet.mockResolvedValue(mockResponse);
const result = await roleService.getRoleByRoleKey(roleKey);
expect(mockGet).toHaveBeenCalledWith('/sys/role/roleKey/ADMIN');
expect(result.data.roleKey).toBe(roleKey);
});
it('应该处理不存在的角色编码', async () => {
const roleKey = 'NONEXISTENT';
const mockResponse = {
code: 404,
message: '角色不存在',
data: null
};
mockGet.mockResolvedValue(mockResponse);
const result = await roleService.getRoleByRoleKey(roleKey);
expect(result.code).toBe(404);
});
});
describe('getRolesByUser', () => {
it('应该能够获取用户的角色列表', async () => {
const userId = 1;
const mockResponse = {
code: 200,
message: '获取成功',
data: [
{
id: 1,
name: '管理员',
roleKey: 'ADMIN',
description: '系统管理员',
status: 'ENABLED'
}
]
};
mockGet.mockResolvedValue(mockResponse);
const result = await roleService.getRolesByUser(userId);
expect(mockGet).toHaveBeenCalledWith('/sys/role/user/1');
expect(result.data).toBeInstanceOf(Array);
});
});
describe('removeRoleFromUser', () => {
it('应该能够移除用户的角色', async () => {
const userId = 1;
const roleId = 2;
const mockResponse = {
code: 200,
message: '移除成功',
data: true
};
mockDelete.mockResolvedValue(mockResponse);
const result = await roleService.removeRoleFromUser(userId, roleId);
expect(mockDelete).toHaveBeenCalledWith('/sys/role/user/1/role/2');
expect(result.data).toBe(true);
});
});
});
@@ -0,0 +1,244 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useRoleStore } from '@/stores/role.store'
const { mockGetRoles, mockGetRoleById, mockCreateRole, mockUpdateRole, mockDeleteRole, mockAssignRolesToUser, mockGetRolesByUser, mockGetRoleByRoleKey, mockRemoveRoleFromUser } = vi.hoisted(() => ({
mockGetRoles: vi.fn(),
mockGetRoleById: vi.fn(),
mockCreateRole: vi.fn(),
mockUpdateRole: vi.fn(),
mockDeleteRole: vi.fn(),
mockAssignRolesToUser: vi.fn(),
mockGetRolesByUser: vi.fn(),
mockGetRoleByRoleKey: vi.fn(),
mockRemoveRoleFromUser: vi.fn()
}))
vi.mock('@/services/role.service', () => ({
roleService: {
getRoles: mockGetRoles,
getRoleById: mockGetRoleById,
createRole: mockCreateRole,
updateRole: mockUpdateRole,
deleteRole: mockDeleteRole,
assignRolesToUser: mockAssignRolesToUser,
getRolesByUser: mockGetRolesByUser,
getRoleByRoleKey: mockGetRoleByRoleKey,
removeRoleFromUser: mockRemoveRoleFromUser
}
}))
describe('RoleStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('初始状态', () => {
it('应该正确初始化默认状态', () => {
const store = useRoleStore()
expect(store.roles).toEqual([])
expect(store.loading).toBe(false)
expect(store.error).toBeNull()
})
it('activeRoles应该正确过滤活跃角色', () => {
const store = useRoleStore()
store.roles = [
{ id: 1, roleName: 'admin', roleCode: 'ADMIN', status: 'active', description: '管理员' },
{ id: 2, roleName: 'user', roleCode: 'USER', status: 'inactive', description: '普通用户' }
]
expect(store.activeRoles).toHaveLength(1)
expect(store.activeRoles[0].roleCode).toBe('ADMIN')
})
})
describe('fetchRoles', () => {
it('应该能够成功获取角色列表', async () => {
const mockResponse = {
code: '200',
message: '获取成功',
data: [
{ id: 1, roleName: 'admin', roleCode: 'ADMIN', status: 'active', description: '管理员' },
{ id: 2, roleName: 'user', roleCode: 'USER', status: 'active', description: '普通用户' }
]
}
mockGetRoles.mockResolvedValue(mockResponse)
const store = useRoleStore()
const result = await store.fetchRoles()
expect(result).toBe(true)
expect(store.roles).toHaveLength(2)
expect(store.loading).toBe(false)
expect(store.error).toBeNull()
})
it('获取失败时应该设置错误信息', async () => {
const mockResponse = {
code: '500',
message: '服务器错误',
data: null
}
mockGetRoles.mockResolvedValue(mockResponse)
const store = useRoleStore()
const result = await store.fetchRoles()
expect(result).toBe(false)
expect(store.error).toBe('服务器错误')
expect(store.loading).toBe(false)
})
it('异常时应该设置错误信息', async () => {
mockGetRoles.mockRejectedValue(new Error('Network error'))
const store = useRoleStore()
const result = await store.fetchRoles()
expect(result).toBe(false)
expect(store.error).toBe('Network error')
})
})
describe('getRoleById', () => {
it('应该能够成功获取单个角色', () => {
const mockRole = { id: 1, roleName: 'admin', roleCode: 'ADMIN', status: 'active', description: '管理员' }
const store = useRoleStore()
store.roles = [mockRole]
const role = store.getRoleById(1)
expect(role).toEqual(mockRole)
})
})
describe('createRole', () => {
it('应该能够成功创建角色', async () => {
const newRole = {
roleName: 'editor',
roleCode: 'EDITOR',
status: 'active',
description: '编辑'
}
const mockResponse = {
code: '200',
message: '创建成功',
data: { id: 3, ...newRole }
}
mockCreateRole.mockResolvedValue(mockResponse)
const store = useRoleStore()
const result = await store.createRole(newRole)
expect(result).toBe(true)
expect(mockCreateRole).toHaveBeenCalledWith(newRole)
})
it('创建失败时应该返回false', async () => {
const mockResponse = {
code: '400',
message: '角色代码已存在',
data: null
}
mockCreateRole.mockResolvedValue(mockResponse)
const store = useRoleStore()
const result = await store.createRole({ roleName: 'admin', roleCode: 'ADMIN', status: 'active', description: '管理员' })
expect(result).toBe(false)
})
})
describe('updateRole', () => {
it('应该能够成功更新角色', async () => {
const updateRole = {
id: 1,
roleName: 'admin',
roleCode: 'ADMIN',
status: 'active',
description: '超级管理员'
}
const mockResponse = {
code: '200',
message: '更新成功',
data: updateRole
}
mockUpdateRole.mockResolvedValue(mockResponse)
const store = useRoleStore()
const result = await store.updateRole(updateRole)
expect(result).toBe(true)
expect(mockUpdateRole).toHaveBeenCalledWith(updateRole)
})
})
describe('deleteRole', () => {
it('应该能够成功删除角色', async () => {
const mockResponse = {
code: '200',
message: '删除成功',
data: true
}
mockDeleteRole.mockResolvedValue(mockResponse)
const store = useRoleStore()
const result = await store.deleteRole(1)
expect(result).toBe(true)
expect(mockDeleteRole).toHaveBeenCalledWith(1)
})
})
describe('assignRolesToUser', () => {
it('应该能够成功为用户分配角色', async () => {
const mockResponse = {
code: '200',
message: '分配成功',
data: true
}
mockAssignRolesToUser.mockResolvedValue(mockResponse)
const store = useRoleStore()
const result = await store.assignRolesToUser({ userId: 1, roleIds: [1, 2] })
expect(result).toBe(true)
expect(mockAssignRolesToUser).toHaveBeenCalledWith({ userId: 1, roleIds: [1, 2] })
})
})
describe('fetchRolesByUser', () => {
it('应该能够成功获取用户角色', async () => {
const mockRoles = [
{ id: 1, roleName: 'admin', roleCode: 'ADMIN', status: 'active', description: '管理员' }
]
const mockResponse = {
code: '200',
message: '获取成功',
data: mockRoles
}
mockGetRolesByUser.mockResolvedValue(mockResponse)
const store = useRoleStore()
const roles = await store.fetchRolesByUser(1)
expect(roles).toEqual(mockRoles)
expect(mockGetRolesByUser).toHaveBeenCalledWith(1)
})
})
})
@@ -0,0 +1,130 @@
import { expect, afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/vue';
import '@testing-library/jest-dom';
// 设置测试环境变量
vi.stubEnv('MODE', 'test');
vi.stubEnv('VITE_MOCK_ENABLED', 'true');
vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8080');
const mockRoutes = [
{
path: '/',
name: 'Layout',
component: { template: '<div></div>' },
children: [
{
path: '/dashboard',
name: 'Dashboard',
component: { template: '<div></div>' },
meta: { title: '仪表盘', icon: 'DashboardOutlined' }
},
{
path: '/users',
name: 'Users',
component: { template: '<div></div>' },
meta: { title: '用户管理', icon: 'UserOutlined' }
},
{
path: '/system',
name: 'System',
component: { template: '<div></div>' },
meta: { title: '系统管理', icon: 'SettingOutlined' },
children: [
{
path: '/system/roles',
name: 'Roles',
component: { template: '<div></div>' },
meta: { title: '角色管理', icon: 'LockOutlined' }
},
{
path: '/system/menus',
name: 'Menus',
component: { template: '<div></div>' },
meta: { title: '菜单管理', icon: 'MenuOutlined' }
}
]
},
{
path: '/hidden',
name: 'Hidden',
component: { template: '<div></div>' },
meta: { title: '隐藏菜单', icon: 'SettingOutlined', hidden: true }
}
]
}
];
const mockRouter = {
beforeEach: vi.fn(),
afterEach: vi.fn(),
push: vi.fn(),
currentRoute: { value: { path: '/dashboard' } },
getRoutes: () => mockRoutes
};
vi.mock('vue-router', () => ({
useRouter: () => mockRouter,
useRoute: () => ({ path: '/dashboard' }),
createRouter: vi.fn(),
createWebHistory: vi.fn()
}));
vi.mock('@/router', () => ({
resetRouter: vi.fn(),
default: mockRouter
}));
vi.mock('@/views/dashboard.vue', () => ({
default: {
template: '<div>Dashboard</div>'
}
}));
vi.mock('@/views/Login.vue', () => ({
default: {
template: '<div>Login</div>'
}
}));
vi.mock('@/views/NotFound.vue', () => ({
default: {
template: '<div>NotFound</div>'
}
}));
vi.mock('@/views/UserManagement.vue', () => ({
default: {
template: '<div>UserManagement</div>'
}
}));
vi.mock('@/views/RoleManagement.vue', () => ({
default: {
template: '<div>RoleManagement</div>'
}
}));
vi.mock('@/views/MenuManagement.vue', () => ({
default: {
template: '<div>MenuManagement</div>'
}
}));
vi.mock('@/views/PermissionManagement.vue', () => ({
default: {
template: '<div>PermissionManagement</div>'
}
}));
vi.mock('@/layouts/DefaultLayout.vue', () => ({
default: {
template: '<div>DefaultLayout</div>'
}
}));
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
@@ -0,0 +1,249 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, VueWrapper } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router';
import { useAppStore } from '@/stores/app.store';
import { useAuthStore } from '@/stores/auth.store';
const mockRoutes = [
{
path: '/',
name: 'Layout',
component: { template: '<div></div>' },
children: [
{
path: '/dashboard',
name: 'Dashboard',
component: { template: '<div></div>' },
meta: { title: '仪表盘', icon: 'DashboardOutlined' }
},
{
path: '/users',
name: 'Users',
component: { template: '<div></div>' },
meta: { title: '用户管理', icon: 'UserOutlined' }
},
{
path: '/system',
name: 'System',
component: { template: '<div></div>' },
meta: { title: '系统管理', icon: 'SettingOutlined' },
children: [
{
path: '/system/roles',
name: 'Roles',
component: { template: '<div></div>' },
meta: { title: '角色管理', icon: 'LockOutlined' }
},
{
path: '/system/menus',
name: 'Menus',
component: { template: '<div></div>' },
meta: { title: '菜单管理', icon: 'MenuOutlined' }
}
]
},
{
path: '/hidden',
name: 'Hidden',
component: { template: '<div></div>' },
meta: { title: '隐藏菜单', icon: 'SettingOutlined', hidden: true }
}
]
}
];
const router = createRouter({
history: createMemoryHistory(),
routes: mockRoutes
});
vi.mock('@/router', () => ({
resetRouter: vi.fn(),
default: router
}));
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>();
return {
...actual,
useRouter: () => router,
useRoute: () => router.currentRoute.value
};
});
import Sidebar from '@/components/Sidebar.vue';
describe('Sidebar 组件', () => {
let wrapper: VueWrapper;
let appStore: ReturnType<typeof useAppStore>;
let authStore: ReturnType<typeof useAuthStore>;
beforeEach(() => {
setActivePinia(createPinia());
appStore = useAppStore();
authStore = useAuthStore();
authStore.user = {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
status: 'ACTIVE',
permissions: ['dashboard:view', 'user:view', 'role:view', 'menu:view']
};
wrapper = mount(Sidebar, {
global: {
plugins: [router],
stubs: {
'MenuOutlined': true,
'DashboardOutlined': true,
'UserOutlined': true,
'LockOutlined': true,
'SettingOutlined': true
}
}
});
vi.clearAllMocks();
});
describe('初始状态', () => {
it('应该正确渲染组件', () => {
expect(wrapper.exists()).toBe(true);
});
it('应该显示侧边栏', () => {
expect(wrapper.find('.sidebar').exists()).toBe(true);
});
it('应该显示菜单', () => {
expect(wrapper.find('.sidebar-menu').exists()).toBe(true);
});
});
describe('侧边栏折叠', () => {
it('应该响应侧边栏折叠状态', () => {
appStore.toggleSidebar();
expect(wrapper.vm.collapsed).toBe(true);
});
it('折叠时应该显示图标而不是文字', async () => {
appStore.setSidebarCollapsed(true);
await wrapper.vm.$nextTick();
expect(wrapper.vm.collapsed).toBe(true);
});
});
describe('菜单项生成', () => {
it('应该根据路由生成菜单项', () => {
const menuItems = wrapper.vm.menuItems;
expect(menuItems).toBeDefined();
expect(Array.isArray(menuItems)).toBe(true);
});
it('应该隐藏标记为hidden的菜单项', () => {
const menuItems = wrapper.vm.menuItems;
const hiddenItem = menuItems.find((item: any) => item.key === 'Hidden');
expect(hiddenItem).toBeUndefined();
});
it('应该包含仪表盘菜单项', () => {
const menuItems = wrapper.vm.menuItems;
const dashboardItem = menuItems.find((item: any) => item.key === 'Dashboard');
expect(dashboardItem).toBeDefined();
expect(dashboardItem.label).toBe('仪表盘');
});
});
describe('菜单选择', () => {
it('应该能够选择菜单项', async () => {
await wrapper.vm.handleMenuSelect({ key: 'Dashboard' });
expect(wrapper.emitted('menuSelect')).toBeTruthy();
expect(wrapper.emitted('menuSelect')?.[0]).toEqual(['Dashboard']);
});
it('选择菜单项应该导航到对应路由', async () => {
const pushSpy = vi.spyOn(router, 'push');
await wrapper.vm.handleMenuSelect({ key: 'Dashboard' });
expect(pushSpy).toHaveBeenCalledWith('/dashboard');
pushSpy.mockRestore();
});
});
describe('权限控制', () => {
it('应该检查用户权限', () => {
const hasPermission = wrapper.vm.hasPermission('user:view');
expect(hasPermission).toBe(true);
});
it('没有权限的菜单项应该被隐藏', () => {
authStore.user = {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
status: 'ACTIVE',
permissions: []
};
const hasPermission = wrapper.vm.hasPermission('user:view');
expect(hasPermission).toBe(false);
});
it('未定义权限的菜单项应该显示', () => {
const hasPermission = wrapper.vm.hasPermission(undefined);
expect(hasPermission).toBe(true);
});
});
describe('路由监听', () => {
it('应该更新选中的菜单项', () => {
expect(wrapper.vm.selectedKeys).toContain('Dashboard');
});
it('应该展开包含当前路由的父菜单', () => {
const openKeys = wrapper.vm.openKeys;
expect(Array.isArray(openKeys)).toBe(true);
});
});
describe('主题和模式', () => {
it('应该接受主题属性', () => {
const wrapperWithLightTheme = mount(Sidebar, {
props: { theme: 'light' },
global: {
plugins: [router],
stubs: {
'a-layout-sider': {
template: '<div class="sidebar-stub"><slot /></div>'
},
'a-menu': {
template: '<div class="menu-stub"><slot /></div>'
}
}
}
});
expect(wrapperWithLightTheme.exists()).toBe(true);
});
it('应该接受模式属性', () => {
const wrapperWithVerticalMode = mount(Sidebar, {
props: { mode: 'vertical' },
global: {
plugins: [router],
stubs: {
'a-layout-sider': {
template: '<div class="sidebar-stub"><slot /></div>'
},
'a-menu': {
template: '<div class="menu-stub"><slot /></div>'
}
}
}
});
expect(wrapperWithVerticalMode.exists()).toBe(true);
});
});
});
@@ -0,0 +1,309 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { userService } from '@/services/user.service';
const { mockGet, mockPost, mockPut, mockDelete } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPost: vi.fn(),
mockPut: vi.fn(),
mockDelete: vi.fn()
}));
vi.mock('@/utils/request', () => ({
default: {
get: mockGet,
post: mockPost,
put: mockPut,
delete: mockDelete
}
}));
describe('UserService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getUsers', () => {
it('应该能够获取用户列表', async () => {
const params = {
current: 1,
size: 10
};
const mockResponse = {
code: 200,
message: '获取成功',
data: {
records: [
{
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
status: 'ACTIVE'
},
{
id: 2,
username: 'user',
email: 'user@example.com',
phone: '13900139000',
status: 'ACTIVE'
}
],
total: 2,
current: 1,
size: 10
}
};
mockGet.mockResolvedValue(mockResponse);
const result = await userService.getUsers(params);
expect(mockGet).toHaveBeenCalledWith('/sys/user', { params });
expect(result.data.records).toBeInstanceOf(Array);
expect(result.data.records.length).toBe(2);
});
it('应该支持搜索参数', async () => {
const params = {
current: 1,
size: 10,
username: 'admin',
email: 'admin@example.com',
status: 'ACTIVE'
};
const mockResponse = {
code: 200,
message: '获取成功',
data: {
records: [
{
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
status: 'ACTIVE'
}
],
total: 1,
current: 1,
size: 10
}
};
mockGet.mockResolvedValue(mockResponse);
const result = await userService.getUsers(params);
expect(mockGet).toHaveBeenCalledWith('/sys/user', { params });
expect(result.data.records.length).toBe(1);
});
});
describe('createUser', () => {
it('应该能够创建新用户', async () => {
const newUser = {
username: 'newuser',
password: 'password123',
email: 'newuser@example.com',
phone: '13700137000'
};
const mockResponse = {
code: 200,
message: '创建成功',
data: 3
};
mockPost.mockResolvedValue(mockResponse);
const result = await userService.createUser(newUser);
expect(mockPost).toHaveBeenCalledWith('/sys/user', newUser);
expect(result.data).toBe(3);
});
it('应该验证必填字段', async () => {
const invalidUser = {
username: '',
password: ''
};
const mockResponse = {
code: 400,
message: '用户名和密码不能为空',
data: null
};
mockPost.mockResolvedValue(mockResponse);
const result = await userService.createUser(invalidUser);
expect(result.code).toBe(400);
});
it('应该验证邮箱格式', async () => {
const invalidUser = {
username: 'testuser',
password: 'password123',
email: 'invalid-email'
};
const mockResponse = {
code: 400,
message: '邮箱格式不正确',
data: null
};
mockPost.mockResolvedValue(mockResponse);
const result = await userService.createUser(invalidUser);
expect(result.code).toBe(400);
});
});
describe('updateUser', () => {
it('应该能够更新用户', async () => {
const updateUser = {
id: 1,
username: 'admin',
email: 'newadmin@example.com',
phone: '13800138001',
status: 'ACTIVE'
};
const mockResponse = {
code: 200,
message: '更新成功',
data: updateUser
};
mockPut.mockResolvedValue(mockResponse);
const result = await userService.updateUser(updateUser);
expect(mockPut).toHaveBeenCalledWith('/sys/user', updateUser);
expect(result.data.email).toBe('newadmin@example.com');
});
});
describe('getUserById', () => {
it('应该能够根据ID获取用户详情', async () => {
const userId = 1;
const mockResponse = {
code: 200,
message: '获取成功',
data: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
status: 'ACTIVE'
}
};
mockGet.mockResolvedValue(mockResponse);
const result = await userService.getUserById(userId);
expect(mockGet).toHaveBeenCalledWith('/sys/user/1');
expect(result.data.id).toBe(userId);
});
it('应该处理不存在的用户ID', async () => {
const userId = 999;
const mockResponse = {
code: 404,
message: '用户不存在',
data: null
};
mockGet.mockResolvedValue(mockResponse);
const result = await userService.getUserById(userId);
expect(result.code).toBe(404);
});
});
describe('getUserByUsername', () => {
it('应该能够根据用户名获取用户', async () => {
const username = 'admin';
const mockResponse = {
code: 200,
message: '获取成功',
data: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
status: 'ACTIVE'
}
};
mockGet.mockResolvedValue(mockResponse);
const result = await userService.getUserByUsername(username);
expect(mockGet).toHaveBeenCalledWith('/sys/user/username/admin');
expect(result.data.username).toBe(username);
});
it('应该处理不存在的用户名', async () => {
const username = 'nonexistent';
const mockResponse = {
code: 404,
message: '用户不存在',
data: null
};
mockGet.mockResolvedValue(mockResponse);
const result = await userService.getUserByUsername(username);
expect(result.code).toBe(404);
});
});
describe('deleteUser', () => {
it('应该能够删除用户', async () => {
const userId = 1;
const mockResponse = {
code: 200,
message: '删除成功',
data: true
};
mockDelete.mockResolvedValue(mockResponse);
const result = await userService.deleteUser(userId);
expect(mockDelete).toHaveBeenCalledWith('/sys/user/1');
expect(result.data).toBe(true);
});
it('应该处理删除管理员用户', async () => {
const userId = 1;
const mockResponse = {
code: 400,
message: '不能删除管理员用户',
data: false
};
mockDelete.mockResolvedValue(mockResponse);
const result = await userService.deleteUser(userId);
expect(result.code).toBe(400);
expect(result.data).toBe(false);
});
});
});
@@ -0,0 +1,382 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useUserStore } from '@/stores/user.store'
const { mockGetUsers, mockGetUserById, mockCreateUser, mockUpdateUser, mockDeleteUser } = vi.hoisted(() => ({
mockGetUsers: vi.fn(),
mockGetUserById: vi.fn(),
mockCreateUser: vi.fn(),
mockUpdateUser: vi.fn(),
mockDeleteUser: vi.fn()
}))
vi.mock('@/services/user.service', () => ({
userService: {
getUsers: mockGetUsers,
getUserById: mockGetUserById,
createUser: mockCreateUser,
updateUser: mockUpdateUser,
deleteUser: mockDeleteUser
}
}))
describe('UserStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('初始状态', () => {
it('应该正确初始化默认状态', () => {
const store = useUserStore()
expect(store.users).toEqual([])
expect(store.total).toBe(0)
expect(store.current).toBe(1)
expect(store.size).toBe(10)
expect(store.loading).toBe(false)
expect(store.error).toBeNull()
})
})
describe('fetchUsers', () => {
it('应该能够成功获取用户列表', async () => {
const mockResponse = {
code: '200',
message: '获取成功',
data: {
records: [
{ id: 1, username: 'admin', email: 'admin@example.com', phone: '13800138000', status: 'ACTIVE' },
{ id: 2, username: 'user', email: 'user@example.com', phone: '13800138001', status: 'ACTIVE' }
],
total: 2,
current: 1,
size: 10
}
}
mockGetUsers.mockResolvedValue(mockResponse)
const store = useUserStore()
const result = await store.fetchUsers()
expect(result).toBe(true)
expect(store.users).toHaveLength(2)
expect(store.total).toBe(2)
expect(store.current).toBe(1)
expect(store.size).toBe(10)
expect(store.loading).toBe(false)
expect(store.error).toBeNull()
})
it('应该支持分页参数', async () => {
const mockResponse = {
code: '200',
message: '获取成功',
data: {
records: [],
total: 0,
current: 2,
size: 20
}
}
mockGetUsers.mockResolvedValue(mockResponse)
const store = useUserStore()
await store.fetchUsers({ current: 2, size: 20 })
expect(store.current).toBe(2)
expect(store.size).toBe(20)
})
it('应该支持搜索参数', async () => {
const mockResponse = {
code: '200',
message: '获取成功',
data: {
records: [],
total: 0,
current: 1,
size: 10
}
}
mockGetUsers.mockResolvedValue(mockResponse)
const store = useUserStore()
await store.fetchUsers({ username: 'admin', email: 'admin@example.com', status: 'ACTIVE' })
expect(mockGetUsers).toHaveBeenCalledWith({
username: 'admin',
email: 'admin@example.com',
status: 'ACTIVE'
})
})
it('获取失败时应该设置错误信息', async () => {
const mockResponse = {
code: '500',
message: '服务器错误',
data: null
}
mockGetUsers.mockResolvedValue(mockResponse)
const store = useUserStore()
const result = await store.fetchUsers()
expect(result).toBe(false)
expect(store.error).toBe('服务器错误')
expect(store.loading).toBe(false)
})
it('异常时应该设置错误信息', async () => {
mockGetUsers.mockRejectedValue(new Error('Network error'))
const store = useUserStore()
const result = await store.fetchUsers()
expect(result).toBe(false)
expect(store.error).toBe('Network error')
})
})
describe('fetchUserById', () => {
it('应该能够成功获取单个用户', async () => {
const mockUser = {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
status: 'ACTIVE'
}
const mockResponse = {
code: '200',
message: '获取成功',
data: mockUser
}
mockGetUserById.mockResolvedValue(mockResponse)
const store = useUserStore()
const user = await store.fetchUserById(1)
expect(user).toEqual(mockUser)
expect(mockGetUserById).toHaveBeenCalledWith(1)
})
})
describe('createUser', () => {
it('应该能够成功创建用户', async () => {
const newUser = {
username: 'newuser',
password: 'password123',
email: 'newuser@example.com',
phone: '13800138002'
}
const mockResponse = {
code: '200',
message: '创建成功',
data: { id: 3, ...newUser, status: 'ACTIVE' }
}
mockCreateUser.mockResolvedValue(mockResponse)
const store = useUserStore()
const result = await store.createUser(newUser)
expect(result).toBe(true)
expect(mockCreateUser).toHaveBeenCalledWith(newUser)
})
it('创建失败时应该返回false', async () => {
const mockResponse = {
code: '400',
message: '用户名已存在',
data: null
}
mockCreateUser.mockResolvedValue(mockResponse)
const store = useUserStore()
const result = await store.createUser({ username: 'admin', password: '123' })
expect(result).toBe(false)
})
})
describe('updateUser', () => {
it('应该能够成功更新用户', async () => {
const updateUser = {
id: 1,
username: 'admin',
email: 'newadmin@example.com',
phone: '13800138000'
}
const mockResponse = {
code: '200',
message: '更新成功',
data: updateUser
}
mockUpdateUser.mockResolvedValue(mockResponse)
const store = useUserStore()
const result = await store.updateUser(updateUser)
expect(result).toBe(true)
expect(mockUpdateUser).toHaveBeenCalledWith(updateUser)
})
it('更新失败时应该返回false', async () => {
const mockResponse = {
code: '400',
message: '更新失败',
data: null
}
mockUpdateUser.mockResolvedValue(mockResponse)
const store = useUserStore()
const result = await store.updateUser({ id: 1, username: 'admin' })
expect(result).toBe(false)
})
})
describe('deleteUser', () => {
it('应该能够成功删除用户', async () => {
const mockResponse = {
code: '200',
message: '删除成功',
data: true
}
mockDeleteUser.mockResolvedValue(mockResponse)
const store = useUserStore()
const result = await store.deleteUser(1)
expect(result).toBe(true)
expect(mockDeleteUser).toHaveBeenCalledWith(1)
})
it('删除失败时应该返回false', async () => {
const mockResponse = {
code: '400',
message: '删除失败',
data: null
}
mockDeleteUser.mockResolvedValue(mockResponse)
const store = useUserStore()
const result = await store.deleteUser(1)
expect(result).toBe(false)
})
})
describe('activeUsers', () => {
it('应该正确过滤活跃用户', () => {
const store = useUserStore()
store.users = [
{ id: 1, username: 'admin', email: 'admin@example.com', phone: '13800138000', status: 'active' },
{ id: 2, username: 'user', email: 'user@example.com', phone: '13800138001', status: 'inactive' }
]
expect(store.activeUsers).toHaveLength(1)
expect(store.activeUsers[0].username).toBe('admin')
})
})
describe('userOptions', () => {
it('应该正确生成用户选项', () => {
const store = useUserStore()
store.users = [
{ id: 1, username: 'admin', email: 'admin@example.com', phone: '13800138000', status: 'ACTIVE' },
{ id: 2, username: 'user', email: 'user@example.com', phone: '13800138001', status: 'ACTIVE' }
]
expect(store.userOptions).toHaveLength(2)
expect(store.userOptions[0]).toEqual({
label: 'admin',
value: 1,
email: 'admin@example.com'
})
})
})
describe('getUserById', () => {
it('应该能够根据ID查找用户', () => {
const store = useUserStore()
store.users = [
{ id: 1, username: 'admin', email: 'admin@example.com', phone: '13800138000', status: 'ACTIVE' },
{ id: 2, username: 'user', email: 'user@example.com', phone: '13800138001', status: 'ACTIVE' }
]
const user = store.getUserById(1)
expect(user?.username).toBe('admin')
})
it('找不到用户时应该返回undefined', () => {
const store = useUserStore()
store.users = [
{ id: 1, username: 'admin', email: 'admin@example.com', phone: '13800138000', status: 'ACTIVE' }
]
const user = store.getUserById(999)
expect(user).toBeUndefined()
})
})
describe('getUserByUsername', () => {
it('应该能够根据用户名查找用户', () => {
const store = useUserStore()
store.users = [
{ id: 1, username: 'admin', email: 'admin@example.com', phone: '13800138000', status: 'ACTIVE' },
{ id: 2, username: 'user', email: 'user@example.com', phone: '13800138001', status: 'ACTIVE' }
]
const user = store.getUserByUsername('admin')
expect(user?.id).toBe(1)
})
it('找不到用户时应该返回undefined', () => {
const store = useUserStore()
store.users = [
{ id: 1, username: 'admin', email: 'admin@example.com', phone: '13800138000', status: 'ACTIVE' }
]
const user = store.getUserByUsername('nonexistent')
expect(user).toBeUndefined()
})
})
describe('getUserByEmail', () => {
it('应该能够根据邮箱查找用户', () => {
const store = useUserStore()
store.users = [
{ id: 1, username: 'admin', email: 'admin@example.com', phone: '13800138000', status: 'ACTIVE' },
{ id: 2, username: 'user', email: 'user@example.com', phone: '13800138001', status: 'ACTIVE' }
]
const user = store.getUserByEmail('admin@example.com')
expect(user?.username).toBe('admin')
})
it('找不到用户时应该返回undefined', () => {
const store = useUserStore()
store.users = [
{ id: 1, username: 'admin', email: 'admin@example.com', phone: '13800138000', status: 'ACTIVE' }
]
const user = store.getUserByEmail('nonexistent@example.com')
expect(user).toBeUndefined()
})
})
})
@@ -0,0 +1,231 @@
export interface Result<T = any> {
code: string;
message: string;
data: T;
}
export interface PageResult<T> {
records: T[];
total: number;
current: number;
size: number;
}
export interface Menu {
id: number;
name: string;
code: string;
path: string;
icon?: string;
parentId: number;
sortOrder: number;
status: string;
component?: string;
redirect?: string;
description?: string;
children?: Menu[];
createdAt: string;
updatedAt: string;
}
export interface MenuTree extends Menu {
children?: MenuTree[];
}
export interface CreateMenuRequest {
name: string;
code: string;
path: string;
icon?: string;
parentId: number;
sortOrder: number;
status: string;
}
export interface UpdateMenuRequest {
id: number;
name: string;
code: string;
path: string;
icon?: string;
parentId: number;
sortOrder: number;
status: string;
}
export interface MenuQueryParams {
name?: string;
code?: string;
status?: string;
page?: number;
pageSize?: number;
}
export interface Role {
id: number;
name: string;
roleKey: string;
description?: string;
status: string;
sortOrder?: number;
remark?: string;
menuIds?: number[];
createdAt: string;
updatedAt: string;
}
export interface CreateRoleRequest {
name: string;
roleKey: string;
description?: string;
status: string;
}
export interface UpdateRoleRequest {
id: number;
name: string;
roleKey: string;
description?: string;
status: string;
}
export interface RoleQueryParams {
name?: string;
roleKey?: string;
status?: string;
page?: number;
pageSize?: number;
}
export interface User {
id: number;
username: string;
email: string;
phone: string;
nickname?: string;
status: string;
remark?: string;
createBy: string;
updateBy: string;
createdAt: string;
updatedAt: string;
permissions?: string[];
roles?: Role[];
}
export interface CreateUserRequest {
username: string;
password: string;
email: string;
phone: string;
nickname?: string;
status?: number;
roleIds?: number[];
remark?: string;
}
export interface UpdateUserRequest {
id: number;
username?: string;
email?: string;
phone?: string;
nickname?: string;
status?: number;
roleIds?: number[];
remark?: string;
}
export interface UserQueryParams {
username?: string;
email?: string;
status?: string;
page?: number;
pageSize?: number;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
token: string;
refreshToken: string;
user: User;
}
export interface RegisterRequest {
username: string;
password: string;
email: string;
phone: string;
}
export interface RefreshTokenResponse {
token: string;
refreshToken: string;
user: User;
}
export interface AssignMenuToRoleRequest {
roleId: number;
menuIds: number[];
}
export interface AssignRoleToUserRequest {
userId: number;
roleIds: number[];
}
export interface DashboardStats {
userCount: number;
roleCount: number;
menuCount: number;
}
export interface RecentUser {
id: number;
username: string;
email: string;
lastLoginAt: string;
}
export interface DashboardData {
stats: DashboardStats;
recentUsers: RecentUser[];
systemStatus: string;
}
export type Language = 'zh-CN' | 'en-US';
export interface AppConfig {
language: Language;
theme: 'light' | 'dark';
}
export interface OperationLog {
id: number;
userId: number;
username: string;
module: string;
operation: string;
method: string;
path: string;
params: string;
ip: string;
status: string;
errorMsg?: string;
duration: number;
createdAt: string;
}
export interface OperationLogQueryParams {
username?: string;
module?: string;
operation?: string;
status?: string;
startTime?: string;
endTime?: string;
page?: number;
pageSize?: number;
}
@@ -0,0 +1,380 @@
export const first = <T>(array: T[]): T | undefined => {
return array[0]
}
export const last = <T>(array: T[]): T | undefined => {
return array[array.length - 1]
}
export const head = <T>(array: T[]): T | undefined => {
return array[0]
}
export const tail = <T>(array: T[]): T[] => {
return array.slice(1)
}
export const initial = <T>(array: T[]): T[] => {
return array.slice(0, -1)
}
export const take = <T>(array: T[], n: number): T[] => {
return array.slice(0, n)
}
export const takeRight = <T>(array: T[], n: number): T[] => {
return array.slice(-n)
}
export const drop = <T>(array: T[], n: number): T[] => {
return array.slice(n)
}
export const dropRight = <T>(array: T[], n: number): T[] => {
return array.slice(0, -n)
}
export const chunk = <T>(array: T[], size: number): T[][] => {
const result: T[][] = []
for (let i = 0; i < array.length; i += size) {
result.push(array.slice(i, i + size))
}
return result
}
export const flatten = <T>(array: any[]): T[] => {
return array.flat()
}
export const flattenDeep = <T>(array: any[]): T[] => {
return array.flat(Infinity)
}
export const uniq = <T>(array: T[]): T[] => {
return [...new Set(array)]
}
export const uniqBy = <T>(array: T[], key: keyof T | ((item: T) => any)): T[] => {
const seen = new Set()
const getKey = typeof key === 'function' ? key : (item: T) => item[key]
return array.filter(item => {
const k = getKey(item)
if (seen.has(k)) return false
seen.add(k)
return true
})
}
export const intersection = <T>(...arrays: T[][]): T[] => {
return arrays.reduce((acc, array) => acc.filter(item => array.includes(item)))
}
export const difference = <T>(array: T[], ...values: T[][]): T[] => {
const exclude = new Set(values.flat())
return array.filter(item => !exclude.has(item))
}
export const union = <T>(...arrays: T[][]): T[] => {
return uniq(arrays.flat())
}
export const without = <T>(array: T[], ...values: T[]): T[] => {
return array.filter(item => !values.includes(item))
}
export const pull = <T>(array: T[], ...values: T[]): T[] => {
values.forEach(value => {
const index = array.indexOf(value)
if (index > -1) array.splice(index, 1)
})
return array
}
export const pullAll = <T>(array: T[], values: T[]): T[] => {
values.forEach(value => {
const index = array.indexOf(value)
if (index > -1) array.splice(index, 1)
})
return array
}
export const remove = <T>(array: T[], predicate: (item: T, index: number, array: T[]) => boolean): T[] => {
const removed: T[] = []
let i = 0
while (i < array.length) {
if (predicate(array[i], i, array)) {
removed.push(array.splice(i, 1)[0])
} else {
i++
}
}
return removed
}
export const compact = <T>(array: (T | null | undefined | false | 0 | '')[]): T[] => {
return array.filter(Boolean) as T[]
}
export const find = <T>(array: T[], predicate: (item: T, index: number, array: T[]) => boolean): T | undefined => {
return array.find(predicate)
}
export const findIndex = <T>(array: T[], predicate: (item: T, index: number, array: T[]) => boolean): number => {
return array.findIndex(predicate)
}
export const findLast = <T>(array: T[], predicate: (item: T, index: number, array: T[]) => boolean): T | undefined => {
for (let i = array.length - 1; i >= 0; i--) {
if (predicate(array[i], i, array)) {
return array[i]
}
}
return undefined
}
export const findLastIndex = <T>(array: T[], predicate: (item: T, index: number, array: T[]) => boolean): number => {
for (let i = array.length - 1; i >= 0; i--) {
if (predicate(array[i], i, array)) {
return i
}
}
return -1
}
export const includes = <T>(array: T[], value: T): boolean => {
return array.includes(value)
}
export const indexOf = <T>(array: T[], value: T, fromIndex?: number): number => {
return array.indexOf(value, fromIndex)
}
export const lastIndexOf = <T>(array: T[], value: T, fromIndex?: number): number => {
return array.lastIndexOf(value, fromIndex)
}
export const every = <T>(array: T[], predicate: (item: T, index: number, array: T[]) => boolean): boolean => {
return array.every(predicate)
}
export const some = <T>(array: T[], predicate: (item: T, index: number, array: T[]) => boolean): boolean => {
return array.some(predicate)
}
export const filter = <T>(array: T[], predicate: (item: T, index: number, array: T[]) => boolean): T[] => {
return array.filter(predicate)
}
export const map = <T, U>(array: T[], iteratee: (item: T, index: number, array: T[]) => U): U[] => {
return array.map(iteratee)
}
export const reduce = <T, U>(array: T[], reducer: (acc: U, item: T, index: number, array: T[]) => U, initialValue: U): U => {
return array.reduce(reducer, initialValue)
}
export const reduceRight = <T, U>(array: T[], reducer: (acc: U, item: T, index: number, array: T[]) => U, initialValue: U): U => {
return array.reduceRight(reducer, initialValue)
}
export const forEach = <T>(array: T[], iteratee: (item: T, index: number, array: T[]) => void): void => {
array.forEach(iteratee)
}
export const sample = <T>(array: T[]): T | undefined => {
return array[Math.floor(Math.random() * array.length)]
}
export const sampleSize = <T>(array: T[], n: number): T[] => {
const shuffled = [...array].sort(() => Math.random() - 0.5)
return shuffled.slice(0, n)
}
export const shuffle = <T>(array: T[]): T[] => {
const shuffled = [...array]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
return shuffled
}
export const sortBy = <T>(array: T[], iteratees: Array<(item: T) => any>): T[] => {
return [...array].sort((a, b) => {
for (const iteratee of iteratees) {
const aVal = iteratee(a)
const bVal = iteratee(b)
if (aVal < bVal) return -1
if (aVal > bVal) return 1
}
return 0
})
}
export const orderBy = <T>(array: T[], iteratees: Array<(item: T) => any>, orders: Array<'asc' | 'desc'>): T[] => {
return [...array].sort((a, b) => {
for (let i = 0; i < iteratees.length; i++) {
const aVal = iteratees[i](a)
const bVal = iteratees[i](b)
const order = orders[i] || 'asc'
if (aVal < bVal) return order === 'asc' ? -1 : 1
if (aVal > bVal) return order === 'asc' ? 1 : -1
}
return 0
})
}
export const groupBy = <T>(array: T[], iteratee: (item: T) => string): Record<string, T[]> => {
return array.reduce((acc, item) => {
const key = iteratee(item)
if (!acc[key]) acc[key] = []
acc[key].push(item)
return acc
}, {} as Record<string, T[]>)
}
export const keyBy = <T>(array: T[], iteratee: (item: T) => string): Record<string, T> => {
return array.reduce((acc, item) => {
acc[iteratee(item)] = item
return acc
}, {} as Record<string, T>)
}
export const countBy = <T>(array: T[], iteratee: (item: T) => string): Record<string, number> => {
return array.reduce((acc, item) => {
const key = iteratee(item)
acc[key] = (acc[key] || 0) + 1
return acc
}, {} as Record<string, number>)
}
export const partition = <T>(array: T[], predicate: (item: T, index: number, array: T[]) => boolean): [T[], T[]] => {
const truthy: T[] = []
const falsy: T[] = []
array.forEach((item, index, arr) => {
if (predicate(item, index, arr)) {
truthy.push(item)
} else {
falsy.push(item)
}
})
return [truthy, falsy]
}
export const zip = <T, U>(arrays: [T[], U[]]): [T, U][] => {
const maxLength = Math.max(...arrays.map(arr => arr.length))
return Array.from({ length: maxLength }, (_, i) => arrays.map(arr => arr[i]) as [T, U])
}
export const unzip = <T, U>(array: [T, U][]): [T[], U[]] => {
return array.reduce(
(acc, [a, b]) => {
acc[0].push(a)
acc[1].push(b)
return acc
},
[[], []] as [T[], U[]]
)
}
export const zipObject = <T extends string, U>(keys: T[], values: U[]): Record<T, U> => {
return keys.reduce((acc, key, index) => {
acc[key] = values[index]
return acc
}, {} as Record<T, U>)
}
export const fromPairs = <T extends string, U>(pairs: [T, U][]): Record<T, U> => {
return pairs.reduce((acc, [key, value]) => {
acc[key] = value
return acc
}, {} as Record<T, U>)
}
export const toPairs = <T extends string, U>(object: Record<T, U>): [T, U][] => {
return Object.entries(object) as [T, U][]
}
export const size = <T>(array: T[] | string | object): number => {
if (Array.isArray(array) || typeof array === 'string') {
return array.length
}
return Object.keys(array).length
}
export const isEmpty = <T>(array: T[] | string | object): boolean => {
return size(array) === 0
}
export const nth = <T>(array: T[], n: number): T | undefined => {
const index = n < 0 ? array.length + n : n
return array[index]
}
export const slice = <T>(array: T[], start?: number, end?: number): T[] => {
return array.slice(start, end)
}
export const splice = <T>(array: T[], start: number, deleteCount?: number, ...items: T[]): T[] => {
return array.splice(start, deleteCount, ...items)
}
export const concat = <T>(...arrays: (T | T[])[]): T[] => {
return arrays.flat() as T[]
}
export const join = <T>(array: T[], separator?: string): string => {
return array.join(separator)
}
export const reverse = <T>(array: T[]): T[] => {
return array.reverse()
}
export const sort = <T>(array: T[], compareFn?: (a: T, b: T) => number): T[] => {
return array.sort(compareFn)
}
export const fill = <T>(array: T[], value: T, start?: number, end?: number): T[] => {
return array.fill(value, start, end)
}
export const copyWithin = <T>(array: T[], target: number, start?: number, end?: number): T[] => {
return array.copyWithin(target, start, end)
}
export const entries = <T>(array: T[]): [number, T][] => {
return array.entries()
}
export const keys = <T>(array: T[]): number[] => {
return array.keys()
}
export const values = <T>(array: T[]): T[] => {
return array.values()
}
export const at = <T>(array: T[], ...indices: number[]): (T | undefined)[] => {
return indices.map(index => array[index])
}
export const flatMap = <T, U>(array: T[], callback: (item: T, index: number, array: T[]) => U[]): U[] => {
return array.flatMap(callback)
}
export const flatMapDeep = <T, U>(array: T[], callback: (item: T, index: number, array: T[]) => any[]): U[] => {
return array.flatMap(callback).flat(Infinity)
}
export const isArray = <T>(value: any): value is T[] => {
return Array.isArray(value)
}
export const isArrayLike = <T>(value: any): value is ArrayLike<T> => {
return value != null && typeof value.length === 'number' && value.length >= 0
}
export const castArray = <T>(value: T | T[]): T[] => {
return Array.isArray(value) ? value : [value]
}
@@ -0,0 +1,825 @@
export const noop = (): void => {}
export const identity = <T>(value: T): T => {
return value
}
export const constant = <T>(value: T): (() => T) => {
return () => value
}
export const times = <T>(n: number, iteratee: (index: number) => T): T[] => {
return Array.from({ length: n }, (_, index) => iteratee(index))
}
export const range = (start: number, end: number, step: number = 1): number[] => {
const result: number[] = []
for (let i = start; i < end; i += step) {
result.push(i)
}
return result
}
export const debounce = <T extends (...args: any[]) => any>(
func: T,
wait: number,
immediate: boolean = false
): (...args: Parameters<T>) => void => {
let timeout: ReturnType<typeof setTimeout> | null = null
return function (this: any, ...args: Parameters<T>) {
const context = this
const later = () => {
timeout = null
if (!immediate) func.apply(context, args)
}
const callNow = immediate && !timeout
if (timeout) clearTimeout(timeout)
timeout = setTimeout(later, wait)
if (callNow) func.apply(context, args)
}
}
export const throttle = <T extends (...args: any[]) => any>(
func: T,
wait: number,
options: { leading?: boolean; trailing?: boolean } = {}
): (...args: Parameters<T>) => void => {
let timeout: ReturnType<typeof setTimeout> | null = null
let previous = 0
const { leading = true, trailing = true } = options
return function (this: any, ...args: Parameters<T>) {
const context = this
const now = Date.now()
if (!previous && !leading) previous = now
const remaining = wait - (now - previous)
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
previous = now
func.apply(context, args)
} else if (!timeout && trailing) {
timeout = setTimeout(() => {
previous = leading ? Date.now() : 0
timeout = null
func.apply(context, args)
}, remaining)
}
}
}
export const delay = (ms: number): Promise<void> => {
return new Promise(resolve => setTimeout(resolve, ms))
}
export const sleep = (ms: number): Promise<void> => {
return new Promise(resolve => setTimeout(resolve, ms))
}
export const retry = async <T>(
fn: () => Promise<T>,
maxAttempts: number = 3,
delayMs: number = 1000
): Promise<T> => {
let lastError: Error | null = null
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error as Error
if (attempt < maxAttempts) {
await delay(delayMs * attempt)
}
}
}
throw lastError
}
export const memoize = <T extends (...args: any[]) => any>(
func: T,
resolver?: (...args: Parameters<T>) => string
): T => {
const cache = new Map<string, ReturnType<T>>()
return function (this: any, ...args: Parameters<T>): ReturnType<T> {
const key = resolver ? resolver(...args) : JSON.stringify(args)
if (cache.has(key)) {
return cache.get(key)!
}
const result = func.apply(this, args)
cache.set(key, result)
return result
} as T
}
export const once = <T extends (...args: any[]) => any>(func: T): T => {
let called = false
let result: ReturnType<T>
return function (this: any, ...args: Parameters<T>): ReturnType<T> {
if (!called) {
called = true
result = func.apply(this, args)
}
return result
} as T
}
export const partial = <T extends (...args: any[]) => any>(
func: T,
...partialArgs: any[]
): (...args: any[]) => ReturnType<T> => {
return function (this: any, ...args: any[]): ReturnType<T> {
return func.apply(this, [...partialArgs, ...args])
}
}
export const curry = <T extends (...args: any[]) => any>(
func: T,
arity: number = func.length
): any => {
return function curried(this: any, ...args: any[]): any {
if (args.length >= arity) {
return func.apply(this, args)
}
return function (this: any, ...nextArgs: any[]) {
return curried.apply(this, [...args, ...nextArgs])
}
}
}
export const compose = <T extends any[]>(...funcs: Array<(arg: any) => any>): (...args: T) => any => {
return (...args: T) => {
return funcs.reduceRight((acc, func) => func(acc), args[0])
}
}
export const pipe = <T extends any[]>(...funcs: Array<(arg: any) => any>): (...args: T) => any => {
return (...args: T) => {
return funcs.reduce((acc, func) => func(acc), args[0])
}
}
export const tap = <T>(value: T, interceptor: (value: T) => void): T => {
interceptor(value)
return value
}
export const trace = <T>(label: string): (value: T) => T => {
return (value: T) => {
console.log(label, value)
return value
}
}
export const isFunction = (value: any): value is Function => {
return typeof value === 'function'
}
export const isAsyncFunction = (value: any): value is (...args: any[]) => Promise<any> => {
return value instanceof Function && value.constructor.name === 'AsyncFunction'
}
export const isGeneratorFunction = (value: any): value is GeneratorFunction => {
return value instanceof Function && value.constructor.name === 'GeneratorFunction'
}
export const isPromise = <T = any>(value: any): value is Promise<T> => {
return value instanceof Promise || (value && typeof value.then === 'function')
}
export const isNil = (value: any): boolean => {
return value === null || value === undefined
}
export const isNull = (value: any): value is null => {
return value === null
}
export const isUndefined = (value: any): value is undefined => {
return value === undefined
}
export const isDefined = <T>(value: T | null | undefined): value is T => {
return value !== null && value !== undefined
}
export const isTruthy = (value: any): boolean => {
return !!value
}
export const isFalsy = (value: any): boolean => {
return !value
}
export const isPrimitive = (value: any): boolean => {
return value === null || (typeof value !== 'object' && typeof value !== 'function')
}
export const isPlainObject = (value: any): value is object => {
if (typeof value !== 'object' || value === null) return false
const proto = Object.getPrototypeOf(value)
return proto === null || proto === Object.prototype
}
export const isArray = <T = any>(value: any): value is T[] => {
return Array.isArray(value)
}
export const isString = (value: any): value is string => {
return typeof value === 'string'
}
export const isNumber = (value: any): value is number => {
return typeof value === 'number' && !isNaN(value)
}
export const isBoolean = (value: any): value is boolean => {
return typeof value === 'boolean'
}
export const isSymbol = (value: any): value is symbol => {
return typeof value === 'symbol'
}
export const isBigInt = (value: any): value is bigint => {
return typeof value === 'bigint'
}
export const isDate = (value: any): value is Date => {
return value instanceof Date
}
export const isRegExp = (value: any): value is RegExp => {
return value instanceof RegExp
}
export const isError = (value: any): value is Error => {
return value instanceof Error
}
export const getType = (value: any): string => {
return Object.prototype.toString.call(value).slice(8, -1)
}
export const deepClone = <T>(value: T): T => {
if (value === null || typeof value !== 'object') {
return value
}
if (value instanceof Date) {
return new Date(value.getTime()) as any
}
if (value instanceof Array) {
return value.map(item => deepClone(item)) as any
}
if (value instanceof Object) {
const clonedObj = {} as any
for (const key in value) {
if (value.hasOwnProperty(key)) {
clonedObj[key] = deepClone(value[key])
}
}
return clonedObj
}
return value
}
export const deepMerge = <T extends object, U extends object>(target: T, source: U): T & U => {
const result = { ...target } as any
for (const key in source) {
if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
result[key] = deepMerge(result[key] || {}, source[key])
} else {
result[key] = source[key]
}
}
return result
}
export const isEqual = <T>(a: T, b: T): boolean => {
if (a === b) return true
if (a == null || b == null) return false
if (typeof a !== typeof b) return false
if (typeof a !== 'object') return false
if (Array.isArray(a) !== Array.isArray(b)) return false
if (Array.isArray(a)) {
if (a.length !== b.length) return false
return a.every((item, index) => isEqual(item, (b as any)[index]))
}
const keysA = Object.keys(a)
const keysB = Object.keys(b)
if (keysA.length !== keysB.length) return false
return keysA.every(key => isEqual((a as any)[key], (b as any)[key]))
}
export const omitBy = <T extends object>(
obj: T,
predicate: (value: T[keyof T], key: keyof T) => boolean
): Partial<T> => {
const result = {} as Partial<T>
for (const key in obj) {
if (obj.hasOwnProperty(key) && !predicate(obj[key], key)) {
result[key] = obj[key]
}
}
return result
}
export const pickBy = <T extends object>(
obj: T,
predicate: (value: T[keyof T], key: keyof T) => boolean
): Partial<T> => {
const result = {} as Partial<T>
for (const key in obj) {
if (obj.hasOwnProperty(key) && predicate(obj[key], key)) {
result[key] = obj[key]
}
}
return result
}
export const defaultsDeep = <T extends object, U extends object>(obj: T, defaults: U): T & U => {
const result = { ...obj } as any
for (const key in defaults) {
if (typeof defaults[key] === 'object' && defaults[key] !== null && !Array.isArray(defaults[key])) {
result[key] = defaultsDeep(result[key] || {}, defaults[key])
} else if (!(key in result)) {
result[key] = defaults[key]
}
}
return result
}
export const random = (min: number, max: number): number => {
return Math.random() * (max - min) + min
}
export const randomInt = (min: number, max: number): number => {
return Math.floor(random(min, max + 1))
}
export const clamp = (value: number, min: number, max: number): number => {
return Math.min(Math.max(value, min), max)
}
export const lerp = (start: number, end: number, t: number): number => {
return start + (end - start) * t
}
export const round = (value: number, precision: number = 0): number => {
const factor = Math.pow(10, precision)
return Math.round(value * factor) / factor
}
export const floor = (value: number, precision: number = 0): number => {
const factor = Math.pow(10, precision)
return Math.floor(value * factor) / factor
}
export const ceil = (value: number, precision: number = 0): number => {
const factor = Math.pow(10, precision)
return Math.ceil(value * factor) / factor
}
export const uuid = (): string => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, char => {
const random = Math.random() * 16 | 0
const value = char === 'x' ? random : (random & 0x3 | 0x8)
return value.toString(16)
})
}
export const uniqueId = (prefix: string = ''): string => {
return `${prefix}${Date.now().toString(36)}${Math.random().toString(36).substr(2)}`
}
export const generateRandomString = (length: number = 8): string => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
export const generateRandomNumber = (min: number = 100000, max: number = 999999): number => {
return Math.floor(Math.random() * (max - min + 1)) + min
}
export const generateRandomColor = (): string => {
return `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0')}`
}
export const generateRandomHex = (length: number = 6): string => {
return Math.floor(Math.random() * Math.pow(16, length)).toString(16).padStart(length, '0')
}
export const parseUrl = (url: string): URL => {
return new URL(url)
}
export const parseQueryString = (queryString: string): Record<string, string> => {
const params = new URLSearchParams(queryString)
const result: Record<string, string> = {}
params.forEach((value, key) => {
result[key] = value
})
return result
}
export const buildQueryString = (params: Record<string, any>): string => {
return new URLSearchParams(
Object.entries(params)
.filter(([_, value]) => value !== undefined && value !== null)
.map(([key, value]) => [key, String(value)])
).toString()
}
export const getBrowserInfo = (): { name: string; version: string } => {
const ua = navigator.userAgent
let name = 'Unknown'
let version = 'Unknown'
if (ua.includes('Firefox')) {
name = 'Firefox'
version = ua.match(/Firefox\/(\d+\.\d+)/)?.[1] || 'Unknown'
} else if (ua.includes('Chrome')) {
name = 'Chrome'
version = ua.match(/Chrome\/(\d+\.\d+)/)?.[1] || 'Unknown'
} else if (ua.includes('Safari')) {
name = 'Safari'
version = ua.match(/Version\/(\d+\.\d+)/)?.[1] || 'Unknown'
} else if (ua.includes('Edge')) {
name = 'Edge'
version = ua.match(/Edge\/(\d+\.\d+)/)?.[1] || 'Unknown'
}
return { name, version }
}
export const getOSInfo = (): string => {
const ua = navigator.userAgent
if (ua.includes('Windows')) return 'Windows'
if (ua.includes('Mac')) return 'MacOS'
if (ua.includes('Linux')) return 'Linux'
if (ua.includes('Android')) return 'Android'
if (ua.includes('iOS')) return 'iOS'
return 'Unknown'
}
export const isMobile = (): boolean => {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
}
export const isTablet = (): boolean => {
return /iPad|Android|webOS|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) && window.innerWidth >= 768
}
export const isDesktop = (): boolean => {
return !isMobile() && !isTablet()
}
export const copyToClipboard = async (text: string): Promise<boolean> => {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text)
return true
} else {
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const result = document.execCommand('copy')
document.body.removeChild(textArea)
return result
}
} catch {
return false
}
}
export const downloadFile = (url: string, filename?: string): void => {
const a = document.createElement('a')
a.href = url
if (filename) {
a.download = filename
}
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
export const downloadBlob = (blob: Blob, filename: string): void => {
const url = URL.createObjectURL(blob)
downloadFile(url, filename)
URL.revokeObjectURL(url)
}
export const readFileAsText = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(reader.error)
reader.readAsText(file)
})
}
export const readFileAsDataURL = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(file)
})
}
export const readFileAsArrayBuffer = (file: File): Promise<ArrayBuffer> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as ArrayBuffer)
reader.onerror = () => reject(reader.error)
reader.readAsArrayBuffer(file)
})
}
export const formatFileSize = (bytes: number): string => {
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(2)} ${units[unitIndex]}`
}
export const formatDuration = (seconds: number): string => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
const parts: string[] = []
if (hours > 0) parts.push(`${hours}h`)
if (minutes > 0) parts.push(`${minutes}m`)
if (secs > 0 || parts.length === 0) parts.push(`${secs}s`)
return parts.join(' ')
}
export const formatDate = (date: Date | string | number, format: string = 'YYYY-MM-DD HH:mm:ss'): string => {
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
const seconds = String(d.getSeconds()).padStart(2, '0')
return format
.replace('YYYY', String(year))
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds)
}
export const getRelativeTime = (date: Date | string | number): string => {
const now = new Date()
const target = new Date(date)
const diff = now.getTime() - target.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
const months = Math.floor(days / 30)
const years = Math.floor(days / 365)
if (years > 0) return `${years}年前`
if (months > 0) return `${months}个月前`
if (days > 0) return `${days}天前`
if (hours > 0) return `${hours}小时前`
if (minutes > 0) return `${minutes}分钟前`
return '刚刚'
}
export const isToday = (date: Date | string | number): boolean => {
const today = new Date()
const target = new Date(date)
return (
today.getFullYear() === target.getFullYear() &&
today.getMonth() === target.getMonth() &&
today.getDate() === target.getDate()
)
}
export const isYesterday = (date: Date | string | number): boolean => {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const target = new Date(date)
return (
yesterday.getFullYear() === target.getFullYear() &&
yesterday.getMonth() === target.getMonth() &&
yesterday.getDate() === target.getDate()
)
}
export const isTomorrow = (date: Date | string | number): boolean => {
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
const target = new Date(date)
return (
tomorrow.getFullYear() === target.getFullYear() &&
tomorrow.getMonth() === target.getMonth() &&
tomorrow.getDate() === target.getDate()
)
}
export const startOfDay = (date: Date | string | number): Date => {
const d = new Date(date)
d.setHours(0, 0, 0, 0)
return d
}
export const endOfDay = (date: Date | string | number): Date => {
const d = new Date(date)
d.setHours(23, 59, 59, 999)
return d
}
export const startOfWeek = (date: Date | string | number): Date => {
const d = new Date(date)
const day = d.getDay()
const diff = d.getDate() - day + (day === 0 ? -6 : 1)
d.setDate(diff)
d.setHours(0, 0, 0, 0)
return d
}
export const endOfWeek = (date: Date | string | number): Date => {
const d = startOfWeek(date)
d.setDate(d.getDate() + 6)
d.setHours(23, 59, 59, 999)
return d
}
export const startOfMonth = (date: Date | string | number): Date => {
const d = new Date(date)
d.setDate(1)
d.setHours(0, 0, 0, 0)
return d
}
export const endOfMonth = (date: Date | string | number): Date => {
const d = new Date(date)
d.setMonth(d.getMonth() + 1)
d.setDate(0)
d.setHours(23, 59, 59, 999)
return d
}
export const addDays = (date: Date | string | number, days: number): Date => {
const d = new Date(date)
d.setDate(d.getDate() + days)
return d
}
export const subtractDays = (date: Date | string | number, days: number): Date => {
return addDays(date, -days)
}
export const addMonths = (date: Date | string | number, months: number): Date => {
const d = new Date(date)
d.setMonth(d.getMonth() + months)
return d
}
export const subtractMonths = (date: Date | string | number, months: number): Date => {
return addMonths(date, -months)
}
export const addYears = (date: Date | string | number, years: number): Date => {
const d = new Date(date)
d.setFullYear(d.getFullYear() + years)
return d
}
export const subtractYears = (date: Date | string | number, years: number): Date => {
return addYears(date, -years)
}
export const diffDays = (date1: Date | string | number, date2: Date | string | number): number => {
const d1 = new Date(date1)
const d2 = new Date(date2)
const diff = d1.getTime() - d2.getTime()
return Math.floor(diff / (1000 * 60 * 60 * 24))
}
export const diffHours = (date1: Date | string | number, date2: Date | string | number): number => {
const d1 = new Date(date1)
const d2 = new Date(date2)
const diff = d1.getTime() - d2.getTime()
return Math.floor(diff / (1000 * 60 * 60))
}
export const diffMinutes = (date1: Date | string | number, date2: Date | string | number): number => {
const d1 = new Date(date1)
const d2 = new Date(date2)
const diff = d1.getTime() - d2.getTime()
return Math.floor(diff / (1000 * 60))
}
export const diffSeconds = (date1: Date | string | number, date2: Date | string | number): number => {
const d1 = new Date(date1)
const d2 = new Date(date2)
const diff = d1.getTime() - d2.getTime()
return Math.floor(diff / 1000)
}
export const isValidDate = (date: any): boolean => {
if (!(date instanceof Date)) return false
return !isNaN(date.getTime())
}
export const parseDate = (dateString: string): Date | null => {
const date = new Date(dateString)
return isValidDate(date) ? date : null
}
export const formatDateShort = (date: Date | string | number): string => {
return formatDate(date, 'YYYY-MM-DD')
}
export const formatDateLong = (date: Date | string | number): string => {
return formatDate(date, 'YYYY年MM月DD日 HH:mm:ss')
}
export const formatTime = (date: Date | string | number): string => {
return formatDate(date, 'HH:mm:ss')
}
export const formatTimeShort = (date: Date | string | number): string => {
return formatDate(date, 'HH:mm')
}
export const getWeekNumber = (date: Date | string | number): number => {
const d = new Date(date)
d.setHours(0, 0, 0, 0)
d.setDate(d.getDate() + 4 - (d.getDay() || 7))
const yearStart = new Date(d.getFullYear(), 0, 1)
return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
}
export const getDayOfYear = (date: Date | string | number): number => {
const d = new Date(date)
const start = new Date(d.getFullYear(), 0, 0)
const diff = d.getTime() - start.getTime()
return Math.floor(diff / (1000 * 60 * 60 * 24))
}
export const getQuarter = (date: Date | string | number): number => {
const d = new Date(date)
return Math.floor(d.getMonth() / 3) + 1
}
export const isWeekend = (date: Date | string | number): boolean => {
const d = new Date(date)
const day = d.getDay()
return day === 0 || day === 6
}
export const isWeekday = (date: Date | string | number): boolean => {
return !isWeekend(date)
}
export const isLeapYear = (year: number): boolean => {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
}
export const getDaysInMonth = (year: number, month: number): number => {
return new Date(year, month + 1, 0).getDate()
}
export const getDaysInYear = (year: number): number => {
return isLeapYear(year) ? 366 : 365
}
@@ -0,0 +1,197 @@
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import relativeTime from 'dayjs/plugin/relativeTime'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
dayjs.extend(relativeTime)
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.locale('zh-cn')
export type DateFormat = 'YYYY-MM-DD' | 'YYYY-MM-DD HH:mm:ss' | 'YYYY-MM-DD HH:mm' | 'HH:mm:ss' | 'HH:mm' | 'MM-DD' | 'YYYY/MM/DD'
export const formatDateTime = (date: string | number | Date, format: DateFormat = 'YYYY-MM-DD HH:mm:ss'): string => {
return dayjs(date).format(format)
}
export const formatDate = (date: string | number | Date, format: string = 'YYYY-MM-DD'): string => {
return dayjs(date).format(format)
}
export const formatTime = (date: string | number | Date): string => {
return dayjs(date).format('HH:mm:ss')
}
export const formatRelativeTime = (date: string | number | Date): string => {
return dayjs(date).fromNow()
}
export const isToday = (date: string | number | Date): boolean => {
return dayjs(date).isSame(dayjs(), 'day')
}
export const isYesterday = (date: string | number | Date): boolean => {
return dayjs(date).isSame(dayjs().subtract(1, 'day'), 'day')
}
export const isTomorrow = (date: string | number | Date): boolean => {
return dayjs(date).isSame(dayjs().add(1, 'day'), 'day')
}
export const addDays = (date: string | number | Date, days: number): Date => {
return dayjs(date).add(days, 'day').toDate()
}
export const subtractDays = (date: string | number | Date, days: number): Date => {
return dayjs(date).subtract(days, 'day').toDate()
}
export const diffDays = (date1: string | number | Date, date2: string | number | Date): number => {
return dayjs(date1).diff(dayjs(date2), 'day')
}
export const diffHours = (date1: string | number | Date, date2: string | number | Date): number => {
return dayjs(date1).diff(dayjs(date2), 'hour')
}
export const diffMinutes = (date1: string | number | Date, date2: string | number | Date): number => {
return dayjs(date1).diff(dayjs(date2), 'minute')
}
export const isBefore = (date1: string | number | Date, date2: string | number | Date): boolean => {
return dayjs(date1).isBefore(dayjs(date2))
}
export const isAfter = (date1: string | number | Date, date2: string | number | Date): boolean => {
return dayjs(date1).isAfter(dayjs(date2))
}
export const isSame = (date1: string | number | Date, date2: string | number | Date, unit?: dayjs.OpUnitType): boolean => {
return dayjs(date1).isSame(dayjs(date2), unit)
}
export const getStartOfDay = (date: string | number | Date): Date => {
return dayjs(date).startOf('day').toDate()
}
export const getEndOfDay = (date: string | number | Date): Date => {
return dayjs(date).endOf('day').toDate()
}
export const getStartOfMonth = (date: string | number | Date): Date => {
return dayjs(date).startOf('month').toDate()
}
export const getEndOfMonth = (date: string | number | Date): Date => {
return dayjs(date).endOf('month').toDate()
}
export const getStartOfYear = (date: string | number | Date): Date => {
return dayjs(date).startOf('year').toDate()
}
export const getEndOfYear = (date: string | number | Date): Date => {
return dayjs(date).endOf('year').toDate()
}
export const isValidDate = (date: any): boolean => {
return dayjs(date).isValid()
}
export const parseDate = (date: string | number | Date): Date => {
return dayjs(date).toDate()
}
export const getCurrentTimestamp = (): number => {
return dayjs().unix()
}
export const timestampToDate = (timestamp: number): Date => {
return dayjs.unix(timestamp).toDate()
}
export const isLeapYear = (year: number): boolean => {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
}
export const getDaysInMonth = (year: number, month: number): number => {
return new Date(year, month + 1, 0).getDate()
}
export const getWeekNumber = (date: string | number | Date): number => {
const d = dayjs(date).toDate()
const dayNum = d.getUTCDay() || 7
d.setUTCDate(d.getUTCDate() + 4 - dayNum)
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
}
export const getAge = (birthDate: string | number | Date): number => {
const today = new Date()
const birth = dayjs(birthDate).toDate()
let age = today.getFullYear() - birth.getFullYear()
const monthDiff = today.getMonth() - birth.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age--
}
return age
}
export const formatDuration = (milliseconds: number): string => {
const seconds = Math.floor(milliseconds / 1000)
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
const parts: string[] = []
if (hours > 0) parts.push(`${hours}小时`)
if (minutes > 0) parts.push(`${minutes}分钟`)
if (secs > 0 || parts.length === 0) parts.push(`${secs}`)
return parts.join('')
}
export const parseDuration = (durationStr: string): number => {
let totalMilliseconds = 0
const hourMatch = durationStr.match(/(\d+)\s*小时/)
const minuteMatch = durationStr.match(/(\d+)\s*分钟/)
const secondMatch = durationStr.match(/(\d+)\s*秒/)
if (hourMatch) totalMilliseconds += parseInt(hourMatch[1]) * 3600 * 1000
if (minuteMatch) totalMilliseconds += parseInt(minuteMatch[1]) * 60 * 1000
if (secondMatch) totalMilliseconds += parseInt(secondMatch[1]) * 1000
return totalMilliseconds
}
export const getRelativeTime = (date: string | number | Date): string => {
return dayjs(date).fromNow()
}
export const addMonths = (date: string | number | Date, months: number): Date => {
return dayjs(date).add(months, 'month').toDate()
}
export const addYears = (date: string | number | Date, years: number): Date => {
return dayjs(date).add(years, 'year').toDate()
}
export const getStartOfWeek = (date: string | number | Date): Date => {
return dayjs(date).startOf('week').subtract(1, 'day').toDate()
}
export const isSameDay = (date1: string | number | Date, date2: string | number | Date): boolean => {
return dayjs(date1).isSame(dayjs(date2), 'day')
}
export const isSameWeek = (date1: string | number | Date, date2: string | number | Date): boolean => {
return dayjs(date1).isSame(dayjs(date2), 'week')
}
export const isSameMonth = (date1: string | number | Date, date2: string | number | Date): boolean => {
return dayjs(date1).isSame(dayjs(date2), 'month')
}
@@ -0,0 +1,85 @@
export enum Trigger {
BLUR = 'blur',
CHANGE = 'change',
ALL = ['blur', 'change']
}
export interface FormRule {
required?: boolean
message?: string
trigger?: string | string[]
min?: number
max?: number
pattern?: RegExp
validator?: (rule: any, value: any) => Promise<void> | void
}
class FormRules {
required(message: string, trigger: string | string[] = 'blur'): FormRule {
return {
required: true,
message,
trigger
}
}
email(message: string = '请输入有效的邮箱地址', trigger: string | string[] = 'blur'): FormRule {
return {
type: 'email',
message,
trigger
}
}
phone(message: string = '请输入有效的手机号码', trigger: string | string[] = 'blur'): FormRule {
return {
pattern: /^1[3-9]\d{9}$/,
message,
trigger
}
}
minLength(min: number, message: string, trigger: string | string[] = 'blur'): FormRule {
return {
min,
message,
trigger
}
}
maxLength(max: number, message: string, trigger: string | string[] = 'blur'): FormRule {
return {
max,
message,
trigger
}
}
range(min: number, max: number, message: string, trigger: string | string[] = 'blur'): FormRule {
return {
min,
max,
message,
trigger
}
}
pattern(pattern: RegExp, message: string, trigger: string | string[] = 'blur'): FormRule {
return {
pattern,
message,
trigger
}
}
custom(validator: (rule: any, value: any) => Promise<void> | void, trigger: string | string[] = 'blur'): FormRule {
return {
validator,
trigger
}
}
}
const formRules = new FormRules()
export default formRules
@@ -0,0 +1,173 @@
export interface ValidationRule {
required?: boolean
type?: 'string' | 'number' | 'email' | 'url'
min?: number
max?: number
minLength?: number
maxLength?: number
pattern?: RegExp
validator?: (value: any) => boolean | Promise<boolean>
message?: string
async?: boolean
}
export interface FieldConfig {
name: string
rules: ValidationRule[]
}
export interface FormValidationResult {
isValid: boolean
errors: Record<string, string[]>
}
export interface ValidationOptions {
stopOnFirstError?: boolean
}
export class FormValidator {
private defaultMessages = {
required: '此字段为必填项',
email: '请输入有效的邮箱地址',
url: '请输入有效的URL',
min: (value: number) => `值不能小于${value}`,
max: (value: number) => `值不能大于${value}`,
minLength: (value: number) => `长度不能少于${value}个字符`,
maxLength: (value: number) => `长度不能超过${value}个字符`,
pattern: '格式不正确'
}
validate(fields: FieldConfig[], data: Record<string, any>, options?: ValidationOptions): FormValidationResult {
const errors: Record<string, string[]> = {}
for (const field of fields) {
const value = data[field.name]
const fieldErrors: string[] = []
for (const rule of field.rules) {
if (rule.async) continue
const error = this.validateRule(field.name, value, rule)
if (error) {
fieldErrors.push(error)
if (options?.stopOnFirstError) break
}
}
if (fieldErrors.length > 0) {
errors[field.name] = fieldErrors
if (options?.stopOnFirstError) break
}
}
return {
isValid: Object.keys(errors).length === 0,
errors
}
}
async validateAsync(fields: FieldConfig[], data: Record<string, any>, options?: ValidationOptions): Promise<FormValidationResult> {
const errors: Record<string, string[]> = {}
for (const field of fields) {
const value = data[field.name]
const fieldErrors: string[] = []
for (const rule of field.rules) {
const error = await this.validateRuleAsync(field.name, value, rule)
if (error) {
fieldErrors.push(error)
if (options?.stopOnFirstError) break
}
}
if (fieldErrors.length > 0) {
errors[field.name] = fieldErrors
if (options?.stopOnFirstError) break
}
}
return {
isValid: Object.keys(errors).length === 0,
errors
}
}
private validateRule(fieldName: string, value: any, rule: ValidationRule): string | null {
if (rule.required) {
if (value === undefined || value === null || value === '') {
return rule.message || this.defaultMessages.required
}
}
if (value === undefined || value === null || value === '') {
return null
}
if (rule.type === 'email') {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(value)) {
return rule.message || this.defaultMessages.email
}
}
if (rule.type === 'url') {
try {
new URL(value)
} catch {
return rule.message || this.defaultMessages.url
}
}
if (rule.type === 'number') {
const num = Number(value)
if (isNaN(num)) {
return rule.message || '请输入有效的数字'
}
if (rule.min !== undefined && num < rule.min) {
return rule.message || this.defaultMessages.min(rule.min)
}
if (rule.max !== undefined && num > rule.max) {
return rule.message || this.defaultMessages.max(rule.max)
}
}
if (typeof value === 'string') {
if (rule.minLength !== undefined && value.length < rule.minLength) {
return rule.message || this.defaultMessages.minLength(rule.minLength)
}
if (rule.maxLength !== undefined && value.length > rule.maxLength) {
return rule.message || this.defaultMessages.maxLength(rule.maxLength)
}
}
if (rule.pattern && !rule.pattern.test(value)) {
return rule.message || this.defaultMessages.pattern
}
if (rule.validator && !rule.async) {
const isValid = rule.validator(value)
if (!isValid) {
return rule.message || '验证失败'
}
}
return null
}
private async validateRuleAsync(fieldName: string, value: any, rule: ValidationRule): Promise<string | null> {
if (rule.async && rule.validator) {
const isValid = await rule.validator(value)
if (!isValid) {
return rule.message || '验证失败'
}
}
return this.validateRule(fieldName, value, rule)
}
}
export default FormValidator
@@ -0,0 +1,9 @@
export * from './date'
export * from './validate'
export * from './string'
export * from './number'
export * from './array'
export * from './object'
export * from './storage'
export * from './common'
export { default as request } from './request'
@@ -0,0 +1,329 @@
export const clamp = (value: number, min: number, max: number): number => {
return Math.min(Math.max(value, min), max)
}
export const random = (min: number, max: number): number => {
return Math.random() * (max - min) + min
}
export const randomInt = (min: number, max: number): number => {
return Math.floor(random(min, max + 1))
}
export const round = (value: number, precision: number = 0): number => {
const factor = Math.pow(10, precision)
return Math.round(value * factor) / factor
}
export const floor = (value: number, precision: number = 0): number => {
const factor = Math.pow(10, precision)
return Math.floor(value * factor) / factor
}
export const ceil = (value: number, precision: number = 0): number => {
const factor = Math.pow(10, precision)
return Math.ceil(value * factor) / factor
}
export const abs = (value: number): number => {
return Math.abs(value)
}
export const min = (...values: number[]): number => {
return Math.min(...values)
}
export const max = (...values: number[]): number => {
return Math.max(...values)
}
export const sum = (...values: number[]): number => {
return values.reduce((acc, value) => acc + value, 0)
}
export const average = (...values: number[]): number => {
return sum(...values) / values.length
}
export const median = (...values: number[]): number => {
const sorted = [...values].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
export const mode = (...values: number[]): number => {
const frequency: Record<number, number> = {}
let maxFreq = 0
let mode = values[0]
for (const value of values) {
frequency[value] = (frequency[value] || 0) + 1
if (frequency[value] > maxFreq) {
maxFreq = frequency[value]
mode = value
}
}
return mode
}
export const range = (start: number, end: number, step: number = 1): number[] => {
const result: number[] = []
for (let i = start; i < end; i += step) {
result.push(i)
}
return result
}
export const isInRange = (value: number, min: number, max: number): boolean => {
return value >= min && value <= max
}
export const isEven = (value: number): boolean => {
return value % 2 === 0
}
export const isOdd = (value: number): boolean => {
return value % 2 !== 0
}
export const isPositive = (value: number): boolean => {
return value > 0
}
export const isNegative = (value: number): boolean => {
return value < 0
}
export const isZero = (value: number): boolean => {
return value === 0
}
export const isInteger = (value: number): boolean => {
return Number.isInteger(value)
}
export const isFloat = (value: number): boolean => {
return !isInteger(value)
}
export const toFixed = (value: number, precision: number = 2): string => {
return value.toFixed(precision)
}
export const toPercentage = (value: number, precision: number = 2): string => {
return `${(value * 100).toFixed(precision)}%`
}
export const toCurrency = (value: number, currency: string = 'CNY', locale: string = 'zh-CN'): string => {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency
}).format(value)
}
export const toBytes = (bytes: number): string => {
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(2)} ${units[unitIndex]}`
}
export const toDuration = (seconds: number): string => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
const parts: string[] = []
if (hours > 0) parts.push(`${hours}h`)
if (minutes > 0) parts.push(`${minutes}m`)
if (secs > 0 || parts.length === 0) parts.push(`${secs}s`)
return parts.join(' ')
}
export const add = (a: number, b: number): number => {
return a + b
}
export const subtract = (a: number, b: number): number => {
return a - b
}
export const multiply = (a: number, b: number): number => {
return a * b
}
export const divide = (a: number, b: number): number => {
return a / b
}
export const modulo = (a: number, b: number): number => {
return a % b
}
export const power = (base: number, exponent: number): number => {
return Math.pow(base, exponent)
}
export const sqrt = (value: number): number => {
return Math.sqrt(value)
}
export const cbrt = (value: number): number => {
return Math.cbrt(value)
}
export const log = (value: number, base: number = Math.E): number => {
return Math.log(value) / Math.log(base)
}
export const log10 = (value: number): number => {
return Math.log10(value)
}
export const log2 = (value: number): number => {
return Math.log2(value)
}
export const exp = (value: number): number => {
return Math.exp(value)
}
export const sin = (value: number): number => {
return Math.sin(value)
}
export const cos = (value: number): number => {
return Math.cos(value)
}
export const tan = (value: number): number => {
return Math.tan(value)
}
export const asin = (value: number): number => {
return Math.asin(value)
}
export const acos = (value: number): number => {
return Math.acos(value)
}
export const atan = (value: number): number => {
return Math.atan(value)
}
export const atan2 = (y: number, x: number): number => {
return Math.atan2(y, x)
}
export const degrees = (radians: number): number => {
return (radians * 180) / Math.PI
}
export const radians = (degrees: number): number => {
return (degrees * Math.PI) / 180
}
export const factorial = (n: number): number => {
if (n < 0) return NaN
if (n === 0 || n === 1) return 1
return n * factorial(n - 1)
}
export const fibonacci = (n: number): number => {
if (n < 0) return NaN
if (n === 0) return 0
if (n === 1) return 1
return fibonacci(n - 1) + fibonacci(n - 2)
}
export const gcd = (a: number, b: number): number => {
return b === 0 ? a : gcd(b, a % b)
}
export const lcm = (a: number, b: number): number => {
return (a * b) / gcd(a, b)
}
export const isPrime = (n: number): boolean => {
if (n < 2) return false
if (n === 2) return true
if (n % 2 === 0) return false
for (let i = 3; i <= Math.sqrt(n); i += 2) {
if (n % i === 0) return false
}
return true
}
export const formatNumber = (value: number, locale: string = 'zh-CN'): string => {
return new Intl.NumberFormat(locale).format(value)
}
export const formatNumberWithSeparator = (value: number, separator: string = ','): string => {
return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, separator)
}
export const parseNumber = (value: string): number => {
return parseFloat(value.replace(/,/g, ''))
}
export const toSafeNumber = (value: any, defaultValue: number = 0): number => {
const num = Number(value)
return isNaN(num) ? defaultValue : num
}
export const isFinite = (value: number): boolean => {
return Number.isFinite(value)
}
export const isNaN = (value: number): boolean => {
return Number.isNaN(value)
}
export const isSafeInteger = (value: number): boolean => {
return Number.isSafeInteger(value)
}
export const toPrecision = (value: number, precision: number): string => {
return value.toPrecision(precision)
}
export const toExponential = (value: number, fractionDigits?: number): string => {
return value.toExponential(fractionDigits)
}
export const toLocaleString = (value: number, locale: string = 'zh-CN', options?: Intl.NumberFormatOptions): string => {
return value.toLocaleString(locale, options)
}
export const sign = (value: number): number => {
return Math.sign(value)
}
export const trunc = (value: number): number => {
return Math.trunc(value)
}
export const hypot = (...values: number[]): number => {
return Math.hypot(...values)
}
export const imul = (a: number, b: number): number => {
return Math.imul(a, b)
}
export const clz32 = (value: number): number => {
return Math.clz32(value)
}
export const fround = (value: number): number => {
return Math.fround(value)
}
@@ -0,0 +1,373 @@
export const keys = <T extends object>(obj: T): (keyof T)[] => {
return Object.keys(obj) as (keyof T)[]
}
export const values = <T extends object>(obj: T): T[keyof T][] => {
return Object.values(obj)
}
export const entries = <T extends object>(obj: T): [keyof T, T[keyof T]][] => {
return Object.entries(obj) as [keyof T, T[keyof T]][]
}
export const fromEntries = <K extends PropertyKey, V>(entries: [K, V][]): Record<K, V> => {
return Object.fromEntries(entries) as Record<K, V>
}
export function get<T, K extends keyof T>(obj: T, path: K, defaultValue?: T[K]): T[K] | undefined
export function get<T, K extends string>(obj: T, path: K, defaultValue?: any): any
export function get(obj: any, path: string, defaultValue?: any): any {
const keys = path.split('.')
let result = obj
for (const key of keys) {
if (result == null) {
return defaultValue
}
result = result[key]
}
return result !== undefined ? result : defaultValue
}
export const set = <T extends object, K extends string>(obj: T, path: K, value: any): T => {
const keys = path.split('.')
let current: any = obj
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]
if (!(key in current)) {
current[key] = {}
}
current = current[key]
}
current[keys[keys.length - 1]] = value
return obj
}
export const has = <T extends object, K extends keyof T>(obj: T, key: K): boolean => {
return key in obj
}
export const hasPath = <T extends object, K extends string>(obj: T, path: K): boolean => {
const keys = path.split('.')
let current: any = obj
for (const key of keys) {
if (current == null || !(key in current)) {
return false
}
current = current[key]
}
return true
}
export const omit = <T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> => {
const result = { ...obj }
keys.forEach(key => delete result[key])
return result
}
export const pick = <T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> => {
const result = {} as Pick<T, K>
keys.forEach(key => {
if (key in obj) {
result[key] = obj[key]
}
})
return result
}
export const merge = <T extends object, U extends object>(obj1: T, obj2: U): T & U => {
return { ...obj1, ...obj2 }
}
export const deepMerge = <T extends object, U extends object>(obj1: T, obj2: U): T & U => {
const result = { ...obj1 } as any
for (const key in obj2) {
if (typeof obj2[key] === 'object' && obj2[key] !== null && !Array.isArray(obj2[key])) {
result[key] = deepMerge(result[key] || {}, obj2[key])
} else {
result[key] = obj2[key]
}
}
return result
}
export const clone = <T extends object>(obj: T): T => {
return { ...obj }
}
export const deepClone = <T>(obj: T): T => {
if (obj === null || typeof obj !== 'object') {
return obj
}
if (obj instanceof Date) {
return new Date(obj.getTime()) as any
}
if (obj instanceof Array) {
return obj.map(item => deepClone(item)) as any
}
if (obj instanceof Object) {
const clonedObj = {} as any
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key])
}
}
return clonedObj
}
return obj
}
export const isEqual = <T>(a: T, b: T): boolean => {
if (a === b) return true
if (a == null || b == null) return false
if (typeof a !== typeof b) return false
if (typeof a !== 'object') return false
if (Array.isArray(a) !== Array.isArray(b)) return false
if (Array.isArray(a)) {
if (a.length !== b.length) return false
return a.every((item, index) => isEqual(item, (b as any)[index]))
}
const keysA = Object.keys(a)
const keysB = Object.keys(b)
if (keysA.length !== keysB.length) return false
return keysA.every(key => isEqual((a as any)[key], (b as any)[key]))
}
export const isEmpty = <T extends object>(obj: T): boolean => {
return Object.keys(obj).length === 0
}
export const isObject = (value: any): value is object => {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export const isPlainObject = (value: any): value is object => {
if (typeof value !== 'object' || value === null) return false
const proto = Object.getPrototypeOf(value)
return proto === null || proto === Object.prototype
}
export const transform = <T extends object, U>(
obj: T,
iteratee: (result: U, value: T[keyof T], key: keyof T, obj: T) => void,
accumulator?: U
): U => {
const result = accumulator || {} as U
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
iteratee(result, obj[key], key, obj)
}
}
return result
}
export const mapValues = <T extends object, U>(
obj: T,
iteratee: (value: T[keyof T], key: keyof T, obj: T) => U
): Record<keyof T, U> => {
const result = {} as Record<keyof T, U>
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
result[key] = iteratee(obj[key], key, obj)
}
}
return result
}
export const mapKeys = <T extends object, U extends PropertyKey>(
obj: T,
iteratee: (value: T[keyof T], key: keyof T, obj: T) => U
): Record<U, T[keyof T]> => {
const result = {} as Record<U, T[keyof T]>
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const newKey = iteratee(obj[key], key, obj)
result[newKey] = obj[key]
}
}
return result
}
export const invert = <T extends PropertyKey, U extends PropertyKey>(obj: Record<T, U>): Record<U, T> => {
const result = {} as Record<U, T>
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
result[obj[key]] = key
}
}
return result
}
export const invertBy = <T extends PropertyKey, U extends PropertyKey>(
obj: Record<T, U>,
iteratee?: (value: U) => T
): Record<U, T[]> => {
const result = {} as Record<U, T[]>
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const value = obj[key]
const newKey = iteratee ? iteratee(value) : value
if (!result[newKey]) {
result[newKey] = []
}
result[newKey].push(key)
}
}
return result
}
export const assign = <T extends object, U extends object>(target: T, ...sources: U[]): T & U => {
return Object.assign(target, ...sources) as T & U
}
export const defaults = <T extends object, U extends object>(obj: T, ...defaults: U[]): T & U => {
const result = { ...obj } as any
defaults.forEach(source => {
for (const key in source) {
if (!(key in result)) {
result[key] = source[key]
}
}
})
return result
}
export const toPairs = <T extends object>(obj: T): [keyof T, T[keyof T]][] => {
return Object.entries(obj) as [keyof T, T[keyof T]][]
}
export const fromPairs = <K extends PropertyKey, V>(pairs: [K, V][]): Record<K, V> => {
return Object.fromEntries(pairs) as Record<K, V>
}
export const forIn = <T extends object>(
obj: T,
iteratee: (value: T[keyof T], key: keyof T, obj: T) => void | boolean
): void => {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const result = iteratee(obj[key], key, obj)
if (result === false) break
}
}
}
export const forOwn = <T extends object>(
obj: T,
iteratee: (value: T[keyof T], key: keyof T, obj: T) => void | boolean
): void => {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const result = iteratee(obj[key], key, obj)
if (result === false) break
}
}
}
export const functions = <T extends object>(obj: T): Array<keyof T> => {
return Object.keys(obj).filter(key => typeof (obj as any)[key] === 'function') as Array<keyof T>
}
export const functionsIn = <T extends object>(obj: T): Array<keyof T> => {
const result: Array<keyof T> = []
for (const key in obj) {
if (typeof (obj as any)[key] === 'function') {
result.push(key)
}
}
return result
}
export const hasOwnProperty = <T extends object, K extends PropertyKey>(obj: T, key: K): boolean => {
return Object.prototype.hasOwnProperty.call(obj, key)
}
export const create = <T extends object>(prototype: T, properties?: PropertyDescriptorMap): T => {
return Object.create(prototype, properties)
}
export const getPrototypeOf = <T extends object>(obj: T): object | null => {
return Object.getPrototypeOf(obj)
}
export const setPrototypeOf = <T extends object, U extends object>(obj: T, prototype: U): T & U => {
return Object.setPrototypeOf(obj, prototype) as T & U
}
export const getOwnPropertyDescriptor = <T extends object, K extends keyof T>(
obj: T,
key: K
): PropertyDescriptor | undefined => {
return Object.getOwnPropertyDescriptor(obj, key)
}
export const getOwnPropertyDescriptors = <T extends object>(obj: T): PropertyDescriptorMap => {
return Object.getOwnPropertyDescriptors(obj)
}
export const getOwnPropertyNames = <T extends object>(obj: T): (string | number)[] => {
return Object.getOwnPropertyNames(obj)
}
export const getOwnPropertySymbols = <T extends object>(obj: T): symbol[] => {
return Object.getOwnPropertySymbols(obj)
}
export const freeze = <T extends object>(obj: T): Readonly<T> => {
return Object.freeze(obj)
}
export const isFrozen = <T extends object>(obj: T): boolean => {
return Object.isFrozen(obj)
}
export const seal = <T extends object>(obj: T): T => {
return Object.seal(obj)
}
export const isSealed = <T extends object>(obj: T): boolean => {
return Object.isSealed(obj)
}
export const preventExtensions = <T extends object>(obj: T): T => {
return Object.preventExtensions(obj)
}
export const isExtensible = <T extends object>(obj: T): boolean => {
return Object.isExtensible(obj)
}
export const defineProperty = <T extends object, K extends keyof T>(
obj: T,
key: K,
descriptor: PropertyDescriptor
): T => {
return Object.defineProperty(obj, key, descriptor)
}
export const defineProperties = <T extends object>(obj: T, descriptors: PropertyDescriptorMap): T => {
return Object.defineProperties(obj, descriptors)
}
export const entriesIn = <T extends object>(obj: T): [string, T[keyof T]][] => {
const result: [string, T[keyof T]][] = []
for (const key in obj) {
result.push([key, obj[key]])
}
return result
}
export const keysIn = <T extends object>(obj: T): string[] => {
const result: string[] = []
for (const key in obj) {
result.push(key)
}
return result
}
export const valuesIn = <T extends object>(obj: T): T[keyof T][] => {
const result: T[keyof T][] = []
for (const key in obj) {
result.push(obj[key])
}
return result
}
@@ -0,0 +1,163 @@
/**
* 性能优化工具函数
*/
/**
* 防抖函数 - 在指定时间间隔内只执行最后一次调用
* @param func 要防抖的函数
* @param wait 等待时间(毫秒)
* @returns 防抖后的函数
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return function (this: any, ...args: Parameters<T>): void {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
func.apply(this, args);
}, wait);
};
}
/**
* 节流函数 - 在指定时间间隔内最多执行一次
* @param func 要节流的函数
* @param limit 时间间隔(毫秒)
* @returns 节流后的函数
*/
export function throttle<T extends (...args: any[]) => any>(
func: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle: boolean;
return function (this: any, ...args: Parameters<T>): void {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}
/**
* 延迟加载函数 - 延迟执行函数直到下一个事件循环
* @param func 要延迟执行的函数
* @returns 延迟执行的函数
*/
export function defer<T extends (...args: any[]) => any>(func: T): (...args: Parameters<T>) => void {
return function (this: any, ...args: Parameters<T>): void {
setTimeout(() => func.apply(this, args), 0);
};
}
/**
* 缓存函数 - 缓存函数的计算结果
* @param func 要缓存的函数
* @returns 缓存后的函数
*/
export function memoize<T extends (...args: any[]) => any>(func: T): T {
const cache = new Map<string, ReturnType<T>>();
return function (this: any, ...args: Parameters<T>): ReturnType<T> {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key)!;
}
const result = func.apply(this, args) as ReturnType<T>;
cache.set(key, result);
return result;
} as T;
}
/**
* 空闲回调包装器 - 利用浏览器空闲时间执行函数
* @param func 要执行的函数
* @param options 选项
* @returns 执行函数
*/
export function idleCallback<T extends (...args: any[]) => any>(
func: T,
options?: { timeout?: number }
): (...args: Parameters<T>) => void {
return function (this: any, ...args: Parameters<T>): void {
if ('requestIdleCallback' in window) {
requestIdleCallback(
() => {
func.apply(this, args);
},
{ timeout: options?.timeout || 1000 }
);
} else {
// 降级到 setTimeout
setTimeout(() => {
func.apply(this, args);
}, 1);
}
};
}
/**
* 图片懒加载
* @param selector 图片选择器
*/
export function lazyLoadImages(selector: string = 'img[data-src]'): void {
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement;
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute('data-src');
}
imageObserver.unobserve(img);
}
});
});
document.querySelectorAll(selector).forEach((img) => {
imageObserver.observe(img);
});
} else {
// 降级处理:直接加载所有图片
const images = document.querySelectorAll(selector) as NodeListOf<HTMLImageElement>;
images.forEach((img) => {
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute('data-src');
}
});
}
}
/**
* 批量处理大量数据
* @param items 数据项数组
* @param processor 单项处理器
* @param batchSize 批处理大小
* @returns 处理结果Promise
*/
export async function batchProcess<T, R>(
items: T[],
processor: (item: T) => Promise<R>,
batchSize: number = 10
): Promise<R[]> {
const results: R[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(processor));
results.push(...batchResults);
// 让出控制权给主线程,避免阻塞
if (i + batchSize < items.length) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
return results;
}
@@ -0,0 +1,208 @@
/**
* 前端性能监控工具
*/
export class PerformanceMonitor {
private static instance: PerformanceMonitor;
private observer: PerformanceObserver | null = null;
private metrics: Record<string, any> = {};
public static getInstance(): PerformanceMonitor {
if (!PerformanceMonitor.instance) {
PerformanceMonitor.instance = new PerformanceMonitor();
}
return PerformanceMonitor.instance;
}
/**
* 初始化性能监控
*/
public init(): void {
// 监控首次绘制(FP)和首次内容绘制(FCP)
this.observeFirstPaint();
// 监控最大内容绘制(LCP)
this.observeLCP();
// 监控累积布局偏移(CLS)
this.observeCLS();
// 监控资源加载性能
this.observeResourceTiming();
}
/**
* 监控首次绘制和首次内容绘制
*/
private observeFirstPaint(): void {
if ('performance' in window) {
// 监控首次绘制
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (entry.name === 'first-paint') {
this.metrics.fp = entry.startTime;
console.log(`首次绘制 FP: ${Math.round(entry.startTime)} ms`);
}
}
}).observe({ entryTypes: ['paint'] });
// 监控首次内容绘制
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (entry.name === 'first-contentful-paint') {
this.metrics.fcp = entry.startTime;
console.log(`首次内容绘制 FCP: ${Math.round(entry.startTime)} ms`);
}
}
}).observe({ entryTypes: ['paint'] });
}
}
/**
* 监控最大内容绘制
*/
private observeLCP(): void {
if ('PerformanceObserver' in window) {
this.observer = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
this.metrics.lcp = entry.startTime;
console.log(`最大内容绘制 LCP: ${Math.round(entry.startTime)} ms`);
}
});
this.observer.observe({ entryTypes: ['largest-contentful-paint'] });
}
}
/**
* 监控累积布局偏移
*/
private observeCLS(): void {
if ('PerformanceObserver' in window) {
let clsValue = 0;
this.observer = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value;
this.metrics.cls = clsValue;
console.log(`累积布局偏移 CLS: ${clsValue.toFixed(3)}`);
}
}
});
this.observer.observe({ entryTypes: ['layout-shift'] });
}
}
/**
* 监控资源加载性能
*/
private observeResourceTiming(): void {
if ('PerformanceObserver' in window) {
this.observer = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
// 记录资源加载时间
const loadTime = entry.responseEnd - entry.startTime;
console.log(`资源加载: ${entry.name} - ${Math.round(loadTime)} ms`);
}
});
this.observer.observe({ entryTypes: ['resource'] });
}
}
/**
* 获取当前性能指标
*/
public getMetrics(): Record<string, any> {
return { ...this.metrics };
}
/**
* 记录自定义性能指标
*/
public measure(name: string, startMark?: string, endMark?: string): void {
if ('performance' in window) {
if (startMark && endMark) {
performance.measure(name, startMark, endMark);
} else {
performance.mark(name);
}
}
}
/**
* 获取测量结果
*/
public getMeasure(name: string): PerformanceEntry | null {
if ('performance' in window) {
const measures = performance.getEntriesByName(name, 'measure');
return measures.length > 0 ? measures[0] : null;
}
return null;
}
/**
* 销毁性能监控
*/
public destroy(): void {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
}
}
/**
* 页面加载性能工具
*/
export class PageLoadPerformance {
/**
* 获取页面加载性能数据
*/
static getPageLoadData(): Record<string, number> {
if (!('performance' in window)) {
return {};
}
const timing = performance.timing;
return {
// DNS 查询时间
dnsTime: timing.domainLookupEnd - timing.domainLookupStart,
// TCP 连接时间
tcpTime: timing.connectEnd - timing.connectStart,
// 请求响应时间
requestTime: timing.responseEnd - timing.requestStart,
// DOM 加载时间
domReadyTime: timing.domContentLoadedEventEnd - timing.navigationStart,
// 页面完全加载时间
loadCompleteTime: timing.loadEventEnd - timing.navigationStart,
// 白屏时间
whiteScreenTime: timing.responseStart - timing.navigationStart,
// 首次可交互时间
firstInteractive: (timing.domInteractive || timing.domContentLoadedEventEnd) - timing.navigationStart
};
}
/**
* 获取导航性能数据
*/
static getNavigationData(): Record<string, any> {
if (!('performance' in window) || !performance.getEntriesByType) {
return {};
}
const navigation = performance.getEntriesByType('navigation')[0];
if (!navigation) return {};
return {
// 页面加载类型
type: navigation.type === 1 ? 'reload' : navigation.type === 2 ? 'back_forward' : 'navigate',
// 重定向次数
redirectCount: (navigation as PerformanceNavigationTiming).redirectCount,
// 页面加载耗时
duration: Math.round(navigation.duration),
// DNS 查询时间
domainLookup: Math.round((navigation as PerformanceNavigationTiming).domainLookupEnd - (navigation as PerformanceNavigationTiming).domainLookupStart),
// TCP 连接时间
connect: Math.round((navigation as PerformanceNavigationTiming).connectEnd - (navigation as PerformanceNavigationTiming).connectStart),
// 请求响应时间
response: Math.round((navigation as PerformanceNavigationTiming).responseEnd - (navigation as PerformanceNavigationTiming).requestStart),
// DOM 解析时间
domProcessing: Math.round((navigation as PerformanceNavigationTiming).domContentLoadedEventEnd - (navigation as PerformanceNavigationTiming).responseEnd)
};
}
}
@@ -0,0 +1,259 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import type { Result } from '@/types';
// Mock相关导入
let mockInterceptor: typeof import('@/mocks/mock-interceptor') | null = null;
let mockInstalled = false;
const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8080';
const TOKEN_KEY = 'access_token';
const REFRESH_TOKEN_KEY = 'refreshToken';
export { TOKEN_KEY, REFRESH_TOKEN_KEY };
interface RequestConfig extends AxiosRequestConfig {
skipAuth?: boolean;
skipErrorHandler?: boolean;
_retry?: boolean;
}
class Request {
private instance: AxiosInstance;
private isRefreshing = false;
private refreshSubscribers: Array<(token: string) => void> = [];
constructor(config: AxiosRequestConfig = {}) {
this.instance = axios.create({
baseURL: BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json;charset=UTF-8'
},
...config
});
this.setupInterceptors();
// 动态导入Mock模块并安装拦截器
this.setupMock();
}
private async setupMock() {
// 检查是否应该启用Mock
const env = import.meta.env.MODE;
const shouldUseMock = env === 'development-local' || env === 'test';
const forceMock = import.meta.env.VITE_MOCK_ENABLED === 'true';
// E2E 测试环境检测:检查是否有 E2E 测试标记
// 在 E2E 测试时,global-setup.ts 会设置 VITE_E2E_TEST=true
const isE2EEnvironment = import.meta.env.VITE_E2E_TEST === 'true';
if (isE2EEnvironment) {
console.log('E2E 测试环境检测到,跳过应用内部 Mock 拦截器安装');
return;
}
if (shouldUseMock || forceMock) {
try {
mockInterceptor = await import('@/mocks/mock-interceptor');
mockInterceptor.installMockInterceptor(this.instance);
mockInstalled = true;
console.log(`Mock interceptor installed for environment: ${env}`);
} catch (error) {
console.log('Mock module not found, skipping mock setup');
}
} else {
console.log(`Mock interceptor skipped for environment: ${env}`);
}
}
private setupInterceptors(): void {
this.instance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const requestConfig = config as RequestConfig;
if (!requestConfig.skipAuth) {
const token = localStorage.getItem(TOKEN_KEY);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
this.instance.interceptors.response.use(
(response: AxiosResponse) => {
let data = response.data;
console.log('=== RESPONSE INTERCEPTOR START ===');
console.log('Raw response.data:', JSON.stringify(data, null, 2));
console.log('Response status:', response.status);
console.log('Response headers:', response.headers);
// 处理 Playwright mock 响应格式
// Playwright mock 响应格式: { status: 200, body: { code, message, data } }
// axios 响应格式: { data: { code, message, data }, status: 200, ... }
if (data && data.body && (data.code !== undefined || data.body.code !== undefined)) {
console.log('Detected Playwright mock response format, extracting body');
data = data.body;
}
console.log('Processed data:', JSON.stringify(data, null, 2));
console.log('Has code field:', data && data.code !== undefined);
console.log('Has body field:', data && data.body !== undefined);
if (data && data.code !== undefined) {
if (data.code === '200' || data.code === 200) {
console.log('Response code is 200, returning data');
console.log('=== RESPONSE INTERCEPTOR END ===');
return data;
} else {
const error = new Error(data.message || '请求失败');
(error as any).response = { data };
console.log('Response code is not 200, rejecting with error:', error.message);
console.log('=== RESPONSE INTERCEPTOR END ===');
return Promise.reject(error);
}
}
console.log('No code field in response, returning data as is');
console.log('=== RESPONSE INTERCEPTOR END ===');
return data;
},
async (error) => {
const { config, response } = error;
const requestConfig = config as RequestConfig;
if (requestConfig?.skipErrorHandler) {
return Promise.reject(error);
}
if (response?.status === 401 && !requestConfig?._retry) {
return this.handleTokenRefresh(requestConfig);
}
return this.handleError(error);
}
);
}
private async handleTokenRefresh(originalConfig: RequestConfig): Promise<any> {
if (this.isRefreshing) {
return new Promise((resolve) => {
this.refreshSubscribers.push((token: string) => {
if (originalConfig.headers) {
originalConfig.headers.Authorization = `Bearer ${token}`;
}
resolve(this.instance(originalConfig));
});
});
}
this.isRefreshing = true;
originalConfig._retry = true;
try {
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
if (!refreshToken) {
this.clearAuth();
window.location.href = '/login';
return Promise.reject(new Error('No refresh token'));
}
const response = await this.instance.post<Result<{ token: string; refreshToken: string }>>('/sys/auth/refresh', { token: refreshToken });
const { token, refreshToken: newRefreshToken } = response.data.data;
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken);
this.refreshSubscribers.forEach((callback) => callback(token));
this.refreshSubscribers = [];
if (originalConfig.headers) {
originalConfig.headers.Authorization = `Bearer ${token}`;
}
return this.instance(originalConfig);
} catch (refreshError) {
this.clearAuth();
this.refreshSubscribers = [];
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
this.isRefreshing = false;
}
}
private handleError(error: any): Promise<never> {
const { response } = error;
if (response) {
const { status, data } = response;
switch (status) {
case 400:
console.error('请求参数错误:', data.message || 'Bad Request');
break;
case 401:
console.error('未授权,请重新登录:', data.message || 'Unauthorized');
this.clearAuth();
window.location.href = '/login';
break;
case 403:
console.error('拒绝访问:', data.message || 'Forbidden');
break;
case 404:
console.error('请求资源不存在:', data.message || 'Not Found');
break;
case 500:
console.error('服务器错误:', data.message || 'Internal Server Error');
break;
default:
console.error('请求错误:', data.message || `Error ${status}`);
}
} else if (error.request) {
console.error('网络错误,请检查网络连接');
} else {
console.error('请求配置错误:', error.message);
}
return Promise.reject(error);
}
private clearAuth(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
}
public get<T = any>(url: string, config?: RequestConfig): Promise<Result<T>> {
return this.instance.get<Result<T>>(url, config);
}
public post<T = any>(url: string, data?: any, config?: RequestConfig): Promise<Result<T>> {
return this.instance.post<Result<T>>(url, data, config);
}
public put<T = any>(url: string, data?: any, config?: RequestConfig): Promise<Result<T>> {
return this.instance.put<Result<T>>(url, data, config);
}
public delete<T = any>(url: string, config?: RequestConfig): Promise<Result<T>> {
return this.instance.delete<Result<T>>(url, config);
}
public patch<T = any>(url: string, data?: any, config?: RequestConfig): Promise<Result<T>> {
return this.instance.patch<Result<T>>(url, data, config);
}
public getInstance(): AxiosInstance {
return this.instance;
}
}
const request = new Request();
export default request;
export type { RequestConfig };
@@ -0,0 +1,400 @@
const PREFIX = 'app_'
const getKey = (key: string): string => {
return `${PREFIX}${key}`
}
export const setItem = (key: string, value: any): void => {
try {
const serializedValue = JSON.stringify(value)
localStorage.setItem(getKey(key), serializedValue)
} catch (error) {
console.error(`Error setting item ${key} to localStorage:`, error)
}
}
export const getItem = <T = any>(key: string, defaultValue?: T): T | undefined => {
try {
const item = localStorage.getItem(getKey(key))
if (item === null) {
return defaultValue
}
return JSON.parse(item) as T
} catch (error) {
console.error(`Error getting item ${key} from localStorage:`, error)
return defaultValue
}
}
export const removeItem = (key: string): void => {
try {
localStorage.removeItem(getKey(key))
} catch (error) {
console.error(`Error removing item ${key} from localStorage:`, error)
}
}
export const clear = (): void => {
try {
const keysToRemove: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && key.startsWith(PREFIX)) {
keysToRemove.push(key)
}
}
keysToRemove.forEach(key => localStorage.removeItem(key))
} catch (error) {
console.error('Error clearing localStorage:', error)
}
}
export const hasItem = (key: string): boolean => {
try {
return localStorage.getItem(getKey(key)) !== null
} catch (error) {
console.error(`Error checking item ${key} in localStorage:`, error)
return false
}
}
export const getAllKeys = (): string[] => {
try {
const keys: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && key.startsWith(PREFIX)) {
keys.push(key.substring(PREFIX.length))
}
}
return keys
} catch (error) {
console.error('Error getting all keys from localStorage:', error)
return []
}
}
export const getAllItems = (): Record<string, any> => {
try {
const items: Record<string, any> = {}
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && key.startsWith(PREFIX)) {
const value = localStorage.getItem(key)
if (value) {
try {
items[key.substring(PREFIX.length)] = JSON.parse(value)
} catch {
items[key.substring(PREFIX.length)] = value
}
}
}
}
return items
} catch (error) {
console.error('Error getting all items from localStorage:', error)
return {}
}
}
export const getSize = (): number => {
try {
let size = 0
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && key.startsWith(PREFIX)) {
const value = localStorage.getItem(key)
if (value) {
size += key.length + value.length
}
}
}
return size
} catch (error) {
console.error('Error getting localStorage size:', error)
return 0
}
}
export const getSizeInKB = (): number => {
return getSize() / 1024
}
export const getSizeInMB = (): number => {
return getSizeInKB() / 1024
}
export const setSessionItem = (key: string, value: any): void => {
try {
const serializedValue = JSON.stringify(value)
sessionStorage.setItem(getKey(key), serializedValue)
} catch (error) {
console.error(`Error setting item ${key} to sessionStorage:`, error)
}
}
export const getSessionItem = <T = any>(key: string, defaultValue?: T): T | undefined => {
try {
const item = sessionStorage.getItem(getKey(key))
if (item === null) {
return defaultValue
}
return JSON.parse(item) as T
} catch (error) {
console.error(`Error getting item ${key} from sessionStorage:`, error)
return defaultValue
}
}
export const removeSessionItem = (key: string): void => {
try {
sessionStorage.removeItem(getKey(key))
} catch (error) {
console.error(`Error removing item ${key} from sessionStorage:`, error)
}
}
export const clearSession = (): void => {
try {
const keysToRemove: string[] = []
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i)
if (key && key.startsWith(PREFIX)) {
keysToRemove.push(key)
}
}
keysToRemove.forEach(key => sessionStorage.removeItem(key))
} catch (error) {
console.error('Error clearing sessionStorage:', error)
}
}
export const hasSessionItem = (key: string): boolean => {
try {
return sessionStorage.getItem(getKey(key)) !== null
} catch (error) {
console.error(`Error checking item ${key} in sessionStorage:`, error)
return false
}
}
export const getAllSessionKeys = (): string[] => {
try {
const keys: string[] = []
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i)
if (key && key.startsWith(PREFIX)) {
keys.push(key.substring(PREFIX.length))
}
}
return keys
} catch (error) {
console.error('Error getting all keys from sessionStorage:', error)
return []
}
}
export const getAllSessionItems = (): Record<string, any> => {
try {
const items: Record<string, any> = {}
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i)
if (key && key.startsWith(PREFIX)) {
const value = sessionStorage.getItem(key)
if (value) {
try {
items[key.substring(PREFIX.length)] = JSON.parse(value)
} catch {
items[key.substring(PREFIX.length)] = value
}
}
}
}
return items
} catch (error) {
console.error('Error getting all items from sessionStorage:', error)
return {}
}
}
export const setCookie = (key: string, value: string, days: number = 7): void => {
try {
const date = new Date()
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000)
const expires = `expires=${date.toUTCString()}`
document.cookie = `${getKey(key)}=${value};${expires};path=/`
} catch (error) {
console.error(`Error setting cookie ${key}:`, error)
}
}
export const getCookie = (key: string): string | null => {
try {
const name = `${getKey(key)}=`
const cookies = document.cookie.split(';')
for (const cookie of cookies) {
let c = cookie
while (c.charAt(0) === ' ') {
c = c.substring(1)
}
if (c.indexOf(name) === 0) {
return c.substring(name.length, c.length)
}
}
return null
} catch (error) {
console.error(`Error getting cookie ${key}:`, error)
return null
}
}
export const removeCookie = (key: string): void => {
try {
document.cookie = `${getKey(key)}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`
} catch (error) {
console.error(`Error removing cookie ${key}:`, error)
}
}
export const hasCookie = (key: string): boolean => {
return getCookie(key) !== null
}
export const getAllCookies = (): Record<string, string> => {
try {
const cookies: Record<string, string> = {}
const cookieStrings = document.cookie.split(';')
for (const cookieString of cookieStrings) {
const [key, value] = cookieString.trim().split('=')
if (key.startsWith(PREFIX)) {
cookies[key.substring(PREFIX.length)] = decodeURIComponent(value || '')
}
}
return cookies
} catch (error) {
console.error('Error getting all cookies:', error)
return {}
}
}
export const clearCookies = (): void => {
try {
const cookies = getAllCookies()
Object.keys(cookies).forEach(key => removeCookie(key))
} catch (error) {
console.error('Error clearing cookies:', error)
}
}
export const isLocalStorageAvailable = (): boolean => {
try {
const testKey = '__test__'
localStorage.setItem(testKey, 'test')
localStorage.removeItem(testKey)
return true
} catch {
return false
}
}
export const isSessionStorageAvailable = (): boolean => {
try {
const testKey = '__test__'
sessionStorage.setItem(testKey, 'test')
sessionStorage.removeItem(testKey)
return true
} catch {
return false
}
}
export const isCookieAvailable = (): boolean => {
try {
document.cookie = 'test=test'
const result = document.cookie.indexOf('test=test') !== -1
document.cookie = 'test=; expires=Thu, 01 Jan 1970 00:00:00 GMT'
return result
} catch {
return false
}
}
export const migrateItem = (key: string, from: 'localStorage' | 'sessionStorage', to: 'localStorage' | 'sessionStorage'): void => {
try {
if (from === 'localStorage' && to === 'sessionStorage') {
const value = getItem(key)
if (value !== undefined) {
setSessionItem(key, value)
removeItem(key)
}
} else if (from === 'sessionStorage' && to === 'localStorage') {
const value = getSessionItem(key)
if (value !== undefined) {
setItem(key, value)
removeSessionItem(key)
}
}
} catch (error) {
console.error(`Error migrating item ${key}:`, error)
}
}
export const backupLocalStorage = (): string => {
try {
return JSON.stringify(getAllItems())
} catch (error) {
console.error('Error backing up localStorage:', error)
return '{}'
}
}
export const restoreLocalStorage = (backup: string): void => {
try {
const data = JSON.parse(backup)
Object.entries(data).forEach(([key, value]) => {
setItem(key, value)
})
} catch (error) {
console.error('Error restoring localStorage:', error)
}
}
export const exportLocalStorage = (): void => {
try {
const data = JSON.stringify(getAllItems(), null, 2)
const blob = new Blob([data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `localStorage-backup-${Date.now()}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (error) {
console.error('Error exporting localStorage:', error)
}
}
export const importLocalStorage = (file: File): Promise<void> => {
return new Promise((resolve, reject) => {
try {
const reader = new FileReader()
reader.onload = (event) => {
try {
const data = JSON.parse(event.target?.result as string)
Object.entries(data).forEach(([key, value]) => {
setItem(key, value)
})
resolve()
} catch (error) {
reject(error)
}
}
reader.onerror = () => reject(reader.error)
reader.readAsText(file)
} catch (error) {
reject(error)
}
})
}
@@ -0,0 +1,337 @@
export const truncate = (str: string, length: number, suffix: string = '...'): string => {
if (str.length <= length) {
return str
}
return str.slice(0, length) + suffix
}
export const capitalize = (str: string): string => {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
}
export const capitalizeWords = (str: string): string => {
return str.replace(/\b\w/g, char => char.toUpperCase())
}
export const camelCase = (str: string): string => {
return str
.replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : ''))
.replace(/^(.)/, char => char.toLowerCase())
}
export const kebabCase = (str: string): string => {
return str
.replace(/([a-z])([A-Z])/g, '$1-$2')
.replace(/[\s_]+/g, '-')
.toLowerCase()
}
export const snakeCase = (str: string): string => {
return str
.replace(/([a-z])([A-Z])/g, '$1_$2')
.replace(/[\s-]+/g, '_')
.toLowerCase()
}
export const pascalCase = (str: string): string => {
return str
.replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : ''))
.replace(/^(.)/, char => char.toUpperCase())
}
export const startCase = (str: string): string => {
return str
.replace(/[-_\s]+(.)?/g, (_, char) => (char ? ' ' + char.toUpperCase() : ''))
.replace(/^(.)/, char => char.toUpperCase())
}
export const lowerCase = (str: string): string => {
return str.toLowerCase()
}
export const upperCase = (str: string): string => {
return str.toUpperCase()
}
export const trim = (str: string, chars?: string): string => {
if (chars) {
const regex = new RegExp(`^[${chars}]+|[${chars}]+$`, 'g')
return str.replace(regex, '')
}
return str.trim()
}
export const trimStart = (str: string, chars?: string): string => {
if (chars) {
const regex = new RegExp(`^[${chars}]+`, 'g')
return str.replace(regex, '')
}
return str.trimStart()
}
export const trimEnd = (str: string, chars?: string): string => {
if (chars) {
const regex = new RegExp(`[${chars}]+$`, 'g')
return str.replace(regex, '')
}
return str.trimEnd()
}
export const padStart = (str: string, length: number, char: string = ' '): string => {
return str.padStart(length, char)
}
export const padEnd = (str: string, length: number, char: string = ' '): string => {
return str.padEnd(length, char)
}
export const repeat = (str: string, count: number): string => {
return str.repeat(count)
}
export const replace = (str: string, search: string | RegExp, replacement: string): string => {
return str.replace(search, replacement)
}
export const replaceAll = (str: string, search: string, replacement: string): string => {
return str.split(search).join(replacement)
}
export const split = (str: string, separator: string | RegExp, limit?: number): string[] => {
return str.split(separator, limit)
}
export const join = (array: string[], separator: string = ','): string => {
return array.join(separator)
}
export const includes = (str: string, search: string, position?: number): boolean => {
return str.includes(search, position)
}
export const startsWith = (str: string, search: string, position?: number): boolean => {
return str.startsWith(search, position)
}
export const endsWith = (str: string, search: string, length?: number): boolean => {
return str.endsWith(search, length)
}
export const indexOf = (str: string, search: string, position?: number): number => {
return str.indexOf(search, position)
}
export const lastIndexOf = (str: string, search: string, position?: number): number => {
return str.lastIndexOf(search, position)
}
export const charAt = (str: string, index: number): string => {
return str.charAt(index)
}
export const charCodeAt = (str: string, index: number): number => {
return str.charCodeAt(index)
}
export const substring = (str: string, start: number, end?: number): string => {
return str.substring(start, end)
}
export const slice = (str: string, start: number, end?: number): string => {
return str.slice(start, end)
}
export const substr = (str: string, start: number, length?: number): string => {
return str.substr(start, length)
}
export const length = (str: string): number => {
return str.length
}
export const isEmpty = (str: string): boolean => {
return str.length === 0
}
export const isBlank = (str: string): boolean => {
return str.trim().length === 0
}
export const reverse = (str: string): string => {
return str.split('').reverse().join('')
}
export const toNumber = (str: string): number => {
return Number(str)
}
export const toBoolean = (str: string): boolean => {
return str.toLowerCase() === 'true'
}
export const toTitleCase = (str: string): string => {
return str.replace(/\w\S*/g, txt => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase())
}
export const toSentenceCase = (str: string): string => {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
}
export const words = (str: string): string[] => {
return str.match(/\b\w+\b/g) || []
}
export const countWords = (str: string): number => {
return words(str).length
}
export const countChars = (str: string, char: string): number => {
return str.split(char).length - 1
}
export const remove = (str: string, pattern: string | RegExp): string => {
return str.replace(pattern, '')
}
export const removeFirst = (str: string, pattern: string | RegExp): string => {
return str.replace(pattern, '')
}
export const removeLast = (str: string, pattern: string | RegExp): string => {
const lastIndex = str.search(pattern)
if (lastIndex === -1) return str
return str.slice(0, lastIndex) + str.slice(lastIndex).replace(pattern, '')
}
export const insert = (str: string, index: number, value: string): string => {
return str.slice(0, index) + value + str.slice(index)
}
export const surround = (str: string, wrapper: string): string => {
return wrapper + str + wrapper
}
export const quote = (str: string): string => {
return `"${str}"`
}
export const unquote = (str: string): string => {
return str.replace(/^["']|["']$/g, '')
}
export const slugify = (str: string): string => {
return str
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '')
}
export const deslugify = (str: string): string => {
return str.replace(/-/g, ' ')
}
export const escape = (str: string): string => {
return str.replace(/[&<>"']/g, char => {
const escapeMap: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}
return escapeMap[char]
})
}
export const unescape = (str: string): string => {
return str.replace(/&amp;|&lt;|&gt;|&quot;|&#39;/g, char => {
const unescapeMap: Record<string, string> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'"
}
return unescapeMap[char]
})
}
export const stripTags = (str: string): string => {
return str.replace(/<[^>]*>/g, '')
}
export const stripHTML = (str: string): string => {
return str.replace(/<[^>]+>/g, '')
}
export const truncateHTML = (html: string, length: number, suffix: string = '...'): string => {
const text = stripHTML(html)
if (text.length <= length) {
return html
}
return text.slice(0, length) + suffix
}
export const mask = (str: string, maskChar: string = '*', visibleChars: number = 4): string => {
if (str.length <= visibleChars) {
return str
}
const masked = maskChar.repeat(str.length - visibleChars)
return str.slice(0, visibleChars) + masked
}
export const maskEmail = (email: string): string => {
const [username, domain] = email.split('@')
const maskedUsername = username.length > 2
? username.slice(0, 2) + '*'.repeat(username.length - 2)
: username
return `${maskedUsername}@${domain}`
}
export const maskPhone = (phone: string): string => {
if (phone.length < 7) {
return phone
}
return phone.slice(0, 3) + '****' + phone.slice(-4)
}
export const maskIdCard = (idCard: string): string => {
if (idCard.length < 8) {
return idCard
}
return idCard.slice(0, 4) + '********' + idCard.slice(-4)
}
export const initials = (str: string): string => {
return str
.split(' ')
.map(word => word.charAt(0).toUpperCase())
.join('')
}
export const abbreviate = (str: string, length: number = 3): string => {
const words = str.split(' ')
if (words.length === 1) {
return str.slice(0, length).toUpperCase()
}
return words.map(word => word.charAt(0)).join('').toUpperCase()
}
export const generateRandomString = (length: number = 8): string => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
export const generateUUID = (): string => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, char => {
const random = Math.random() * 16 | 0
const value = char === 'x' ? random : (random & 0x3 | 0x8)
return value.toString(16)
})
}
@@ -0,0 +1,270 @@
export const isEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
export const isPhone = (phone: string): boolean => {
const phoneRegex = /^1[3-9]\d{9}$/
return phoneRegex.test(phone)
}
export const isUrl = (url: string): boolean => {
try {
new URL(url)
return true
} catch {
return false
}
}
export const isNumber = (value: any): boolean => {
return typeof value === 'number' && !isNaN(value)
}
export const isInteger = (value: any): boolean => {
return Number.isInteger(value)
}
export const isFloat = (value: any): boolean => {
return isNumber(value) && !isInteger(value)
}
export const isString = (value: any): boolean => {
return typeof value === 'string'
}
export const isArray = (value: any): boolean => {
return Array.isArray(value)
}
export const isObject = (value: any): boolean => {
return typeof value === 'object' && value !== null && !isArray(value)
}
export const isFunction = (value: any): boolean => {
return typeof value === 'function'
}
export const isBoolean = (value: any): boolean => {
return typeof value === 'boolean'
}
export const isNull = (value: any): boolean => {
return value === null
}
export const isUndefined = (value: any): boolean => {
return value === undefined
}
export const isNil = (value: any): boolean => {
return isNull(value) || isUndefined(value)
}
export const isEmpty = (value: any): boolean => {
if (isNil(value)) {
return true
}
if (isString(value) || isArray(value)) {
return value.length === 0
}
if (isObject(value)) {
return Object.keys(value).length === 0
}
return false
}
export const isLength = (value: string | any[], min: number, max?: number): boolean => {
const length = value.length
if (max !== undefined) {
return length >= min && length <= max
}
return length >= min
}
export const isRange = (value: number, min: number, max: number): boolean => {
return value >= min && value <= max
}
export const isPositive = (value: number): boolean => {
return value > 0
}
export const isNegative = (value: number): boolean => {
return value < 0
}
export const isNonNegative = (value: number): boolean => {
return value >= 0
}
export const isNonPositive = (value: number): boolean => {
return value <= 0
}
export const isEven = (value: number): boolean => {
return value % 2 === 0
}
export const isOdd = (value: number): boolean => {
return value % 2 !== 0
}
export const isDivisible = (value: number, divisor: number): boolean => {
return value % divisor === 0
}
export const isRequired = (value: any): boolean => {
return !isNil(value) && !isEmpty(value)
}
export const isMatch = (value: string, pattern: string | RegExp): boolean => {
const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern
return regex.test(value)
}
export const isAlpha = (value: string): boolean => {
return /^[a-zA-Z]+$/.test(value)
}
export const isAlphanumeric = (value: string): boolean => {
return /^[a-zA-Z0-9]+$/.test(value)
}
export const isNumeric = (value: string): boolean => {
return /^\d+$/.test(value)
}
export const isHex = (value: string): boolean => {
return /^#?([a-f0-9]{6}|[a-f0-9]{3})$/i.test(value)
}
export const isRGB = (value: string): boolean => {
return /^rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)$/i.test(value)
}
export const isRGBA = (value: string): boolean => {
return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)$/i.test(value)
}
export const isColor = (value: string): boolean => {
return isHex(value) || isRGB(value) || isRGBA(value) || CSS.supports('color', value)
}
export const isJSON = (value: string): boolean => {
try {
JSON.parse(value)
return true
} catch {
return false
}
}
export const isDate = (value: any): boolean => {
return !isNaN(Date.parse(value))
}
export const isFuture = (date: string | Date): boolean => {
return new Date(date) > new Date()
}
export const isPast = (date: string | Date): boolean => {
return new Date(date) < new Date()
}
export const isToday = (date: string | Date): boolean => {
const today = new Date()
const target = new Date(date)
return (
today.getFullYear() === target.getFullYear() &&
today.getMonth() === target.getMonth() &&
today.getDate() === target.getDate()
)
}
export const isUUID = (value: string): boolean => {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
}
export const isBase64 = (value: string): boolean => {
try {
return btoa(atob(value)) === value
} catch {
return false
}
}
export const isIP = (value: string): boolean => {
const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/
return ipv4Regex.test(value) || ipv6Regex.test(value)
}
export const isPort = (value: number): boolean => {
return isRange(value, 1, 65535)
}
export const isCreditCard = (value: string): boolean => {
const cleaned = value.replace(/\D/g, '')
if (cleaned.length < 13 || cleaned.length > 19) {
return false
}
let sum = 0
let isEven = false
for (let i = cleaned.length - 1; i >= 0; i--) {
let digit = parseInt(cleaned[i], 10)
if (isEven) {
digit *= 2
if (digit > 9) {
digit -= 9
}
}
sum += digit
isEven = !isEven
}
return sum % 10 === 0
}
export const isStrongPassword = (password: string): boolean => {
const minLength = 8
const hasUpperCase = /[A-Z]/.test(password)
const hasLowerCase = /[a-z]/.test(password)
const hasNumber = /\d/.test(password)
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password)
return (
password.length >= minLength &&
hasUpperCase &&
hasLowerCase &&
hasNumber &&
hasSpecialChar
)
}
export const isWeakPassword = (password: string): boolean => {
return !isStrongPassword(password)
}
export const validateUsername = (username: string): boolean => {
const usernameRegex = /^[a-zA-Z0-9_]{3,20}$/
return usernameRegex.test(username)
}
export const validateChineseName = (name: string): boolean => {
const chineseNameRegex = /^[\u4e00-\u9fa5]{2,10}$/
return chineseNameRegex.test(name)
}
export const validateIdCard = (idCard: string): boolean => {
const idCardRegex = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/
if (!idCardRegex.test(idCard)) {
return false
}
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
let sum = 0
for (let i = 0; i < 17; i++) {
sum += parseInt(idCard[i], 10) * weights[i]
}
const checkCode = checkCodes[sum % 11]
return checkCode === idCard[17].toUpperCase()
}
@@ -0,0 +1,180 @@
<template>
<div class="login-container">
<div class="login-box">
<div class="login-header">
<h1 class="login-title">{{ t('menu.systemManagement') }}</h1>
<p class="login-subtitle">{{ t('auth.login') }}</p>
</div>
<a-form
ref="formRef"
:model="formState"
:rules="formRules"
@finish="handleLogin"
class="login-form"
data-testid="login-form"
>
<a-form-item name="username">
<a-input
v-model:value="formState.username"
:placeholder="t('auth.usernamePlaceholder')"
data-testid="username-input"
size="large"
>
<template #prefix>
<UserOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item name="password">
<a-input-password
v-model:value="formState.password"
:placeholder="t('auth.passwordPlaceholder')"
data-testid="password-input"
size="large"
>
<template #prefix>
<LockOutlined />
</template>
</a-input-password>
</a-form-item>
<a-form-item>
<a-checkbox v-model:checked="formState.remember" data-testid="remember-checkbox">
{{ t('auth.rememberMe') }}
</a-checkbox>
</a-form-item>
<a-form-item>
<a-button
type="primary"
html-type="submit"
size="large"
block
:loading="loading"
data-testid="login-button"
>
{{ t('auth.login') }}
</a-button>
</a-form-item>
</a-form>
<div class="login-footer">
<p class="footer-text">
{{ t('auth.username') }}: admin / {{ t('auth.password') }}: admin123
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue';
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
import type { FormInstance } from 'ant-design-vue';
import { useAuthStore } from '@/stores/auth.store';
const router = useRouter();
const { t } = useI18n();
const authStore = useAuthStore();
const formRef = ref<FormInstance>();
const loading = ref(false);
const formState = reactive({
username: '',
password: '',
remember: false
});
const formRules = {
username: [
{ required: true, message: t('auth.usernameRequired'), trigger: 'blur' },
{ min: 3, max: 20, message: t('validation.minLength', { field: t('auth.username'), min: 3, max: 20 }), trigger: 'blur' }
],
password: [
{ required: true, message: t('auth.passwordRequired'), trigger: 'blur' },
{ min: 6, max: 20, message: t('validation.minLength', { field: t('auth.password'), min: 6, max: 20 }), trigger: 'blur' }
]
};
async function handleLogin() {
try {
loading.value = true;
await authStore.login({
username: formState.username,
password: formState.password
});
message.success(t('auth.loginSuccess'));
router.push('/');
} catch (error: any) {
message.error(error.message || t('auth.loginFailed'));
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-box {
width: 100%;
max-width: 400px;
padding: 48px 40px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-title {
font-size: 28px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.login-subtitle {
font-size: 14px;
color: #666;
margin: 0;
}
.login-form {
margin-top: 24px;
}
.login-footer {
margin-top: 24px;
text-align: center;
}
.footer-text {
font-size: 12px;
color: #999;
margin: 0;
}
:deep(.ant-input-affix-wrapper) {
padding: 8px 11px;
}
:deep(.ant-input-affix-wrapper-focused) {
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
</style>
@@ -0,0 +1,588 @@
<template>
<div class="menu-management">
<a-card class="search-card" :bordered="false">
<a-form layout="inline" :model="searchForm">
<a-form-item label="菜单名称">
<a-input
v-model:value="searchForm.name"
placeholder="请输入菜单名称"
allow-clear
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="菜单标识">
<a-input
v-model:value="searchForm.code"
placeholder="请输入菜单标识"
allow-clear
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="searchForm.status"
placeholder="请选择状态"
allow-clear
style="width: 120px"
>
<a-select-option value="active">启用</a-select-option>
<a-select-option value="inactive">禁用</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="handleSearch">
<template #icon>
<SearchOutlined />
</template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon>
<ReloadOutlined />
</template>
重置
</a-button>
<a-button type="primary" @click="handleExpandAll">
<template #icon>
<ExpandOutlined />
</template>
展开全部
</a-button>
<a-button @click="handleCollapseAll">
<template #icon>
<CompressOutlined />
</template>
折叠全部
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<a-card class="table-card" :bordered="false">
<template #title>
<a-space>
<span>菜单列表</span>
<a-tag color="blue"> {{ menuStore.menus.length }} </a-tag>
</a-space>
</template>
<template #extra>
<a-button type="primary" @click="handleAdd">
<template #icon>
<PlusOutlined />
</template>
新增菜单
</a-button>
</template>
<a-tree
v-model:expandedKeys="expandedKeys"
v-model:selectedKeys="selectedKeys"
:tree-data="filteredMenuTree"
:field-names="{ children: 'children', title: 'name', key: 'id' }"
:show-line="true"
:default-expand-all="false"
class="menu-tree"
>
<template #title="nodeData">
<div class="tree-node">
<span class="node-icon">
<component :is="nodeData.icon || 'MenuOutlined'" />
</span>
<span class="node-name">{{ nodeData.name }}</span>
<span class="node-code">({{ nodeData.code }})</span>
<a-tag :color="nodeData.status === 'active' ? 'success' : 'error'" class="node-status">
{{ nodeData.status === 'active' ? '启用' : '禁用' }}
</a-tag>
<a-tag v-if="nodeData.path" color="blue" class="node-path">
{{ nodeData.path }}
</a-tag>
<a-space class="node-actions">
<a-button type="link" size="small" @click.stop="handleView(nodeData)">
<template #icon>
<EyeOutlined />
</template>
查看
</a-button>
<a-button type="link" size="small" @click.stop="handleAddChild(nodeData)">
<template #icon>
<PlusOutlined />
</template>
新增子菜单
</a-button>
<a-button type="link" size="small" @click.stop="handleEdit(nodeData)">
<template #icon>
<EditOutlined />
</template>
编辑
</a-button>
<a-popconfirm
title="确定要删除该菜单吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDelete(nodeData.id)"
>
<a-button type="link" size="small" danger @click.stop>
<template #icon>
<DeleteOutlined />
</template>
删除
</a-button>
</a-popconfirm>
</a-space>
</div>
</template>
</a-tree>
</a-card>
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
width="600px"
@ok="handleModalOk"
@cancel="handleModalCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<a-form-item label="父级菜单" name="parentId">
<a-tree-select
v-model:value="formData.parentId"
:tree-data="parentMenuOptions"
placeholder="请选择父级菜单"
allow-clear
:field-names="{ children: 'children', label: 'name', value: 'id' }"
tree-default-expand-all
/>
</a-form-item>
<a-form-item label="菜单名称" name="name">
<a-input
v-model:value="formData.name"
placeholder="请输入菜单名称"
:disabled="isViewMode"
/>
</a-form-item>
<a-form-item label="菜单标识" name="code">
<a-input
v-model:value="formData.code"
placeholder="请输入菜单标识"
:disabled="isViewMode"
/>
</a-form-item>
<a-form-item label="路由路径" name="path">
<a-input
v-model:value="formData.path"
placeholder="请输入路由路径"
:disabled="isViewMode"
/>
</a-form-item>
<a-form-item label="菜单图标" name="icon">
<a-input
v-model:value="formData.icon"
placeholder="请输入菜单图标"
:disabled="isViewMode"
>
<template #prefix>
<component :is="formData.icon || 'MenuOutlined'" />
</template>
</a-input>
</a-form-item>
<a-form-item label="排序" name="sortOrder">
<a-input-number
v-model:value="formData.sortOrder"
:min="0"
:max="9999"
placeholder="请输入排序"
:disabled="isViewMode"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="状态" name="status">
<a-select
v-model:value="formData.status"
placeholder="请选择状态"
:disabled="isViewMode"
>
<a-select-option value="active">启用</a-select-option>
<a-select-option value="inactive">禁用</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
<a-drawer
v-model:open="drawerVisible"
title="菜单详情"
width="500px"
placement="right"
>
<a-descriptions :column="1" bordered>
<a-descriptions-item label="菜单ID">
{{ currentMenu?.id }}
</a-descriptions-item>
<a-descriptions-item label="菜单名称">
{{ currentMenu?.name }}
</a-descriptions-item>
<a-descriptions-item label="菜单标识">
{{ currentMenu?.code }}
</a-descriptions-item>
<a-descriptions-item label="路由路径">
{{ currentMenu?.path || '-' }}
</a-descriptions-item>
<a-descriptions-item label="菜单图标">
<span v-if="currentMenu?.icon">
<component :is="currentMenu.icon" />
{{ currentMenu.icon }}
</span>
<span v-else>-</span>
</a-descriptions-item>
<a-descriptions-item label="父级菜单">
{{ getParentMenuName(currentMenu?.parentId) }}
</a-descriptions-item>
<a-descriptions-item label="排序">
{{ currentMenu?.sortOrder }}
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="currentMenu?.status === 'active' ? 'success' : 'error'">
{{ currentMenu?.status === 'active' ? '启用' : '禁用' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ currentMenu?.createdAt }}
</a-descriptions-item>
<a-descriptions-item label="更新时间">
{{ currentMenu?.updatedAt }}
</a-descriptions-item>
</a-descriptions>
<template #extra>
<a-space>
<a-button type="primary" @click="handleEditFromDrawer">
<template #icon>
<EditOutlined />
</template>
编辑
</a-button>
<a-button @click="drawerVisible = false">关闭</a-button>
</a-space>
</template>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, reactive } from 'vue';
import { message } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
EditOutlined,
DeleteOutlined,
EyeOutlined,
ExpandOutlined,
CompressOutlined,
MenuOutlined
} from '@ant-design/icons-vue';
import { useMenuStore } from '../stores/menu.store';
import type { Menu, CreateMenuRequest, UpdateMenuRequest } from '../types';
const menuStore = useMenuStore();
const searchForm = reactive({
name: '',
code: '',
status: undefined as string | undefined
});
const expandedKeys = ref<number[]>([]);
const selectedKeys = ref<number[]>([]);
const modalVisible = ref(false);
const modalLoading = ref(false);
const modalTitle = ref('新增菜单');
const drawerVisible = ref(false);
const currentMenu = ref<Menu | null>(null);
const formRef = ref();
const formData = reactive<CreateMenuRequest>({
name: '',
code: '',
path: '',
icon: '',
parentId: 0,
sortOrder: 0,
status: 'active'
});
const formRules = {
name: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入菜单标识', trigger: 'blur' }],
path: [{ required: true, message: '请输入路由路径', trigger: 'blur' }],
sortOrder: [{ required: true, message: '请输入排序', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
};
const isViewMode = computed(() => modalTitle.value === '查看菜单');
const filteredMenuTree = computed(() => {
const filterTree = (nodes: Menu[]): Menu[] => {
return nodes
.filter(node => {
const matchName = !searchForm.name || node.name.includes(searchForm.name);
const matchCode = !searchForm.code || node.code.includes(searchForm.code);
const matchStatus = !searchForm.status || node.status === searchForm.status;
return matchName && matchCode && matchStatus;
})
.map(node => ({
...node,
children: node.children ? filterTree(node.children) : undefined
}));
};
return filterTree(menuStore.menuTree);
});
const parentMenuOptions = computed(() => {
const buildOptions = (nodes: Menu[]): any[] => {
return nodes.map(node => ({
id: node.id,
name: node.name,
children: node.children ? buildOptions(node.children) : undefined
}));
};
return buildOptions(menuStore.menuTree);
});
function getParentMenuName(parentId: number | undefined): string {
if (!parentId || parentId === 0) return '根菜单';
const parent = menuStore.getMenuById(parentId);
return parent ? parent.name : '-';
}
async function loadMenus() {
const success = await menuStore.fetchMenus();
if (success) {
message.success('菜单列表加载成功');
} else {
message.error(menuStore.error || '菜单列表加载失败');
}
}
function handleSearch() {
expandedKeys.value = [];
}
function handleReset() {
searchForm.name = '';
searchForm.code = '';
searchForm.status = undefined;
expandedKeys.value = [];
}
function handleExpandAll() {
const getAllKeys = (nodes: Menu[]): number[] => {
const keys: number[] = [];
nodes.forEach(node => {
keys.push(node.id);
if (node.children) {
keys.push(...getAllKeys(node.children));
}
});
return keys;
};
expandedKeys.value = getAllKeys(menuStore.menuTree);
}
function handleCollapseAll() {
expandedKeys.value = [];
}
function handleAdd() {
modalTitle.value = '新增菜单';
resetFormData();
modalVisible.value = true;
}
function handleAddChild(parentMenu: Menu) {
modalTitle.value = '新增子菜单';
resetFormData();
formData.parentId = parentMenu.id;
formData.sortOrder = (parentMenu.children?.length || 0) * 10;
modalVisible.value = true;
}
function handleEdit(menu: Menu) {
modalTitle.value = '编辑菜单';
resetFormData();
Object.assign(formData, {
id: menu.id,
name: menu.name,
code: menu.code,
path: menu.path,
icon: menu.icon,
parentId: menu.parentId,
sortOrder: menu.sortOrder,
status: menu.status
});
modalVisible.value = true;
}
function handleView(menu: Menu) {
currentMenu.value = menu;
drawerVisible.value = true;
}
async function handleDelete(id: number) {
const success = await menuStore.deleteMenu(id);
if (success) {
message.success('删除成功');
} else {
message.error(menuStore.error || '删除失败');
}
}
function handleEditFromDrawer() {
if (currentMenu.value) {
handleEdit(currentMenu.value);
drawerVisible.value = false;
}
}
async function handleModalOk() {
try {
await formRef.value.validate();
modalLoading.value = true;
let success = false;
if (modalTitle.value === '新增菜单' || modalTitle.value === '新增子菜单') {
success = await menuStore.createMenu(formData);
} else if (modalTitle.value === '编辑菜单') {
const updateData: UpdateMenuRequest = {
id: (formData as any).id,
name: formData.name,
code: formData.code,
path: formData.path,
icon: formData.icon,
parentId: formData.parentId,
sortOrder: formData.sortOrder,
status: formData.status
};
success = await menuStore.updateMenu(updateData);
}
if (success) {
message.success('操作成功');
modalVisible.value = false;
await loadMenus();
} else {
message.error(menuStore.error || '操作失败');
}
} catch (error) {
console.error('表单验证失败:', error);
} finally {
modalLoading.value = false;
}
}
function handleModalCancel() {
modalVisible.value = false;
resetFormData();
}
function resetFormData() {
Object.assign(formData, {
name: '',
code: '',
path: '',
icon: '',
parentId: 0,
sortOrder: 0,
status: 'active'
});
formRef.value?.clearValidate();
}
onMounted(() => {
loadMenus();
});
</script>
<style scoped>
.menu-management {
padding: 24px;
background: #f0f2f5;
min-height: 100vh;
}
.search-card {
margin-bottom: 16px;
}
.table-card {
min-height: 600px;
}
.menu-tree {
background: #fff;
padding: 16px;
border-radius: 4px;
}
.tree-node {
display: flex;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.3s;
}
.tree-node:hover {
background-color: #f5f5f5;
}
.node-icon {
margin-right: 8px;
color: #1890ff;
}
.node-name {
font-weight: 500;
margin-right: 8px;
}
.node-code {
color: #8c8c8c;
font-size: 12px;
margin-right: 8px;
}
.node-status {
margin-right: 8px;
}
.node-path {
margin-right: 16px;
}
.node-actions {
margin-left: auto;
opacity: 0;
transition: opacity 0.3s;
}
.tree-node:hover .node-actions {
opacity: 1;
}
</style>
@@ -0,0 +1,46 @@
<template>
<div class="not-found">
<a-result
status="404"
title="404"
sub-title="抱歉您访问的页面不存在"
>
<template #extra>
<a-button type="primary" @click="goHome">
返回首页
</a-button>
<a-button @click="goBack">
返回上一页
</a-button>
</template>
</a-result>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
function goHome() {
router.push('/');
}
function goBack() {
router.back();
}
</script>
<style scoped>
.not-found {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 64px);
background: #f0f2f5;
}
:deep(.ant-result) {
padding: 48px 24px;
}
</style>
@@ -0,0 +1,455 @@
<template>
<div class="operation-log-management">
<a-card class="search-card" :bordered="false">
<a-form layout="inline" :model="searchForm">
<a-form-item label="用户名">
<a-input
v-model:value="searchForm.username"
placeholder="请输入用户名"
allow-clear
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="模块">
<a-input
v-model:value="searchForm.module"
placeholder="请输入模块"
allow-clear
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="操作">
<a-input
v-model:value="searchForm.operation"
placeholder="请输入操作"
allow-clear
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="searchForm.status"
placeholder="请选择状态"
allow-clear
style="width: 120px"
>
<a-select-option value="success">成功</a-select-option>
<a-select-option value="error">失败</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="时间范围">
<a-range-picker
v-model:value="dateRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
:placeholder="['开始时间', '结束时间']"
@change="handleDateRangeChange"
/>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="handleSearch">
<template #icon>
<SearchOutlined />
</template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon>
<ReloadOutlined />
</template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<a-card class="table-card" :bordered="false">
<template #title>
<a-space>
<span>操作日志列表</span>
<a-tag color="blue"> {{ pagination.total }} </a-tag>
</a-space>
</template>
<a-table
:columns="columns"
:data-source="dataSource"
:pagination="pagination"
:loading="loading"
:scroll="{ x: 1800 }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 'success' ? 'success' : 'error'">
{{ record.status === 'success' ? '成功' : '失败' }}
</a-tag>
</template>
<template v-else-if="column.key === 'method'">
<a-tag :color="getMethodColor(record.method)">
{{ record.method }}
</a-tag>
</template>
<template v-else-if="column.key === 'duration'">
<a-tag :color="getDurationColor(record.duration)">
{{ record.duration }}ms
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleView(record)">
<template #icon>
<EyeOutlined />
</template>
查看
</a-button>
<a-popconfirm
title="确定要删除该日志吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDelete(record.id)"
>
<a-button type="link" size="small" danger>
<template #icon>
<DeleteOutlined />
</template>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<a-drawer
v-model:open="drawerVisible"
title="操作日志详情"
width="600px"
placement="right"
>
<a-descriptions :column="1" bordered>
<a-descriptions-item label="日志ID">
{{ currentLog?.id }}
</a-descriptions-item>
<a-descriptions-item label="用户ID">
{{ currentLog?.userId }}
</a-descriptions-item>
<a-descriptions-item label="用户名">
{{ currentLog?.username }}
</a-descriptions-item>
<a-descriptions-item label="模块">
{{ currentLog?.module }}
</a-descriptions-item>
<a-descriptions-item label="操作">
{{ currentLog?.operation }}
</a-descriptions-item>
<a-descriptions-item label="请求方法">
<a-tag :color="getMethodColor(currentLog?.method || '')">
{{ currentLog?.method }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="请求路径">
{{ currentLog?.path }}
</a-descriptions-item>
<a-descriptions-item label="请求参数">
<pre class="json-display">{{ formatJson(currentLog?.params) }}</pre>
</a-descriptions-item>
<a-descriptions-item label="IP地址">
{{ currentLog?.ip }}
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="currentLog?.status === 'success' ? 'success' : 'error'">
{{ currentLog?.status === 'success' ? '成功' : '失败' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="currentLog?.errorMsg" label="错误信息">
<pre class="error-display">{{ currentLog.errorMsg }}</pre>
</a-descriptions-item>
<a-descriptions-item label="耗时">
<a-tag :color="getDurationColor(currentLog?.duration || 0)">
{{ currentLog?.duration }}ms
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ currentLog?.createdAt }}
</a-descriptions-item>
</a-descriptions>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import type { TableProps } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
EyeOutlined,
DeleteOutlined
} from '@ant-design/icons-vue';
import { useOperationLogStore } from '@/stores/operationLog.store';
import type { OperationLog, OperationLogQueryParams } from '@/types';
const operationLogStore = useOperationLogStore();
const columns = [
{
title: '日志ID',
dataIndex: 'id',
key: 'id',
width: 100
},
{
title: '用户名',
dataIndex: 'username',
key: 'username',
width: 120
},
{
title: '模块',
dataIndex: 'module',
key: 'module',
width: 120
},
{
title: '操作',
dataIndex: 'operation',
key: 'operation',
width: 150
},
{
title: '方法',
key: 'method',
width: 80,
align: 'center'
},
{
title: '路径',
dataIndex: 'path',
key: 'path',
width: 250
},
{
title: 'IP地址',
dataIndex: 'ip',
key: 'ip',
width: 130
},
{
title: '状态',
key: 'status',
width: 80,
align: 'center'
},
{
title: '耗时',
key: 'duration',
width: 100,
align: 'center'
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180
},
{
title: '操作',
key: 'action',
width: 150,
fixed: 'right'
}
];
const searchForm = reactive<OperationLogQueryParams>({
username: '',
module: '',
operation: '',
status: undefined,
startTime: '',
endTime: '',
page: 1,
pageSize: 10
});
const dateRange = ref<[string, string] | null>(null);
const dataSource = ref<OperationLog[]>([]);
const loading = ref(false);
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showTotal: (total: number) => `${total}`,
pageSizeOptions: ['10', '20', '50', '100']
});
const drawerVisible = ref(false);
const currentLog = ref<OperationLog | null>(null);
function getMethodColor(method: string): string {
const colors: Record<string, string> = {
'GET': 'green',
'POST': 'blue',
'PUT': 'orange',
'DELETE': 'red',
'PATCH': 'purple'
};
return colors[method] || 'default';
}
function getDurationColor(duration: number): string {
if (duration < 100) return 'green';
if (duration < 500) return 'orange';
return 'red';
}
function formatJson(jsonString: string | undefined): string {
if (!jsonString) return '-';
try {
const parsed = JSON.parse(jsonString);
return JSON.stringify(parsed, null, 2);
} catch {
return jsonString;
}
}
function handleDateRangeChange(dates: [string, string] | null) {
if (dates && dates.length === 2) {
searchForm.startTime = dates[0];
searchForm.endTime = dates[1];
} else {
searchForm.startTime = '';
searchForm.endTime = '';
}
}
async function fetchOperationLogs() {
loading.value = true;
try {
const success = await operationLogStore.fetchOperationLogs({
...searchForm,
page: searchForm.page,
pageSize: searchForm.pageSize
});
if (success) {
dataSource.value = operationLogStore.operationLogs;
pagination.total = operationLogStore.total;
pagination.current = searchForm.page || 1;
pagination.pageSize = searchForm.pageSize || 10;
} else {
message.error('获取操作日志列表失败');
}
} catch (error) {
message.error('获取操作日志列表失败');
} finally {
loading.value = false;
}
}
function handleSearch() {
searchForm.page = 1;
fetchOperationLogs();
}
function handleReset() {
Object.assign(searchForm, {
username: '',
module: '',
operation: '',
status: undefined,
startTime: '',
endTime: '',
page: 1,
pageSize: 10
});
dateRange.value = null;
fetchOperationLogs();
}
const handleTableChange: TableProps['onChange'] = (pag) => {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchOperationLogs();
};
function handleView(record: OperationLog) {
currentLog.value = record;
drawerVisible.value = true;
}
async function handleDelete(id: number) {
try {
const success = await operationLogStore.deleteOperationLog(id);
if (success) {
message.success('删除操作日志成功');
fetchOperationLogs();
} else {
message.error(operationLogStore.error || '删除操作日志失败');
}
} catch (error) {
message.error('删除操作日志失败');
}
}
onMounted(() => {
fetchOperationLogs();
});
</script>
<style scoped>
.operation-log-management {
padding: 24px;
background: #f0f2f5;
min-height: calc(100vh - 64px);
}
.search-card {
margin-bottom: 16px;
}
.table-card {
background: #fff;
}
:deep(.ant-table-thead > tr > th) {
background: #fafafa;
font-weight: 600;
}
:deep(.ant-card-head) {
border-bottom: 1px solid #f0f0f0;
}
:deep(.ant-card-body) {
padding: 24px;
}
.json-display {
margin: 0;
padding: 8px;
background: #f5f5f5;
border-radius: 4px;
font-size: 12px;
max-height: 200px;
overflow-y: auto;
}
.error-display {
margin: 0;
padding: 8px;
background: #fff2f0;
border: 1px solid #ffccc7;
border-radius: 4px;
font-size: 12px;
color: #ff4d4f;
max-height: 200px;
overflow-y: auto;
}
</style>
@@ -0,0 +1,476 @@
<template>
<div class="permission-management">
<a-card class="search-card" :bordered="false">
<a-form layout="inline" :model="searchForm">
<a-form-item label="角色">
<a-select
v-model:value="searchForm.roleId"
placeholder="请选择角色"
allow-clear
style="width: 200px"
show-search
:filter-option="filterRoleOption"
>
<a-select-option v-for="role in roleStore.roles" :key="role.id" :value="role.id">
{{ role.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="handleSearch">
<template #icon>
<SearchOutlined />
</template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon>
<ReloadOutlined />
</template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<a-row :gutter="16">
<a-col :span="8">
<a-card class="role-card" :bordered="false" title="角色列表">
<template #extra>
<a-tag color="blue"> {{ roleStore.roles.length }} </a-tag>
</template>
<a-list
:data-source="roleStore.roles"
:loading="roleStore.loading"
class="role-list"
>
<template #renderItem="{ item }">
<a-list-item
:class="{ 'active': selectedRoleId === item.id }"
@click="handleRoleSelect(item)"
>
<a-list-item-meta>
<template #avatar>
<a-avatar :style="{ backgroundColor: item.status === 0 || item.status === '0' ? '#1890ff' : '#d9d9d9' }">
<SafetyOutlined />
</a-avatar>
</template>
<template #title>
<a-space>
<span>{{ item.name }}</span>
<a-tag :color="item.status === 0 || item.status === '0' ? 'success' : 'error'" size="small">
{{ item.status === 0 || item.status === '0' ? '启用' : '禁用' }}
</a-tag>
</a-space>
</template>
<template #description>
{{ item.roleKey }}
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
<a-col :span="16">
<a-card class="permission-card" :bordered="false">
<template #title>
<a-space>
<span>权限分配</span>
<a-tag v-if="selectedRole" color="blue">{{ selectedRole.name }}</a-tag>
</a-space>
</template>
<template #extra>
<a-button
type="primary"
:disabled="!selectedRoleId"
:loading="saving"
@click="handleSavePermissions"
>
<template #icon>
<SaveOutlined />
</template>
保存权限
</a-button>
</template>
<div v-if="!selectedRoleId" class="empty-state">
<a-empty description="请选择角色以查看和分配权限" />
</div>
<div v-else class="permission-tree-container">
<a-alert
message="提示"
description="勾选菜单项即为分配该权限,子菜单会自动继承父菜单的权限"
type="info"
show-icon
style="margin-bottom: 16px"
/>
<a-tree
v-model:checkedKeys="checkedKeys"
checkable
:tree-data="menuTreeWithCheck"
:field-names="{ children: 'children', title: 'name', key: 'id' }"
:check-strictly="false"
:default-expand-all="true"
class="permission-tree"
>
<template #title="nodeData">
<div class="tree-node">
<span class="node-icon">
<component :is="nodeData.icon || 'MenuOutlined'" />
</span>
<span class="node-name">{{ nodeData.name }}</span>
<span class="node-code">({{ nodeData.code }})</span>
<a-tag v-if="nodeData.path" color="blue" class="node-path">
{{ nodeData.path }}
</a-tag>
</div>
</template>
</a-tree>
</div>
</a-card>
</a-col>
</a-row>
<a-modal
v-model:open="permissionHistoryVisible"
title="权限分配历史"
width="800px"
:footer="null"
>
<a-timeline>
<a-timeline-item
v-for="item in permissionHistory"
:key="item.id"
:color="item.type === 'assign' ? 'blue' : 'red'"
>
<template #dot>
<component :is="item.type === 'assign' ? 'CheckCircleOutlined' : 'CloseCircleOutlined'" />
</template>
<div class="history-item">
<div class="history-header">
<span class="history-role">{{ item.roleName }}</span>
<span class="history-time">{{ item.time }}</span>
</div>
<div class="history-content">
{{ item.type === 'assign' ? '分配权限' : '移除权限' }}:
<a-tag v-for="menu in item.menus" :key="menu.id" color="blue">
{{ menu.name }}
</a-tag>
</div>
<div class="history-operator">
操作人: {{ item.operator }}
</div>
</div>
</a-timeline-item>
</a-timeline>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, reactive } from 'vue';
import { message } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
SaveOutlined,
SafetyOutlined,
MenuOutlined,
CheckCircleOutlined,
CloseCircleOutlined
} from '@ant-design/icons-vue';
import { useRoleStore } from '../stores/role.store';
import { useMenuStore } from '../stores/menu.store';
import type { Role, Menu, AssignMenuToRoleRequest } from '../types';
const roleStore = useRoleStore();
const menuStore = useMenuStore();
const searchForm = reactive({
roleId: undefined as number | undefined
});
const selectedRoleId = ref<number | null>(null);
const selectedRole = ref<Role | null>(null);
const checkedKeys = ref<number[]>([]);
const saving = ref(false);
const permissionHistoryVisible = ref(false);
interface PermissionHistoryItem {
id: number;
roleName: string;
type: 'assign' | 'remove';
menus: Menu[];
time: string;
operator: string;
}
const permissionHistory = ref<PermissionHistoryItem[]>([
{
id: 1,
roleName: '管理员',
type: 'assign',
menus: [
{ id: 1, name: '用户管理', code: 'user', path: '/user', icon: 'UserOutlined', parentId: 0, sortOrder: 1, status: 'active', createdAt: '', updatedAt: '' },
{ id: 2, name: '角色管理', code: 'role', path: '/role', icon: 'SafetyOutlined', parentId: 0, sortOrder: 2, status: 'active', createdAt: '', updatedAt: '' }
],
time: '2024-01-10 10:30:00',
operator: 'admin'
},
{
id: 2,
roleName: '普通用户',
type: 'remove',
menus: [
{ id: 3, name: '系统设置', code: 'system', path: '/system', icon: 'SettingOutlined', parentId: 0, sortOrder: 3, status: 'active', createdAt: '', updatedAt: '' }
],
time: '2024-01-09 15:45:00',
operator: 'admin'
}
]);
const menuTreeWithCheck = computed(() => {
const buildTreeWithCheck = (nodes: Menu[]): Menu[] => {
return nodes.map(node => ({
...node,
children: node.children ? buildTreeWithCheck(node.children) : undefined,
disabled: node.status === 'inactive'
}));
};
return buildTreeWithCheck(menuStore.menuTree);
});
function filterRoleOption(input: string, option: any) {
return option.children[0].children.toLowerCase().includes(input.toLowerCase());
}
async function loadRoles() {
const success = await roleStore.fetchRoles();
if (!success) {
message.error(roleStore.error || '角色列表加载失败');
}
}
async function loadMenus() {
const success = await menuStore.fetchMenus();
if (!success) {
message.error(menuStore.error || '菜单列表加载失败');
}
}
async function handleRoleSelect(role: Role) {
selectedRoleId.value = role.id;
selectedRole.value = role;
checkedKeys.value = [];
const menus = await menuStore.fetchMenusByRole(role.id);
if (menus) {
checkedKeys.value = menus.map(m => m.id);
}
}
async function handleSearch() {
if (searchForm.roleId) {
const role = roleStore.getRoleById(searchForm.roleId);
if (role) {
handleRoleSelect(role);
}
}
}
function handleReset() {
searchForm.roleId = undefined;
selectedRoleId.value = null;
selectedRole.value = null;
checkedKeys.value = [];
}
async function handleSavePermissions() {
if (!selectedRoleId.value) {
message.warning('请先选择角色');
return;
}
saving.value = true;
try {
const data: AssignMenuToRoleRequest = {
roleId: selectedRoleId.value,
menuIds: checkedKeys.value
};
const success = await menuStore.assignMenusToRole(data);
if (success) {
message.success('权限分配成功');
const historyItem: PermissionHistoryItem = {
id: Date.now(),
roleName: selectedRole.value?.name || '',
type: 'assign',
menus: checkedKeys.value.map(id => menuStore.getMenuById(id)).filter(Boolean) as Menu[],
time: new Date().toLocaleString('zh-CN'),
operator: 'admin'
};
permissionHistory.value.unshift(historyItem);
} else {
message.error(menuStore.error || '权限分配失败');
}
} catch (error) {
console.error('保存权限失败:', error);
message.error('保存权限失败');
} finally {
saving.value = false;
}
}
onMounted(async () => {
await Promise.all([loadRoles(), loadMenus()]);
});
</script>
<style scoped>
.permission-management {
padding: 24px;
background: #f0f2f5;
min-height: 100vh;
}
.search-card {
margin-bottom: 16px;
}
.role-card {
height: calc(100vh - 200px);
overflow: hidden;
display: flex;
flex-direction: column;
}
.role-card :deep(.ant-card-body) {
flex: 1;
overflow-y: auto;
}
.role-list {
height: 100%;
overflow-y: auto;
}
.role-list .ant-list-item {
cursor: pointer;
border-radius: 4px;
transition: all 0.3s;
padding: 12px 16px;
}
.role-list .ant-list-item:hover {
background-color: #f5f5f5;
}
.role-list .ant-list-item.active {
background-color: #e6f7ff;
border-left: 3px solid #1890ff;
}
.permission-card {
height: calc(100vh - 200px);
overflow: hidden;
display: flex;
flex-direction: column;
}
.permission-card :deep(.ant-card-body) {
flex: 1;
overflow-y: auto;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 300px;
}
.permission-tree-container {
padding: 16px 0;
}
.permission-tree {
background: #fff;
padding: 16px;
border-radius: 4px;
border: 1px solid #f0f0f0;
}
.tree-node {
display: flex;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.3s;
}
.tree-node:hover {
background-color: #f5f5f5;
}
.node-icon {
margin-right: 8px;
color: #1890ff;
}
.node-name {
font-weight: 500;
margin-right: 8px;
}
.node-code {
color: #8c8c8c;
font-size: 12px;
margin-right: 8px;
}
.node-path {
margin-right: 16px;
}
.history-item {
padding: 8px 0;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.history-role {
font-weight: 500;
color: #1890ff;
}
.history-time {
color: #8c8c8c;
font-size: 12px;
}
.history-content {
margin-bottom: 8px;
color: #595959;
}
.history-operator {
color: #8c8c8c;
font-size: 12px;
}
</style>
@@ -0,0 +1,601 @@
<template>
<div class="role-management">
<a-card class="search-card" :bordered="false">
<a-form layout="inline" :model="searchForm">
<a-form-item label="角色名称">
<a-input
v-model:value="searchForm.name"
placeholder="请输入角色名称"
allow-clear
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="角色标识">
<a-input
v-model:value="searchForm.roleKey"
placeholder="请输入角色标识"
allow-clear
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="searchForm.status"
placeholder="请选择状态"
allow-clear
style="width: 120px"
>
<a-select-option value="1">启用</a-select-option>
<a-select-option value="0">禁用</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="handleSearch">
<template #icon>
<SearchOutlined />
</template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon>
<ReloadOutlined />
</template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<a-card class="table-card" :bordered="false">
<template #title>
<a-space>
<span>角色列表</span>
<a-tag color="blue"> {{ pagination.total }} </a-tag>
</a-space>
</template>
<template #extra>
<a-button type="primary" @click="handleAdd">
<template #icon>
<PlusOutlined />
</template>
新增角色
</a-button>
</template>
<a-table
:columns="columns"
:data-source="dataSource"
:pagination="pagination"
:loading="loading"
:scroll="{ x: 1000 }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 0 || record.status === '0' ? 'success' : 'error'">
{{ record.status === 0 || record.status === '0' ? '启用' : '禁用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleView(record)">
<template #icon>
<EyeOutlined />
</template>
查看
</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">
<template #icon>
<EditOutlined />
</template>
编辑
</a-button>
<a-button type="link" size="small" @click="handleAssignPermissions(record)">
<template #icon>
<SafetyOutlined />
</template>
分配权限
</a-button>
<a-popconfirm
title="确定要删除该角色吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDelete(record.id)"
>
<a-button type="link" size="small" danger>
<template #icon>
<DeleteOutlined />
</template>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
width="600px"
@ok="handleModalOk"
@cancel="handleModalCancel"
>
<a-form
ref="formRef"
:model="formState"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="角色名称" name="name">
<a-input
v-model:value="formState.name"
placeholder="请输入角色名称"
/>
</a-form-item>
<a-form-item label="角色标识" name="roleKey">
<a-input
v-model:value="formState.roleKey"
placeholder="请输入角色标识"
:disabled="isEdit"
/>
</a-form-item>
<a-form-item label="排序" name="sortOrder">
<a-input-number
v-model:value="formState.sortOrder"
:min="0"
:max="999"
placeholder="请输入排序"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formState.status">
<a-radio :value="1">启用</a-radio>
<a-radio :value="0">禁用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="描述" name="description">
<a-textarea
v-model:value="formState.description"
placeholder="请输入描述"
:rows="3"
/>
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea
v-model:value="formState.remark"
placeholder="请输入备注"
:rows="2"
/>
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="permissionModalVisible"
title="分配权限"
:confirm-loading="permissionModalLoading"
width="700px"
@ok="handlePermissionModalOk"
@cancel="handlePermissionModalCancel"
>
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 18 }">
<a-form-item label="角色">
<span>{{ currentRole?.name }}</span>
</a-form-item>
<a-form-item label="权限">
<a-tree
v-model:checkedKeys="checkedPermissionKeys"
checkable
:tree-data="permissionTreeData"
:field-names="{ children: 'children', title: 'name', key: 'id' }"
:check-strictly="false"
:default-expand-all="true"
/>
</a-form-item>
</a-form>
</a-modal>
<a-drawer
v-model:open="drawerVisible"
title="角色详情"
width="500px"
placement="right"
>
<a-descriptions :column="1" bordered>
<a-descriptions-item label="角色名称">
{{ currentRole?.name }}
</a-descriptions-item>
<a-descriptions-item label="角色标识">
{{ currentRole?.roleKey }}
</a-descriptions-item>
<a-descriptions-item label="排序">
{{ currentRole?.sortOrder }}
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="currentRole?.status === 'active' ? 'success' : 'error'">
{{ currentRole?.status === 'active' ? '启用' : '禁用' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="描述">
{{ currentRole?.description || '-' }}
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ currentRole?.createdAt }}
</a-descriptions-item>
<a-descriptions-item label="更新时间">
{{ currentRole?.updatedAt }}
</a-descriptions-item>
<a-descriptions-item label="备注">
{{ currentRole?.remark || '-' }}
</a-descriptions-item>
</a-descriptions>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import type { TableProps, FormInstance } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
EyeOutlined,
EditOutlined,
DeleteOutlined,
SafetyOutlined
} from '@ant-design/icons-vue';
import { useRoleStore } from '@/stores/role.store';
import { useMenuStore } from '@/stores/menu.store';
import type { Role, RoleQueryParams, Menu } from '@/types';
const roleStore = useRoleStore();
const menuStore = useMenuStore();
const columns = [
{
title: '角色名称',
dataIndex: 'name',
key: 'name',
width: 150
},
{
title: '角色标识',
dataIndex: 'roleKey',
key: 'roleKey',
width: 150
},
{
title: '排序',
dataIndex: 'sortOrder',
key: 'sortOrder',
width: 80,
align: 'center'
},
{
title: '状态',
key: 'status',
width: 80,
align: 'center'
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
width: 200
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
width: 180
},
{
title: '操作',
key: 'action',
width: 280,
fixed: 'right'
}
];
const searchForm = reactive<RoleQueryParams>({
name: '',
roleKey: '',
status: undefined,
page: 1,
pageSize: 10
});
const dataSource = ref<Role[]>([]);
const loading = ref(false);
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showTotal: (total: number) => `${total}`,
pageSizeOptions: ['10', '20', '50', '100']
});
const modalVisible = ref(false);
const modalLoading = ref(false);
const modalTitle = computed(() => isEdit.value ? '编辑角色' : '新增角色');
const isEdit = ref(false);
const formRef = ref<FormInstance>();
const formState = reactive({
id: '',
name: '',
roleKey: '',
sortOrder: 0,
status: 1,
description: '',
remark: ''
});
const formRules = {
name: [
{ required: true, message: '请输入角色名称', trigger: 'blur' },
{ min: 2, max: 50, message: '角色名称长度在 2 到 50 个字符', trigger: 'blur' }
],
roleKey: [
{ required: true, message: '请输入角色标识', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: '角色标识只能包含字母、数字、下划线和连字符', trigger: 'blur' }
],
sortOrder: [
{ required: true, message: '请输入排序', trigger: 'blur' }
]
};
const drawerVisible = ref(false);
const currentRole = ref<Role | null>(null);
const permissionModalVisible = ref(false);
const permissionModalLoading = ref(false);
const checkedPermissionKeys = ref<(string | number)[]>([]);
const permissionTreeData = ref<Menu[]>([]);
async function fetchRoles() {
loading.value = true;
try {
const success = await roleStore.fetchRoles();
if (success) {
// 过滤数据以匹配搜索条件
let filteredRoles = roleStore.roles;
if (searchForm.name) {
filteredRoles = filteredRoles.filter(role => role.name.includes(searchForm.name!));
}
if (searchForm.roleKey) {
filteredRoles = filteredRoles.filter(role => role.roleKey.includes(searchForm.roleKey!));
}
if (searchForm.status) {
filteredRoles = filteredRoles.filter(role => role.status === searchForm.status);
}
dataSource.value = filteredRoles;
pagination.total = filteredRoles.length;
pagination.current = searchForm.page || 1;
pagination.pageSize = searchForm.pageSize || 10;
} else {
message.error('获取角色列表失败');
}
} catch (error) {
message.error('获取角色列表失败');
} finally {
loading.value = false;
}
}
async function fetchMenus() {
try {
const success = await menuStore.fetchMenus();
if (success) {
permissionTreeData.value = menuStore.menuTree;
} else {
console.error('获取菜单列表失败:', menuStore.error);
}
} catch (error) {
console.error('获取菜单列表失败:', error);
}
}
function handleSearch() {
searchForm.page = 1;
fetchRoles();
}
function handleReset() {
Object.assign(searchForm, {
name: '',
roleKey: '',
status: undefined,
page: 1,
pageSize: 10
});
fetchRoles();
}
const handleTableChange: TableProps['onChange'] = (pag) => {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchRoles();
};
function handleAdd() {
isEdit.value = false;
Object.assign(formState, {
id: '',
name: '',
roleKey: '',
sortOrder: 0,
status: 1,
description: '',
remark: ''
});
modalVisible.value = true;
}
function handleEdit(record: Role) {
isEdit.value = true;
Object.assign(formState, {
id: record.id.toString(),
name: record.name,
roleKey: record.roleKey,
sortOrder: record.sortOrder || 0,
status: typeof record.status === 'string' ? (record.status === 'active' ? 1 : 0) : record.status,
description: record.description || '',
remark: record.remark || ''
});
modalVisible.value = true;
}
function handleView(record: Role) {
currentRole.value = record;
drawerVisible.value = true;
}
async function handleModalOk() {
try {
await formRef.value?.validate();
modalLoading.value = true;
if (isEdit.value) {
const updateData = {
id: parseInt(formState.id),
name: formState.name,
roleKey: formState.roleKey, // 需要包含 roleKey
sortOrder: formState.sortOrder,
status: formState.status.toString(), // 转换为字符串
description: formState.description,
remark: formState.remark
};
const success = await roleStore.updateRole(updateData);
if (success) {
message.success('更新角色成功');
} else {
message.error(roleStore.error || '更新角色失败');
throw false; // Prevent modal close
}
} else {
const createData = {
name: formState.name,
roleKey: formState.roleKey,
sortOrder: formState.sortOrder,
status: formState.status.toString(), // 转换为字符串
description: formState.description,
remark: formState.remark
};
const success = await roleStore.createRole(createData);
if (success) {
message.success('创建角色成功');
} else {
message.error(roleStore.error || '创建角色失败');
throw false; // Prevent modal close
}
}
modalVisible.value = false;
fetchRoles();
} catch (error) {
if (error !== false) {
message.error(isEdit.value ? '更新角色失败' : '创建角色失败');
}
} finally {
modalLoading.value = false;
}
}
function handleModalCancel() {
formRef.value?.resetFields();
modalVisible.value = false;
}
async function handleDelete(id: number) {
try {
const success = await roleStore.deleteRole(id);
if (success) {
message.success('删除角色成功');
fetchRoles();
} else {
message.error(roleStore.error || '删除角色失败');
}
} catch (error) {
message.error('删除角色失败');
}
}
async function handleAssignPermissions(record: Role) {
currentRole.value = record;
checkedPermissionKeys.value = record.menuIds || [];
permissionModalVisible.value = true;
}
async function handlePermissionModalOk() {
try {
permissionModalLoading.value = true;
const data = {
roleId: currentRole.value!.id,
menuIds: checkedPermissionKeys.value as number[]
};
const success = await menuStore.assignMenusToRole(data);
if (success) {
message.success('分配权限成功');
permissionModalVisible.value = false;
fetchRoles();
} else {
message.error(menuStore.error || '分配权限失败');
}
} catch (error) {
message.error('分配权限失败');
} finally {
permissionModalLoading.value = false;
}
}
function handlePermissionModalCancel() {
permissionModalVisible.value = false;
}
onMounted(() => {
fetchRoles();
fetchMenus();
});
</script>
<style scoped>
.role-management {
padding: 24px;
background: #f0f2f5;
min-height: calc(100vh - 64px);
}
.search-card {
margin-bottom: 16px;
}
.table-card {
background: #fff;
}
:deep(.ant-table-thead > tr > th) {
background: #fafafa;
font-weight: 600;
}
:deep(.ant-card-head) {
border-bottom: 1px solid #f0f0f0;
}
:deep(.ant-card-body) {
padding: 24px;
}
</style>
@@ -0,0 +1,604 @@
<template>
<div class="user-management">
<div class="page-header">
<h2 data-testid="page-title">用户管理</h2>
</div>
<a-card class="search-card" :bordered="false">
<a-form layout="inline" :model="searchForm">
<a-form-item label="用户名">
<a-input
v-model:value="searchForm.username"
placeholder="请输入用户名"
data-testid="username-search-input"
allow-clear
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="邮箱">
<a-input
v-model:value="searchForm.email"
placeholder="请输入邮箱"
data-testid="email-search-input"
allow-clear
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="searchForm.status"
placeholder="请选择状态"
data-testid="status-select"
allow-clear
style="width: 120px"
>
<a-select-option value="1">启用</a-select-option>
<a-select-option value="0">禁用</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" data-testid="search-button" @click="handleSearch">
<template #icon>
<SearchOutlined />
</template>
搜索
</a-button>
<a-button data-testid="reset-button" @click="handleReset">
<template #icon>
<ReloadOutlined />
</template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<a-card class="table-card" :bordered="false">
<template #title>
<a-space>
<span>用户列表</span>
<a-tag color="blue"> {{ pagination.total }} </a-tag>
</a-space>
</template>
<template #extra>
<a-button type="primary" data-testid="add-user-button" @click="handleAdd">
<template #icon>
<PlusOutlined />
</template>
新增用户
</a-button>
</template>
<a-table
:columns="columns"
:data-source="dataSource"
:pagination="pagination"
:loading="loading"
:scroll="{ x: 1200 }"
data-testid="user-table"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'avatar'">
<a-avatar :src="record.avatar" :size="40">
<template #icon>
<UserOutlined />
</template>
</a-avatar>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 0 || record.status === '0' ? 'success' : 'error'">
{{ record.status === 0 || record.status === '0' ? '启用' : '禁用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'roles'">
<a-tag v-for="role in record.roles" :key="role.id" color="blue">
{{ role.name }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleView(record)">
<template #icon>
<EyeOutlined />
</template>
查看
</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">
<template #icon>
<EditOutlined />
</template>
编辑
</a-button>
<a-popconfirm
title="确定要删除该用户吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDelete(record.id)"
>
<a-button type="link" size="small" danger>
<template #icon>
<DeleteOutlined />
</template>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
width="600px"
@ok="handleModalOk"
@cancel="handleModalCancel"
>
<a-form
ref="formRef"
:model="formState"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="用户名" name="username">
<a-input
v-model:value="formState.username"
placeholder="请输入用户名"
:disabled="isEdit"
/>
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input
v-model:value="formState.email"
placeholder="请输入邮箱"
/>
</a-form-item>
<a-form-item label="手机号" name="phone">
<a-input
v-model:value="formState.phone"
placeholder="请输入手机号"
/>
</a-form-item>
<a-form-item v-if="!isEdit" label="密码" name="password">
<a-input-password
v-model:value="formState.password"
placeholder="请输入密码"
/>
</a-form-item>
<a-form-item label="昵称" name="nickname">
<a-input
v-model:value="formState.nickname"
placeholder="请输入昵称"
/>
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formState.status">
<a-radio :value="1">启用</a-radio>
<a-radio :value="0">禁用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="角色" name="roleIds">
<a-select
v-model:value="formState.roleIds"
mode="multiple"
placeholder="请选择角色"
:options="roleOptions"
/>
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea
v-model:value="formState.remark"
placeholder="请输入备注"
:rows="3"
/>
</a-form-item>
</a-form>
</a-modal>
<a-drawer
v-model:open="drawerVisible"
title="用户详情"
width="500px"
placement="right"
>
<a-descriptions :column="1" bordered>
<a-descriptions-item label="用户名">
{{ currentUser?.username }}
</a-descriptions-item>
<a-descriptions-item label="邮箱">
{{ currentUser?.email }}
</a-descriptions-item>
<a-descriptions-item label="手机号">
{{ currentUser?.phone }}
</a-descriptions-item>
<a-descriptions-item label="昵称">
{{ currentUser?.nickname }}
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="currentUser?.status === 'active' ? 'success' : 'error'">
{{ currentUser?.status === 'active' ? '启用' : '禁用' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="角色">
<a-tag v-for="role in currentUser?.roles" :key="role.id" color="blue">
{{ role.name }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ currentUser?.createdAt }}
</a-descriptions-item>
<a-descriptions-item label="更新时间">
{{ currentUser?.updatedAt }}
</a-descriptions-item>
<a-descriptions-item label="备注">
{{ currentUser?.remark || '-' }}
</a-descriptions-item>
</a-descriptions>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import type { TableProps, FormInstance } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
EyeOutlined,
EditOutlined,
DeleteOutlined,
UserOutlined
} from '@ant-design/icons-vue';
import { useUserStore } from '@/stores/user.store';
import { useRoleStore } from '@/stores/role.store';
import type { User, UserQueryParams } from '@/types';
const userStore = useUserStore();
const roleStore = useRoleStore();
const columns = [
{
title: '头像',
key: 'avatar',
width: 80,
align: 'center'
},
{
title: '用户名',
dataIndex: 'username',
key: 'username',
width: 120
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
width: 200
},
{
title: '手机号',
dataIndex: 'phone',
key: 'phone',
width: 130
},
{
title: '昵称',
dataIndex: 'nickname',
key: 'nickname',
width: 120
},
{
title: '状态',
key: 'status',
width: 80,
align: 'center'
},
{
title: '角色',
key: 'roles',
width: 150
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
width: 180
},
{
title: '操作',
key: 'action',
width: 200,
fixed: 'right'
}
];
const searchForm = reactive<UserQueryParams>({
username: '',
email: '',
status: undefined,
page: 1,
pageSize: 10
});
const dataSource = ref<User[]>([]);
const loading = ref(false);
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showTotal: (total: number) => `${total}`,
pageSizeOptions: ['10', '20', '50', '100']
});
const modalVisible = ref(false);
const modalLoading = ref(false);
const modalTitle = computed(() => isEdit.value ? '编辑用户' : '新增用户');
const isEdit = ref(false);
const formRef = ref<FormInstance>();
const formState = reactive({
id: '',
username: '',
email: '',
phone: '',
password: '',
nickname: '',
status: 1,
roleIds: [] as number[],
remark: ''
});
const formRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入有效的手机号', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
],
nickname: [
{ max: 50, message: '昵称长度不能超过 50 个字符', trigger: 'blur' }
],
roleIds: [
{ type: 'array', required: true, message: '请选择角色', trigger: 'change' }
]
};
const drawerVisible = ref(false);
const currentUser = ref<User | null>(null);
const roleOptions = computed(() => {
return roleStore.roles.map(role => ({
label: role.name,
value: role.id
}));
});
async function fetchUsers() {
loading.value = true;
try {
const success = await userStore.fetchUsers({
...searchForm,
current: searchForm.page,
size: searchForm.pageSize
});
if (success) {
dataSource.value = userStore.users;
pagination.total = userStore.total;
pagination.current = searchForm.page || 1;
pagination.pageSize = searchForm.pageSize || 10;
} else {
message.error('获取用户列表失败');
}
} catch (error) {
message.error('获取用户列表失败');
} finally {
loading.value = false;
}
}
async function fetchRoles() {
try {
await roleStore.fetchRoles();
} catch (error) {
console.error('获取角色列表失败:', error);
}
}
function handleSearch() {
searchForm.page = 1;
fetchUsers();
}
function handleReset() {
Object.assign(searchForm, {
username: '',
email: '',
status: undefined,
page: 1,
pageSize: 10
});
fetchUsers();
}
const handleTableChange: TableProps['onChange'] = (pag) => {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchUsers();
};
function handleAdd() {
isEdit.value = false;
Object.assign(formState, {
id: '',
username: '',
email: '',
phone: '',
password: '',
nickname: '',
status: 1,
roleIds: [],
remark: ''
});
modalVisible.value = true;
}
function handleEdit(record: User) {
isEdit.value = true;
Object.assign(formState, {
id: record.id.toString(),
username: record.username,
email: record.email,
phone: record.phone,
password: '',
nickname: record.nickname || '',
status: typeof record.status === 'string' ? parseInt(record.status) : record.status,
roleIds: record.roles?.map(r => r.id) || [],
remark: record.remark || ''
});
modalVisible.value = true;
}
function handleView(record: User) {
currentUser.value = record;
drawerVisible.value = true;
}
async function handleModalOk() {
try {
await formRef.value?.validate();
modalLoading.value = true;
if (isEdit.value) {
const updateData = {
id: parseInt(formState.id),
email: formState.email,
phone: formState.phone,
nickname: formState.nickname,
status: formState.status,
roleIds: formState.roleIds,
remark: formState.remark
};
const success = await userStore.updateUser(updateData);
if (success) {
message.success('更新用户成功');
} else {
message.error(userStore.error || '更新用户失败');
throw false; // Prevent modal close
}
} else {
const createData = {
username: formState.username,
email: formState.email,
phone: formState.phone,
password: formState.password,
nickname: formState.nickname,
status: formState.status,
roleIds: formState.roleIds,
remark: formState.remark
};
const success = await userStore.createUser(createData);
if (success) {
message.success('创建用户成功');
} else {
message.error(userStore.error || '创建用户失败');
throw false; // Prevent modal close
}
}
modalVisible.value = false;
fetchUsers();
} catch (error) {
if (error !== false) {
message.error(isEdit.value ? '更新用户失败' : '创建用户失败');
}
} finally {
modalLoading.value = false;
}
}
function handleModalCancel() {
formRef.value?.resetFields();
modalVisible.value = false;
}
async function handleDelete(id: number) {
try {
const success = await userStore.deleteUser(id);
if (success) {
message.success('删除用户成功');
fetchUsers();
} else {
message.error(userStore.error || '删除用户失败');
}
} catch (error) {
message.error('删除用户失败');
}
}
onMounted(() => {
fetchUsers();
fetchRoles();
});
</script>
<style scoped>
.user-management {
padding: 24px;
background: #f0f2f5;
min-height: calc(100vh - 64px);
}
.page-header {
margin-bottom: 16px;
background: #fff;
padding: 16px 24px;
border-radius: 4px;
}
.page-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #262626;
}
.search-card {
margin-bottom: 16px;
}
.table-card {
background: #fff;
}
:deep(.ant-table-thead > tr > th) {
background: #fafafa;
font-weight: 600;
}
:deep(.ant-card-head) {
border-bottom: 1px solid #f0f0f0;
}
:deep(.ant-card-body) {
padding: 24px;
}
</style>
@@ -0,0 +1,243 @@
<template>
<div class="dashboard">
<a-row :gutter="16">
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stat-card" :bordered="false">
<a-statistic
title="用户总数"
:value="stats.userCount"
:prefix="h(UserOutlined)"
:value-style="{ color: '#3f8600' }"
/>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stat-card" :bordered="false">
<a-statistic
title="角色总数"
:value="stats.roleCount"
:prefix="h(TeamOutlined)"
:value-style="{ color: '#1890ff' }"
/>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stat-card" :bordered="false">
<a-statistic
title="菜单总数"
:value="stats.menuCount"
:prefix="h(MenuOutlined)"
:value-style="{ color: '#722ed1' }"
/>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stat-card" :bordered="false">
<a-statistic
title="在线用户"
:value="stats.onlineCount"
:prefix="h(ThunderboltOutlined)"
:value-style="{ color: '#fa8c16' }"
/>
</a-card>
</a-col>
</a-row>
<a-row :gutter="16" style="margin-top: 16px">
<a-col :xs="24" :lg="12">
<a-card title="用户增长趋势" :bordered="false">
<div class="chart-placeholder">
<a-empty description="图表功能开发中" />
</div>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card title="系统访问统计" :bordered="false">
<div class="chart-placeholder">
<a-empty description="图表功能开发中" />
</div>
</a-card>
</a-col>
</a-row>
<a-row :gutter="16" style="margin-top: 16px">
<a-col :span="24">
<a-card title="最近操作日志" :bordered="false">
<a-table
:columns="logColumns"
:data-source="recentLogs"
:pagination="false"
:scroll="{ x: 800 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a-tag :color="getActionColor(record.action)">
{{ record.action }}
</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 'success' ? 'success' : 'error'">
{{ record.status === 'success' ? '成功' : '失败' }}
</a-tag>
</template>
</template>
</a-table>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, h } from 'vue';
import { UserOutlined, TeamOutlined, MenuOutlined, ThunderboltOutlined } from '@ant-design/icons-vue';
const stats = reactive({
userCount: 0,
roleCount: 0,
menuCount: 0,
onlineCount: 0
});
const logColumns = [
{
title: '操作人',
dataIndex: 'username',
key: 'username',
width: 120
},
{
title: '操作类型',
key: 'action',
width: 100
},
{
title: '操作内容',
dataIndex: 'content',
key: 'content',
width: 200
},
{
title: 'IP地址',
dataIndex: 'ip',
key: 'ip',
width: 130
},
{
title: '状态',
key: 'status',
width: 80
},
{
title: '操作时间',
dataIndex: 'createTime',
key: 'createTime',
width: 180
}
];
const recentLogs = ref([
{
id: 1,
username: 'admin',
action: '登录',
content: '用户登录系统',
ip: '192.168.1.100',
status: 'success',
createTime: '2024-01-15 10:30:00'
},
{
id: 2,
username: 'admin',
action: '创建',
content: '创建用户 testuser',
ip: '192.168.1.100',
status: 'success',
createTime: '2024-01-15 10:25:00'
},
{
id: 3,
username: 'admin',
action: '更新',
content: '更新角色权限',
ip: '192.168.1.100',
status: 'success',
createTime: '2024-01-15 10:20:00'
},
{
id: 4,
username: 'testuser',
action: '登录',
content: '用户登录系统',
ip: '192.168.1.101',
status: 'success',
createTime: '2024-01-15 10:15:00'
},
{
id: 5,
username: 'admin',
action: '删除',
content: '删除菜单项',
ip: '192.168.1.100',
status: 'success',
createTime: '2024-01-15 10:10:00'
}
]);
function getActionColor(action: string) {
const colorMap: Record<string, string> = {
'登录': 'blue',
'创建': 'green',
'更新': 'orange',
'删除': 'red',
'查询': 'cyan'
};
return colorMap[action] || 'default';
}
function fetchStats() {
stats.userCount = 156;
stats.roleCount = 8;
stats.menuCount = 24;
stats.onlineCount = 12;
}
onMounted(() => {
fetchStats();
});
</script>
<style scoped>
.dashboard {
padding: 24px;
background: #f0f2f5;
min-height: calc(100vh - 64px);
}
.stat-card {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
.chart-placeholder {
width: 100%;
height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
:deep(.ant-card-head) {
border-bottom: 1px solid #f0f0f0;
}
:deep(.ant-card-body) {
padding: 24px;
}
:deep(.ant-table-thead > tr > th) {
background: #fafafa;
font-weight: 600;
}
</style>
+17
View File
@@ -0,0 +1,17 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
readonly VITE_APP_PORT: string
readonly VITE_APP_BASE_API: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}