摘要:后端 API 全部就绪了(Day01-16),但用户总不能抱着 Postman 购物吧?今天把前端搞起来------用 uni-app + Vue3 从零搭建商城小程序。涵盖项目初始化、五大核心页面(首页/商品详情/购物车/订单/个人中心)、组件化开发、Pinia 全局状态管理。成本?依然是零。
后端 API 写好了,然后呢?
过去 16 天,我们把商城的后端搭得明明白白:
- Django + DRF 框架稳稳当当
- 商品、订单、用户、支付、客服......接口全齐了
- 统一的响应格式
{code: 0, message: "success", data: ...}
但问题来了------用户怎么用?
你不会真的让用户打开 Postman,填个 POST 请求来买东西吧?那大概是客户流失最快的电商了。
我们需要一个前端。一个能在手机上跑的、界面好看的、交互流畅的前端。
为什么选 uni-app?
- 一套代码,多端运行:微信小程序、支付宝小程序、H5、App......写一次,到处跑
- Vue3 生态:Composition API、Pinia 状态管理
- 国内生态好:插件市场丰富,踩坑有人帮
今天就从零开始,把商城的小程序前端搭起来。
uni-app 和 Vue3 的组合拳
uni-app 是什么
一句话:用 Vue 的语法写跨平台应用。
你的 Vue3 代码
│
├── 编译 → 微信小程序(WXML + WXSS + JS)
├── 编译 → 支付宝小程序
├── 编译 → H5(网页版)
└── 编译 → App(iOS / Android)
底层是编译器 + 运行时。你写的是标准 Vue3 代码,uni-app 帮你翻译成各平台的原生代码。
Vue3 + Composition API
Vue3 最核心的变化是 Composition API。相比 Options API 那种 data/methods/computed 分家的写法,Composition API 让你按功能组织代码:
javascript
// Options API:按类型分家
export default {
data() { return { count: 0 } },
methods: { increment() { this.count++ } },
computed: { double() { return this.count * 2 } }
}
// Composition API:按逻辑聚合
import { ref, computed } from 'vue'
const count = ref(0)
const increment = () => count.value++
const double = computed(() => count.value * 2)
Pinia:Vue3 的官方状态管理
Vuex 已经是过去式了。Pinia 的优势很明显:
- API 更简洁:没有 mutations,直接改 state
- TypeScript 友好:类型推导完美
- 体积更小:核心代码不到 1KB
- 支持 Composition API :
setup语法无缝集成
购物车数据、用户登录状态......这些跨页面共享的数据,用 Pinia 管理再合适不过。
动手实战
项目初始化
bash
# 拉取 uni-app + Vue3 模板(vite-ts 自带 TypeScript)
npx degit dcloudio/uni-preset-vue#vite-ts mei-ya-da-app
cd mei-ya-da-app
npm install
# 安装 Pinia + 持久化插件(刷新/杀掉小程序不丢数据)
npm install pinia
npm install pinia-plugin-persisted-uniapp
初始化完成后,目录按功能分层:
mei-ya-da-app/src/
├── api/ # 接口请求封装(request.ts 基础封装 + 按模块分的接口文件)
├── components/ # 通用组件(ProductCard、NavBar、CartModal、EmptyState)
├── composables/ # 组合式函数(useAuth、useCart)
├── pages/ # 页面(index 首页 / product 详情 / cart 购物车 / order 订单 / mine 我的)
├── stores/ # Pinia 状态(cart 购物车 / user 用户)
├── static/ # 静态资源
└── styles/ # 全局样式
这个结构看着复杂,其实就是按功能分层。api 管网络、components 放组件、pages 放页面、stores 放状态。找代码一目了然。
请求封装(api/request.ts)
所有接口请求的底层封装,统一处理 Base URL、Token 注入、错误拦截。核心逻辑三段:
typescript
export function request<T>(options): Promise<T> {
return new Promise((resolve, reject) => {
// ① 从本地存储取 Token,注入请求头 Authorization
const token = uni.getStorageSync('token') || ''
uni.request({
url: BASE_URL + options.url,
method: options.method || 'GET',
header: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '' },
success: (res) => {
const data = res.data
// ② 统一响应格式 {code:0, message, data} ------ code 0 成功,取 data.data
// ③ code 401 说明 Token 过期 → 清 Token、提示登录、跳转
// 其他 code → 弹出后端返回的错误信息
},
})
})
}
这段代码做了三件事:① 自动在请求头加 Token;② 自动解析后端统一响应格式;③ 401 自动跳登录。前端所有接口调用都走这个封装。
页面路由配置(pages.json)
底部导航栏四个 Tab:首页、购物车、订单、我的。商品详情页不在 TabBar 里,通过跳转进入。
json
{
"pages": [
{ "path": "pages/index/index", "style": { "navigationBarTitleText": "商城", "enablePullDownRefresh": true } },
{ "path": "pages/product/detail","style": { "navigationBarTitleText": "商品详情" } },
{ "path": "pages/cart/cart", "style": { "navigationBarTitleText": "购物车" } },
{ "path": "pages/order/order", "style": { "navigationBarTitleText": "我的订单" } },
{ "path": "pages/mine/mine", "style": { "navigationBarTitleText": "个人中心", "navigationStyle": "custom" } }
],
"tabBar": {
"color": "#999999", "selectedColor": "#FF6034",
"list": [
{ "pagePath": "pages/index/index", "text": "首页" },
{ "pagePath": "pages/cart/cart", "text": "购物车" },
{ "pagePath": "pages/order/order", "text": "订单" },
{ "pagePath": "pages/mine/mine", "text": "我的" }
]
}
}
五个核心页面
首页(pages/index/index.vue)
页面布局:顶部搜索栏 (灰色圆角输入框)→ 轮播 Banner (约 180rpx 高,自动播放)→ 分类导航 (一行四个图标)→ 商品瀑布流(两列卡片:图/名称/价格/销量)。
关键逻辑:
vue
<template>
<!-- 搜索栏 / 轮播 Banner / 分类导航 / 两列商品网格 -->
<view class="product-grid">
<ProductCard v-for="p in productList" :key="p.id" :product="p" @click="goDetail(p.id)" />
</view>
</template>
<script setup lang="ts">
// ① onMounted 并行加载:轮播 + 分类 + 商品列表(Promise.all)
// ② loadMore 分页:page+1 追加到列表尾部,hasMore 控制是否还能加载
// ③ 下拉刷新 onMounted 重新拉取首页数据
</script>
商品详情页(pages/product/detail.vue)
页面布局:商品大图轮播 → 价格区 (红色大字 + 原价删除线 + 自营/包邮标签)→ 商品标题描述 → 规格选择入口 (点开 SKU 弹窗)→ 图文详情(rich-text) → 底部固定操作栏(收藏/客服/加入购物车/立即购买)。
关键逻辑:
vue
<script setup lang="ts">
// ① loadProduct:从路由参数拿 id → getProductDetail(id) 加载详情
// ② addToCart:没选规格先弹 SKU 窗;选了规格调 cartStore.addItem 加购
// ③ buyNow:跳过购物车,带 product_id + sku 参数直接跳确认订单页
// ④ 图片预览 uni.previewImage / 收藏 toggleFavorite / 客服占位
</script>
购物车页(pages/cart/cart.vue)
页面布局:商品行 (勾选框 + 缩略图 + 名称规格 + 价格 + 数量步进器)→ 底部结算栏(全选 + 合计金额 + 结算按钮带件数)。空车显示空状态插画 + "去逛逛"。
关键逻辑:
vue
<script setup lang="ts">
// ① toggleCheck / toggleSelectAll:改 checked 后调 store.updateChecked()
// ② changeQty:数量减到 0 时弹窗确认删除
// ③ goCheckout:只取勾选商品,编码后跳确认订单页
</script>
订单页(pages/order/order.vue)
页面布局:顶部 Tab 栏 (全部/待付款/待发货/待收货/已完成)→ 订单卡片(订单号+状态标签 / 商品缩略图+名称+规格+价格×数量 / 底部合计+操作按钮)。
关键逻辑:
vue
<script setup lang="ts">
// ① switchTab:切换状态 → 重置 page=1 → 重新拉取订单列表
// ② 操作按钮按 status 条件渲染:待付款显示"去支付/取消订单",待收货显示"确认收货"
// ③ cancelOrder / confirmReceive:先弹确认框,确认后调对应 API 并刷新列表
</script>
个人中心(pages/mine/mine.vue)
页面布局:渐变头部 (圆形头像 + 昵称 + 会员标签)→ 订单快捷入口 (四宫格图标)→ 功能列表 (地址/优惠券/客服/关于/设置)→ 退出登录按钮。
关键逻辑:
vue
<script setup lang="ts">
// ① 未登录时显示"点击登录",跳登录页
// ② 各菜单项目前是占位 toast,后续接真实功能
// ③ logout:弹确认框 → userStore.logout() 清 Token
</script>
五个页面的样式(SCSS)篇幅长但全是通用布局代码,这里不逐行贴------照着"页面由哪几块组成"的描述写就行,布局思路比样式代码更重要。
组件化开发
商品卡片组件 (ProductCard.vue)被首页、分类页、搜索结果页复用------defineProps 接收商品数据,defineEmits 抛出点击事件,父组件决定跳哪:
vue
<script setup lang="ts">
const props = defineProps<{ product: { id: number; name: string; image: string; price: number } }>()
const emit = defineEmits<{ (e: 'click'): void }>()
</script>
空状态组件(EmptyState.vue)------购物车空了、订单空了、搜索没结果都用它:一个图标 + 一句话 + 可选按钮,通过 props 传文案、emit 触发按钮点击。
Pinia 状态管理
购物车 Store(stores/cart.ts)
购物车状态需要在首页(加购)、购物车页(管理)、结算页(读取)之间共享。核心逻辑:
typescript
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
// Getters:isAllChecked 全选 / checkedCount 选中件数 / checkedTotal 选中总金额 / totalCount 角标数
// Actions:
function addItem(newItem) {
// 关键:同商品 + 同规格合并数量,否则新增一条
const existing = items.value.find(
item => item.product_id === newItem.product_id &&
JSON.stringify(item.spec) === JSON.stringify(newItem.spec)
)
existing ? existing.quantity += newItem.quantity
: items.value.push({ ...newItem, checked: false })
updateBadge() // 更新 TabBar 角标
}
function removeItem(productId) { ... } // 过滤掉指定商品 + 更新角标
function clearCart() { ... } // 清空 + 更新角标
}, { persist: { key: 'meiyada-cart' } }) // 持久化:杀掉小程序购物车还在
用户 Store(stores/user.ts)
管理登录状态、Token、个人信息:
typescript
export const useUserStore = defineStore('user', () => {
const token = ref(''); const nickname = ref(''); const avatar = ref('')
const isLoggedIn = computed(() => !!token.value)
async function login(username, password) {
// 调后端登录接口拿 token → 存 state + uni.setStorageSync('token')(request.ts 自动读)
}
function logout() {
// 清空 state + uni.removeStorageSync('token')
}
}, { persist: { key: 'meiyada-user' } })
main.ts 注册 Pinia
typescript
import { createPinia } from 'pinia'
import piniaPluginPersisteduniapp from 'pinia-plugin-persisted-uniapp'
const pinia = createPinia()
pinia.use(piniaPluginPersisteduniapp) // 挂持久化插件
app.use(pinia)
两个 Store 都用了持久化插件。购物车、Token 存在本地,小程序杀掉再打开,购物车的东西还在,用户也不用重新登录。
AI 加速:让 AI 帮你写页面
写前端页面最烦的是重复劳动------每个页面的结构都差不多,就是数据和样式不一样。这种活让 AI 来:
"基于 uni-app + Vue3 + TypeScript,生成一个电商商品列表页面。要求:两列瀑布流布局,每个卡片包含商品图片、名称、标签、价格、销量。使用 Composition API,样式用 SCSS,适配微信小程序。"
AI 能给你生成 80% 的代码,剩下 20% 是调整细节、对接接口。
小程序的样式调试很痛苦------没有 Chrome DevTools 那么好用。遇到样式问题直接截图丢给 AI:"这个商品卡片的价格和标题重叠了,帮我看看哪里出了问题。"比自己一个个属性排查快多了。
后端 API 文档给到 AI,让它直接生成前端的接口调用代码,也省得自己对着文档一行行翻译。
学完来练练手
- 🟢 入门 :给商品卡片组件加一个"限时折扣"标签,当商品有
discount字段时显示倒计时 - 🟡 进阶 :给购物车页面加上"左滑删除"功能(
touchstart/touchmove/touchend监听手势 +transform: translateX()实现滑动效果) - 🔴 挑战:实现一个完整的搜索功能------搜索框输入 → 调用后端搜索接口 → 展示结果 → 支持历史记录
本系列为 梅雅达编程笔记 原创,首发于 CSDN。有问题?评论区见