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

257 lines
8.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# AI 建议回复功能重构说明
## 重构日期
2025-11-17
## 最新更新
2025-11-17 - 优化请求逻辑,确保每次只发送 3 次 API 请求
## 问题描述
重构前的 AI 建议功能存在以下问题:
1. **状态管理复杂**:维护了大量状态(`allSuggestions`、`loadedPages`、`batchNo`、`excContentList`、`isPageLoading`、`isRequesting` 等)
2. **页面切换逻辑复杂**:需要跟踪哪些页面已加载,切换时判断是否显示骨架屏
3. **显示逻辑不清晰**`isLoading` 只在首次加载时为 true分页切换时骨架屏显示不正确
4. **用户体验问题**:会出现 AI 辅助提示为空的情况(如图所示)
## 重构目标
1. **简化状态管理**:只保留必要的状态
2. **统一交互逻辑**每次打开建议面板时重置到第1页并重新获取数据
3. **清晰的骨架屏显示**:数据未加载完成时始终显示骨架屏
## 重构内容
### 1. `useAiReplySuggestions` Hook 重构
#### 状态简化
**重构前:**
```typescript
const [allSuggestions, setAllSuggestions] = useState<ReplySuggestion[]>([])
const [loadedPages, setLoadedPages] = useState<Set<number>>(new Set([1]))
const [isPageLoading, setIsPageLoading] = useState(false)
const [isRequesting, setIsRequesting] = useState(false)
// ... 其他状态
```
**重构后:**
```typescript
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. 骨架屏显示逻辑
**重构前:**
```typescript
const displaySuggestions = isCurrentPageLoaded ? suggestions : Array.from({ length: 3 }, ...);
```
**重构后:**
```typescript
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[]>` 按页存储数据,结构清晰
- 移除了 `isRequesting`、`isPageLoading`、`isCurrentPageLoaded` 等冗余状态
- 只需维护 `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 返回的接口保持不变,确保向后兼容:
```typescript
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页数据使用局部变量传递 `batchNo``excContentList`
2. **防重复调用保护**:在 `fetchAllData` 开始时检查 `loadingPages.size > 0`,如果已有加载则跳过
3. **移除分页独立加载**:删除了 `fetchPageData` 函数和 `handlePageChange` 中的数据获取逻辑
4. **简化页面切换**`handlePageChange` 只负责切换 `currentPage`,不触发数据加载
### 渐进式加载流程
采用**渐进式加载**策略,让用户尽早看到数据,提升体验:
```typescript
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. 测试新消息场景:发送消息后面板已打开时自动刷新