主题
商城前端页面实现
本章把所有商城页面串起来。后端接口按第 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 临时返回本地假数据,前端先开发。