先前写了一个约课小程序,最近想增加候补功能。举个例子,课程容量20人,预约满了后,用户可以选择候补,加入候补队列。一旦有人取消预约,系统将自动按候补顺序递补名额。
我们先梳理下候补要修改的逻辑
| 功能模块 | 详细说明 | 关键点 |
|---|---|---|
| ✅ 按钮状态智能判断 | 根据课程状态和用户预约情况动态显示按钮 | 预约 :课程未满员 候补 :课程已满员(确认预约 >= 容量) 取消预约 :用户已正式预约 取消候补(第X位):用户在候补列表中 |
| ✅ 候补队列管理 | 完整的候补排队机制 | 按预约时间顺序自动排队 显示候补位置(第1位、第2位...) 候补用户不消耗次数 |
| ✅ 自动提升机制 | 候补自动转正式预约流程 | 有人取消正式预约时触发 候补第一位自动转为确认预约 自动扣除该用户的次数 其他候补用户位置自动前移 |
| ✅ 数据结构变更 | reservations 表添加新字段 | status: "confirmed" | "waitlist"(预约状态) waitlist_position: Number(候补位置,仅候补时有值) |
我们想要的效果,如果课程预约已满,那么就显示候补按钮;如果已经成功候补,那么就显示候补中,并且告知用户候补排名

