Vue.js从零到精通系列(八):项目实战——构建一个完整的电商后台管理系统

摘要: 经过前几篇的系统学习,已经掌握了 Vue 3 的组件化、路由、状态管理、组合式函数以及一系列高级特性。但知识只有落地到真实项目中,才能真正内化为能力。本篇将带领你从零开始,构建一个功能完整、界面美观电商后台管理系统 ------涵盖登录认证、仪表盘数据看板、商品列表与搜索、订单管理、用户管理与权限分配等核心模块。我们将综合运用 Vite + Vue 3 + TypeScript + Vue Router + Pinia 的技术栈,遵循"项目初始化 → 目录规划 → 路由设计 → 状态管理 → 页面开发 → 数据交互"的标准流程,让你完整体验一次企业级前端项目的开发全过程。


一、项目蓝图与初始化

1.1 项目需求概述

我们将构建一个名为 VueMart Admin 的后台管理系统,包含以下核心模块:

  • 登录认证:用户登录、Token 存储、路由守卫鉴权。

  • 仪表盘:数据概览卡片、近七日订单趋势图。

  • 商品管理:商品列表、搜索筛选、新增/编辑商品、删除。

  • 订单管理:订单列表、状态筛选、订单详情查看。

  • 用户管理:用户列表、新增/编辑/删除用户、角色分配(基础展示)。

1.2 技术栈选型

技术 用途
Vite 构建工具
Vue 3 + Composition API 框架
TypeScript 类型安全
Vue Router 4 前端路由
Pinia 全局状态管理
Axios HTTP 请求(已封装,可对接真实API)
Element Plus UI 组件库(降低样式工作量)
ECharts 图表展示

注:为聚焦 Vue 核心能力,本教程的数据采用 Mock 模拟数据 + localStorage 持久化,不依赖后端服务。你可以无缝替换为真实 API。

1.3 项目初始化

在终端中执行以下命令创建项目:

TypeScript 复制代码
pnpm create vite vue-mart-admin --template vue-ts
cd vue-mart-admin
pnpm install

安装依赖:

TypeScript 复制代码
pnpm add vue-router@4 pinia axios
pnpm add element-plus echarts
pnpm add -D @types/node

1.4 目录结构规划

TypeScript 复制代码
src/
├── api/                  # API 接口封装
│   └── index.ts          # Axios 实例与拦截器
├── assets/               # 静态资源(可放 logo 等)
├── components/           # 全局通用组件
│   ├── AppLayout.vue     # 后台布局(侧边栏+顶栏+内容区)
│   ├── StatCard.vue      # 统计卡片
│   └── ChartCard.vue     # 图表卡片
├── composables/          # 组合式函数(可选)
├── directives/           # 自定义指令
│   └── permission.ts     # 权限指令
├── mock/                 # 模拟数据
│   └── data.ts           # 商品、订单、用户假数据
├── router/               # 路由配置
│   └── index.ts
├── stores/               # Pinia 状态管理
│   ├── auth.ts           # 用户认证 Store
│   ├── product.ts        # 商品 Store
│   ├── order.ts          # 订单 Store
│   └── user.ts           # 用户管理 Store
├── styles/               # 全局样式
│   └── global.css
├── types/                # TypeScript 类型定义
│   ├── global.d.ts       # 全局类型扩展
│   └── models.ts         # 业务模型类型
├── views/                # 页面组件
│   ├── LoginPage.vue
│   ├── DashboardPage.vue
│   ├── ProductListPage.vue
│   ├── ProductEditPage.vue
│   ├── OrderListPage.vue
│   ├── OrderDetailPage.vue
│   ├── UserListPage.vue
│   └── UserEditPage.vue
├── App.vue
└── main.ts

二、基础设施搭建

2.1 类型定义(types/models.ts)

先定义整个应用中使用的核心数据类型:

javascript 复制代码
// src/types/models.ts

/** 用户信息 */
export interface UserInfo {
  id: number
  username: string
  nickname: string
  avatar: string
  role: 'admin' | 'editor' | 'viewer'  // 扩展了 viewer 角色
  token: string
}

/** 登录表单 */
export interface LoginForm {
  username: string
  password: string
}

/** 商品 */
export interface Product {
  id: number
  name: string
  category: string
  price: number
  stock: number
  status: 'on' | 'off'
  description: string
  image: string
  createTime: string
}

/** 订单 */
export interface Order {
  id: number
  orderNo: string
  customerName: string
  totalAmount: number
  status: 'pending' | 'shipped' | 'completed' | 'cancelled'
  products: { productId: number; name: string; quantity: number; price: number }[]
  createTime: string
}

/** 系统用户(管理端) */
export interface SystemUser {
  id: number
  username: string
  nickname: string
  role: string
  status: 'active' | 'disabled'
  createTime: string
  password?: string  // 新增密码字段(可选)
}

/** 分页参数 */
export interface PaginationParams {
  page: number
  pageSize: number
}

/** 分页响应 */
export interface PaginatedResponse<T> {
  list: T[]
  total: number
  page: number
  pageSize: number
}

2.2 全局类型扩展(types/global.d.ts)

扩展 Vue 的全局属性类型(后续会在全局属性上挂载 $formatDate 等方法):

javascript 复制代码
// src/types/global.d.ts
export {}

declare module 'vue' {
  interface ComponentCustomProperties {
    $formatDate: (date: string | Date, format?: string) => string
    $formatMoney: (amount: number) => string
  }
}

2.3 Axios 封装(api/index.ts)

创建统一的请求实例,配置拦截器处理 Token 和错误响应:

