自助终端 Kiosk — CUJ 关键用户旅程
Layer 1-3 完成 | 8 个页面 · 4 条 CUJ · 状态机驱动 · 7 项修复 · 全部 BDD 场景已验证
更新日期: 2026-02-07 | 方法论: CUJ 四步走
Step 1: CUJ 优先级矩阵
8 个页面 | 3 个 Cubit | 1 个 integration_test + 1 个 mock 集成测试 + 8 个 Widget 测试 + 6 个单元测试
| CUJ |
优先级 |
描述 |
触发点 |
业务价值 |
测试状态 |
| K-CUJ-1 |
P0 |
Walk-in 签到 |
客人到店,已有预约 |
减轻前台工作量,加快排队速度 |
已覆盖 |
| K-CUJ-2 |
P0 |
Walk-in 现场预约 |
客人想当天预约 |
转化 Walk-in 客人,增加预约量 |
已覆盖 |
| K-CUJ-3 |
P0 |
签到未果转预约 |
客人签到查不到预约 |
挽回流失客人,将困惑转化为预约 |
已覆盖 |
| K-CUJ-4 |
P1 |
管理员初始设置 |
首次配置 Kiosk |
一次性设置,启用所有其他旅程 |
已覆盖 |
K-CUJ-1: Walk-in 签到
P0 已有预约的客人自助签到
Layer 1: CUJ 分析
| 触发点 |
客人到店,已有预约,需要通知前台自己到了。看到门口的 iPad Kiosk,决定自助签到而非排队等前台。 |
| 阶段 |
1. 触屏唤醒 → 2. 选择"签到" → 3. 输入手机号 → 4. 查看预约列表 → 5. 确认签到 → 6. 完成
|
| 情感曲线 |
到店 — 中性
看到 Kiosk — 好奇
输入手机号 — 轻微焦虑(能找到吗?)
看到预约列表 — 安心
签到成功 — 满足
|
| 触点 |
WelcomePage → ModeSelectionPage → PhoneInputPage → CheckinPage → SuccessPage
|
| 痛点 |
- 手机号纯数字键盘输入,没有自动补全/建议,容易输错
- 如果手机号和预约对不上,客人不知道原因(输错了?预约被取消了?)
- 多个预约时,列表信息密度可能不够(哪个是今天要做的?)
|
| 机会点 |
- 未来支持扫预约确认短信中的 QR 码一键签到
- 手机号输入时实时验证格式(10位数字提示)
- 签到后显示预计等待时间(排队位次)
|
Layer 2: User Flow (代码实际行为)
flowchart TD
A["欢迎页
点击任意位置"] --> B["模式选择
goToModeSelection()"]
B -->|"签到"| C["手机号输入页
goToPhoneInput()"]
C --> D["输入手机号"]
D --> E["点击查询
lookupPhone()"]
E --> F{"找到预约?"}
F -->|"是"| G["签到页 - 显示预约列表"]
F -->|"否"| H["签到页 - 空状态
→ K-CUJ-3"]
G --> I["选择预约"]
I --> J["点击签到
checkin(apt)"]
J --> K{"签到成功?"}
K -->|"是"| L["成功页
15s 自动返回"]
K -->|"否"| M["错误提示 + 重试"]
G -->|"返回箭头"| N["手机号输入页
retryPhoneInput()"]
L --> O["欢迎页"]
style A fill:#0B6E6E,stroke:#064E4E,color:#fff
style L fill:#4CAF50,stroke:#2E7D32,color:#fff
style H fill:#FF9800,stroke:#E65100,color:#fff
style O fill:#0B6E6E,stroke:#064E4E,color:#fff
style N fill:#1565C0,stroke:#0D47A1,color:#fff
✅ 已修复 (Step 2)
- 签到页返回箭头: 已改为
retryPhoneInput(),返回手机号输入页并清空输入框。
Layer 3: BDD 场景
场景 1.1: 正常签到流程 (Happy Path)
Given Kiosk 处于欢迎页
When 客人点击屏幕
And 选择"签到"
And 输入手机号 "2125551234"
And 点击"查询"
Then 显示该手机号对应的当天预约列表
When 客人点击自己的预约
And 点击"签到"
Then 成功页显示确认详情
And Kiosk 15 秒后自动返回欢迎页
Tests: integration_test/app_test.dart (flow 2), test/widget/checkin_page_test.dart, test/widget/phone_input_page_test.dart
场景 1.2: 多个预约时选择正确的一个
Given 客人今天有 2 个预约(10:00 AM Manicure, 2:00 PM Pedicure)
When 客人查询手机号
Then 列表显示 2 条预约,各自有时间和服务名称
When 客人点击 "10:00 AM Manicure"
And 确认签到
Then 仅该预约被签到,另一条不受影响
Tests: test/unit/kiosk_cubit_test.dart, test/widget/checkin_page_test.dart
K-CUJ-2: Walk-in 现场预约
P0 通过 Kiosk 进行当天预约(确认后自动签到)
Layer 1: CUJ 分析
| 触发点 |
客人路过/到店,没有预约,想当场预约并等待服务。或者被同行朋友带来,临时起意。 |
| 阶段 |
1. 触屏唤醒 → 2. 选择"预约" → 3. 选服务 → 4. 选技师(可选) → 5. 选时间 → 6. 填写信息 → 7. 确认 → 8. 自动签到+完成
|
| 情感曲线 |
好奇 — 看到 Kiosk
选服务 — 犹豫(哪个适合我?)
选技师 — 纠结或直接跳过
选时间 — 焦虑(还有空位吗?)
填手机号 — 抵触(隐私)
确认成功 — 满足+期待
|
| 触点 |
WelcomePage → ModeSelectionPage → BookingWizardPage(Step 1-4)→ SuccessPage
|
| 痛点 |
- 4 步向导步骤偏多,客人站着操作 iPad 会累
- 手机号收集引起隐私顾虑(虽然可跳过,但 UI 引导强烈)
- 服务列表没有图片/描述,不了解的客人难以选择
- 没有可用时段时缺少引导(死胡同)
- 技师选择页没有照片/评分,"任意技师"成为默认逃避选项
|
| 机会点 |
- 智能推荐热门服务 / "今日特惠" 标签,减少选择焦虑
- 技师照片 + 好评率帮助决策
- 手机号输入时强调好处:"输入手机号可收到短信提醒"
- 无可用时段时推荐下一个最近可用日期(当前限当天)
|
Layer 2: User Flow (代码实际行为)
flowchart TD
A["欢迎页"] --> B["模式选择"]
B -->|"预约"| C["Step 1: 选择服务
goToBooking() → loadServices()"]
C --> D["选择一项服务"]
D --> F["Step 2: 选择技师
loadEmployees()"]
F --> H{"指定技师?"}
H -->|"选择具体技师"| G["Step 3: 选择时间
loadTimeSlots()"]
H -->|"任意可用技师 (null)"| G
G --> I["选择时间段"]
I --> J["Step 4: 确认"]
J --> K["输入姓名(必填)"]
K --> L["输入手机号(可选)"]
L --> M["确认预约
confirmBooking()"]
M --> N{"预约成功?"}
N -->|"是"| O["成功页
已自动签到 · 15s 返回"]
N -->|"否"| P["错误提示 + 重试"]
C -->|"Step 1 返回"| Q["模式选择页
goToModeSelection()"]
O --> R["欢迎页"]
style A fill:#0B6E6E,stroke:#064E4E,color:#fff
style O fill:#4CAF50,stroke:#2E7D32,color:#fff
style P fill:#f44336,stroke:#c62828,color:#fff
style Q fill:#1565C0,stroke:#0D47A1,color:#fff
style R fill:#0B6E6E,stroke:#064E4E,color:#fff
✅ 已修复 (Step 2)
- Step 1 返回: 已改为
goToModeSelection(),返回模式选择页。
Layer 3: BDD 场景
场景 2.1: 完整预约流程 — 任意技师 (Happy Path)
Given Kiosk 处于模式选择页面
When 客人选择"预约"
And 选择服务 "Gel Manicure"
And 选择"任意可用技师"
And 选择第一个可用时间段
And 输入姓名 "Jane Doe"
And 点击"确认预约"(跳过手机号)
Then 成功页显示预约详情
And 预约已自动签到
And 显示确认号
Tests: integration_test/app_test.dart (flows 3 & 4), test/widget/booking_wizard_page_test.dart
场景 2.2: 指定技师的预约
Given 客人处于 Step 2(选择技师)
And 技师列表显示 "Sarah" 和 "Lisa"
When 客人选择 "Sarah"
Then 进入 Step 3,时间段仅显示 Sarah 的可用时间
When 客人完成后续步骤并确认
Then 预约关联到技师 Sarah
Tests: test/widget/booking_wizard_page_test.dart, test/unit/kiosk_cubit_test.dart
场景 2.3: 带手机号的预约
Given 客人处于 Step 4(确认)
When 客人输入姓名 "John" 和手机号 "2125559999"
And 确认预约
Then 客人记录通过手机号关联
And 未来可通过手机号查询该预约进行签到
Tests: test/widget/booking_wizard_page_test.dart
场景 2.4: 向导中返回上一步
Given 客人处于 Step 3(选择时间)
When 客人点击返回按钮
Then 返回 Step 2(选择技师)
And 之前选择的技师仍然高亮
When 在 Step 1 点击返回
Then 返回模式选择页(goToModeSelection())
Tests: test/widget/booking_wizard_page_test.dart
K-CUJ-3: 签到未果转预约
P0 签到查不到预约的客人被引导转为现场预约
Layer 1: CUJ 分析
| 触发点 |
客人以为自己有预约来签到,但查询不到。可能原因:记错了日期、手机号输入有误、预约已被取消、预约是用别的手机号。 |
| 阶段 |
1. 选择"签到" → 2. 输入手机号 → 3. 查询失败 → 4. 看到"未找到"提示 → 5. 决定是否预约 → 6. 进入预约向导 → 7. 完成预约+自动签到
|
| 情感曲线 |
自信 — "我有预约"
输入手机号 — 平静
未找到 — 困惑/沮丧
看到选项 — 犹豫(怎么办?)
决定预约 — 恢复信心
完成预约 — 释然
|
| 触点 |
PhoneInputPage → CheckinPage(空状态)→ BookingWizardPage(Step 1-4)→ SuccessPage
|
| 痛点 |
- "未找到"时客人不知道为什么 — 是手机号错了?预约被取消了?记错日期了?缺乏诊断信息
- 没有"重新输入手机号"的明确选项 — 如果只是输错一位数字,客人想改而不是放弃
- 签到→预约的流程切换不够顺滑 — 心理上从"确认到达"切换到"重新选择服务"需要情绪调整
- 之前输入的手机号没有带入预约流程 — 到 Step 4 时还需要重新输入
|
| 机会点 |
- 提示可能原因:"请确认手机号是否正确" / "您的预约可能不是今天"
- 提供"重新输入手机号"按钮,和"立即预约"并列
- 一键跳转预约时,自动将已输入的手机号带入 Step 4 确认页
- 记录"签到未果"事件用于运营分析(有多少客人走到这步?转化率如何?)
|
Layer 2: User Flow (代码实际行为)
flowchart TD
A["手机号输入页"] --> B["输入手机号"]
B --> C["点击查询
lookupPhone()"]
C --> D{"找到预约?"}
D -->|"是"| E["→ K-CUJ-1 签到页(有数据)"]
D -->|"否 / API 错误"| F["签到页 — 空状态
icon + '未找到预约'"]
F --> G{"客人选择"}
G -->|"'Book New' 按钮"| H["进入预约向导 Step 1
startBookingFromCheckin()"]
G -->|"'重新输入手机号' 按钮"| I2["手机号输入页
retryPhoneInput()"]
G -->|"返回箭头"| I["手机号输入页
retryPhoneInput()"]
H --> J["→ K-CUJ-2 预约流程
phoneNumber 保留在 state 中"]
J --> K["成功页
已自动签到"]
style F fill:#FF9800,stroke:#E65100,color:#fff
style K fill:#4CAF50,stroke:#2E7D32,color:#fff
style E fill:#4CAF50,stroke:#2E7D32,color:#fff
style I fill:#1565C0,stroke:#0D47A1,color:#fff
style I2 fill:#1565C0,stroke:#0D47A1,color:#fff
✅ 已修复 (Step 2 + Step 3)
- "重新输入手机号"按钮: 已添加
retryPhoneInput() 方法 + UI 按钮 + i18n 键。
- 手机号带入 Step 4:
_ConfirmationStep 已改为 StatefulWidget,TextEditingController 从 state 初始化。
- 空状态诊断信息: 已添加提示文案 "请确认手机号是否正确,或预约日期是否为今天"。
- 签到页返回箭头: 已改为
retryPhoneInput()。
Layer 3: BDD 场景
场景 3.1: 签到未果后转预约 (Happy Path)
Given 客人输入手机号 "2125551234" 查询签到
And 该手机号今天没有预约
When 查询返回空结果
Then 显示签到页空状态(icon + "未找到预约"文案)
And 显示"Book New Appointment"按钮
When 客人点击"Book New Appointment"
Then 进入预约向导 Step 1
When 客人完成 4 步预约流程
Then 成功页显示预约已确认且已自动签到
Tests: test/widget/checkin_page_test.dart (空状态), test/unit/kiosk_cubit_test.dart
场景 3.1b: 手机号自动带入预约 Step 4
Given 客人在签到流程中输入了手机号 "2125551234"
And 查询未找到预约后点击"Book New"
When 客人到达 Step 4 确认页
Then 手机号输入框应预填 "2125551234"
And 客人无需重新输入手机号
Tests: test/widget/booking_wizard_page_test.dart ✅ 已实现 — StatefulWidget + TextEditingController 预填
场景 3.2: 手机号输错后重新输入
Given 客人输入了错误的手机号,查询未找到预约
When 客人点击"重新输入手机号"
Then 返回手机号输入页
And 输入框已清空,准备重新输入
When 客人输入正确的手机号
And 查询找到预约
Then 进入正常签到流程(K-CUJ-1)
Tests: test/widget/checkin_page_test.dart ✅ 已实现 — retryPhoneInput() + 按钮 + i18n
场景 3.3: 签到未果后放弃离开
Given 客人查询签到未找到预约
When 客人不操作,等待 120 秒
Then Kiosk 自动返回欢迎页(idle 超时)
Tests: ✅ idle 超时已在 KioskShellPage 实现(Listener + 120s Timer + 10s 倒计时警告浮层)
K-CUJ-4: 管理员初始设置
P1 一次性配置 — 管理员登录并为 Kiosk 选择门店
Layer 1: CUJ 分析
| 触发点 |
新 Kiosk iPad 到店,或需要重新配置(更换门店/重置设备)。由门店管理员操作。 |
| 阶段 |
1. 启动 App → 2. 管理员登录 → 3. 选择门店 → 4. 配置保存 → 5. 进入运行模式
|
| 情感曲线 |
拿到设备 — 期待
登录 — 找密码有点烦
选门店 — 简单直接
配置完成 — 满足
|
| 触点 |
AdminLoginPage → StoreSelectionPage → WelcomePage(配置完成后落地页)
|
| 痛点 |
- 登录凭据需要管理员记住(没有"记住我"或生物识别)
- 重置入口通过长按左上角触发,不容易被发现(but 这也是防止客人误触的设计)
- 配置后无法远程修改(换门店需要物理接触设备)
|
| 机会点 |
- QR 码快速配置(管理后台生成,iPad 扫码即完成)
- MDM(移动设备管理)集成,远程推送配置
- 管理后台可查看 Kiosk 在线状态和最后活跃时间
|
Layer 2: User Flow
flowchart TD
A["应用启动"] --> B{"已有配置?"}
B -->|"否"| C["管理员登录页"]
B -->|"是"| D["欢迎页"]
C --> E["输入邮箱 + 密码"]
E --> F{"登录成功?"}
F -->|"是"| G["门店选择页"]
F -->|"否"| H["错误提示"]
H --> C
G --> I["从列表中选择门店"]
I --> J["配置保存到 SecureStorage"]
J --> D
style A fill:#0B6E6E,stroke:#064E4E,color:#fff
style D fill:#4CAF50,stroke:#2E7D32,color:#fff
style H fill:#f44336,stroke:#c62828,color:#fff
Layer 3: BDD 场景
场景 4.1: 首次管理员设置 (Happy Path)
Given Kiosk 没有已保存的配置(首次启动)
When 用户输入有效邮箱和密码
And 从列表中选择门店 "Main Street Salon"
Then 配置(token, storeId, tenant)被持久化保存到 SecureStorage
And 自动跳转到欢迎页
And 后续启动直接进入欢迎页
Tests: integration_test/app_test.dart (flow 1), test/widget/admin_login_page_test.dart, test/unit/admin_setup_cubit_test.dart
场景 4.2: 登录失败
Given Kiosk 显示配置登录页
When 用户输入错误密码
Then 显示错误提示(凭据无效)
And 停留在登录页,可重试
Tests: test/widget/admin_login_page_test.dart, test/unit/admin_setup_cubit_test.dart
场景 4.3: 管理员重置 Kiosk
Given Kiosk 已配置并显示欢迎页
When 用户长按左上角
Then 显示设置底部弹窗
When 用户点击"重置"
Then SecureStorage 中的配置被清除
And 跳转到管理员登录页
Tests: test/widget/kiosk_settings_sheet_test.dart
设计决策
预约约束
- 仅限当天: 所有 Kiosk 预约限制为当天日期,鼓励即时转化
- 自动签到: Walk-in 预约在确认后自动签到(无需单独签到步骤)
手机号收集策略
- 流程末端收集: 手机号在最终确认步骤收集,而非流程开头
- 可选但引导: UI 强烈建议输入手机号("强烈推荐"),但提供小字"跳过"链接
- 降低摩擦: 避免前置门槛,减少客人对开始预约流程的抵触感
客人身份模型
第一层: 匿名客人 — 仅有姓名,source: 'kiosk',无手机号/邮箱
第二层: 已识别客人 — 有手机号,未来可通过手机号查询签到
第三层: 注册用户 — 完整账户(有密码),可登录客人移动端 App
- 有手机号: 通过手机号查找/创建客人记录,支持未来签到查询
- 无手机号: 创建匿名客人记录(
source: 'kiosk'),不做姓名去重
- 数据库约束:
appointments.guest_id NOT NULL 要求所有预约必须关联客人记录
SMS 验证码跳过
- Kiosk 场景: 对于
source: 'kiosk' + X-Device-Type: kiosk 的请求,跳过 SMS 验证码校验
- 安全理由: Kiosk 的物理位置隐式验证了客人确实在店内
Idle 超时机制
- 120 秒无操作: 自动返回欢迎页,防止前一个客人的信息暴露给下一个
- 成功页 15 秒: 成功后较短超时,加快 Kiosk 周转
测试覆盖率总结
| CUJ |
integration_test |
Mock 集成测试 |
Widget 测试 |
单元测试 |
状态 |
| K-CUJ-1: 签到 |
app_test.dart (flow 2) |
kiosk_flow_test.dart |
phone_input, checkin_page |
kiosk_cubit |
完整覆盖 |
| K-CUJ-2: 预约 |
app_test.dart (flows 3, 4) |
kiosk_flow_test.dart |
booking_wizard, success_page |
kiosk_cubit |
完整覆盖 |
| K-CUJ-3: 签到未果转预约 |
— |
— |
checkin_page (空状态 + 重新输入按钮) |
kiosk_cubit (retryPhoneInput + selectEmployee) |
完整覆盖 |
| K-CUJ-4: 管理员设置 |
app_test.dart (flow 1) |
— |
admin_login, settings_sheet |
admin_setup_cubit |
完整覆盖 |
修复记录 (Step 2 + Step 3)
| 类型 | 问题 | CUJ | BDD 场景 | 状态 |
| ✅ 已修复 |
"重新输入手机号"按钮 — retryPhoneInput() + UI + i18n |
K-CUJ-3 |
3.2 |
checkin_page.dart, kiosk_cubit.dart, app_en/zh.arb |
| ✅ 已修复 |
手机号预填 Step 4 — StatefulWidget + TextEditingController |
K-CUJ-3 |
3.1b |
booking_wizard_page.dart |
| ✅ 已修复 |
签到页返回 → 手机号输入页(retryPhoneInput) |
K-CUJ-1 |
— |
checkin_page.dart |
| ✅ 已修复 |
预约 Step 1 返回 → 模式选择页(goToModeSelection) |
K-CUJ-2 |
2.4 |
kiosk_cubit.dart |
| ✅ 已修复 |
空状态添加诊断提示文案 |
K-CUJ-3 |
— |
checkin_page.dart, app_en/zh.arb |
| ✅ 已修复 |
手机号输入页返回 → 模式选择页(goToModeSelection) |
K-CUJ-1 |
— |
phone_input_page.dart |
| ✅ 已修复 |
selectEmployee(null) 无法复原为"任意技师" — copyWith 陷阱 |
K-CUJ-2 |
2.1 |
kiosk_cubit.dart — 手动 emit 绕过 copyWith |
| ✅ 已确认 |
Idle 超时 — KioskShellPage 已实现 120s + 10s 倒计时 |
K-CUJ-3 |
3.3 |
kiosk_shell_page.dart |
Kiosk 自助服务 iPad App — CUJ 四步走文档
方法: Layer 1 CUJ → Layer 2 User Flow (Mermaid) → Layer 3 BDD (Given-When-Then) → Layer 4 E2E 测试映射
更新日期: 2026-02-07