跳转到内容

手搓一个前端管理系统Admin

💡

目标:手把手从0到1搭建一个Vue3+Element-Plus+Vue Router+Pinia的前端管理系统。纯前端项目,模拟数据,不需要后端。每一步都有完整代码和解释,跟着做就能跑起来。学完你将掌握:Vue3 Composition API、Element-Plus组件体系、Vue Router路由守卫、Pinia状态管理、Axios请求封装、后台管理经典布局。


第一步:创建项目(5分钟)

1.1 初始化Vue3项目

1.2 安装所有需要的依赖

📌

每个包的作用:element-plus(UI组件库)、vue-router(页面路由)、pinia(状态管理)、@element-plus/icons-vue(图标库)、axios(HTTP请求)。

1.3 规划目录结构(动手创建这些文件夹)


第二步:核心配置文件编写

2.1 main.ts — 应用入口(全局注册所有插件)

📌

这一步把Element-Plus、Element图标、Pinia、Vue Router全部注册到Vue应用中,后面所有组件都可以直接用。

2.2 router/index.ts — 路由配置(页面导航的大脑)

📌

定义所有页面的URL路径,以及哪些页面需要登录才能访问。路由守卫的作用:没登录 → 强制跳登录页。

2.3 stores/user.ts — 用户状态管理(登录/退出)

📌

Pinia是Vue3官方推荐的状态管理库。这里管理用户登录状态:token存localStorage实现刷新不丢失,login/logout方法供全局使用。

2.4 api/mock.ts — 模拟后端数据

📌

在内存中模拟一个"数据库"。getUserList实现分页+搜索,saveUser实现新增+编辑,deleteUser实现删除。每个方法都加了300ms延迟模拟网络请求。


第三步:页面开发

3.1 App.vue — 管理后台经典布局骨架

最关键的一步!这个组件实现了管理后台的经典布局:左侧导航栏 + 顶部面包屑 + 用户头像下拉 + 右侧内容区。登录页单独全屏显示。


3.2 Login.vue — 登录页面

📌

登录页面做了三件事:表单校验(用户名密码必填)、模拟登录验证(admin/123456)、成功后跳转首页。密码框支持回车直接登录。


3.3 Dashboard.vue — 首页仪表盘

📌

首页用el-row+el-col实现4列卡片统计,加一个快捷入口区域。


3.4 UserList.vue — 用户管理(最完整的CRUD页面)

这是整个项目最核心的页面。包含:搜索区、数据表格(带分页)、新增/编辑弹窗(带表单校验)、删除确认。完整覆盖了后台管理系统最常见的CRUD模式。理解了这一页,其他管理页面都是复制修改。


3.5 RoleList.vue — 角色管理


第四步:运行与验证

你应该能看到的完整流程:访问页面 → 自动跳转登录页(/login) → 输入admin/123456登录 → 进入首页Dashboard看到4个统计卡片 → 点击左侧"用户管理" → 看到用户列表 → 点击新增→弹出表单→提交→列表刷新 → 点击编辑→修改→提交 → 点击删除→确认→列表刷新 → 点击右上角退出。


第五步:学生扩展练习

🎯

完成以下练习来巩固所学:

  1. 角色管理完善(必做):参照UserList.vue,将RoleList.vue改为完整的CRUD页面(增删改查+搜索+分页)。提示:在mock.ts中增加角色数据和对应API方法
  2. 菜单管理页面(进阶):新增一个菜单管理页面,管理侧边栏菜单项(菜单名称、路径、图标、排序、父级菜单)。提示:使用el-tree组件展示菜单树
  3. 暗黑模式切换(进阶):在顶部栏添加一个开关按钮,使用Pinia管理主题状态,切换Element-Plus的暗黑模式。提示:使用useDark()和useToggle()
  4. 权限控制(进阶):根据用户角色(admin/user)控制菜单和按钮的显示/隐藏。admin可以看到所有菜单和执行所有操作,user只能看Dashboard和用户列表(不能编辑删除)
  5. 集成ECharts图表(进阶):在Dashboard中用ECharts替换静态统计卡片,展示:用户增长趋势折线图、用户状态分布饼图、月度新增柱状图
  6. 对接真实后端(综合):将api/mock.ts替换为真实axios请求,对接你的Spring Boot后端API

