Vue 3 + Vite + Pinia + Element Plus + Vue Router + Tailwind CSS 后台管理系统平台搭建教程

Vue 3 + Vite + Pinia + Element Plus + Vue Router + Tailwind CSS 后台管理系统平台搭建教程

引言:为什么选择这个技术栈组合?

在现代前端开发中,选择一个合适的技术栈对于项目的成功至关重要。本教程将引导您使用 Vue 3 + Vite + Pinia + Element Plus + Vue Router + Tailwind CSS 快速构建一个高性能、可维护的后台管理系统。

这个技术栈组合的优势:

  1. Vue 3: 渐进式JavaScript框架的最新版本,提供了Composition API、Teleport、Fragments等新特性,使代码更具可读性、可维护性和重用性。
  2. Vite: 新一代前端构建工具,以其极致的开发服务器启动速度和闪电般的HMR(热模块替换)而闻名,极大地提升了开发体验。生产构建基于Rollup,同样高效。
  3. Pinia: Vue官方推荐的状态管理库,轻量、直观,并且类型安全(TypeScript友好)。相比Vuex,Pinia的学习曲线更平缓,使用起来更像直接操作响应式数据。
  4. Element Plus: 基于Vue 3的组件库,提供了丰富的UI组件,风格统一,设计优雅。它极大地加速了UI界面的开发,并支持按需导入以优化打包体积。
  5. Vue Router: Vue官方的路由管理器,用于构建单页面应用(SPA)的导航。支持嵌套路由、命名视图、导航守卫等高级功能。
  6. Tailwind CSS: 一个原子化CSS框架。它不提供预设组件样式,而是通过大量实用的工具类(Utility-first)让您直接在HTML中快速构建自定义UI,避免了传统CSS的命名困扰和样式冲突。与Element Plus结合,可以实现UI的快速定制和布局的灵活性。

总结: 这个组合兼顾了开发效率、运行时性能、代码可维护性和社区支持,是构建现代后台管理系统的理想选择。


第一部分:项目初始化与核心技术栈介绍

本部分将指导您从零开始创建一个Vue 3项目,并逐步集成Vue Router、Pinia、Element Plus和Tailwind CSS。

1.1 环境准备

在开始之前,请确保您的开发环境中已安装Node.js。建议使用LTS版本。

1.2 使用 Vite 创建 Vue 3 项目

Vite提供了一个非常方便的脚手架工具来快速创建Vue项目。

# 使用 pnpm 创建 Vue 3 项目
pnpm create vue@latest my-admin-template -- --typescript --tailwind --router --pinia --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 配置
1.3 集成 Vue Router

在您使用pnpm create vue@latest --router命令时,Vue Router已经自动集成并配置。

1.4 集成 Pinia

通过pnpm create vue@latest --pinia命令,Pinia已自动集成。

1.5 集成 Element Plus

通过pnpm create vue@latest --element-plus命令,Element Plus已自动集成。

1.6 集成 Tailwind CSS

通过pnpm create vue@latest --tailwind命令,Tailwind CSS已自动集成。


第二部分:UI布局与通用组件设计

本部分将深入探讨后台管理系统的整体UI布局设计,并着手实现一些通用的界面组件,为后续的核心功能开发打下坚实的基础。我们将充分利用Element Plus的强大组件库和Tailwind CSS的灵活性来实现这些目标。

2.1 整体布局设计

一个经典的后台管理系统通常包含以下几个区域:顶部导航、侧边栏和主内容区。这种布局既能提供清晰的导航,又能有效利用屏幕空间展示核心业务内容。

我们将使用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>

代码解析:

2.2 侧边栏菜单

前面在1.3 Vue Router2.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

2.3 顶部导航栏

顶部导航栏在src/layout/index.vue中已经实现了一部分,包括:

补充:用户头像与下拉菜单

src/layout/index.vue<el-header>中已经包含了用户头像和下拉菜单,这里不再重复展示。关键在于使用el-dropdown和其trigger@command属性。

2.4 Tab 标签页管理

实现多开标签页功能可以显著提升用户体验。我们将结合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);
}
*/
2.5 全局加载与错误处理

一个健壮的后台管理系统需要全局的加载提示和统一的错误处理机制,以提升用户体验和开发效率。

