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

239 lines
8.7 KiB
Markdown
Raw Permalink Normal View History

2025-11-24 03:47:20 +00:00
# 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. 测试新消息场景:发送消息后面板已打开时自动刷新