javascript 复制代码
// src/api/index.ts
import axios from 'axios'
import type { AxiosInstance, AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'

const instance: AxiosInstance = axios.create({
  baseURL: '/api',      // 可改为真实地址
  timeout: 10000
})

// 请求拦截器:自动添加 Token
instance.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('admin-token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => Promise.reject(error)
)

// 响应拦截器:统一错误处理
instance.interceptors.response.use(
  (response: AxiosResponse) => {
    const { code, data, message } = response.data
    if (code === 0) {
      return data
    } else {
      ElMessage.error(message || '请求失败')
      return Promise.reject(new Error(message))
    }
  },
  (error) => {
    if (error.response?.status === 401) {
      localStorage.removeItem('admin-token')
      window.location.href = '/#/login'
    }
    ElMessage.error(error.message || '网络错误')
    return Promise.reject(error)
  }
)

export default instance

由于我们使用 Mock 数据,实际上并不会真正发送请求,但这个封装保证了将来对接真实 API 时只需修改 baseURL

2.4 模拟数据(mock/data.ts)

创建初始的 Mock 数据,同时在 localStorage 中初始化:

javascript 复制代码
// src/mock/data.ts
import type { Product, Order, SystemUser, UserInfo } from '../types/models'

/** 默认管理员账号 */
export const defaultAdmin: UserInfo = {
  id: 1,
  username: 'admin',
  nickname: '超级管理员',
  avatar: '',
  role: 'admin',
  token: 'mock-jwt-token-admin-2026'
}

/** 初始化商品数据 */
export function getInitialProducts(): Product[] {
  return [
    {
      id: 1,
      name: 'iPhone 15 Pro',
      category: '手机数码',
      price: 8999,
      stock: 120,
      status: 'on',
      description: '全新 A17 Pro 芯片',
      image: '',
      createTime: '2026-01-15'
    },
    {
      id: 2,
      name: 'MacBook Air M3',
      category: '电脑办公',
      price: 10499,
      stock: 58,
      status: 'on',
      description: '13.6英寸 Liquid Retina 显示屏',
      image: '',
      createTime: '2026-02-20'
    },
    {
      id: 3,
      name: 'AirPods Pro 2',
      category: '手机配件',
      price: 1899,
      stock: 300,
      status: 'on',
      description: '自适应音频功能',
      image: '',
      createTime: '2026-03-10'
    },
    {
      id: 4,
      name: 'Apple Watch S9',
      category: '智能穿戴',
      price: 3999,
      stock: 0,
      status: 'off',
      description: '全新S9芯片',
      image: '',
      createTime: '2026-01-28'
    },
    {
      id: 5,
      name: 'iPad Pro M4',
      category: '平板电脑',
      price: 6799,
      stock: 45,
      status: 'on',
      description: '超视网膜XDR显示屏',
      image: '',
      createTime: '2026-04-05'
    }
  ]
}

/** 初始化订单数据 */
export function getInitialOrders(): Order[] {
  return [
    {
      id: 1,
      orderNo: 'OM20240301001',
      customerName: '张三',
      totalAmount: 10898,
      status: 'completed',
      products: [
        { productId: 1, name: 'iPhone 15 Pro', quantity: 1, price: 8999 },
        { productId: 3, name: 'AirPods Pro 2', quantity: 1, price: 1899 }
      ],
      createTime: '2026-03-01'
    },
    {
      id: 2,
      orderNo: 'OM20240302002',
      customerName: '李四',
      totalAmount: 10499,
      status: 'shipped',
      products: [
        { productId: 2, name: 'MacBook Air M3', quantity: 1, price: 10499 }
      ],
      createTime: '2026-03-02'
    },
    {
      id: 3,
      orderNo: 'OM20240303003',
      customerName: '王五',
      totalAmount: 1899,
      status: 'pending',
      products: [
        { productId: 3, name: 'AirPods Pro 2', quantity: 1, price: 1899 }
      ],
      createTime: '2026-03-03'
    }
  ]
}

/** 初始化用户数据 */
export function getInitialUsers(): SystemUser[] {
  return [
    {
      id: 1,
      username: 'admin',
      nickname: '超级管理员',
      role: 'admin',
      status: 'active',
      createTime: '2026-01-01',
      password: 'admin123'
    },
    {
      id: 2,
      username: 'editor01',
      nickname: '编辑小王',
      role: 'editor',
      status: 'active',
      createTime: '2026-02-15',
      password: '123456'
    }
  ]
}

/** 通用 localStorage 数据初始化 */
export function initMockData() {
  if (!localStorage.getItem('products')) {
    localStorage.setItem('products', JSON.stringify(getInitialProducts()))
  }
  if (!localStorage.getItem('orders')) {
    localStorage.setItem('orders', JSON.stringify(getInitialOrders()))
  }
  if (!localStorage.getItem('users')) {
    localStorage.setItem('users', JSON.stringify(getInitialUsers()))
    localStorage.setItem('nextUserId', '3')  // 初始下一个 ID 为 3
  }
}

main.ts 中调用 initMockData() 完成初始化。


三、路由设计与守卫

3.1 路由配置(router/index.ts)

根据页面结构设计路由表,后台页面共享 AppLayout 布局:

javascript 复制代码
// src/router/index.ts
import { createRouter, createWebHashHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('../views/LoginPage.vue'),
    meta: { title: '登录' }
  },
  {
    path: '/',
    component: () => import('../components/AppLayout.vue'),
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('../views/DashboardPage.vue'),
        meta: { title: '仪表盘', icon: 'dashboard', roles: ['admin', 'editor', 'viewer'] }
      },
      {
        path: 'products',
        name: 'ProductList',
        component: () => import('../views/ProductListPage.vue'),
        meta: { title: '商品管理', icon: 'goods', roles: ['admin', 'editor', 'viewer'] }
      },
      {
        path: 'products/create',
        name: 'ProductCreate',
        component: () => import('../views/ProductEditPage.vue'),
        meta: { title: '新增商品', roles: ['admin', 'editor'] }
      },
      {
        path: 'products/:id/edit',
        name: 'ProductEdit',
        component: () => import('../views/ProductEditPage.vue'),
        meta: { title: '编辑商品', roles: ['admin', 'editor'] }
      },
      {
        path: 'orders',
        name: 'OrderList',
        component: () => import('../views/OrderListPage.vue'),
        meta: { title: '订单管理', icon: 'order', roles: ['admin', 'editor', 'viewer'] }
      },
      {
        path: 'orders/:id',
        name: 'OrderDetail',
        component: () => import('../views/OrderDetailPage.vue'),
        meta: { title: '订单详情', roles: ['admin', 'editor', 'viewer'] }
      },
      {
        path: 'users',
        name: 'UserList',
        component: () => import('../views/UserListPage.vue'),
        meta: { title: '用户管理', icon: 'user', roles: ['admin'] }
      },
      {
        path: 'users/create',
        name: 'UserCreate',
        component: () => import('../views/UserEditPage.vue'),
        meta: { title: '新增用户', roles: ['admin'] }
      },
      {
        path: 'users/:id/edit',
        name: 'UserEdit',
        component: () => import('../views/UserEditPage.vue'),
        meta: { title: '编辑用户', roles: ['admin'] }
      }
    ]
  },
  {
    path: '/:pathMatch(.*)*',
    redirect: '/dashboard'
  }
]

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

export default router

3.2 路由守卫

router/index.ts 中添加全局前置守卫,实现登录鉴权:

javascript 复制代码
// 在 router/index.ts 底部继续添加以下代码
import { useAuthStore } from '../stores/auth'
import { ElMessage } from 'element-plus'

router.beforeEach((to, _from, next) => {
  const authStore = useAuthStore()
  document.title = (to.meta.title as string) || 'VueMart Admin'

  if (to.path === '/login') {
    if (authStore.isLoggedIn) {
      next('/dashboard')
    } else {
      next()
    }
    return
  }

  if (!authStore.isLoggedIn) {
    next('/login')
    return
  }

  const requiredRoles = to.meta.roles as string[] | undefined
  if (requiredRoles && !requiredRoles.includes(authStore.userInfo!.role)) {
    ElMessage.warning('权限不足,无法访问该页面')
    next('/dashboard')
    return
  }

  next()
})

注意:由于守卫中使用了 Pinia Store,需要确保在创建 router 之前已经安装 Pinia。在 main.ts 中先注册 Pinia,再注册 Router 即可。


四、状态管理(Pinia Stores)

4.1 认证 Store(stores/auth.ts)

管理用户登录状态、Token 及退出逻辑:

javascript 复制代码
// src/stores/auth.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import type { UserInfo, LoginForm } from '../types/models'
import { useUserStore } from './user'

export const useAuthStore = defineStore('auth', () => {
  const userInfo = ref<UserInfo | null>(null)
  const token = ref<string>('')

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

  // 从 localStorage 恢复
  function initAuth() {
    const savedToken = localStorage.getItem('admin-token')
    if (savedToken) {
      token.value = savedToken
      // 尝试从 token 解析出用户名(简化处理:直接给一个 admin 信息,后续可优化)
      userInfo.value = {
        id: 1,
        username: 'admin',
        nickname: '超级管理员',
        avatar: '',
        role: 'admin',
        token: savedToken
      }
    }
  }

  async function login(form: LoginForm): Promise<boolean> {
    const userStore = useUserStore()
    const matchedUser = userStore.findUserByCredentials(form.username, form.password)
    if (matchedUser) {
      userInfo.value = {
        id: matchedUser.id,
        username: matchedUser.username,
        nickname: matchedUser.nickname,
        avatar: '',
        role: matchedUser.role as 'admin' | 'editor' | 'viewer',
        token: 'mock-jwt-token-' + matchedUser.username + '-' + Date.now()
      }
      token.value = userInfo.value.token
      localStorage.setItem('admin-token', token.value)
      return true
    }
    return false
  }

  function logout() {
    userInfo.value = null
    token.value = ''
    localStorage.removeItem('admin-token')
  }

  initAuth()

  return { userInfo, token, isLoggedIn, login, logout }
})

