feat(admin): 添加用户管理相关文件
添加用户管理视图、API和状态管理文件
This commit is contained in:
@@ -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>
|
||||
@@ -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 环境使用 Mock,development 和 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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
return escapeMap[char]
|
||||
})
|
||||
}
|
||||
|
||||
export const unescape = (str: string): string => {
|
||||
return str.replace(/&|<|>|"|'/g, char => {
|
||||
const unescapeMap: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'"
|
||||
}
|
||||
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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user