微信小程序开发与预约类小程序
微信小程序是一种无需下载安装即可使用的应用形态。本文档从小程序基础开发知识出发,系统讲解核心 API、云开发模式,并以预约类小程序为案例,提供完整的设计与实现方案。
1. 微信小程序基础
1.1 项目结构
一个标准的微信小程序项目包含以下文件结构:
miniprogram/
├── app.js # 小程序逻辑入口
├── app.json # 小程序全局配置
├── app.wxss # 小程序全局样式
├── project.config.json # 项目配置文件
├── sitemap.json # 搜索索引配置
├── pages/ # 页面文件目录
│ ├── index/
│ │ ├── index.js # 页面逻辑
│ │ ├── index.json # 页面配置
│ │ ├── index.wxml # 页面结构
│ │ └── index.wxss # 页面样式
│ └── ...
├── components/ # 自定义组件目录
│ ├── nav-bar/
│ │ ├── nav-bar.js
│ │ ├── nav-bar.json
│ │ ├── nav-bar.wxml
│ │ └── nav-bar.wxss
│ └── ...
├── utils/ # 工具函数
│ ├── request.js # 网络请求封装
│ └── util.js
└── images/ # 静态资源1.1.1 app.js 入口文件
app.js 是小程序的逻辑入口,在其中调用 App() 函数注册小程序实例,全局数据和生命周期在此定义:
// app.js
App({
// 全局共享数据
globalData: {
userInfo: null,
token: '',
systemInfo: {}
},
onLaunch(options) {
// 小程序初始化完成时触发(全局只触发一次)
this.getSystemInfo();
this.checkLogin();
},
onShow(options) {
// 小程序启动或从后台进入前台时触发
},
onHide() {
// 小程序从前台进入后台时触发
},
getSystemInfo() {
wx.getSystemInfo({
success: (res) => {
this.globalData.systemInfo = res;
}
});
},
checkLogin() {
const token = wx.getStorageSync('token');
if (token) {
this.globalData.token = token;
}
}
});1.1.2 app.json 全局配置
app.json 是小程序的全局配置文件,定义页面路径、窗口样式、底部 tab 等:
{
"pages": [
"pages/index/index",
"pages/service/service",
"pages/booking/booking",
"pages/order/order",
"pages/user/user"
],
"window": {
"navigationBarTitleText": "预约助手",
"navigationBarBackgroundColor": "#07c160",
"navigationBarTextStyle": "white",
"backgroundColor": "#f5f5f5",
"backgroundTextStyle": "dark"
},
"tabBar": {
"color": "#999999",
"selectedColor": "#07c160",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页",
"iconPath": "images/home.png",
"selectedIconPath": "images/home-active.png"
},
{
"pagePath": "pages/order/order",
"text": "预约",
"iconPath": "images/order.png",
"selectedIconPath": "images/order-active.png"
},
{
"pagePath": "pages/user/user",
"text": "我的",
"iconPath": "images/user.png",
"selectedIconPath": "images/user-active.png"
}
]
},
"usingComponents": {},
"permission": {
"scope.userLocation": {
"desc": "获取您的位置信息用于推荐附近门店"
}
},
"requiredPrivateInfos": ["getLocation"]
}1.1.3 app.wxss 全局样式
/* app.wxss */
@import './styles/reset.wxss';
page {
background-color: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif;
font-size: 28rpx;
color: #333;
box-sizing: border-box;
}
/* 全局通用类 */
.container {
padding: 20rpx;
}
.card {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.btn-primary {
background: linear-gradient(135deg, #07c160, #06ad56);
color: #fff;
border-radius: 44rpx;
height: 88rpx;
line-height: 88rpx;
text-align: center;
font-size: 32rpx;
font-weight: 500;
}
.btn-primary:active {
opacity: 0.85;
}1.2 WXML 语法
WXML(WeiXin Markup Language)是小程序的标记语言,类似 HTML,但提供数据绑定和指令系统。
1.2.1 数据绑定
使用 Mustache 语法 将数据绑定到视图:
<!-- 内容绑定 -->
<view>{{ message }}</view>
<!-- 属性绑定 -->
<image src="{{ imageUrl }}" mode="aspectFill"></image>
<!-- 条件绑定 -->
<view class="{{ isActive ? 'active' : 'normal' }}">状态切换</view>
<!-- 算术运算 -->
<view>总价:{{ price * quantity }} 元</view>
<!-- 逻辑运算 -->
<view>{{ isVIP && score > 100 ? 'VIP 会员' : '普通用户' }}</view>1.2.2 条件渲染
<!-- wx:if 条件渲染,惰性渲染(条件为 false 时不渲染) -->
<view wx:if="{{ status === 'success' }}">预约成功</view>
<view wx:elif="{{ status === 'pending' }}">待审核</view>
<view wx:else>预约失败</view>
<!-- hidden 控制显隐,始终渲染 -->
<view hidden="{{ !showDetail }}">详情内容</view>wx:if 和 hidden 的选择:频繁切换使用 hidden,初次条件为 false 且不切换使用 wx:if。
1.2.3 列表渲染
<!-- wx:for 列表渲染,默认 item 为当前项,index 为索引 -->
<view wx:for="{{ serviceList }}" wx:key="id" class="service-item">
<text class="name">{{ item.name }}</text>
<text class="price">¥{{ item.price }}</text>
</view>
<!-- 自定义变量名 -->
<view wx:for="{{ timeSlots }}" wx:for-item="slot" wx:for-index="idx" wx:key="slotId">
<text>{{ idx + 1 }}. {{ slot.timeRange }} (剩余 {{ slot.remain }} 名额)</text>
</view>
<!-- 嵌套列表 -->
<view wx:for="{{ categories }}" wx:for-item="category" wx:key="catId">
<view class="category-name">{{ category.name }}</view>
<view wx:for="{{ category.services }}" wx:for-item="svc" wx:key="svcId">
<text>{{ svc.name }}</text>
</view>
</view>wx:key 的作用是提高渲染性能和避免渲染错误,建议始终提供。
1.2.4 模板
<!-- 定义模板 -->
<template name="serviceCard">
<view class="service-card" data-id="{{ id }}">
<image src="{{ icon }}" class="icon"></image>
<text class="name">{{ name }}</text>
<text class="price">¥{{ price }}</text>
<text class="duration">{{ duration }}分钟</text>
</view>
</template>
<!-- 使用模板 -->
<template is="serviceCard" data="{{ ...item }}" />1.3 WXSS 样式
WXSS 在 CSS 基础上扩展了尺寸单位 rpx 和样式导入功能。
1.3.1 rpx 适配方案
rpx(responsive pixel)以屏幕宽度 750rpx 为基准,实现不同屏幕的自动适配:
/* 设计稿以 375px(iPhone 6/7/8)为基准,1px = 2rpx */
.container {
width: 750rpx; /* 满宽 */
padding: 30rpx; /* 上下左右各 15px 内边距 */
font-size: 28rpx; /* 14px */
}
.card {
width: 690rpx; /* 345px */
margin: 0 30rpx; /* 左右 15px 外边距 */
height: 200rpx; /* 100px */
}
.avatar {
width: 120rpx; /* 60px */
height: 120rpx;
border-radius: 50%; /* 圆形头像 */
}1.3.2 Flexbox 布局
小程序推荐使用 Flexbox 布局,兼容性良好:
/* 水平居中布局 */
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
/* 两端对齐 */
.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
/* 弹性换行 */
.flex-wrap {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.flex-wrap > .item {
width: calc((100% - 40rpx) / 3); /* 三列布局 */
}
/* 垂直排列 */
.flex-column {
display: flex;
flex-direction: column;
}
.flex-1 {
flex: 1;
}1.3.3 全局样式与组件样式隔离
小程序默认开启组件样式隔离,组件内样式不会影响外部,外部样式也不会穿透到组件内部。可在组件配置中修改:
{
"component": true,
"styleIsolation": "isolated"
// isolated - 隔离(默认)
// apply-shared - 页面样式影响组件
// shared - 页面和组件样式互相影响
}1.4 小程序生命周期
1.4.1 App 生命周期
| 方法 | 触发时机 | 用途 |
|---|---|---|
onLaunch | 小程序初始化完成(全局一次) | 初始化数据、登录校验 |
onShow | 小程序启动或从后台切前台 | 刷新数据、检查更新 |
onHide | 小程序从前台切后台 | 保存状态、暂停操作 |
onError | 脚本错误或 API 调用失败 | 错误上报 |
onPageNotFound | 页面不存在 | 重定向到默认页 |
1.4.2 Page 生命周期
// pages/index/index.js
Page({
data: {
services: [],
loading: true
},
// 页面加载,只触发一次,可接收 onLoad 参数
onLoad(query) {
// query 为页面跳转携带的参数对象
this.loadServices();
},
// 页面显示,每次页面出现在屏幕时触发
onShow() {
// 适合做数据刷新(从后台切换回来时)
},
// 页面渲染完成,首次渲染完成后触发
onReady() {
// 适合做复杂动画初始化
},
// 页面隐藏(切换 tab、跳转新页面等)
onHide() {
// 暂停非必需操作
},
// 页面卸载(redirectTo 或 navigateBack 到其他页面)
onUnload() {
// 清理定时器、取消请求订阅
},
// 下拉刷新
onPullDownRefresh() {
this.loadServices(() => {
wx.stopPullDownRefresh();
});
},
// 上拉触底
onReachBottom() {
this.loadMore();
},
// 页面滚动
onPageScroll(event) {
// event.scrollTop 滚动位置
},
// 分享
onShareAppMessage() {
return {
title: '快来预约服务吧',
path: '/pages/index/index'
};
}
});1.4.3 Component 生命周期
// components/service-card/service-card.js
Component({
properties: {
service: { type: Object, value: {} }
},
data: {
formattedPrice: ''
},
lifetimes: {
// 组件创建时触发
created() {
// 此时不能调用 setData
},
// 组件初始化完成、进入页面节点树
attached() {
// 可获取节点信息、发起请求
this.formatPrice();
},
// 组件在视图层布局完成后触发
ready() {
// 可获取节点宽高等信息
},
// 组件移动到节点树另一个位置
moved() {},
// 组件被移除节点树
detached() {
// 清理操作
}
},
// 组件所在页面生命周期
pageLifetimes: {
show() {
// 页面显示时触发
},
hide() {
// 页面隐藏时触发
}
},
methods: {
formatPrice() {
this.setData({
formattedPrice: `¥${this.properties.service.price}`
});
}
}
});2. 核心 API
2.1 网络请求
2.1.1 wx.request 封装
微信小程序使用 wx.request 发起网络请求,需要对域名进行白名单配置,并且必须使用 HTTPS 协议。以下是完整的请求封装:
// utils/request.js
const BASE_URL = 'https://api.example.com';
const TOKEN_KEY = 'token';
// 请求拦截器
function requestInterceptor(config) {
const token = wx.getStorageSync(TOKEN_KEY);
if (token) {
config.header = {
...config.header,
Authorization: `Bearer ${token}`
};
}
// 添加公共参数
config.header['X-App-Version'] = '1.0.0';
config.header['X-Platform'] = 'weapp';
return config;
}
// 响应拦截器
function responseInterceptor(response) {
const { statusCode, data } = response;
if (statusCode === 401) {
// token 过期,跳转登录
wx.removeStorageSync(TOKEN_KEY);
wx.navigateTo({ url: '/pages/login/login' });
return Promise.reject(new Error('未登录或登录已过期'));
}
if (statusCode === 403) {
wx.showToast({ title: '没有操作权限', icon: 'none' });
return Promise.reject(new Error('权限不足'));
}
if (statusCode >= 500) {
wx.showToast({ title: '服务器异常,请稍后重试', icon: 'none' });
return Promise.reject(new Error('服务器错误'));
}
if (data.code !== 0) {
wx.showToast({ title: data.message || '请求失败', icon: 'none' });
return Promise.reject(new Error(data.message));
}
return data.data;
}
// 核心请求方法
function request(method, url, data = {}, options = {}) {
const config = {
url: `${BASE_URL}${url}`,
method,
data,
header: {
'Content-Type': 'application/json'
},
timeout: options.timeout || 10000,
...options
};
requestInterceptor(config);
return new Promise((resolve, reject) => {
wx.request({
...config,
success: (res) => {
responseInterceptor(res).then(resolve).catch(reject);
},
fail: (err) => {
wx.showToast({ title: '网络请求失败,请检查网络连接', icon: 'none' });
reject(err);
}
});
});
}
// 导出便捷方法
export const get = (url, data, options) => request('GET', url, data, options);
export const post = (url, data, options) => request('POST', url, data, options);
export const put = (url, data, options) => request('PUT', url, data, options);
export const del = (url, data, options) => request('DELETE', url, data, options);
export default { get, post, put, del };2.1.2 使用示例
// 在页面中使用
import { get, post } from '../../utils/request';
Page({
async loadServiceList() {
this.setData({ loading: true });
try {
const services = await get('/api/services', { categoryId: 1 });
this.setData({ services, loading: false });
} catch (err) {
this.setData({ loading: false });
console.error('加载服务列表失败:', err);
}
},
async submitBooking(formData) {
wx.showLoading({ title: '提交中...' });
try {
const result = await post('/api/appointments', formData);
wx.hideLoading();
wx.showToast({ title: '预约成功', icon: 'success' });
return result;
} catch (err) {
wx.hideLoading();
throw err;
}
}
});2.2 本地存储
2.2.1 存储 API
// 同步存储(推荐,避免异步回调问题)
wx.setStorageSync('key', 'value');
const value = wx.getStorageSync('key');
wx.removeStorageSync('key');
wx.clearStorageSync();
// 异步存储
wx.setStorage({ key: 'userInfo', data: userObj });
wx.getStorage({ key: 'userInfo', success: (res) => {} });
wx.removeStorage({ key: 'tempData' });2.2.2 缓存策略实现
// utils/cache.js
const CACHE_PREFIX = 'app_';
const DEFAULT_EXPIRE = 30 * 60 * 1000; // 默认 30 分钟
class CacheManager {
// 设置缓存,带过期时间
set(key, value, expire = DEFAULT_EXPIRE) {
const cacheData = {
value,
expire: Date.now() + expire
};
wx.setStorageSync(`${CACHE_PREFIX}${key}`, cacheData);
}
// 获取缓存,自动检查过期
get(key) {
const cacheData = wx.getStorageSync(`${CACHE_PREFIX}${key}`);
if (!cacheData) return null;
if (cacheData.expire && Date.now() > cacheData.expire) {
wx.removeStorageSync(`${CACHE_PREFIX}${key}`);
return null;
}
return cacheData.value;
}
// 清除指定缓存
remove(key) {
wx.removeStorageSync(`${CACHE_PREFIX}${key}`);
}
// 清除所有带前缀的缓存
clear() {
const keys = wx.getStorageInfoSync().keys;
keys.forEach(key => {
if (key.startsWith(CACHE_PREFIX)) {
wx.removeStorageSync(key);
}
});
}
}
export const cache = new CacheManager();2.2.3 缓存策略应用
// 使用缓存缓存首页数据
import { cache } from '../../utils/cache';
import { get } from '../../utils/request';
Page({
async onShow() {
// 先读取缓存
const cachedServices = cache.get('home_services');
if (cachedServices) {
this.setData({ services: cachedServices, loadedFromCache: true });
}
// 再发起网络请求更新
try {
const services = await get('/api/services/home');
cache.set('home_services', services, 5 * 60 * 1000); // 5 分钟缓存
this.setData({ services });
} catch (err) {
// 网络失败时缓存数据仍然可用
if (!this.data.services) {
wx.showToast({ title: '加载失败', icon: 'none' });
}
}
}
});2.3 路由跳转
小程序页面栈最多十层,需合理选择跳转方式。
// 1. navigateTo - 保留当前页面,跳转到新页面(最常用)
wx.navigateTo({
url: '/pages/booking/booking?serviceId=123&name=理发',
events: {
// 从目标页面返回时接收数据
bookingConfirmed(data) {
console.log('预约已确认:', data);
}
},
success: function(res) {
// 通过 eventChannel 向目标页面传递数据
res.eventChannel.emit('acceptDataFromOpener', { fromPage: 'index' });
}
});
// 2. redirectTo - 关闭当前页面,跳转到新页面
wx.redirectTo({
url: '/pages/login/login?redirect=/pages/user/user'
});
// 3. switchTab - 跳转到 tabBar 页面(必须使用此 API)
wx.switchTab({
url: '/pages/order/order'
});
// 4. navigateBack - 返回上一页或多页
wx.navigateBack({
delta: 1 // 返回一级,默认为 1
});
// 5. reLaunch - 关闭所有页面,打开到某个页面
wx.reLaunch({
url: '/pages/index/index'
});
// 页面间传参——目标页面接收
// pages/booking/booking.js
Page({
onLoad(query) {
console.log(query.serviceId, query.name); // 123, 理发
}
});2.4 组件通信
2.4.1 父组件向子组件传参(properties)
// components/booking-card/booking-card.js
Component({
properties: {
// 简写方式
title: String,
// 完整定义
booking: {
type: Object,
value: {},
observer(newVal, oldVal) {
// 属性变化时的回调
this.updateDisplay();
}
},
readonly: {
type: Boolean,
value: false
}
},
methods: {
updateDisplay() {
const { serviceName, status, time } = this.properties.booking;
this.setData({
displayText: `${serviceName} - ${time} [${status}]`
});
}
}
});<!-- 父页面使用 -->
<booking-card
title="待确认预约"
booking="{{ bookingInfo }}"
readonly="{{ isHistory }}"
/>2.4.2 子组件向父组件传参(events)
// components/counter-picker/counter-picker.js
Component({
properties: {
min: { type: Number, value: 1 },
max: { type: Number, value: 99 },
value: { type: Number, value: 1 }
},
methods: {
increase() {
if (this.data.value >= this.data.max) {
wx.showToast({ title: '已达上限', icon: 'none' });
return;
}
const newVal = this.data.value + 1;
this.setData({ value: newVal });
// 触发自定义事件,向父组件传递新值
this.triggerEvent('change', { value: newVal });
},
decrease() {
if (this.data.value <= this.data.min) return;
const newVal = this.data.value - 1;
this.setData({ value: newVal });
this.triggerEvent('change', { value: newVal });
}
}
});<!-- 父页面使用 -->
<counter-picker
min="1"
max="10"
value="{{ quantity }}"
bind:change="onQuantityChange"
/>// 父页面中处理事件
Page({
onQuantityChange(event) {
const { value } = event.detail;
this.setData({ quantity: value });
}
});2.4.3 slot 插槽
<!-- components/section-layout/section-layout.wxml -->
<view class="section">
<view class="section-header">
<text class="title">{{ title }}</text>
<!-- 具名插槽,用于自定义头部右侧内容 -->
<slot name="header-right"></slot>
</view>
<view class="section-body">
<!-- 默认插槽 -->
<slot></slot>
</view>
</view><!-- 使用组件的页面 -->
<section-layout title="可选时段">
<!-- 默认插槽内容 -->
<view wx:for="{{ slots }}" wx:key="id">
<text>{{ item.time }}</text>
</view>
<!-- 具名插槽 -->
<view slot="header-right">
<button size="mini" bindtap="viewAll">查看全部</button>
</view>
</section-layout>多 slot 需在组件 JSON 中声明:
{
"component": true,
"usingComponents": {},
"componentGenerics": {}
}// components/section-layout/section-layout.js
Component({
options: {
multipleSlots: true // 启用多 slot 支持
}
});3. 云开发
微信云开发提供云函数、云数据库、云存储和云调用能力,开发者无需自建后端即可完成全栈开发。
3.1 环境初始化
// app.js - 云开发环境初始化
App({
onLaunch() {
wx.cloud.init({
env: 'prod-xxxxx', // 云环境 ID
traceUser: true // 追踪用户
});
}
});3.2 云函数
云函数在 Node.js 环境中运行,每个云函数独立部署。
3.2.1 云函数定义
// cloudfunctions/createAppointment/index.js
const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
const db = cloud.database();
const _ = db.command;
exports.main = async (event, context) => {
const { OPENID, APPID } = cloud.getWXContext(); // 获取用户身份
const { serviceId, date, timeSlotId, contactName, contactPhone } = event;
try {
// 1. 校验时间段是否已被占满
const slot = await db.collection('time_slots')
.doc(timeSlotId)
.get();
if (!slot.data || slot.data.remain <= 0) {
return { code: -1, message: '该时段已满,请选择其他时段' };
}
if (slot.data.date !== date) {
return { code: -1, message: '日期不匹配' };
}
// 2. 创建预约单
const result = await db.collection('appointments').add({
data: {
_openid: OPENID,
serviceId,
date,
timeSlotId,
timeRange: slot.data.timeRange,
contactName,
contactPhone,
status: 'pending', // pending-待审核, confirmed-已确认, completed-已完成, cancelled-已取消
createTime: db.serverDate(),
updateTime: db.serverDate()
}
});
// 3. 扣减时段余量
await db.collection('time_slots')
.doc(timeSlotId)
.update({
data: {
remain: _.inc(-1),
updateTime: db.serverDate()
}
});
// 4. 发送订阅消息(异步)
try {
await cloud.openapi.subscribeMessage.send({
touser: OPENID,
templateId: '模板消息ID',
page: 'pages/order/order',
data: {
thing1: { value: '服务预约' },
date2: { value: date },
thing3: { value: '预约已提交,等待审核' }
}
});
} catch (msgErr) {
console.error('发送订阅消息失败:', msgErr);
}
return { code: 0, data: { appointmentId: result._id } };
} catch (err) {
console.error('创建预约失败:', err);
return { code: -1, message: '创建预约失败,请稍后重试' };
}
};3.2.2 小程序端调用云函数
// 小程序端调用
Page({
async submitBooking(formData) {
wx.showLoading({ title: '提交中...' });
try {
const res = await wx.cloud.callFunction({
name: 'createAppointment',
data: formData
});
wx.hideLoading();
if (res.result.code === 0) {
wx.showToast({ title: '预约成功', icon: 'success' });
wx.navigateTo({
url: `/pages/order/detail?id=${res.result.data.appointmentId}`
});
} else {
wx.showToast({ title: res.result.message, icon: 'none' });
}
} catch (err) {
wx.hideLoading();
wx.showToast({ title: '网络异常,请重试', icon: 'none' });
}
}
});3.3 云数据库
云数据库是腾讯云提供的 MongoDB 数据库,在小程序端和云函数中均可操作。
3.3.1 小程序端直接操作
const db = wx.cloud.database();
const _ = db.command;
const $ = db.command.aggregate;
// 查询
async function loadAppointments() {
const { data } = await db.collection('appointments')
.where({
_openid: '{openid}', // 自动替换为当前用户 openid
status: _.in(['pending', 'confirmed']) // 待审核或已确认
})
.orderBy('createTime', 'desc') // 按创建时间降序
.skip(0)
.limit(20)
.get();
return data;
}
// 聚合查询——统计用户预约数量
async function countByStatus() {
const { list } = await db.collection('appointments')
.aggregate()
.match({
_openid: '{openid}'
})
.group({
_id: '$status',
count: $.sum(1)
})
.end();
return list;
}
// 分页查询——游标分页,适合无限滚动
async function loadMoreBookings(lastId) {
const { data, errMsg } = await db.collection('appointments')
.where({ _openid: '{openid}' })
.orderBy('createTime', 'desc')
.limit(10)
.get();
return data;
}
// 事务操作——适合需要原子性的场景
async function transactionalBooking(slotId, bookingData) {
return await db.runTransaction(async (transaction) => {
// 在事务中读取
const slotRes = await transaction.collection('time_slots').doc(slotId).get();
if (slotRes.data.remain <= 0) {
throw new Error('时段已满');
}
// 在事务中写入
await transaction.collection('appointments').add({
data: bookingData
});
await transaction.collection('time_slots').doc(slotId).update({
data: { remain: slotRes.data.remain - 1 }
});
});
}3.3.2 安全规则配置
{
"appointments": {
"read": "doc._openid == auth.openid || auth.role == 'admin'",
"write": "doc._openid == auth.openid",
"create": "auth.openid != null"
},
"time_slots": {
"read": true,
"write": "auth.role == 'admin'"
}
}3.4 云存储
云存储用于存储图片、文件等资源。
// 上传图片
Page({
async uploadImage() {
wx.chooseImage({
count: 1,
success: async (res) => {
const filePath = res.tempFilePaths[0];
const cloudPath = `images/${Date.now()}_${Math.random().toString(36).slice(2)}.jpg`;
wx.showLoading({ title: '上传中...' });
try {
const result = await wx.cloud.uploadFile({
cloudPath,
filePath
});
wx.hideLoading();
// 获取文件访问链接
const { fileList } = await wx.cloud.getTempFileURL({
fileList: [result.fileID]
});
this.setData({ imageUrl: fileList[0].tempFileURL });
wx.showToast({ title: '上传成功', icon: 'success' });
} catch (err) {
wx.hideLoading();
wx.showToast({ title: '上传失败', icon: 'none' });
}
}
});
},
// 删除文件
async deleteImage(fileID) {
try {
await wx.cloud.deleteFile({
fileList: [fileID]
});
} catch (err) {
console.error('删除文件失败:', err);
}
}
});3.5 云调用
云调用允许在云函数中调用微信开放 API,无需获取 access_token。
// cloudfunctions/sendSubscribeMessage/index.js
const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
exports.main = async (event) => {
const { OPENID } = cloud.getWXContext();
const { appointmentId, status } = event;
try {
const result = await cloud.openapi.subscribeMessage.send({
touser: OPENID,
templateId: 'TEMPLATE_ID',
page: `pages/order/detail?id=${appointmentId}`,
data: {
thing1: { value: '预约状态更新' },
phrase2: { value: status === 'confirmed' ? '已确认' : '已完成' },
time3: { value: new Date().toLocaleString() },
thing4: { value: '点击查看详情' }
},
miniprogramState: 'formal' // formal-正式版, trial-体验版, developer-开发者版
});
return { code: 0, data: result };
} catch (err) {
console.error('发送订阅消息失败:', err);
return { code: -1, message: err.message };
}
};4. 预约类小程序完整设计
4.1 数据库表设计
4.1.1 预约单表(appointments)
// 云数据库集合: appointments
{
_id: String, // 自动生成,预约单号
_openid: String, // 用户 openid
orderNo: String, // 业务订单号(如: AP20260710120001)
// 服务信息
serviceId: String, // 服务项 ID
serviceName: String, // 服务项名称
serviceDuration: Number, // 服务时长(分钟)
servicePrice: Number, // 服务价格(分)
// 资源信息
resourceId: String, // 资源 ID(如理发师、会议室、车辆)
resourceName: String, // 资源名称
// 时间信息
date: String, // 预约日期(YYYY-MM-DD)
timeSlotId: String, // 时段 ID
timeRange: String, // 时段范围(如 "14:00-15:00")
startTime: Date, // 预约开始时间(完整时间戳)
endTime: Date, // 预约结束时间(完整时间戳)
// 客户信息
contactName: String, // 联系人
contactPhone: String, // 联系电话
remark: String, // 备注
// 状态管理
status: String, // pending-待审核, confirmed-已确认, completed-已完成, cancelled-已取消, expired-已过期
cancelReason: String, // 取消原因
cancelTime: Date, // 取消时间
// 核销信息
verifyCode: String, // 核销码
verifyTime: Date, // 核销时间
verifyBy: String, // 核销人
// 时间戳
createTime: Date,
updateTime: Date
}
// 索引建议
// 1. { _openid: 1, status: 1, createTime: -1 } — 用户预约列表
// 2. { date: 1, timeSlotId: 1, status: 1 } — 时段预约查询
// 3. { verifyCode: 1 } — 核销码查询(唯一索引)
// 4. { orderNo: 1 } — 订单号查询(唯一索引)4.1.2 预约明细表(appointment_items)
// 云数据库集合: appointment_items
{
_id: String,
appointmentId: String, // 关联预约单 ID
// 明细类型
itemType: String, // service-服务项, addon-加项, product-商品
// 明细内容
itemId: String, // 关联项 ID
itemName: String, // 项名称
quantity: Number, // 数量
unitPrice: Number, // 单价(分)
totalPrice: Number, // 小计(分)
// 状态
status: String, // normal-正常, refunded-已退款
createTime: Date,
updateTime: Date
}
// 索引
// { appointmentId: 1 } — 按预约单查询明细4.1.3 资源表(resources)
// 云数据库集合: resources
{
_id: String,
name: String, // 资源名称(如"张师傅-高级理发师")
type: String, // 资源类型(staff-员工, room-房间, equipment-设备, vehicle-车辆)
category: String, // 资源分类
description: String, // 描述
avatar: String, // 头像 URL
// 服务能力
serviceIds: [String], // 可提供的服务 ID 列表
maxConcurrent: Number, // 最大并发服务数(默认为 1)
// 排班
workDays: [Number], // 工作日(0-6,周日到周六)
workStart: String, // 上班时间(HH:mm)
workEnd: String, // 下班时间(HH:mm)
// 状态
status: String, // active-启用, inactive-停用
createTime: Date,
updateTime: Date
}4.1.4 时段表(time_slots)
// 云数据库集合: time_slots
// 按天生成可用时段
{
_id: String,
resourceId: String, // 关联资源 ID
date: String, // 日期(YYYY-MM-DD)
startTime: String, // 开始时间(HH:mm)
endTime: String, // 结束时间(HH:mm)
timeRange: String, // 时段展示文字("14:00-15:00")
// 容量
capacity: Number, // 总容量
used: Number, // 已使用数
remain: Number, // 剩余数
// 状态
isAvailable: Boolean, // 是否可预约
isSpecial: Boolean, // 是否为特殊时段(节假日等)
createTime: Date,
updateTime: Date
}
// 索引
// { resourceId: 1, date: 1, startTime: 1 } — 资源某天的所有时段(唯一复合索引)
// { date: 1, isAvailable: 1 } — 按日期查询可用时段4.1.5 服务项表(services)
// 云数据库集合: services
{
_id: String,
name: String, // 服务名称
category: String, // 服务分类
description: String, // 描述
coverImage: String, // 封面图片
price: Number, // 价格(分)
duration: Number, // 时长(分钟)
unit: String, // 计价单位(次/小时/天)
// 预约规则
maxAdvanceDays: Number, // 最大提前预约天数
minAdvanceHours: Number, // 最少提前预约小时数
allowMulti: Boolean, // 是否允许同一时段多份
// 状态
status: String, // online-上架, offline-下架
createTime: Date,
updateTime: Date
}4.2 预约完整流程
预约类小程序的典型流程如下:
选择服务 -> 选择资源 -> 选择时段 -> 填写信息 -> 提交预约 -> 审核 -> 通知4.2.1 选择服务
// pages/service/service.js
Page({
data: {
categories: [],
services: [],
currentCategory: '',
loading: false
},
async onLoad() {
await this.loadCategories();
await this.loadServices();
},
async loadCategories() {
const db = wx.cloud.database();
const { data } = await db.collection('service_categories')
.orderBy('sort', 'asc')
.get();
this.setData({
categories: data,
currentCategory: data[0]?._id || ''
});
},
async loadServices() {
this.setData({ loading: true });
const db = wx.cloud.database();
const { data } = await db.collection('services')
.where({
category: this.data.currentCategory,
status: 'online'
})
.get();
this.setData({ services: data, loading: false });
},
switchCategory(e) {
const { id } = e.currentTarget.dataset;
this.setData({ currentCategory: id });
this.loadServices();
},
selectService(e) {
const { id } = e.currentTarget.dataset;
wx.navigateTo({
url: `/pages/booking/booking?serviceId=${id}`
});
}
});4.2.2 选择时段
// pages/booking/booking.js
Page({
data: {
service: null,
resources: [],
selectedResource: null,
availableDates: [],
selectedDate: '',
timeSlots: [],
selectedSlot: null,
loading: false
},
async onLoad(query) {
const { serviceId } = query;
await this.loadServiceDetail(serviceId);
this.generateAvailableDates();
this.setData({ selectedDate: this.data.availableDates[0] });
await this.loadResources();
await this.loadTimeSlots();
},
loadServiceDetail(serviceId) {
const db = wx.cloud.database();
return db.collection('services').doc(serviceId).get()
.then(res => {
this.setData({ service: res.data });
});
},
// 生成可预约日期列表(根据服务配置的最大提前预约天数)
generateAvailableDates() {
const dates = [];
const today = new Date();
const maxDays = this.data.service?.maxAdvanceDays || 7;
for (let i = 0; i < maxDays; i++) {
const d = new Date(today);
d.setDate(d.getDate() + i);
const dateStr = d.toISOString().split('T')[0];
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
dates.push({
value: dateStr,
label: i === 0 ? '今天' : i === 1 ? '明天' : weekdays[d.getDay()],
fullLabel: `${d.getMonth() + 1}/${d.getDate()} ${weekdays[d.getDay()]}`
});
}
this.setData({ availableDates: dates });
},
async loadResources() {
const db = wx.cloud.database();
// 查询可提供该服务的资源
const { data } = await db.collection('resources')
.where({
serviceIds: db.command.all([this.data.service._id]),
status: 'active'
})
.get();
this.setData({ resources: data });
if (data.length > 0) {
this.setData({ selectedResource: data[0]._id });
}
},
async loadTimeSlots() {
if (!this.data.selectedResource || !this.data.selectedDate) return;
this.setData({ loading: true });
const db = wx.cloud.database();
const { data } = await db.collection('time_slots')
.where({
resourceId: this.data.selectedResource,
date: this.data.selectedDate,
isAvailable: true,
remain: db.command.gt(0)
})
.orderBy('startTime', 'asc')
.get();
this.setData({ timeSlots: data, loading: false });
},
selectDate(e) {
const { date } = e.currentTarget.dataset;
this.setData({
selectedDate: date,
selectedSlot: null
});
this.loadTimeSlots();
},
selectSlot(e) {
const { id } = e.currentTarget.dataset;
this.setData({ selectedSlot: id });
}
});4.2.3 时段冲突检测算法
预约系统需要检测时段冲突,确保同一资源在同一时间不会被重复预约。
// cloudfunctions/checkTimeConflict/index.js
const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
const db = cloud.database();
const _ = db.command;
/**
* 时段冲突检测算法
*
* 时段重叠判定:两个时段 [A_start, A_end] 和 [B_start, B_end]
* 当且仅当 A_start < B_end 且 A_end > B_start 时,两个时段重叠
*
* 时间段分段示意:
* 现有预约: [----已预约----]
* 可预约: [A] [B] [C]
* 冲突: [---D---] [---E---]
*/
exports.main = async (event) => {
const { resourceId, date, startTime, endTime, excludeAppointmentId } = event;
try {
// 查询该资源在指定日期已有的预约(排除自身)
const condition = {
resourceId,
date,
status: _.in(['pending', 'confirmed']),
startTime: _.lt(endTime), // 已有预约开始时间 < 新预约结束时间
endTime: _.gt(startTime) // 已有预约结束时间 > 新预约开始时间
};
if (excludeAppointmentId) {
condition._id = _.neq(excludeAppointmentId);
}
const { data: conflicts } = await db.collection('appointments')
.where(condition)
.get();
if (conflicts.length > 0) {
// 存在冲突,返回冲突信息
return {
hasConflict: true,
conflicts: conflicts.map(c => ({
id: c._id,
timeRange: `${c.startTime}-${c.endTime}`,
serviceName: c.serviceName
}))
};
}
return { hasConflict: false, conflicts: [] };
} catch (err) {
console.error('时段冲突检测失败:', err);
return { hasConflict: false, conflicts: [], error: err.message };
}
};冲突检测算法说明:
两个时间段 [A_start, A_end] 和 [B_start, B_end] 重叠的条件是:
A_start < B_end AND A_end > B_start在云数据库中的查询实现:
// 查询与指定时间段重叠的所有现有预约
db.collection('appointments').where({
resourceId: resourceId,
date: date,
status: _.in(['pending', 'confirmed']), // 只考虑有效状态的预约
startTime: _.lt(endTime),
endTime: _.gt(startTime)
})此算法可覆盖所有时段重叠情况:
- 完全包含:A 在 B 内部 → A_start > B_start AND A_end < B_end
- 部分重叠(前):A 开始早于 B,结束在 B 内部 → A_start < B_start AND A_end > B_start
- 部分重叠(后):A 开始于 B 内部,结束晚于 B → A_start < B_end AND A_end > B_end
- 完全相等:A_start = B_start AND A_end = B_end
4.2.4 填写信息与提交
<!-- pages/booking/booking.wxml 表单部分 -->
<form catchsubmit="onSubmit">
<view class="card">
<view class="form-item">
<text class="label">联系人</text>
<input
name="contactName"
placeholder="请输入联系人姓名"
value="{{ formData.contactName }}"
bindinput="onInputChange"
data-field="contactName"
/>
</view>
<view class="form-item">
<text class="label">手机号</text>
<input
name="contactPhone"
type="number"
maxlength="11"
placeholder="请输入手机号"
value="{{ formData.contactPhone }}"
bindinput="onInputChange"
data-field="contactPhone"
/>
</view>
<view class="form-item">
<text class="label">备注</text>
<textarea
name="remark"
placeholder="如有特殊需求请在此备注"
value="{{ formData.remark }}"
bindinput="onInputChange"
data-field="remark"
/>
</view>
</view>
<button
class="btn-primary submit-btn"
formType="submit"
disabled="{{ !canSubmit }}"
loading="{{ submitting }}"
>
提交预约
</button>
</form>Page({
data: {
formData: {
contactName: '',
contactPhone: '',
remark: ''
},
canSubmit: false,
submitting: false
},
onLoad() {
// 尝试填充用户信息
const userInfo = wx.getStorageSync('userInfo');
if (userInfo) {
this.setData({
'formData.contactName': userInfo.nickName || '',
'formData.contactPhone': userInfo.phone || ''
});
}
},
onInputChange(e) {
const { field } = e.currentTarget.dataset;
const { value } = e.detail;
this.setData({
[`formData.${field}`]: value
}, () => {
this.validateForm();
});
},
validateForm() {
const { contactName, contactPhone, selectedSlot } = this.data;
const valid = contactName.trim().length > 0
&& /^1\d{10}$/.test(contactPhone)
&& selectedSlot !== null;
this.setData({ canSubmit: valid });
},
async onSubmit(e) {
if (!this.data.canSubmit || this.data.submitting) return;
// 请求订阅消息授权
const tmplIds = ['预约成功通知模板ID', '状态变更通知模板ID'];
const subscribeRes = await wx.requestSubscribeMessage({
tmplIds
});
this.setData({ submitting: true });
try {
const result = await wx.cloud.callFunction({
name: 'createAppointment',
data: {
serviceId: this.data.service._id,
serviceName: this.data.service.name,
resourceId: this.data.selectedResource,
date: this.data.selectedDate,
timeSlotId: this.data.selectedSlot,
...this.data.formData
}
});
if (result.result.code === 0) {
wx.showToast({ title: '预约提交成功', icon: 'success' });
wx.redirectTo({
url: `/pages/order/detail?id=${result.result.data.appointmentId}`
});
} else {
wx.showToast({ title: result.result.message, icon: 'none' });
}
} catch (err) {
wx.showToast({ title: '提交失败,请重试', icon: 'none' });
} finally {
this.setData({ submitting: false });
}
}
});4.3 预约提醒
4.3.1 订阅消息流程
微信小程序使用订阅消息(新标准)替换模板消息。订阅消息需要用户主动订阅,且每次发送前需用户确认。
用户提交预约 -> 请求订阅消息授权 -> 获取 tmplIds -> 保存订阅状态
|
管理员审核通过 -> 云函数发送审核结果通知
|
预约时间到达前 -> 云函数定时触发,发送服务提醒
|
预约完成/取消 -> 云函数发送状态变更通知4.3.2 订阅消息授权
// pages/booking/booking.js - 提交预约前请求订阅
async function requestSubscribe() {
try {
const result = await wx.requestSubscribeMessage({
tmplIds: [
'TEMPLATE_ID_APPOINTMENT_CONFIRM', // 预约确认通知
'TEMPLATE_ID_REMINDER', // 服务提醒
'TEMPLATE_ID_STATUS_CHANGE' // 状态变更通知
]
});
// result 中每个模板 ID 对应的值为 'accept' 或 'reject' 或 'ban'
// 保存用户的订阅选择,供后续业务参考
const subscribeMap = {};
for (const tmplId of tmplIds) {
subscribeMap[tmplId] = result[tmplId] === 'accept';
}
wx.setStorageSync('subscribe_prefs', subscribeMap);
} catch (err) {
// 用户拒绝授权或系统错误,不影响正常预约流程
console.warn('订阅消息授权失败:', err);
}
}4.3.3 云函数发送订阅消息
// cloudfunctions/notifyAppointment/index.js
const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
exports.main = async (event) => {
const { type, appointmentId } = event;
const db = cloud.database();
try {
// 查询预约信息
const appointmentRes = await db.collection('appointments')
.doc(appointmentId)
.get();
const appointment = appointmentRes.data;
if (!appointment) return { code: -1, message: '预约不存在' };
const { _openid, serviceName, date, timeRange, status } = appointment;
let templateId, data, page;
switch (type) {
case 'confirm':
// 预约确认通知
templateId = 'TEMPLATE_ID_APPOINTMENT_CONFIRM';
data = {
thing1: { value: serviceName },
date2: { value: `${date} ${timeRange}` },
phrase3: { value: '已确认' },
thing4: { value: '请按时到达,如有变动请提前取消' }
};
page = `pages/order/detail?id=${appointmentId}`;
break;
case 'reminder':
// 服务提醒(预约时间前 1 小时发送)
templateId = 'TEMPLATE_ID_REMINDER';
data = {
thing1: { value: serviceName },
date2: { value: `${date} ${timeRange}` },
thing3: { value: '您有预约即将开始,请准时到达' }
};
page = `pages/order/detail?id=${appointmentId}`;
break;
case 'cancel':
// 状态变更通知
templateId = 'TEMPLATE_ID_STATUS_CHANGE';
data = {
thing1: { value: serviceName },
phrase2: { value: status === 'cancelled' ? '已取消' : '已完成' },
time3: { value: new Date().toLocaleString() }
};
page = `pages/order/order`;
break;
default:
return { code: -1, message: '未知通知类型' };
}
const result = await cloud.openapi.subscribeMessage.send({
touser: _openid,
templateId,
page,
data,
miniprogramState: 'formal'
});
return { code: 0, data: result };
} catch (err) {
console.error('发送订阅消息失败:', err);
return { code: -1, message: err.message };
}
};4.3.4 定时任务触发提醒(云函数定时触发器)
// cloudfunctions/appointmentReminder/config.json
{
"triggers": [
{
"name": "reminderTrigger",
"type": "timer",
"config": "0 */30 * * * * *" // 每 30 分钟执行一次
}
]
}// cloudfunctions/appointmentReminder/index.js
const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
const db = cloud.database();
const _ = db.command;
exports.main = async (event, context) => {
// 查找未来 1 小时到 1 小时 30 分之间开始且尚未发送提醒的预约
const now = new Date();
const hourLater = new Date(now.getTime() + 60 * 60 * 1000);
const hourHalfLater = new Date(now.getTime() + 90 * 60 * 1000);
const todayStr = now.toISOString().split('T')[0];
try {
const { data: appointments } = await db.collection('appointments')
.where({
status: 'confirmed',
date: todayStr,
startTime: _.gte(hourLater).and(_.lt(hourHalfLater)),
reminded: _.neq(true)
})
.get();
const results = [];
for (const appointment of appointments) {
try {
await cloud.openapi.subscribeMessage.send({
touser: appointment._openid,
templateId: 'TEMPLATE_ID_REMINDER',
page: `pages/order/detail?id=${appointment._id}`,
data: {
thing1: { value: appointment.serviceName },
date2: { value: `${appointment.date} ${appointment.timeRange}` },
thing3: { value: '您有预约即将开始,请准时到达' }
}
});
// 标记已提醒
await db.collection('appointments')
.doc(appointment._id)
.update({ data: { reminded: true, updateTime: db.serverDate() } });
results.push({ id: appointment._id, status: 'sent' });
} catch (err) {
results.push({ id: appointment._id, status: 'failed', error: err.message });
}
}
return { code: 0, data: { total: appointments.length, results } };
} catch (err) {
console.error('提醒任务执行失败:', err);
return { code: -1, message: err.message };
}
};4.4 模板消息(旧版,兼容历史系统)
对于仍在使用模板消息的历史系统,调用方式如下:
// 云函数发送模板消息(需获取 access_token)
async function sendTemplateMessage(openid, templateId, formId, data) {
const accessToken = await getAccessToken();
return await cloud.openapi.templateMessage.send({
touser: openid,
template_id: templateId,
form_id: formId, // 通过表单提交或支付获取的 formId
page: 'pages/order/order',
data: {
keyword1: { value: '预约确认' },
keyword2: { value: '2026-07-10 14:00' },
keyword3: { value: '理发服务' }
},
emphasis_keyword: 'keyword1.DATA'
});
}5. 后台管理
5.1 预约规则配置
5.1.1 配置数据模型
// 云数据库集合: booking_config
{
_id: String, // 配置 ID
shopId: String, // 门店 ID
// 可预约天数
maxAdvanceDays: Number, // 最大提前预约天数(默认 7)
minAdvanceHours: Number, // 最少提前预约小时数(默认 2)
closeHour: Number, // 当天预约截止时间(默认 22,即 22 点后不可约当天)
// 时段设置
slotDuration: Number, // 时段长度(分钟,默认 60)
slotInterval: Number, // 时段间隔(分钟,默认 0)
workStartTime: String, // 营业开始时间(HH:mm)
workEndTime: String, // 营业结束时间(HH:mm)
// 人数上限
maxBookingPerSlot: Number, // 每个时段最大预约人数
maxBookingPerUser: Number, // 每用户每天最大预约数
maxBookingPerDay: Number, // 每天总预约数上限
// 限制规则
allowMultiService: Boolean, // 是否允许一次预约多个服务
allowCancelBefore: Number, // 最晚取消时间(小时,预约前 X 小时可取消)
requireVerify: Boolean, // 是否需要审核
autoConfirmAfter: Number, // 自动确认时间(小时,-1 表示不自动确认)
// 特殊日期
holidays: [{ // 节假日休息设置
date: String, // 日期
isWorkDay: Boolean, // 是否上班
workStart: String, // 特殊上班时间
workEnd: String // 特殊下班时间
}],
updateTime: Date
}5.1.2 时段生成工具
// cloudfunctions/generateTimeSlots/index.js
const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
const db = cloud.database();
const _ = db.command;
/**
* 根据预约规则配置,生成某一天的可用时段
*/
exports.main = async (event) => {
const { date, configId } = event;
try {
// 获取配置
const configRes = await db.collection('booking_config').doc(configId).get();
const config = configRes.data;
if (!config) return { code: -1, message: '配置不存在' };
// 解析配置
const {
slotDuration = 60,
slotInterval = 0,
workStartTime = '09:00',
workEndTime = '22:00',
maxBookingPerSlot = 1
} = config;
// 检查是否为特殊日期
const holiday = config.holidays?.find(h => h.date === date);
const actualStart = holiday?.isWorkDay !== false
? (holiday?.workStart || workStartTime)
: null;
// 如果节假日休息,返回空时段
if (actualStart === null) {
return { code: 0, data: { date, slots: [] } };
}
const actualEnd = holiday?.workEnd || workEndTime;
// 生成时段
const slots = [];
const startMinutes = timeToMinutes(actualStart);
const endMinutes = timeToMinutes(actualEnd);
const interval = slotDuration + slotInterval;
for (let m = startMinutes; m + slotDuration <= endMinutes; m += interval) {
const startH = Math.floor(m / 60);
const startM = m % 60;
const endM = m + slotDuration;
const endH = Math.floor(endM / 60);
const endMin = endM % 60;
const startTime = `${pad(startH)}:${pad(startM)}`;
const endTime = `${pad(endH)}:${pad(endMin)}`;
slots.push({
resourceId: event.resourceId || 'default',
date,
startTime,
endTime,
timeRange: `${startTime}-${endTime}`,
capacity: maxBookingPerSlot,
used: 0,
remain: maxBookingPerSlot,
isAvailable: true,
isSpecial: !!holiday,
createTime: db.serverDate(),
updateTime: db.serverDate()
});
}
// 批量写入时段表
if (slots.length > 0) {
// 先清除该日期已有时段
await db.collection('time_slots').where({
date,
resourceId: event.resourceId || 'default'
}).remove();
// 批量插入
const batch = db.collection('time_slots');
const batchSize = 20;
for (let i = 0; i < slots.length; i += batchSize) {
const batchSlots = slots.slice(i, i + batchSize);
await batch.add({ data: batchSlots });
}
}
return { code: 0, data: { date, total: slots.length } };
} catch (err) {
console.error('生成时段失败:', err);
return { code: -1, message: err.message };
}
};
// 辅助函数
function timeToMinutes(time) {
const [h, m] = time.split(':').map(Number);
return h * 60 + m;
}
function pad(n) {
return String(n).padStart(2, '0');
}5.2 预约日历展示
5.2.1 日历组件设计
<!-- components/booking-calendar/booking-calendar.wxml -->
<view class="calendar">
<view class="calendar-header">
<view class="nav-btn" bindtap="prevMonth">‹</view>
<text class="month-label">{{ year }}年{{ month }}月</text>
<view class="nav-btn" bindtap="nextMonth">›</view>
</view>
<view class="weekday-row">
<view wx:for="{{ weekdays }}" wx:key="index" class="weekday">{{ item }}</view>
</view>
<view class="day-grid">
<view
wx:for="{{ days }}"
wx:key="index"
class="day-cell {{ item.className }}"
data-date="{{ item.date }}"
bindtap="onSelectDay"
>
<text class="day-number">{{ item.day }}</text>
<text class="day-status {{ item.statusClass }}">
{{ item.statusText }}
</text>
</view>
</view>
</view>// components/booking-calendar/booking-calendar.js
Component({
properties: {
// 可预约日期数据
bookingData: {
type: Array,
value: [],
observer: 'updateCalendar'
},
selectedDate: {
type: String,
value: '',
observer: 'updateSelected'
}
},
data: {
year: 0,
month: 0,
weekdays: ['日', '一', '二', '三', '四', '五', '六'],
days: [],
dateMap: {} // 用于快速查找日期状态
},
lifetimes: {
attached() {
const now = new Date();
this.setData({
year: now.getFullYear(),
month: now.getMonth() + 1
});
this.renderDays();
}
},
methods: {
prevMonth() {
let { year, month } = this.data;
if (month === 1) {
year--;
month = 12;
} else {
month--;
}
this.setData({ year, month });
this.renderDays();
},
nextMonth() {
let { year, month } = this.data;
if (month === 12) {
year++;
month = 1;
} else {
month++;
}
this.setData({ year, month });
this.renderDays();
},
renderDays() {
const { year, month, bookingData } = this.data;
const firstDay = new Date(year, month - 1, 1).getDay();
const daysInMonth = new Date(year, month, 0).getDate();
// 构建日期状态映射
const dateMap = {};
bookingData.forEach(item => {
dateMap[item.date] = item;
});
const today = new Date();
const todayStr = today.toISOString().split('T')[0];
const days = [];
// 上月填充
const prevMonthDays = new Date(year, month - 1, 0).getDate();
for (let i = firstDay - 1; i >= 0; i--) {
days.push({
day: prevMonthDays - i,
date: '',
className: 'other-month',
statusText: '',
statusClass: ''
});
}
// 本月日期
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const dayData = dateMap[dateStr];
const isToday = dateStr === todayStr;
const isPast = dateStr < todayStr;
let className = '';
let statusText = '';
let statusClass = '';
if (isToday) {
className = 'today';
statusText = '今天';
statusClass = 'today-status';
} else if (isPast) {
className = 'past';
statusText = '已过期';
statusClass = 'past-status';
} else if (dayData) {
if (dayData.available) {
className = 'available';
statusText = '可约';
statusClass = 'avail-status';
} else {
className = 'full';
statusText = '已满';
statusClass = 'full-status';
}
} else {
className = 'unavailable';
statusText = '休息';
statusClass = 'rest-status';
}
days.push({
day: d,
date: dateStr,
className,
statusText,
statusClass
});
}
this.setData({ days, dateMap });
},
onSelectDay(e) {
const { date } = e.currentTarget.dataset;
if (!date) return;
this.triggerEvent('select', { date });
},
updateCalendar() {
this.renderDays();
},
updateSelected() {
// 可以在这里添加选中状态相关逻辑
}
}
});
#### 5.2.2 日历组件样式
```css
/* components/booking-calendar/booking-calendar.wxss */
.calendar {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #eee;
}
.nav-btn {
width: 60rpx;
height: 60rpx;
line-height: 60rpx;
text-align: center;
font-size: 40rpx;
color: #333;
}
.month-label {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.weekday-row {
display: flex;
padding: 20rpx 0;
}
.weekday {
flex: 1;
text-align: center;
font-size: 26rpx;
color: #999;
}
.day-grid {
display: flex;
flex-wrap: wrap;
}
.day-cell {
width: calc(100% / 7);
padding: 16rpx 0;
text-align: center;
position: relative;
}
.day-cell.other-month .day-number {
color: #ccc;
}
.day-cell.today .day-number {
color: #07c160;
font-weight: 700;
}
.day-cell.past .day-number {
color: #ccc;
}
.day-cell.available .day-number {
color: #333;
font-weight: 500;
}
.day-cell.available:active {
background: #f0faf4;
border-radius: 8rpx;
}
.day-cell.full .day-number {
color: #999;
}
.day-cell.unavailable .day-number {
color: #ccc;
}
.day-number {
font-size: 30rpx;
line-height: 1.4;
}
.day-status {
font-size: 18rpx;
display: block;
line-height: 1.2;
}
.today-status {
color: #07c160;
}
.past-status {
color: #ccc;
}
.avail-status {
color: #07c160;
}
.full-status {
color: #ff6b6b;
}
.rest-status {
color: #ccc;
}
/* 选中状态 */
.day-cell.selected {
background: #07c160;
border-radius: 12rpx;
}
.day-cell.selected .day-number,
.day-cell.selected .day-status {
color: #fff;
}5.3 核销流程
核销是预约完成后,用户到店/到现场后,商家通过核销码确认消费的流程。
生成核销码(预约确认时) -> 用户出示核销码 -> 商家扫码核销 -> 状态变更 -> 记录核销信息5.3.1 核销码生成
核销码需要具备唯一性和时效性,推荐使用加密方式生成:
// cloudfunctions/generateVerifyCode/index.js
const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
const crypto = require('crypto');
/**
* 生成核销码
*
* 核销码格式:6-8 位数字或字母组合
* 生成策略:对 appointmentId + 时间戳 + 密钥进行 HmacSHA256 签名,取前 8 位
*/
exports.main = async (event) => {
const { appointmentId } = event;
// 生成核销码(基于 HMAC 签名)
const secret = 'booking_secret_key'; // 实际应使用环境变量
const timestamp = Date.now().toString(36);
const raw = `${appointmentId}_${timestamp}`;
// 取 HMAC 结果的前 6 位作为核销码
const hmac = crypto.createHmac('sha256', secret);
hmac.update(raw);
const code = hmac.digest('hex').substring(0, 6).toUpperCase();
// 确保唯一性(如果冲突则重新生成)
const db = cloud.database();
const existing = await db.collection('appointments')
.where({ verifyCode: code, status: _.in(['confirmed', 'completed']) })
.get();
if (existing.data.length > 0) {
// 极小概率冲突,使用更长的 code 并重试
const newHmac = crypto.createHmac('sha256', `${secret}_${timestamp}`);
newHmac.update(raw);
const newCode = newHmac.digest('hex').substring(0, 8).toUpperCase();
return { code: 0, data: { verifyCode: newCode, timestamp } };
}
return { code: 0, data: { verifyCode: code, timestamp } };
};在预约确认时调用核销码生成:
// cloudfunctions/confirmAppointment/index.js
const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
const db = cloud.database();
const _ = db.command;
exports.main = async (event) => {
const { appointmentId, adminId } = event;
try {
// 生成核销码
const verifyRes = await cloud.callFunction({
name: 'generateVerifyCode',
data: { appointmentId }
});
if (verifyRes.result.code !== 0) {
return { code: -1, message: '核销码生成失败' };
}
const { verifyCode } = verifyRes.result.data;
// 更新预约状态为已确认,并记录核销码
await db.collection('appointments')
.doc(appointmentId)
.update({
data: {
status: 'confirmed',
verifyCode,
verifyCreateTime: db.serverDate(),
confirmBy: adminId,
confirmTime: db.serverDate(),
updateTime: db.serverDate()
}
});
return { code: 0, data: { appointmentId, verifyCode } };
} catch (err) {
console.error('确认预约失败:', err);
return { code: -1, message: err.message };
}
};5.3.2 用户端核销码展示
<!-- pages/order/detail.wxml 核销码部分 -->
<view class="verify-section">
<view class="verify-card" wx:if="{{ appointment.status === 'confirmed' }}">
<view class="verify-title">核销码</view>
<view class="verify-code">{{ appointment.verifyCode }}</view>
<view class="verify-hint">到店后请出示此核销码给工作人员</view>
<view class="verify-qr">
<image src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data={{ appointment.verifyCode }}" mode="aspectFit"></image>
</view>
<view class="verify-timer">
<text>核销码有效期:{{ appointment.date }} {{ appointment.timeRange }}</text>
</view>
</view>
<view class="verify-result" wx:elif="{{ appointment.status === 'completed' }}">
<view class="verify-success-icon">核销成功</view>
<view class="verify-info">
<text>核销时间:{{ appointment.verifyTime }}</text>
<text>核销门店:{{ appointment.shopName }}</text>
<text>核销人员:{{ appointment.verifyByName }}</text>
</view>
</view>
</view>/* pages/order/detail.wxss */
.verify-section {
margin: 30rpx;
}
.verify-card {
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 20rpx;
padding: 40rpx;
text-align: center;
color: #fff;
}
.verify-title {
font-size: 28rpx;
opacity: 0.9;
margin-bottom: 20rpx;
}
.verify-code {
font-size: 72rpx;
font-weight: 700;
letter-spacing: 12rpx;
font-family: 'Courier New', monospace;
margin-bottom: 16rpx;
}
.verify-hint {
font-size: 24rpx;
opacity: 0.8;
margin-bottom: 30rpx;
}
.verify-qr {
width: 300rpx;
height: 300rpx;
margin: 0 auto 30rpx;
background: #fff;
padding: 20rpx;
border-radius: 16rpx;
}
.verify-qr image {
width: 100%;
height: 100%;
}
.verify-timer {
font-size: 22rpx;
opacity: 0.7;
}
.verify-result {
background: #fff;
border-radius: 20rpx;
padding: 40rpx;
text-align: center;
}
.verify-success-icon {
font-size: 36rpx;
color: #07c160;
font-weight: 600;
margin-bottom: 30rpx;
}
.verify-info text {
display: block;
font-size: 28rpx;
color: #666;
line-height: 1.8;
}5.3.3 扫码核销
// pages/admin/verify/verify.js
Page({
data: {
scanResult: null,
appointment: null,
verifying: false
},
// 扫码入口
onScan() {
wx.scanCode({
onlyFromCamera: false,
scanType: ['barCode', 'qrCode'],
success: (res) => {
// res.result 为扫码得到的核销码
this.lookupAppointment(res.result);
},
fail: (err) => {
wx.showToast({ title: '扫码失败', icon: 'none' });
}
});
},
// 手动输入核销码
onManualInput(e) {
const code = e.detail.value.trim().toUpperCase();
if (code.length >= 6) {
this.lookupAppointment(code);
}
},
// 查询预约信息
async lookupAppointment(code) {
wx.showLoading({ title: '查询中...' });
try {
const db = wx.cloud.database();
const { data } = await db.collection('appointments')
.where({
verifyCode: code,
status: 'confirmed' // 只有已确认状态才能核销
})
.get();
wx.hideLoading();
if (data.length === 0) {
wx.showModal({
title: '核销码无效',
content: '未找到对应的有效预约,请确认核销码是否正确或预约是否已核销',
showCancel: false
});
return;
}
this.setData({
scanResult: code,
appointment: data[0]
});
} catch (err) {
wx.hideLoading();
wx.showToast({ title: '查询失败', icon: 'none' });
}
},
// 确认核销
async onConfirmVerify() {
if (this.data.verifying || !this.data.appointment) return;
wx.showModal({
title: '确认核销',
content: `确认对 ${this.data.appointment.serviceName} 进行核销操作?`,
success: async (res) => {
if (res.confirm) {
this.setData({ verifying: true });
await this.doVerify();
}
}
});
},
async doVerify() {
wx.showLoading({ title: '核销中...' });
try {
const result = await wx.cloud.callFunction({
name: 'verifyAppointment',
data: {
appointmentId: this.data.appointment._id,
verifyCode: this.data.scanResult,
verifyBy: 'admin'
}
});
wx.hideLoading();
if (result.result.code === 0) {
wx.showToast({ title: '核销成功', icon: 'success' });
this.setData({
scanResult: null,
appointment: null,
verifying: false
});
} else {
wx.showToast({ title: result.result.message || '核销失败', icon: 'none' });
this.setData({ verifying: false });
}
} catch (err) {
wx.hideLoading();
wx.showToast({ title: '核销异常', icon: 'none' });
this.setData({ verifying: false });
}
},
// 取消核销
onCancel() {
this.setData({
scanResult: null,
appointment: null
});
}
});5.3.4 核销云函数
// cloudfunctions/verifyAppointment/index.js
const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
const db = cloud.database();
const _ = db.command;
exports.main = async (event) => {
const { appointmentId, verifyCode, verifyBy } = event;
try {
// 查询预约
const appointmentRes = await db.collection('appointments')
.doc(appointmentId)
.get();
const appointment = appointmentRes.data;
if (!appointment) {
return { code: -1, message: '预约不存在' };
}
// 核验状态
if (appointment.status !== 'confirmed') {
return { code: -1, message: `当前预约状态为"${appointment.status}",无法核销` };
}
// 核验核销码
if (appointment.verifyCode !== verifyCode) {
return { code: -1, message: '核销码不匹配' };
}
// 核验日期是否过期
const today = new Date();
const appointmentDate = new Date(appointment.date + 'T' + (appointment.endTime || '23:59'));
if (today > appointmentDate) {
// 过期预约自动标记为过期
await db.collection('appointments')
.doc(appointmentId)
.update({
data: {
status: 'expired',
updateTime: db.serverDate()
}
});
return { code: -1, message: '预约已过期,无法核销' };
}
// 执行核销
await db.collection('appointments')
.doc(appointmentId)
.update({
data: {
status: 'completed',
verifyTime: db.serverDate(),
verifyBy,
updateTime: db.serverDate()
}
});
// 发送核销完成通知
try {
await cloud.openapi.subscribeMessage.send({
touser: appointment._openid,
templateId: 'TEMPLATE_ID_STATUS_CHANGE',
page: `pages/order/detail?id=${appointmentId}`,
data: {
thing1: { value: appointment.serviceName },
phrase2: { value: '已完成' },
time3: { value: new Date().toLocaleString() }
}
});
} catch (msgErr) {
console.error('发送核销通知失败:', msgErr);
}
return { code: 0, data: { appointmentId, status: 'completed' } };
} catch (err) {
console.error('核销失败:', err);
return { code: -1, message: '核销操作失败' };
}
};5.3.5 核销记录查询
// pages/admin/verify/history.js
Page({
data: {
records: [],
page: 1,
pageSize: 20,
hasMore: true,
dateFilter: '',
statusFilter: ''
},
async onLoad() {
this.loadRecords();
},
async loadRecords() {
const db = wx.cloud.database();
const _ = db.command;
const condition = {};
// 筛选条件
if (this.data.dateFilter) {
condition.date = this.data.dateFilter;
}
if (this.data.statusFilter) {
condition.status = this.data.statusFilter;
}
const { data } = await db.collection('appointments')
.where(condition)
.orderBy('updateTime', 'desc')
.skip((this.data.page - 1) * this.data.pageSize)
.limit(this.data.pageSize)
.get();
this.setData({
records: [...this.data.records, ...data],
hasMore: data.length === this.data.pageSize
});
},
// 核销统计
async loadStatistics() {
const db = wx.cloud.database();
const $ = db.command.aggregate;
const { list } = await db.collection('appointments')
.aggregate()
.match({
status: 'completed',
verifyTime: $.gte(new Date(new Date().setHours(0, 0, 0, 0)))
})
.group({
_id: null,
total: $.sum(1)
})
.end();
return list[0]?.total || 0;
}
});5.3.6 状态变更流程总结
预约单的完整状态机如下:
+---> cancelled (用户取消)
|
pending (待审核) ----> confirmed (已确认/待核销) ----> completed (已完成)
| |
+---> expired (自动过期) +---> refunded (已退款)
|
+---> cancelled (核销前取消)各状态变更的触发条件:
| 状态变更 | 触发条件 | 执行方 | 通知 |
|---|---|---|---|
| pending -> confirmed | 管理员审核通过 / 自动确认定时任务 | 后台 / 系统 | 订阅消息通知用户 |
| pending -> cancelled | 用户在可取消时间内取消 | 用户 | 无(用户主动操作) |
| pending -> expired | 超过预约日期未处理 | 系统定时任务 | 无需通知 |
| confirmed -> completed | 商家扫码核销 | 商家 | 发送完成通知 |
| confirmed -> cancelled | 用户在预约前取消 / 管理员取消 | 用户 / 管理员 | 发送取消通知 |
| confirmed -> expired | 超过预约日期未核销 | 系统定时任务 | 发送过期通知 |
| completed -> refunded | 管理员执行退款 | 管理员 | 发送退款通知 |