我们主要分为以下几部分
- 修改用户预约表: 新增预约状态、候补位置字段
- 修改后端: 预约/取消预约逻辑; 新增/取消候补 候补位置
- 修改前端: 显示候补、已候补按钮 候补排名
修改用户预约表: 新增预约状态、候补位置字段
这是我们的user-reserve表结构,user-reserve.schema.json文件,我们需要增加2个字段status, waitlist_position
json
{
"bsonType": "object",
"required": [],
"permission": {
"read": true,
"create": true,
"update": true,
"delete": true
},
"properties": {
"_id": {
"description": "ID,系统自动生成"
},
...
"status": {
"bsonType": "string",
"title": "预约状态",
"description": "预约状态:confirmed(已确认) / waitlist(候补)",
"enum": ["confirmed", "waitlist"],
"defaultValues": "confirmed"
},
"waitlist_position": {
"bsonType": "int",
"title": "候补位置",
"description": "如果是候补状态,记录候补顺序位置"
}
}
}
后端预约的逻辑
- 查询预约那节课的可容纳人数 : 因为我们现在要判断用户是否需要候补,我们需要获取课程的
capacity,每节课的可容纳人数,去查询class-schedule表,如下面代码,我们查询后获取capacity字段
ini
const classInfo = await db
.collection("class-schedule")
.doc(queryClassId)
.get();
if (!classInfo.data.length) {
return {
code: 404,
message: "未找到课程信息",
};
}
uniCloud.logger.info("classInfo", classInfo);
const classData = classInfo.data[0];
const classCapacity = classData.capacity || 10;
uniCloud.logger.info("params", queryClassId, clickDate);
- 查询预约那节课的已预约人数 : 我们查询
user-reserve用户预约表,因为我们的课表是每周周而复始的,不同星期同一时间段的课表ID是一样的,所以我们查询的时候,要加上日期(上课的那天),这样就查不出历史上课数据了,免得受历史数据影响,而且我们设置了查询上课那天的一整天,这样也不会漏数据了
js
// 传给bookCourse方法的参数
const { userId, avatar, queryClassId, clickDate, cardId, cardType } =
params;
const db = uniCloud.database();
const _ = db.command;
// 获取当天的起始时间 clickDate就是上课那天(预约时候点击日历对应的那天)
const startOfDay = new Date(clickDate);
startOfDay.setHours(0, 0, 0, 0); // 设置为当天 00:00:00.000
// 获取当天的结束时间
const endOfDay = new Date(clickDate);
endOfDay.setHours(23, 59, 59, 999); // 设置为当天 23:59:59.999
const confirmedCount = (
await db
.collection("user-reserve")
.where({
class_id: queryClassId,
reserve_class_date: _.gte(startOfDay).and(db.command.lte(endOfDay)),
canceled: false,
status: "confirmed",
})
.count()
).total;
- 查询用户是否已预约: 可能先前取消过,再次预约,数据库里还存有预约记录;如果先前没取消,前端逻辑没问题的话,那显示的就是取消预约、取消候补,就不会走这个逻辑
js
// 3. 检查用户是否已有预约
const existingReservation = await db
.collection("user-reserve")
.where({
user_id: userId,
class_id: queryClassId,
reserve_class_date: _.gte(startOfDay).and(db.command.lte(endOfDay)), // 筛选上课日期今天及以后的 同一个日期不存在相同课程ID的课 不然过去预约了 现在就无法预约
})
.get();
- 查询用户是否已预约:如果用户先前预约过,我们先判断是不是需要候补,如果需要候补,我们还要计算候补排名;如果不需要候补,就是成功预约,我们用.update()更新那条用户数据,如果是次卡,我们要减去1次
ini
if (existingReservation.data.length > 0) {
const reservation = existingReservation.data[0];
if (!reservation.canceled) {
return {
code: 400,
message: "该课程已预约,无需重复预约",
};
}
// 曾取消过 → 恢复预约
const newStatus =
confirmedCount < classCapacity ? "confirmed" : "waitlist";
// 如果是候补状态,需要计算候补位置
let updateData = {
canceled: false,
reserve_class_date: clickDate,
reserve_time: Date.now(),
status: newStatus,
};
if (newStatus === "waitlist") {
// 获取当前候补队列人数
const waitlistCount = (
await db
.collection("user-reserve")
.where({
class_id: queryClassId,
reserve_class_date: _.gte(startOfDay).and(
db.command.lte(endOfDay)
),
status: "waitlist",
canceled: false,
})
.count()
).total;
updateData.waitlist_position = waitlistCount + 1;
} else {
// 如果是确认状态,清空候补位置
updateData.waitlist_position = null;
}
await db
.collection("user-reserve")
.doc(reservation._id)
.update(updateData);
uniCloud.logger.info(
"恢复预约状态",
newStatus,
updateData.waitlist_position
);
// 扣减次卡(仅确认状态)
if (cardType === "sessionCard" && newStatus === "confirmed") {
await db
.collection("user-membership-card")
.doc(cardId)
.update({
remainingSessions: _.inc(-1),
});
}
return {
code: 200,
message:
newStatus === "confirmed" ? "预约成功" : "课程已满,已进入候补名单",
status: newStatus,
waitlist_position: updateData.waitlist_position, // 返回候补位置信息
};
}
如果用户先前没有预约过,那就是新预约;我们同样先判断是不是要候补,候补计算候补排名,如果不是候补,成功预约,更新次卡
js
// 4. 新预约
let newReservationData = {
user_id: userId,
class_id: queryClassId,
reserve_class_date: clickDate,
reserve_time: Date.now(),
canceled: false,
};
if (confirmedCount >= classCapacity) {
const waitlistCount = (
await db
.collection("user-reserve")
.where({
class_id: queryClassId,
reserve_class_date: _.gte(startOfDay).and(
db.command.lte(endOfDay)
),
status: "waitlist",
})
.count()
).total;
uniCloud.logger.info("waitlistCount", waitlistCount);
newReservationData.status = "waitlist";
newReservationData.waitlist_position = waitlistCount + 1;
} else {
newReservationData.status = "confirmed";
newReservationData.waitlist_position = null;
}
const addRes = await db
.collection("user-reserve")
.add(newReservationData);
// 5. 扣减次卡
if (
cardType === "sessionCard" &&
newReservationData.status === "confirmed"
) {
await db
.collection("user-membership-card")
.doc(cardId)
.update({
remainingSessions: _.inc(-1),
});
}
上面就是预约的逻辑,判断预约/候补, 逻辑好像有点冗余;因为用户点击按钮的时候,那个按钮就有文字预约/候补,直接做为参数传过来就行
我们这样相当于双保险,以防前端判断逻辑出现问题,我们后端也能校验,确保预约/候补 逻辑正确
前端按钮文案 预约/候补 取消预约/取消候补 判断
我们先回顾下我们课表逻辑,
bash
管理员创建课程模板(循环课表)
↓
每周复用这些模板
↓
用户预约时,我们有预约课程的具体日期
↓
通过 class_id + reserve_class_date 来唯一标识"某天的某节课"
每天课表的渲染逻辑
当用户点击某天的时候,我们知道某天对应是周几,我们查询课表class-schedule,根据对应的周几,就可以渲染出那天对应的排课安排
js
const fetchCourses = async () => {
try {
if (!loading) {
loading = true;
// uni.showLoading({ title: "加载中...", mask: true });
}
const res = await db.collection("class-schedule").get();
if (res.result?.errCode === 0) {
courseList.value = res.result.data || [];
} else {
uni.showToast({ title: "课程加载失败", icon: "none" });
}
} catch (error) {
uni.showToast({ title: "网络异常", icon: "none" });
} finally {
if (loading) {
loading = false;
// uni.hideLoading();
}
}
};
// 数据过滤:根据指定的星期过滤课程
const filterCoursesByDay = (day) =>
courseList.value.filter((course) => course.day === day);

