微信小程序日程预约

涉及仪器的预约使用,仿照小米日历日程预约开发开发对应页。

效果展示

文章目录


需求分析

  • 顶部七日选择器
    • 横向显示从当前日期开始后的七天,并区分月-日
    • 七天共计预约时间段综合为3
  • 中部canvas绘制区
    • 左侧时间刻度
    • 右侧绘制区,总计24格,每大格为1h,一大格后期拆分四小格,为15min
    • 右侧绘制区功能
      • 激活:单击
      • 长按:拖动激活区域移动选区,存在激活区域之间的互斥
      • 拉伸:双击后改变预约起止时间
  • 底部数据回显区
    • 显示预约时间段
    • 支持删除

代码实现

一、构建基础页面结构

1. 顶部日期选择器

获取当前日期,即六天后的所有日期,并解析出具体月-日,存入数组dateList

javascript 复制代码
 // 初始化日期列表
  initDateList() {
    const dateList = [];
    const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
    
    for (let i = 0; i < 7; i++) {
      const date = new Date();
      // 获取未来几天的日期
      date.setDate(date.getDate() + i);
      
      dateList.push({
        date: date.getTime(),
        month: date.getMonth() + 1,
        day: date.getDate(),
        weekDay: weekDays[date.getDay()]
      });
    }

    this.setData({ dateList });
  },
html 复制代码
<view 
      wx:for="{{ dateList }}" 
      wx:key="date"
      class="date-item {{ currentDateIndex === index ? 'active' : '' }}"
      bindtap="onDateSelect"
      data-index="{{ index }}"
    >
      <text class="date-text">{{ item.month }}-{{ item.day }}</text>
      <text class="week-text">{{ item.weekDay }}</text>
      <text class="today-text" wx:if="{{ index === 0 }}">今天</text>
    </view>

2. 中部canvas绘制

左侧25条数据,从0:00-24:00,只作为标志数据;【主体】右侧24格,通过canvas进行绘制。

  1. 初始化canvas,获取宽高,并通过ctx.scale(dpr,dpr)缩放canvas适应设备像素比;
  2. 绘制网格
js 复制代码
   for (let i = 0; i <= 24; i++) {
     ctx.beginPath();
     const y = i * hourHeight;
     ctx.moveTo(0, y);
     ctx.lineTo(width, y);
     ctx.stroke();
   }

3. 底部数据回显

二、中间canvas功能细分

1. 激活状态的判断

  1. 首先给canvas添加点击事件bindtouchstart="onCanvasClick"

获取点击坐标,并解析首次触摸点的位置touch[0]clientX clientY 是触摸点在屏幕上的坐标