4.2 商品 Store(stores/product.ts)

使用 localStorage 模拟后端 CRUD:

javascript 复制代码
// src/stores/product.ts
import { ref } from 'vue'
import { defineStore } from 'pinia'
import type { Product } from '../types/models'

export const useProductStore = defineStore('product', () => {
  const products = ref<Product[]>([])
  const nextId = ref<number>(1)

  // 加载商品
  function loadProducts() {
    const stored = localStorage.getItem('products')
    if (stored) products.value = JSON.parse(stored)
    if (products.value.length > 0) {
      nextId.value = Math.max(...products.value.map(p => p.id)) + 1
    }
  }

  // 保存到 localStorage
  function saveProducts() {
    localStorage.setItem('products', JSON.stringify(products.value))
  }

  // 按条件筛选
  function filterProducts(params: { keyword?: string; category?: string; status?: string }) {
    let list = products.value
    if (params.keyword) {
      list = list.filter(p => p.name.includes(params.keyword!))
    }
    if (params.category) {
      list = list.filter(p => p.category === params.category)
    }
    if (params.status) {
      list = list.filter(p => p.status === params.status)
    }
    return list
  }

  function getProductById(id: number): Product | undefined {
    return products.value.find(p => p.id === id)
  }

  function createProduct(data: Omit<Product, 'id' | 'createTime'>) {
    const newProduct: Product = {
      ...data,
      id: nextId.value++,   // 使用自增短 ID
      createTime: new Date().toISOString().split('T')[0]
    }
    products.value.unshift(newProduct)
    saveProducts()
    return newProduct
  }

  function updateProduct(id: number, data: Partial<Product>) {
    const index = products.value.findIndex(p => p.id === id)
    if (index !== -1) {
      products.value[index] = { ...products.value[index], ...data }
      saveProducts()
    }
  }

  function deleteProduct(id: number) {
    products.value = products.value.filter(p => p.id !== id)
    saveProducts()
  }

  loadProducts()

  return { products, filterProducts, getProductById, createProduct, updateProduct, deleteProduct }
})

4.3 订单 Store(stores/order.ts)

javascript 复制代码
// src/stores/order.ts
import { ref } from 'vue'
import { defineStore } from 'pinia'
import type { Order } from '../types/models'

export const useOrderStore = defineStore('order', () => {
  const orders = ref<Order[]>([])

  function loadOrders() {
    const stored = localStorage.getItem('orders')
    if (stored) orders.value = JSON.parse(stored)
  }

  function saveOrders() {
    localStorage.setItem('orders', JSON.stringify(orders.value))
  }

  function filterOrders(params: { keyword?: string; status?: string }) {
    let list = orders.value
    if (params.keyword) {
      list = list.filter(o => o.orderNo.includes(params.keyword!) || o.customerName.includes(params.keyword!))
    }
    if (params.status) {
      list = list.filter(o => o.status === params.status)
    }
    return list
  }

  function getOrderById(id: number): Order | undefined {
    return orders.value.find(o => o.id === id)
  }

  function updateOrderStatus(id: number, status: Order['status']) {
    const order = orders.value.find(o => o.id === id)
    if (order) {
      order.status = status
      saveOrders()
    }
  }

  loadOrders()

  return { orders, filterOrders, getOrderById, updateOrderStatus }
})

4.4 用户管理 Store(stores/user.ts)

包含完整的增、删、改、查及角色更新功能:

javascript 复制代码
// src/stores/user.ts
import { ref } from 'vue'
import { defineStore } from 'pinia'
import type { SystemUser } from '../types/models'

export const useUserStore = defineStore('user', () => {
  const users = ref<SystemUser[]>([])
  const nextId = ref<number>(1)

  function loadUsers() {
    const stored = localStorage.getItem('users')
    if (stored) users.value = JSON.parse(stored)
    const storedNextId = localStorage.getItem('nextUserId')
    if (storedNextId) {
      nextId.value = Number(storedNextId)
    } else if (users.value.length > 0) {
      nextId.value = Math.max(...users.value.map(u => u.id)) + 1
    }
  }

  function saveUsers() {
    localStorage.setItem('users', JSON.stringify(users.value))
    localStorage.setItem('nextUserId', String(nextId.value))
  }

  function filterUsers(keyword: string) {
    if (!keyword) return users.value
    return users.value.filter(
      u => u.nickname.includes(keyword) || u.username.includes(keyword)
    )
  }

  function getUserById(id: number): SystemUser | undefined {
    return users.value.find(u => u.id === id)
  }

  // 新增用户
  function createUser(data: Omit<SystemUser, 'id' | 'createTime'> & { password?: string }) {
    const newUser: SystemUser = {
      id: nextId.value++,
      username: data.username,
      nickname: data.nickname,
      role: data.role,
      status: data.status,
      createTime: new Date().toISOString().split('T')[0],
      password: data.password
    }
    users.value.unshift(newUser)
    saveUsers()
  }

  // 编辑用户
  function updateUser(id: number, data: Partial<SystemUser> & { password?: string }) {
    const index = users.value.findIndex(u => u.id === id)
    if (index !== -1) {
      users.value[index] = { ...users.value[index], ...data }
      saveUsers()
    }
  }

  // 删除用户
  function deleteUser(id: number) {
    users.value = users.value.filter(u => u.id !== id)
    saveUsers()
  }

  // 单独更新角色
  function updateUserRole(id: number, role: string) {
    const user = users.value.find(u => u.id === id)
    if (user) {
      user.role = role
      saveUsers()
    }
  }

  // 供登录使用:根据用户名和密码查找用户
  function findUserByCredentials(username: string, password: string) {
    return users.value.find(u => u.username === username && u.password === password)
  }

  loadUsers()

  return {
    users,
    filterUsers,
    getUserById,
    createUser,
    updateUser,
    deleteUser,
    updateUserRole,
    findUserByCredentials
  }
})

五、全局样式与主题美化

为了让项目拥有更现代、更美观的视觉效果,我们在 src/style.css 中定义全局样式覆盖 Element Plus 变量:

css 复制代码
:root {
  --el-color-primary: #6c5ce7;
  --el-color-primary-light-3: #a29bfe;
  --el-border-radius-base: 12px;
  --el-card-border-radius: 16px;
  --el-bg-color-page: #f5f7fa;
  --el-bg-color: #ffffff;
  font-family: 'Inter', 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', sans-serif;
}

body {
  margin: 0;
  background: var(--el-bg-color-page);
}

.el-card {
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
  border: none;
}

.el-card:hover {
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
}

.el-table th.el-table__cell {
  background-color: #f8f9fc;
  font-weight: 600;
  color: #2c3e50;
}

.el-button {
  border-radius: 8px;
  font-weight: 500;
}

.el-input__wrapper,
.el-select .el-input__wrapper {
  border-radius: 8px;
}

然后在 main.ts 中引入(放在 Element Plus 样式之后)。


六、通用组件

6.1 统计卡片组件(components/StatCard.vue)

一个可复用的统计卡片,用于仪表盘。

javascript 复制代码
<!-- src/components/StatCard.vue -->
<script setup lang="ts">
defineProps<{
  title: string
  value: string | number
  color?: string
  icon?: string
}>()
</script>