全局加载 (Loading)

我们可以使用Element Plus的ElLoading服务,并结合Pinia Store来管理其显示与隐藏。

  1. 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;
        },
      },
    });
    
  2. 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');
    
  3. 在 Axios 拦截器中使用: (将在第三部分详细讲解) 在请求发出时显示Loading,响应或错误时隐藏。

全局错误边界处理

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)

为了保持一致性,建议对ElMessageElNotification进行二次封装。

// 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请求、用户认证、数据展示与编辑等。

3.1 Axios 封装与统一请求

Axios是一个基于Promise的HTTP客户端,用于浏览器和Node.js。对其进行封装可以实现请求/响应拦截、错误统一处理、Loading状态管理等。

  1. 安装 Axios:
    pnpm install axios
    
  2. 创建 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);
    };
    
  3. 配置 API 基础路径: 在项目根目录下创建.env.development.env.production文件。

    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
          }
        }
      }
    })
    
3.2 登录与认证模块

本节将实现用户登录功能,并结合Pinia和Vue Router守卫进行认证状态管理。

  1. 认证 Pinia Store (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');
        },
      },
    });
    
  2. 登录 API (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');
    };
    
  3. 登录页面 (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>
    
3.3 数据列表与分页

数据列表是后台管理系统中最常见的组件。我们将使用Element Plus的el-tableel-pagination

  1. 用户列表组件 (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>
    
3.4 表单新增与编辑

通常会使用一个单独的弹窗组件来处理新增和编辑逻辑。

  1. 用户表单弹窗组件 (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>
    
3.5 文件上传功能

我们将使用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>
3.6 富文本编辑器集成

集成富文本编辑器可以方便用户编辑复杂内容,例如文章、产品描述等。wangEditorQuill是常见的选择。这里以wangEditor为例。

  1. 安装 wangEditor:

    pnpm install @wangeditor/editor @wangeditor/editor-for-vue@next
    
  2. 创建富文本编辑器组件 (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>
    

    注意:

  3. 在页面中使用:

    <!-- 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>
    

第四部分:权限管理与进阶优化

本部分将深入讲解后台管理系统中复杂的权限管理机制,并介绍一些常用的性能优化和开发调试技巧。

4.1 权限管理(初步)

权限管理是后台管理系统的核心。我们将采用基于角色的权限控制(RBAC),实现前端的路由级和元素级权限。

基本思路:

  1. 用户登录获取权限信息: 登录成功后,后端返回用户的角色信息和/或可访问的菜单/路由列表。

  2. Pinia 存储权限: 将这些权限信息存储到Pinia Store中。

  3. 动态路由: 根据用户的权限动态生成可访问的路由表。

  4. 路由守卫: 在路由跳转前判断用户是否有权访问目标路由。

  5. 自定义指令/组件: 针对页面内的按钮、表格列等元素进行权限控制。

  6. 权限 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 = [];
        }
      },
    });
    
  7. 路由配置 (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;
    

    注意:

  8. 前端按钮/菜单级权限控制(自定义指令): 创建一个自定义指令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>
    
4.2 组件化与复用
4.3 性能优化实践
4.4 开发调试工具
4.5 项目构建与部署
4.6 其他

第五部分:实战案例

本部分将以“用户管理”和“角色管理”为例,展示一个完整的CRUD(创建、读取、更新、删除)模块的实现。

5.1 完整的CRUD模块实现

5.1.1 用户管理模块

这部分我们已经结合useCrudCommonTable在前面详细讲解,这里我们只补充后端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 (结合 useCrudCommonTable) 这部分我们在4.2 组件化与复用中已经展示了如何使用CommonTableuseCrud的思路,您可以根据提供的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 角色与权限管理

角色管理与用户管理类似,也可以复用CommonTableuseCrud。关键在于如何将角色与菜单/权限进行关联。

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布局、核心功能开发、权限管理、性能优化以及实战案例等多个方面。

希望这份教程能帮助您更好地理解和掌握这些现代前端技术栈,并应用于您的实际项目中。记住,持续学习和实践是提升技能的关键。

祝您编程愉快!

互动区域

登录后可以点赞此内容

参与互动

登录后可以点赞和评论此内容,与作者互动交流