Files
eryao/.trellis/tasks/04-15-divination-tutorial-first-visit/prd.md
T
qzl 69b34bd723 feat: 添加起卦教程首次访问追踪和Agent时间上下文
- 后端 ProfileSettingsV1 添加 DivinationTutorialSettings 字段
- 前端三个起卦页面添加首次访问检测,自动弹出教程
- 教程展示后更新 settings 标记,避免重复弹出
- 使用本地状态管理避免并发更新覆盖问题
- Agent 系统提示添加时间上下文信息
2026-04-15 18:56:41 +08:00

12 KiB

PRD: Divination Tutorial First-Visit Tracking

Background

用户首次进入起卦相关页面时,应该自动弹出教程引导。当前实现已有教程弹窗逻辑(用户可主动点击按钮触发),但缺少"首次访问自动弹出"的能力。

需要区分三个页面的首次访问状态:

  1. 起卦方式选择页面 (divination_screen.dart) - 显示手动起卦教程弹窗 (DivinationGuideDialog)
  2. 自动起卦页面 (auto_divination_screen.dart) - 显示 Onboarding 步骤引导
  3. 手动起卦页面 (manual_divination_screen.dart) - 显示 Onboarding 步骤引导

Goal

  1. 在用户 profile 的 settings 中添加字段,记录三个页面的教程是否已展示
  2. 前端在进入对应页面时检查 settings,首次访问自动弹出教程
  3. 教程展示后,前端调用后端 API 更新 settings 标记为已展示
  4. 老用户(settings 中没有这些字段)默认视为已看过教程,不弹出

Scope

  • In scope:
    • 后端: ProfileSettingsV1 schema 添加 divination_tutorial 字段
    • 后端: 更新 API 端点支持新字段
    • 前端: ProfileSettingsV1 model 添加对应字段
    • 前端: 三个起卦页面的首次访问检测和教程自动弹出逻辑
  • Out of scope:
    • 新增教程内容(复用现有教程逻辑)
    • 用户主动触发教程的按钮(已存在,保持不变)

Current Architecture

前端现有教程逻辑

页面 文件路径 教程触发方式
起卦方式选择 apps/lib/features/divination/presentation/screens/divination_screen.dart DivinationGuideDialog 弹窗,_showGuide() 函数
自动起卦 apps/lib/features/divination/presentation/screens/auto_divination_screen.dart Onboarding 组件,_showGuide() 函数
手动起卦 apps/lib/features/divination/presentation/screens/manual_divination_screen.dart Onboarding 组件,_showGuide() 函数

后端 Settings 结构

文件: backend/src/schemas/shared/user.py

class ProfileSettingsV1(BaseModel):
    version: Literal[1] = 1
    preferences: PreferenceSettings = Field(default_factory=PreferenceSettings)
    privacy: dict[str, object] = Field(default_factory=dict)
    notification: NotificationSettings = Field(default_factory=NotificationSettings)

前端 Settings Model

文件: apps/lib/features/settings/data/models/profile_settings.dart

class ProfileSettingsV1 {
  final int version;
  final String displayName;
  final String bio;
  final String? avatarPath;
  final String? avatarUrl;
  final PreferenceSettings preferences;
  final Map<String, Object?> privacy;
  final NotificationSettings notification;
}

更新 Settings API

  • 端点: PATCH /users/me/settings
  • Service: backend/src/v1/users/service.pyupdate_settings()
  • 前端 API: apps/lib/features/settings/data/apis/profile_api.dartupdateSettings()

Technical Design

1. 后端 Schema 变更

新增 DivinationTutorialSettings:

class DivinationTutorialSettings(BaseModel):
    """起卦教程首次访问状态"""
    divination_entry_shown: bool = False   # 起卦方式选择页教程已展示
    auto_divination_shown: bool = False    # 自动起卦页教程已展示
    manual_divination_shown: bool = False  # 手动起卦页教程已展示

修改 ProfileSettingsV1:

class ProfileSettingsV1(BaseModel):
    version: Literal[1] = 1
    preferences: PreferenceSettings = Field(default_factory=PreferenceSettings)
    privacy: dict[str, object] = Field(default_factory=dict)
    notification: NotificationSettings = Field(default_factory=NotificationSettings)
    divination_tutorial: DivinationTutorialSettings = Field(default_factory=DivinationTutorialSettings)