<template>
  <el-card class="stat-card">
    <div class="stat-card-content">
      <div class="stat-card-left">
        <div class="stat-card-title">{{ title }}</div>
        <div class="stat-card-value">{{ value }}</div>
      </div>
      <div v-if="icon" class="stat-card-icon" :style="{ backgroundColor: color || '#6c5ce7' }">
        <el-icon :size="32" color="#fff"><component :is="icon" /></el-icon>
      </div>
    </div>
  </el-card>
</template>

<style scoped>
.stat-card {
  border-radius: 16px;
  transition: transform 0.2s;
}
.stat-card:hover {
  transform: translateY(-2px);
}
.stat-card-content {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.stat-card-title {
  font-size: 14px;
  color: #909399;
  margin-bottom: 8px;
}
.stat-card-value {
  font-size: 28px;
  font-weight: 700;
  color: #2c3e50;
}
.stat-card-icon {
  width: 60px;
  height: 60px;
  border-radius: 16px;
  display: flex;
  align-items: center;
  justify-content: center;
}
</style>

6.2 图表卡片组件(components/ChartCard.vue)

封装 ECharts 图表的卡片容器,并负责初始化图表。

javascript 复制代码
<!-- src/components/ChartCard.vue -->
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import * as echarts from 'echarts'

const props = defineProps<{
  option: echarts.EChartsOption
  height?: string
}>()

const chartRef = ref<HTMLDivElement>()
let chartInstance: echarts.ECharts | null = null

onMounted(() => {
  if (chartRef.value) {
    chartInstance = echarts.init(chartRef.value)
    chartInstance.setOption(props.option)
  }
})

watch(() => props.option, (newOption) => {
  chartInstance?.setOption(newOption)
}, { deep: true })
</script>

<template>
  <el-card class="chart-card">
    <div ref="chartRef" :style="{ height: height || '350px' }"></div>
  </el-card>
</template>

<style scoped>
.chart-card {
  border-radius: 16px;
}
</style>

6.3 后台布局组件(components/AppLayout.vue)

使用 Element Plus 的 el-containerel-menu 构建侧边栏 + 顶栏 + 内容区经典布局:

javascript 复制代码
<!-- src/components/AppLayout.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'

const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()

const isCollapse = ref(false)

const activeMenu = computed(() => route.path)

function handleCommand(command: string) {
  if (command === 'logout') {
    authStore.logout()
    router.push('/login')
  }
}
</script>

<template>
  <el-container class="layout">
    <!-- 侧边栏 -->
    <el-aside :width="isCollapse ? '64px' : '220px'" class="aside">
      <div class="logo" @click="router.push('/dashboard')">
        <img src="/logo.svg" v-show="!isCollapse" class="logo-img" />
        <span v-show="!isCollapse" class="logo-text">VueMart</span>
        <span v-show="isCollapse" class="logo-mini">VM</span>
      </div>
      <el-menu
        :default-active="activeMenu"
        :collapse="isCollapse"
        router
        background-color="#ffffff"
        text-color="#374151"
        active-text-color="#6c5ce7"
        class="menu"
      >
        <el-menu-item index="/dashboard">
          <el-icon><DataLine /></el-icon>
          <span>仪表盘</span>
        </el-menu-item>
        <el-menu-item index="/products">
          <el-icon><Goods /></el-icon>
          <span>商品管理</span>
        </el-menu-item>
        <el-menu-item index="/orders">
          <el-icon><Document /></el-icon>
          <span>订单管理</span>
        </el-menu-item>
        <el-menu-item index="/users" v-if="authStore.userInfo?.role === 'admin'">
          <el-icon><User /></el-icon>
          <span>用户管理</span>
        </el-menu-item>
      </el-menu>
    </el-aside>

    <!-- 右侧主体 -->
    <el-container>
      <el-header class="header">
        <div class="header-left">
          <span class="collapse-btn" @click="isCollapse = !isCollapse">
            <el-icon :size="22"><Fold v-if="!isCollapse" /><Expand v-else /></el-icon>
          </span>
        </div>
        <div class="header-right">
          <el-dropdown @command="handleCommand">
            <span class="user-info">
              <el-avatar :size="32" class="mr-2">{{ authStore.userInfo?.nickname?.charAt(0) }}</el-avatar>
              {{ authStore.userInfo?.nickname }}
              <el-icon class="ml-1"><ArrowDown /></el-icon>
            </span>
            <template #dropdown>
              <el-dropdown-menu>
                <el-dropdown-item command="logout">退出登录</el-dropdown-item>
              </el-dropdown-menu>
            </template>
          </el-dropdown>
        </div>
      </el-header>
      <el-main>
        <router-view />
      </el-main>
    </el-container>
  </el-container>
</template>

<style scoped>
.layout {
  height: 100vh;
  background: #f0f2f5;
}

.aside {
  background: #ffffff;
  box-shadow: 2px 0 12px rgba(0, 0, 0, 0.02);
  overflow-x: hidden;
  transition: width 0.3s;
}

.logo {
  height: 64px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 16px;
  border-bottom: 1px solid #eef0f2;
  cursor: pointer;
}

.logo-img {
  width: 32px;
  height: 32px;
  margin-right: 10px;
}

.logo-text {
  font-size: 20px;
  font-weight: 700;
  color: #6c5ce7;
}

.logo-mini {
  font-size: 22px;
  font-weight: bold;
  color: #6c5ce7;
}

.menu {
  border-right: none;
}

.header {
  background: #ffffff;
  border-bottom: 1px solid #e4e7ed;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 24px;
}

.header-left {
  display: flex;
  align-items: center;
}

.collapse-btn {
  cursor: pointer;
  display: flex;
  align-items: center;
  color: #606266;
  padding: 8px;
  border-radius: 8px;
  transition: background 0.2s;
}

.collapse-btn:hover {
  background: #f0f2f5;
}

.header-right {
  display: flex;
  align-items: center;
}

.user-info {
  display: flex;
  align-items: center;
  cursor: pointer;
  padding: 4px 12px;
  border-radius: 8px;
  transition: background 0.2s;
  font-weight: 500;
}

.user-info:hover {
  background: #f0f2f5;
}

.mr-2 {
  margin-right: 8px;
}
.ml-1 {
  margin-left: 4px;
}
</style>

七、核心页面开发

7.1 登录页(views/LoginPage.vue)

使用渐变背景 + 玻璃拟态卡片,让登录页成为第一眼惊艳的入口。

javascript 复制代码
<!-- src/views/LoginPage.vue -->
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { ElMessage } from 'element-plus'
import type { LoginForm } from '../types/models'

const router = useRouter()
const authStore = useAuthStore()

const form = reactive<LoginForm>({ username: 'admin', password: '' })
const loading = ref(false)

async function handleLogin() {
  if (!form.username || !form.password) {
    ElMessage.warning('请输入用户名和密码')
    return
  }
  loading.value = true
  const success = await authStore.login(form)
  loading.value = false
  if (success) {
    ElMessage.success('登录成功')
    router.push('/dashboard')
  } else {
    ElMessage.error('用户名或密码错误')
  }
}
</script>

<template>
  <div class="login-container">
    <div class="login-card">
      <div class="login-header">
        <img src="/logo.svg" class="login-logo" />
        <h1>VueMart Admin</h1>
        <p>简洁高效的管理后台</p>
      </div>
      <el-form @submit.prevent="handleLogin" class="login-form">
        <el-form-item>
          <el-input
            v-model="form.username"
            placeholder="用户名"
            prefix-icon="User"
            size="large"
            clearable
          />
        </el-form-item>
        <el-form-item>
          <el-input
            v-model="form.password"
            type="password"
            placeholder="密码"
            prefix-icon="Lock"
            show-password
            size="large"
            @keyup.enter="handleLogin"
          />
        </el-form-item>
        <el-form-item>
          <el-button
            type="primary"
            size="large"
            :loading="loading"
            class="login-btn"
            @click="handleLogin"
          >
            登 录
          </el-button>
        </el-form-item>
      </el-form>
      <div class="login-tips">
        演示账号:admin / admin123
      </div>
    </div>
  </div>
</template>

<style scoped>
.login-container {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  position: relative;
  overflow: hidden;
}

.login-container::before {
  content: '';
  position: absolute;
  width: 600px;
  height: 600px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.05);
  top: -200px;
  right: -200px;
}

