手搓一个前端管理系统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个统计卡片 → 点击左侧"用户管理" → 看到用户列表 → 点击新增→弹出表单→提交→列表刷新 → 点击编辑→修改→提交 → 点击删除→确认→列表刷新 → 点击右上角退出。
第五步:学生扩展练习
完成以下练习来巩固所学:
- 角色管理完善(必做):参照UserList.vue,将RoleList.vue改为完整的CRUD页面(增删改查+搜索+分页)。提示:在mock.ts中增加角色数据和对应API方法
- 菜单管理页面(进阶):新增一个菜单管理页面,管理侧边栏菜单项(菜单名称、路径、图标、排序、父级菜单)。提示:使用el-tree组件展示菜单树
- 暗黑模式切换(进阶):在顶部栏添加一个开关按钮,使用Pinia管理主题状态,切换Element-Plus的暗黑模式。提示:使用useDark()和useToggle()
- 权限控制(进阶):根据用户角色(admin/user)控制菜单和按钮的显示/隐藏。admin可以看到所有菜单和执行所有操作,user只能看Dashboard和用户列表(不能编辑删除)
- 集成ECharts图表(进阶):在Dashboard中用ECharts替换静态统计卡片,展示:用户增长趋势折线图、用户状态分布饼图、月度新增柱状图
- 对接真实后端(综合):将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 → 右上角退出。