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-container、el-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 变量和组件设计统一视觉风格的方法,提升了项目的美观度。

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


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

相关推荐
Dream-Y.ocean1 小时前
鸿蒙PC平台宅匠 3D 室内设计适配实战:基于 Electron 壳方案的 Three.js 双渲染引擎与参数化户型编辑器开发
javascript·3d·harmonyos
Csvn2 小时前
性能监控
前端
Highcharts.js2 小时前
Highcharts Gantt使用符号的自定义数据标签开发甘特图示例
javascript·甘特图·highcharts·图表开发·实战代码·响应式图表
Csvn2 小时前
构建工具
前端
FRing033 小时前
vue2干货总结3(computed计算属性、watch监听器)
开发语言·前端·vue.js·vue2
Java小卷3 小时前
JuanForm vs FormCreate!Vue3 表单引擎联动配置 PK!
vue.js·低代码
kyriewen5 小时前
Skills 榜前 10 我全装了:只有 3 个能直接用
前端·javascript·程序员
IT_陈寒5 小时前
当Java的hashCode遇上Lombok,我的缓存被全清了
前端·人工智能·后端
工控丫丫6 小时前
帆软看板中经常使用的公式函数及经常搭的组合
前端·javascript·数据库
jerrywus6 小时前
我用 GPT-6.1-sol 做了台会讲解的 3D 售货机:60 罐饮料,一口没喝上
前端·agent·claude