更新日期: 2026-02-05
相关功能: 014-web-public-booking(重构)
参考实现: Guest Mobile App (Flutter) + 管理后台 BookingWizard
对齐 Guest Mobile App:逐个服务配置,不是批量多选。每个服务独立选技师和时间,选完自动入购物车。支持多 Guest(Group Appointment)。
| 步骤 | 页面 | 用户操作 | 说明 |
|---|---|---|---|
| 1 | 服务列表 | 浏览服务,点击一个服务 | 单选(非多选),点击后进入技师选择。服务可重复添加。底部有购物车指示栏(已选数量、总价) |
| 2 | 技师选择 | 选择"任意可用"或某位技师 | 返回按钮 → 回到服务列表(放弃当前配置) |
| 3 | 时间选择 | 选日期 + 选时段 | 返回按钮 → 回到技师选择页。选完时间后自动回到服务列表,该服务入购物车 |
| 4 | 服务列表 | 继续添加服务 / 打开购物车弹框 | 可为当前 Guest 继续添加,或添加新 Guest |
| 5 | 购物车弹框 | 查看/编辑/移除项目,添加 Guest | 按 Guest 分组显示。编辑 → 重走技师→时间流程 |
| 6 | 验证/确认页 | 填信息 + 手机验证 + 填 notes + 通知偏好 | Primary Guest 做手机验证。Notes 留给技师。通知偏好选 SMS/Email |
| 7 | 成功页 | 查看确认信息 | 显示所有 Guest 的预约详情 |
┌──────────────────────────────────────┐
│ 购物车 ✕ │
├──────────────────────────────────────┤
│ │
│ 👤 Guest A (You) │
│ ┌────────────────────────────────┐ │
│ │ 💅 Full Nail Set │ │
│ │ Tech: Emma · Feb 15, 10:00 AM │ │
│ │ $50 · 60 min │ │
│ │ [编辑] [移除] │ │
│ └────────────────────────────────┘ │
│ ┌────────────────────────────────┐ │
│ │ 🦶 Pedicure │ │
│ │ Tech: Any · Feb 15, 11:00 AM │ │
│ │ $40 · 45 min │ │
│ │ [编辑] [移除] │ │
│ └────────────────────────────────┘ │
│ │
│ 👤 Guest B (Alice) │
│ ┌────────────────────────────────┐ │
│ │ 💅 Gel Manicure │ │
│ │ Tech: Emma · Feb 15, 11:00 AM │ │
│ │ $45 · 50 min │ │
│ │ [编辑] [移除] │ │
│ └────────────────────────────────┘ │
│ │
│ [+ 添加 Guest] │
│ │
├──────────────────────────────────────┤
│ 3 services · 155 min │
│ Total: $135 [结账 →] │
└──────────────────────────────────────┘
┌──────────────────────────────┐
│ 添加同行客人 │
├──────────────────────────────┤
│ 姓名 *: [________________] │
│ 手机号: [________________] │
│ (可选) │
│ │
│ [取消] [确认添加] │
└──────────────────────────────┘
添加 Guest 后回到服务列表,当前活跃 Guest 切换为新 Guest,可为其选择服务。
| 步骤 | API | 参数 | 说明 |
|---|---|---|---|
| 加载店铺 | GET /api/public/booking/stores/{code} |
store code (URL) | 获取店铺信息 |
| 加载服务 | GET /api/public/booking/services |
store_id |
获取服务列表(含分类) |
| 加载技师 | GET /api/public/booking/employees |
store_id, service_id, date |
获取可用技师列表 |
| 加载时段 | GET /api/public/booking/slots |
store_id, service_id, date, employee_id, excluded_slots |
获取可用时间段(含冲突排除) |
参数格式(JSON 编码后作为 query string):
[
{
"employee_id": "emp_001", // 或 null(任意技师)
"start_time": "10:00", // HH:mm
"duration": 60 // 分钟
},
{
"employee_id": null,
"start_time": "11:00",
"duration": 45
}
]
后端处理逻辑:
employee_id 有值 → 该技师在该时间段被屏蔽employee_id = null(任意技师)→ 从可用技师池减少一个名额前端生成逻辑:遍历购物车中所有 Guest 的所有项目,筛选出与当前选择日期相同的项目:
// 伪代码
const excludedSlots = cart.allGuests
.flatMap(guest => guest.items)
.filter(item => item.date === selectedDate && item.time !== null)
.map(item => ({
employee_id: item.employee_id, // null = any
start_time: item.time,
duration: item.service.duration
}));
| 步骤 | API | 参数 |
|---|---|---|
| 发送验证码 | POST /api/public/booking/send-code |
{ phone, store_id } |
| 确认预约 | POST /api/public/booking/confirm |
见下方详细结构 |
单 Guest(无 Group):
{
"phone": "1234567890",
"code": "123456",
"store_id": "store_001",
"guest_name": "Alice",
"email": "alice@example.com",
"notes": "Please use gel polish",
"source": "customer_web",
"notification_preferences": { "sms": true, "email": true },
"ref": "marketing-abc",
"sub_appointments": [
{ "service_id": "svc_1", "employee_id": "emp_1", "datetime": "2025-02-15T10:00:00" },
{ "service_id": "svc_2", "employee_id": null, "datetime": "2025-02-15T11:00:00" }
]
}
多 Guest(Group Appointment):
{
"phone": "1234567890",
"code": "123456",
"store_id": "store_001",
"guest_name": "Alice",
"email": "alice@example.com",
"notes": "We're celebrating a birthday!",
"source": "customer_web",
"notification_preferences": { "sms": true, "email": true },
"ref": "marketing-abc",
// Primary Guest 的服务
"sub_appointments": [
{ "service_id": "svc_1", "employee_id": "emp_1", "datetime": "2025-02-15T10:00:00" }
],
// Companion Guests
"companion_appointments": [
{
"guest_name": "Bob",
"guest_phone": "0987654321", // optional
"items": [
{ "service_id": "svc_2", "employee_id": "emp_2", "datetime": "2025-02-15T10:00:00" }
]
},
{
"guest_name": "Charlie",
"items": [
{ "service_id": "svc_1", "employee_id": null, "datetime": "2025-02-15T10:30:00" }
]
}
]
}
cart_appointment.js 中的 Group Booking 逻辑为共享服务,两个端点调用同一函数,结果 100% 一致。
// backend/services/booking/bookingService.js (NEW)
/**
* 创建预约(支持单 Guest 和 Group)
* 被 cart_appointment.js 和 public/booking.js 共同调用
*/
async function createBooking(client, {
// 必需
tenantSchema,
storeId,
primaryGuestId,
startDatetime,
subAppointments, // [{ service_id, employee_id, datetime }]
source, // 'customer_web' | 'customer_mobile' | 'walk_in' | ...
createdBy, // guest_self_service | employee_id
// 可选
notes,
companionAppointments, // [{ guest_id, guest_name, guest_phone, items }]
notificationPreferences,
trackingRef,
}) {
// 1. 创建 primary appointment + sub_appointments
// 2. 如果有 companion_appointments:
// a. 查找或创建 companion guest 记录
// b. 为每个 companion 创建 appointment + sub_appointments
// c. 创建 appointment_group
// d. 添加所有 group_members
// e. 更新 guest_relationships
// 3. 返回 { appointmentId, groupCode?, allAppointmentIds }
}
| 文件 | 状态 | 说明 |
|---|---|---|
backend/services/booking/bookingService.js |
NEW | 共享服务层:createBooking() |
backend/api/cart_appointment.js |
MODIFY | 重构:调用 bookingService 代替内联逻辑 |
backend/api/public/booking.js |
MODIFY | /confirm 端点支持 companion_appointments,调用 bookingService |
// 新的购物车结构(按 Guest 分组)
interface GuestCart {
id: string; // 唯一标识
name: string; // Guest 姓名
phone?: string; // Guest 手机号(optional)
isPrimary: boolean; // 是否为主 Guest
items: CartItem[]; // 该 Guest 的服务项目
}
interface CartItem {
id: string; // 唯一标识
service: Service;
employee_id: string | null;
employee_name: string | null;
date: string | null; // YYYY-MM-DD
time: string | null; // HH:mm
}
interface BookingState {
store: Store | null;
guests: GuestCart[]; // 所有 Guest 及其购物车
activeGuestId: string | null; // 当前正在配置的 Guest
editingItemId: string | null; // 当前正在编辑的项目
// Checkout 信息
notificationPreferences: { sms: boolean; email: boolean } | null;
notes: string | null;
trackingRef: string | null;
}
| 操作 | 说明 |
|---|---|
addGuest(name, phone?) | 添加新 Guest,自动切换为 activeGuest |
removeGuest(guestId) | 移除 Guest 及其所有项目 |
setActiveGuest(guestId) | 切换当前活跃 Guest |
addItemToGuest(guestId, service) | 为某 Guest 添加服务(初始无技师/时间) |
updateItem(itemId, updates) | 更新项目的技师/日期/时间 |
removeItem(itemId) | 移除某个服务项目 |
getAllExcludedSlots(date) | 获取所有 Guest 在该日期的已选时段(用于冲突检测) |
canCheckout() | 所有 Guest 的所有项目都已配置完成 |
| 页面 | 状态 | 主要变更 |
|---|---|---|
/booking/[store]/page.tsx服务列表 |
MODIFY |
• 改为单选(点击服务 → 进入技师选择) • 底部购物车指示栏(数量、总价、打开弹框) • 显示当前活跃 Guest 标识 • 购物车弹框组件(查看/编辑/移除/添加 Guest) |
/booking/[store]/employee/page.tsx技师选择 |
MODIFY |
• 返回按钮 → 服务列表 • 支持编辑模式(editingItemId) |
/booking/[store]/time/page.tsx时间选择 |
MODIFY |
• 返回按钮 → 技师选择页 • excluded_slots 取自所有 Guest 的所有项目 • 选完时间后自动回到服务列表(非 verify) |
/booking/[store]/verify/page.tsx验证确认 |
MODIFY |
• 添加 notes 输入框 • 订单摘要按 Guest 分组显示 • 提交时构建 companion_appointments |
/booking/[store]/success/page.tsx成功页 |
MODIFY | • 显示所有 Guest 的预约详情 |
/booking/[store]/cart/page.tsx |
删除 | 不再需要独立购物车页面,改用弹框 |
| 组件 | 位置 | 复用方式 |
|---|---|---|
| appointment_groups 表 | Migration 024 | 直接使用,无需修改 |
| appointment_group_members 表 | Migration 024 | 直接使用,无需修改 |
| guest_relationships 表 | Migration 024 | 直接使用,Group 创建后自动更新 |
| Group 创建逻辑 | cart_appointment.js:620-650 |
抽取到 bookingService.js |
| 冲突检测 | appointments.js:2261-2339 |
已有 checkMultiServiceConflicts() |
| excluded_slots API | public/booking.js /slots |
已支持 excluded_slots 参数 |
| Companion 处理 | cart_appointment.js |
抽取到 bookingService.js |
| Relationship 更新 | guest-relationships.js |
直接调用 updateVisitCount() |
| 维度 | Guest Mobile App | Web 重构后 | 差异原因 |
|---|---|---|---|
| 服务选择 | 单选,逐个配置 | 单选,逐个配置 | ✅ 对齐 |
| 购物车 | 独立页面 | 弹框 | Web 端交互更适合弹框 |
| 冲突检测 | excluded_slots + 前端校验 | excluded_slots + 前端校验 | ✅ 对齐 |
| Group Appointment | 暂未实现 | 支持多 Guest | Web 端先行实现 |
| Notes | Checkout 时填写 | Checkout 时填写 | ✅ 对齐 |
| 通知偏好 | 无(APP 推送) | SMS / Email 选择 | Web 特有,无 push notification |
| 验证方式 | 手机验证码 | 手机验证码 | ✅ 对齐 |