# 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. 测试新消息场景：发送消息后面板已打开时自动刷新