2. 前端 Model 变更

新增 DivinationTutorialSettings:

class DivinationTutorialSettings {
  const DivinationTutorialSettings({
    this.divinationEntryShown = false,
    this.autoDivinationShown = false,
    this.manualDivinationShown = false,
  });

  final bool divinationEntryShown;
  final bool autoDivinationShown;
  final bool manualDivinationShown;

  DivinationTutorialSettings copyWith({...});
}

修改 ProfileSettingsV1:

class ProfileSettingsV1 {
  // ... existing fields ...
  final DivinationTutorialSettings divinationTutorial;

  ProfileSettingsV1 copyWith({
    // ... existing params ...
    DivinationTutorialSettings? divinationTutorial,
  });
}

3. 前端页面逻辑变更

起卦方式选择页面 (divination_screen.dart):

// 在 initState 或 didChangeDependencies 中检查
void _checkFirstVisit() {
  final settings = // 从 widget 或 context 获取 profileSettings
  if (!settings.divinationTutorial.divinationEntryShown) {
    // 延迟显示,确保页面渲染完成
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) _showGuide(context, l10n);
    });
    // 调用 API 更新 settings
    _markTutorialShown('divination_entry');
  }
}

Future<void> _markTutorialShown(String tutorialKey) async {
  // 调用 onProfileSettingsChanged 更新 settings
}

自动起卦页面 (auto_divination_screen.dart):

void _checkFirstVisit() {
  final settings = // 获取 profileSettings
  if (!settings.divinationTutorial.autoDivinationShown) {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) _showGuide();
    });
    _markTutorialShown('auto_divination');
  }
}

手动起卦页面 (manual_divination_screen.dart):

void _checkFirstVisit() {
  final settings = // 获取 profileSettings
  if (!settings.divinationTutorial.manualDivinationShown) {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) _showGuide();
    });
    _markTutorialShown('manual_divination');
  }
}

4. Settings 传递方式(已确认)

采用构造函数参数传入方式(选项 A)。

调用链分析:

EryaoApp (app.dart)
  └── HomeScreen
        ├── profileSettings: ProfileSettingsV1
        ├── onProfileSettingsChanged: Future<void> Function(ProfileSettingsV1)
        └── _onStartDivination()
              └── DivinationScreen  <-- 需要添加 profileSettings 和 onProfileSettingsChanged
                    ├── _onStart() → AutoDivinationScreen   <-- 需要添加
                    └── _onStart() → ManualDivinationScreen <-- 需要添加

需要修改的文件:

文件 修改内容
home_screen.dart:155-166 _onStartDivination() 传入 profileSettingsonProfileSettingsChanged
divination_screen.dart 构造函数添加 profileSettingsonProfileSettingsChanged;添加首次访问检测
divination_screen.dart:164-176 _onStart() 传入参数到 ManualDivinationScreenAutoDivinationScreen
auto_divination_screen.dart 构造函数添加参数;添加首次访问检测
manual_divination_screen.dart 构造函数添加参数;添加首次访问检测

5. 教程关闭回调处理

Onboarding 组件 (用于自动/手动起卦页面):

  • Onboarding 组件有 onEnd 回调参数
  • onEnd 中调用 _markTutorialShown()

DivinationGuideDialog (用于起卦方式选择页面):

  • 这是一个 Dialog,关闭时 Navigator.pop() 被调用
  • 需要在 showDialog.then() 中处理关闭回调
  • 或者在弹窗显示后立即标记(因为用户已经看到了)

推荐方案: 在弹窗显示后立即标记,无需等待关闭

  • 用户看到教程即视为"已展示"
  • 避免因用户快速关闭导致 settings 未更新

6. 老用户兼容

  • 后端新字段默认值为 false
  • 数据库中现有用户 settings 没有 divination_tutorial 字段
  • 前端解析时:
    • 如果字段不存在,视为已看过教程(不弹出)
    • 实现方式: JSON 解析时提供默认值 true,或检查字段是否存在