项目核心代码(完整可运行)

完整目录结构

admin-system/
├── index.html
├── package.json
├── vite.config.ts
├── tsconfig.json
├── src/
│   ├── main.ts                    # 应用入口
│   ├── App.vue                    # 根组件(经典布局骨架)
│   ├── env.d.ts                   # TS 声明
│   ├── router/
│   │   └── index.ts               # 路由配置 + 守卫
│   ├── stores/
│   │   └── user.ts                # Pinia 用户状态
│   ├── api/
│   │   └── mock.ts                # 模拟后端数据
│   ├── views/
│   │   ├── Login.vue              # 登录页
│   │   ├── Dashboard.vue          # 首页仪表盘
│   │   ├── UserList.vue           # 用户管理(完整CRUD)
│   │   └── RoleList.vue           # 角色管理
│   └── styles/
│       └── global.scss            # 全局样式

1. package.json

{
  "name": "admin-system",
  "version": "1.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "vue": "^3.4.0",
    "vue-router": "^4.3.0",
    "pinia": "^2.1.0",
    "element-plus": "^2.7.0",
    "@element-plus/icons-vue": "^2.3.0",
    "axios": "^1.7.0"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.0.0",
    "typescript": "^5.4.0",
    "vite": "^5.2.0",
    "vue-tsc": "^2.0.0",
    "sass": "^1.72.0"
  }
}

2. vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src')
    }
  },
  server: {
    port: 3000,
    open: true
  }
})

3. src/main.ts — 应用入口

import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './styles/global.scss'

const app = createApp(App)

// 注册所有 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

app.use(ElementPlus, { locale: zhCn })
app.use(createPinia())
app.use(router)
app.mount('#app')

4. src/router/index.ts — 路由配置 + 守卫

import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue'),
    meta: { title: '登录', noAuth: true }
  },
  {
    path: '/',
    name: 'Layout',
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('@/views/Dashboard.vue'),
        meta: { title: '首页', icon: 'HomeFilled' }
      },
      {
        path: 'users',
        name: 'UserList',
        component: () => import('@/views/UserList.vue'),
        meta: { title: '用户管理', icon: 'User' }
      },
      {
        path: 'roles',
        name: 'RoleList',
        component: () => import('@/views/RoleList.vue'),
        meta: { title: '角色管理', icon: 'Avatar' }
      }
    ]
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

// 路由守卫:未登录 → 跳转登录页
router.beforeEach((to, _from, next) => {
  const token = localStorage.getItem('token')
  if (!to.meta.noAuth && !token) {
    next('/login')
  } else if (to.path === '/login' && token) {
    next('/dashboard')
  } else {
    next()
  }
})

export default router

5. src/stores/user.ts — Pinia 用户状态管理

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import router from '@/router'

export const useUserStore = defineStore('user', () => {
  const token = ref<string>(localStorage.getItem('token') || '')
  const userInfo = ref<{ name: string; avatar: string; role: string } | null>(null)

  const isLogin = computed(() => !!token.value)

  // 登录
  async function login(username: string, password: string) {
    // 模拟登录验证
    return new Promise<void>((resolve, reject) => {
      setTimeout(() => {
        if (username === 'admin' && password === '123456') {
          const fakeToken = 'token_' + Date.now()
          token.value = fakeToken
          localStorage.setItem('token', fakeToken)
          userInfo.value = {
            name: '管理员',
            avatar: '',
            role: 'admin'
          }
          resolve()
        } else {
          reject(new Error('用户名或密码错误'))
        }
      }, 500)
    })
  }

  // 退出
  function logout() {
    token.value = ''
    userInfo.value = null
    localStorage.removeItem('token')
    router.push('/login')
  }

  // 获取用户信息
  async function getUserInfo() {
    userInfo.value = {
      name: '管理员',
      avatar: '',
      role: 'admin'
    }
  }

  return { token, userInfo, isLogin, login, logout, getUserInfo }
})

6. src/api/mock.ts — 模拟后端数据(内存数据库)

// ============ 模拟数据库 ============
interface User {
  id: number
  username: string
  nickname: string
  email: string
  phone: string
  status: boolean
  role: string
  createTime: string
}

