Appearance
章节 4:模块综合实战
学习目标
- 综合运用小程序云开发 + 前端组件完成社区团购小程序
- 实现带 Django 后端的个人作品集展示小程序
- 掌握小程序项目的完整开发流程与上线准备
实战一:社区团购小程序
项目概述
构建一个完整的社区团购小程序,支持团长开团、用户参团、商品浏览、下单支付等核心功能。
1. 功能模块
社区团购小程序
├── 首页模块
│ ├── 轮播 Banner
│ ├── 商品分类入口
│ └── 今日团购推荐
├── 商品模块
│ ├── 商品列表(分类筛选)
│ ├── 商品详情
│ └── 搜索
├── 团购模块
│ ├── 开团(团长发起)
│ ├── 参团(用户参与)
│ ├── 团购进度显示
│ └── 倒计时
├── 订单模块
│ ├── 下单
│ ├── 订单列表
│ ├── 订单详情
│ └── 支付
├── 用户模块
│ ├── 登录授权
│ ├── 个人信息
│ ├── 地址管理
│ └── 我的团购
└── 团长模块
├── 团长申请
├── 团长中心
└── 收益统计2. 项目结构
group-buying/
├── app.js # 全局逻辑(云开发初始化)
├── app.json # 全局配置
├── app.wxss # 全局样式
├── project.config.json
├── pages/
│ ├── index/ # 首页
│ │ ├── index.wxml
│ │ ├── index.wxss
│ │ ├── index.js
│ │ └── index.json
│ ├── category/ # 商品分类
│ ├── product/ # 商品详情
│ ├── group/ # 团购详情
│ ├── order/ # 订单
│ ├── cart/ # 购物车
│ ├── user/ # 个人中心
│ ├── address/ # 地址管理
│ └── leader/ # 团长中心
├── components/ # 公共组件
│ ├── product-card/ # 商品卡片
│ ├── countdown/ # 倒计时
│ ├── price-display/ # 价格显示
│ └── empty-state/ # 空状态
└── cloud/
└── functions/
├── login/ # 登录云函数
├── getProducts/ # 获取商品列表
├── createOrder/ # 创建订单
└── payNotify/ # 支付回调3. 云数据库设计
javascript
// 集合 1:products —— 商品
{
_id: "auto",
name: "进口车厘子 5斤装",
description: "智利进口 JJ 级车厘子",
images: ["cloud://xxx/cherry-1.jpg"],
price: 99.00, // 普通价格
groupPrice: 69.00, // 团购价
groupMinCount: 3, // 最低成团人数
category: "水果",
stock: 500,
sales: 1280,
isHot: true,
isActive: true,
createdAt: db.serverDate(),
}
// 集合 2:groups —— 团购
{
_id: "auto",
productId: "product_001",
leaderId: "user_001", // 团长 ID
leaderName: "王团长",
minCount: 3, // 最低人数
currentCount: 1, // 当前人数
price: 69.00, // 团购价
status: "active", // active | full | closed
expireAt: "2024-07-01T12:00:00Z",
members: [ // 参团成员
{ userId: "user_001", nickname: "王团长", joinTime: "..." },
],
createdAt: db.serverDate(),
}
// 集合 3:orders —— 订单
{
_id: "auto",
orderNo: "202407010001", // 订单号
userId: "user_001",
productId: "product_001",
groupId: "group_001",
quantity: 1,
totalPrice: 69.00,
status: "pending_pay", // pending_pay | paid | shipped | received | cancelled
address: {
name: "张三",
phone: "13800001111",
detail: "北京市朝阳区 xxx 小区 3 号楼 201",
},
payTime: null,
createdAt: db.serverDate(),
}4. 首页实现
xml
<!-- pages/index/index.wxml -->
<view class="page">
<!-- 搜索栏 -->
<view class="search-bar" bindtap="onSearch">
<icon type="search" size="16" />
<text>搜索商品</text>
</view>
<!-- 轮播图 -->
<swiper class="banner" indicator-dots autoplay circular>
<swiper-item wx:for="{{ banners }}" wx:key="id">
<image src="{{ item.image }}" mode="aspectFill" bindtap="onBannerTap"
data-url="{{ item.url }}" />
</swiper-item>
</swiper>
<!-- 分类入口 -->
<view class="categories">
<view class="category-item" wx:for="{{ categories }}" wx:key="id"
bindtap="onCategoryTap" data-category="{{ item.name }}">
<image src="{{ item.icon }}" class="category-icon" />
<text>{{ item.name }}</text>
</view>
</view>
<!-- 今日团购推荐 -->
<view class="section">
<view class="section-header">
<text class="section-title">🔥 今日团购</text>
<text class="section-more" bindtap="onViewAll">查看更多 ></text>
</view>
<view class="product-grid">
<product-card wx:for="{{ hotProducts }}" wx:key="id"
image="{{ item.images[0] }}"
name="{{ item.name }}"
price="{{ item.price }}"
groupPrice="{{ item.groupPrice }}"
sales="{{ item.sales }}"
bind:producttap="onProductTap" />
</view>
</view>
<!-- 加载更多 -->
<load-more status="{{ loadStatus }}" />
</view>javascript
// pages/index/index.js
const db = wx.cloud.database();
const _ = db.command;
Page({
data: {
banners: [],
categories: [
{ id: 1, name: "水果", icon: "/images/icon-fruit.png" },
{ id: 2, name: "蔬菜", icon: "/images/icon-veg.png" },
{ id: 3, name: "肉禽", icon: "/images/icon-meat.png" },
{ id: 4, name: "乳品", icon: "/images/icon-milk.png" },
{ id: 5, name: "零食", icon: "/images/icon-snack.png" },
{ id: 6, name: "日用", icon: "/images/icon-daily.png" },
],
hotProducts: [],
loadStatus: "loading",
},
async onLoad() {
await Promise.all([this.loadBanners(), this.loadHotProducts()]);
},
async loadBanners() {
const { data } = await db.collection("banners")
.where({ isActive: true })
.orderBy("sortOrder", "asc")
.get();
this.setData({ banners: data });
},
async loadHotProducts() {
const { data } = await db.collection("products")
.where({
isActive: true,
isHot: true,
stock: _.gt(0),
})
.orderBy("sales", "desc")
.limit(10)
.get();
this.setData({ hotProducts: data, loadStatus: data.length ? "more" : "empty" });
},
onCategoryTap(e) {
const category = e.currentTarget.dataset.category;
wx.navigateTo({
url: `/pages/category/category?name=${category}`,
});
},
onProductTap(e) {
const { id } = e.detail;
wx.navigateTo({ url: `/pages/product/product?id=${id}` });
},
});5. 团购详情页
xml
<!-- pages/group/group.wxml -->
<view class="page">
<!-- 团购信息 -->
<view class="group-header">
<image src="{{ product.images[0] }}" class="product-image" />
<view class="price-section">
<text class="group-price">团购价 ¥{{ product.groupPrice }}</text>
<text class="original-price">原价 ¥{{ product.price }}</text>
</view>
<text class="product-name">{{ product.name }}</text>
</view>
<!-- 团购进度 -->
<view class="group-progress">
<view class="progress-bar">
<view class="progress-fill" style="width: {{ progressPercent }}%"></view>
</view>
<text class="progress-text">
已团 {{ currentCount }}/{{ minCount }} 件
<text wx:if="{{ leftCount > 0 }}">还差 {{ leftCount }} 件成团</text>
<text wx:else>🎉 已成团</text>
</text>
</view>
<!-- 倒计时 -->
<countdown deadline="{{ group.expireAt }}" />
<!-- 参团成员 -->
<view class="members-section">
<text class="section-title">参团成员</text>
<view class="member-list">
<view class="member" wx:for="{{ group.members }}" wx:key="userId">
<image src="{{ item.avatarUrl }}" class="member-avatar" />
<text>{{ item.nickname }}</text>
</view>
</view>
</view>
<!-- 下单按钮 -->
<view class="bottom-bar">
<view class="quantity-selector">
<button size="mini" bindtap="decrease">-</button>
<text>{{ quantity }}</text>
<button size="mini" bindtap="increase">+</button>
</view>
<button class="buy-btn" type="primary" bindtap="onJoinGroup" loading="{{ buying }}">
{{ leftCount > 0 ? `¥${totalPrice} 立即参团` : '¥${totalPrice} 立即购买' }}
</button>
</view>
</view>javascript
// pages/group/group.js
Page({
data: {
product: {},
group: {},
quantity: 1,
totalPrice: 0,
currentCount: 0,
minCount: 0,
leftCount: 0,
progressPercent: 0,
buying: false,
},
onLoad(options) {
this.loadGroupData(options.id);
},
async loadGroupData(groupId) {
const db = wx.cloud.database();
const groupRes = await db.collection("groups").doc(groupId).get();
const group = groupRes.data;
const productRes = await db.collection("products").doc(group.productId).get();
const product = productRes.data;
const currentCount = group.members.length;
const leftCount = Math.max(0, group.minCount - currentCount);
const progressPercent = (currentCount / group.minCount) * 100;
this.setData({
group,
product,
currentCount,
leftCount,
progressPercent,
totalPrice: product.groupPrice,
});
},
increase() {
const quantity = this.data.quantity + 1;
this.setData({
quantity,
totalPrice: (quantity * this.data.product.groupPrice).toFixed(2),
});
},
decrease() {
if (this.data.quantity <= 1) return;
const quantity = this.data.quantity - 1;
this.setData({
quantity,
totalPrice: (quantity * this.data.product.groupPrice).toFixed(2),
});
},
async onJoinGroup() {
this.setData({ buying: true });
try {
const res = await wx.cloud.callFunction({
name: "createOrder",
data: {
groupId: this.data.group._id,
productId: this.data.product._id,
quantity: this.data.quantity,
totalPrice: this.data.totalPrice,
},
});
if (res.result.code === 0) {
// 跳转支付
wx.navigateTo({
url: `/pages/order/payment?orderNo=${res.result.data.orderNo}`,
});
}
} catch (err) {
wx.showToast({ title: "下单失败", icon: "error" });
} finally {
this.setData({ buying: false });
}
},
});6. 创建订单云函数
javascript
// cloud/functions/createOrder/index.js
const cloud = require("wx-server-sdk");
cloud.init();
const db = cloud.database();
exports.main = async (event) => {
const { OPENID } = cloud.getWXContext();
const { groupId, productId, quantity, totalPrice } = event;
try {
// 1. 检查团购是否存在且未满
const groupRes = await db.collection("groups").doc(groupId).get();
const group = groupRes.data;
if (!group || group.status !== "active") {
return { code: -1, message: "团购已结束" };
}
// 2. 生成本地订单号
const now = new Date();
const orderNo = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}${Date.now()}`;
// 3. 创建订单
const orderRes = await db.collection("orders").add({
data: {
orderNo,
userId: OPENID,
productId,
groupId,
quantity,
totalPrice,
status: "pending_pay",
createdAt: db.serverDate(),
},
});
// 4. 减少库存
await db.collection("products").doc(productId).update({
data: { stock: db.command.inc(-quantity) },
});
return {
code: 0,
data: { orderNo, orderId: orderRes._id },
};
} catch (err) {
return { code: -1, message: err.message };
}
};实战二:个人作品集展示小程序(Django 后端联调)
项目概述
构建一个带 Django 管理后端的个人作品集展示小程序,支持作品分类展示、项目详情、联系方式等功能。
1. Django 后端
python
# backend/models.py
from django.db import models
class Category(models.Model):
"""作品分类"""
name = models.CharField(max_length=50)
icon = models.CharField(max_length=200, blank=True)
sort_order = models.IntegerField(default=0)
class Meta:
ordering = ["sort_order"]
def __str__(self):
return self.name
class Project(models.Model):
"""作品项目"""
category = models.ForeignKey(
Category, on_delete=models.CASCADE, related_name="projects"
)
title = models.CharField(max_length=200)
summary = models.TextField()
description = models.TextField(blank=True)
cover_image = models.URLField(blank=True)
images = models.JSONField(default=list, blank=True) # 多图
tech_stack = models.JSONField(default=list, blank=True) # 技术栈
github_url = models.URLField(blank=True)
demo_url = models.URLField(blank=True)
is_featured = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return self.titlepython
# backend/serializers.py
from rest_framework import serializers
from .models import Category, Project
class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = "__all__"
class ProjectSerializer(serializers.ModelSerializer):
category_name = serializers.CharField(source="category.name", read_only=True)
class Meta:
model = Project
fields = [
"id", "category", "category_name", "title", "summary",
"description", "cover_image", "images", "tech_stack",
"github_url", "demo_url", "is_featured", "created_at",
]python
# backend/views.py
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import Category, Project
from .serializers import CategorySerializer, ProjectSerializer
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Category.objects.all()
serializer_class = CategorySerializer
class ProjectViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Project.objects.all()
serializer_class = ProjectSerializer
@action(detail=False)
def featured(self, request):
"""精选作品"""
projects = self.get_queryset().filter(is_featured=True)[:6]
serializer = self.get_serializer(projects, many=True)
return Response(serializer.data)
def get_queryset(self):
qs = super().get_queryset()
category = self.request.query_params.get("category")
if category:
qs = qs.filter(category__name=category)
return qspython
# backend/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register(r"categories", views.CategoryViewSet)
router.register(r"projects", views.ProjectViewSet)
urlpatterns = [
path("api/", include(router.urls)),
]2. 小程序前端
xml
<!-- pages/index/index.wxml —— 首页 -->
<view class="page">
<!-- 个人简介 -->
<view class="profile">
<image src="{{ userInfo.avatar }}" class="avatar" />
<text class="name">{{ userInfo.name }}</text>
<text class="bio">{{ userInfo.bio }}</text>
<view class="social-links">
<button open-type="contact" size="mini">联系我</button>
<navigator url="{{ userInfo.github }}" open-type="webview">
<button size="mini">GitHub</button>
</navigator>
</view>
</view>
<!-- 分类导航 -->
<scroll-view class="category-tabs" scroll-x>
<view class="tab {{ currentCategory === '' ? 'active' : '' }}"
bindtap="switchCategory" data-category="">
全部
</view>
<view class="tab {{ currentCategory === item.name ? 'active' : '' }}"
wx:for="{{ categories }}" wx:key="id"
bindtap="switchCategory" data-category="{{ item.name }}">
{{ item.name }}
</view>
</scroll-view>
<!-- 作品网格 -->
<view class="project-grid">
<view class="project-card" wx:for="{{ projects }}" wx:key="id"
bindtap="onProjectTap" data-id="{{ item.id }}">
<image src="{{ item.cover_image }}" mode="aspectFill" />
<view class="card-overlay">
<text class="card-title">{{ item.title }}</text>
<view class="tech-tags">
<text class="tag" wx:for="{{ item.tech_stack }}" wx:key="*this">{{ item }}</text>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<load-more status="{{ loadStatus }}" />
</view>javascript
// pages/index/index.js
const API_BASE = "https://your-domain.com/api";
Page({
data: {
userInfo: {
name: "张三",
avatar: "/images/avatar.png",
bio: "全栈开发者 | Python & JavaScript",
github: "https://github.com/zhangsan",
},
categories: [],
projects: [],
currentCategory: "",
page: 1,
loadStatus: "loading",
hasMore: true,
},
async onLoad() {
await Promise.all([
this.loadCategories(),
this.loadProjects(),
]);
},
async loadCategories() {
const res = await wx.request({
url: `${API_BASE}/categories/`,
});
this.setData({ categories: res.data || [] });
},
async loadProjects(append = false) {
const { page, currentCategory } = this.data;
let url = `${API_BASE}/projects/?page=${page}&page_size=10`;
if (currentCategory) {
url += `&category=${currentCategory}`;
}
const res = await wx.request({ url });
const newData = res.data.results || res.data || [];
this.setData({
projects: append ? [...this.data.projects, ...newData] : newData,
loadStatus: newData.length < 10 ? "noMore" : "more",
hasMore: newData.length >= 10,
});
},
switchCategory(e) {
const category = e.currentTarget.dataset.category || "";
if (category === this.data.currentCategory) return;
this.setData({ currentCategory: category, page: 1 });
this.loadProjects();
},
onProjectTap(e) {
const id = e.currentTarget.dataset.id;
wx.navigateTo({ url: `/pages/detail/detail?id=${id}` });
},
onReachBottom() {
if (!this.data.hasMore) return;
this.setData({ page: this.data.page + 1 });
this.loadProjects(true);
},
onShareAppMessage() {
return {
title: `${this.data.userInfo.name} 的作品集`,
path: "/pages/index/index",
};
},
});3. 作品详情页
xml
<!-- pages/detail/detail.wxml -->
<view class="page">
<!-- 封面图 -->
<image src="{{ project.cover_image }}" class="cover" mode="aspectFill" />
<!-- 基本信息 -->
<view class="info-section">
<text class="title">{{ project.title }}</text>
<text class="category">{{ project.category_name }}</text>
<!-- 技术栈标签 -->
<view class="tech-stack">
<text class="label">技术栈:</text>
<text class="tag" wx:for="{{ project.tech_stack }}" wx:key="*this">
{{ item }}
</text>
</view>
</view>
<!-- 项目描述 -->
<view class="desc-section">
<text class="section-title">项目简介</text>
<text class="description">{{ project.description }}</text>
</view>
<!-- 图片集 -->
<view class="images-section" wx:if="{{ project.images && project.images.length }}">
<text class="section-title">截图展示</text>
<scroll-view scroll-x class="image-scroll">
<image wx:for="{{ project.images }}" wx:key="*this"
src="{{ item }}" mode="aspectFill"
bindtap="previewImage" data-url="{{ item }}" />
</scroll-view>
</view>
<!-- 底部按钮 -->
<view class="bottom-bar" wx:if="{{ project.github_url || project.demo_url }}">
<button wx:if="{{ project.github_url }}" bindtap="openGithub">
<image src="/images/icon-github.png" /> 查看源码
</button>
<button wx:if="{{ project.demo_url }}" bindtap="openDemo" type="primary">
在线演示
</button>
</view>
</view>项目上线准备
1. 小程序发布流程
开发阶段
├── 在开发者工具中完成开发和调试
├── 真机调试:使用真机扫码测试
└── 云开发环境切换:从测试环境切至正式环境
提交审核
├── 设置 TabBar、导航栏、加载动画等 UI 细节
├── 完善 app.json 配置(窗品表现、页面注册)
├── 上传代码:工具栏 → 上传
└── 登录微信公众平台 → 版本管理 → 提交审核
发布上线
└── 审核通过后 → 发布(全量/灰度)2. 常见审核驳回原因
| 原因 | 解决方案 |
|---|---|
| 首页加载空白 | 添加骨架屏或加载提示 |
| 无实质功能 | 确保核心功能完整可交互 |
| 用户信息未授权无法使用 | 提供游客模式或引导授权 |
| 界面设计不符合规范 | 遵循小程序设计指南(WeUI) |
| 支付功能未完善 | 确保支付流程完整(或隐藏未完成的功能) |
| 存在测试数据 | 上线前清理测试数据 |
3. 性能优化要点
javascript
// 1. 图片懒加载
<image lazy-load="{{ true }}" />
// 2. 分包加载(减少首包体积)
// app.json
{
"subpackages": [
{
"root": "packageA",
"pages": [
"pages/leader/leader",
"pages/statistics/statistics"
]
}
]
}
// 3. 数据预拉取
App({
onLaunch() {
wx.setStorageSync("preloadData", null);
},
});
// 4. setData 优化——只更新变化的数据
this.setData({
[`items[${index}].name`]: newName, // 精确更新
});
// 5. 使用 WXS 处理复杂计算
// <wxs module="utils">
// function formatPrice(price) { return '¥' + price.toFixed(2); }
// </wxs>小结
- 社区团购小程序:核心是开团/参团机制 + 倒计时 + 库存管理,云函数处理业务逻辑,云数据库持久化
- 个人作品集小程序:Django REST Framework 提供 API,小程序展示分类+列表+详情,支持 GitHub/Demo 跳转
- 上线流程:开发者工具调试 → 真机测试 → 上传代码 → 提交审核 → 正式发布
- 性能优化:分包加载、图片懒加载、精确 setData、数据预拉取
练习
- 团购小程序:完成社区团购小程序的首页、商品详情页和下单页的编码,使用云开发实现完整的开团、参团流程。
- 作品集小程序:搭建 Django 后端,通过 DRF 提供作品分类和项目列表 API,小程序端实现按分类筛选。
- 审核准备:检查小程序是否符合发布标准,列出至少 3 个需要在上线前解决的问题。
- 性能优化:对已有的小程序进行性能优化,使用分包加载将不常用的页面拆分到分包中。
- 完整项目:选择一个真实场景(如二手交易、租房平台、美食推荐),从需求分析到小程序上线完成全流程开发。