.login-container::after {
  content: '';
  position: absolute;
  width: 400px;
  height: 400px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.05);
  bottom: -150px;
  left: -150px;
}

.login-card {
  width: 420px;
  padding: 48px 40px 36px;
  background: rgba(255, 255, 255, 0.9);
  backdrop-filter: blur(12px);
  border-radius: 24px;
  box-shadow: 0 24px 80px rgba(0, 0, 0, 0.25);
  position: relative;
  z-index: 1;
}

.login-header {
  text-align: center;
  margin-bottom: 36px;
}

.login-logo {
  width: 64px;
  height: 64px;
  margin-bottom: 16px;
}

.login-header h1 {
  font-size: 28px;
  color: #2c3e50;
  margin: 0 0 8px;
  font-weight: 700;
}

.login-header p {
  color: #8b8fa3;
  font-size: 14px;
  margin: 0;
}

.login-form {
  margin-top: 12px;
}

.login-btn {
  width: 100%;
  height: 48px;
  font-size: 16px;
  border-radius: 12px;
  font-weight: 600;
  letter-spacing: 2px;
}

.login-tips {
  text-align: center;
  color: #a0a4b8;
  font-size: 13px;
  margin-top: 24px;
  background: rgba(108, 92, 231, 0.08);
  padding: 8px 16px;
  border-radius: 8px;
}
</style>

7.2 仪表盘页(views/DashboardPage.vue)

利用 StatCard 和 ChartCard 组件构建数据看板。

javascript 复制代码
<!-- src/views/DashboardPage.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useProductStore } from '../stores/product'
import { useOrderStore } from '../stores/order'
import StatCard from '../components/StatCard.vue'
import ChartCard from '../components/ChartCard.vue'

const productStore = useProductStore()
const orderStore = useOrderStore()

const stats = computed(() => [
  { title: '商品总数', value: productStore.products.length, icon: 'Goods', color: '#6c5ce7' },
  { title: '订单总数', value: orderStore.orders.length, icon: 'Document', color: '#00cec9' },
  { title: '待处理订单', value: orderStore.orders.filter(o => o.status === 'pending').length, icon: 'Clock', color: '#fdcb6e' },
  { title: '总销售额(元)', value: '¥' + orderStore.orders.reduce((s, o) => s + o.totalAmount, 0), icon: 'Money', color: '#e17055' }
])

const chartOption = {
  title: { text: '近七日订单趋势', textStyle: { fontSize: 16, fontWeight: 600 } },
  tooltip: { trigger: 'axis' },
  grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
  xAxis: { data: ['周一','周二','周三','周四','周五','周六','周日'] },
  yAxis: { type: 'value' },
  series: [{
    name: '订单量',
    type: 'line',
    data: [12, 19, 8, 15, 22, 18, 25],
    smooth: true,
    color: '#6c5ce7',
    areaStyle: { color: 'rgba(108, 92, 231, 0.1)' }
  }]
}
</script>

<template>
  <div class="dashboard">
    <h2 class="page-title">仪表盘</h2>
    <el-row :gutter="20" class="stat-row">
      <el-col :span="6" v-for="(item, index) in stats" :key="index">
        <StatCard :title="item.title" :value="item.value" :icon="item.icon" :color="item.color" />
      </el-col>
    </el-row>
    <ChartCard :option="chartOption" class="chart-mt" />
  </div>
</template>

<style scoped>
.page-title {
  margin: 0 0 24px;
  font-size: 24px;
  font-weight: 700;
  color: #1f2937;
}
.stat-row {
  margin-bottom: 10px;
}
.chart-mt {
  margin-top: 24px;
}
</style>

7.3 商品列表页(views/ProductListPage.vue)

包含搜索栏、数据表格和操作按钮。

javascript 复制代码
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useProductStore } from '../stores/product'
import { useAuthStore } from '../stores/auth'
import type { Product } from '../types/models'
import { ElMessage, ElMessageBox } from 'element-plus'

const router = useRouter()
const productStore = useProductStore()
const authStore = useAuthStore()

const keyword = ref('')
const statusFilter = ref('')

const filteredProducts = computed(() => {
  return productStore.filterProducts({
    keyword: keyword.value,
    status: statusFilter.value
  })
})

function handleDelete(product: Product) {
  ElMessageBox.confirm(`确定删除 "${product.name}" 吗?`, '提示', { type: 'warning' })
    .then(() => {
      productStore.deleteProduct(product.id)
      ElMessage.success('删除成功')
    })
}

function handleStatusChange(product: Product) {
  const newStatus = product.status === 'on' ? 'off' : 'on'
  productStore.updateProduct(product.id, { status: newStatus })
  ElMessage.success(newStatus === 'on' ? '已上架' : '已下架')
}
</script>

<template>
  <div class="product-list">
    <div class="page-header">
      <h2 class="page-title">商品管理</h2>
      <el-button
        v-permission="['admin', 'editor']"
        type="primary"
        size="large"
        @click="router.push('/products/create')"
      >
        <el-icon class="mr-1"><Plus /></el-icon>新增商品
      </el-button>
    </div>

    <el-card class="search-bar">
      <el-row :gutter="16">
        <el-col :span="8">
          <el-input v-model="keyword" placeholder="搜索商品名称" clearable size="large" />
        </el-col>
        <el-col :span="4">
          <el-select v-model="statusFilter" placeholder="状态筛选" clearable size="large">
            <el-option label="上架" value="on" />
            <el-option label="下架" value="off" />
          </el-select>
        </el-col>
      </el-row>
    </el-card>

    <el-card class="table-card">
      <el-table :data="filteredProducts" stripe style="width: 100%">
        <el-table-column prop="id" label="ID" width="80" />
        <el-table-column prop="name" label="商品名称" min-width="150" />
        <el-table-column prop="category" label="分类" width="120" />
        <el-table-column prop="price" label="价格" width="100">
          <template #default="{ row }">¥{{ row.price }}</template>
        </el-table-column>
        <el-table-column prop="stock" label="库存" width="80" />
        <el-table-column prop="status" label="状态" width="120">
          <template #default="{ row }">
            <!-- 改用v-if做权限判断,v-permission保留做二次权限校验 -->
            <el-switch
              v-if="authStore.userInfo?.role === 'admin' || authStore.userInfo?.role === 'editor'"
              v-permission="['admin', 'editor']"
              :model-value="row.status === 'on'"
              @change="handleStatusChange(row)"
              active-text="上架"
              inactive-text="下架"
              inline-prompt
            />
            <el-tag v-else effect="light" :type="row.status === 'on' ? 'success' : 'info'">
              {{ row.status === 'on' ? '上架' : '下架' }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column label="操作" width="180" fixed="right">
          <template #default="{ row }">
            <template v-if="authStore.userInfo?.role === 'admin' || authStore.userInfo?.role === 'editor'">
              <el-button size="small" type="primary" link @click="router.push(`/products/${row.id}/edit`)">编辑</el-button>
              <el-button
                v-permission="['admin']"
                size="small"
                type="danger"
                link
                @click="handleDelete(row)"
              >删除</el-button>
            </template>
            <span v-else class="text-gray">无权限</span>
          </template>
        </el-table-column>
      </el-table>
    </el-card>
  </div>
</template>

<style scoped>
.page-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 24px;
}
.page-title {
  margin: 0;
  font-size: 24px;
  font-weight: 700;
  color: #1f2937;
}
.search-bar {
  margin-bottom: 16px;
}
.table-card {
  border-radius: 16px;
}
.mr-1 {
  margin-right: 4px;
}
.text-gray {
  color: #909399;
}
</style>

