约课小程序增加候补功能

先前写了一个约课小程序,最近想增加候补功能。举个例子,课程容量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分支,完整逻辑看源码

相关推荐
西西西西胡萝卜鸡1 小时前
徽标(Badge)的实现与优化铁壁猿版(简易版)
前端
Never_Satisfied1 小时前
在JavaScript / 微信小程序中,动态修改页面元素的方法
开发语言·javascript·微信小程序
王大宇_1 小时前
虚拟列表从入门到出门
前端·javascript
程序猿小蒜1 小时前
基于springboot的人口老龄化社区服务与管理平台
java·前端·spring boot·后端·spring
Coder-coco2 小时前
个人健康系统|健康管理|基于java+Android+微信小程序的个人健康系统设计与实现(源码+数据库+文档)
android·java·vue.js·spring boot·微信小程序·论文·个人健康系统
用户21411832636022 小时前
Google Nano Banana Pro图像生成王者归来
前端
文心快码BaiduComate2 小时前
下周感恩节!文心快码助力感恩节抽奖页快速开发
前端·后端·程序员
_小九2 小时前
【开源】耗时数月、我开发了一款功能全面的AI图床
前端·后端·图片资源
恋猫de小郭2 小时前
聊一聊 Gemini3、 AntiGravity 和 Nano Banana Pro 的体验和问题
前端·aigc·gemini