主题
商城前端页面实现
与 Vue 端功能完全对齐,这里展示 React 写法的差异。后端接口逻辑见第 6 部分,代码在
web-react/。
一、Axios 请求封装
ts
// src/utils/request.ts
import axios from 'axios'
import { message } from 'antd'
import { useUserStore } from '@/stores/user'
const request = axios.create({
baseURL: '/api',
timeout: 10000,
})
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
message.error(res.message || '请求失败')
return Promise.reject(new Error(res.message))
},
(error) => {
if (error.response?.status === 401) {
message.error('登录已过期,请重新登录')
window.location.href = '/login'
} else {
message.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 const getProductList = (params: { keyword?: string; page?: number; size?: number }) =>
request.get('/product/list', { params })
export const getProductDetail = (id: number) => request.get(`/product/${id}`)ts
// src/api/cart.ts
export const getCart = () => request.get('/cart')
export const addCart = (data: { productId: number; count: number }) => request.post('/cart', data)
export const updateCartCount = (id: number, count: number) => request.patch(`/cart/${id}`, { count })
export const removeCartItem = (id: number) => request.delete(`/cart/${id}`)三、商品列表页(首页)
tsx
// src/views/Home.tsx
import { useEffect, useState } from 'react'
import { Input, Pagination, Skeleton } from 'antd'
import { getProductList } from '@/api/product'
import type { Product } from '@/types/product'
import { useNavigate } from 'react-router-dom'
export default function Home() {
const navigate = useNavigate()
const [products, setProducts] = useState<Product[]>([])
const [keyword, setKeyword] = useState('')
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
useEffect(() => {
setLoading(true)
getProductList({ keyword, page, size: 12 })
.then((data) => {
setProducts(data.records)
setTotal(data.total)
})
.finally(() => setLoading(false))
}, [keyword, page]) // 搜索词/页码变化自动重新请求
return (
<div className="container">
<Input.Search
placeholder="搜索商品"
onSearch={(v) => { setKeyword(v); setPage(1) }}
/>
{loading ? <Skeleton active /> : (
<div className="grid">
{products.map((p) => (
<div className="card" key={p.id} onClick={() => navigate(`/product/${p.id}`)}>
<img src={p.image} alt={p.name} />
<h3>{p.name}</h3>
<p className="price">¥{p.price}</p>
{p.stock === 0 && <span className="sold-out">已售罄</span>}
</div>
))}
</div>
)}
<Pagination
current={page}
total={total}
pageSize={12}
onChange={setPage}
/>
</div>
)
}对比 Vue
Vue 用 watch(keyword) 触发重新请求;React 直接把 keyword、page 放进 useEffect 依赖数组,变化即重跑。这是两种框架处理"数据变化后做事"的标准差异。
四、商品详情 + 加购
tsx
// src/views/ProductDetail.tsx
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Button, InputNumber, message } from 'antd'
import { getProductDetail } from '@/api/product'
import { addCart } from '@/api/cart'
import { useUserStore } from '@/stores/user'
import type { Product } from '@/types/product'
export default function ProductDetail() {
const { id } = useParams()
const navigate = useNavigate()
const isLogin = useUserStore((s) => s.isLogin)
const [product, setProduct] = useState<Product | null>(null)
const [count, setCount] = useState(1)
useEffect(() => {
getProductDetail(Number(id)).then(setProduct)
}, [id])
const handleAddCart = async () => {
if (!isLogin) return navigate('/login')
await addCart({ productId: product!.id, count })
message.success('已加入购物车')
}
if (!product) return null
return (
<div className="detail">
<img src={product.image} alt={product.name} />
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p className="price">¥{product.price} <small>库存 {product.stock}</small></p>
<InputNumber min={1} max={product.stock} value={count} onChange={(v) => setCount(v ?? 1)} />
<Button type="primary" onClick={handleAddCart}>加入购物车</Button>
</div>
</div>
)
}五、购物车页
tsx
// src/views/Cart.tsx
import { useEffect, useState } from 'react'
import { Button, InputNumber, Modal, message } from 'antd'
import { getCart, updateCartCount, removeCartItem } from '@/api/cart'
import { createOrder } from '@/api/order'
import { useNavigate } from 'react-router-dom'
interface CartItem {
id: number; productId: number; productName: string
price: number; image: string; count: number; stock: number
}
export default function Cart() {
const navigate = useNavigate()
const [items, setItems] = useState<CartItem[]>([])
useEffect(() => { getCart().then(setItems) }, [])
const totalPrice = items.reduce((sum, i) => sum + i.price * i.count, 0)
const changeCount = (id: number, count: number) => {
updateCartCount(id, count)
setItems(items.map((i) => (i.id === id ? { ...i, count } : i))) // 不可变更新
}
const removeItem = (id: number) => {
Modal.confirm({
title: '确认删除该商品?',
onOk: async () => {
await removeCartItem(id)
setItems(items.filter((i) => i.id !== id))
},
})
}
const checkout = async () => {
await createOrder({ cartIds: items.map((i) => i.id), address: '默认收货地址' })
message.success('下单成功')
navigate('/order/list')
}
return (
<div>
{items.map((item) => (
<div className="item" key={item.id}>
<img src={item.image} alt={item.productName} />
<span>{item.productName}</span>
<InputNumber min={1} max={item.stock} value={item.count}
onChange={(v) => changeCount(item.id, v ?? 1)} />
<span>¥{item.price * item.count}</span>
<Button type="link" danger onClick={() => removeItem(item.id)}>删除</Button>
</div>
))}
<footer>
合计:¥{totalPrice}
<Button type="primary" onClick={checkout}>去结算</Button>
</footer>
</div>
)
}六、订单列表页
tsx
// src/views/OrderList.tsx
import { useEffect, useState } from 'react'
import { Card, Tag } from 'antd'
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 statusMap: Record<string, string> = {
PENDING: '待支付', PAID: '已支付', SHIPPED: '已发货', CANCELLED: '已取消',
}
export default function OrderList() {
const [orders, setOrders] = useState<Order[]>([])
useEffect(() => {
getOrderList({ page: 1, size: 20 }).then((data) => setOrders(data.records))
}, [])
return (
<div>
{orders.map((o) => (
<Card key={o.id} className="order">
<p>订单号:{o.orderNo} <Tag>{statusMap[o.status]}</Tag> ¥{o.totalAmount}</p>
{o.items.map((i) => (
<p key={i.productName}>{i.productName} × {i.count}</p>
))}
<small>{o.createTime}</small>
</Card>
))}
</div>
)
}七、登录页
tsx
// src/views/Login.tsx
import { useState } from 'react'
import { Button, Form, Input, message } from 'antd'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import request from '@/utils/request'
import { useUserStore } from '@/stores/user'
export default function Login() {
const navigate = useNavigate()
const location = useLocation()
const setLogin = useUserStore((s) => s.setLogin)
const [loading, setLoading] = useState(false)
const onFinish = async (values: { username: string; password: string }) => {
setLoading(true)
try {
const data = await request.post('/auth/login', values)
setLogin(data.token, data.nickname)
message.success('登录成功')
navigate((location.state as any)?.from || '/home')
} finally {
setLoading(false)
}
}
return (
<Form onFinish={onFinish} className="login">
<h2>登录</h2>
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input placeholder="用户名" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password placeholder="密码" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>登录</Button>
<Link to="/register">没有账号?去注册</Link>
</Form>
)
}八、公共布局(Header + Outlet)
tsx
// src/layouts/DefaultLayout.tsx
import { Link, Outlet, useNavigate } from 'react-router-dom'
import { Badge, Button } from 'antd'
import { useUserStore } from '@/stores/user'
import { useCartStore } from '@/stores/cart'
export default function DefaultLayout() {
const navigate = useNavigate()
const isLogin = useUserStore((s) => s.isLogin)
const nickname = useUserStore((s) => s.nickname)
const logout = useUserStore((s) => s.logout)
const count = useCartStore((s) => s.count)
return (
<>
<header className="header">
<Link to="/home" className="logo">商城</Link>
<nav>
<Link to="/home">首页</Link>
<Link to="/cart"><Badge count={count} size="small">购物车</Badge></Link>
<Link to="/order/list">我的订单</Link>
</nav>
{isLogin ? (
<span>{nickname}<Button type="link" onClick={() => { logout(); navigate('/home') }}>退出</Button></span>
) : <Link to="/login"><Button>登录</Button></Link>}
</header>
<main className="main"><Outlet /></main>
<footer className="footer">全栈教程 · 商城项目</footer>
</>
)
}九、验收清单
- [ ] 首页商品网格 + 搜索 + 分页
- [ ] 详情页加购(未登录跳登录)
- [ ] 购物车改数量/删除,合计正确
- [ ] 下单后订单列表出现、状态正确
- [ ] RequireAuth 保护私有页面
双端进度对照
到这里,React 端与 Vue 端功能完全一致,只是写法不同。可以跳到 第 6 部分 实现后端,让两端真正跑起来。