7.4 商品编辑页(views/ProductEditPage.vue)

复用为新增和编辑两种模式,通过路由参数判断:

javascript 复制代码
<!-- src/views/ProductEditPage.vue -->
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useProductStore } from '../stores/product'
import type { Product } from '../types/models'
import { ElMessage } from 'element-plus'

const route = useRoute()
const router = useRouter()
const productStore = useProductStore()

const isEdit = ref(false)
const form = reactive({
  name: '',
  category: '',
  price: 0,
  stock: 0,
  status: 'on' as 'on' | 'off',
  description: '',
  image: ''
})

onMounted(() => {
  const id = route.params.id
  if (id) {
    isEdit.value = true
    const product = productStore.getProductById(Number(id))
    if (product) {
      Object.assign(form, {
        name: product.name,
        category: product.category,
        price: product.price,
        stock: product.stock,
        status: product.status,
        description: product.description,
        image: product.image
      })
    }
  }
})

function handleSubmit() {
  if (!form.name || !form.category) {
    ElMessage.warning('请填写必填项')
    return
  }
  if (isEdit.value) {
    const id = Number(route.params.id)
    productStore.updateProduct(id, form)
    ElMessage.success('编辑成功')
  } else {
    productStore.createProduct(form)
    ElMessage.success('新增成功')
  }
  router.push('/products')
}
</script>

<template>
  <div class="product-edit">
    <div class="page-header">
      <h2 class="page-title">{{ isEdit ? '编辑商品' : '新增商品' }}</h2>
    </div>
    <el-card>
      <el-form :model="form" label-width="100px" class="edit-form">
        <el-form-item label="商品名称" required>
          <el-input v-model="form.name" placeholder="请输入商品名称" size="large" />
        </el-form-item>
        <el-form-item label="分类" required>
          <el-select v-model="form.category" placeholder="选择分类" size="large">
            <el-option label="手机数码" value="手机数码" />
            <el-option label="电脑办公" value="电脑办公" />
            <el-option label="手机配件" value="手机配件" />
            <el-option label="智能穿戴" value="智能穿戴" />
            <el-option label="平板电脑" value="平板电脑" />
          </el-select>
        </el-form-item>
        <el-form-item label="价格">
          <el-input-number v-model="form.price" :min="0" :precision="2" size="large" />
        </el-form-item>
        <el-form-item label="库存">
          <el-input-number v-model="form.stock" :min="0" size="large" />
        </el-form-item>
        <el-form-item label="状态">
          <el-switch
            v-model="form.status"
            active-value="on"
            inactive-value="off"
            active-text="上架"
            inactive-text="下架"
            inline-prompt
            size="large"
          />
        </el-form-item>
        <el-form-item label="描述">
          <el-input v-model="form.description" type="textarea" :rows="4" placeholder="商品描述" />
        </el-form-item>
        <el-form-item>
          <div class="form-actions">
            <el-button type="primary" size="large" @click="handleSubmit">保存</el-button>
            <el-button size="large" @click="router.back()">取消</el-button>
          </div>
        </el-form-item>
      </el-form>
    </el-card>
  </div>
</template>

<style scoped>
.page-header {
  margin-bottom: 24px;
}
.page-title {
  margin: 0;
  font-size: 24px;
  font-weight: 700;
  color: #1f2937;
}
.edit-form {
  max-width: 640px;
}
.form-actions {
  display: flex;
  gap: 12px;
}
</style>

7.5 订单列表页(views/OrderListPage.vue)

javascript 复制代码
<!-- src/views/OrderListPage.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useOrderStore } from '../stores/order'

const router = useRouter()
const orderStore = useOrderStore()

const keyword = ref('')
const statusFilter = ref('')

const filteredOrders = computed(() => {
  return orderStore.filterOrders({
    keyword: keyword.value,
    status: statusFilter.value
  })
})

const statusMap: Record<string, { text: string; type: string }> = {
  pending: { text: '待发货', type: 'warning' },
  shipped: { text: '已发货', type: 'primary' },
  completed: { text: '已完成', type: 'success' },
  cancelled: { text: '已取消', type: 'info' }
}
</script>

<template>
  <div class="order-list">
    <div class="page-header">
      <h2 class="page-title">订单管理</h2>
    </div>

    <el-card class="search-bar">
      <el-row :gutter="16">
        <el-col :span="8">
          <el-input v-model="keyword" placeholder="订单号/客户姓名" clearable size="large" />
        </el-col>
        <el-col :span="4">
          <el-select v-model="statusFilter" placeholder="状态筛选" clearable size="large">
            <el-option label="待发货" value="pending" />
            <el-option label="已发货" value="shipped" />
            <el-option label="已完成" value="completed" />
            <el-option label="已取消" value="cancelled" />
          </el-select>
        </el-col>
      </el-row>
    </el-card>

    <el-card class="table-card">
      <el-table :data="filteredOrders" stripe>
        <el-table-column prop="orderNo" label="订单号" width="180" />
        <el-table-column prop="customerName" label="客户" width="120" />
        <el-table-column prop="totalAmount" label="金额" width="120">
          <template #default="{ row }">¥{{ row.totalAmount }}</template>
        </el-table-column>
        <el-table-column prop="status" label="状态" width="100">
          <template #default="{ row }">
            <el-tag :type="statusMap[row.status]?.type" effect="light">
              {{ statusMap[row.status]?.text }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="createTime" label="下单时间" width="140" />
        <el-table-column label="操作" width="120">
          <template #default="{ row }">
            <el-button type="primary" link size="small" @click="router.push(`/orders/${row.id}`)">详情</el-button>
          </template>
        </el-table-column>
      </el-table>
    </el-card>
  </div>
</template>

<style scoped>
.page-header {
  margin-bottom: 24px;
}
.page-title {
  margin: 0;
  font-size: 24px;
  font-weight: 700;
  color: #1f2937;
}
.search-bar {
  margin-bottom: 16px;
}
.table-card {
  border-radius: 16px;
}
</style>

7.6 订单详情页(views/OrderDetailPage.vue)

javascript 复制代码
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useOrderStore } from '../stores/order'
import { useAuthStore } from '../stores/auth'
import { ElMessage } from 'element-plus'

const route = useRoute()
const router = useRouter()
const orderStore = useOrderStore()
const authStore = useAuthStore()

const order = computed(() => {
  const id = Number(route.params.id)
  return orderStore.getOrderById(id)
})

const isEditorOrAbove = computed(() => {
  const role = authStore.userInfo?.role
  return role === 'admin' || role === 'editor'
})

const statusMap: Record<string, { text: string; type: string }> = {
  pending: { text: '待发货', type: 'warning' },
  shipped: { text: '已发货', type: 'primary' },
  completed: { text: '已完成', type: 'success' },
  cancelled: { text: '已取消', type: 'info' }
}

function updateStatus(newStatus: string) {
  if (!order.value) return
  orderStore.updateOrderStatus(order.value.id, newStatus as any)
  ElMessage.success('状态更新成功')
}
</script>