interface Role {
  id: number
  name: string
  code: string
  description: string
  status: boolean
  createTime: string
}

// 初始用户数据
const users: User[] = [
  { id: 1, username: 'admin', nickname: '超级管理员', email: 'admin@example.com', phone: '13800000001', status: true, role: '超级管理员', createTime: '2024-01-01 10:00:00' },
  { id: 2, username: 'zhangsan', nickname: '张三', email: 'zhangsan@example.com', phone: '13800000002', status: true, role: '普通用户', createTime: '2024-02-15 14:30:00' },
  { id: 3, username: 'lisi', nickname: '李四', email: 'lisi@example.com', phone: '13800000003', status: false, role: '普通用户', createTime: '2024-03-10 09:15:00' },
  { id: 4, username: 'wangwu', nickname: '王五', email: 'wangwu@example.com', phone: '13800000004', status: true, role: '普通用户', createTime: '2024-04-20 16:45:00' },
  { id: 5, username: 'zhaoliu', nickname: '赵六', email: 'zhaoliu@example.com', phone: '13800000005', status: true, role: '普通用户', createTime: '2024-05-05 11:20:00' },
]

// 初始角色数据
const roles: Role[] = [
  { id: 1, name: '超级管理员', code: 'admin', description: '拥有所有权限', status: true, createTime: '2024-01-01 10:00:00' },
  { id: 2, name: '普通用户', code: 'user', description: '基础权限', status: true, createTime: '2024-01-01 10:00:00' },
  { id: 3, name: '访客', code: 'guest', description: '只读权限', status: false, createTime: '2024-03-01 08:00:00' },
]

// 模拟网络延迟
const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms))

// ============ 用户 API ============
export interface PageParams {
  page: number
  pageSize: number
  keyword?: string
}

export interface PageResult<T> {
  list: T[]
  total: number
  page: number
  pageSize: number
}

// 获取用户列表(分页 + 搜索)
export async function getUserList(params: PageParams): Promise<PageResult<User>> {
  await delay()
  let list = [...users]

  // 搜索过滤
  if (params.keyword) {
    const kw = params.keyword.toLowerCase()
    list = list.filter(u =>
      u.username.includes(kw) ||
      u.nickname.includes(kw) ||
      u.email.includes(kw)
    )
  }

  const total = list.length
  const start = (params.page - 1) * params.pageSize
  const end = start + params.pageSize

  return {
    list: list.slice(start, end),
    total,
    page: params.page,
    pageSize: params.pageSize
  }
}

// 新增 / 编辑用户
export async function saveUser(data: Partial<User> & { id?: number }): Promise<void> {
  await delay()
  if (data.id) {
    // 编辑
    const index = users.findIndex(u => u.id === data.id)
    if (index !== -1) {
      users[index] = { ...users[index], ...data }
    }
  } else {
    // 新增
    const newUser: User = {
      id: Math.max(...users.map(u => u.id)) + 1,
      username: data.username || '',
      nickname: data.nickname || '',
      email: data.email || '',
      phone: data.phone || '',
      status: data.status ?? true,
      role: data.role || '普通用户',
      createTime: new Date().toISOString().replace('T', ' ').substring(0, 19)
    }
    users.unshift(newUser)
  }
}

// 删除用户
export async function deleteUser(id: number): Promise<void> {
  await delay()
  const index = users.findIndex(u => u.id === id)
  if (index !== -1) {
    users.splice(index, 1)
  }
}

// ============ 角色 API ============
export async function getRoleList(params: PageParams): Promise<PageResult<Role>> {
  await delay()
  let list = [...roles]
  if (params.keyword) {
    const kw = params.keyword.toLowerCase()
    list = list.filter(r => r.name.includes(kw) || r.code.includes(kw))
  }
  const total = list.length
  const start = (params.page - 1) * params.pageSize
  return { list: list.slice(start, start + params.pageSize), total, page: params.page, pageSize: params.pageSize }
}

