18 KiB
预约时间自由选择 - 开发文档
文档版本:v1.0 更新日期:2024年 文档类型:开发人员版
一、改动概述
1.1 需求说明
- 将固定时段选择(6小时一段)改为自由时间选择
- 房间列表正确显示跨时段预约的占用状态
1.2 影响范围评估
- 前端:主要改动,需重构时间选择UI
- 后端:少量改动,逻辑已基本支持
二、前端改动详情
2.1 appointment-page.vue(主要改动)
文件路径:/uniapp/mahjong_group/pages/appointment/appointment-page.vue
2.1.1 移除旧的时段选择代码
需删除的部分:
<!-- 删除:固定时段选择UI -->
<view class="time-slot-selector">
<view v-for="slot in timeSlotOptions" :key="slot.value"
:class="['slot-item', selectedTimeSlot === slot.value ? 'active' : '']"
@click="selectTimeSlot(slot.value)">
{{ slot.label }}
</view>
</view>
// 删除:固定时段计算函数
const calculateTimeFromSlot = () => {
const date = new Date(selectedDate.value);
let startHour, endHour;
switch (selectedTimeSlot.value) {
case 0: startHour = 0; endHour = 6; break;
case 1: startHour = 6; endHour = 12; break;
case 2: startHour = 12; endHour = 18; break;
case 3: startHour = 18; endHour = 24; break;
}
// ...
};
2.1.2 新增时间选择器代码
Template部分:
<template>
<!-- 新增:时间选择器 -->
<view class="time-picker-container">
<!-- 开始时间 -->
<view class="time-picker-row">
<text class="label">开始时间</text>
<picker mode="time" :value="startTime" @change="onStartTimeChange">
<view class="picker-value">
{{ startTime || '请选择开始时间' }}
</view>
</picker>
</view>
<!-- 结束时间 -->
<view class="time-picker-row">
<text class="label">结束时间</text>
<picker mode="time" :value="endTime" @change="onEndTimeChange">
<view class="picker-value">
{{ endTime || '请选择结束时间' }}
</view>
</picker>
</view>
<!-- 时间信息展示 -->
<view class="time-info" v-if="startTime && endTime">
<view class="info-item">
<text class="info-label">预计时长</text>
<text class="info-value">{{ calculateDuration() }}</text>
</view>
<view class="info-item">
<text class="info-label">跨越时段</text>
<text class="info-value">{{ calculateCrossSlots() }}</text>
</view>
</view>
<!-- 错误提示 -->
<view class="time-error" v-if="timeError">
<text>{{ timeError }}</text>
</view>
</view>
</template>
Script部分:
<script setup>
import { ref, computed } from 'vue';
// 响应式数据
const startTime = ref('');
const endTime = ref('');
const timeError = ref('');
// 开始时间变更
const onStartTimeChange = (e) => {
startTime.value = e.detail.value;
validateTimeRange();
};
// 结束时间变更
const onEndTimeChange = (e) => {
endTime.value = e.detail.value;
validateTimeRange();
};
// 验证时间范围
const validateTimeRange = () => {
timeError.value = '';
if (!startTime.value || !endTime.value) {
return true;
}
const [startH, startM] = startTime.value.split(':').map(Number);
const [endH, endM] = endTime.value.split(':').map(Number);
const startMinutes = startH * 60 + startM;
const endMinutes = endH * 60 + endM;
if (endMinutes <= startMinutes) {
timeError.value = '结束时间必须晚于开始时间';
return false;
}
// 可选:最短时长检查(如最少1小时)
if (endMinutes - startMinutes < 60) {
timeError.value = '预约时长不能少于1小时';
return false;
}
return true;
};
// 计算时长
const calculateDuration = () => {
if (!startTime.value || !endTime.value) return '-';
const [startH, startM] = startTime.value.split(':').map(Number);
const [endH, endM] = endTime.value.split(':').map(Number);
const startMinutes = startH * 60 + startM;
const endMinutes = endH * 60 + endM;
const diffMinutes = endMinutes - startMinutes;
if (diffMinutes <= 0) return '-';
const hours = Math.floor(diffMinutes / 60);
const minutes = diffMinutes % 60;
if (minutes === 0) {
return `${hours}小时`;
}
return `${hours}小时${minutes}分钟`;
};
// 计算跨越的时段
const calculateCrossSlots = () => {
if (!startTime.value || !endTime.value) return '-';
const [startH] = startTime.value.split(':').map(Number);
const [endH] = endTime.value.split(':').map(Number);
const slots = [];
const slotRanges = [
{ name: '凌晨', start: 0, end: 6 },
{ name: '上午', start: 6, end: 12 },
{ name: '下午', start: 12, end: 18 },
{ name: '晚上', start: 18, end: 24 }
];
for (const slot of slotRanges) {
// 重叠判断:预约开始 < 时段结束 AND 预约结束 > 时段开始
if (startH < slot.end && endH > slot.start) {
slots.push(slot.name);
}
}
return slots.join('、') || '-';
};
// 构建预约数据(提交时调用)
const buildReservationData = () => {
if (!validateTimeRange()) {
return null;
}
const date = new Date(selectedDate.value);
const [startH, startM] = startTime.value.split(':').map(Number);
const [endH, endM] = endTime.value.split(':').map(Number);
const startDateTime = new Date(date);
startDateTime.setHours(startH, startM, 0, 0);
const endDateTime = new Date(date);
endDateTime.setHours(endH, endM, 0, 0);
return {
start_time: formatDateTime(startDateTime),
end_time: formatDateTime(endDateTime),
// ... 其他字段保持不变
};
};
// 格式化日期时间
const formatDateTime = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = '00';
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
// 修改提交函数
const submitReservation = async () => {
const data = buildReservationData();
if (!data) {
uni.showToast({ title: timeError.value || '请选择正确的时间', icon: 'none' });
return;
}
// 调用API提交
// ...
};
</script>
Style部分:
<style lang="scss">
.time-picker-container {
padding: 20rpx;
background-color: #fff;
border-radius: 16rpx;
margin: 20rpx 0;
}
.time-picker-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.label {
font-size: 28rpx;
color: #333;
}
.picker-value {
font-size: 28rpx;
color: #666;
padding: 10rpx 20rpx;
background-color: #f5f5f5;
border-radius: 8rpx;
}
}
.time-info {
margin-top: 20rpx;
padding: 20rpx;
background-color: #f8f8f8;
border-radius: 12rpx;
.info-item {
display: flex;
justify-content: space-between;
margin-bottom: 10rpx;
&:last-child {
margin-bottom: 0;
}
}
.info-label {
font-size: 24rpx;
color: #999;
}
.info-value {
font-size: 24rpx;
color: #333;
font-weight: 500;
}
}
.time-error {
margin-top: 16rpx;
padding: 16rpx;
background-color: #fff2f0;
border-radius: 8rpx;
text {
font-size: 24rpx;
color: #ff4d4f;
}
}
</style>
2.2 book-room-page.vue(验证,可能无需改动)
文件路径:/uniapp/mahjong_group/pages/appointment/book-room-page.vue
当前时段显示已从后端获取数据,后端已支持跨时段检测,理论上无需前端改动。
验证方法:
- 创建一个15:00-20:00的预约
- 查看房间列表,确认下午和晚上都显示为已预约状态
如显示不正确,检查后端SQRoomsServices.cs中的时段状态返回逻辑。
三、后端改动详情
3.1 SQController.cs(验证/微调)
文件路径:/server/CoreCms.Net.Web.WebApi/Controllers/SQController.cs
3.1.1 AddSQReservation 接口验证
当前接口已支持接收start_time和end_time参数,主要验证:
[HttpPost("AddSQReservation")]
public async Task<IActionResult> AddSQReservation([FromBody] AddReservationDto dto)
{
// 验证时间有效性(建议新增)
if (dto.end_time <= dto.start_time)
{
return BadRequest(new { code = 400, msg = "结束时间必须晚于开始时间" });
}
// 验证最短时长(可选,如最少1小时)
var duration = (dto.end_time - dto.start_time).TotalHours;
if (duration < 1)
{
return BadRequest(new { code = 400, msg = "预约时长不能少于1小时" });
}
// 验证最长时长(可选,如最多12小时)
if (duration > 12)
{
return BadRequest(new { code = 400, msg = "预约时长不能超过12小时" });
}
// ... 现有逻辑
}
3.2 SQRoomsServices.cs(验证,无需改动)
文件路径:/server/CoreCms.Net.Services/SQ/SQRoomsServices.cs
当前时段状态判断逻辑已正确支持跨时段检测:
// 现有代码(已支持跨时段)
bool isReserved = reservations?.Any(r =>
r.start_time < slotEnd && r.end_time > slotStart) ?? false;
逻辑验证:
| 预约时间 | 时段 | 判断公式 | 结果 |
|---|---|---|---|
| 15:00-20:00 | 下午(12-18) | 15 < 18 && 20 > 12 | true ✓ |
| 15:00-20:00 | 晚上(18-24) | 15 < 24 && 20 > 18 | true ✓ |
| 15:00-20:00 | 上午(6-12) | 15 < 12 | false ✓ |
结论:后端逻辑无需改动。
四、签到功能代码参考
4.1 前端签到组件
文件路径:/uniapp/mahjong_group/components/com/page/qiandao-popup.vue
签到按钮显示条件(reservation-item.vue):
// 签到按钮显示条件
const isQianDaoVisible = computed(() => {
const item = props.reservation;
// 1. 预约状态必须为 1(已锁定)
if (item.status !== 1) return false;
// 2. 只有发起者可签到
if (item.role !== 1) return false;
// 3. 时间范围检查
const now = new Date();
const startTime = new Date(item.start_time);
const endTime = new Date(item.end_time);
const tenMinutes = 10 * 60 * 1000;
const timeToStart = startTime.getTime() - now.getTime();
const timeToEnd = endTime.getTime() - now.getTime();
// 可签到:开始前10分钟内 或 已开始但未结束
return (timeToStart <= tenMinutes && timeToStart > 0) ||
(timeToStart <= 0 && timeToEnd > 0);
});
4.2 后端签到接口
文件路径:/server/CoreCms.Net.Web.WebApi/Controllers/SQController.cs
方法位置:第1205-1431行 CheckInReservation
请求格式:
POST /api/sq/checkinreservation
{
"reservation_id": 123,
"attendeds": [
{ "user_id": 2, "isAttended": true },
{ "user_id": 3, "isAttended": false }
]
}
核心处理逻辑:
// 1. 更新预约状态为"进行中"
reservation.status = 2;
// 2. 处理参与者
foreach (var attendee in dto.attendeds)
{
if (attendee.isAttended)
{
// 已到场:信誉+0.2,标记为已赴约
participant.is_arrive = 1;
user.credit_score = Math.Min(5, user.credit_score + 0.2);
}
else
{
// 未到场:信誉-0.5,鸽子+1,踢出
participant.is_arrive = 2;
participant.status = 1; // 已退出
user.credit_score = Math.Max(0, user.credit_score - 0.5);
user.dove_count += 1;
}
}
// 3. 处理押金退款
if (reservation.deposit_fee > 0)
{
// 已到场的参与者:发起退款
// 未到场的参与者:不退款
}
五、数据库相关
5.1 相关表结构
SQReservations(预约表):
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| status | int | 0=待开始,1=已锁定,2=进行中,3=已结束,4=已取消 |
| start_time | datetime | 开始时间 |
| end_time | datetime | 结束时间 |
| room_id | int | 房间ID |
| deposit_fee | decimal | 押金金额 |
SQReservationParticipants(参与者表):
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| reservation_id | int | 预约ID |
| user_id | int | 用户ID |
| role | int | 0=参与者,1=发起者 |
| status | int | 0=正常,1=已退出 |
| is_arrive | int | 0=默认,1=已赴约,2=未赴约 |
| is_refund | int | 退款状态 |
5.2 无需数据库改动
本次改动不涉及数据库结构变更,现有字段已满足需求:
start_time/end_time已支持任意时间- 时段状态在服务层动态计算
六、测试用例
6.1 时间选择功能测试
| 用例ID | 测试场景 | 输入 | 预期结果 |
|---|---|---|---|
| T001 | 正常选择 | 开始15:00,结束20:00 | 成功,显示"5小时",跨越"下午、晚上" |
| T002 | 同时段 | 开始14:00,结束17:00 | 成功,显示"3小时",跨越"下午" |
| T003 | 跨三时段 | 开始10:00,结束20:00 | 成功,显示"10小时",跨越"上午、下午、晚上" |
| T004 | 时间倒置 | 开始20:00,结束15:00 | 失败,提示"结束时间必须晚于开始时间" |
| T005 | 时间相同 | 开始15:00,结束15:00 | 失败,提示"结束时间必须晚于开始时间" |
| T006 | 时长过短 | 开始15:00,结束15:30 | 失败,提示"预约时长不能少于1小时" |
6.2 时段冲突测试
| 用例ID | 已有预约 | 新预约 | 预期结果 |
|---|---|---|---|
| T101 | 15:00-20:00 | 10:00-14:00 | 成功(无重叠) |
| T102 | 15:00-20:00 | 14:00-16:00 | 失败(重叠) |
| T103 | 15:00-20:00 | 19:00-22:00 | 失败(重叠) |
| T104 | 15:00-20:00 | 20:00-22:00 | 成功(边界无重叠) |
| T105 | 15:00-20:00 | 12:00-22:00 | 失败(完全包含) |
6.3 房间列表显示测试
| 用例ID | 预约时间 | 凌晨 | 上午 | 下午 | 晚上 |
|---|---|---|---|---|---|
| T201 | 02:00-05:00 | 🟠 | 🟢 | 🟢 | 🟢 |
| T202 | 05:00-08:00 | 🟠 | 🟠 | 🟢 | 🟢 |
| T203 | 10:00-14:00 | 🟢 | 🟠 | 🟠 | 🟢 |
| T204 | 15:00-20:00 | 🟢 | 🟢 | 🟠 | 🟠 |
| T205 | 08:00-22:00 | 🟢 | 🟠 | 🟠 | 🟠 |
6.4 签到功能测试
| 用例ID | 测试场景 | 预期结果 |
|---|---|---|
| T301 | 开始前11分钟签到 | 签到按钮不显示 |
| T302 | 开始前10分钟签到 | 签到按钮显示,可签到 |
| T303 | 开始后5分钟签到 | 签到按钮显示,可签到 |
| T304 | 结束后签到 | 签到按钮不显示 |
| T305 | 参与者尝试签到 | 签到按钮不显示 |
| T306 | 重复签到 | 提示"已签到,无法重复签到" |
七、改动文件清单
7.1 必须改动
| 序号 | 文件路径 | 改动类型 | 说明 |
|---|---|---|---|
| 1 | pages/appointment/appointment-page.vue |
修改 | 时间选择器重构 |
7.2 验证确认
| 序号 | 文件路径 | 改动类型 | 说明 |
|---|---|---|---|
| 2 | pages/appointment/book-room-page.vue |
验证 | 确认跨时段显示正确 |
| 3 | SQController.cs |
验证 | 确认时间参数接收正确 |
| 4 | SQRoomsServices.cs |
验证 | 确认跨时段检测正确 |
7.3 可选优化
| 序号 | 文件路径 | 改动类型 | 说明 |
|---|---|---|---|
| 5 | SQController.cs |
新增 | 添加时长限制验证 |
八、工具函数
8.1 跨时段计算(前端)
/**
* 判断时间范围跨越哪些时段
* @param {number} startHour 开始小时 (0-23)
* @param {number} endHour 结束小时 (0-24)
* @returns {Array} 跨越的时段数组
*/
function getCrossedSlots(startHour, endHour) {
const slots = [];
const slotRanges = [
{ name: '凌晨', start: 0, end: 6 },
{ name: '上午', start: 6, end: 12 },
{ name: '下午', start: 12, end: 18 },
{ name: '晚上', start: 18, end: 24 }
];
for (const slot of slotRanges) {
if (startHour < slot.end && endHour > slot.start) {
slots.push(slot.name);
}
}
return slots;
}
// 使用示例
getCrossedSlots(15, 20); // ['下午', '晚上']
getCrossedSlots(10, 14); // ['上午', '下午']
getCrossedSlots(2, 5); // ['凌晨']
8.2 时长格式化(前端)
/**
* 格式化时长显示
* @param {number} minutes 分钟数
* @returns {string} 格式化后的时长
*/
function formatDuration(minutes) {
if (minutes <= 0) return '-';
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours === 0) {
return `${mins}分钟`;
}
if (mins === 0) {
return `${hours}小时`;
}
return `${hours}小时${mins}分钟`;
}
九、注意事项
- 向后兼容:已有预约数据不受影响,仍可正常显示
- 时间精度:UniApp的time picker默认支持分钟级别选择
- 时区处理:前后端统一使用本地时间,避免时区转换问题
- 边界条件:特别注意24:00的处理(可视为次日00:00)
- 并发控制:创建预约时需考虑并发冲突,建议后端加锁