<template>
  <div class="order-detail" v-if="order">
    <div class="page-header">
      <h2 class="page-title">订单详情 - {{ order.orderNo }}</h2>
      <el-button @click="router.back()">返回</el-button>
    </div>

    <el-card class="info-card">
      <template #header>
        <span>基本信息</span>
      </template>
      <el-descriptions :column="2" border>
        <el-descriptions-item label="订单号">{{ order.orderNo }}</el-descriptions-item>
        <el-descriptions-item label="客户姓名">{{ order.customerName }}</el-descriptions-item>
        <el-descriptions-item label="下单时间">{{ order.createTime }}</el-descriptions-item>
        <el-descriptions-item label="总金额">¥{{ order.totalAmount }}</el-descriptions-item>
        <el-descriptions-item label="状态">
          <el-tag :type="statusMap[order.status]?.type" effect="light">
            {{ statusMap[order.status]?.text }}
          </el-tag>
        </el-descriptions-item>
      </el-descriptions>
    </el-card>

    <el-card class="product-card">
      <template #header>
        <span>商品明细</span>
      </template>
      <el-table :data="order.products" border stripe>
        <el-table-column prop="name" label="商品名称" />
        <el-table-column prop="price" label="单价" width="120">
          <template #default="{ row }">¥{{ row.price }}</template>
        </el-table-column>
        <el-table-column prop="quantity" label="数量" width="80" />
        <el-table-column label="小计" width="120">
          <template #default="{ row }">¥{{ row.price * row.quantity }}</template>
        </el-table-column>
      </el-table>
    </el-card>

    <div
      class="action-bar"
      v-if="(order.status === 'pending' || order.status === 'shipped') && isEditorOrAbove"
    >
      <el-button
        v-if="order.status === 'pending'"
        type="primary"
        @click="updateStatus('shipped')"
      >
        标记为已发货
      </el-button>
      <el-button
        v-if="order.status === 'shipped'"
        type="success"
        @click="updateStatus('completed')"
      >
        标记为已完成
      </el-button>
      <el-button
        type="danger"
        plain
        @click="updateStatus('cancelled')"
      >
        取消订单
      </el-button>
    </div>
  </div>
  <div v-else class="not-found">
    <el-empty description="订单不存在" />
  </div>
</template>

<style scoped>
.page-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 24px;
}
.page-title {
  margin: 0;
  font-size: 24px;
  font-weight: 700;
  color: #1f2937;
}
.info-card, .product-card {
  margin-bottom: 20px;
}
.action-bar {
  display: flex;
  gap: 12px;
  justify-content: flex-end;
}
.not-found {
  padding: 60px 0;
}
</style>

7.7 用户列表页(views/UserListPage.vue)

具备搜索、新增、编辑、删除和角色快捷修改功能,且操作按钮受权限控制。

javascript 复制代码
<!-- src/views/UserListPage.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '../stores/user'
import { useAuthStore } from '../stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'

const router = useRouter()
const userStore = useUserStore()
const authStore = useAuthStore()
const keyword = ref('')

const filteredUsers = computed(() => userStore.filterUsers(keyword.value))
const isAdmin = computed(() => authStore.userInfo?.role === 'admin')

function handleDelete(userId: number, username: string) {
  ElMessageBox.confirm(`确定删除用户 "${username}" 吗?`, '警告', { type: 'warning' })
    .then(() => {
      userStore.deleteUser(userId)
      ElMessage.success('删除成功')
    })
}

function handleRoleChange(userId: number, newRole: string) {
  userStore.updateUserRole(userId, newRole)
  ElMessage.success('角色已更新')
}
</script>

<template>
  <div class="user-list">
    <div class="page-header">
      <div>
        <h2 class="page-title">用户管理</h2>
        <p class="page-desc">仅管理员可访问此页面</p>
      </div>
      <el-button
        v-permission="['admin']"
        type="primary"
        size="large"
        @click="router.push('/users/create')"
      >
        <el-icon class="mr-1"><Plus /></el-icon>新增用户
      </el-button>
    </div>

    <el-card class="search-bar">
      <el-input
        v-model="keyword"
        placeholder="搜索用户名或昵称"
        clearable
        size="large"
        style="max-width: 300px"
      />
    </el-card>

    <el-card class="table-card">
      <el-table :data="filteredUsers" stripe>
        <el-table-column prop="id" label="ID" width="80" />
        <el-table-column prop="username" label="用户名" width="140" />
        <el-table-column prop="nickname" label="昵称" width="140" />
        <el-table-column prop="role" label="角色" width="160">
          <template #default="{ row }">
  <!-- 管理员展示下拉框 -->
  <el-select
    v-if="isAdmin"
    :model-value="row.role"
    size="small"
    @change="(val: string) => handleRoleChange(row.id, val)"
    :disabled="row.id === authStore.userInfo?.id"
  >
    <el-option label="管理员" value="admin" />
    <el-option label="编辑员" value="editor" />
    <el-option label="观察者" value="viewer" />
  </el-select>
  <!-- 非管理员展示标签 -->
  <el-tag v-else effect="light">{{ row.role }}</el-tag>
</template>
        </el-table-column>
        <el-table-column prop="status" label="状态" width="100">
          <template #default="{ row }">
            <el-tag :type="row.status === 'active' ? 'success' : 'danger'" effect="light">
              {{ row.status === 'active' ? '正常' : '禁用' }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="createTime" label="创建时间" />
        <el-table-column label="操作" width="180" fixed="right">
          <template #default="{ row }">
            <div v-if="isAdmin">
              <el-button
                type="primary"
                link
                size="small"
                @click="router.push(`/users/${row.id}/edit`)"
                :disabled="row.id === authStore.userInfo?.id"
              >
                编辑
              </el-button>
              <el-button
                type="danger"
                link
                size="small"
                @click="handleDelete(row.id, row.username)"
                :disabled="row.id === authStore.userInfo?.id"
              >
                删除
              </el-button>
            </div>
            <span v-else class="text-gray">无权限</span>
          </template>
        </el-table-column>
      </el-table>
    </el-card>
  </div>
</template>

<style scoped>
.page-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 24px;
}
.page-title {
  margin: 0;
  font-size: 24px;
  font-weight: 700;
  color: #1f2937;
}
.page-desc {
  margin: 8px 0 0;
  color: #909399;
}
.search-bar {
  margin-bottom: 16px;
}
.table-card {
  border-radius: 16px;
}
.mr-1 {
  margin-right: 4px;
}
.text-gray {
  color: #909399;
}
</style>

7.8 用户编辑页(views/UserEditPage.vue)

支持新增和编辑用户,包括用户名、昵称、角色、状态以及密码设置。

javascript 复制代码
<!-- src/views/UserEditPage.vue -->
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '../stores/user'
import { ElMessage } from 'element-plus'

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

const isEdit = ref(false)

const form = reactive({
  username: '',
  nickname: '',
  role: 'editor' as string,
  status: 'active' as 'active' | 'disabled',
  password: '',
  passwordConfirm: ''
})

onMounted(() => {
  const id = route.params.id
  if (id) {
    isEdit.value = true
    const user = userStore.getUserById(Number(id))
    if (user) {
      form.username = user.username
      form.nickname = user.nickname
      form.role = user.role
      form.status = user.status
    }
  }
})

function handleSubmit() {
  if (!form.username || !form.nickname) {
    ElMessage.warning('请填写用户名和昵称')
    return
  }
  if (!isEdit.value && !form.password) {
    ElMessage.warning('请输入密码')
    return
  }
  if (form.password && form.password !== form.passwordConfirm) {
    ElMessage.warning('两次密码输入不一致')
    return
  }

  const userData = {
    username: form.username,
    nickname: form.nickname,
    role: form.role,
    status: form.status,
    ...(form.password ? { password: form.password } : {})
  }

  if (isEdit.value) {
    const id = Number(route.params.id)
    userStore.updateUser(id, userData)
    ElMessage.success('用户信息已更新')
  } else {
    userStore.createUser(userData)
    ElMessage.success('用户创建成功')
  }
  router.push('/users')
}
</script>

