在现代前端开发中,选择一个合适的技术栈对于项目的成功至关重要。本教程将引导您使用 Vue 3 + Vite + Pinia + Element Plus + Vue Router + Tailwind CSS 快速构建一个高性能、可维护的后台管理系统。
这个技术栈组合的优势:
总结: 这个组合兼顾了开发效率、运行时性能、代码可维护性和社区支持,是构建现代后台管理系统的理想选择。
本部分将指导您从零开始创建一个Vue 3项目,并逐步集成Vue Router、Pinia、Element Plus和Tailwind CSS。
在开始之前,请确保您的开发环境中已安装Node.js。建议使用LTS版本。
# 安装 pnpm (如果尚未安装)
npm install -g pnpm
Vite提供了一个非常方便的脚手架工具来快速创建Vue项目。
# 使用 pnpm 创建 Vue 3 项目
pnpm create vue@latest my-admin-template -- --typescript --tailwind --router --pinia --element-plus
命令解析:
pnpm create vue@latest: 使用pnpm执行Vue官方提供的项目创建工具的最新版本。my-admin-template: 您项目的名称,可以自定义。-- --typescript: 添加TypeScript支持。--tailwind: 添加Tailwind CSS支持(Vite会自动配置PostCSS和Autoprefixer)。--router: 添加Vue Router支持。--pinia: 添加Pinia状态管理支持。--element-plus: 添加Element Plus组件库支持。项目创建完成后,进入项目目录并安装依赖:
cd my-admin-template
pnpm install
pnpm dev
此时,您的项目应该已经在开发服务器上运行,通常是http://localhost:5173。
项目结构初探:
my-admin-template/
├── public/
├── src/
│ ├── assets/ # 静态资源,如图片、样式
│ ├── components/ # 可复用组件
│ ├── layouts/ # 布局组件(可选,可手动创建)
│ ├── router/ # Vue Router 路由配置
│ │ └── index.ts
│ ├── stores/ # Pinia 状态管理模块
│ │ └── counter.ts
│ ├── views/ # 页面级组件
│ ├── App.vue # 根组件
│ ├── main.ts # 应用入口文件
│ └── styles.css # Tailwind CSS 基础样式
├── .eslintrc.cjs # ESLint 配置
├── .gitignore
├── index.html # HTML 入口文件
├── package.json # 项目依赖与脚本
├── pnpm-lock.yaml # pnpm 锁定文件
├── postcss.config.js # PostCSS 配置 (Tailwind CSS 依赖)
├── README.md
├── tailwind.config.js# Tailwind CSS 配置
├── tsconfig.json # TypeScript 配置
└── vite.config.ts # Vite 配置
在您使用pnpm create vue@latest --router命令时,Vue Router已经自动集成并配置。
src/router/index.ts 文件是核心。// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), // 使用 History 模式
routes: [
{
path: '/',
name: 'home',
component: () => import('../views/HomeView.vue') // 示例首页
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue') // 示例关于页面
}
]
})
export default router
在src/main.ts中,Vue Router被注册到Vue应用实例:// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router' // 导入路由
const app = createApp(App)
app.use(router) // 注册路由
app.mount('#app')
createWebHistory(): 使用HTML5 History API,URL更美观,如 example.com/user/1。需要后端服务器配置支持(例如Nginx或Apache的重写规则),以防刷新时出现404。createWebHashHistory(): 使用URL哈希,如 example.com/#/user/1。无需后端配置,兼容性好,但URL不美观。
在后台管理系统中,通常推荐使用createWebHistory()。<router-view>中渲染。// 示例:
{
path: '/system',
name: 'System',
component: () => import('@/layout/index.vue'), // 布局组件
children: [
{
path: 'user', // 子路由路径不带'/',会自动拼接成 '/system/user'
name: 'UserManage',
component: () => import('@/views/system/UserManage.vue'),
},
{
path: 'role',
name: 'RoleManage',
component: () => import('@/views/system/RoleManage.vue'),
}
]
}
{
path: '/user/:id', // :id 是动态参数
name: 'UserDetail',
component: () => import('@/views/user/UserDetail.vue'),
props: true // 将路由参数作为props传递给组件
}
在组件中通过useRoute().params.id访问参数。// src/router/index.ts (在 export default router 之前)
import { useAuthStore } from '@/stores/auth'; // 假设您有认证 Pinia store
router.beforeEach((to, from, next) => {
// 设置页面标题
document.title = to.meta.title ? `${to.meta.title} | 后台管理` : '后台管理';
// 示例:登录认证守卫
if (to.meta.requiresAuth) { // 检查路由元信息中是否需要认证
const authStore = useAuthStore();
if (authStore.token) { // 检查 Pinia Store 中是否有 token
next(); // 已登录,放行
} else {
// 未登录,重定向到登录页,并携带重定向参数
next({
path: '/login',
query: { redirect: to.fullPath }
});
}
} else {
next(); // 不需要认证的页面,直接放行
}
});
路由元信息配置:// 路由配置中
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '仪表盘', requiresAuth: true } // 添加元信息
}
通过pnpm create vue@latest --pinia命令,Pinia已自动集成。
src/main.ts中:// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia' // 导入 Pinia
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia()) // 注册 Pinia
app.use(router)
app.mount('#app')
src/stores/counter.ts):// src/stores/counter.ts
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0
}),
getters: {
doubleCount: (state) => state.count * 2,
// 可以通过this访问其他getter
doubleCountPlusOne(): number {
return this.doubleCount + 1;
}
},
actions: {
increment() {
this.count++;
},
decrement() {
this.count--;
},
// 异步 action
async incrementAsync() {
await new Promise(resolve => setTimeout(resolve, 1000));
this.count++;
}
}
})
在组件中使用:<!-- src/views/HomeView.vue -->
<template>
<div>
<h1>计数器示例</h1>
<p>Count: {{ counterStore.count }}</p>
<p>Double Count: {{ counterStore.doubleCount }}</p>
<p>Double Count Plus One: {{ counterStore.doubleCountPlusOne }}</p>
<button @click="counterStore.increment()">增加</button>
<button @click="counterStore.decrement()">减少</button>
<button @click="counterStore.incrementAsync()">异步增加 (1s)</button>
</div>
</template>
<script setup lang="ts">
import { useCounterStore } from '@/stores/counter';
const counterStore = useCounterStore();
</script>
通过pnpm create vue@latest --element-plus命令,Element Plus已自动集成。
vite.config.ts中会看到unplugin-vue-components和unplugin-auto-import的配置。// vite.config.ts 示例片段
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
// ... 其他插件
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
})
这意味着您无需手动导入组件,例如,在模板中直接使用<el-button>即可。
如果您需要全局导入(不推荐,但对于小型项目可能方便),可以在main.ts中:// src/main.ts
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css' // 导入 Element Plus 样式
// ...
app.use(ElementPlus)
main.ts中配置。// src/main.ts
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs' // 导入中文语言包
// ...
app.use(ElementPlus, {
locale: zhCn, // 配置为中文
})
src/styles/element/index.scss (如果项目有此文件,或者您可以手动创建) 中引入 Element Plus 的样式变量并覆盖:/* src/styles/element/index.scss */
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
$colors: (
'primary': (
'base': #409EFF, // 主题色
),
)
);
@use "element-plus/theme-chalk/src/index.scss" as *; // 导入所有 Element Plus 样式
// 如果您想修改其他变量,可以在 @forward 后添加
// 例如:
// @forward 'element-plus/theme-chalk/src/common/var.scss' with (
// $menu: (
// 'bg-color': #334155,
// 'text-color': #bfcbd9,
// 'active-color': #409eff,
// )
// );
然后确保在main.ts中引入此SCSS文件:import '@/styles/element/index.scss';.vue文件中直接使用Element Plus组件:<template>
<div>
<el-button type="primary">主要按钮</el-button>
<el-button type="success">成功按钮</el-button>
<el-input v-model="inputValue" placeholder="请输入内容" />
<el-form :model="form" label-width="120px">
<el-form-item label="活动名称">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="活动区域">
<el-select v-model="form.region" placeholder="请选择活动区域">
<el-option label="区域一" value="shanghai" />
<el-option label="区域二" value="beijing" />
</el-select>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
const inputValue = ref('');
const form = reactive({
name: '',
region: ''
});
</script>
通过pnpm create vue@latest --tailwind命令,Tailwind CSS已自动集成。
postcss.config.js文件会自动生成。tailwind.config.js 文件配置: 这是Tailwind CSS的灵魂,您可以在此配置主题、变体、插件等。// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}", // 告诉 Tailwind 扫描这些文件以找到类名
],
theme: {
extend: {
// 在这里扩展 Tailwind 默认主题,例如添加自定义颜色、字体、间距等
colors: {
'custom-blue': '#1e3a8a', // 添加自定义颜色
},
},
},
plugins: [],
}
content配置非常重要,它告诉Tailwind CSS扫描哪些文件来生成最终的CSS。<template>
<div class="p-4 bg-white shadow-md rounded-lg flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-800">用户列表</h2>
<el-button type="primary" class="bg-blue-500 hover:bg-blue-600">添加用户</el-button>
</div>
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="p-6 bg-green-100 border border-green-300 rounded-lg">
<p class="text-green-800 font-semibold">这是一个卡片</p>
</div>
<!-- 更多卡片 -->
</div>
</template>
p-4: padding为1rem。bg-white: 背景色为白色。shadow-md: 中等阴影。rounded-lg: 圆角。flex items-center justify-between: Flex布局,垂直居中,两端对齐。text-xl font-bold text-gray-800: 文字大小、加粗、颜色。mt-4: margin-top为1rem。grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4: 响应式网格布局。
Tailwind CSS 3.0 默认启用了JIT (Just-In-Time) 模式,这意味着它只会在您使用时才生成CSS类,开发过程中速度飞快,并且最终的CSS文件非常小。本部分将深入探讨后台管理系统的整体UI布局设计,并着手实现一些通用的界面组件,为后续的核心功能开发打下坚实的基础。我们将充分利用Element Plus的强大组件库和Tailwind CSS的灵活性来实现这些目标。
一个经典的后台管理系统通常包含以下几个区域:顶部导航、侧边栏和主内容区。这种布局既能提供清晰的导航,又能有效利用屏幕空间展示核心业务内容。
我们将使用Element Plus的布局容器组件(el-container, el-header, el-aside, el-main, el-footer)来构建整体框架,并结合Tailwind CSS进行精细的样式调整。
<!-- src/layout/index.vue (新建此文件作为整体布局组件) -->
<template>
<el-container class="min-h-screen bg-gray-100">
<!-- 侧边栏 -->
<el-aside :width="isCollapse ? '64px' : '200px'"
class="bg-blue-800 text-white shadow-lg overflow-hidden transition-all duration-300 ease-in-out">
<!-- Logo区域 -->
<div class="h-16 flex items-center justify-center border-b border-blue-700">
<h1 v-if="!isCollapse" class="text-xl font-bold whitespace-nowrap">管理平台</h1>
<el-icon v-else :size="24">
<i-ep-menu />
</el-icon>
</div>
<!-- 菜单组件 -->
<SideMenu :is-collapse="isCollapse" />
</el-aside>
<el-container>
<!-- 顶部导航 -->
<el-header class="bg-white shadow-md flex items-center justify-between px-6 z-10">
<div class="flex items-center">
<!-- 菜单折叠按钮 -->
<el-icon :size="24" class="cursor-pointer mr-4" @click="toggleCollapse">
<i-ep-fold v-if="!isCollapse" />
<i-ep-expand v-else />
</el-icon>
<!-- 面包屑导航 -->
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<transition-group name="breadcrumb" mode="out-in">
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item.path" :to="item.path">
{{ item.meta?.title }}
</el-breadcrumb-item>
</transition-group>
</el-breadcrumb>
</div>
<div class="flex items-center space-x-4">
<!-- 用户信息和操作 -->
<el-dropdown trigger="click" @command="handleCommand">
<span class="el-dropdown-link flex items-center cursor-pointer">
<el-avatar :size="30" src="https://cube.elemecdn.com/0/88/03b0dff30d500609ae7455a09d6f6png.png" />
<span class="ml-2 text-gray-700 whitespace-nowrap">管理员</span>
<el-icon class="el-icon--right"><i-ep-arrow-down /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人中心</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 标签页管理 -->
<TagViews class="shadow-sm border-b border-gray-200" />
<!-- 主内容区 -->
<el-main class="p-4 overflow-y-auto">
<router-view v-slot="{ Component }">
<transition name="fade-transform" mode="out-in">
<keep-alive :include="cachedViews">
<component :is="Component" :key="$route.path" />
</keep-alive>
</transition>
</router-view>
</el-main>
<!-- 底部 (可选) -->
<el-footer
class="bg-white border-t border-gray-200 text-gray-500 text-sm flex items-center justify-center h-12">
© 2023 My Admin Template. All rights reserved.
</el-footer>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import SideMenu from '@/components/SideMenu.vue'; // 假设您将侧边菜单组件分离
import TagViews from '@/components/TagViews.vue'; // 假设您将标签页组件分离
import { ElMessage } from 'element-plus'; // 导入 Element Plus 消息提示
// 侧边栏折叠状态
const isCollapse = ref(false);
const toggleCollapse = () => {
isCollapse.value = !isCollapse.value;
};
// 路由与面包屑
const route = useRoute();
const router = useRouter();
const breadcrumbs = computed(() => {
const matched = route.matched.filter(item => item.meta && item.meta.title && item.path !== '/');
// 过滤掉没有title的,并确保首页不重复
return matched.length > 0 ? [{ path: '/', meta: { title: '首页' } }, ...matched] : [];
});
// 顶部下拉菜单命令处理
const handleCommand = (command: string) => {
if (command === 'logout') {
// 实际项目中会调用 Pinia action 来清除 token
ElMessage.success('退出登录成功');
router.push('/login'); // 跳转到登录页
} else if (command === 'profile') {
ElMessage.info('前往个人中心');
// router.push('/profile');
}
};
// 页面缓存 (KeepAlive)
// 实际项目通常将 cachedViews 放在 Pinia store 中,由 TagViews 组件维护
const cachedViews = ref<string[]>([]);
watch(route, (newRoute) => {
if (newRoute.meta && newRoute.meta.cache) { // 在路由 meta 中定义是否需要缓存
if (!cachedViews.value.includes(newRoute.name as string)) {
cachedViews.value.push(newRoute.name as string);
}
}
}, { immediate: true });
// 路由过渡动画样式
// 定义在 main.ts 或全局 styles.css 中
/*
.fade-transform-leave-active,
.fade-transform-enter-active {
transition: all .5s;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(30px);
}
*/
</script>
<style scoped>
/* 针对 Element Plus 菜单的样式调整,消除右侧边框 */
.el-menu-vertical-demo:not(.el-menu--collapse) {
width: 200px;
min-height: 400px;
}
.el-aside {
overflow-y: auto;
overflow-x: hidden;
}
.el-header {
height: 60px;
/* 确保顶部导航栏高度固定 */
}
.el-main {
height: calc(100vh - 60px - 40px - 48px);
/* 100vh - header - tagViews - footer */
display: flex;
flex-direction: column;
}
/* 面包屑过渡动画 */
.breadcrumb-enter-active,
.breadcrumb-leave-active {
transition: all .5s;
}
.breadcrumb-enter-from,
.breadcrumb-leave-to {
opacity: 0;
transform: translateX(20px);
}
.breadcrumb-leave-active {
position: absolute;
}
</style>
代码解析:
src/layout/index.vue: 引入了SideMenu和TagViews两个子组件,使布局更加模块化。isCollapse状态控制侧边栏宽度,transition-all提供了平滑过渡效果。breadcrumbs计算属性根据当前路由的matched数组动态生成面包屑路径。使用了transition-group为面包屑项添加了过渡动画。el-dropdown实现用户操作菜单。<router-view v-slot="{ Component }">: Vue 3的路由插槽写法,配合<transition>和<keep-alive>实现页面切换动画和组件缓存。<keep-alive>: 配合include属性(需要缓存的组件的name属性数组),可以缓存组件状态,避免组件销毁重建,提升用户体验,尤其是在多标签页场景。cachedViews: 一个字符串数组,存储需要缓存的组件的name。在实际项目中,它通常由Pinia Store管理,并与标签页组件联动。el-main高度计算: 使用calc()函数精确计算主内容区的高度,确保其能够填充剩余空间并提供滚动条。前面在1.3 Vue Router和2.1 整体布局中已经给出了侧边栏菜单的代码。这里我们再强调一些关键点。
我们将创建一个src/components/SideMenu.vue组件:
<!-- src/components/SideMenu.vue -->
<template>
<el-menu :default-active="activeMenu" class="el-menu-vertical-demo !border-r-0" :collapse="isCollapse"
background-color="#1e3a8a" text-color="#ffffff" active-text-color="#ffd04b" router :unique-opened="true">
<template v-for="routeItem in menuRoutes" :key="routeItem.path">
<template v-if="!routeItem.hidden">
<!-- 如果有子菜单且不隐藏,渲染 el-sub-menu -->
<el-sub-menu v-if="hasChildren(routeItem)" :index="routeItem.path">
<template #title>
<el-icon v-if="routeItem.meta?.icon">
<component :is="getIconComponent(routeItem.meta.icon)" />
</el-icon>
<span class="ml-2">{{ routeItem.meta?.title }}</span>
</template>
<template v-for="child in routeItem.children" :key="child.path">
<el-menu-item v-if="!child.hidden" :index="resolvePath(routeItem.path, child.path)">
<el-icon v-if="child.meta?.icon">
<component :is="getIconComponent(child.meta.icon)" />
</el-icon>
<span class="ml-2">{{ child.meta?.title }}</span>
</el-menu-item>
</template>
</el-sub-menu>
<!-- 如果没有子菜单或子菜单隐藏,渲染 el-menu-item -->
<el-menu-item v-else :index="routeItem.path">
<el-icon v-if="routeItem.meta?.icon">
<component :is="getIconComponent(routeItem.meta.icon)" />
</el-icon>
<span class="ml-2">{{ routeItem.meta?.title }}</span>
</el-menu-item>
</template>
</template>
</el-menu>
</template>
<script setup lang="ts">
import { computed, ref, watch, markRaw } from 'vue';
import { useRoute } from 'vue-router';
import path from 'path-browserify'; // 需要安装 path-browserify
import * as ElementPlusIconsVue from '@element-plus/icons-vue'; // 导入所有Element Plus图标
const props = defineProps<{
isCollapse: boolean;
}>();
const route = useRoute();
// 计算当前激活的菜单项
const activeMenu = computed(() => {
const { path } = route;
return path;
});
// 模拟路由数据,实际会从 router/index.ts 中获取并可能进行权限过滤
// 注意:这里的 hidden 属性是自定义的,用于控制菜单显示与否
// meta.icon 存放的是图标组件的名称字符串,例如 'HomeFilled'
const menuRoutes = ref([
{
path: '/dashboard',
name: 'Dashboard',
hidden: false,
meta: { title: '仪表盘', icon: 'HomeFilled' },
children: [] // 确保children存在,即使为空数组
},
{
path: '/system',
name: 'System',
hidden: false,
meta: { title: '系统管理', icon: 'Setting' },
children: [
{
path: 'user',
name: 'UserManage',
hidden: false,
meta: { title: '用户管理', icon: 'UserFilled' },
},
{
path: 'role',
name: 'RoleManage',
hidden: false,
meta: { title: '角色管理', icon: 'Lock' },
},
{
path: 'menu',
name: 'MenuManage',
hidden: false, // 假设不再隐藏
meta: { title: '菜单管理', icon: 'Operation' },
},
{
path: 'log',
name: 'SystemLog',
hidden: false,
meta: { title: '系统日志', icon: 'Document' },
},
]
},
{
path: '/products',
name: 'Products',
hidden: false,
meta: { title: '商品管理', icon: 'Goods' },
children: [
{
path: 'list',
name: 'ProductList',
hidden: false,
meta: { title: '商品列表', icon: 'List' },
},
{
path: 'category',
name: 'ProductCategory',
hidden: false,
meta: { title: '商品分类', icon: 'Collection' },
},
]
},
{
path: '/about',
name: 'About',
hidden: false,
meta: { title: '关于', icon: 'InfoFilled' },
}
]);
// 辅助函数:判断路由是否有可见的子路由
const hasChildren = (routeItem: any) => {
if (routeItem.children && routeItem.children.length > 0) {
return routeItem.children.some((child: any) => !child.hidden);
}
return false;
};
// 动态获取图标组件
const getIconComponent = (iconName: string) => {
// ElementPlusIconsVue 包含所有图标组件
// 需要确保图标名称和导入的名称一致,例如 'HomeFilled' 对应 HomeFilled
return markRaw(ElementPlusIconsVue[iconName as keyof typeof ElementPlusIconsVue]);
};
// 路径解析函数,处理嵌套路由的正确路径拼接
const resolvePath = (basePath: string, routePath: string) => {
return path.resolve(basePath, routePath);
};
</script>
<style scoped>
.el-menu-vertical-demo:not(.el-menu--collapse) {
width: 200px;
}
.el-menu-vertical-demo {
border-right: none;
/* 移除 Element Plus 菜单的右边框 */
}
/* 侧边栏滚动条美化(可选,针对Chrome) */
.el-aside::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.el-aside::-webkit-scrollbar-thumb {
border-radius: 3px;
background-color: rgba(255, 255, 255, 0.3);
}
.el-aside::-webkit-scrollbar-track {
background-color: rgba(0, 0, 0, 0.1);
}
</style>
注意: 这里的 path-browserify 需要您手动安装:pnpm install path-browserify
顶部导航栏在src/layout/index.vue中已经实现了一部分,包括:
el-icon结合isCollapse状态控制。el-breadcrumb组件展示当前路径。补充:用户头像与下拉菜单
在src/layout/index.vue的<el-header>中已经包含了用户头像和下拉菜单,这里不再重复展示。关键在于使用el-dropdown和其trigger、@command属性。
实现多开标签页功能可以显著提升用户体验。我们将结合Pinia来存储已打开的标签页状态,并实现添加、关闭、切换标签页等功能。
首先,创建一个Pinia Store来管理标签页状态:
// src/stores/tagViews.ts
import { defineStore } from 'pinia';
import { RouteLocationNormalizedLoaded } from 'vue-router';
// 定义一个标签页的接口
export interface TagView extends Partial<RouteLocationNormalizedLoaded> {
title?: string;
path: string;
name?: string;
fullPath: string;
meta?: {
cache?: boolean; // 是否缓存该路由组件
title?: string;
// ... 其他自定义 meta
};
}
export const useTagViewsStore = defineStore('tagViews', {
state: () => ({
visitedViews: [] as TagView[], // 已访问的标签页
cachedViews: [] as string[], // 需要缓存的组件名称
}),
actions: {
addTagView(view: TagView) {
// 如果标签页已存在,则不再添加
if (this.visitedViews.some(v => v.path === view.path)) {
return;
}
this.visitedViews.push(
Object.assign({}, view, {
title: view.meta?.title || 'No-name', // 默认标题
})
);
// 如果需要缓存,添加到 cachedViews
if (view.meta?.cache && view.name && !this.cachedViews.includes(view.name as string)) {
this.cachedViews.push(view.name as string);
}
},
delTagView(view: TagView) {
return new Promise<{ visitedViews: TagView[]; cachedViews: string[] }>((resolve) => {
const index = this.visitedViews.findIndex(v => v.path === view.path);
if (index > -1) {
this.visitedViews.splice(index, 1);
}
// 从缓存中移除
if (view.name && this.cachedViews.includes(view.name as string)) {
const cacheIndex = this.cachedViews.indexOf(view.name as string);
if (cacheIndex > -1) {
this.cachedViews.splice(cacheIndex, 1);
}
}
resolve({
visitedViews: [...this.visitedViews],
cachedViews: [...this.cachedViews],
});
});
},
// 其他如关闭所有、关闭其他等 actions...
delAllTagViews() {
this.visitedViews = [];
this.cachedViews = [];
},
delOtherTagViews(currentView: TagView) {
this.visitedViews = this.visitedViews.filter(view => {
return view.path === currentView.path;
});
this.cachedViews = this.cachedViews.filter(name => {
// 只有当前view需要缓存且名字在缓存列表中才保留
return currentView.name && name === currentView.name;
});
},
updateTagView(view: TagView) {
const index = this.visitedViews.findIndex(v => v.path === view.path);
if (index > -1) {
Object.assign(this.visitedViews[index], view);
}
},
},
});
然后,创建src/components/TagViews.vue组件:
<!-- src/components/TagViews.vue -->
<template>
<div class="tag-views-container bg-white flex items-center h-10 px-4 border-b border-gray-200 overflow-x-auto">
<el-tag v-for="tag in tagViewsStore.visitedViews" :key="tag.path" :closable="tag.path !== '/dashboard'"
:disable-transitions="false" :type="isActive(tag) ? '' : 'info'" :effect="isActive(tag) ? 'dark' : 'plain'"
class="tag-item mr-2" @click="handleTagClick(tag)" @close="handleCloseTag(tag)">
{{ tag.title }}
</el-tag>
<!-- 右键菜单 (可选,Element Plus Contextmenu) -->
<!-- <el-dropdown trigger="contextmenu" @command="handleContextMenuCommand">
<div slot="reference" class="contextmenu-target"></div>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="closeCurrent">关闭当前</el-dropdown-item>
<el-dropdown-item command="closeOthers">关闭其他</el-dropdown-item>
<el-dropdown-item command="closeAll">关闭所有</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown> -->
</div>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useTagViewsStore, TagView } from '@/stores/tagViews';
import { ElMessage } from 'element-plus';
const route = useRoute();
const router = useRouter();
const tagViewsStore = useTagViewsStore();
// 判断当前标签是否激活
const isActive = (tag: TagView) => {
return tag.path === route.path;
};
// 监听路由变化,添加标签页
watch(route, (newRoute) => {
if (newRoute.meta && newRoute.meta.title) {
tagViewsStore.addTagView(newRoute as TagView);
}
}, { immediate: true });
// 点击标签页,进行跳转
const handleTagClick = (tag: TagView) => {
router.push(tag.path);
};
// 关闭标签页
const handleCloseTag = async (tag: TagView) => {
if (tag.path === '/dashboard') {
ElMessage.warning('首页不能关闭!');
return;
}
const { visitedViews } = await tagViewsStore.delTagView(tag);
// 如果关闭的是当前激活的标签页,则跳转到最近的标签页
if (isActive(tag)) {
const latestView = visitedViews[visitedViews.length - 1];
if (latestView) {
router.push(latestView.path);
} else {
// 如果没有其他标签页了,跳转到首页
router.push('/dashboard');
}
}
};
// 右键菜单处理函数 (示例,需要 Element Plus Contextmenu 组件)
// const handleContextMenuCommand = (command: string) => {
// // ... 根据 command 执行关闭逻辑
// };
// 暴露给 layout 组件的 cachedViews
defineExpose({
cachedViews: computed(() => tagViewsStore.cachedViews)
});
</script>
<style scoped>
.tag-views-container {
/* 隐藏滚动条 */
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
.tag-views-container::-webkit-scrollbar {
display: none;
/* Chrome, Safari, Opera*/
}
.tag-item {
cursor: pointer;
white-space: nowrap;
/* 防止标签文本换行 */
}
</style>
在src/layout/index.vue中,更新cachedViews的获取方式:
<!-- src/layout/index.vue -->
<template>
<!-- ... 省略其他部分 ... -->
<TagViews class="shadow-sm border-b border-gray-200" ref="tagViewsRef" />
<!-- 主内容区 -->
<el-main class="p-4 overflow-y-auto">
<router-view v-slot="{ Component }">
<transition name="fade-transform" mode="out-in">
<!-- 缓存的视图通过 ref 从 TagViews 组件获取 -->
<keep-alive :include="cachedViews">
<component :is="Component" :key="$route.fullPath" />
</keep-alive>
</transition>
</router-view>
</el-main>
<!-- ... 省略其他部分 ... -->
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import SideMenu from '@/components/SideMenu.vue';
import TagViews from '@/components/TagViews.vue';
import { ElMessage } from 'element-plus';
import { useTagViewsStore } from '@/stores/tagViews'; // 导入 TagViews Store
const tagViewsStore = useTagViewsStore(); // 实例化 Store
// 侧边栏折叠状态
const isCollapse = ref(false);
const toggleCollapse = () => {
isCollapse.value = !isCollapse.value;
};
// 路由与面包屑
const route = useRoute();
const router = useRouter();
const breadcrumbs = computed(() => {
const matched = route.matched.filter(item => item.meta && item.meta.title && item.path !== '/');
return matched.length > 0 ? [{ path: '/', meta: { title: '首页' } }, ...matched] : [];
});
// 顶部下拉菜单命令处理
const handleCommand = (command: string) => {
if (command === 'logout') {
// 实际项目中会调用 Pinia action 来清除 token
ElMessage.success('退出登录成功');
// 清除标签页和缓存
tagViewsStore.delAllTagViews();
router.push('/login');
} else if (command === 'profile') {
ElMessage.info('前往个人中心');
// router.push('/profile');
}
};
// 直接从 Pinia Store 中获取 cachedViews
const cachedViews = computed(() => tagViewsStore.cachedViews);
// 路由过渡动画样式 (请确保这些样式定义在全局CSS中,例如 src/style.css 或 src/main.ts)
/*
.fade-transform-leave-active,
.fade-transform-enter-active {
transition: all .5s;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(30px);
}
*/
</script>
在您的 src/router/index.ts 中为需要缓存的路由添加 meta: { cache: true }:
// src/router/index.ts
// ...
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue'),
meta: { title: '登录' }
},
{
path: '/',
name: 'Layout',
component: () => import('@/layout/index.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '仪表盘', requiresAuth: true, icon: 'HomeFilled', cache: true } // 缓存
},
{
path: 'system/user',
name: 'UserManage',
component: () => import('@/views/system/UserManage.vue'),
meta: { title: '用户管理', requiresAuth: true, icon: 'UserFilled', cache: true } // 缓存
},
{
path: 'system/role',
name: 'RoleManage',
component: () => import('@/views/system/RoleManage.vue'),
meta: { title: '角色管理', requiresAuth: true, icon: 'Lock', cache: true } // 缓存
},
// ... 其他路由
]
},
// ... 404 路由
]
});
// 在 main.ts 或全局样式文件中添加过渡动画CSS
/*
.fade-transform-leave-active,
.fade-transform-enter-active {
transition: all .5s;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(30px);
}
*/
一个健壮的后台管理系统需要全局的加载提示和统一的错误处理机制,以提升用户体验和开发效率。
全局加载 (Loading)
我们可以使用Element Plus的ElLoading服务,并结合Pinia Store来管理其显示与隐藏。
src/stores/app.ts):// src/stores/app.ts
import { defineStore } from 'pinia';
export const useAppStore = defineStore('app', {
state: () => ({
isLoading: false,
}),
actions: {
showLoading() {
this.isLoading = true;
},
hideLoading() {
this.isLoading = false;
},
},
});
main.ts 中注册 ElLoading 并监听 isLoading 状态:// src/main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import ElementPlus from 'element-plus';
import zhCn from 'element-plus/dist/locale/zh-cn.mjs';
import 'element-plus/dist/index.css'; // Element Plus 基础样式
import '@/styles/index.css'; // Tailwind CSS 导入点,确保包含基础样式和自定义样式
import * as ElementPlusIconsVue from '@element-plus/icons-vue'; // 导入 Element Plus 图标
import { useAppStore } from '@/stores/app'; // 导入 app store
import { ElLoading } from 'element-plus';
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.use(ElementPlus, {
locale: zhCn,
});
// 注册所有 Element Plus 图标为全局组件,方便 SideMenu 等地方使用
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(`i-ep-${key}`, component);
}
// 全局 Loading 监听
let loadingInstance: ReturnType<typeof ElLoading.service> | null = null;
pinia.use(({ store }) => {
// 监听 app store 的 isLoading 状态
if (store.$id === 'app') {
store.$subscribe((mutation, state) => {
if (state.isLoading && !loadingInstance) {
loadingInstance = ElLoading.service({
fullscreen: true,
text: '加载中...',
background: 'rgba(0, 0, 0, 0.7)',
});
} else if (!state.isLoading && loadingInstance) {
loadingInstance.close();
loadingInstance = null;
}
});
}
});
app.mount('#app');
全局错误边界处理
Vue 3 提供了errorHandler来捕获组件渲染函数和侦听器中的错误。
// src/main.ts (添加到 app.mount('#app') 之前)
import { ElMessage } from 'element-plus';
app.config.errorHandler = (err, vm, info) => {
// `err`:错误对象
// `vm`:出错的组件实例
// `info`:Vue 特定的错误信息字符串,例如错误发生在哪个生命周期钩子
console.error('全局错误捕获:', err, vm, info);
ElMessage.error(`应用发生错误: ${err.message || '未知错误'}`);
// 可以在这里上报错误到监控系统
};
app.config.warnHandler = (msg, vm, trace) => {
// 可以在这里处理 Vue 的警告信息,例如忽略某些特定的警告
// console.warn('Vue 警告:', msg, vm, trace);
};
消息提示统一封装 (ElMessage, ElNotification)
为了保持一致性,建议对ElMessage和ElNotification进行二次封装。
// src/utils/message.ts
import { ElMessage, ElNotification } from 'element-plus';
// 封装 ElMessage
export const showMessage = (type: 'success' | 'warning' | 'info' | 'error', message: string, duration = 3000) => {
ElMessage({
type,
message,
duration,
offset: 50, // 距离顶部距离
});
};
export const successMsg = (message: string, duration?: number) => showMessage('success', message, duration);
export const warningMsg = (message: string, duration?: number) => showMessage('warning', message, duration);
export const infoMsg = (message: string, duration?: number) => showMessage('info', message, duration);
export const errorMsg = (message: string, duration?: number) => showMessage('error', message, duration);
// 封装 ElNotification (可根据需求选择使用)
export const showNotification = (type: 'success' | 'warning' | 'info' | 'error', title: string, message: string, duration = 4500) => {
ElNotification({
type,
title,
message,
duration,
position: 'top-right',
});
};
export const successNoti = (title: string, message: string, duration?: number) => showNotification('success', title, message, duration);
export const errorNoti = (title: string, message: string, duration?: number) => showNotification('error', title, message, duration);
// 在 main.ts 中注册到全局属性 (可选,推荐直接导入使用)
// app.config.globalProperties.$msg = { successMsg, errorMsg, /* ... */ };
然后在组件中导入使用:
import { successMsg, errorMsg } from '@/utils/message';
// ...
successMsg('操作成功!');
errorMsg('请求失败,请重试。');
本部分将涵盖后台管理系统中常见且核心的功能模块的开发,包括HTTP请求、用户认证、数据展示与编辑等。
Axios是一个基于Promise的HTTP客户端,用于浏览器和Node.js。对其进行封装可以实现请求/响应拦截、错误统一处理、Loading状态管理等。
pnpm install axios
src/utils/request.ts):// src/utils/request.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
import { ElMessage, ElNotification } from 'element-plus';
import { useAppStore } from '@/stores/app'; // 导入全局 loading store
import { useAuthStore } from '@/stores/auth'; // 导入认证 store
import router from '@/router'; // 导入 router 实例
// 定义响应数据接口 (根据后端实际返回结构调整)
interface ApiResponse<T = any> {
code: number;
message: string;
data: T;
}
const service: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_API, // 从环境变量获取 API 基础路径
timeout: 10000, // 请求超时时间
headers: {
'Content-Type': 'application/json;charset=UTF-8',
},
});
// 请求拦截器
service.interceptors.request.use(
(config: AxiosRequestConfig) => {
const appStore = useAppStore();
const authStore = useAuthStore();
// 1. 添加 Token
if (authStore.token) {
(config.headers as any)['Authorization'] = `Bearer ${authStore.token}`; // 或者 'token'
}
// 2. 显示全局 Loading
if (!config.noLoading) { // 可以在请求配置中添加 noLoading: true 来禁用
appStore.showLoading();
}
return config;
},
(error: AxiosError) => {
console.error('请求错误:', error);
appStore.hideLoading();
ElMessage.error('请求发送失败');
return Promise.reject(error);
}
);
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const appStore = useAppStore();
appStore.hideLoading(); // 隐藏全局 Loading
const { code, message, data } = response.data;
if (code === 200) { // 根据后端业务状态码判断成功
return data; // 返回业务数据
} else if (code === 401) { // 认证失败或 token 过期
ElNotification.error({
title: '认证失败',
message: message || '您的登录已失效,请重新登录。',
});
const authStore = useAuthStore();
authStore.clearToken(); // 清除过期的 token
router.push('/login'); // 重定向到登录页
return Promise.reject(new Error(message || 'Error'));
} else {
// 其他业务错误
ElMessage.error(message || '请求失败,请稍后重试');
return Promise.reject(new Error(message || 'Error'));
}
},
(error: AxiosError) => {
const appStore = useAppStore();
appStore.hideLoading(); // 隐藏全局 Loading
let msg = '请求错误';
if (error.response) {
switch (error.response.status) {
case 400:
msg = '请求参数错误';
break;
case 401:
msg = '认证失败,请重新登录';
const authStore = useAuthStore();
authStore.clearToken();
router.push('/login');
break;
case 403:
msg = '无权访问';
break;
case 404:
msg = '请求资源不存在';
break;
case 500:
msg = '服务器内部错误';
break;
default:
msg = `网络错误 (${error.response.status})`;
}
} else {
msg = '网络连接异常,请检查网络';
}
ElMessage.error(msg);
return Promise.reject(error);
}
);
// 封装常用请求方法
export default service;
export const get = <T = any>(url: string, params?: object, config?: AxiosRequestConfig): Promise<T> => {
return service.get(url, { params, ...config });
};
export const post = <T = any>(url: string, data?: object, config?: AxiosRequestConfig): Promise<T> => {
return service.post(url, data, config);
};
export const put = <T = any>(url: string, data?: object, config?: AxiosRequestConfig): Promise<T> => {
return service.put(url, data, config);
};
export const del = <T = any>(url: string, config?: AxiosRequestConfig): Promise<T> => {
return service.delete(url, config);
};
.env.development和.env.production文件。.env.development (开发环境)VITE_APP_BASE_API = /api # 开发环境代理地址,Vite 配置 proxy
.env.production (生产环境)VITE_APP_BASE_API = https://your-prod-api.com/api # 生产环境实际 API 地址
在vite.config.ts中配置代理以解决开发环境跨域问题:
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Icons from 'unplugin-icons/vite' // 如果使用 unplugin-icons
import IconsResolver from 'unplugin-icons/resolver'
import path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'), // 配置 @ 别名
},
},
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
imports: ['vue', 'vue-router', 'pinia'], // 自动导入vue, vue-router, pinia相关函数
dts: './auto-imports.d.ts', // 生成类型声明文件
}),
Components({
resolvers: [
ElementPlusResolver(),
IconsResolver({ // Element Plus 图标按需导入
prefix: 'i-ep', // 例如:i-ep-Setting
}),
],
dts: './components.d.ts', // 生成类型声明文件
}),
Icons({ // 如果使用 unplugin-icons
autoInstall: true,
})
],
server: {
port: 8080, // 开发服务器端口
open: true, // 自动打开浏览器
proxy: {
'/api': { // 代理 /api 请求
target: 'http://localhost:3000', // 您的后端 API 地址
changeOrigin: true, // 允许跨域
rewrite: (path) => path.replace(/^\/api/, '') // 重写路径,移除 /api
}
}
}
})
本节将实现用户登录功能,并结合Pinia和Vue Router守卫进行认证状态管理。
src/stores/auth.ts):// src/stores/auth.ts
import { defineStore } from 'pinia';
import { loginApi } from '@/api/auth'; // 假设您有 auth 相关的 api
import router from '@/router'; // 导入 router
interface UserInfo {
username: string;
roles: string[];
// ... 其他用户信息
}
export const useAuthStore = defineStore('auth', {
state: () => ({
token: localStorage.getItem('token') || '', // 从 localStorage 持久化 token
userInfo: JSON.parse(localStorage.getItem('userInfo') || '{}') as UserInfo,
}),
actions: {
async login(loginForm: any) { // loginForm 包含 username, password
try {
// 调用登录 API
const response = await loginApi(loginForm);
// 假设后端返回 { token: '...', user: { ... } }
this.token = response.token;
this.userInfo = response.user;
localStorage.setItem('token', response.token);
localStorage.setItem('userInfo', JSON.stringify(response.user));
return true; // 登录成功
} catch (error) {
console.error('登录失败:', error);
this.clearToken(); // 确保失败时清空
return false; // 登录失败
}
},
logout() {
this.clearToken();
// 跳转到登录页
router.push('/login');
},
clearToken() {
this.token = '';
this.userInfo = {} as UserInfo;
localStorage.removeItem('token');
localStorage.removeItem('userInfo');
},
},
});
src/api/auth.ts):// src/api/auth.ts
import request from '@/utils/request';
// 假设登录接口是 POST /login
export const loginApi = (data: any) => {
return request.post('/login', data);
};
// 假设获取用户信息的接口
export const getUserInfoApi = () => {
return request.get('/user/info');
};
src/views/Login.vue):<!-- src/views/Login.vue -->
<template>
<div class="login-container min-h-screen flex items-center justify-center bg-gray-50">
<el-card class="box-card w-full max-w-sm p-6 shadow-lg rounded-lg">
<template #header>
<div class="text-center text-2xl font-bold text-gray-800">
后台管理系统登录
</div>
</template>
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" label-position="top" @keyup.enter="handleLogin">
<el-form-item prop="username">
<el-input v-model="loginForm.username" placeholder="请输入用户名" size="large">
<template #prefix>
<el-icon><i-ep-user /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input type="password" v-model="loginForm.password" placeholder="请输入密码" show-password size="large">
<template #prefix>
<el-icon><i-ep-lock /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" class="w-full mt-4" size="large" :loading="loading" @click="handleLogin">
登录
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { ElMessage, FormInstance, FormRules } from 'element-plus';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const loginFormRef = ref<FormInstance>();
const loading = ref(false);
const loginForm = reactive({
username: 'admin', // 示例默认值
password: '123' // 示例默认值
});
const loginRules: FormRules = reactive({
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '长度在 3 到 20 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 3, max: 20, message: '长度在 3 到 20 个字符', trigger: 'blur' }
]
});
const handleLogin = async () => {
if (!loginFormRef.value) return;
await loginFormRef.value.validate(async (valid) => {
if (valid) {
loading.value = true;
try {
const success = await authStore.login(loginForm);
if (success) {
ElMessage.success('登录成功!');
// 获取重定向地址,如果没有则跳转到仪表盘
const redirect = route.query.redirect as string || '/dashboard';
router.push(redirect);
} else {
ElMessage.error('登录失败,请检查用户名或密码。');
}
} catch (error) {
// 错误已在 request.ts 中统一处理
} finally {
loading.value = false;
}
} else {
ElMessage.error('请填写完整的登录信息!');
return false;
}
});
};
</script>
<style scoped>
.login-container {
background: linear-gradient(to right, #6dd5ed, #2193b0);
/* 渐变背景 */
}
</style>
数据列表是后台管理系统中最常见的组件。我们将使用Element Plus的el-table和el-pagination。
src/views/system/UserManage.vue):<!-- src/views/system/UserManage.vue -->
<template>
<div class="p-4 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold mb-4">用户管理</h2>
<!-- 搜索表单 -->
<el-form :inline="true" :model="searchForm" class="mb-4">
<el-form-item label="用户名">
<el-input v-model="searchForm.username" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="启用" value="1" />
<el-option label="禁用" value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="handleResetSearch">重置</el-button>
</el-form-item>
</el-form>
<!-- 操作按钮 -->
<div class="mb-4 flex space-x-2">
<el-button type="primary" :icon="Plus" @click="handleAdd">新增用户</el-button>
<el-button type="danger" :icon="Delete" :disabled="selectedUsers.length === 0" @click="handleBatchDelete">
批量删除
</el-button>
<el-button :icon="Download">导出</el-button>
</div>
<!-- 数据表格 -->
<el-table :data="userList" border style="width: 100%" v-loading="loading" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button link type="primary" :icon="Edit" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" :icon="Delete" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="flex justify-end mt-4">
<el-pagination v-model:currentPage="pagination.currentPage" v-model:page-size="pagination.pageSize"
:page-sizes="" :small="false" :disabled="loading" :background="true"
layout="total, sizes, prev, pager, next, jumper" :total="pagination.total" @size-change="handleSizeChange"
@current-change="handleCurrentChange" />
</div>
<!-- 用户编辑/新增弹窗 -->
<UserFormDialog v-model:visible="dialogVisible" :form-data="currentEditUser" @submit-success="fetchUserList" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Search, Refresh, Plus, Delete, Edit, Download } from '@element-plus/icons-vue'; // 导入图标
import UserFormDialog from './UserFormDialog.vue'; // 用户新增/编辑弹窗组件
// 搜索表单数据
const searchForm = reactive({
username: '',
status: '',
});
// 分页数据
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0,
});
// 用户列表数据
const userList = ref<any[]>([]);
const loading = ref(false);
const selectedUsers = ref<any[]>([]); // 选中用户
// 弹窗相关
const dialogVisible = ref(false);
const currentEditUser = ref<any>(null); // 当前编辑的用户数据
// 模拟后端 API 请求
const fetchUserListApi = async (params: any) => {
// 实际项目中会调用封装的 request
console.log('Fetching users with params:', params);
return new Promise(resolve => {
setTimeout(() => {
const totalData = Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
username: `user${i + 1}`,
email: `user${i + 1}@example.com`,
status: i % 3 === 0 ? 0 : 1, // 随机生成状态
createTime: new Date().toISOString().slice(0, 10),
}));
const filteredData = totalData.filter(user => {
const usernameMatch = params.username ? user.username.includes(params.username) : true;
const statusMatch = params.status !== '' ? String(user.status) === params.status : true;
return usernameMatch && statusMatch;
});
const startIndex = (params.currentPage - 1) * params.pageSize;
const endIndex = startIndex + params.pageSize;
const list = filteredData.slice(startIndex, endIndex);
resolve({
list,
total: filteredData.length,
});
}, 500);
});
};
// 获取用户列表
const fetchUserList = async () => {
loading.value = true;
try {
const res: any = await fetchUserListApi({
...searchForm,
currentPage: pagination.currentPage,
pageSize: pagination.pageSize,
});
userList.value = res.list;
pagination.total = res.total;
} catch (error) {
ElMessage.error('获取用户列表失败');
console.error(error);
} finally {
loading.value = false;
}
};
// 搜索
const handleSearch = () => {
pagination.currentPage = 1; // 搜索时重置回第一页
fetchUserList();
};
// 重置搜索
const handleResetSearch = () => {
searchForm.username = '';
searchForm.status = '';
handleSearch();
};
// 新增用户
const handleAdd = () => {
currentEditUser.value = null; // 清空当前编辑数据
dialogVisible.value = true;
};
// 编辑用户
const handleEdit = (row: any) => {
currentEditUser.value = { ...row }; // 传递用户数据副本
dialogVisible.value = true;
};
// 删除用户
const handleDelete = async (row: any) => {
await ElMessageBox.confirm(`确定删除用户 "${row.username}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
// 实际调用删除 API
ElMessage.success(`用户 "${row.username}" 删除成功!`);
fetchUserList(); // 重新加载列表
};
// 批量删除
const handleBatchDelete = async () => {
if (selectedUsers.value.length === 0) {
ElMessage.warning('请选择要删除的用户!');
return;
}
await ElMessageBox.confirm(`确定删除选中的 ${selectedUsers.value.length} 个用户吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
// 实际调用批量删除 API
ElMessage.success(`成功删除 ${selectedUsers.value.length} 个用户!`);
selectedUsers.value = []; // 清空选中
fetchUserList();
};
// 表格多选变化
const handleSelectionChange = (selection: any[]) => {
selectedUsers.value = selection;
};
// 每页显示条数变化
const handleSizeChange = (val: number) => {
pagination.pageSize = val;
fetchUserList();
};
// 当前页变化
const handleCurrentChange = (val: number) => {
pagination.currentPage = val;
fetchUserList();
};
// 组件挂载时获取数据
onMounted(() => {
fetchUserList();
});
</script>
<style scoped></style>
通常会使用一个单独的弹窗组件来处理新增和编辑逻辑。
src/views/system/UserFormDialog.vue):<!-- src/views/system/UserFormDialog.vue -->
<template>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" @close="handleClose">
<el-form ref="userFormRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<!-- 密码字段只在新增时显示 -->
<el-form-item label="密码" prop="password" v-if="!isEdit">
<el-input type="password" v-model="form.password" placeholder="请输入密码" show-password />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
{{ isEdit ? '更新' : '新增' }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue';
import { ElMessage, FormInstance, FormRules } from 'element-plus';
const props = defineProps<{
visible: boolean;
formData: any | null; // 传入的编辑数据
}>();
const emit = defineEmits(['update:visible', 'submit-success']);
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
const isEdit = computed(() => !!props.formData && props.formData.id);
const dialogTitle = computed(() => (isEdit.value ? '编辑用户' : '新增用户'));
const submitLoading = ref(false);
const userFormRef = ref<FormInstance>();
const form = reactive({
id: null,
username: '',
email: '',
status: 1, // 默认启用
password: '',
});
const rules: FormRules = reactive({
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: ['blur', 'change'] },
],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度至少为6位', trigger: 'blur' }
],
});
// 监听 formData 变化,填充表单
watch(() => props.formData, (newVal) => {
if (newVal) {
Object.assign(form, newVal);
form.password = ''; // 编辑时清空密码
} else {
// 新增时重置表单
resetForm();
}
}, { immediate: true });
// 重置表单
const resetForm = () => {
form.id = null;
form.username = '';
form.email = '';
form.status = 1;
form.password = '';
userFormRef.value?.resetFields();
};
// 弹窗关闭时重置表单
const handleClose = () => {
resetForm();
};
// 提交表单
const handleSubmit = async () => {
if (!userFormRef.value) return;
await userFormRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true;
try {
// 模拟 API 请求
await new Promise(resolve => setTimeout(resolve, 800));
if (isEdit.value) {
console.log('更新用户:', form);
ElMessage.success('用户更新成功!');
} else {
console.log('新增用户:', form);
ElMessage.success('用户新增成功!');
}
emit('submit-success'); // 通知父组件刷新列表
dialogVisible.value = false; // 关闭弹窗
} catch (error) {
ElMessage.error('操作失败,请重试!');
console.error(error);
} finally {
submitLoading.value = false;
}
} else {
ElMessage.error('请检查表单填写是否完整和正确!');
return false;
}
});
};
</script>
<style scoped></style>
我们将使用Element Plus的ElUpload组件来实现文件上传。
<!-- src/views/components/FileUpload.vue -->
<template>
<el-upload class="upload-demo" :action="uploadUrl" :headers="uploadHeaders" :on-preview="handlePreview"
:on-remove="handleRemove" :before-remove="beforeRemove" :on-success="handleSuccess" :on-error="handleError"
:on-progress="handleProgress" :on-exceed="handleExceed" :file-list="fileList" list-type="picture-card"
:limit="limit" :auto-upload="autoUpload" :http-request="customUploadRequest">
<el-icon v-if="fileList.length < limit">
<i-ep-plus />
</el-icon>
<template #file="{ file }">
<div class="el-upload-list__item-thumbnail-wrapper">
<img class="el-upload-list__item-thumbnail" :src="file.url" alt="" />
<span class="el-upload-list__item-actions">
<span class="el-upload-list__item-preview" @click="handlePreview(file)">
<el-icon><i-ep-zoom-in /></el-icon>
</span>
<span v-if="!disabled" class="el-upload-list__item-delete" @click="handleRemove(file)">
<el-icon><i-ep-delete /></el-icon>
</span>
</span>
</div>
</template>
</el-upload>
<el-dialog v-model="dialogVisible">
<img w-full :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAuthStore } from '@/stores/auth'; // 用于获取 token
const props = defineProps<{
modelValue: string | string[]; // v-model 绑定文件URL(s)
limit?: number; // 允许上传文件数量
autoUpload?: boolean; // 是否自动上传
fileType?: string[]; // 允许的文件类型,例如 ['image/jpeg', 'image/png']
fileSize?: number; // 文件大小限制 (MB)
}>();
const emit = defineEmits(['update:modelValue', 'upload-success']);
const authStore = useAuthStore();
const uploadUrl = import.meta.env.VITE_APP_BASE_API + '/upload'; // 替换为您的上传接口
const uploadHeaders = {
Authorization: `Bearer ${authStore.token}`,
};
const fileList = ref<any[]>([]);
const dialogImageUrl = ref('');
const dialogVisible = ref(false);
const disabled = ref(false);
// 监听 modelValue 变化,初始化 fileList
watch(() => props.modelValue, (newVal) => {
if (Array.isArray(newVal)) {
fileList.value = newVal.map(url => ({ name: url.split('/').pop(), url, status: 'success' }));
} else if (typeof newVal === 'string' && newVal) {
fileList.value = [{ name: newVal.split('/').pop(), url: newVal, status: 'success' }];
} else {
fileList.value = [];
}
}, { immediate: true });
// 自定义上传请求 (如果您有特殊需求,例如上传到第三方云存储)
const customUploadRequest = async (options: any) => {
const { file, onSuccess, onError, onProgress } = options;
const formData = new FormData();
formData.append('file', file);
// 模拟上传请求
const xhr = new XMLHttpRequest();
xhr.open('POST', uploadUrl, true);
for (const key in uploadHeaders) {
xhr.setRequestHeader(key, (uploadHeaders as any)[key]);
}
xhr.upload.onprogress = (e) => {
onProgress({ percent: (e.loaded / e.total) * 100 }, file);
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
const response = JSON.parse(xhr.responseText);
if (response.code === 200) {
onSuccess(response, file); // 传递后端返回的实际文件URL
} else {
onError(new Error(response.message || '上传失败'), file);
}
} else {
onError(new Error('上传失败,网络错误'), file);
}
};
xhr.onerror = (e) => {
onError(e, file);
};
xhr.send(formData);
};
// 预览文件
const handlePreview = (file: any) => {
dialogImageUrl.value = file.url;
dialogVisible.value = true;
};
// 移除文件前的钩子
const beforeRemove = (file: any) => {
return ElMessageBox.confirm(`确定移除 ${file.name}?`);
};
// 移除文件后的钩子
const handleRemove = (file: any) => {
const newFileList = fileList.value.filter(item => item.uid !== file.uid);
fileList.value = newFileList;
updateModelValue(newFileList);
};
// 文件上传成功钩子
const handleSuccess = (response: any, file: any) => {
// response 是后端返回的数据,假设包含文件 URL
file.url = response.data.url; // 更新文件的URL
fileList.value = fileList.value.map(item => item.uid === file.uid ? file : item); // 更新 fileList 中的文件对象
updateModelValue(fileList.value);
ElMessage.success(`${file.name} 上传成功!`);
emit('upload-success', file.url);
};
// 文件上传失败钩子
const handleError = (error: any, file: any) => {
ElMessage.error(`${file.name} 上传失败!${error.message}`);
};
// 文件上传进度钩子
const handleProgress = (event: any, file: any) => {
// console.log(`文件 ${file.name} 上传进度: ${event.percent}%`);
};
// 文件超出限制钩子
const handleExceed = (files: any, uploadFiles: any) => {
ElMessage.warning(`当前限制选择 ${props.limit} 个文件,本次选择了 ${files.length} 个文件,共 ${files.length + uploadFiles.length} 个`);
};
// 更新 v-model 绑定的值
const updateModelValue = (currentFileList: any[]) => {
if (props.limit === 1) {
emit('update:modelValue', currentFileList.length > 0 ? currentFileList.url : '');
} else {
emit('update:modelValue', currentFileList.map(file => file.url));
}
};
</script>
<style scoped>
/* 调整上传框的样式 */
.upload-demo :deep(.el-upload--picture-card) {
width: 100px;
height: 100px;
}
.upload-demo :deep(.el-upload-list__item) {
width: 100px;
height: 100px;
}
</style>
在组件中使用:
<template>
<div>
<h3>单图上传</h3>
<FileUpload v-model="singleImageUrl" :limit="1" />
<p>图片URL: {{ singleImageUrl }}</p>
<h3 class="mt-4">多图上传</h3>
<FileUpload v-model="multipleImageUrls" :limit="3" />
<p>图片URLs: {{ multipleImageUrls }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import FileUpload from '@/views/components/FileUpload.vue'; // 引入文件上传组件
const singleImageUrl = ref('');
const multipleImageUrls = ref<string[]>([]);
</script>
集成富文本编辑器可以方便用户编辑复杂内容,例如文章、产品描述等。wangEditor和Quill是常见的选择。这里以wangEditor为例。
安装 wangEditor:
pnpm install @wangeditor/editor @wangeditor/editor-for-vue@next
创建富文本编辑器组件 (src/components/WangEditor.vue):
<!-- src/components/WangEditor.vue -->
<template>
<div class="editor-container" :style="{ border: '1px solid #ccc', zIndex: editorZIndex }">
<Toolbar :editor="editorRef" :defaultConfig="toolbarConfig" :mode="mode"
class="editor-toolbar border-b border-gray-200" />
<Editor :defaultConfig="editorConfig" :mode="mode" v-model="valueHtml" @onCreated="handleCreated"
@onChange="handleChange" class="editor-content" :style="{ height: editorHeight }" />
</div>
</template>
<script setup lang="ts">
import '@wangeditor/editor/dist/css/style.css'; // 引入 css
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue';
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
import { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
import { useAuthStore } from '@/stores/auth';
const props = defineProps({
modelValue: {
type: String,
default: '',
},
height: {
type: String,
default: '300px', // 编辑器高度
},
zIndex: {
type: Number,
default: 100, // z-index
},
mode: {
type: String as () => 'default' | 'simple',
default: 'default', // 编辑器模式
},
uploadImgServer: {
type: String,
default: import.meta.env.VITE_APP_BASE_API + '/upload/editor', // 图片上传接口
},
uploadVideoServer: {
type: String,
default: import.meta.env.VITE_APP_BASE_API + '/upload/editor', // 视频上传接口
}
});
const emit = defineEmits(['update:modelValue', 'onChange']);
const authStore = useAuthStore();
// 编辑器实例,必须用 shallowRef
const editorRef = shallowRef<IDomEditor>();
// 内容 HTML
const valueHtml = ref(props.modelValue);
// 监听 modelValue 变化
watch(() => props.modelValue, (newVal) => {
if (newVal !== valueHtml.value && editorRef.value) {
valueHtml.value = newVal;
}
});
const editorHeight = computed(() => props.height);
const editorZIndex = computed(() => props.zIndex);
// 工具栏配置
const toolbarConfig: Partial<IToolbarConfig> = {
excludeKeys: [
// 'fullScreen', // 移除全屏按钮
],
};
// 编辑器配置
const editorConfig: Partial<IEditorConfig> = {
placeholder: '请输入内容...',
readOnly: false,
MENU_CONF: {
uploadImage: {
server: props.uploadImgServer,
fieldName: 'file', // 上传文件字段名
headers: {
Authorization: `Bearer ${authStore.token}`,
},
// 自定义上传文件名称
customInsert(res: any, insertFn: Function) {
// res 即后端返回的接口结果
// insertFn 是一个函数,会把图片插入到编辑器中
// 假设 res.data.url 是图片 url
if (res.code === 200 && res.data && res.data.url) {
insertFn(res.data.url, res.data.alt || '', res.data.href || res.data.url);
} else {
ElMessage.error(res.message || '图片上传失败');
}
},
},
uploadVideo: {
server: props.uploadVideoServer,
fieldName: 'file',
headers: {
Authorization: `Bearer ${authStore.token}`,
},
customInsert(res: any, insertFn: Function) {
if (res.code === 200 && res.data && res.data.url) {
insertFn(res.data.url, res.data.poster || ''); // video url, poster url
} else {
ElMessage.error(res.message || '视频上传失败');
}
},
},
},
};
// 编辑器创建之后
const handleCreated = (editor: IDomEditor) => {
editorRef.value = editor; // 记录 editor 实例,重要!
// 首次创建时如果 modelValue 有值,则同步
if (props.modelValue) {
editor.setHtml(props.modelValue);
}
};
// 编辑器内容改变时
const handleChange = (editor: IDomEditor) => {
valueHtml.value = editor.getHtml();
emit('update:modelValue', valueHtml.value);
emit('onChange', valueHtml.value);
};
// 组件销毁时,及时销毁编辑器
onBeforeUnmount(() => {
const editor = editorRef.value;
if (editor == null) return;
editor.destroy();
});
</script>
<style scoped>
.editor-container {
/* border: 1px solid #ccc; */
}
.editor-toolbar {
/* border-bottom: 1px solid #ccc; */
}
.editor-content {
/* height: 300px; */
overflow-y: hidden;
/* 隐藏编辑器自身的滚动条,让外部容器控制 */
}
</style>
注意:
uploadImgServer和uploadVideoServer需要配置您的文件上传接口。customInsert函数需要根据后端返回的数据结构来解析文件URL并插入到编辑器中。fieldName是后端接收文件参数的名称,通常为file。vite.config.ts中配置/upload/editor的代理。在页面中使用:
<!-- src/views/ArticleForm.vue -->
<template>
<div class="p-4 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold mb-4">文章编辑</h2>
<el-form :model="articleForm" label-width="80px">
<el-form-item label="文章标题">
<el-input v-model="articleForm.title" />
</el-form-item>
<el-form-item label="文章内容">
<WangEditor v-model="articleForm.content" height="400px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveArticle">保存文章</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import WangEditor from '@/components/WangEditor.vue';
import { ElMessage } from 'element-plus';
const articleForm = reactive({
title: '',
content: '<p>初始内容...</p>', // 初始内容
});
const saveArticle = () => {
console.log('保存文章:', articleForm);
ElMessage.success('文章保存成功!');
// 实际调用API将 articleForm 发送到后端
};
</script>
本部分将深入讲解后台管理系统中复杂的权限管理机制,并介绍一些常用的性能优化和开发调试技巧。
权限管理是后台管理系统的核心。我们将采用基于角色的权限控制(RBAC),实现前端的路由级和元素级权限。
基本思路:
用户登录获取权限信息: 登录成功后,后端返回用户的角色信息和/或可访问的菜单/路由列表。
Pinia 存储权限: 将这些权限信息存储到Pinia Store中。
动态路由: 根据用户的权限动态生成可访问的路由表。
路由守卫: 在路由跳转前判断用户是否有权访问目标路由。
自定义指令/组件: 针对页面内的按钮、表格列等元素进行权限控制。
权限 Pinia Store (src/stores/permission.ts):
// src/stores/permission.ts
import { defineStore } from 'pinia';
import { RouteRecordRaw } from 'vue-router';
import { asyncRoutes, constantRoutes } from '@/router'; // 导入静态和动态路由
/**
* 判断用户是否具有某个权限
* @param roles 用户角色数组
* @param route 路由对象
* @returns boolean
*/
function hasPermission(roles: string[], route: RouteRecordRaw) {
if (route.meta && route.meta.roles) {
// 如果路由定义了角色,则检查用户是否至少有一个匹配的角色
return roles.some(role => (route.meta?.roles as string[]).includes(role));
} else {
// 如果路由没有定义角色,则默认所有角色都可访问
return true;
}
}
/**
* 递归过滤异步路由表
* @param routes 完整的异步路由表
* @param roles 用户角色数组
* @returns 过滤后的路由表
*/
function filterAsyncRoutes(routes: RouteRecordRaw[], roles: string[]): RouteRecordRaw[] {
const res: RouteRecordRaw[] = [];
routes.forEach(route => {
const tmp = { ...route }; // 复制路由对象,避免直接修改
if (hasPermission(roles, tmp)) {
if (tmp.children) {
tmp.children = filterAsyncRoutes(tmp.children, roles);
}
res.push(tmp);
}
});
return res;
}
export const usePermissionStore = defineStore('permission', {
state: () => ({
routes: [] as RouteRecordRaw[], // 用户最终可访问的路由表 (包含静态和动态)
addRoutes: [] as RouteRecordRaw[], // 用户动态添加的路由 (异步路由)
}),
actions: {
/**
* 根据用户角色生成可访问路由
* @param roles 用户角色数组
* @returns 过滤后的异步路由表
*/
generateRoutes(roles: string[]): Promise<RouteRecordRaw[]> {
return new Promise(resolve => {
let accessedRoutes: RouteRecordRaw[];
if (roles.includes('admin')) {
// 如果是管理员,则拥有所有异步路由
accessedRoutes = asyncRoutes || [];
} else {
// 否则根据角色过滤异步路由
accessedRoutes = filterAsyncRoutes(asyncRoutes, roles);
}
this.addRoutes = accessedRoutes;
// 最终的路由表是静态路由 + 过滤后的异步路由
this.routes = constantRoutes.concat(accessedRoutes);
resolve(accessedRoutes);
});
},
// 清除权限路由
clearRoutes() {
this.routes = [];
this.addRoutes = [];
}
},
});
路由配置 (src/router/index.ts):
将路由分为constantRoutes(静态路由,所有用户可见)和asyncRoutes(异步路由,需要权限)。
// src/router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { usePermissionStore } from '@/stores/permission';
import { ElMessage } from 'element-plus';
import NProgress from 'nprogress'; // 进度条
import 'nprogress/nprogress.css'; // 进度条样式
NProgress.configure({ showSpinner: false }); // 禁用加载圆圈
// 静态路由:所有用户都可以访问
export const constantRoutes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { title: '登录', hidden: true } // hidden: true 表示不在菜单中显示
},
{
path: '/',
name: 'Layout',
component: () => import('@/layout/index.vue'),
redirect: '/dashboard',
meta: { hidden: true },
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '仪表盘', icon: 'HomeFilled', cache: true }
}
]
},
// 404 页面必须放在最后
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFound.vue'),
meta: { title: '404 Not Found', hidden: true }
}
];
// 异步路由:需要根据权限动态加载
export const asyncRoutes: RouteRecordRaw[] = [
{
path: '/system',
name: 'System',
component: () => import('@/layout/index.vue'), // 布局组件
redirect: '/system/user',
meta: { title: '系统管理', icon: 'Setting', roles: ['admin', 'editor'] }, // 只有admin和editor角色可见
children: [
{
path: 'user',
name: 'UserManage',
component: () => import('@/views/system/UserManage.vue'),
meta: { title: '用户管理', icon: 'UserFilled', roles: ['admin'], cache: true } // 只有admin角色可见
},
{
path: 'role',
name: 'RoleManage',
component: () => import('@/views/system/RoleManage.vue'),
meta: { title: '角色管理', icon: 'Lock', roles: ['admin'] }
},
{
path: 'menu',
name: 'MenuManage',
component: () => import('@/views/system/MenuManage.vue'), // 假设有这个组件
meta: { title: '菜单管理', icon: 'Operation', roles: ['admin'] }
},
{
path: 'log',
name: 'SystemLog',
component: () => import('@/views/system/SystemLog.vue'), // 假设有这个组件
meta: { title: '系统日志', icon: 'Document', roles: ['admin', 'auditor'] }
}
]
},
{
path: '/products',
name: 'Products',
component: () => import('@/layout/index.vue'),
redirect: '/products/list',
meta: { title: '商品管理', icon: 'Goods', roles: ['admin', 'editor'] },
children: [
{
path: 'list',
name: 'ProductList',
component: () => import('@/views/products/ProductList.vue'), // 假设有这个组件
meta: { title: '商品列表', icon: 'List', cache: true }
},
{
path: 'category',
name: 'ProductCategory',
component: () => import('@/views/products/ProductCategory.vue'), // 假设有这个组件
meta: { title: '商品分类', icon: 'Collection' }
}
]
},
// ... 更多异步路由
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: constantRoutes // 初始只加载静态路由
});
// 重置路由,用于退出登录时清空动态添加的路由
export function resetRouter() {
const newRouter = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: constantRoutes,
});
(router as any).options.routes = constantRoutes; // 刷新 router 的 options.routes
router.getRoutes().forEach(route => {
if (route.name && !constantRoutes.some(c => c.name === route.name)) {
router.removeRoute(route.name);
}
});
}
// 全局前置守卫
const whiteList = ['/login']; // 无需登录即可访问的白名单路由
router.beforeEach(async (to, from, next) => {
NProgress.start(); // 开启进度条
document.title = to.meta.title ? `${to.meta.title} | 后台管理` : '后台管理';
const authStore = useAuthStore();
const permissionStore = usePermissionStore();
if (authStore.token) { // 已登录
if (to.path === '/login') {
next({ path: '/' }); // 已登录,不允许访问登录页,重定向到首页
NProgress.done();
} else {
// 检查用户角色是否已加载(通过判断 permissionStore.addRoutes 是否为空)
if (permissionStore.addRoutes.length === 0) {
try {
// 模拟获取用户角色 (实际应从后端获取)
const roles = authStore.userInfo.roles || ['admin']; // 假设登录后会将角色存在 userInfo 中
// 根据角色生成可访问路由表
const accessedRoutes = await permissionStore.generateRoutes(roles);
// 动态添加路由
accessedRoutes.forEach(route => {
router.addRoute(route);
});
// Hack: 确保路由已经完全添加
// set the replace: true, so the navigation will not leave a history record
next({ ...to, replace: true });
} catch (error) {
console.error('动态路由生成失败:', error);
ElMessage.error('获取用户权限失败,请重新登录!');
authStore.clearToken();
permissionStore.clearRoutes(); // 清空路由
next(`/login?redirect=${to.path}`); // 返回登录页
NProgress.done();
}
} else {
next(); // 路由已加载,直接放行
}
}
} else { // 未登录
if (whiteList.includes(to.path)) {
next(); // 在白名单中,直接放行
} else {
// 不在白名单中且未登录,重定向到登录页
next(`/login?redirect=${to.path}`);
NProgress.done();
}
}
});
router.afterEach(() => {
NProgress.done(); // 结束进度条
});
export default router;
注意:
router.addRoute()用于动态添加路由。resetRouter()用于退出登录时清除动态路由,避免污染。NProgress是一个轻量级的页面加载进度条,提供良好的用户体验。前端按钮/菜单级权限控制(自定义指令):
创建一个自定义指令v-permission来控制元素的显示与隐藏。
// src/directives/permission.ts
import { App, DirectiveBinding } from 'vue';
import { useAuthStore } from '@/stores/auth'; // 假设 auth store 存储用户角色
export const permission = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const { value } = binding; // value 应该是一个字符串或数组,表示所需的角色
if (value) {
const authStore = useAuthStore();
const userRoles = authStore.userInfo.roles || []; // 获取当前用户角色
// 如果指令的值是数组,表示需要其中任意一个角色
// 如果指令的值是字符串,表示需要该特定角色
const hasRequiredRole = Array.isArray(value)
? userRoles.some(role => value.includes(role))
: userRoles.includes(value);
if (!hasRequiredRole) {
el.parentNode && el.parentNode.removeChild(el); // 如果没有权限,则移除元素
}
} else {
// 如果没有传递 value,或者 value 为空,默认不隐藏
console.warn(`[Permissions]: no value given for v-permission directive`);
}
},
};
// 在 main.ts 中注册
// import { permission } from '@/directives/permission';
// app.directive('permission', permission);
在组件中使用:
<template>
<div>
<el-button v-permission="['admin']">只有管理员可见</el-button>
<el-button v-permission="'editor'">只有编辑可见</el-button>
<el-button v-permission="['admin', 'editor']">管理员或编辑可见</el-button>
</div>
</template>
通用业务组件的提取与封装:
例如,一个带有搜索、分页、增删改查功能的表格可以封装成一个通用组件。
src/components/CommonTable.vue (示例简化):
<!-- src/components/CommonTable.vue -->
<template>
<div>
<!-- 搜索区插槽 -->
<div class="mb-4">
<slot name="searchForm" :searchForm="searchForm" :handleSearch="handleSearch" :handleResetSearch="handleResetSearch"></slot>
</div>
<!-- 操作按钮区插槽 -->
<div class="mb-4">
<slot name="actions" :selectedRows="selectedRows"></slot>
</div>
<!-- 表格 -->
<el-table :data="tableData" border v-loading="loading" @selection-change="handleSelectionChange">
<el-table-column v-if="showSelection" type="selection" width="55" />
<slot></slot> <!-- 用于插入 el-table-column -->
</el-table>
<!-- 分页 -->
<div class="flex justify-end mt-4">
<el-pagination v-model:currentPage="pagination.currentPage" v-model:page-size="pagination.pageSize"
:page-sizes="pageSizes" :total="pagination.total" layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange" @current-change="handleCurrentChange" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
const props = defineProps({
requestApi: { // 获取列表数据的API函数
type: Function,
required: true,
},
queryParams: { // 额外的查询参数
type: Object,
default: () => ({}),
},
showSelection: {
type: Boolean,
default: true,
},
pageSizes: {
type: Array as () => number[],
default: () => ,
},
});
const emit = defineEmits(['data-loaded', 'selection-change']);
const searchForm = reactive({}); // 搜索表单数据,通过插槽传递给父组件管理
const pagination = reactive({
currentPage: 1,
pageSize: props.pageSizes || 10,
total: 0,
});
const tableData = ref<any[]>([]);
const loading = ref(false);
const selectedRows = ref<any[]>([]);
// 组合所有查询参数
const mergedParams = computed(() => ({
...props.queryParams,
...searchForm, // 搜索表单的值
currentPage: pagination.currentPage,
pageSize: pagination.pageSize,
}));
// 获取数据列表
const fetchData = async () => {
loading.value = true;
try {
const res: any = await props.requestApi(mergedParams.value);
tableData.value = res.list;
pagination.total = res.total;
emit('data-loaded', res);
} catch (error) {
ElMessage.error('获取数据失败');
console.error(error);
} finally {
loading.value = false;
}
};
// 搜索
const handleSearch = () => {
pagination.currentPage = 1;
fetchData();
};
// 重置搜索 (由父组件实现具体重置逻辑)
const handleResetSearch = () => {
// 可以在这里重置searchForm,或者交给父组件通过绑定控制
fetchData();
};
// 分页
const handleSizeChange = (val: number) => {
pagination.pageSize = val;
fetchData();
};
const handleCurrentChange = (val: number) => {
pagination.currentPage = val;
fetchData();
};
// 选中行变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection;
emit('selection-change', selection);
};
onMounted(() => {
fetchData();
});
// 暴露方法给父组件
defineExpose({
fetchData, // 允许父组件手动刷新数据
searchForm // 允许父组件直接修改 searchForm
});
</script>
使用示例:
<!-- src/views/system/UserManage.vue (更新后) -->
<template>
<div class="p-4 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold mb-4">用户管理</h2>
<CommonTable :request-api="fetchUserListApi" :query-params="commonQueryParams">
<!-- 搜索表单插槽 -->
<template #searchForm="{ searchForm, handleSearch, handleResetSearch }">
<el-form :inline="true" :model="searchForm" class="mb-4">
<el-form-item label="用户名">
<el-input v-model="searchForm.username" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="启用" value="1" />
<el-option label="禁用" value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="handleResetSearch">重置</el-button>
</el-form-item>
</el-form>
</template>
<!-- 操作按钮插槽 -->
<template #actions="{ selectedRows }">
<div class="flex space-x-2">
<el-button type="primary" :icon="Plus" @click="handleAdd">新增用户</el-button>
<el-button type="danger" :icon="Delete" :disabled="selectedRows.length === 0" @click="handleBatchDelete(selectedRows)">
批量删除
</el-button>
<el-button :icon="Download">导出</el-button>
</div>
</template>
<!-- 表格列插槽 -->
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button link type="primary" :icon="Edit" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" :icon="Delete" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</CommonTable>
<!-- 用户编辑/新增弹窗 -->
<UserFormDialog v-model:visible="dialogVisible" :form-data="currentEditUser" @submit-success="commonTableRef?.fetchData()" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Search, Refresh, Plus, Delete, Edit, Download } from '@element-plus/icons-vue';
import CommonTable from '@/components/CommonTable.vue'; // 导入通用表格
import UserFormDialog from './UserFormDialog.vue'; // 用户新增/编辑弹窗组件
// 模拟后端 API 请求 (UserManage 内部提供)
const fetchUserListApi = async (params: any) => {
console.log('UserManage fetching users with params:', params);
return new Promise(resolve => {
setTimeout(() => {
const totalData = Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
username: `user${i + 1}`,
email: `user${i + 1}@example.com`,
status: i % 3 === 0 ? 0 : 1,
createTime: new Date().toISOString().slice(0, 10),
}));
const filteredData = totalData.filter(user => {
const usernameMatch = params.username ? user.username.includes(params.username) : true;
const statusMatch = params.status !== '' ? String(user.status) === params.status : true;
return usernameMatch && statusMatch;
});
const startIndex = (params.currentPage - 1) * params.pageSize;
const endIndex = startIndex + params.pageSize;
const list = filteredData.slice(startIndex, endIndex);
resolve({
list,
total: filteredData.length,
});
}, 500);
});
};
const commonTableRef = ref<InstanceType<typeof CommonTable> | null>(null);
// 弹窗相关
const dialogVisible = ref(false);
const currentEditUser = ref<any>(null); // 当前编辑的用户数据
// 通用查询参数(如果 CommonTable 有外部公共参数)
const commonQueryParams = reactive({
// example: 'someValue'
});
// 新增用户
const handleAdd = () => {
currentEditUser.value = null;
dialogVisible.value = true;
};
// 编辑用户
const handleEdit = (row: any) => {
currentEditUser.value = { ...row };
dialogVisible.value = true;
};
// 删除用户
const handleDelete = async (row: any) => {
await ElMessageBox.confirm(`确定删除用户 "${row.username}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
ElMessage.success(`用户 "${row.username}" 删除成功!`);
commonTableRef.value?.fetchData(); // 通过 ref 调用 CommonTable 内部的刷新方法
};
// 批量删除
const handleBatchDelete = async (selectedRows: any[]) => {
if (selectedRows.length === 0) {
ElMessage.warning('请选择要删除的用户!');
return;
}
await ElMessageBox.confirm(`确定删除选中的 ${selectedRows.length} 个用户吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
ElMessage.success(`成功删除 ${selectedRows.length} 个用户!`);
commonTableRef.value?.fetchData(); // 刷新列表
};
</script>
自定义 Hooks (Composition API):
封装通用逻辑,如表单操作、弹窗管理、数据加载等。
src/composables/useCrud.ts (简化版):
// src/composables/useCrud.ts
import { ref, reactive, computed } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
interface CrudOptions<T, S> {
api: {
getList: (params: S) => Promise<{ list: T[]; total: number }>;
add?: (data: T) => Promise<any>;
update?: (data: T) => Promise<any>;
del?: (id: string | number) => Promise<any>;
batchDel?: (ids: (string | number)[]) => Promise<any>;
};
initialSearchForm: S;
initialEditForm: T; // 用于新增时的表单初始化
}
export function useCrud<T extends { id?: string | number }, S>(options: CrudOptions<T, S>) {
const tableData = ref<T[]>([]);
const loading = ref(false);
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0,
});
const searchForm = reactive({ ...options.initialSearchForm });
const dialogVisible = ref(false);
const isEdit = ref(false);
const editForm = reactive<T>({ ...options.initialEditForm });
const selectedRows = ref<T[]>([]);
// 获取列表数据
const fetchList = async () => {
loading.value = true;
try {
const res = await options.api.getList({
...searchForm,
currentPage: pagination.currentPage,
pageSize: pagination.pageSize,
});
tableData.value = res.list;
pagination.total = res.total;
} catch (error) {
ElMessage.error('获取数据失败');
console.error(error);
} finally {
loading.value = false;
}
};
// 搜索
const handleSearch = () => {
pagination.currentPage = 1;
fetchList();
};
// 重置搜索
const handleResetSearch = () => {
Object.assign(searchForm, options.initialSearchForm);
handleSearch();
};
// 每页条数变化
const handleSizeChange = (size: number) => {
pagination.pageSize = size;
fetchList();
};
// 当前页变化
const handleCurrentChange = (page: number) => {
pagination.currentPage = page;
fetchList();
};
// 表格多选变化
const handleSelectionChange = (selection: T[]) => {
selectedRows.value = selection;
};
// 新增操作
const handleAdd = () => {
isEdit.value = false;
Object.assign(editForm, options.initialEditForm); // 重置表单
dialogVisible.value = true;
};
// 编辑操作
const handleEdit = (row: T) => {
isEdit.value = true;
Object.assign(editForm, row); // 填充表单
dialogVisible.value = true;
};
// 删除操作
const handleDelete = async (row: T) => {
if (!options.api.del || !row.id) {
ElMessage.warning('删除API未定义或ID不存在!');
return;
}
await ElMessageBox.confirm(`确定删除 ${row.id} 吗?`, '提示', { type: 'warning' });
try {
await options.api.del(row.id);
ElMessage.success('删除成功!');
fetchList();
} catch (error) {
ElMessage.error('删除失败');
}
};
// 批量删除
const handleBatchDelete = async () => {
if (!options.api.batchDel) {
ElMessage.warning('批量删除API未定义!');
return;
}
if (selectedRows.value.length === 0) {
ElMessage.warning('请选择要删除的项!');
return;
}
await ElMessageBox.confirm(`确定删除选中的 ${selectedRows.value.length} 项吗?`, '提示', { type: 'warning' });
try {
const ids = selectedRows.value.map(row => row.id as string | number);
await options.api.batchDel(ids);
ElMessage.success('批量删除成功!');
selectedRows.value = [];
fetchList();
} catch (error) {
ElMessage.error('批量删除失败');
}
};
// 提交表单 (新增/编辑)
const handleSubmitForm = async (formInstance: any) => {
if (!formInstance) return;
await formInstance.validate(async (valid: boolean) => {
if (valid) {
try {
if (isEdit.value && options.api.update) {
await options.api.update(editForm);
ElMessage.success('更新成功!');
} else if (!isEdit.value && options.api.add) {
await options.api.add(editForm);
ElMessage.success('新增成功!');
} else {
ElMessage.warning('操作API未定义!');
return;
}
dialogVisible.value = false;
fetchList(); // 刷新列表
} catch (error) {
ElMessage.error('操作失败');
}
}
});
};
return {
tableData,
loading,
pagination,
searchForm,
dialogVisible,
isEdit,
editForm,
selectedRows,
fetchList,
handleSearch,
handleResetSearch,
handleSizeChange,
handleCurrentChange,
handleSelectionChange,
handleAdd,
handleEdit,
handleDelete,
handleBatchDelete,
handleSubmitForm,
};
}
使用 useCrud 的组件会变得非常简洁。
路由懒加载 (import()):
在 src/router/index.ts 中,我们已经使用了路由懒加载:
component: () => import('@/views/Dashboard.vue') // 只有当路由被访问时才加载对应的组件
这将每个路由组件打包成独立的JS文件,减少初始加载时的资源大小。
Element Plus 组件按需加载:
Vite 脚手架结合 unplugin-vue-components 和 unplugin-auto-import 已经实现了Element Plus的按需导入。您无需手动导入每个组件,工具会自动识别并按需打包。
图片懒加载:
对于大量图片,可以使用浏览器原生的loading="lazy"属性或第三方库。
<img src="your-image.jpg" loading="lazy" alt="Lazy Loaded Image">
或者使用Intersection Observer API自行实现。
虚拟滚动(长列表优化):
当列表数据量巨大时,只渲染可视区域的DOM可以极大提升性能。推荐使用成熟的库,如vue-virtual-scroller。
pnpm install vue-virtual-scroller
<template>
<RecycleScroller class="scroller" :items="items" :item-size="50" key-field="id">
<template #default="{ item }">
<div class="user-item">
{{ item.id }} - {{ item.name }}
</div>
</template>
</RecycleScroller>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
const items = ref(
Array.from({ length: 10000 }, (_, i) => ({ id: i, name: `User ${i}` }))
);
</script>
<style scoped>
.scroller {
height: 400px; /* 必须设置高度 */
overflow-y: auto;
}
.user-item {
height: 50px; /* 必须设置 item-size */
display: flex;
align-items: center;
padding-left: 10px;
border-bottom: 1px solid #eee;
}
</style>
json-server: 快速搭建RESTful API服务器。pnpm install -g json-server
json-server --watch db.json --port 3000
db.json示例:{
"users": [
{ "id": 1, "username": "admin", "email": "[email protected]" },
{ "id": 2, "username": "editor", "email": "[email protected]" }
],
"login": {
"token": "mock-token-admin",
"user": { "username": "admin", "roles": ["admin"] }
}
}
您的Axios代理可以指向http://localhost:3000。mock.js: 在前端拦截并模拟请求。适用于更复杂的Mock场景。pnpm install mockjs
// src/mock/index.ts
import Mock from 'mockjs';
// 登录接口
Mock.mock('/api/login', 'post', {
'code': 200,
'message': '登录成功',
'data': {
'token': 'mock-admin-token-' + Mock.Random.guid(),
'user': {
'username': 'admin',
'roles': ['admin'],
'avatar': 'https://cube.elemecdn.com/0/88/03b0dff30d500609ae7455a09d6f6png.png'
}
}
});
// 用户列表
Mock.mock(/\/api\/users(\?.*)?/, 'get', (options: any) => {
const query = new URLSearchParams(options.url.split('?'));
const currentPage = parseInt(query.get('currentPage') || '1');
const pageSize = parseInt(query.get('pageSize') || '10');
const username = query.get('username') || '';
const status = query.get('status') || '';
const list = Mock.mock({
'items|100': [
{
'id|+1': 1,
'username': '@first',
'email': '@email',
'status|1': , // 0: 禁用, 1: 启用
'createTime': '@date("yyyy-MM-dd HH:mm:ss")'
}
]
}).items;
const filteredList = list.filter((item: any) => {
const nameMatch = username ? item.username.includes(username) : true;
const statusMatch = status !== '' ? String(item.status) === status : true;
return nameMatch && statusMatch;
});
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedList = filteredList.slice(startIndex, endIndex);
return {
code: 200,
message: '获取成功',
data: {
list: paginatedList,
total: filteredList.length
}
};
});
// 在 main.ts 中引入
// import '@/mock';
Vite 构建优化:
在package.json中,build脚本通常是vite build。
vite.config.ts中针对build选项进行更多配置。// vite.config.ts
export default defineConfig({
// ...
build: {
outDir: 'dist', // 构建输出目录
assetsDir: 'static', // 静态资源目录
minify: 'esbuild', // 'terser' 更彻底,但速度慢
rollupOptions: {
output: {
// 控制 chunk 命名,避免 vendor.js 过大
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('element-plus')) {
return 'vendor_element_plus';
}
if (id.includes('vue') || id.includes('vue-router') || id.includes('pinia')) {
return 'vendor_vue';
}
return 'vendor';
}
},
},
},
},
});
Nginx 部署基础配置(History 模式支持):
当使用Vue Router的History模式时,为了避免刷新页面出现404,Nginx需要配置try_files。
# nginx.conf 片段
server {
listen 80;
server_name your_domain.com; # 替换为你的域名
root /usr/share/nginx/html; # 你的前端项目打包后的 dist 目录路径
location / {
# 尝试访问文件或目录,如果不存在,则返回 index.html
try_files $uri $uri/ /index.html;
}
# 如果你的后端API也在此服务器上,可以配置代理
location /api/ {
proxy_pass http://localhost:3000/; # 你的后端API地址和端口
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
Docker 部署简介(可选): 使用Docker可以将应用及其所有依赖打包成一个独立的、可移植的容器。
# 阶段1: 构建阶段
FROM node:18-alpine as builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./ # 复制依赖文件
RUN pnpm install --frozen-lockfile # 安装依赖
COPY . . # 复制项目代码
RUN pnpm build # 执行构建命令
# 阶段2: 运行阶段
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html # 复制构建好的静态文件到Nginx目录
COPY nginx.conf /etc/nginx/conf.d/default.conf # 复制自定义Nginx配置
EXPOSE 80 # 暴露80端口
CMD ["nginx", "-g", "daemon off;"] # 启动Nginx
docker build -t my-admin-template .
docker run -p 80:80 my-admin-template
主题切换(Element Plus 自定义主题、Tailwind CSS 主题):
tailwind.config.js中配置darkMode: 'class'来实现深色模式,然后通过添加/移除dark类来切换。也可以通过动态修改主题颜色变量来实现更细粒度的控制。国际化(i18n)简介(集成 vue-i18n):
如果您的后台系统需要支持多语言,vue-i18n是Vue生态系统中最常用的国际化库。
pnpm install vue-i18n
// src/locales/index.ts
import { createI18n } from 'vue-i18n';
import zh from './zh-CN'; // 中文语言包
import en from './en-US'; // 英文语言包
const i18n = createI18n({
locale: 'zh', // 默认语言
fallbackLocale: 'en', // 备用语言
messages: {
zh,
en,
},
});
export default i18n;
// src/locales/zh-CN.ts
export default {
message: {
hello: '你好',
dashboard: '仪表盘',
login: '登录',
username: '用户名',
password: '密码',
// ...更多
},
// 结合 Element Plus 语言包
...ElementPlusLocaleZhCn,
};
在main.ts中注册:app.use(i18n);
在组件中使用:{{ $t('message.dashboard') }}
代码规范与ESLint/Prettier 自动化: 通过Vite创建的项目通常会自带ESLint和Prettier配置。
package.json中可以添加lint和format脚本:"scripts": {
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/",
// ...
}
本部分将以“用户管理”和“角色管理”为例,展示一个完整的CRUD(创建、读取、更新、删除)模块的实现。
5.1.1 用户管理模块
这部分我们已经结合useCrud和CommonTable在前面详细讲解,这里我们只补充后端API模拟的结构和使用方式。
模拟后端 API (src/api/user.ts)
// src/api/user.ts
import request from '@/utils/request';
interface User {
id: number;
username: string;
email: string;
status: 0 | 1; // 0: 禁用, 1: 启用
createTime: string;
}
interface UserListParams {
currentPage: number;
pageSize: number;
username?: string;
status?: string;
}
// 获取用户列表
export const getUserList = (params: UserListParams): Promise<{ list: User[]; total: number }> => {
return request.get('/users', { params }); // 对应 /api/users
};
// 新增用户
export const addUser = (data: Omit<User, 'id' | 'createTime'> & { password: string }): Promise<any> => {
return request.post('/users', data);
};
// 更新用户
export const updateUser = (id: number, data: Partial<User>): Promise<any> => {
return request.put(`/users/${id}`, data);
};
// 删除用户
export const deleteUser = (id: number): Promise<any> => {
return request.del(`/users/${id}`);
};
// 批量删除用户
export const batchDeleteUsers = (ids: number[]): Promise<any> => {
// 实际后端可能接收一个ids数组,或者多次调用删除
// 这里简化为发送一个包含ids的DELETE请求
return request.del('/users/batch', { data: { ids } });
};
后端 Mock 数据 (src/mock/index.ts - 补充 users 的增删改查 Mock)
// src/mock/index.ts (补充)
// ... 其他 Mock 代码
// 新增用户
Mock.mock('/api/users', 'post', (options: any) => {
const body = JSON.parse(options.body);
const newUser = {
id: Mock.Random.increment(),
username: body.username,
email: body.email,
status: body.status,
createTime: Mock.Random.date('yyyy-MM-dd HH:mm:ss')
};
// 实际应该将 newUser 存入一个数组或数据库
return {
code: 200,
message: '新增成功',
data: newUser
};
});
// 更新用户
Mock.mock(/\/api\/users\/\d+/, 'put', (options: any) => {
const id = parseInt(options.url.split('/').pop());
const body = JSON.parse(options.body);
// 实际应该根据 id 更新数据
return {
code: 200,
message: '更新成功',
data: { id, ...body }
};
});
// 删除用户
Mock.mock(/\/api\/users\/\d+/, 'delete', (options: any) => {
const id = parseInt(options.url.split('/').pop());
// 实际应该根据 id 删除数据
return {
code: 200,
message: '删除成功'
};
});
// 批量删除用户 (假设后端接收 { ids: } )
Mock.mock('/api/users/batch', 'delete', (options: any) => {
const body = JSON.parse(options.body);
const ids = body.ids;
if (!ids || ids.length === 0) {
return { code: 400, message: '请提供要删除的ID' };
}
// 实际应该根据 ids 批量删除数据
return {
code: 200,
message: `成功删除 ${ids.length} 条数据`
};
});
src/views/system/UserManage.vue (结合 useCrud 和 CommonTable)
这部分我们在4.2 组件化与复用中已经展示了如何使用CommonTable和useCrud的思路,您可以根据提供的useCrud Hook,进一步将UserManage.vue的逻辑简化。
例如,UserManage.vue可以这样使用useCrud:
<!-- src/views/system/UserManage.vue (使用 useCrud 后) -->
<template>
<div class="p-4 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold mb-4">用户管理</h2>
<el-form :inline="true" :model="searchForm" class="mb-4">
<el-form-item label="用户名">
<el-input v-model="searchForm.username" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="启用" value="1" />
<el-option label="禁用" value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="handleResetSearch">重置</el-button>
</el-form-item>
</el-form>
<div class="mb-4 flex space-x-2">
<el-button type="primary" :icon="Plus" @click="handleAdd">新增用户</el-button>
<el-button type="danger" :icon="Delete" :disabled="selectedRows.length === 0" @click="handleBatchDelete">
批量删除
</el-button>
<el-button :icon="Download">导出</el-button>
</div>
<el-table :data="tableData" border style="width: 100%" v-loading="loading" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button link type="primary" :icon="Edit" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" :icon="Delete" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<el-pagination v-model:currentPage="pagination.currentPage" v-model:page-size="pagination.pageSize"
:page-sizes="" :small="false" :disabled="loading" :background="true"
layout="total, sizes, prev, pager, next, jumper" :total="pagination.total" @size-change="handleSizeChange"
@current-change="handleCurrentChange" />
</div>
<UserFormDialog v-model:visible="dialogVisible" :form-data="editForm"
@submit-success="fetchList" :is-edit="isEdit" />
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { Search, Refresh, Plus, Delete, Edit, Download } from '@element-plus/icons-vue';
import UserFormDialog from './UserFormDialog.vue';
import { useCrud } from '@/composables/useCrud'; // 导入 useCrud
import { getUserList, addUser, updateUser, deleteUser, batchDeleteUsers } from '@/api/user'; // 导入用户 API
interface User {
id?: number;
username: string;
email: string;
status: 0 | 1;
createTime?: string;
password?: string; // 仅在新增时需要
}
interface UserSearchForm {
username: string;
status: string | number;
}
const {
tableData,
loading,
pagination,
searchForm, // searchForm 已经由 useCrud 管理
dialogVisible,
isEdit,
editForm,
selectedRows,
fetchList,
handleSearch,
handleResetSearch,
handleSizeChange,
handleCurrentChange,
handleSelectionChange,
handleAdd,
handleEdit,
handleDelete,
handleBatchDelete,
} = useCrud<User, UserSearchForm>({
api: {
getList: getUserList,
add: addUser,
update: (data) => updateUser(data.id as number, data), // 更新需要ID
del: deleteUser,
batchDel: batchDeleteUsers,
},
initialSearchForm: {
username: '',
status: '',
},
initialEditForm: {
username: '',
email: '',
status: 1,
password: '', // 初始编辑表单包含密码字段
},
});
// 在组件挂载后立即获取数据
onMounted(() => {
fetchList();
});
</script>
<style scoped></style>
UserFormDialog.vue 也需要更新,移除部分 watch 逻辑,因为它现在由 useCrud 驱动,editForm 已经传递过来。
<!-- src/views/system/UserFormDialog.vue (更新后) -->
<template>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" @close="handleClose">
<el-form ref="userFormRef" :model="formData" :rules="rules" label-width="80px">
<el-form-item label="用户名" prop="username">
<el-input v-model="formData.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="formData.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<!-- 密码字段只在新增时显示 -->
<el-form-item label="密码" prop="password" v-if="!isEdit">
<el-input type="password" v-model="formData.password" placeholder="请输入密码" show-password />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
{{ isEdit ? '更新' : '新增' }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue';
import { ElMessage, FormInstance, FormRules } from 'element-plus';
const props = defineProps<{
visible: boolean;
formData: any; // formData 现在直接就是 useCrud 传入的 editForm
isEdit: boolean; // 新增 isEdit prop
}>();
const emit = defineEmits(['update:visible', 'submit-success']);
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
const dialogTitle = computed(() => (props.isEdit ? '编辑用户' : '新增用户'));
const submitLoading = ref(false);
const userFormRef = ref<FormInstance>();
const rules: FormRules = reactive({
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: ['blur', 'change'] },
],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度至少为6位', trigger: 'blur' }
],
});
// 重置表单 (现在只需要重置校验状态,数据由父组件管理)
const handleClose = () => {
userFormRef.value?.resetFields();
};
// 提交表单
const handleSubmit = async () => {
if (!userFormRef.value) return;
await userFormRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true;
try {
// 由于 useCrud 中的 handleSubmitForm 会进行实际的 API 调用
// 这里的组件只需要发出 'submit-success' 事件即可
emit('submit-success', props.formData); // 将当前表单数据传回父组件,父组件会调用 useCrud 的提交方法
dialogVisible.value = false;
ElMessage.success('操作成功!'); // 这里可以改为 successMsg
} catch (error) {
ElMessage.error('操作失败,请重试!'); // 这里可以改为 errorMsg
console.error(error);
} finally {
submitLoading.value = false;
}
} else {
ElMessage.error('请检查表单填写是否完整和正确!');
return false;
}
});
};
// 暴露 submit 方法给父组件 (如果父组件需要直接调用弹窗内部的提交逻辑)
defineExpose({
handleSubmit
});
</script>
<style scoped></style>
5.1.2 角色与权限管理
角色管理与用户管理类似,也可以复用CommonTable和useCrud。关键在于如何将角色与菜单/权限进行关联。
src/api/role.ts (模拟)
// src/api/role.ts
import request from '@/utils/request';
interface Role {
id: number;
name: string; // 角色名称,如 'admin', 'editor'
description?: string;
createTime?: string;
menuIds?: number[]; // 关联的菜单ID列表
permissionIds?: number[]; // 关联的权限点ID列表 (例如API权限)
}
interface RoleListParams {
currentPage: number;
pageSize: number;
name?: string;
}
export const getRoleList = (params: RoleListParams): Promise<{ list: Role[]; total: number }> => {
return request.get('/roles', { params });
};
export const addRole = (data: Omit<Role, 'id' | 'createTime'>): Promise<any> => {
return request.post('/roles', data);
};
export const updateRole = (id: number, data: Partial<Role>): Promise<any> => {
return request.put(`/roles/${id}`, data);
};
export const deleteRole = (id: number): Promise<any> => {
return request.del(`/roles/${id}`);
};
// 获取所有菜单列表 (用于权限分配)
export const getAllMenus = (): Promise<any[]> => {
return request.get('/menus/all');
};
// 分配角色菜单权限
export const assignRoleMenus = (roleId: number, menuIds: number[]): Promise<any> => {
return request.post(`/roles/${roleId}/menus`, { menuIds });
};
src/views/system/RoleManage.vue (示例)
<!-- src/views/system/RoleManage.vue -->
<template>
<div class="p-4 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold mb-4">角色管理</h2>
<el-form :inline="true" :model="searchForm" class="mb-4">
<el-form-item label="角色名称">
<el-input v-model="searchForm.name" placeholder="请输入角色名称" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="handleResetSearch">重置</el-button>
</el-form-item>
</el-form>
<div class="mb-4 flex space-x-2">
<el-button type="primary" :icon="Plus" @click="handleAdd">新增角色</el-button>
<el-button type="danger" :icon="Delete" :disabled="selectedRows.length === 0" @click="handleBatchDelete">
批量删除
</el-button>
</div>
<el-table :data="tableData" border style="width: 100%" v-loading="loading" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="角色名称" />
<el-table-column prop="description" label="描述" />
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" width="250">
<template #default="{ row }">
<el-button link type="primary" :icon="Edit" @click="handleEdit(row)">编辑</el-button>
<el-button link type="warning" :icon="Setting" @click="handleAssignMenus(row)">分配菜单</el-button>
<el-button link type="danger" :icon="Delete" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<el-pagination v-model:currentPage="pagination.currentPage" v-model:page-size="pagination.pageSize"
:page-sizes="" :small="false" :disabled="loading" :background="true"
layout="total, sizes, prev, pager, next, jumper" :total="pagination.total" @size-change="handleSizeChange"
@current-change="handleCurrentChange" />
</div>
<!-- 角色新增/编辑弹窗 -->
<RoleFormDialog v-model:visible="dialogVisible" :form-data="editForm" :is-edit="isEdit"
@submit-success="fetchList" />
<!-- 菜单分配弹窗 -->
<el-dialog v-model="menuAssignDialogVisible" title="分配菜单权限" width="600px">
<el-tree ref="menuTreeRef" :data="menuTreeData" show-checkbox node-key="id" default-expand-all :props="defaultProps" />
<template #footer>
<span class="dialog-footer">
<el-button @click="menuAssignDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitAssignMenus">保存</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { Search, Refresh, Plus, Delete, Edit, Setting } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox, ElTree } from 'element-plus';
import { useCrud } from '@/composables/useCrud';
import { getRoleList, addRole, updateRole, deleteRole, getAllMenus, assignRoleMenus } from '@/api/role';
import RoleFormDialog from './RoleFormDialog.vue'; // 角色新增/编辑弹窗组件
interface Role {
id?: number;
name: string;
description: string;
createTime?: string;
menuIds?: number[];
permissionIds?: number[];
}
interface RoleSearchForm {
name: string;
}
const {
tableData,
loading,
pagination,
searchForm,
dialogVisible,
isEdit,
editForm,
selectedRows,
fetchList,
handleSearch,
handleResetSearch,
handleSizeChange,
handleCurrentChange,
handleSelectionChange,
handleAdd,
handleEdit,
handleDelete,
handleBatchDelete,
} = useCrud<Role, RoleSearchForm>({
api: {
getList: getRoleList,
add: addRole,
update: (data) => updateRole(data.id as number, data),
del: deleteRole,
// batchDel: batchDeleteRoles, // 假设批量删除角色
},
initialSearchForm: {
name: '',
},
initialEditForm: {
name: '',
description: '',
},
});
// 菜单分配相关
const menuAssignDialogVisible = ref(false);
const menuTreeData = ref<any[]>([]);
const currentAssignRole = ref<Role | null>(null);
const menuTreeRef = ref<InstanceType<typeof ElTree>>();
const defaultProps = {
children: 'children',
label: 'title', // 菜单树的显示名称字段
};
// 分配菜单权限
const handleAssignMenus = async (row: Role) => {
currentAssignRole.value = row;
menuAssignDialogVisible.value = true;
// 获取所有菜单
try {
const res = await getAllMenus(); // 模拟获取所有菜单
// 将扁平化的菜单数据转换为树形结构(如果后端返回的是扁平结构)
menuTreeData.value = buildMenuTree(res);
// 设置已选中的菜单项
await new Promise(resolve => setTimeout(resolve, 0)); // 确保树形组件已渲染
if (menuTreeRef.value && row.menuIds) {
menuTreeRef.value.setCheckedKeys(row.menuIds, false); // 第二个参数false表示不严格父子关联
}
} catch (error) {
ElMessage.error('获取菜单列表失败');
}
};
// 提交菜单分配
const submitAssignMenus = async () => {
if (!currentAssignRole.value?.id) {
ElMessage.error('未选择角色!');
return;
}
const checkedKeys = menuTreeRef.value?.getCheckedKeys(false) as number[]; // false表示只获取叶子节点
const halfCheckedKeys = menuTreeRef.value?.getHalfCheckedKeys() as number[];
const allCheckedMenuIds = [...checkedKeys, ...halfCheckedKeys]; // 获取所有选中和半选中的菜单ID
try {
await assignRoleMenus(currentAssignRole.value.id, allCheckedMenuIds); // 模拟提交
ElMessage.success('菜单分配成功!');
menuAssignDialogVisible.value = false;
fetchList(); // 刷新角色列表,更新角色拥有的菜单ID
} catch (error) {
ElMessage.error('菜单分配失败');
}
};
// 将扁平菜单列表构建成树形结构
function buildMenuTree(menuList: any[]): any[] {
const map: { [key: number]: any } = {};
menuList.forEach(item => {
map[item.id] = { ...item, children: [] };
});
const tree: any[] = [];
menuList.forEach(item => {
if (item.parentId && map[item.parentId]) {
map[item.parentId].children.push(map[item.id]);
} else {
tree.push(map[item.id]);
}
});
return tree;
}
onMounted(() => {
fetchList();
});
</script>
<style scoped></style>
src/views/system/RoleFormDialog.vue (示例)
<!-- src/views/system/RoleFormDialog.vue -->
<template>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" @close="handleClose">
<el-form ref="roleFormRef" :model="formData" :rules="rules" label-width="80px">
<el-form-item label="角色名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入角色名称" />
</el-form-item>
<el-form-item label="角色描述" prop="description">
<el-input type="textarea" v-model="formData.description" placeholder="请输入角色描述" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
{{ isEdit ? '更新' : '新增' }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue';
import { ElMessage, FormInstance, FormRules } from 'element-plus';
const props = defineProps<{
visible: boolean;
formData: any;
isEdit: boolean;
}>();
const emit = defineEmits(['update:visible', 'submit-success']);
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
const dialogTitle = computed(() => (props.isEdit ? '编辑角色' : '新增角色'));
const submitLoading = ref(false);
const roleFormRef = ref<FormInstance>();
const rules: FormRules = reactive({
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
description: [{ required: true, message: '请输入角色描述', trigger: 'blur' }],
});
const handleClose = () => {
roleFormRef.value?.resetFields();
};
const handleSubmit = async () => {
if (!roleFormRef.value) return;
await roleFormRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true;
try {
emit('submit-success', props.formData);
dialogVisible.value = false;
ElMessage.success('操作成功!');
} catch (error) {
ElMessage.error('操作失败,请重试!');
console.error(error);
} finally {
submitLoading.value = false;
}
} else {
ElMessage.error('请检查表单填写是否完整和正确!');
return false;
}
});
};
defineExpose({
handleSubmit
});
</script>
<style scoped></style>
5.1.3 系统日志查看
系统日志通常是一个只读的列表,包含分页、搜索、查看详情等功能。其实现与用户列表类似,可以直接复用CommonTable。
src/api/log.ts (模拟)
// src/api/log.ts
import request from '@/utils/request';
interface SystemLog {
id: number;
userName: string;
action: string;
module: string;
ipAddress: string;
createTime: string;
detail?: string;
}
interface LogListParams {
currentPage: number;
pageSize: number;
userName?: string;
module?: string;
startDate?: string;
endDate?: string;
}
export const getSystemLogList = (params: LogListParams): Promise<{ list: SystemLog[]; total: number }> => {
return request.get('/logs', { params });
};
export const getLogDetail = (id: number): Promise<SystemLog> => {
return request.get(`/logs/${id}`);
};
src/views/system/SystemLog.vue (示例)
<!-- src/views/system/SystemLog.vue -->
<template>
<div class="p-4 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold mb-4">系统日志</h2>
<el-form :inline="true" :model="searchForm" class="mb-4">
<el-form-item label="操作用户">
<el-input v-model="searchForm.userName" placeholder="请输入操作用户" clearable />
</el-form-item>
<el-form-item label="模块">
<el-input v-model="searchForm.module" placeholder="请输入模块名称" clearable />
</el-form-item>
<el-form-item label="操作时间">
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期"
end-placeholder="结束日期" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="handleResetSearch">重置</el-button>
</el-form-item>
</el-form>
<el-table :data="tableData" border style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="userName" label="操作用户" width="120" />
<el-table-column prop="action" label="操作内容" />
<el-table-column prop="module" label="模块" width="120" />
<el-table-column prop="ipAddress" label="IP地址" width="150" />
<el-table-column prop="createTime" label="操作时间" width="180" />
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button link type="primary" :icon="View" @click="handleViewDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<el-pagination v-model:currentPage="pagination.currentPage" v-model:page-size="pagination.pageSize"
:page-sizes="" :small="false" :disabled="loading" :background="true"
layout="total, sizes, prev, pager, next, jumper" :total="pagination.total" @size-change="handleSizeChange"
@current-change="handleCurrentChange" />
</div>
<!-- 日志详情弹窗 -->
<el-dialog v-model="detailDialogVisible" title="日志详情" width="600px">
<el-descriptions :column="1" border>
<el-descriptions-item label="ID">{{ currentLogDetail?.id }}</el-descriptions-item>
<el-descriptions-item label="操作用户">{{ currentLogDetail?.userName }}</el-descriptions-item>
<el-descriptions-item label="操作内容">{{ currentLogDetail?.action }}</el-descriptions-item>
<el-descriptions-item label="模块">{{ currentLogDetail?.module }}</el-descriptions-item>
<el-descriptions-item label="IP地址">{{ currentLogDetail?.ipAddress }}</el-descriptions-item>
<el-descriptions-item label="操作时间">{{ currentLogDetail?.createTime }}</el-descriptions-item>
<el-descriptions-item label="详细信息">{{ currentLogDetail?.detail }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue';
import { Search, Refresh, View } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { useCrud } from '@/composables/useCrud';
import { getSystemLogList, getLogDetail } from '@/api/log';
interface SystemLog {
id: number;
userName: string;
action: string;
module: string;
ipAddress: string;
createTime: string;
detail?: string;
}
interface LogSearchForm {
userName: string;
module: string;
startDate?: string;
endDate?: string;
}
const dateRange = ref<[string, string] | null>(null); // 日期选择器绑定的值
const {
tableData,
loading,
pagination,
searchForm,
fetchList,
handleSearch,
handleResetSearch,
handleSizeChange,
handleCurrentChange,
} = useCrud<SystemLog, LogSearchForm>({
api: {
getList: getSystemLogList,
},
initialSearchForm: {
userName: '',
module: '',
startDate: '',
endDate: '',
},
initialEditForm: {} as SystemLog, // 日志无需编辑
});
// 监听日期范围变化
watch(dateRange, (newVal) => {
if (newVal && newVal.length === 2) {
searchForm.startDate = newVal;
searchForm.endDate = newVal;
} else {
searchForm.startDate = '';
searchForm.endDate = '';
}
});
const detailDialogVisible = ref(false);
const currentLogDetail = ref<SystemLog | null>(null);
// 查看日志详情
const handleViewDetail = async (row: SystemLog) => {
try {
// 模拟获取日志详情,实际可能需要调用单独的API
const res = await getLogDetail(row.id);
currentLogDetail.value = res;
detailDialogVisible.value = true;
} catch (error) {
ElMessage.error('获取日志详情失败');
console.error(error);
}
};
onMounted(() => {
fetchList();
});
</script>
<style scoped></style>
Mermaid 图表示例:
graph TD
A[用户] -- 登录 --> B(Auth Store)
B -- 获取Token/Roles --> C{路由守卫}
C -- Token存在/Roles已加载 --> D[动态路由添加]
D -- 路由注册 --> E(Layout/Views)
E -- 请求数据 --> F(Axios封装)
F -- 返回数据/错误 --> E
E -- 交互 --> G(Element Plus UI)
E -- 样式 --> H(Tailwind CSS)
LaTeX 数学公式示例:
行内公式: $E = mc^2$
块级公式: $$ \sum_{i=1}^{n} i = \frac{n(n+1)}{2} $$
总结
这份教程详细介绍了如何使用Vue 3、Vite、Pinia、Element Plus、Vue Router和Tailwind CSS从零开始搭建一个功能完备的后台管理系统。我们涵盖了项目初始化、UI布局、核心功能开发、权限管理、性能优化以及实战案例等多个方面。
希望这份教程能帮助您更好地理解和掌握这些现代前端技术栈,并应用于您的实际项目中。记住,持续学习和实践是提升技能的关键。
祝您编程愉快!