Skip to content

商城前端页面实现

本章把所有商城页面串起来。后端接口按第 6 部分实现后即可联调,先用 Mock 数据或后端文档先开发。

一、Axios 请求封装(先做这个)

所有请求统一走一个封装,自动带 token、统一处理错误:

ts
// src/utils/request.ts
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'

const request = axios.create({
  baseURL: '/api',
  timeout: 10000,
})

// 请求拦截器:自动带 token
request.interceptors.request.use((config) => {
  const token = localStorage.getItem('token')
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})

// 响应拦截器:统一处理业务码
request.interceptors.response.use(
  (response) => {
    const res = response.data
    if (res.code === 0) return res.data
    ElMessage.error(res.message || '请求失败')
    return Promise.reject(new Error(res.message))
  },
  (error) => {
    if (error.response?.status === 401) {
      ElMessage.error('登录已过期,请重新登录')
      window.location.href = '/login'
    } else {
      ElMessage.error('网络异常,请稍后重试')
    }
    return Promise.reject(error)
  },
)

export default request

二、接口定义集中管理

ts
// src/api/product.ts
import request from '@/utils/request'
import type { Product, ProductPage } from '@/types/product'

export function getProductList(params: { keyword?: string; categoryId?: number; page?: number; size?: number }) {
  return request.get<unknown, ProductPage>('/product/list', { params })
}

export function getProductDetail(id: number) {
  return request.get<unknown, Product>(`/product/${id}`)
}
ts
// src/api/cart.ts
export function getCart()       { return request.get('/cart') }
export function addCart(data: { productId: number; count: number }) {
  return request.post('/cart', data)
}
export function updateCartCount(id: number, count: number) {
  return request.patch(`/cart/${id}`, { count })
}
export function removeCartItem(id: number) {
  return request.delete(`/cart/${id}`)
}
ts
// src/api/order.ts
export function createOrder(data: { cartIds: number[]; address: string }) {
  return request.post('/order', data)
}
export function getOrderList(params: { page?: number; size?: number }) {
  return request.get('/order/list', { params })
}

三、商品列表页(首页)

vue
<!-- src/views/HomeView.vue -->
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { getProductList } from '@/api/product'
import type { Product } from '@/types/product'

const products = ref<Product[]>([])
const keyword = ref('')
const page = ref(1)
const total = ref(0)
const loading = ref(false)

async function load() {
  loading.value = true
  try {
    const data = await getProductList({ keyword: keyword.value, page: page.value, size: 12 })
    products.value = data.records
    total.value = data.total
  } finally {
    loading.value = false
  }
}

onMounted(load)
watch(keyword, () => { page.value = 1; load() })

function goDetail(id: number) {
  // 跳转商品详情
}
</script>

<template>
  <div class="container">
    <input v-model="keyword" placeholder="搜索商品" @keyup.enter="load" />
    <el-skeleton v-if="loading" :rows="6" />
    <div v-else class="grid">
      <div v-for="p in products" :key="p.id" class="card" @click="goDetail(p.id)">
        <img :src="p.image" :alt="p.name" />
        <h3>{{ p.name }}</h3>
        <p class="price">¥{{ p.price }}</p>
        <span v-if="p.stock === 0" class="sold-out">已售罄</span>
      </div>
    </div>
    <el-pagination
      v-model:current-page="page"
      :total="total"
      :page-size="12"
      layout="prev, pager, next"
      @current-change="load"
    />
  </div>
</template>

<style scoped>
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
.card { border: 1px solid #eee; border-radius: 8px; padding: 12px; cursor: pointer; }
.card img { width: 100%; height: 200px; object-fit: cover; }
.price { color: #e64545; font-weight: bold; }
</style>

四、商品详情 + 加购

vue
<!-- src/views/ProductDetailView.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getProductDetail } from '@/api/product'
import { addCart } from '@/api/cart'
import { useUserStore } from '@/stores/user'
import type { Product } from '@/types/product'

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

const product = ref<Product | null>(null)
const count = ref(1)

onMounted(async () => {
  product.value = await getProductDetail(Number(route.params.id))
})

async function handleAddCart() {
  if (!userStore.isLogin) return router.push('/login')
  await addCart({ productId: product.value!.id, count: count.value })
  ElMessage.success('已加入购物车')
}
</script>

<template>
  <div v-if="product" class="detail">
    <img :src="product.image" :alt="product.name" />
    <div>
      <h1>{{ product.name }}</h1>
      <p>{{ product.description }}</p>
      <p class="price">¥{{ product.price }} <small>库存 {{ product.stock }}</small></p>
      <el-input-number v-model="count" :min="1" :max="product.stock" />
      <el-button type="primary" @click="handleAddCart">加入购物车</el-button>
    </div>
  </div>
</template>

五、购物车页

vue
<!-- src/views/CartView.vue -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCart, updateCartCount, removeCartItem } from '@/api/cart'
import { createOrder } from '@/api/order'
import { useRouter } from 'vue-router'