export async function saveRole(data: Partial<Role> & { id?: number }): Promise<void> {
  await delay()
  if (data.id) {
    const index = roles.findIndex(r => r.id === data.id)
    if (index !== -1) roles[index] = { ...roles[index], ...data }
  } else {
    roles.unshift({
      id: Math.max(...roles.map(r => r.id)) + 1,
      name: data.name || '',
      code: data.code || '',
      description: data.description || '',
      status: data.status ?? true,
      createTime: new Date().toISOString().replace('T', ' ').substring(0, 19)
    })
  }
}

export async function deleteRole(id: number): Promise<void> {
  await delay()
  const index = roles.findIndex(r => r.id === id)
  if (index !== -1) roles.splice(index, 1)
}

7. src/App.vue — 管理后台经典布局

<template>
  <div id="app">
    <!-- 登录页不显示侧边栏和顶部栏 -->
    <template v-if="route.path === '/login'">
      <router-view />
    </template>

    <template v-else>
      <el-container class="layout-container">
        <!-- 左侧导航栏 -->
        <el-aside width="220px" class="aside">
          <div class="logo">
            <span>Admin 管理系统</span>
          </div>
          <el-menu
            :default-active="route.path"
            router
            background-color="#304156"
            text-color="#bfcbd9"
            active-text-color="#409EFF"
          >
            <el-menu-item
              v-for="item in menuList"
              :key="item.path"
              :index="item.path"
            >
              <el-icon><component :is="item.icon" /></el-icon>
              <span>{{ item.title }}</span>
            </el-menu-item>
          </el-menu>
        </el-aside>

        <!-- 右侧内容区 -->
        <el-container>
          <!-- 顶部栏 -->
          <el-header class="header">
            <div class="header-left">
              <el-breadcrumb separator="/">
                <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
                <el-breadcrumb-item v-if="route.meta.title">
                  {{ route.meta.title }}
                </el-breadcrumb-item>
              </el-breadcrumb>
            </div>
            <div class="header-right">
              <el-dropdown @command="handleCommand">
                <span class="user-info">
                  <el-icon><UserFilled /></el-icon>
                  {{ userStore.userInfo?.name || '未登录' }}
                  <el-icon><ArrowDown /></el-icon>
                </span>
                <template #dropdown>
                  <el-dropdown-menu>
                    <el-dropdown-item command="profile">个人信息</el-dropdown-item>
                    <el-dropdown-item command="password">修改密码</el-dropdown-item>
                    <el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
                  </el-dropdown-menu>
                </template>
              </el-dropdown>
            </div>
          </el-header>

          <!-- 主内容区 -->
          <el-main class="main-content">
            <router-view />
          </el-main>
        </el-container>
      </el-container>
    </template>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessageBox } from 'element-plus'

const route = useRoute()
const router = useRouter()
const userStore = useUserStore()

// 侧边栏菜单(从路由配置中提取)
const menuList = computed(() => {
  return router.options.routes
    .find(r => r.path === '/')
    ?.children
    ?.filter(r => r.meta?.title && r.meta?.icon)
    .map(r => ({
      path: '/' + r.path,
      title: r.meta?.title as string,
      icon: r.meta?.icon as string
    })) || []
})

// 下拉菜单操作
function handleCommand(command: string) {
  if (command === 'logout') {
    ElMessageBox.confirm('确定要退出登录吗?', '提示', {
      type: 'warning'
    }).then(() => {
      userStore.logout()
    }).catch(() => {})
  } else if (command === 'profile') {
    ElMessageBox.alert('个人信息功能开发中...')
  } else if (command === 'password') {
    ElMessageBox.alert('修改密码功能开发中...')
  }
}
</script>

<style scoped lang="scss">
.layout-container {
  height: 100vh;
}

.aside {
  background-color: #304156;
  overflow-y: auto;

  .logo {
    height: 60px;
    display: flex;
    align-items: center;
    justify-content: center;
    color: #fff;
    font-size: 18px;
    font-weight: bold;
    border-bottom: 1px solid rgba(255, 255, 255, 0.1);
  }
}

.header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  background: #fff;
  border-bottom: 1px solid #e6e6e6;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);

  .header-right {
    .user-info {
      cursor: pointer;
      display: flex;
      align-items: center;
      gap: 4px;
      color: #333;

      &:hover { color: #409EFF; }
    }
  }
}