js 复制代码
const query = wx.createSelectorQuery();
query.select('#timeGridCanvas')
  .boundingClientRect(rect => {
    const x = e.touches[0].clientX - rect.left;
    const y = e.touches[0].clientY - rect.top;
  1. 计算时间格
js 复制代码
const hourIndex = Math.floor(y / this.data.hourHeight);

hourHeight: rect.height / 24,来自于initCanvas初始化时,提前计算好的每个时间格的高度

  1. 获取选中的时间段
javascript 复制代码
const existingBlockIndex = this.data.selectedBlocks.findIndex(block => 
          hourIndex >= block.startHour && hourIndex < block.endHour
        );

使用 findIndex 查找点击位置是否在已选时间段内

  1. 取消选中逻辑
javascript 复制代码
if (existingBlockIndex !== -1) {
  // 从当前日期的选中块中移除
  const newSelectedBlocks = [...this.data.selectedBlocks];
  newSelectedBlocks.splice(existingBlockIndex, 1);
  
  // 从所有选中块中移除
  const currentDate = `${this.data.dateList[this.data.currentDateIndex].month}-${this.data.dateList[this.data.currentDateIndex].day}`;
  const allBlockIndex = this.data.allSelectedBlocks.findIndex(block => 
    block.date === currentDate && 
    block.startHour === this.data.selectedBlocks[existingBlockIndex].startHour
  );
  
  const newAllBlocks = [...this.data.allSelectedBlocks];
  if (allBlockIndex !== -1) {
    newAllBlocks.splice(allBlockIndex, 1);
  }
  
  this.setData({
    selectedBlocks: newSelectedBlocks,
    allSelectedBlocks: newAllBlocks
  });
}

同时需要考虑两个数组:当前日期选中时间段selectedBlocks,七日内选中时间段总数allSelectedBlocks

  1. 新增时间段逻辑
javascript 复制代码
else {
  // 检查限制
  if (this.data.allSelectedBlocks.length >= 3) {
    wx.showToast({
      title: '最多只能选择3个时间段',
      icon: 'none'
    });
    return;
  }

  // 添加新时间段
  const startHour = Math.floor(y / this.data.hourHeight);
  const endHour = startHour + 1;
  
  const newBlock = {
    date: `${this.data.dateList[this.data.currentDateIndex].month}-${this.data.dateList[this.data.currentDateIndex].day}`,
    startHour: startHour,
    endHour: endHour,
    startTime: this.formatTime(startHour * 60),
    endTime: this.formatTime(endHour * 60)
  };

  this.setData({
    selectedBlocks: [...this.data.selectedBlocks, newBlock],
    allSelectedBlocks: [...this.data.allSelectedBlocks, newBlock]
  });
}

先检查是否达到最大选择限制,创建新的时间段对象

javascript 复制代码
date: 当前选中的日期
startHour: 开始小时
endHour: 结束小时
startTime: 格式化后的开始时间
endTime: 格式化后的结束时间

2. 时间块拉伸逻辑

  1. 检测拉伸手柄
    为了避免和后期的长按拖动逻辑的冲突,在选中时间块上额外添加上下手柄以作区分:
javascript 复制代码
checkResizeHandle(x, y) {
  const handleSize = 16; // 手柄的点击范围大小
  
  for (let i = 0; i < this.data.selectedBlocks.length; i++) {
    const block = this.data.selectedBlocks[i];
    const startY = block.startHour * this.data.hourHeight;
    const endY = block.endHour * this.data.hourHeight;
    
    // 检查是否点击到上方手柄
    if (y >= startY - handleSize && y <= startY + handleSize) {
      return { blockIndex: i, isStart: true, position: startY };
    }
    
    // 检查是否点击到下方手柄
    if (y >= endY - handleSize && y <= endY + handleSize) {
      return { blockIndex: i, isStart: false, position: endY };
    }
  }
  return null;
}
  1. 处理拖拽拉伸逻辑
    在判断确定点击到拉伸手柄的情况下,处理逻辑
javascript 复制代码
const resizeHandle = this.checkResizeHandle(x, y);
        if (resizeHandle) {
          // 开始拉伸操作
          this.setData({
            isResizing: true,
            resizingBlockIndex: resizeHandle.blockIndex,
            startY: y,
            initialY: resizeHandle.position,
            isResizingStart: resizeHandle.isStart
          });
          return;
        }
javascript 复制代码
isResizing:标记正在拉伸
startY:开始拖动的位置
initialY:手柄的初始位置
isResizingStart:是否在调整开始时间
  1. 处理拖动过程
    需要根据拖动的距离来计算新的时间,将拖动的距离转换成时间的变化。简单来说,假设一小时占60px的高度,那么15min=15px,如果用户往下拖动30px,换算成时间就是30min。
javascript 复制代码
// 计算拖动了多远
const deltaY = currentY - startY;  // 比如拖动了30像素

// 计算15分钟对应的高度
const quarterHeight = hourHeight / 4;  // 假设hourHeight是60,那么这里是15

// 计算移动了多少个15分钟
const quarterMoved = Math.floor(Math.abs(deltaY) / quarterHeight) * (deltaY > 0 ? 1 : -1);

// 计算新的时间
const newTime = originalTime + (quarterMoved * 0.25);  // 0.25代表15分钟
  1. 更新时间显示
    计算出新的时间后,需要在确保有效范围内的同时,对齐15min的刻度并转化显示格式
javascript 复制代码
// 确保时间合理,比如不能小于0点,不能超过24点
if (newTime >= 0 && newTime <= 24) {
  // 对齐到15分钟
  const alignedTime = Math.floor(newTime * 4) / 4;
  
  // 转换成"HH:MM"格式
  const hours = Math.floor(alignedTime);
  const minutes = Math.round((alignedTime - hours) * 60);
  const timeString = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
}
  1. 结束拉伸逻辑
    当松手时,清楚拖动状态,将标识符置false
    this.setData({
    isResizing: false, // 结束拖动状态
    resizingBlockIndex: null, // 清除正在拖动的时间块
    startY: 0 // 重置起始位置
    });

3. 时间块拖动逻辑

  1. 长按时间块
    首先找到点击的时间块并存储信息,在原视图上"删除"该时间块,并标记拖动状态
javascript 复制代码
onCanvasLongPress(e) {
  // 1. 先找到用户点击的是哪个时间块
  const hourIndex = Math.floor(y / this.data.hourHeight);
  const pressedBlockIndex = this.data.selectedBlocks.findIndex(block => 
    hourIndex >= block.startHour && hourIndex < block.endHour
  );

  // 2. 如果真的点到了时间块
  if (pressedBlockIndex !== -1) {
    // 3. 保存这个时间块的信息,因为待会要用
    const pressedBlock = {...this.data.selectedBlocks[pressedBlockIndex]};
    
    // 4. 从原来的位置删除这个时间块
    const newBlocks = [...this.data.selectedBlocks];
    newBlocks.splice(pressedBlockIndex, 1);
    
    // 5. 设置拖动状态
    this.setData({
      isDragging: true,                // 标记正在拖动
      dragBlock: pressedBlock,         // 保存被拖动的时间块
      dragStartY: y,                   // 记录开始拖动的位置
      selectedBlocks: newBlocks,       // 更新剩下的时间块
      dragBlockDuration: pressedBlock.endHour - pressedBlock.startHour  // 记录时间块长度
    });
  }
}
  1. 时间块投影
    为了区分正常激活时间块,将长按的以投影虚化方式显示,提示拖动结束的位置。
    首先计算触摸移动的距离,并根据上文,推测相应时间变化。在合理的范围内,检测是否和其他时间块互斥,最终更新时间块的显示。
javascript 复制代码
onCanvasMove(e) {
  if (this.data.isDragging) {
    const y = e.touches[0].clientY - rect.top;
    const deltaY = y - this.data.dragStartY;
    
    const quarterHeight = this.data.hourHeight / 4;
    const quarterMoved = Math.floor(deltaY / quarterHeight);
    const targetHour = this.data.dragBlock.startHour + (quarterMoved * 0.25);
    
    const boundedHour = Math.max(0, Math.min(24 - this.data.dragBlockDuration, targetHour));
    
    const isOccupied = this.checkTimeConflict(boundedHour, boundedHour + this.data.dragBlockDuration);
    
    this.setData({
      dragShadowHour: boundedHour,     // 投影的位置
      dragShadowWarning: isOccupied    // 是否显示冲突警告
    });
  }
}
  1. 互斥检测
    排除掉当前拖动时间块,检测与其余是否重叠。
    具体来说,假设当前时间块9:00-10:00,新位置9:30-10:30,这种情况 startHour(9:30) < block.endHour(10:00)endHour(10:30) > block.startHour(9:00)所以检测为重叠
javascript 复制代码
checkTimeConflict(startHour, endHour) {
  return this.data.selectedBlocks.some(block => {
    if (block === this.data.dragBlock) return false;
    
    return (startHour < block.endHour && endHour > block.startHour);
  });
}
  1. 结束拖动
    当位置不互斥,区域有效的情况下,放置新的时间块,并添加到列表中,最后清理所有拖动相关的状态
javascript 复制代码
onCanvasEnd(e) {
  if (this.data.isDragging) {
    if (this.data.dragShadowHour !== null && 
        this.data.dragBlock && 
        !this.data.dragShadowWarning) {
      const newHour = Math.floor(this.data.dragShadowHour * 4) / 4;
      const duration = this.data.dragBlockDuration;
      
      const newBlock = {
        startHour: newHour,
        endHour: newHour + duration,
        startTime: this.formatTime(Math.round(newHour * 60)),
        endTime: this.formatTime(Math.round((newHour + duration) * 60))
      };

      const newSelectedBlocks = [...this.data.selectedBlocks, newBlock];
      this.setData({ selectedBlocks: newSelectedBlocks });
      
    } else if (this.data.dragShadowWarning) {
      const newSelectedBlocks = [...this.data.selectedBlocks, this.data.dragBlock];
      this.setData({ selectedBlocks: newSelectedBlocks });
      
      wx.showToast({
        title: '该时间段已被占用',
        icon: 'none'
      });
    }
    
    this.setData({
      isDragging: false,
      dragBlock: null,
      dragStartY: 0,
      dragCurrentY: 0,
      dragShadowHour: null,
      dragBlockDuration: null,
      dragShadowWarning: false
    });
  }
}

三、底部数据回显

就是基本的数据更新回显,setData

  1. 新增时间段回显
javascript 复制代码
const newBlock = {
  date: `${this.data.dateList[this.data.currentDateIndex].month}-${this.data.dateList[this.data.currentDateIndex].day}`,
  startHour: startHour,
  endHour: endHour,
  startTime: this.formatTime(startHour * 60),
  endTime: this.formatTime(endHour * 60)
};

this.setData({
  allSelectedBlocks: [...this.data.allSelectedBlocks, newBlock]  
});
  1. 删除时间段映射
javascript 复制代码
removeTimeBlock(e) {
  const index = e.currentTarget.dataset.index;
  const removedBlock = this.data.allSelectedBlocks[index];
  
  // 从总列表中删除
  const newAllBlocks = [...this.data.allSelectedBlocks];
  newAllBlocks.splice(index, 1);
  
  const currentDate = `${this.data.dateList[this.data.currentDateIndex].month}-${this.data.dateList[this.data.currentDateIndex].day}`;
  if (removedBlock.date === currentDate) {
    const newSelectedBlocks = this.data.selectedBlocks.filter(block => 
      block.startHour !== removedBlock.startHour || 
      block.endHour !== removedBlock.endHour
    );
    this.setData({ selectedBlocks: newSelectedBlocks });
  }
  
  this.setData({ allSelectedBlocks: newAllBlocks });
}

总结

相比于初版的div控制时间块的操作,canvas的渲染性能更好,交互也也更加灵活(dom操作的时候还需要考虑到阻止事件冒泡等情况),特别是频繁更新时,并且具有完全自定义的绘制能力和更精确的触摸事件处理。

相关推荐
晓风伴月2 分钟前
Css:overflow: hidden截断条件‌及如何避免截断
前端·css·overflow截断条件
最新资讯动态5 分钟前
使用“一次开发,多端部署”,实现Pura X阔折叠的全新设计
前端
爱泡脚的鸡腿20 分钟前
HTML CSS 第二次笔记
前端·css
灯火不休ᝰ35 分钟前
前端处理pdf文件流,展示pdf
前端·pdf
智践行37 分钟前
Trae开发实战之转盘小程序
前端·trae
最新资讯动态43 分钟前
DialogHub上线OpenHarmony开源社区,高效开发鸿蒙应用弹窗
前端
lvbb661 小时前
框架修改思路
前端·javascript·vue.js
树上有只程序猿1 小时前
Java程序员需要掌握的技术
前端
从零开始学安卓1 小时前
Kotlin(三) 协程
前端