Coreshop/预约时间自由选择_改动文档_开发版.md
2025-12-26 16:08:55 +08:00

18 KiB
Raw Permalink Blame History

预约时间自由选择 - 开发文档

文档版本v1.0 更新日期2024年 文档类型:开发人员版


一、改动概述

1.1 需求说明

  1. 将固定时段选择6小时一段改为自由时间选择
  2. 房间列表正确显示跨时段预约的占用状态

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

当前时段显示已从后端获取数据,后端已支持跨时段检测,理论上无需前端改动

验证方法

  1. 创建一个15:00-20:00的预约
  2. 查看房间列表,确认下午和晚上都显示为已预约状态

如显示不正确,检查后端SQRoomsServices.cs中的时段状态返回逻辑。


三、后端改动详情

3.1 SQController.cs验证/微调)

文件路径/server/CoreCms.Net.Web.WebApi/Controllers/SQController.cs

3.1.1 AddSQReservation 接口验证

当前接口已支持接收start_timeend_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}分钟`;
}

九、注意事项

  1. 向后兼容:已有预约数据不受影响,仍可正常显示
  2. 时间精度UniApp的time picker默认支持分钟级别选择
  3. 时区处理:前后端统一使用本地时间,避免时区转换问题
  4. 边界条件特别注意24:00的处理可视为次日00:00
  5. 并发控制:创建预约时需考虑并发冲突,建议后端加锁