電商應用實戰
電商應用是 uni-app 最常見的應用場景之一,本文將介紹如何使用 uni-app 開發一個功能完善的電商應用。
應用架構
一個典型的電商應用通常包含以下核心模組:
- 首頁:展示推薦商品、活動banner、分類入口等
- 分類:商品分類展示
- 商品詳情:展示商品資訊、規格選擇、評價等
- 購物車:管理待購商品
- 訂單:訂單建立、付款、物流追蹤等
- 個人中心:使用者資訊、收貨地址、優惠券等
- 搜尋:商品搜尋功能
技術選型
前端技術
- uni-app:跨平台開發框架
- Vue.js:響應式資料綁定
- Vuex:狀態管理
- uni-ui:UI元件庫
後端服務
- 雲函數:處理業務邏輯
- 雲資料庫:儲存商品、使用者、訂單等資料
- 雲儲存:儲存商品圖片等資源
- 付款介面:對接各平台付款功能
專案結構
├── components // 自訂元件
│ ├── goods-card // 商品卡片元件
│ ├── price // 價格元件
│ └── rating // 評分元件
├── pages // 頁面資料夾
│ ├── index // 首頁
│ ├── category // 分類頁
│ ├── goods-detail // 商品詳情頁
│ ├── cart // 購物車
│ ├── order // 訂單相關頁面
│ └── user // 使用者中心
├── static // 靜態資源
├── store // Vuex 狀態管理
│ ├── index.js // 組裝模組並匯出
│ ├── cart.js // 購物車狀態
│ └── user.js // 使用者狀態
├── utils // 工具函數
│ ├── request.js // 請求封裝
│ └── payment.js // 付款相關
├── App.vue // 應用入口
├── main.js // 主入口
├── manifest.json // 設定檔
└── pages.json // 頁面設定核心功能實現
1. 商品列表與篩選
商品列表是電商應用的基礎功能,支援分類篩選、價格排序、銷量排序等功能。
vue
<template>
<view class="goods-list">
<!-- 篩選欄 -->
<view class="filter-bar">
<view
class="filter-item"
:class="{ active: sortType === 'default' }"
@click="changeSort('default')"
>綜合</view>
<view
class="filter-item"
:class="{ active: sortType === 'sales' }"
@click="changeSort('sales')"
>銷量</view>
<view
class="filter-item"
:class="{ active: sortType === 'price' }"
@click="changeSort('price')"
>
價格
<text class="sort-icon">{{ sortOrder === 'asc' ? '↑' : '↓' }}</text>
</view>
</view>
<!-- 商品列表 -->
<view class="goods-container">
<goods-card
v-for="item in goodsList"
:key="item.id"
:goods="item"
@click="navigateToDetail(item.id)"
></goods-card>
</view>
<!-- 載入更多 -->
<uni-load-more :status="loadMoreStatus"></uni-load-more>
</view>
</template>
<script>
import goodsCard from '@/components/goods-card/goods-card.vue';
import uniLoadMore from '@/components/uni-load-more/uni-load-more.vue';
export default {
components: {
goodsCard,
uniLoadMore
},
data() {
return {
goodsList: [],
categoryId: '',
keyword: '',
page: 1,
pageSize: 10,
sortType: 'default', // default, sales, price
sortOrder: 'desc', // asc, desc
loadMoreStatus: 'more' // more, loading, noMore
}
},
onLoad(options) {
if (options.categoryId) {
this.categoryId = options.categoryId;
}
if (options.keyword) {
this.keyword = options.keyword;
}
this.loadGoodsList();
},
onReachBottom() {
if (this.loadMoreStatus !== 'noMore') {
this.page++;
this.loadGoodsList();
}
},
methods: {
// 載入商品列表
async loadGoodsList() {
this.loadMoreStatus = 'loading';
try {
const params = {
page: this.page,
pageSize: this.pageSize,
sortType: this.sortType,
sortOrder: this.sortOrder,
categoryId: this.categoryId || undefined,
keyword: this.keyword || undefined
};
const res = await this.$api.goods.list(params);
if (this.page === 1) {
this.goodsList = res.data.list;
} else {
this.goodsList = [...this.goodsList, ...res.data.list];
}
this.loadMoreStatus = res.data.list.length < this.pageSize ? 'noMore' : 'more';
} catch (e) {
console.error(e);
this.loadMoreStatus = 'more';
uni.showToast({
title: '載入失敗',
icon: 'none'
});
}
},
// 切換排序方式
changeSort(type) {
if (this.sortType === type) {
// 同一排序類型,切換排序順序
if (type === 'price') {
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
}
} else {
// 不同排序類型,設定預設排序順序
this.sortType = type;
this.sortOrder = type === 'price' ? 'asc' : 'desc';
}
// 重新載入資料
this.page = 1;
this.loadGoodsList();
},
// 跳轉到商品詳情
navigateToDetail(goodsId) {
uni.navigateTo({
url: `/pages/goods-detail/goods-detail?id=${goodsId}`
});
}
}
}
</script>2. 商品詳情頁
商品詳情頁是使用者了解商品資訊、選擇規格並下單的關鍵頁面。
vue
<template>
<view class="goods-detail">
<!-- 輪播圖 -->
<swiper class="swiper" indicator-dots autoplay circular>
<swiper-item v-for="(item, index) in goods.images" :key="index">
<image :src="item" mode="aspectFill" class="slide-image"></image>
</swiper-item>
</swiper>
<!-- 商品資訊 -->
<view class="goods-info">
<view class="price-box">
<price :value="goods.price"></price>
<text class="original-price" v-if="goods.originalPrice">¥{{goods.originalPrice}}</text>
</view>
<view class="title">{{goods.title}}</view>
<view class="sub-title">{{goods.subtitle}}</view>
</view>
<!-- 規格選擇 -->
<view class="spec-section" @click="openSpecModal">
<text class="section-title">選擇</text>
<text class="selected-text">{{selectedSpecText}}</text>
<text class="arrow">></text>
</view>
<!-- 商品詳情 -->
<view class="detail-section">
<view class="section-header">
<text class="section-title">商品詳情</text>
</view>
<rich-text :nodes="goods.detail"></rich-text>
</view>
<!-- 底部操作欄 -->
<view class="footer">
<view class="icon-btn">
<text class="iconfont icon-home"></text>
<text>首頁</text>
</view>
<view class="icon-btn">
<text class="iconfont icon-cart"></text>
<text>購物車</text>
<text class="badge" v-if="cartCount > 0">{{cartCount}}</text>
</view>
<button class="action-btn btn-cart" @click="addToCart">加入購物車</button>
<button class="action-btn btn-buy" @click="buyNow">立即購買</button>
</view>
<!-- 規格選擇彈窗 -->
<uni-popup ref="specPopup" type="bottom">
<view class="spec-popup">
<view class="spec-header">
<image :src="goods.thumbnail" class="goods-thumb"></image>
<view class="spec-info">
<price :value="selectedSkuPrice || goods.price"></price>
<text class="stock">庫存 {{selectedSkuStock || goods.stock}} 件</text>
<text class="selected">已選:{{selectedSpecText}}</text>
</view>
<text class="close-btn" @click="closeSpecModal">×</text>
</view>
<scroll-view scroll-y class="spec-content">
<view
class="spec-group"
v-for="(group, groupIndex) in goods.specs"
:key="groupIndex"
>
<text class="spec-group-title">{{group.name}}</text>
<view class="spec-items">
<text
class="spec-item"
:class="{ active: isSpecSelected(groupIndex, valueIndex) }"
v-for="(value, valueIndex) in group.values"
:key="valueIndex"
@click="selectSpec(groupIndex, valueIndex)"
>{{value}}</text>
</view>
</view>
<view class="quantity-box">
<text class="quantity-label">數量</text>
<uni-number-box
:min="1"
:max="selectedSkuStock || goods.stock"
v-model="quantity"
></uni-number-box>
</view>
</scroll-view>
<view class="spec-footer">
<button class="btn-confirm-cart" @click="confirmAddToCart">加入購物車</button>
<button class="btn-confirm-buy" @click="confirmBuyNow">立即購買</button>
</view>
</view>
</uni-popup>
</view>
</template>
<script>
import price from '@/components/price/price.vue';
import uniPopup from '@/components/uni-popup/uni-popup.vue';
import uniNumberBox from '@/components/uni-number-box/uni-number-box.vue';
import { mapGetters, mapActions } from 'vuex';
export default {
components: {
price,
uniPopup,
uniNumberBox
},
data() {
return {
goodsId: '',
goods: {
id: '',
title: '',
subtitle: '',
price: 0,
originalPrice: 0,
stock: 0,
thumbnail: '',
images: [],
detail: '',
specs: [],
skus: []
},
selectedSpecs: [],
quantity: 1,
buyType: '' // cart, buy
}
},
computed: {
...mapGetters('cart', ['cartCount']),
// 已選規格文字
selectedSpecText() {
if (!this.goods.specs || this.goods.specs.length === 0) {
return '預設';
}
if (this.selectedSpecs.length === 0) {
return '請選擇規格';
}
let text = [];
this.goods.specs.forEach((group, index) => {
if (this.selectedSpecs[index] !== undefined) {
text.push(group.values[this.selectedSpecs[index]]);
}
});
return text.join(',');
},
// 選中的SKU價格
selectedSkuPrice() {
const sku = this.getSelectedSku();
return sku ? sku.price : null;
},
// 選中的SKU庫存
selectedSkuStock() {
const sku = this.getSelectedSku();
return sku ? sku.stock : null;
}
},
onLoad(options) {
this.goodsId = options.id;
this.loadGoodsDetail();
},
methods: {
...mapActions('cart', ['addCart']),
// 載入商品詳情
async loadGoodsDetail() {
try {
const res = await this.$api.goods.detail({
id: this.goodsId
});
this.goods = res.data;
// 初始化選中規格陣列
if (this.goods.specs && this.goods.specs.length > 0) {
this.selectedSpecs = new Array(this.goods.specs.length).fill(undefined);
}
} catch (e) {
console.error(e);
uni.showToast({
title: '載入失敗',
icon: 'none'
});
}
},
// 開啟規格選擇彈窗
openSpecModal() {
this.$refs.specPopup.open();
},
// 關閉規格選擇彈窗
closeSpecModal() {
this.$refs.specPopup.close();
},
// 判斷規格是否選中
isSpecSelected(groupIndex, valueIndex) {
return this.selectedSpecs[groupIndex] === valueIndex;
},
// 選擇規格
selectSpec(groupIndex, valueIndex) {
this.$set(this.selectedSpecs, groupIndex, valueIndex);
},
// 取得選中的SKU
getSelectedSku() {
// 如果沒有規格或者規格未選擇完整,回傳null
if (!this.goods.specs || this.goods.specs.length === 0 ||
this.selectedSpecs.includes(undefined)) {
return null;
}
// 根據選中的規格查找對應的SKU
const selectedSpecValues = this.selectedSpecs.map((valueIndex, groupIndex) => {
return this.goods.specs[groupIndex].values[valueIndex];
});
return this.goods.skus.find(sku => {
return JSON.stringify(sku.specs) === JSON.stringify(selectedSpecValues);
});
},
// 加入購物車按鈕點擊
addToCart() {
this.buyType = 'cart';
this.openSpecModal();
},
// 立即購買按鈕點擊
buyNow() {
this.buyType = 'buy';
this.openSpecModal();
},
// 確認加入購物車
async confirmAddToCart() {
// 檢查規格是否選擇完整
if (this.goods.specs && this.goods.specs.length > 0 &&
this.selectedSpecs.includes(undefined)) {
uni.showToast({
title: '請選擇完整規格',
icon: 'none'
});
return;
}
// 取得選中的SKU
const sku = this.getSelectedSku();
// 建構購物車商品物件
const cartItem = {
goodsId: this.goods.id,
skuId: sku ? sku.id : null,
title: this.goods.title,
price: sku ? sku.price : this.goods.price,
image: this.goods.thumbnail,
specs: this.selectedSpecText,
quantity: this.quantity
};
// 新增到購物車
try {
await this.addCart(cartItem);
uni.showToast({
title: '已加入購物車',
icon: 'success'
});
this.closeSpecModal();
} catch (e) {
uni.showToast({
title: '新增失敗',
icon: 'none'
});
}
},
// 確認立即購買
confirmBuyNow() {
// 檢查規格是否選擇完整
if (this.goods.specs && this.goods.specs.length > 0 &&
this.selectedSpecs.includes(undefined)) {
uni.showToast({
title: '請選擇完整規格',
icon: 'none'
});
return;
}
// 取得選中的SKU
const sku = this.getSelectedSku();
// 建構訂單商品物件
const orderItem = {
goodsId: this.goods.id,
skuId: sku ? sku.id : null,
title: this.goods.title,
price: sku ? sku.price : this.goods.price,
image: this.goods.thumbnail,
specs: this.selectedSpecText,
quantity: this.quantity
};
// 跳轉到訂單確認頁
uni.navigateTo({
url: '/pages/order/confirm/confirm',
success: (res) => {
res.eventChannel.emit('orderData', {
items: [orderItem],
from: 'buyNow'
});
}
});
this.closeSpecModal();
}
}
}
</script>3. 購物車功能
購物車是電商應用的核心功能之一,支援商品的新增、刪除、修改數量等操作。
vue
<template>
<view class="cart-page">
<!-- 空購物車提示 -->
<view class="empty-cart" v-if="cartList.length === 0">
<image src="/static/images/empty-cart.png" class="empty-image"></image>
<text class="empty-text">購物車還是空的</text>
<button class="go-shopping-btn" @click="goShopping">去逛逛</button>
</view>
<!-- 購物車列表 -->
<block v-else>
<view class="cart-list">
<view
class="cart-item"
v-for="(item, index) in cartList"
:key="item.id"
>
<view class="checkbox">
<checkbox
:checked="item.selected"
@click="toggleSelect(index)"
color="#ff6700"
></checkbox>
</view>
<image :src="item.image" class="goods-image" mode="aspectFill"></image>
<view class="goods-info">
<text class="goods-title">{{item.title}}</text>
<text class="goods-specs">{{item.specs}}</text>
<view class="price-quantity">
<price :value="item.price"></price>
<uni-number-box
:value="item.quantity"
:min="1"
@change="(value) => updateQuantity(index, value)"
></uni-number-box>
</view>
</view>
<text class="delete-btn" @click="removeCartItem(index)">×</text>
</view>
</view>
<!-- 底部結算欄 -->
<view class="cart-footer">
<view class="select-all">
<checkbox
:checked="isAllSelected"
@click="toggleSelectAll"
color="#ff6700"
></checkbox>
<text>全選</text>
</view>
<view class="total-info">
<text>合計:</text>
<price :value="totalPrice" size="32"></price>
</view>
<button
class="checkout-btn"
:disabled="selectedCount === 0"
@click="checkout"
>結算({{selectedCount}})</button>
</view>
</block>
</view>
</template>
<script>
import price from '@/components/price/price.vue';
import uniNumberBox from '@/components/uni-number-box/uni-number-box.vue';
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex';
export default {
components: {
price,
uniNumberBox
},
computed: {
...mapState('cart', ['cartList']),
...mapGetters('cart', ['totalPrice', 'selectedCount', 'isAllSelected']),
},
methods: {
...mapMutations('cart', ['updateCartItem', 'removeFromCart', 'toggleSelectItem', 'toggleSelectAll']),
...mapActions('cart', ['checkout']),
// 切換選中狀態
toggleSelect(index) {
this.toggleSelectItem(index);
},
// 更新商品數量
updateQuantity(index, value) {
this.updateCartItem({
index,
quantity: value
});
},
// 刪除購物車商品
removeCartItem(index) {
uni.showModal({
title: '提示',
content: '確定要刪除該商品嗎?',
success: (res) => {
if (res.confirm) {
this.removeFromCart(index);
}
}
});
},
// 去購物
goShopping() {
uni.switchTab({
url: '/pages/index/index'
});
},
// 結算
checkout() {
if (this.selectedCount === 0) {
return;
}
uni.navigateTo({
url: '/pages/order/confirm/confirm',
success: (res) => {
res.eventChannel.emit('orderData', {
from: 'cart'
});
}
});
}
}
}
</script>4. 訂單確認與付款
訂單確認頁面用於確認訂單資訊、選擇收貨地址和付款方式等。
vue
<template>
<view class="order-confirm">
<!-- 收貨地址 -->
<view class="address-section" @click="chooseAddress">
<view class="address-info" v-if="address">
<view class="user-info">
<text class="name">{{address.name}}</text>
<text class="phone">{{address.phone}}</text>
</view>
<view class="address-detail">{{address.province}}{{address.city}}{{address.district}}{{address.detail}}</view>
</view>
<view class="no-address" v-else>
<text>請選擇收貨地址</text>
</view>
<text class="iconfont icon-right"></text>
</view>
<!-- 商品列表 -->
<view class="goods-section">
<view class="section-title">商品資訊</view>
<view
class="goods-item"
v-for="(item, index) in orderItems"
:key="index"
>
<image :src="item.image" class="goods-image" mode="aspectFill"></image>
<view class="goods-info">
<text class="goods-title">{{item.title}}</text>
<text class="goods-specs">{{item.specs}}</text>
</view>
<view class="goods-price">
<price :value="item.price"></price>
<text class="goods-quantity">x{{item.quantity}}</text>
</view>
</view>
</view>
<!-- 配送方式 -->
<view class="delivery-section">
<view class="section-item">
<text class="item-label">配送方式</text>
<view class="item-value">
<text>快遞配送</text>
</view>
</view>
</view>
<!-- 優惠券 -->
<view class="coupon-section" @click="chooseCoupon">
<view class="section-item">
<text class="item-label">優惠券</text>
<view class="item-value">
<text v-if="selectedCoupon">-¥{{selectedCoupon.amount}}</text>
<text v-else>{{availableCoupons.length > 0 ? `${availableCoupons.length}張可用` : '無可用優惠券'}}</text>
<text class="iconfont icon-right"></text>
</view>
</view>
</view>
<!-- 訂單金額 -->
<view class="amount-section">
<view class="section-item">
<text class="item-label">商品金額</text>
<view class="item-value">
<price :value="goodsAmount"></price>
</view>
</view>
<view class="section-item">
<text class="item-label">運費</text>
<view class="item-value">
<price :value="deliveryFee"></price>
</view>
</view>
<view class="section-item" v-if="selectedCoupon">
<text class="item-label">優惠券</text>
<view class="item-value coupon-value">
<price :value="-selectedCoupon.amount"></price>
</view>
</view>
</view>
<!-- 備註 -->
<view class="remark-section">
<text class="remark-label">備註</text>
<input
type="text"
v-model="remark"
placeholder="選填,請先和商家協商一致"
class="remark-input"
/>
</view>
<!-- 底部結算欄 -->
<view class="footer">
<view class="total-info">
<text>合計:</text>
<price :value="totalAmount" size="36" color="#ff6700"></price>
</view>
<button class="submit-btn" @click="submitOrder">提交訂單</button>
</view>
</view>
</template>
<script>
import price from '@/components/price/price.vue';
import { mapState, mapActions } from 'vuex';
export default {
components: {
price
},
data() {
return {
orderItems: [],
address: null,
selectedCoupon: null,
remark: '',
orderSource: '', // cart, buyNow
deliveryFee: 0
}
},
computed: {
...mapState('user', ['addressList', 'availableCoupons']),
// 商品總金額
goodsAmount() {
return this.orderItems.reduce((total, item) => {
return total + item.price * item.quantity;
}, 0);
},
// 訂單總金額
totalAmount() {
let amount = this.goodsAmount + this.deliveryFee;
if (this.selectedCoupon) {
amount -= this.selectedCoupon.amount;
}
return Math.max(amount, 0);
}
},
onLoad() {
const eventChannel = this.getOpenerEventChannel();
eventChannel.on('orderData', (data) => {
this.orderSource = data.from;
if (data.from === 'cart') {
this.loadCartItems();
} else if (data.from === 'buyNow') {
this.orderItems = data.items;
}
});
this.loadDefaultAddress();
this.loadAvailableCoupons();
},
methods: {
...mapActions('user', ['loadAddressList', 'loadCoupons']),
// 載入購物車商品
loadCartItems() {
// 從購物車載入選中的商品
const selectedItems = this.$store.getters['cart/selectedItems'];
this.orderItems = selectedItems;
},
// 載入預設地址
async loadDefaultAddress() {
await this.loadAddressList();
this.address = this.addressList.find(item => item.isDefault) || this.addressList[0];
},
// 載入可用優惠券
async loadAvailableCoupons() {
await this.loadCoupons();
},
// 選擇地址
chooseAddress() {
uni.navigateTo({
url: '/pages/user/address/list/list?from=order'
});
},
// 選擇優惠券
chooseCoupon() {
if (this.availableCoupons.length === 0) {
return;
}
uni.navigateTo({
url: '/pages/user/coupon/list/list?from=order'
});
},
// 提交訂單
async submitOrder() {
if (!this.address) {
uni.showToast({
title: '請選擇收貨地址',
icon: 'none'
});
return;
}
if (this.orderItems.length === 0) {
uni.showToast({
title: '訂單商品不能為空',
icon: 'none'
});
return;
}
try {
const orderData = {
items: this.orderItems,
address: this.address,
coupon: this.selectedCoupon,
remark: this.remark,
totalAmount: this.totalAmount
};
const res = await this.$api.order.create(orderData);
// 跳轉到付款頁面
uni.redirectTo({
url: `/pages/order/payment/payment?orderId=${res.data.orderId}`
});
} catch (e) {
console.error(e);
uni.showToast({
title: '訂單提交失敗',
icon: 'none'
});
}
}
}
}
</script>最佳實踐
1. 效能最佳化
- 圖片最佳化:使用適當的圖片格式和尺寸,啟用圖片懶載入
- 資料分頁:商品列表採用分頁載入,避免一次載入過多資料
- 快取策略:合理使用本地儲存快取常用資料
- 元件最佳化:使用虛擬列表處理大量資料展示
2. 使用者體驗
- 載入狀態:為所有非同步操作提供載入狀態提示
- 錯誤處理:友善的錯誤提示和重試機制
- 離線支援:關鍵功能支援離線使用
- 響應式設計:適配不同螢幕尺寸和解析度
3. 安全性
- 資料驗證:前後端雙重資料驗證
- 付款安全:使用官方付款介面,避免敏感資訊洩露
- 使用者認證:完善的登入驗證和權限控制
- 資料加密:敏感資料傳輸加密
4. 跨平台適配
- 樣式適配:使用條件編譯處理平台差異
- 功能適配:根據平台特性調整功能實現
- 測試覆蓋:在各目標平台進行充分測試
總結
透過本文的介紹,我們了解了如何使用 uni-app 開發一個功能完善的電商應用。從架構設計到核心功能實現,再到最佳實踐,每個環節都需要仔細考慮。
電商應用的成功不僅在於功能的完整性,更在於使用者體驗的優化和效能的提升。希望本文能為你的 uni-app 電商專案開發提供有價值的參考。