Skip to content

章节 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.title
python
# 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 qs
python
# 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>

小结

  1. 社区团购小程序:核心是开团/参团机制 + 倒计时 + 库存管理,云函数处理业务逻辑,云数据库持久化
  2. 个人作品集小程序:Django REST Framework 提供 API,小程序展示分类+列表+详情,支持 GitHub/Demo 跳转
  3. 上线流程:开发者工具调试 → 真机测试 → 上传代码 → 提交审核 → 正式发布
  4. 性能优化:分包加载、图片懒加载、精确 setData、数据预拉取

练习

  1. 团购小程序:完成社区团购小程序的首页、商品详情页和下单页的编码,使用云开发实现完整的开团、参团流程。
  2. 作品集小程序:搭建 Django 后端,通过 DRF 提供作品分类和项目列表 API,小程序端实现按分类筛选。
  3. 审核准备:检查小程序是否符合发布标准,列出至少 3 个需要在上线前解决的问题。
  4. 性能优化:对已有的小程序进行性能优化,使用分包加载将不常用的页面拆分到分包中。
  5. 完整项目:选择一个真实场景(如二手交易、租房平台、美食推荐),从需求分析到小程序上线完成全流程开发。

Python 学习资料