interface CartItem {
  id: number
  productId: number
  productName: string
  price: number
  image: string
  count: number
  stock: number
}

const items = ref<CartItem[]>([])
const router = useRouter()

const totalPrice = computed(() =>
  items.value.reduce((sum, i) => sum + i.price * i.count, 0)
)

onMounted(async () => { items.value = await getCart() })

async function changeCount(id: number, count: number) {
  await updateCartCount(id, count)
}

async function removeItem(id: number) {
  await ElMessageBox.confirm('确认删除该商品?', '提示')
  await removeCartItem(id)
  items.value = items.value.filter((i) => i.id !== id)
}

async function checkout() {
  const ids = items.value.map((i) => i.id)
  await createOrder({ cartIds: ids, address: '默认收货地址' })
  ElMessage.success('下单成功')
  router.push('/order/list')
}
</script>

<template>
  <div v-for="item in items" :key="item.id" class="item">
    <img :src="item.image" />
    <span>{{ item.productName }}</span>
    <el-input-number :model-value="item.count" :min="1" :max="item.stock"
                     @change="(v) => changeCount(item.id, v)" />
    <span>¥{{ item.price * item.count }}</span>
    <el-button type="danger" text @click="removeItem(item.id)">删除</el-button>
  </div>
  <footer>
    合计:¥{{ totalPrice }}
    <el-button type="primary" @click="checkout">去结算</el-button>
  </footer>
</template>

六、订单列表页

vue
<!-- src/views/OrderListView.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getOrderList } from '@/api/order'

interface Order {
  id: number
  orderNo: string
  totalAmount: number
  status: string
  createTime: string
  items: { productName: string; count: number; price: number }[]
}

const orders = ref<Order[]>([])

const statusMap: Record<string, string> = {
  PENDING: '待支付', PAID: '已支付', SHIPPED: '已发货', CANCELLED: '已取消',
}

onMounted(async () => {
  orders.value = (await getOrderList({ page: 1, size: 20 })).records
})
</script>

<template>
  <el-card v-for="o in orders" :key="o.id" class="order">
    <div class="head">
      <span>订单号:{{ o.orderNo }}</span>
      <el-tag>{{ statusMap[o.status] }}</el-tag>
      <span>¥{{ o.totalAmount }}</span>
    </div>
    <p v-for="item in o.items" :key="item.productName">
      {{ item.productName }} × {{ item.count }}
    </p>
    <small>{{ o.createTime }}</small>
  </el-card>
</template>

七、登录 / 注册页

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

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

const username = ref('')
const password = ref('')

async function handleLogin() {
  const data = await request.post('/auth/login', {
    username: username.value, password: password.value,
  })
  userStore.setLogin(data.token, data.nickname)
  ElMessage.success('登录成功')
  router.push(String(route.query.redirect || '/home'))
}
</script>

<template>
  <el-form class="login">
    <h2>登录</h2>
    <el-input v-model="username" placeholder="用户名" />
    <el-input v-model="password" type="password" placeholder="密码" show-password
              @keyup.enter="handleLogin" />
    <el-button type="primary" @click="handleLogin">登录</el-button>
    <RouterLink to="/register">没有账号?去注册</RouterLink>
  </el-form>
</template>

八、验收清单

  • [ ] 首页展示商品网格,支持搜索、分页
  • [ ] 点击商品进入详情页,可改数量加购
  • [ ] 购物车能改数量、删除,合计金额正确
  • [ ] 结算后订单出现在订单列表,状态正确
  • [ ] 未登录操作被正确引导到登录页

联调提示

先按第 6 部分把后端跑起来再联调。若后端还没好,可用 vite-plugin-mock 或把 getProductList 临时返回本地假数据,前端先开发。

基于 MIT 协议发布,可自由学习与修改