crush-level-web/docs/AiReplySuggestions-Refactor.md

8.6 KiB
Raw Blame History

AI 建议回复功能重构说明

重构日期

2025-11-17

最新更新

2025-11-17 - 优化请求逻辑,确保每次只发送 3 次 API 请求

问题描述

重构前的 AI 建议功能存在以下问题:

  1. 状态管理复杂:维护了大量状态(allSuggestionsloadedPagesbatchNoexcContentListisPageLoadingisRequesting 等)
  2. 页面切换逻辑复杂:需要跟踪哪些页面已加载,切换时判断是否显示骨架屏
  3. 显示逻辑不清晰isLoading 只在首次加载时为 true分页切换时骨架屏显示不正确
  4. 用户体验问题:会出现 AI 辅助提示为空的情况(如图所示)

重构目标

  1. 简化状态管理:只保留必要的状态
  2. 统一交互逻辑每次打开建议面板时重置到第1页并重新获取数据
  3. 清晰的骨架屏显示:数据未加载完成时始终显示骨架屏

重构内容

1. useAiReplySuggestions Hook 重构

状态简化

重构前:

const [allSuggestions, setAllSuggestions] = useState<ReplySuggestion[]>([])
const [loadedPages, setLoadedPages] = useState<Set<number>>(new Set([1]))
const [isPageLoading, setIsPageLoading] = useState(false)
const [isRequesting, setIsRequesting] = useState(false)
// ... 其他状态

重构后:

const [pageData, setPageData] = useState<Map<number, ReplySuggestion[]>>(new Map())
const [loadingPages, setLoadingPages] = useState<Set<number>>(new Set())
// ... 其他必要状态

核心逻辑变化

重构前:

  • showSuggestions 函数会根据多种条件判断是否需要获取数据
  • 首次获取第1页数据然后静默获取第2、3页
  • 页面切换时复杂的加载状态判断

重构后:

  • 每次调用 showSuggestions 都重置到第1页并重新获取所有数据
  • 按页存储数据到 Map 中,逻辑更清晰
  • 页面切换时自动检查并加载缺失的页面数据

2. 数据获取流程优化

重构前流程

  1. 获取第1页 → 立即展示
  2. 静默获取第2页 → 更新 loadedPages
  3. 静默获取第3页 → 更新 loadedPages
  4. 用户切换页面时检查 loadedPages

重构后流程

  1. 清空旧数据
  2. 获取第1页 → 存入 pageData.set(1, ...)
  3. 获取第2页 → 存入 pageData.set(2, ...)
  4. 获取第3页 → 存入 pageData.set(3, ...)
  5. 用户切换页面时从 pageData 读取,无数据则显示骨架屏

3. 骨架屏显示逻辑

重构前:

const displaySuggestions = isCurrentPageLoaded ? suggestions : Array.from({ length: 3 }, ...);

重构后:

const suggestions =
  isCurrentPageLoading || !currentPageSuggestions
    ? Array.from({ length: 3 }, (_, index) => ({
        id: `skeleton-${currentPage}-${index}`,
        text: '',
        isSkeleton: true,
      }))
    : currentPageSuggestions

UI 组件根据 isSkeleton 标志渲染骨架屏或真实内容。

4. UI 组件更新

AiReplySuggestions.tsx 更新:

  • 添加 isSkeleton 字段到 ReplySuggestion 接口
  • 检查 suggestions.some(s => s.isSkeleton) 决定是否显示骨架屏
  • 骨架屏添加 animate-pulse 动画效果

重构优势

1. 简化的状态管理

  • 使用 Map<number, ReplySuggestion[]> 按页存储数据,结构清晰
  • 移除了 isRequestingisPageLoadingisCurrentPageLoaded 等冗余状态
  • 只需维护 loadingPages: Set<number> 来跟踪正在加载的页面

2. 智能的数据管理

  • 有新 AI 消息时重置到第1页并重新获取数据
  • 没有新消息时:保留之前的建议内容和当前页面位置
  • 避免了"空白建议"的问题
  • 数据加载状态清晰可见(骨架屏动画)