我们课表卡片上有显示,预约用户列表;我们用联合查询,查询用户预约表user-reserve, user-reserve表里面的user_id字段关联users表,这样用户预约的那条记录里就有用户的详细信息了,就是说可以获得预约用户的用户头像了
判断当前用户这节课是否已预约/已候补
dayCourses.value是每一天的课程
js
// 筛选并格式化课程数据
dayCourses.value = filterCoursesByDay(targetDay).map((item) => ({
...item,
time: formatCourseTime(item.startTime, item.endTime),
isReserved: false,
reservedUsers: [],
}));
每天的课程信息,我们循环处理每节课,根据课程ID和课程日期查询每节课的预约信息
利用课程预约信息列表里用户ID是否和当前用户的ID一样,就可以判断当前用户是否已经预约/候补
js
for (let course of dayCourses.value) {
const res = await getReserveRecords(course, props.clickDate); // 获取预约用户信息
if (res.result.errCode === 0) {
if (res.result.data.length) {
const reserveRecords = res.result.data;
// 保存预约记录(包括用户信息等)
course.reserveRecords = reserveRecords;
// 因为是联合查询 所以这个user_id是一个数组
// 判断当前用户这节课是否已预约
course.isReserved = reserveRecords.some(
(item) =>
item.user_id?.[0]?._id === storedUserInfo.value.userId &&
item.status === "confirmed"
);
// 判断当前用户这节课是否已候补
course.isWaited = reserveRecords.some(
(item) =>
item.user_id?.[0]?._id === storedUserInfo.value.userId &&
item.status === "waitlist"
);
console.log("course---", course);
} else {
course.isReserved = false;
course.isWaited = false;
course.reservedUsers = [];
}
}
}
下面这个方法作用: 精准查询某节课在指定日期的所有有效预约记录(包括候补),并把用户的头像,联表带出来,按预约时间倒序排列,获取预约/候补用户列表
js
const getReserveRecords = (course, targetDate) => {
// 获取当天的起始时间
const startOfDay = new Date(targetDate);
startOfDay.setHours(0, 0, 0, 0); // 设置为当天 00:00:00.000
// 获取当天的结束时间
const endOfDay = new Date(targetDate);
endOfDay.setHours(23, 59, 59, 999); // 设置为当天 23:59:59.999
let reserveTemp = db
.collection("user-reserve")
.where({
class_id: course._id,
canceled: false, // 增加canceled为false的条件
reserve_class_date: db.command
.gte(startOfDay)
.and(db.command.lte(endOfDay)), // 日期范围查询})
})
.getTemp();
let userTemp = db.collection("users").field("_id, avatar").getTemp();
// 不能加 .limit(course.capacity) 因为还有候补的 不然导致用户预约了 不显示
return db
.collection(reserveTemp, userTemp)
.orderBy("reserve_time desc")
.get();
};
在我们的课程卡片组件,course-card.vue,我们要显示按钮文案,用户头像列表
js
// 计算确认预约的用户
const confirmedUsers = computed(() => {
return (
props.courseInfo.reserveRecords?.filter(
(record) => record.status === "confirmed"
) || []
);
});
// 计算候补用户
const waitlistUsers = computed(() => {
return (
props.courseInfo.reserveRecords?.filter(
(record) => record.status === "waitlist"
) || []
);
});
// 确认预约人数
const confirmedCount = computed(() => confirmedUsers.value.length);
// 候补人数
const waitlistCount = computed(() => waitlistUsers.value.length);
// 课程是否已满
const isFull = computed(() => {
return confirmedCount.value >= (props.courseInfo?.capacity || 20);
});
// 当前用户的预约状态 confirmed waitlist
const userReservationStatus = computed(() => {
const currentUserReserve = props.courseInfo.reserveRecords?.find(
(record) => record.user_id?.[0]?._id === userInfo.userId
);
return currentUserReserve?.status || null;
});
看下我们的课程卡片需要展示的信息,基本上都满足了
我们把按钮抽离出来了一个组件card-reserve-button.vue, 我们只需要一个view标签,来显示按钮的8种状态
Vue
<template>
<view class="reserve-btn" :class="buttonClass">
{{ buttonText }}
</view>
</template>
按钮的8种状态
js
// 按钮文本
const buttonText = computed(() => {
const textMap = {
courseCancelled: "已取消",
userConfirmed: "已预约",
userWaitlist: "候补中",
ended: "已结束",
ongoing: "进行中",
waitlistAvailable: "候补",
reserveAvailable: "预约",
notOpenYet: "暂未开放预约",
};
return textMap[buttonStatus.value] || "预约";
});
看下按钮文案显示的逻辑
js
// 当前用户的预约状态
const buttonStatus = computed(() => {
// 1. 优先判断是否已取消
if (props.isCourseCancelled) {
return "courseCancelled";
}
// 2. 判断用户预约状态
if (userReservationStatus.value === "confirmed") {
return "userConfirmed";
}
if (userReservationStatus.value === "waitlist") {
return "userWaitlist";
}
// 3. 判断时间状态
const currentMinutes = now.value.getHours() * 60 + now.value.getMinutes();
const diffDays = Math.floor(
(props.clickDate.getTime() - today.value.getTime()) / (1000 * 60 * 60 * 24)
);
if (diffDays < 0) {
return "ended";
}
if (diffDays === 0) {
if (currentMinutes > endTime) {
return "ended";
}
if (currentMinutes >= startTime && currentMinutes <= endTime) {
return "ongoing";
}
return isFull.value ? "waitlistAvailable" : "reserveAvailable";
}
if (diffDays > 0 && diffDays <= 2) {
if (diffDays === 2) {
if (now.value >= today12PM.value) {
return isFull.value ? "waitlistAvailable" : "reserveAvailable";
} else {
return "notOpenYet";
}
}
return isFull.value ? "waitlistAvailable" : "reserveAvailable";
}
return "notOpenYet";
});
代码逻辑解释
markdown
## 状态判断逻辑(按优先级)
### 1. 课程状态检查(最高优先级)
- 课程被取消 → `courseCancelled`
### 2. 用户预约状态检查
- 用户已确认预约 → `userConfirmed`
- 用户在候补名单 → `userWaitlist`
### 3. 时间相对位置检查
计算课程日期与今天的天数差 `diffDays = (课程日期 - 今天) / 天`
### 4. 根据时间位置判断
#### 4.1 过去的日期 (diffDays < 0)
→ `ended`
#### 4.2 今天的课程 (diffDays = 0)
- 当前时间 > 课程结束时间 → `ended`
- 课程开始时间 ≤ 当前时间 ≤ 课程结束时间 → `ongoing`
- 今天但还未开始 → 满员 ? `waitlistAvailable` : `reserveAvailable`
#### 4.3 明天的课程 (diffDays = 1)
→ 满员 ? `waitlistAvailable` : `reserveAvailable`
#### 4.4 后天的课程 (diffDays = 2)
- 现在时间 ≥ 今天12:00 → 满员 ? `waitlistAvailable` : `reserveAvailable`
- 现在时间 < 今天12:00 → `notOpenYet`
#### 4.5 三天后的课程 (diffDays > 2)
→ `notOpenYet`
按钮状态说明
| 状态 | 说明 | 可点击 | 优先级 |
|---|---|---|---|
courseCancelled |
课程因人数不足被取消 | (跳转详情) | 1 |
userConfirmed |
用户已确认预约 | (跳转详情) | 2 |
userWaitlist |
用户在候补名单 | (跳转详情) | 2 |
ended |
课程已结束 | (跳转详情) | 3 |
ongoing |
课程正在进行中 | (跳转详情) | 3 |
waitlistAvailable |
可以候补(满员) | ✅ (预约) | 4 |
reserveAvailable |
可以预约(有名额) | ✅ (预约) | 4 |
notOpenYet |
暂未开放预约 | (跳转详情) | 4 |
课程卡片和课程详情页的按钮不一样,比如课程卡片如果显示已预约 ,那么课程详情页就要显示取消预约 
如果有候补,要显示候补信息 
代码已经提交到waitlist分支,完整逻辑看源码