Skip to content

商城前端页面实现

与 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 直接把 keywordpage 放进 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 部分 实现后端,让两端真正跑起来。

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