ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

校园二手交易小程序开发实战:从状态机设计到Node.js接口联调

校园二手交易小程序开发实战:从状态机设计到Node.js接口联调 简介这份基于JavaScript开发的校园二手交易微信小程序完整源码包主要面向计算机相关专业在校生、教师及初级开发者可用于课程设计、毕业设计或项目起步演示。项目实现了定位大学、当地天气、轮播图、推荐列表、购物车、商品发布、订单管理、身份验证等典型二手交易功能覆盖小程序常用交互与数据流链路。整个压缩包共205个文件以51个js逻辑脚本、40个wxml页面结构、42个wxss样式、42个json配置为主并配有多张png/jpg界面图片及README文档说明压缩后仅1.2MB轻量清晰便于直接运行和二次修改。当前已有100人学习/下载源码已通过运行测试若遇到环境配置问题还可与作者沟通获取远程教学指导。无论用于课程作业、毕设演示还是想快速理解微信小程序项目结构这套源码都具有不错的参考和复用价值。1. 校园二手交易微信小程序先定交易闭环再写页面拿到“校园二手交易微信小程序”这个标题很多人第一反应是去画首页、做轮播图但我建议先把交易闭环画出来。校园场景里有发布闲置的学生有搜索想买的同学还有交易完成后需要确认收货的节点这三者构成了一个完整的二手交易平台。相比普通商城二手物品没有标准 SKU价格可议、成色不一所以订单状态、商品上下架、交易取消这些逻辑远比页面美观更重要。这篇文章以 JavaScript 为技术主线围绕微信小程序前端和 Node.js 后端把“校园二手书 / 校园二手物品交易”从数据模型到接口联调再到项目源码与文档说明的交付完整走一遍。适合正在做课程设计或毕业设计的开发者也适合接手别人二手交易源码时快速理解项目结构的人。2. 需求分析与数据模型二手交易的状态机和字段设计2.1 从闲置发布到确认收货拆解核心交易链路校园二手交易和普通电商最大的区别在于“信任”和“线下交接”。学生发布一本书可能当天就被同校的人买走也可能挂一个月无人问津。作为开发者第一件事不是写页面而是把业务状态罗列出来商品有“在售 / 已下架 / 已售出”三种状态订单有“买家下单 / 卖家确认 / 买家收货 / 交易完成 / 已取消”五种状态每个状态变更都必须有操作人买家或卖家和发生时间。我一般会先画一张状态流转图而不是直接建表。比如买家下单后商品状态要立刻从“在售”变成“锁定”否则两个人同时下单就会出现超卖卖家确认后订单进入“待收货”买家确认收货后订单变成“交易完成”商品变成“已售出”。这些规则如果不提前定好后面写接口时一定会到处打补丁。把状态转移画成大圈套小圈的图比任何需求文档都直观画完后保留这张图后续写后端接口、写前端页面、写文档说明时都能对照它检查逻辑是否遗漏。2.2 三张核心表用户、商品、订单的字段清单数据模型是整个项目源码里最值得反复推敲的部分。以最常见的校园二手平台为例最少需要三张表user、product、order。下面是我常用的字段设计表字段类型说明useropenidvarchar(64)微信登录唯一标识设为主键usernicknamevarchar(32)用户昵称useravatar_urlvarchar(255)头像地址userstudent_idvarchar(20)学号校园场景用于实名usercreate_timedatetime注册时间productproduct_idint主键自增producttitlevarchar(100)商品标题productdescriptiontext商品描述productpricedecimal(10,2)价格注意用 decimal 而不是 floatproductoriginal_pricedecimal(10,2)原价或参考价productimagesjson图片 URL 列表用 JSON 存多图productcategoryvarchar(20)分类教材、数码、生活用品等productstatustinyint0在售 1锁定 2已售 3下架productseller_openidvarchar(64)发布者 openidproductcreate_timedatetime发布时间orderorder_idbigint主键orderorder_novarchar(32)订单号业务上显示用orderproduct_idint关联商品orderbuyer_openidvarchar(64)买家orderseller_openidvarchar(64)卖家orderstatustinyint0待确认 1待收货 2已完成 3已取消orderpricedecimal(10,2)成交价格下单时从商品快照ordercreate_timedatetime下单时间这里有几个容易忽略的点价格字段千万不要用float因为二进制浮点数在计算总价时会有精度误差尤其微信小程序端 JavaScript 做加法也可能出现0.1 0.2 ! 0.3的问题商品图片用json类型存数组避免单独建一张图片表订单里冗余一份seller_openid和price是为了防止商品信息被修改后订单数据跟着变这在交易系统里叫“快照”。如果你统计过同类校园二手平台的数据会发现教材类商品占到发布量的四成以上所以category字段建议预置教材、数码、生活用品等几个固定值前端渲染单选框时也方便。2.3 状态机与 JavaScript 校验函数数据表定好后把状态流转写进代码。这部分用纯 JavaScript 定义常量比写在注释里更可靠。// constants.js const PRODUCT_STATUS { ON_SALE: 0, // 在售 LOCKED: 1, // 已锁定有人下单未完成 SOLD: 2, // 已售出 OFF_SHELF: 3 // 已下架 }; const ORDER_STATUS { PENDING: 0, // 买家已下单等待卖家确认 CONFIRMED: 1, // 卖家已确认等待买家收货 COMPLETED: 2, // 买家确认收货交易完成 CANCELED: 3 // 已取消 }; // 定义允许的状态迁移旧状态 - 新状态 const ORDER_TRANSITIONS { [ORDER_STATUS.PENDING]: [ORDER_STATUS.CONFIRMED, ORDER_STATUS.CANCELED], [ORDER_STATUS.CONFIRMED]: [ORDER_STATUS.COMPLETED, ORDER_STATUS.CANCELED] }; function canTransition(fromStatus, toStatus) { const allowed ORDER_TRANSITIONS[fromStatus]; return !!allowed allowed.includes(toStatus); } module.exports { PRODUCT_STATUS, ORDER_STATUS, canTransition };这段代码的逻辑说明ORDER_TRANSITIONS是一个映射表key是当前订单状态value是允许跳转到的目标状态数组。canTransition函数返回布尔值后端接口在更新状态前必须先调用它做校验。比如订单处于PENDING等待卖家确认时买家可以取消卖家可以确认但它不能直接跳到COMPLETED因为中间少了确认收货动作。把这种规则集中在文件里比散落在各个接口中好维护得多。参数说明fromStatus是旧状态toStatus是目标状态两者都必须是上面定义的常量建议在接口层再包一层防御判断防止数据库写入脏数据。后面写接口、做订单操作时只认这组常量不许在代码里出现裸数字0、1否则排错时很容易把业务含义搞混。3. 小程序前端实现页面、组件与微信 API 的配合3.1 用 HBuilderX 创建项目并配置 app.json开发微信小程序工具链上我推荐 HBuilderX原因很简单它内置了微信小程序的编译、预览和上传能力同时支持html、css、javascript的语法高亮如果你从零开始也可以直接用官方微信开发者工具。这里遵循标题里 JavaScript 的技术背景用原生小程序语法讲解不引入额外框架。HBuilderX 的快捷键和插件体系偏向前端工程化熟悉 Vue 的同学也能快速上手不过要强调一点HBuilderX 只是编辑器真正编译运行微信小程序仍然依赖微信开发者工具的基础库两者要配合使用。新建一个“微信小程序”项目后先看app.json这是整个小程序的全局配置{ pages: [ pages/index/index, pages/publish/publish, pages/detail/detail, pages/order/order, pages/mine/mine ], window: { navigationBarTitleText: 校园二手交易, navigationBarBackgroundColor: #07c160, navigationBarTextStyle: white, backgroundColor: #f5f5f5 }, tabBar: { list: [ { pagePath: pages/index/index, text: 首页 }, { pagePath: pages/publish/publish, text: 发布 }, { pagePath: pages/mine/mine, text: 我的 } ] } }pages数组的第一个元素是小程序的首页后续所有新页面都要在这里注册否则跳转时会报“页面不存在”。tabBar配置底部导航注意tabBar页面必须在pages中出现且图标路径不能为空。这里我没有加图标文件直接用了文字如果你需要自定义图标要在iconPath和selectedIconPath里填本地图片路径。顶部导航栏高度在不同机型上不一致如果你要自定义导航栏需要关注状态栏高度这块在 3.2 节会涉及。3.2 首页列表WXML 渲染、下拉刷新与加载状态首页是二手物品交易平台的流量入口。用微信小程序的wx.request从后端拉数据然后在WXML里用wx:for渲染列表。先看数据层代码// pages/index/index.js Page({ data: { productList: [], loading: false, finished: false, page: 1, pageSize: 10 }, onLoad() { this.loadProducts(true); }, onPullDownRefresh() { this.loadProducts(true).finally(() { wx.stopPullDownRefresh(); }); }, onReachBottom() { if (this.data.finished) return; this.loadProducts(false); }, loadProducts(reset) { if (this.data.loading) return Promise.resolve(); this.setData({ loading: true }); const page reset ? 1 : this.data.page 1; return wx.request({ url: http://localhost:3000/api/products, data: { page, pageSize: this.data.pageSize }, success: (res) { const list res.data.data.list; this.setData({ productList: reset ? list : this.data.productList.concat(list), page: page, finished: list.length this.data.pageSize }); }, fail: () { wx.showToast({ title: 加载失败, icon: none }); }, complete: () { this.setData({ loading: false }); } }); } });代码说明loadProducts(reset)同时承担首次加载、下拉刷新和触底翻页三种操作。reset为true时从第一页拉数据并替换列表为false时页码加 1并通过concat把新数据追加到已有列表末尾。finished用来判断是否还有更多数据避免无意义的请求。wx.request是小程序发起网络请求的核心 APIsuccess回调里拿到的是后端返回的完整响应体我这里约定的res.data.data.list是“统一响应格式”中的 data 字段具体封装在第四章。逻辑说明onPullDownRefresh需要页面的json配置中设置enablePullDownRefresh: true否则下拉不会触发onReachBottom是页面滚动到底部的钩子不需要额外配置。WXML 侧的结构比较简单view classproduct-list block wx:for{{productList}} wx:keyproduct_id view classproduct-card bindtapgotoDetail>// pages/publish/publish.js Page({ data: { title: , category: 教材, price: , description: , images: [], categories: [教材, 数码, 生活用品, 运动户外, 其他] }, onTitleInput(e) { this.setData({ title: e.detail.value }); }, onCategoryChange(e) { // e.detail.value 是选中的单选框 value this.setData({ category: e.detail.value }); }, onPriceInput(e) { let value e.detail.value.replace(/[^\d.]/g, ); this.setData({ price: value }); }, chooseImage() { wx.chooseMedia({ count: 3 - this.data.images.length, mediaType: [image], success: (res) { const tempFiles res.tempFiles; const images this.data.images.concat(tempFiles.map(f f.tempFilePath)); this.setData({ images }); } }); }, submit() { const { title, price, description, category, images } this.data; if (!title.trim()) { wx.showToast({ title: 请填写标题, icon: none }); return; } if (!price || parseFloat(price) 0) { wx.showToast({ title: 请填写正确价格, icon: none }); return; } if (images.length 0) { wx.showToast({ title: 请至少上传一张图片, icon: none }); return; } // 先上传图片拿到 URL 后再提交商品信息 wx.showLoading({ title: 发布中 }); this.uploadImages(images).then((urls) { wx.request({ url: http://localhost:3000/api/products, method: POST, data: { title, price: parseFloat(price), description, category, images: urls }, success: (res) { wx.hideLoading(); if (res.data.code 0) { wx.showToast({ title: 发布成功, icon: success }); setTimeout(() wx.navigateBack(), 1500); } } }); }); }, uploadImages(images) { const tasks images.map((path) { return new Promise((resolve, reject) { wx.uploadFile({ url: http://localhost:3000/api/upload, filePath: path, name: file, success: (res) resolve(JSON.parse(res.data).data.url), fail: reject }); }); }); return Promise.all(tasks); } });这段代码的核心是submit中的发布流程先做本地校验再上传图片全部成功后用wx.request提交商品数据。chooseImage中的count: 3 - this.data.images.length用来控制最多上传三张图。onPriceInput里的正则只允许数字和小数点这是防止用户在价格框里输入字母的最简单手段。上传题图时wx.uploadFile的name参数必须与后端接收文件字段一致否则后端收不到文件。注意wx.chooseMedia是较新的 API基础库 2.10.0 以后支持如果你要兼容老版本可以用wx.chooseImage。单选框在这里通过categories数组渲染WXML 中用radio-group和radio实现bindchange事件回调里e.detail.value就是选中的值。这种写法比写死五个单选按钮更可维护。发布成功延迟navigateBack是给用户一个看到“发布成功”提示的时间窗口时间太长反而让人觉得卡顿1.5 秒刚好。3.4 个人中心与我的发布管理交易状态“我的”页面通常要展示我的头像、昵称、我发布的商品、我买到的、我卖出的。对于校园二手物品交易来说“我卖出的”比“我买到的”更重要因为卖家需要处理订单确认和发货环节。// pages/mine/mine.js Page({ data: { userInfo: null, myProducts: [] }, onShow() { this.checkLogin(); }, checkLogin() { const token wx.getStorageSync(token); if (!token) { wx.navigateTo({ url: /pages/login/login }); return; } this.fetchUserInfo(token); this.fetchMyProducts(token); }, fetchMyProducts(token) { wx.request({ url: http://localhost:3000/api/my/products, header: { Authorization: token }, success: (res) { this.setData({ myProducts: res.data.data.list }); } }); }, onProductTap(e) { const productId e.currentTarget.dataset.id; wx.navigateTo({ url: /pages/product-status/product-status?productId${productId} }); } });这里的checkLogin在每次页面显示时执行因为用户可能在其他页面修改了登录状态。wx.getStorageSync(token)是同步读取本地缓存适合这种轻量判断。fetchMyProducts通过请求头的Authorization字段传递 token后端再根据 token 解析出 openid从而查询该用户发布的商品列表。点击某件商品后跳转到状态管理页那里可以看到该商品当前的订单、以及切换上下架的按钮。这种“我的发布→商品状态→订单处理”的层级关系比把所有功能堆在“我的”页里更清晰。如果你想让管理页更直观可以在每个订单项上直接放“确认”和“取消”按钮按钮的>// db.js const mysql require(mysql2/promise); const pool mysql.createPool({ host: localhost, user: root, password: 123456, database: campus_secondhand, waitForConnections: true, connectionLimit: 10, namedPlaceholders: true }); module.exports pool;// productController.js const pool require(../db); const { PRODUCT_STATUS, ORDER_STATUS, canTransition } require(../constants); const { v4: uuidv4 } require(uuid); // 商品分页列表 async function getProducts(req, res) { const page parseInt(req.query.page) || 1; const pageSize parseInt(req.query.pageSize) || 10; const offset (page - 1) * pageSize; const [rows] await pool.query( SELECT product_id, title, price, images, status FROM product WHERE status ? ORDER BY create_time DESC LIMIT ? OFFSET ?, [PRODUCT_STATUS.ON_SALE, pageSize, offset] ); res.json({ code: 0, data: { list: rows } }); } // 发布商品 async function createProduct(req, res) { const { title, description, price, category, images } req.body; const openid req.user.openid; // 中间件解析 token 后挂载 const [result] await pool.query( INSERT INTO product (title, description, price, original_price, images, category, status, seller_openid) VALUES (?, ?, ?, ?, ?, ?, ?, ?), [title, description, price, price, JSON.stringify(images || []), category, PRODUCT_STATUS.ON_SALE, openid] ); res.json({ code: 0, data: { productId: result.insertId } }); } // 创建订单 async function createOrder(req, res) { const { productId } req.body; const buyerOpenid req.user.openid; const [products] await pool.query( SELECT * FROM product WHERE product_id ? FOR UPDATE, [productId] ); if (products.length 0) { return res.status(404).json({ code: 1002, message: 商品不存在 }); } const product products[0]; // 校验商品状态只有在售才能下单 if (product.status ! PRODUCT_STATUS.ON_SALE) { return res.json({ code: 1003, message: 商品已被锁定或售出 }); } // 更新商品状态为锁定同时插入订单 const orderNo SO Date.now() uuidv4().slice(0, 6); await pool.query( UPDATE product SET status ? WHERE product_id ? AND status ?, [PRODUCT_STATUS.LOCKED, productId, PRODUCT_STATUS.ON_SALE] ); await pool.query( INSERT INTO \order\ (order_no, product_id, buyer_openid, seller_openid, status, price) VALUES (?, ?, ?, ?, ?, ?), [orderNo, productId, buyerOpenid, product.seller_openid, ORDER_STATUS.PENDING, product.price] ); res.json({ code: 0, data: { orderNo } }); } // 订单状态流转 async function updateOrderStatus(req, res) { const orderId req.params.id; const { action } req.body; // confirm / receive / cancel const openid req.user.openid; const [orders] await pool.query( SELECT * FROM \order\ WHERE order_id ?, [orderId] ); if (orders.length 0) { return res.status(404).json({ code: 1004, message: 订单不存在 }); } const order orders[0]; // 权限校验卖家只能确认/取消买家只能收货/取消 let targetStatus; if (action confirm) { if (order.seller_openid ! openid) return res.status(403).json({ code: 403, message: 无权限 }); targetStatus ORDER_STATUS.CONFIRMED; } else if (action receive) { if (order.buyer_openid ! openid) return res.status(403).json({ code: 403, message: 无权限 }); targetStatus ORDER_STATUS.COMPLETED; } else if (action cancel) { targetStatus ORDER_STATUS.CANCELED; } else { return res.status(400).json({ code: 400, message: 无效操作 }); } // 状态机校验 if (!canTransition(order.status, targetStatus)) { return res.json({ code: 1005, message: 当前状态不允许该操作 }); } await pool.query( UPDATE \order\ SET status ? WHERE order_id ? AND status ?, [targetStatus, orderId, order.status] ); // 如果订单完成或取消商品回到在售或标记售出 if (targetStatus ORDER_STATUS.COMPLETED) { await pool.query(UPDATE product SET status ? WHERE product_id ?, [PRODUCT_STATUS.SOLD, order.product_id]); } else if (targetStatus ORDER_STATUS.CANCELED) { await pool.query(UPDATE product SET status ? WHERE product_id ?, [PRODUCT_STATUS.ON_SALE, order.product_id]); } res.json({ code: 0, data: { status: targetStatus } }); } module.exports { getProducts, createProduct, createOrder, updateOrderStatus };这段代码有几个关键点。SELECT ... FOR UPDATE是行级锁在 MySQL 事务或自动提交下锁住商品行防止并发下单导致超卖。UPDATE product SET status ? WHERE product_id ? AND status ?是乐观锁的玩法更新时再次校验状态如果状态已经被别人改了影响行数为 0可以据此判断冲突。订单操作里的权限校验是必要的尤其是“取消”操作买卖双方都可能有取消诉求但在不同状态下的取消含义不同所以我在代码里做了细分买家在PENDING状态可以取消卖家在PENDING状态可以直接取消但在CONFIRMED状态卖家不能再取消只能等买家确认或申请平台介入。注意order是 MySQL 的保留字表名要加反引号。参数说明action字段定义在接口文档里前端只传字符串后端做映射如果你用 TypeScript可以用联合类型约束更严格但 JavaScript 项目里靠文档和这层校验就足够了。4.3 小程序端 wx.request 封装与请求拦截原生wx.request用起来比较繁琐我习惯封装成一个request函数// utils/request.js const BASE_URL http://localhost:3000/api; function request(path, options {}) { return new Promise((resolve, reject) { wx.request({ url: BASE_URL path, method: options.method || GET, data: options.data || {}, header: { Content-Type: application/json, Authorization: wx.getStorageSync(token) || }, success: (res) { if (res.statusCode 401) { wx.navigateTo({ url: /pages/login/login }); reject(res); return; } if (res.data.code 0) { resolve(res.data.data); } else { wx.showToast({ title: res.data.message, icon: none }); reject(res.data); } }, fail: reject }); }); } module.exports { request };封装后的使用方式const { request } require(../../utils/request); request(/products, { data: { page: 1, pageSize: 10 } }).then((data) { console.log(data.list); });BASE_URL可以放在单独配置文件里根据开发/生产环境切换。header中Authorization可以从缓存读取这样每个接口自动带 token。res.data.code 0是业务成功判断如果后端返回 401 或业务码非 0统一在这里处理业务代码里就不需要到处写错误提示了。注意wx.request默认超时时间是 60 秒如果后端接口慢可以在wx.request中加timeout参数比如实际请求量大的时候把超时改成 10000 毫秒。4.4 真机调试、抓包定位与常见报错开发微信小程序模拟器能跑通不代表真机没问题。真机调试有两种方式一种是在微信开发者工具中点击“预览”生成二维码用手机微信扫码打开另一种是使用真机调试 2.0可以像浏览器 DevTools 一样看 Console 和 Network。如果小程序运行报错最常见的坑有三个request 合法域名校验失败在开发者工具中勾选“不校验合法域名”或者在微信公众平台后台配置 request 合法域名。本地开发建议用前者上线前必须配置 HTTPS 域名。无法连接服务器手机和电脑不在同一个局域网或者后端服务监听的是127.0.0.1。本地联调时把后端监听地址改成0.0.0.0并让手机访问电脑的局域网 IP。图片上传失败wx.uploadFile的url不能与wx.request的baseUrl混用需要单独定义UPLOAD_URL。如果需要抓包分析请求细节可以借助 Charles。电脑端启动 Charles 后手机设置 HTTP 代理指向电脑的 IP 和端口 8888即可看到小程序发出的 HTTPS 请求。注意微信小程序的部分接口走的是 HTTPSCharles 默认会拦截但你可以只关注自己的接口。证书安装这一步比较繁琐但为了定位问题值得做。实在搞不定也可以用微信开发者工具自带的 Network 面板虽然比 Charles 功能少但能看到请求头、响应体和耗时大多数问题足够定位。抓包时重点看三处请求 URL 是否用了可访问的地址、请求 Header 是否带了正确的 token、响应 JSON 的 code 字段是不是 0。只要这三处正常联调问题通常已经走完八成了。5. 文档说明与源码交付让项目源码具备可持续性5.1 README 里必须写清楚的五件事源码包交付不是把代码压缩发过去就完事了。接手的人要能快速跑起来至少要看到以下内容项目简介这个二手交易平台解决什么问题核心功能列表技术栈前端用 JavaScript 原生小程序后端用 Node.js Express MySQL环境要求Node.js 版本建议 14、MySQL 版本、微信开发者工具版本启动步骤创建数据库并导入 SQL 脚本修改db.js的数据库配置npm install和npm start最后用微信开发者工具导入小程序目录目录结构说明client小程序代码和server后端代码分别在哪。README 开头不要写一堆介绍语直接写“如何运行”因为看文档的人第一需求就是跑起来。如果你把 README 放在 GitHub 上还可以在顶部加一个徽章或者一张效果截图但那不影响核心功能描述。5.2 接口文档与 SQL 初始化脚本的组织方式接口文档我推荐用 Markdown 形式命名API.md放在docs目录下。每个接口写清楚用途、URL、请求方法、请求参数、响应示例。上面第二章的字段表可以直接进文档。SQL 脚本命名为init.sql放在server/sql目录下包含建库、建表和初始数据如分类字典。脚本要可重复执行建议在开头加DROP TABLE IF EXISTS或CREATE DATABASE IF NOT EXISTS。# 后端启动 cd server npm install # 修改 db.js 中的数据库密码 node app.js # 小程序端 # 用微信开发者工具导入 client 目录并将 BASE_URL 改为 http://localhost:3000/api这两个步骤必须能从零执行不要省略任何细节。比如我第一次接手别人的二手项目时对方没给 SQL 脚本我花了半小时建表还猜了好几个字段类型这种体验很容易让人放弃。所以交付文档里SQL 脚本、接口文档、README 三件套缺一不可。如果你愿意多花一点时间可以再写一个DEPLOY.md说明如何部署到云服务器和配置 HTTPS 域名但那是上线阶段的事课程设计不强制。5.3 验收清单从克隆源码到跑通全流程的检查表最后给一份自测清单适合开发者在交付前逐项检查也适合接手者验证项目是否完整。核心流程是注册登录 → 发布商品 → 在首页看到 → 另一个账号下单 → 卖家确认 → 买家收货 → 商品变为已售。检查项预期结果状态导入 SQL 脚本后执行node app.js后端监听 3000 端口无报错☐微信开发者工具导入client后编译首页显示商品列表或空状态提示☐用手机号登录或模拟登录获取到 openidtoken 写入 Storage☐发布一件“九成新教材”首页出现该商品价格和图片正确☐使用第二个账号下单商品状态变为锁定第一个账号的订单列表出现待确认订单☐卖家确认订单买家订单状态变为待收货☐买家确认收货订单状态变为已完成商品状态变为已售☐买家取消订单未确认前订单状态变为已取消商品回到在售☐这份清单可以直接复制进你的交付文档作为源码包的最后一个文件。把验收清单命名为CHECKLIST.md放进项目根目录接手的同学从拉取代码到跑通全流程只需要对照它逐项勾选勾到最后一项时这个校园二手交易微信小程序项目就可以放心写上“已交付”了。本文还有配套的精品资源点击获取
返回列表