3. 更可靠的数据加载

  • 智能判断是否需要重新获取数据(基于最后一条 AI 消息 ID
  • 首次打开或数据为空时自动获取
  • 减少了因状态不同步导致的 bug

4. 更好的代码可维护性

  • 代码从 312 行减少到 200 行
  • 逻辑流程更清晰直观
  • 减少了状态依赖和副作用

使用示例

场景1首次打开建议面板渐进式加载

  1. 点击建议按钮 → 面板打开重置到第1页显示骨架屏
  2. ~500ms 后第1页数据返回 立即展示第1页的3条建议(用户可以开始浏览)
  3. 后台继续获取第2、3页 → 不阻塞用户交互
  4. 点击下一页 → 切换到第2页
    • 如果第2页数据已加载 → 立即显示
    • 如果第2页数据未加载 → 显示骨架屏通常第2页已在后台加载完成

场景2关闭后再次打开无新消息

  1. 关闭建议面板
  2. 再次点击建议按钮 → 面板打开
  3. 保留上次的内容和页面位置比如之前在第2页现在仍在第2页
  4. 无需重新加载数据,立即显示缓存的建议

场景3收到新 AI 消息后打开

  1. 用户发送消息AI 回复
  2. 点击建议按钮 → 面板打开
  3. 检测到新的 AI 消息 → 重置到第1页显示骨架屏
  4. 重新获取所有建议数据(基于最新的对话内容)

场景4面板已打开时收到新 AI 消息

  1. 建议面板已打开比如用户正在浏览第2页
  2. 用户发送消息AI 回复
  3. 自动刷新建议数据保持在当前页面第2页不强制切回第1页
  4. 渐进式加载新数据,用户可以继续浏览当前页

API 返回值变化

Hook 返回的接口保持不变,确保向后兼容:

return {
  suggestions, // ReplySuggestion[] (包含 isSkeleton 标志)
  currentPage, // number
  totalPages, // number
  isLoading, // boolean (只要有页面在加载就为 true)
  isVisible, // boolean
  showSuggestions, // () => void
  hideSuggestions, // () => void
  handlePageChange, // (page: number) => void
}

移除的返回值:

  • isPageLoading
  • isCurrentPageLoaded
  • refreshSuggestions

关键优化点

避免多次请求的设计

问题:原始实现会触发多次重复请求

解决方案

  1. 统一的 fetchAllData 函数一次性顺序获取3页数据使用局部变量传递 batchNoexcContentList
  2. 防重复调用保护:在 fetchAllData 开始时检查 loadingPages.size > 0,如果已有加载则跳过
  3. 移除分页独立加载:删除了 fetchPageData 函数和 handlePageChange 中的数据获取逻辑
  4. 简化页面切换handlePageChange 只负责切换 currentPage,不触发数据加载

渐进式加载流程

采用渐进式加载策略,让用户尽早看到数据,提升体验:

fetchAllData() {
  // 1. 检查防重复
  if (loadingPages.size > 0) return;

  // 2. 标记所有页面为加载中
  setLoadingPages(new Set([1, 2, 3]));

  // 3. 获取第1页 → 立即展示
  const response1 = await genSupContentV2({ aiId, excContentList: [] });
  setPageData(new Map([[1, response1]]));  // ✨ 立即展示第1页
  setLoadingPages(new Set([2, 3]));        // 标记第1页已完成

  // 4. 获取第2页 → 追加展示
  const response2 = await genSupContentV2({ aiId, batchNo, excContentList: [response1] });
  setPageData(prev => prev.set(2, response2));  // ✨ 追加第2页
  setLoadingPages(new Set([3]));                // 标记第2页已完成

  // 5. 获取第3页 → 追加展示
  const response3 = await genSupContentV2({ aiId, batchNo, excContentList: [response1, response2] });
  setPageData(prev => prev.set(3, response3));  // ✨ 追加第3页
  setLoadingPages(new Set());                   // 全部完成
}

关键优势

  • 🚀 第1页数据到达后立即展示,用户无需等待所有数据
  • 📊 后续页面数据追加展示不影响用户浏览第1页
  • ⏱️ 感知加载时间更短,提升用户体验
  • 🔄 页面2、3可以并行渲染,用户切换时自动显示骨架屏

注意事项

  1. 精确的请求次数:每次调用 fetchAllData 只会发送 3 次 API 请求第1、2、3页
  2. 智能缓存策略:没有新 AI 消息时,复用已有数据,不发送请求
  3. 网络失败处理:任一页面加载失败会中断整个流程并清空数据
  4. Coin 不足处理:任何页面触发 Coin 不足错误都会关闭整个建议面板
  5. 防重复保护:通过 loadingPages.size 检查防止并发调用

测试建议

  1. 测试正常流程:打开建议 → 浏览3页 → 关闭 → 再次打开
  2. 测试网络慢场景:确认骨架屏正确显示
  3. 测试 Coin 不足场景:确认面板正确关闭
  4. 测试新消息场景:发送消息后面板已打开时自动刷新