.main-content {
  background-color: #f0f2f5;
  min-height: calc(100vh - 60px);
}
</style>

8. src/views/Login.vue — 登录页面

<template>
  <div class="login-container">
    <div class="login-card">
      <h1 class="login-title">Admin 管理系统</h1>
      <p class="login-subtitle">Vue3 + Element-Plus 后台管理模板</p>

      <el-form
        ref="formRef"
        :model="form"
        :rules="rules"
        size="large"
        @keyup.enter="handleLogin"
      >
        <el-form-item prop="username">
          <el-input
            v-model="form.username"
            placeholder="用户名"
            :prefix-icon="User"
          />
        </el-form-item>

        <el-form-item prop="password">
          <el-input
            v-model="form.password"
            type="password"
            placeholder="密码"
            :prefix-icon="Lock"
            show-password
          />
        </el-form-item>

        <el-form-item>
          <el-button
            type="primary"
            :loading="loading"
            style="width: 100%"
            @click="handleLogin"
          >
            {{ loading ? '登录中...' : '登 录' }}
          </el-button>
        </el-form-item>
      </el-form>

      <div class="login-tips">
        <p>提示:用户名 <b>admin</b>,密码 <b>123456</b></p>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { User, Lock } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'

const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const formRef = ref<FormInstance>()

const form = reactive({
  username: 'admin',
  password: '123456'
})

const rules: FormRules = {
  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码至少6位', trigger: 'blur' }
  ]
}

async function handleLogin() {
  if (!formRef.value) return
  const valid = await formRef.value.validate().catch(() => false)
  if (!valid) return

  loading.value = true
  try {
    await userStore.login(form.username, form.password)
    ElMessage.success('登录成功')
    router.push('/dashboard')
  } catch (err: any) {
    ElMessage.error(err.message || '登录失败')
  } finally {
    loading.value = false
  }
}
</script>

<style scoped lang="scss">
.login-container {
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);

  .login-card {
    width: 420px;
    padding: 40px;
    background: #fff;
    border-radius: 12px;
    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);

    .login-title {
      text-align: center;
      font-size: 28px;
      color: #303133;
      margin-bottom: 8px;
    }

    .login-subtitle {
      text-align: center;
      color: #909399;
      margin-bottom: 32px;
      font-size: 14px;
    }

    .login-tips {
      text-align: center;
      font-size: 12px;
      color: #c0c4cc;
      margin-top: 16px;

      b {
        color: #409EFF;
      }
    }
  }
}
</style>

9. src/views/Dashboard.vue — 首页仪表盘

<template>
  <div class="dashboard">
    <!-- 统计卡片 -->
    <el-row :gutter="20">
      <el-col :span="6" v-for="card in statCards" :key="card.title">
        <el-card shadow="hover" class="stat-card">
          <div class="stat-content">
            <div class="stat-info">
              <p class="stat-value">{{ card.value }}</p>
              <p class="stat-title">{{ card.title }}</p>
            </div>
            <div class="stat-icon" :style="{ backgroundColor: card.bgColor }">
              <el-icon :size="28" color="#fff">
                <component :is="card.icon" />
              </el-icon>
            </div>
          </div>
        </el-card>
      </el-col>
    </el-row>

    <!-- 快捷入口 -->
    <el-card shadow="never" style="margin-top: 20px">
      <template #header>
        <span style="font-weight: bold; font-size: 16px;">快捷入口</span>
      </template>
      <el-row :gutter="20">
        <el-col :span="6" v-for="item in quickLinks" :key="item.path">
          <div class="quick-link" @click="$router.push(item.path)">
            <el-icon :size="24" color="#409EFF">
              <component :is="item.icon" />
            </el-icon>
            <p>{{ item.title }}</p>
          </div>
        </el-col>
      </el-row>
    </el-card>
  </div>
</template>

<script setup lang="ts">
import { User, ShoppingCart, DataAnalysis, Message, List, Avatar } from '@element-plus/icons-vue'

const statCards = [
  { title: '用户总数', value: '1,286', icon: 'User', bgColor: '#409EFF' },
  { title: '今日新增', value: '36', icon: 'DataAnalysis', bgColor: '#67C23A' },
  { title: '订单总数', value: '3,892', icon: 'ShoppingCart', bgColor: '#E6A23C' },
  { title: '未读消息', value: '18', icon: 'Message', bgColor: '#F56C6C' },
]