前端兼容逻辑:

factory DivinationTutorialSettings.fromJson(Map<String, dynamic>? json) {
  if (json == null) {
    // 老用户没有这个字段,默认全部 true(已看过)
    return const DivinationTutorialSettings(
      divinationEntryShown: true,
      autoDivinationShown: true,
      manualDivinationShown: true,
    );
  }
  return DivinationTutorialSettings(
    divinationEntryShown: json['divination_entry_shown'] ?? true,
    autoDivinationShown: json['auto_divination_shown'] ?? true,
    manualDivinationShown: json['manual_divination_shown'] ?? true,
  );
}

6. API 更新流程

用户首次看到教程后:

  1. 前端调用 _markTutorialShown('auto_divination')
  2. 构建 ProfileSettingsV1 的 copyWith 更新对应字段
  3. 调用 onProfileSettingsChanged(updatedSettings)
  4. 后端保存新 settings

注意: 只更新一个字段时,需要保留其他字段不变。

7. Onboarding 组件 onEnd 回调

查看 auto_divination_screen.dart:145-148:

return Onboarding(
  key: _onboardingKey,
  steps: steps,
  onChanged: _onGuideStepChanged,
  // 需要添加: onEnd: _markTutorialShown,
);

manual_divination_screen.dart 同理。

Implementation Checklist

后端

  • backend/src/schemas/shared/user.py 添加 DivinationTutorialSettings 和修改 ProfileSettingsV1
  • 确认 API 端点无需修改(使用现有的 PATCH + 完整 settings 替换)
  • 单元测试: 新 schema 的序列化/反序列化

前端

  • apps/lib/features/settings/data/models/profile_settings.dart 添加 DivinationTutorialSettings
  • apps/lib/features/settings/data/apis/profile_api.dart 更新 _toSettings() 解析逻辑
  • 确认三个页面如何获取 ProfileSettingsV1(需要调查)
  • divination_screen.dart 添加首次访问检测
  • auto_divination_screen.dart 添加首次访问检测
  • manual_divination_screen.dart 添加首次访问检测
  • 测试老用户兼容性(settings 无此字段时不弹出)

测试场景

  1. 新用户首次访问: 进入页面 → 自动弹出教程 → settings 更新为 shown
  2. 新用户再次访问: 进入页面 → 不弹出教程
  3. 老用户首次访问: settings 无此字段 → 不弹出教程(视为已看过)
  4. 用户主动触发: 点击教程按钮 → 正常弹出(无论是否首次)

Risks & Mitigations

风险 缓解措施
前端解析 settings 时字段缺失 使用 ?? true 默认值,老用户视为已看过
教程弹出时机过早导致 UI 闪烁 使用 addPostFrameCallback 延迟到下一帧
API 调用失败导致 settings 未更新 不影响用户体验,下次访问仍会弹出(符合预期)
并发更新 settings 可能覆盖其他字段 前端使用 copyWith 保留其他字段

Open Questions

  1. Settings 传递方式: 已确认使用构造函数参数传入。
  2. 教程关闭回调: 建议在弹窗显示后立即标记,无需等待关闭。

File Changes Summary

文件 类型 修改内容
backend/src/schemas/shared/user.py 后端 添加 DivinationTutorialSettings 和修改 ProfileSettingsV1
apps/lib/features/settings/data/models/profile_settings.dart 前端 添加 DivinationTutorialSettings 和修改 ProfileSettingsV1
apps/lib/features/settings/data/apis/profile_api.dart 前端 更新 _toSettings() 解析新字段
apps/lib/features/home/presentation/screens/home_screen.dart 前端 _onStartDivination() 传入 profileSettingsonProfileSettingsChanged
apps/lib/features/divination/presentation/screens/divination_screen.dart 前端 添加构造函数参数 + 首次访问检测 + _markTutorialShown()
apps/lib/features/divination/presentation/screens/auto_divination_screen.dart 前端 添加构造函数参数 + 首次访问检测 + _markTutorialShown()
apps/lib/features/divination/presentation/screens/manual_divination_screen.dart 前端 添加构造函数参数 + 首次访问检测 + _markTutorialShown()