<template>
  <div class="user-edit">
    <div class="page-header">
      <h2 class="page-title">{{ isEdit ? '编辑用户' : '新增用户' }}</h2>
    </div>
    <el-card>
      <el-form :model="form" label-width="100px" class="edit-form">
        <el-form-item label="用户名" required>
          <el-input v-model="form.username" placeholder="请输入用户名" size="large" />
        </el-form-item>
        <el-form-item label="昵称" required>
          <el-input v-model="form.nickname" placeholder="请输入昵称" size="large" />
        </el-form-item>
        <el-form-item label="角色">
          <el-select v-model="form.role" size="large">
            <el-option label="管理员" value="admin" />
            <el-option label="编辑员" value="editor" />
            <el-option label="观察者" value="viewer" />
          </el-select>
        </el-form-item>
        <el-form-item label="状态">
          <el-radio-group v-model="form.status">
            <el-radio value="active">正常</el-radio>
            <el-radio value="disabled">禁用</el-radio>
          </el-radio-group>
        </el-form-item>
        <el-form-item :label="isEdit ? '新密码' : '密码'" :required="!isEdit">
          <el-input
            v-model="form.password"
            type="password"
            show-password
            placeholder="请输入密码"
            size="large"
          />
        </el-form-item>
        <el-form-item label="确认密码" :required="!isEdit">
          <el-input
            v-model="form.passwordConfirm"
            type="password"
            show-password
            placeholder="再次输入密码"
            size="large"
          />
        </el-form-item>
        <el-form-item>
          <div class="form-actions">
            <el-button type="primary" size="large" @click="handleSubmit">保存</el-button>
            <el-button size="large" @click="router.back()">取消</el-button>
          </div>
        </el-form-item>
      </el-form>
    </el-card>
  </div>
</template>

<style scoped>
.page-header {
  margin-bottom: 24px;
}
.page-title {
  margin: 0;
  font-size: 24px;
  font-weight: 700;
  color: #1f2937;
}
.edit-form {
  max-width: 480px;
}
.form-actions {
  display: flex;
  gap: 12px;
}
</style>

八、自定义指令与全局方法

8.1 权限指令(directives/permission.ts)

根据用户角色控制元素显隐:

javascript 复制代码
// src/directives/permission.ts
import type { Directive } from 'vue'
import { useAuthStore } from '../stores/auth'

export const vPermission: Directive = {
  mounted(el, binding) {
    const authStore = useAuthStore()
    const requiredRoles = binding.value as string[]
    if (requiredRoles && !requiredRoles.includes(authStore.userInfo?.role || '')) {
      el.parentNode?.removeChild(el)
    }
  }
}

8.2 全局方法注册(在 main.ts 中扩展)

挂载日期和金额格式化工具(在下一节文件中已加入):

javascript 复制代码
app.config.globalProperties.$formatDate = (date: string | Date) => {
  if (!date) return ''
  const d = new Date(date)
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
app.config.globalProperties.$formatMoney = (amount: number) => {
  return `¥${amount.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`
}

九、入口文件与根组件

9.1 main.ts

javascript 复制代码
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import './style.css'
import { initMockData } from './mock/data'
import { vPermission } from './directives/permission'

const app = createApp(App)
const pinia = createPinia()

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

app.use(pinia)
app.use(router)
app.use(ElementPlus)

// 注册权限指令
app.directive('permission', vPermission)

// 全局方法
app.config.globalProperties.$formatDate = (date: string | Date) => {
  if (!date) return ''
  const d = new Date(date)
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
app.config.globalProperties.$formatMoney = (amount: number) => {
  return `¥${amount.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`
}

// 初始化 Mock 数据
initMockData()

app.mount('#app')

9.2 App.vue

javascript 复制代码
<!-- src/App.vue -->
<template>
  <router-view />
</template>

9.3 添加 Logo(public/logo.svg)

public 目录下放入一个 SVG 图标(可自己设计),例如:

javascript 复制代码
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
  <rect width="100" height="100" rx="20" fill="#6c5ce7"/>
  <path d="M25 35 L50 20 L75 35 L65 70 L35 70 Z" fill="white"/>
  <circle cx="50" cy="45" r="10" fill="#6c5ce7"/>
</svg>

十、项目运行与效果展示

完成以上所有代码后,执行:

javascript 复制代码
pnpm dev

浏览器访问 http://localhost:5173,使用账号 admin / admin123 登录,即可体验全部功能:

登录页输入错误密码会提示,正确登录后跳转仪表盘。

侧边栏切换"仪表盘""商品管理""订单管理""用户管理"。

商品管理页支持搜索、按状态筛选、新增、编辑、删除、上下架切换。

订单管理页展示订单列表,点击可查看详情并进行状态流转。

用户管理页 :管理员角色可新增、编辑、删除用户,并动态分配角色(管理员、编辑员、观察者);非管理员角色无用户管理页;路由和按钮均受 v-permission 指令保护。


十一、总结

本篇我们从零开始,完整构建了一个电商后台管理系统。通过这个实战项目,你将 Vue 3 核心知识、路由守卫、Pinia 状态管理、组合式函数、Element Plus 组件库以及 TypeScript 类型约束融会贯通,经历了一次完整的"需求分析 → 架构设计 → 编码实现 → 功能测试"的闭环。

  • 掌握了从零搭建企业级 Vue 3 + TypeScript 项目的完整流程。

  • 学会了如何使用路由守卫实现登录鉴权和角色权限控制。

  • 熟练运用 Pinia 管理多个业务模块的全局状态。

  • 能够结合 Element Plus 快速构建后台管理系统的常见页面(表格、表单、图表)。

  • 理解了自定义指令和全局方法的实际应用场景。

  • 通过 Mock 数据 + localStorage 模拟了完整的 CRUD 操作,方便后续无缝对接真实后端。

  • 掌握了通过全局 CSS 变量和组件设计统一视觉风格的方法,提升了项目的美观度。

  • 完整实现了用户管理的增删改查与权限分配,并利用指令保证安全。


如果这篇文章帮你解决了实操上的困惑,别忘记点击点赞、分享 ,也可以留言告诉我你遇到的其它问题,我会尽快回复。动手练习是掌握编程最快的方法,请务必亲手敲一遍本文的所有示例代码,并截图保存你的成果。你的关注是我坚持原创和细节共享的力量来源,谢谢大家。

相关推荐
Csvn1 小时前
Vue3 响应式陷阱:解构赋值后页面不动了?Proxy 的"隐形成员"在搞鬼
前端·vue.js
LAM LAB1 小时前
【Web】网页如何模拟移动端获取定位\定位模拟测试
开发语言·前端·javascript
yunceqing1 小时前
从Excel调度到TMS平台:物流软件开发避坑清单
大数据·前端·网络·人工智能·excel·推荐算法
IT_陈寒1 小时前
Redis主从切换把我坑惨了,这份血泪史你最好看看
前端·人工智能·后端
小森林之主1 小时前
JavaScript 正则表达式:从零开始的实战对比
javascript·正则表达式·前端开发·性能对比·文本处理
weixin_471383031 小时前
Taro-04-网络请求
前端·javascript·taro
Doker 多克1 小时前
Spring AI Alibaba—快速构建ReactAgent
java·开发语言·前端·ai编程
快乐的哈士奇1 小时前
【Next.js实战②】Excel 派送表动态解析:表头识别与 FIELD_ALIASES 映射
前端·javascript·excel
2401_840759762 小时前
2026年前端框架生态与AI开发新趋势
前端·人工智能·科技