const quickLinks = [
  { title: '用户管理', path: '/users', icon: 'User' },
  { title: '角色管理', path: '/roles', icon: 'Avatar' },
]
</script>

<style scoped lang="scss">
.stat-card {
  .stat-content {
    display: flex;
    align-items: center;
    justify-content: space-between;

    .stat-value {
      font-size: 28px;
      font-weight: bold;
      color: #303133;
      margin-bottom: 4px;
    }

    .stat-title {
      font-size: 14px;
      color: #909399;
    }

    .stat-icon {
      width: 56px;
      height: 56px;
      border-radius: 8px;
      display: flex;
      align-items: center;
      justify-content: center;
    }
  }
}

.quick-link {
  text-align: center;
  padding: 16px;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.3s;

  &:hover {
    background-color: #ecf5ff;
    transform: translateY(-2px);
  }

  p {
    margin-top: 8px;
    font-size: 13px;
    color: #606266;
  }
}
</style>

10. src/views/UserList.vue — 用户管理(完整CRUD)

<template>
  <div class="user-list">
    <!-- 搜索区 -->
    <el-card shadow="never" class="search-card">
      <el-form :inline="true" :model="searchForm">
        <el-form-item label="关键词">
          <el-input
            v-model="searchForm.keyword"
            placeholder="用户名/昵称/邮箱"
            clearable
            @keyup.enter="handleSearch"
          />
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleSearch">搜索</el-button>
          <el-button @click="handleReset">重置</el-button>
        </el-form-item>
      </el-form>
    </el-card>

    <!-- 表格区 -->
    <el-card shadow="never" style="margin-top: 16px">
      <div class="table-header">
        <span style="font-weight: bold;">用户列表</span>
        <el-button type="primary" @click="handleAdd">新增用户</el-button>
      </div>

      <el-table :data="tableData" border stripe v-loading="loading" style="margin-top: 16px">
        <el-table-column prop="id" label="ID" width="80" align="center" />
        <el-table-column prop="username" label="用户名" width="120" />
        <el-table-column prop="nickname" label="昵称" width="120" />
        <el-table-column prop="email" label="邮箱" min-width="200" />
        <el-table-column prop="phone" label="手机号" width="140" />
        <el-table-column prop="role" label="角色" width="120" />
        <el-table-column prop="status" label="状态" width="100" align="center">
          <template #default="{ row }">
            <el-tag :type="row.status ? 'success' : 'danger'" size="small">
              {{ row.status ? '启用' : '禁用' }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="createTime" label="创建时间" width="180" />
        <el-table-column label="操作" width="200" align="center" fixed="right">
          <template #default="{ row }">
            <el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
            <el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
          </template>
        </el-table-column>
      </el-table>

      <!-- 分页 -->
      <div class="pagination-wrap">
        <el-pagination
          v-model:current-page="pageParams.page"
          v-model:page-size="pageParams.pageSize"
          :page-sizes="[10, 20, 50]"
          :total="total"
          layout="total, sizes, prev, pager, next"
          @change="fetchData"
        />
      </div>
    </el-card>

    <!-- 新增/编辑弹窗 -->
    <el-dialog
      v-model="dialogVisible"
      :title="isEdit ? '编辑用户' : '新增用户'"
      width="560px"
      :close-on-click-modal="false"
    >
      <el-form ref="formRef" :model="formData" :rules="formRules" label-width="80px">
        <el-form-item label="用户名" prop="username">
          <el-input v-model="formData.username" :disabled="isEdit" />
        </el-form-item>
        <el-form-item label="昵称" prop="nickname">
          <el-input v-model="formData.nickname" />
        </el-form-item>
        <el-form-item label="邮箱" prop="email">
          <el-input v-model="formData.email" />
        </el-form-item>
        <el-form-item label="手机号" prop="phone">
          <el-input v-model="formData.phone" />
        </el-form-item>
        <el-form-item label="角色" prop="role">
          <el-select v-model="formData.role" style="width: 100%">
            <el-option label="超级管理员" value="超级管理员" />
            <el-option label="普通用户" value="普通用户" />
            <el-option label="访客" value="访客" />
          </el-select>
        </el-form-item>
        <el-form-item label="状态" prop="status">
          <el-switch v-model="formData.status" />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="handleSubmit">确定</el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { getUserList, saveUser, deleteUser } from '@/api/mock'
import type { PageParams } from '@/api/mock'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'

// ========== 搜索 ==========
const searchForm = reactive({ keyword: '' })

// ========== 表格 ==========
const loading = ref(false)
const tableData = ref<any[]>([])
const total = ref(0)
const pageParams = reactive<PageParams>({ page: 1, pageSize: 10, keyword: '' })

async function fetchData() {
  loading.value = true
  try {
    const res = await getUserList({ ...pageParams, keyword: searchForm.keyword })
    tableData.value = res.list
    total.value = res.total
  } finally {
    loading.value = false
  }
}

function handleSearch() {
  pageParams.page = 1
  fetchData()
}

function handleReset() {
  searchForm.keyword = ''
  handleSearch()
}

// ========== 弹窗表单 ==========
const dialogVisible = ref(false)
const isEdit = ref(false)
const editId = ref<number | null>(null)
const formRef = ref<FormInstance>()
const formData = reactive({
  username: '',
  nickname: '',
  email: '',
  phone: '',
  role: '普通用户',
  status: true
})

const formRules: FormRules = {
  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
  email: [
    { required: true, message: '请输入邮箱', trigger: 'blur' },
    { type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
  ],
}

function resetForm() {
  formData.username = ''
  formData.nickname = ''
  formData.email = ''
  formData.phone = ''
  formData.role = '普通用户'
  formData.status = true
  editId.value = null
  formRef.value?.resetFields()
}

function handleAdd() {
  isEdit.value = false
  resetForm()
  dialogVisible.value = true
}

function handleEdit(row: any) {
  isEdit.value = true
  editId.value = row.id
  Object.assign(formData, {
    username: row.username,
    nickname: row.nickname,
    email: row.email,
    phone: row.phone,
    role: row.role,
    status: row.status
  })
  dialogVisible.value = true
}

async function handleSubmit() {
  if (!formRef.value) return
  const valid = await formRef.value.validate().catch(() => false)
  if (!valid) return

  try {
    await saveUser({ id: editId.value ?? undefined, ...formData })
    ElMessage.success(isEdit.value ? '编辑成功' : '新增成功')
    dialogVisible.value = false
    fetchData()
  } catch {
    ElMessage.error('操作失败')
  }
}

async function handleDelete(row: any) {
  try {
    await ElMessageBox.confirm(`确定要删除用户 "${row.nickname}" 吗?`, '确认删除', { type: 'warning' })
    await deleteUser(row.id)
    ElMessage.success('删除成功')
    fetchData()
  } catch { /* 用户取消 */ }
}

onMounted(() => {
  fetchData()
})
</script>

<style scoped lang="scss">
.table-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.pagination-wrap {
  margin-top: 16px;
  display: flex;
  justify-content: flex-end;
}

.el-form--inline .el-form-item {
  margin-right: 12px;
}
</style>

11. src/views/RoleList.vue — 角色管理

<template>
  <div class="role-list">
    <el-card shadow="never">
      <div class="table-header">
        <span style="font-weight: bold;">角色列表</span>
        <el-button type="primary" @click="handleAdd">新增角色</el-button>
      </div>

      <el-table :data="tableData" border stripe v-loading="loading" style="margin-top: 16px">
        <el-table-column prop="id" label="ID" width="80" align="center" />
        <el-table-column prop="name" label="角色名称" width="150" />
        <el-table-column prop="code" label="角色编码" width="150" />
        <el-table-column prop="description" label="描述" min-width="200" />
        <el-table-column prop="status" label="状态" width="100" align="center">
          <template #default="{ row }">
            <el-tag :type="row.status ? 'success' : 'danger'" size="small">
              {{ row.status ? '启用' : '禁用' }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="createTime" label="创建时间" width="180" />
        <el-table-column label="操作" width="160" align="center">
          <template #default="{ row }">
            <el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
            <el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
          </template>
        </el-table-column>
      </el-table>

      <div class="pagination-wrap">
        <el-pagination
          v-model:current-page="pageParams.page"
          v-model:page-size="pageParams.pageSize"
          :page-sizes="[10, 20, 50]"
          :total="total"
          layout="total, sizes, prev, pager, next"
          @change="fetchData"
        />
      </div>
    </el-card>

    <!-- 弹窗 -->
    <el-dialog
      v-model="dialogVisible"
      :title="isEdit ? '编辑角色' : '新增角色'"
      width="500px"
      :close-on-click-modal="false"
    >
      <el-form ref="formRef" :model="formData" :rules="formRules" label-width="80px">
        <el-form-item label="角色名称" prop="name">
          <el-input v-model="formData.name" />
        </el-form-item>
        <el-form-item label="角色编码" prop="code">
          <el-input v-model="formData.code" :disabled="isEdit" />
        </el-form-item>
        <el-form-item label="描述" prop="description">
          <el-input v-model="formData.description" type="textarea" :rows="3" />
        </el-form-item>
        <el-form-item label="状态">
          <el-switch v-model="formData.status" />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="handleSubmit">确定</el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { getRoleList, saveRole, deleteRole } from '@/api/mock'
import type { PageParams } from '@/api/mock'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'

const loading = ref(false)
const tableData = ref<any[]>([])
const total = ref(0)
const pageParams = reactive<PageParams>({ page: 1, pageSize: 10 })

async function fetchData() {
  loading.value = true
  try {
    const res = await getRoleList(pageParams)
    tableData.value = res.list
    total.value = res.total
  } finally { loading.value = false }
}

const dialogVisible = ref(false)
const isEdit = ref(false)
const editId = ref<number | null>(null)
const formRef = ref<FormInstance>()
const formData = reactive({ name: '', code: '', description: '', status: true })

const formRules: FormRules = {
  name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
  code: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
}

function resetForm() {
  formData.name = ''
  formData.code = ''
  formData.description = ''
  formData.status = true
  editId.value = null
}

function handleAdd() { isEdit.value = false; resetForm(); dialogVisible.value = true }
function handleEdit(row: any) {
  isEdit.value = true; editId.value = row.id
  Object.assign(formData, { name: row.name, code: row.code, description: row.description, status: row.status })
  dialogVisible.value = true
}

async function handleSubmit() {
  if (!formRef.value) return
  const valid = await formRef.value.validate().catch(() => false)
  if (!valid) return
  try {
    await saveRole({ id: editId.value ?? undefined, ...formData } as any)
    ElMessage.success(isEdit.value ? '编辑成功' : '新增成功')
    dialogVisible.value = false
    fetchData()
  } catch { ElMessage.error('操作失败') }
}

async function handleDelete(row: any) {
  try {
    await ElMessageBox.confirm(`确定要删除角色 "${row.name}" 吗?`, '确认删除', { type: 'warning' })
    await deleteRole(row.id)
    ElMessage.success('删除成功')
    fetchData()
  } catch { /* 取消 */ }
}

onMounted(() => fetchData())
</script>

<style scoped lang="scss">
.table-header { display: flex; justify-content: space-between; align-items: center; }
.pagination-wrap { margin-top: 16px; display: flex; justify-content: flex-end; }
</style>

12. src/styles/global.scss — 全局样式

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html, body, #app {
  height: 100%;
  font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', sans-serif;
}

// Element Plus 菜单覆盖
.el-menu {
  border-right: none !important;
}

// 搜索卡片去掉底部 padding
.search-card .el-card__body {
  padding-bottom: 0;
}

// 表格 header 加背景色
.el-table th.el-table__cell {
  background-color: #f5f7fa;
}

13. 运行项目

# 1. 创建项目
npm create vite@latest admin-system -- --template vue-ts
cd admin-system

# 2. 安装依赖
npm install
npm install element-plus @element-plus/icons-vue vue-router pinia sass

# 3. 替换上述文件内容

# 4. 启动
npm run dev

完整流程: 访问 http://localhost:3000 → 自动跳转 /login → 输入 admin / 123456 登录 → 进入 Dashboard → 点击左侧菜单切换页面 → 用户管理 CRUD → 角色管理 CRUD → 右上角退出。