feat: 实现事件总线系统用于跨页面通信
## 新增功能 - 创建事件总线工具 (src/utils/eventBus.js) - 支持跨页面事件通信,无需直接依赖 ## 页面修改 - feedback-list 页面:监听反馈提交事件并刷新列表 - favorites 页面:监听收藏更新事件并刷新列表 - feedback 页面:提交成功后发送事件 ## Composable 修改 - useCollectOperation:收藏操作成功后发送事件 ## 问题修复 - 修复 favorites 和 feedback-list 页面使用 useDidShow 导致的列表意外刷新问题 - 改用事件总线模式,仅在特定事件触发时刷新列表 - LoadMoreList 页面仅使用 useLoad 进行一次性初始化 ## 文档更新 - 更新 docs/lessons-learned.md,新增"跨页面通信"章节 - 记录事件总线实现模式和 useDidShow 陷阱解决方案 ## 技术方案 - 跨页面操作:使用事件总线(收藏、提交反馈) - 单页面操作:使用本地更新(删除收藏) - LoadMoreList 页面:useLoad + 事件总线,避免 useDidShow Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Showing
8 changed files
with
1146 additions
and
225 deletions
| ... | @@ -14,6 +14,8 @@ | ... | @@ -14,6 +14,8 @@ |
| 14 | | 3 | 产品中心 | `src/pages/product-center/index.vue` | `mockProductListAPI` | ✅ 已完成 | | 14 | | 3 | 产品中心 | `src/pages/product-center/index.vue` | `mockProductListAPI` | ✅ 已完成 | |
| 15 | | 4 | 搜索页 | `src/pages/search/index.vue` | `mockSearchAPI` | ✅ 已完成 | | 15 | | 4 | 搜索页 | `src/pages/search/index.vue` | `mockSearchAPI` | ✅ 已完成 | |
| 16 | | 5 | 消息列表 | `src/pages/message/index.vue` | `mockMessageListAPI` | ✅ 已完成 | | 16 | | 5 | 消息列表 | `src/pages/message/index.vue` | `mockMessageListAPI` | ✅ 已完成 | |
| 17 | +| 6 | 收藏列表 | `src/pages/favorites/index.vue` | `mockFavoriteListAPI` | ✅ 已完成 | | ||
| 18 | +| 7 | 意见反馈列表 | `src/pages/feedback-list/index.vue` | `mockFeedbackListAPI` | ✅ 已完成 | | ||
| 17 | 19 | ||
| 18 | --- | 20 | --- |
| 19 | 21 | ||
| ... | @@ -130,6 +132,59 @@ const USE_MOCK_DATA = true // ✅ 已启用 | ... | @@ -130,6 +132,59 @@ const USE_MOCK_DATA = true // ✅ 已启用 |
| 130 | 132 | ||
| 131 | --- | 133 | --- |
| 132 | 134 | ||
| 135 | +### 6. 收藏列表页 ✅ | ||
| 136 | + | ||
| 137 | +**文件**: `src/pages/favorites/index.vue` | ||
| 138 | + | ||
| 139 | +**修改**: | ||
| 140 | +- ✅ 导入 `mockFavoriteListAPI` | ||
| 141 | +- ✅ 添加 `USE_MOCK_DATA = true` 开关 (第 73 行) | ||
| 142 | +- ✅ 使用 LoadMoreList 组件重构 | ||
| 143 | +- ✅ 修改 `fetchFavoritesList` 函数 (第 122-180 行) | ||
| 144 | +- ✅ 添加 `handleLoadMore` 函数 (第 246-257 行) | ||
| 145 | + | ||
| 146 | +**使用**: | ||
| 147 | +```javascript | ||
| 148 | +// 第 73 行 | ||
| 149 | +const USE_MOCK_DATA = true // ✅ 已启用 | ||
| 150 | +``` | ||
| 151 | + | ||
| 152 | +**特性**: | ||
| 153 | +- ✅ 20 种收藏资料模板 | ||
| 154 | +- ✅ 随机创建时间(最近90天内) | ||
| 155 | +- ✅ 随机文件大小和类型 | ||
| 156 | +- ✅ 支持查看和删除操作 | ||
| 157 | +- ✅ 3 页数据,每页 20 条 | ||
| 158 | + | ||
| 159 | +--- | ||
| 160 | + | ||
| 161 | +### 7. 意见反馈列表页 ✅ | ||
| 162 | + | ||
| 163 | +**文件**: `src/pages/feedback-list/index.vue` | ||
| 164 | + | ||
| 165 | +**修改**: | ||
| 166 | +- ✅ 导入 `mockFeedbackListAPI` | ||
| 167 | +- ✅ 添加 `USE_MOCK_DATA = true` 开关 (第 96 行) | ||
| 168 | +- ✅ 使用 LoadMoreList 组件重构 | ||
| 169 | +- ✅ 修改 `loadFeedbackList` 函数 (第 184-236 行) | ||
| 170 | +- ✅ 添加 `handleLoadMore` 函数 (第 243-254 行) | ||
| 171 | + | ||
| 172 | +**使用**: | ||
| 173 | +```javascript | ||
| 174 | +// 第 96 行 | ||
| 175 | +const USE_MOCK_DATA = true // ✅ 已启用 | ||
| 176 | +``` | ||
| 177 | + | ||
| 178 | +**特性**: | ||
| 179 | +- ✅ 20 种反馈内容模板 | ||
| 180 | +- ✅ 3 种反馈分类(功能建议、问题反馈、其他问题) | ||
| 181 | +- ✅ 60%概率已处理 | ||
| 182 | +- ✅ 已处理的反馈有70%概率有回复 | ||
| 183 | +- ✅ 30%概率包含截图 | ||
| 184 | +- ✅ 5 页数据,每页 10 条 | ||
| 185 | + | ||
| 186 | +--- | ||
| 187 | + | ||
| 133 | ## 全局测试步骤 | 188 | ## 全局测试步骤 |
| 134 | 189 | ||
| 135 | ### 1. 启动开发服务器 | 190 | ### 1. 启动开发服务器 |
| ... | @@ -176,6 +231,22 @@ pnpm dev:weapp | ... | @@ -176,6 +231,22 @@ pnpm dev:weapp |
| 176 | 4. 查看消息时间格式(YYYY-MM-DD) | 231 | 4. 查看消息时间格式(YYYY-MM-DD) |
| 177 | 5. 滚动到底,显示"没有更多了" | 232 | 5. 滚动到底,显示"没有更多了" |
| 178 | 233 | ||
| 234 | +#### 收藏列表页 | ||
| 235 | +1. 导航到"我的收藏"页 | ||
| 236 | +2. 查看首次加载(20 条数据) | ||
| 237 | +3. 向下滚动加载更多 | ||
| 238 | +4. 查看收藏时间格式(YYYY-MM-DD) | ||
| 239 | +5. 滚动到底,显示"没有更多了" | ||
| 240 | +6. 测试删除功能(Mock模式) | ||
| 241 | + | ||
| 242 | +#### 意见反馈列表页 | ||
| 243 | +1. 导航到"意见反馈"页 | ||
| 244 | +2. 查看首次加载(10 条数据) | ||
| 245 | +3. 向下滚动加载更多 | ||
| 246 | +4. 查看反馈分类标签(功能建议、问题反馈、其他问题) | ||
| 247 | +5. 查看处理状态(已处理/待处理) | ||
| 248 | +6. 滚动到底,显示"没有更多了" | ||
| 249 | + | ||
| 179 | ### 3. 查看 Console 日志 | 250 | ### 3. 查看 Console 日志 |
| 180 | 251 | ||
| 181 | 正常情况下会看到: | 252 | 正常情况下会看到: |
| ... | @@ -185,6 +256,8 @@ pnpm dev:weapp | ... | @@ -185,6 +256,8 @@ pnpm dev:weapp |
| 185 | [Mock] listAPI - 第0页,共10条 | 256 | [Mock] listAPI - 第0页,共10条 |
| 186 | [Mock] searchAPI - 第0页,产品10条,资料10条 | 257 | [Mock] searchAPI - 第0页,产品10条,资料10条 |
| 187 | [Mock] myListAPI - 第1页,共10条 | 258 | [Mock] myListAPI - 第1页,共10条 |
| 259 | +[Mock] favoriteListAPI - 第0页,共20条 | ||
| 260 | +[Mock] feedbackListAPI - 第0页,共10条 | ||
| 188 | ``` | 261 | ``` |
| 189 | 262 | ||
| 190 | --- | 263 | --- |
| ... | @@ -202,7 +275,9 @@ sed -i '' 's/const USE_MOCK_DATA = true/const USE_MOCK_DATA = false/g' \ | ... | @@ -202,7 +275,9 @@ sed -i '' 's/const USE_MOCK_DATA = true/const USE_MOCK_DATA = false/g' \ |
| 202 | src/pages/material-list/index.vue \ | 275 | src/pages/material-list/index.vue \ |
| 203 | src/pages/product-center/index.vue \ | 276 | src/pages/product-center/index.vue \ |
| 204 | src/pages/search/index.vue \ | 277 | src/pages/search/index.vue \ |
| 205 | - src/pages/message/index.vue | 278 | + src/pages/message/index.vue \ |
| 279 | + src/pages/favorites/index.vue \ | ||
| 280 | + src/pages/feedback-list/index.vue | ||
| 206 | ``` | 281 | ``` |
| 207 | 282 | ||
| 208 | ### 手动切换 | 283 | ### 手动切换 |
| ... | @@ -217,12 +292,14 @@ const USE_MOCK_DATA = true | ... | @@ -217,12 +292,14 @@ const USE_MOCK_DATA = true |
| 217 | const USE_MOCK_DATA = false | 292 | const USE_MOCK_DATA = false |
| 218 | ``` | 293 | ``` |
| 219 | 294 | ||
| 220 | -**需要修改的 5 个文件**: | 295 | +**需要修改的 7 个文件**: |
| 221 | 1. `src/pages/week-hot-material/index.vue` | 296 | 1. `src/pages/week-hot-material/index.vue` |
| 222 | 2. `src/pages/material-list/index.vue` | 297 | 2. `src/pages/material-list/index.vue` |
| 223 | 3. `src/pages/product-center/index.vue` | 298 | 3. `src/pages/product-center/index.vue` |
| 224 | 4. `src/pages/search/index.vue` | 299 | 4. `src/pages/search/index.vue` |
| 225 | 5. `src/pages/message/index.vue` | 300 | 5. `src/pages/message/index.vue` |
| 301 | +6. `src/pages/favorites/index.vue` | ||
| 302 | +7. `src/pages/feedback-list/index.vue` | ||
| 226 | 303 | ||
| 227 | --- | 304 | --- |
| 228 | 305 | ||
| ... | @@ -288,6 +365,29 @@ const USE_MOCK_DATA = false | ... | @@ -288,6 +365,29 @@ const USE_MOCK_DATA = false |
| 288 | | `is_read` | integer | 是否已读 | 0 或 1 | | 365 | | `is_read` | integer | 是否已读 | 0 或 1 | |
| 289 | | `type` | string | 消息类型 | "notice" 或 "system" | | 366 | | `type` | string | 消息类型 | "notice" 或 "system" | |
| 290 | 367 | ||
| 368 | +### 收藏列表 | ||
| 369 | + | ||
| 370 | +| 字段 | 类型 | 说明 | 示例 | | ||
| 371 | +|------|------|------|------| | ||
| 372 | +| `meta_id` | integer | 文件ID | 1, 2, 3... | | ||
| 373 | +| `name` | string | 文件名称(含扩展名) | "财富管理基础知识指南.pdf" | | ||
| 374 | +| `size` | string | 文件大小 | "2.5MB" | | ||
| 375 | +| `src` | string | 文件URL | "https://cdn.example.com/files/1.pdf" | | ||
| 376 | +| `created_time` | string | 收藏时间 | "2024-01-15" | | ||
| 377 | + | ||
| 378 | +### 意见反馈列表 | ||
| 379 | + | ||
| 380 | +| 字段 | 类型 | 说明 | 示例 | | ||
| 381 | +|------|------|------|------| | ||
| 382 | +| `id` | integer | 反馈ID | 1, 2, 3... | | ||
| 383 | +| `category` | string | 反馈分类 | "1"=功能建议, "3"=问题反馈, "7"=其他问题 | | ||
| 384 | +| `status` | integer | 处理状态 | 1=待处理, 5=已处理 | | ||
| 385 | +| `note` | string | 反馈内容 | "希望能够增加资料下载功能" | | ||
| 386 | +| `images` | Array<string> | 截图URL列表 | ["https://placehold.co/200x200/..."] | | ||
| 387 | +| `contact` | string | 联系方式 | "138****8888" | | ||
| 388 | +| `reply` | string | 客服回复 | "感谢您的宝贵建议..." | | ||
| 389 | +| `reply_time` | string | 回复时间 | "2024-01-15" | | ||
| 390 | + | ||
| 291 | --- | 391 | --- |
| 292 | 392 | ||
| 293 | ## 常见问题 | 393 | ## 常见问题 |
| ... | @@ -345,6 +445,10 @@ const totalPages = 10 // 从 5 改为 10 | ... | @@ -345,6 +445,10 @@ const totalPages = 10 // 从 5 改为 10 |
| 345 | - [ ] 搜索页产品/资料切换正常 | 445 | - [ ] 搜索页产品/资料切换正常 |
| 346 | - [ ] 搜索页滚动加载正常 | 446 | - [ ] 搜索页滚动加载正常 |
| 347 | - [ ] 消息列表页滚动加载正常 | 447 | - [ ] 消息列表页滚动加载正常 |
| 448 | +- [ ] 收藏列表页滚动加载正常 | ||
| 449 | +- [ ] 收藏列表页删除功能正常(Mock模式) | ||
| 450 | +- [ ] 意见反馈列表页滚动加载正常 | ||
| 451 | +- [ ] 意见反馈列表页分类和状态显示正常 | ||
| 348 | - [ ] 所有页面 Console 日志正确输出 | 452 | - [ ] 所有页面 Console 日志正确输出 |
| 349 | - [ ] 所有页面"没有更多了"正常显示 | 453 | - [ ] 所有页面"没有更多了"正常显示 |
| 350 | - [ ] 数据格式正确,无报错 | 454 | - [ ] 数据格式正确,无报错 | ... | ... |
| ... | @@ -16,6 +16,7 @@ | ... | @@ -16,6 +16,7 @@ |
| 16 | - [错误处理](#3-错误处理) | 16 | - [错误处理](#3-错误处理) |
| 17 | - [API 调用错误:使用 fn() 包装](#坑-api-调用了-fn-包装重复-2-次) ⭐ 新增 | 17 | - [API 调用错误:使用 fn() 包装](#坑-api-调用了-fn-包装重复-2-次) ⭐ 新增 |
| 18 | - [架构设计](#架构设计) | 18 | - [架构设计](#架构设计) |
| 19 | +- [跨页面通信](#跨页面通信) ⭐ 新增 | ||
| 19 | - [开发工作流](#开发工作流) ⭐ 新增 | 20 | - [开发工作流](#开发工作流) ⭐ 新增 |
| 20 | - [Mock 数据环境自动切换](#mock-数据环境自动切换模式) ⭐ 新增 | 21 | - [Mock 数据环境自动切换](#mock-数据环境自动切换模式) ⭐ 新增 |
| 21 | 22 | ||
| ... | @@ -1041,6 +1042,368 @@ src/ | ... | @@ -1041,6 +1042,368 @@ src/ |
| 1041 | 1042 | ||
| 1042 | --- | 1043 | --- |
| 1043 | 1044 | ||
| 1045 | +## 跨页面通信 | ||
| 1046 | + | ||
| 1047 | +### ❌ 坑: useDidShow 导致列表意外刷新 | ||
| 1048 | + | ||
| 1049 | +**问题描述**: | ||
| 1050 | +在收藏列表页(favorites)和反馈列表页(feedback-list)中,使用 `useDidShow` 钩子会导致列表在关闭图片预览时意外刷新。 | ||
| 1051 | + | ||
| 1052 | +**错误表现**: | ||
| 1053 | +- 用户点击图片预览(使用 `Taro.previewImage()`) | ||
| 1054 | +- 关闭预览后,`useDidShow` 钩子触发 | ||
| 1055 | +- 列表重新加载,显示 loading 动画 | ||
| 1056 | +- 用户体验差,感觉应用卡顿 | ||
| 1057 | + | ||
| 1058 | +**错误代码**: | ||
| 1059 | +```javascript | ||
| 1060 | +// ❌ 错误:使用 useDidShow 导致意外刷新 | ||
| 1061 | +import { useLoad, useDidShow } from '@tarojs/taro' | ||
| 1062 | + | ||
| 1063 | +useLoad(() => { | ||
| 1064 | + // 页面首次加载 | ||
| 1065 | + fetchList({ page: 0, limit: pageSize }) | ||
| 1066 | +}) | ||
| 1067 | + | ||
| 1068 | +useDidShow(() => { | ||
| 1069 | + // ❌ 每次页面显示都刷新(包括关闭图片预览时) | ||
| 1070 | + refreshList() | ||
| 1071 | +}) | ||
| 1072 | +``` | ||
| 1073 | + | ||
| 1074 | +**根本原因**: | ||
| 1075 | +- `useDidShow` 在任何页面显示时都会触发 | ||
| 1076 | +- 关闭 `Taro.previewImage()` 会触发页面显示事件 | ||
| 1077 | +- 不区分正常的页面返回(需要刷新)和模态框关闭(不需要刷新) | ||
| 1078 | + | ||
| 1079 | +**解决方案**: 仅使用 `useLoad` + 事件总线 | ||
| 1080 | + | ||
| 1081 | +```javascript | ||
| 1082 | +// ✅ 正确:仅 useLoad 初始化 + 事件总线刷新特定事件 | ||
| 1083 | +import { useLoad } from '@tarojs/taro' | ||
| 1084 | +import { ref, onMounted, onUnmounted } from 'vue' | ||
| 1085 | +import eventBus, { Events } from '@/utils/eventBus' | ||
| 1086 | + | ||
| 1087 | +// 首次加载时获取列表 | ||
| 1088 | +useLoad(() => { | ||
| 1089 | + console.log('[Favorites] 页面加载,获取列表') | ||
| 1090 | + currentPage.value = 0 | ||
| 1091 | + hasMore.value = true | ||
| 1092 | + fetchList({ page: 0, limit: pageSize }) | ||
| 1093 | +}) | ||
| 1094 | + | ||
| 1095 | +// 监听收藏更新事件(收藏/取消收藏时触发) | ||
| 1096 | +onMounted(() => { | ||
| 1097 | + console.log('[Favorites] 注册事件监听') | ||
| 1098 | + | ||
| 1099 | + const unsubscribe = eventBus.on(Events.FAVORITES_UPDATE, async (data) => { | ||
| 1100 | + console.log('[Favorites] 收到收藏更新事件:', data) | ||
| 1101 | + await refreshList() | ||
| 1102 | + }) | ||
| 1103 | + | ||
| 1104 | + // 组件卸载时取消监听 | ||
| 1105 | + onUnmounted(() => { | ||
| 1106 | + unsubscribe() | ||
| 1107 | + console.log('[Favorites] 取消事件监听') | ||
| 1108 | + }) | ||
| 1109 | +}) | ||
| 1110 | +``` | ||
| 1111 | + | ||
| 1112 | +**收益**: | ||
| 1113 | +- ✅ 避免了关闭图片预览时的意外刷新 | ||
| 1114 | +- ✅ 保留了跨页面操作后的自动刷新(如收藏、提交反馈) | ||
| 1115 | +- ✅ 用户体验显著提升 | ||
| 1116 | + | ||
| 1117 | +**相关文件**: | ||
| 1118 | +- `src/pages/favorites/index.vue`(已修复) | ||
| 1119 | +- `src/pages/feedback-list/index.vue`(已修复) | ||
| 1120 | + | ||
| 1121 | +### ✅ 最佳实践:事件总线模式 | ||
| 1122 | + | ||
| 1123 | +#### 使用场景判断 | ||
| 1124 | + | ||
| 1125 | +**关键原则**:跨页面操作需要事件总线,单页面操作使用本地更新 | ||
| 1126 | + | ||
| 1127 | +| 操作类型 | 是否跨页面 | 数据同步方式 | 示例 | | ||
| 1128 | +|---------|----------|------------|------| | ||
| 1129 | +| **收藏操作** | ✅ 是 | 事件总线 | 在产品详情页收藏 → 收藏列表页自动刷新 | | ||
| 1130 | +| **提交反馈** | ✅ 是 | 事件总线 | 提交反馈后 → 反馈列表页自动刷新 | | ||
| 1131 | +| **删除收藏** | ❌ 否 | 本地更新 | 在收藏列表页删除 → 立即从列表移除 | | ||
| 1132 | + | ||
| 1133 | +#### 事件总线实现 | ||
| 1134 | + | ||
| 1135 | +**1. 创建事件总线**(`src/utils/eventBus.js`): | ||
| 1136 | +```javascript | ||
| 1137 | +/** | ||
| 1138 | + * 事件总线工具 | ||
| 1139 | + * | ||
| 1140 | + * @description 轻量级事件总线,用于跨页面组件通信 | ||
| 1141 | + * @module utils/eventBus | ||
| 1142 | + * @author Claude Code | ||
| 1143 | + * @created 2026-02-08 | ||
| 1144 | + */ | ||
| 1145 | + | ||
| 1146 | +const eventBus = { | ||
| 1147 | + events: {}, | ||
| 1148 | + | ||
| 1149 | + /** | ||
| 1150 | + * 监听事件 | ||
| 1151 | + * | ||
| 1152 | + * @param {string} event - 事件名称 | ||
| 1153 | + * @param {Function} callback - 回调函数 | ||
| 1154 | + * @returns {Function} 取消监听函数 | ||
| 1155 | + */ | ||
| 1156 | + on(event, callback) { | ||
| 1157 | + if (!this.events[event]) { | ||
| 1158 | + this.events[event] = [] | ||
| 1159 | + } | ||
| 1160 | + this.events[event].push(callback) | ||
| 1161 | + | ||
| 1162 | + // 返回取消监听函数 | ||
| 1163 | + return () => this.off(event, callback) | ||
| 1164 | + }, | ||
| 1165 | + | ||
| 1166 | + /** | ||
| 1167 | + * 发送事件 | ||
| 1168 | + * | ||
| 1169 | + * @param {string} event - 事件名称 | ||
| 1170 | + * @param {*} data - 事件数据 | ||
| 1171 | + */ | ||
| 1172 | + emit(event, data) { | ||
| 1173 | + if (!this.events[event]) return | ||
| 1174 | + | ||
| 1175 | + this.events[event].forEach(callback => { | ||
| 1176 | + try { | ||
| 1177 | + callback(data) | ||
| 1178 | + } catch (err) { | ||
| 1179 | + console.error(`[EventBus] 事件处理错误 [${event}]:`, err) | ||
| 1180 | + } | ||
| 1181 | + }) | ||
| 1182 | + }, | ||
| 1183 | + | ||
| 1184 | + /** | ||
| 1185 | + * 取消监听事件 | ||
| 1186 | + * | ||
| 1187 | + * @param {string} event - 事件名称 | ||
| 1188 | + * @param {Function} callback - 回调函数 | ||
| 1189 | + */ | ||
| 1190 | + off(event, callback) { | ||
| 1191 | + if (!this.events[event]) return | ||
| 1192 | + | ||
| 1193 | + if (callback) { | ||
| 1194 | + // 移除特定的回调 | ||
| 1195 | + this.events[event] = this.events[event].filter(cb => cb !== callback) | ||
| 1196 | + } else { | ||
| 1197 | + // 移除所有回调 | ||
| 1198 | + delete this.events[event] | ||
| 1199 | + } | ||
| 1200 | + }, | ||
| 1201 | + | ||
| 1202 | + /** | ||
| 1203 | + * 清空所有事件监听器 | ||
| 1204 | + */ | ||
| 1205 | + clear() { | ||
| 1206 | + this.events = {} | ||
| 1207 | + } | ||
| 1208 | +} | ||
| 1209 | + | ||
| 1210 | +/** | ||
| 1211 | + * 事件名称常量 | ||
| 1212 | + * @enum {string} | ||
| 1213 | + */ | ||
| 1214 | +export const Events = { | ||
| 1215 | + /** 反馈提交成功 */ | ||
| 1216 | + FEEDBACK_SUBMIT: 'feedback:submit', | ||
| 1217 | + /** 收藏列表更新 */ | ||
| 1218 | + FAVORITES_UPDATE: 'favorites:update', | ||
| 1219 | + /** 用户信息更新 */ | ||
| 1220 | + USER_UPDATE: 'user:update' | ||
| 1221 | +} | ||
| 1222 | + | ||
| 1223 | +export default eventBus | ||
| 1224 | +``` | ||
| 1225 | + | ||
| 1226 | +**2. 发送事件**(在操作页面): | ||
| 1227 | +```javascript | ||
| 1228 | +// src/pages/feedback/index.vue | ||
| 1229 | +import eventBus, { Events } from '@/utils/eventBus' | ||
| 1230 | + | ||
| 1231 | +const onSubmit = async () => { | ||
| 1232 | + // ... 提交逻辑 | ||
| 1233 | + | ||
| 1234 | + if (res.code === 1) { | ||
| 1235 | + Taro.showToast({ title: '提交成功', icon: 'success' }) | ||
| 1236 | + | ||
| 1237 | + // 发送事件通知反馈列表页刷新 | ||
| 1238 | + eventBus.emit(Events.FEEDBACK_SUBMIT, { | ||
| 1239 | + timestamp: Date.now() | ||
| 1240 | + }) | ||
| 1241 | + | ||
| 1242 | + // 返回上一页 | ||
| 1243 | + setTimeout(() => { | ||
| 1244 | + Taro.navigateBack() | ||
| 1245 | + }, 1500) | ||
| 1246 | + } | ||
| 1247 | +} | ||
| 1248 | +``` | ||
| 1249 | + | ||
| 1250 | +**3. 接收事件**(在列表页面): | ||
| 1251 | +```javascript | ||
| 1252 | +// src/pages/feedback-list/index.vue | ||
| 1253 | +import { ref, onMounted, onUnmounted } from 'vue' | ||
| 1254 | +import eventBus, { Events } from '@/utils/eventBus' | ||
| 1255 | + | ||
| 1256 | +onMounted(() => { | ||
| 1257 | + console.log('[Feedback] 注册事件监听') | ||
| 1258 | + | ||
| 1259 | + // 监听反馈提交成功事件 | ||
| 1260 | + const unsubscribe = eventBus.on(Events.FEEDBACK_SUBMIT, async (data) => { | ||
| 1261 | + console.log('[Feedback] 收到反馈提交事件:', data) | ||
| 1262 | + | ||
| 1263 | + // 刷新列表 | ||
| 1264 | + await refreshList() | ||
| 1265 | + }) | ||
| 1266 | + | ||
| 1267 | + // 组件卸载时取消监听 | ||
| 1268 | + onUnmounted(() => { | ||
| 1269 | + unsubscribe() | ||
| 1270 | + console.log('[Feedback] 取消事件监听') | ||
| 1271 | + }) | ||
| 1272 | +}) | ||
| 1273 | +``` | ||
| 1274 | + | ||
| 1275 | +**4. Composable 中发送事件**(收藏操作): | ||
| 1276 | +```javascript | ||
| 1277 | +// src/composables/useCollectOperation.js | ||
| 1278 | +import eventBus, { Events } from '@/utils/eventBus' | ||
| 1279 | + | ||
| 1280 | +export function useCollectOperation(options = {}) { | ||
| 1281 | + const { onSuccess, onError } = options | ||
| 1282 | + | ||
| 1283 | + const toggleCollect = async (item, successMsg, errorMsg = '操作失败') => { | ||
| 1284 | + try { | ||
| 1285 | + // 乐观更新 UI | ||
| 1286 | + const newCollectStatus = !item.collected | ||
| 1287 | + item.collected = newCollectStatus | ||
| 1288 | + | ||
| 1289 | + const metaId = item.meta_id || item.id | ||
| 1290 | + | ||
| 1291 | + // 调用 API | ||
| 1292 | + const res = newCollectStatus | ||
| 1293 | + ? await addAPI({ meta_id: metaId }) | ||
| 1294 | + : await delAPI({ meta_id: metaId }) | ||
| 1295 | + | ||
| 1296 | + if (res.code === 1) { | ||
| 1297 | + Taro.showToast({ | ||
| 1298 | + title: successMsg || (newCollectStatus ? '已收藏' : '已取消收藏'), | ||
| 1299 | + icon: 'success', | ||
| 1300 | + duration: 1000 | ||
| 1301 | + }) | ||
| 1302 | + | ||
| 1303 | + // 发送收藏更新事件(通知收藏列表页刷新) | ||
| 1304 | + eventBus.emit(Events.FAVORITES_UPDATE, { | ||
| 1305 | + metaId, | ||
| 1306 | + collected: newCollectStatus, | ||
| 1307 | + timestamp: Date.now() | ||
| 1308 | + }) | ||
| 1309 | + | ||
| 1310 | + onSuccess?.(item, newCollectStatus) | ||
| 1311 | + return true | ||
| 1312 | + } else { | ||
| 1313 | + // API 失败,回滚 UI 状态 | ||
| 1314 | + item.collected = !newCollectStatus | ||
| 1315 | + Taro.showToast({ | ||
| 1316 | + title: res.msg || errorMsg, | ||
| 1317 | + icon: 'none', | ||
| 1318 | + duration: 2000 | ||
| 1319 | + }) | ||
| 1320 | + | ||
| 1321 | + onError?.(item, res.msg) | ||
| 1322 | + return false | ||
| 1323 | + } | ||
| 1324 | + } catch (err) { | ||
| 1325 | + // 发生错误,回滚 UI 状态 | ||
| 1326 | + item.collected = !item.collected | ||
| 1327 | + console.error('[useCollectOperation] 收藏操作失败:', err) | ||
| 1328 | + Taro.showToast({ | ||
| 1329 | + title: '网络错误,请重试', | ||
| 1330 | + icon: 'none', | ||
| 1331 | + duration: 2000 | ||
| 1332 | + }) | ||
| 1333 | + | ||
| 1334 | + onError?.(item, err.message) | ||
| 1335 | + return false | ||
| 1336 | + } | ||
| 1337 | + } | ||
| 1338 | + | ||
| 1339 | + return { toggleCollect } | ||
| 1340 | +} | ||
| 1341 | +``` | ||
| 1342 | + | ||
| 1343 | +#### 生命周期最佳实践 | ||
| 1344 | + | ||
| 1345 | +**LoadMoreList 页面**(分页列表页面)的生命周期使用规范: | ||
| 1346 | + | ||
| 1347 | +```javascript | ||
| 1348 | +// ✅ 正确:LoadMoreList 页面生命周期 | ||
| 1349 | +import { useLoad } from '@tarojs/taro' | ||
| 1350 | +import { ref, onMounted, onUnmounted } from 'vue' | ||
| 1351 | +import eventBus, { Events } from '@/utils/eventBus' | ||
| 1352 | + | ||
| 1353 | +// 首次加载时获取列表(只执行一次) | ||
| 1354 | +useLoad(() => { | ||
| 1355 | + console.log('[Page] 页面加载,获取列表') | ||
| 1356 | + currentPage.value = 0 | ||
| 1357 | + hasMore.value = true | ||
| 1358 | + fetchList({ page: 0, limit: pageSize }) | ||
| 1359 | +}) | ||
| 1360 | + | ||
| 1361 | +// 监听跨页面事件(需要刷新时) | ||
| 1362 | +onMounted(() => { | ||
| 1363 | + const unsubscribe = eventBus.on(Events.SOME_EVENT, async (data) => { | ||
| 1364 | + console.log('[Page] 收到事件:', data) | ||
| 1365 | + await refreshList() | ||
| 1366 | + }) | ||
| 1367 | + | ||
| 1368 | + onUnmounted(() => { | ||
| 1369 | + unsubscribe() | ||
| 1370 | + }) | ||
| 1371 | +}) | ||
| 1372 | +``` | ||
| 1373 | + | ||
| 1374 | +**关键点**: | ||
| 1375 | +- ✅ 使用 `useLoad` 进行一次性初始化 | ||
| 1376 | +- ✅ 使用事件总线监听跨页面操作 | ||
| 1377 | +- ✅ 在 `onMounted` 中注册监听器 | ||
| 1378 | +- ✅ 在 `onUnmounted` 中取消监听器(防止内存泄漏) | ||
| 1379 | +- ❌ 不使用 `useDidShow`(避免意外刷新) | ||
| 1380 | + | ||
| 1381 | +**适用范围**: | ||
| 1382 | +- ✅ 所有使用 `LoadMoreList` 组件的分页列表页面 | ||
| 1383 | +- ✅ 需要跨页面通信的场景 | ||
| 1384 | +- ✅ 需要在特定事件后刷新数据的场景 | ||
| 1385 | + | ||
| 1386 | +#### 检查清单 | ||
| 1387 | + | ||
| 1388 | +在实现列表页面时,确认: | ||
| 1389 | +- [ ] 是否为 LoadMoreList 页面(分页列表)? | ||
| 1390 | + - [ ] 是 → 使用 `useLoad` + 事件总线 | ||
| 1391 | + - [ ] 否 → 根据需求使用 `useLoad` 或 `useDidShow` | ||
| 1392 | +- [ ] 是否需要跨页面刷新? | ||
| 1393 | + - [ ] 是 → 使用事件总线模式 | ||
| 1394 | + - [ ] 否 → 使用本地更新即可 | ||
| 1395 | +- [ ] 是否正确清理事件监听器? | ||
| 1396 | + - [ ] 在 `onUnmounted` 中调用 `unsubscribe()` | ||
| 1397 | +- [ ] 是否避免了 `useDidShow` 的误用? | ||
| 1398 | + - [ ] 不在 LoadMoreList 页面中使用 `useDidShow` | ||
| 1399 | + | ||
| 1400 | +**历史记录**: | ||
| 1401 | +- **第 1 次错误**: favorites 页面使用 `useDidShow` 导致关闭图片预览时列表刷新 | ||
| 1402 | +- **第 2 次错误**: feedback-list 页面使用 `useDidShow` 导致同样问题 | ||
| 1403 | +- **教训**: LoadMoreList 页面应使用 `useLoad` + 事件总线,避免 `useDidShow` 导致的意外刷新 | ||
| 1404 | + | ||
| 1405 | +--- | ||
| 1406 | + | ||
| 1044 | ## 开发工作流 | 1407 | ## 开发工作流 |
| 1045 | 1408 | ||
| 1046 | ### ✅ Mock 数据环境自动切换模式 | 1409 | ### ✅ Mock 数据环境自动切换模式 |
| ... | @@ -1221,8 +1584,9 @@ const USE_MOCK_DATA = true // 容易导致生产环境误用 | ... | @@ -1221,8 +1584,9 @@ const USE_MOCK_DATA = true // 容易导致生产环境误用 |
| 1221 | 6. **代码质量**: 强制 JSDoc 注释,统一命名规范 | 1584 | 6. **代码质量**: 强制 JSDoc 注释,统一命名规范 |
| 1222 | 7. **API 调用规范**: ⚠️ **不要使用 `fn()` 包装 API,直接调用并自己处理错误** | 1585 | 7. **API 调用规范**: ⚠️ **不要使用 `fn()` 包装 API,直接调用并自己处理错误** |
| 1223 | 8. **架构设计**: 分层清晰,职责单一 | 1586 | 8. **架构设计**: 分层清晰,职责单一 |
| 1224 | -9. **Mock 数据切换**: ⭐ **使用环境变量 `process.env.NODE_ENV` 自动判断,禁止硬编码** | 1587 | +9. **跨页面通信**: ⭐ **使用事件总线模式,LoadMoreList 页面避免使用 `useDidShow`**(防止意外刷新) |
| 1225 | -10. **⚠️ 写代码前必查**: 先搜索项目中是否有类似实现,保持写法一致 | 1588 | +10. **Mock 数据切换**: ⭐ **使用环境变量 `process.env.NODE_ENV` 自动判断,禁止硬编码** |
| 1589 | +11. **⚠️ 写代码前必查**: 先搜索项目中是否有类似实现,保持写法一致 | ||
| 1226 | 1590 | ||
| 1227 | ### 📚 推荐阅读 | 1591 | ### 📚 推荐阅读 |
| 1228 | 1592 | ||
| ... | @@ -1243,4 +1607,5 @@ const USE_MOCK_DATA = true // 容易导致生产环境误用 | ... | @@ -1243,4 +1607,5 @@ const USE_MOCK_DATA = true // 容易导致生产环境误用 |
| 1243 | **项目**: Manulife WeApp | 1607 | **项目**: Manulife WeApp |
| 1244 | 1608 | ||
| 1245 | **更新记录**: | 1609 | **更新记录**: |
| 1610 | +- 2026-02-08: 新增 "跨页面通信" 章节,记录事件总线实现和 useDidShow 陷阱解决方案 | ||
| 1246 | - 2026-02-08: 新增 "开发工作流" 章节,记录 Mock 数据环境自动切换模式 | 1611 | - 2026-02-08: 新增 "开发工作流" 章节,记录 Mock 数据环境自动切换模式 | ... | ... |
| ... | @@ -8,6 +8,7 @@ | ... | @@ -8,6 +8,7 @@ |
| 8 | 8 | ||
| 9 | import { addAPI, delAPI } from '../api/favorite.js' | 9 | import { addAPI, delAPI } from '../api/favorite.js' |
| 10 | import Taro from '@tarojs/taro' | 10 | import Taro from '@tarojs/taro' |
| 11 | +import eventBus, { Events } from '@/utils/eventBus' | ||
| 11 | 12 | ||
| 12 | /** | 13 | /** |
| 13 | * 使用收藏操作 | 14 | * 使用收藏操作 |
| ... | @@ -59,6 +60,13 @@ export function useCollectOperation(options = {}) { | ... | @@ -59,6 +60,13 @@ export function useCollectOperation(options = {}) { |
| 59 | duration: 1000 | 60 | duration: 1000 |
| 60 | }) | 61 | }) |
| 61 | 62 | ||
| 63 | + // 发送收藏更新事件(通知收藏列表页刷新) | ||
| 64 | + eventBus.emit(Events.FAVORITES_UPDATE, { | ||
| 65 | + metaId, | ||
| 66 | + collected: newCollectStatus, | ||
| 67 | + timestamp: Date.now() | ||
| 68 | + }) | ||
| 69 | + | ||
| 62 | // 调用成功回调 | 70 | // 调用成功回调 |
| 63 | onSuccess?.(item, newCollectStatus) | 71 | onSuccess?.(item, newCollectStatus) |
| 64 | 72 | ... | ... |
| 1 | <!-- | 1 | <!-- |
| 2 | - * @Date: 2026-02-05 | 2 | + * @Date: 2026-02-08 |
| 3 | - * @Description: 我的收藏 - 已接入真实API,移除分类逻辑 | 3 | + * @Description: 我的收藏 - 使用 LoadMoreList 组件重构版本 |
| 4 | --> | 4 | --> |
| 5 | <template> | 5 | <template> |
| 6 | <view class="h-screen bg-gray-50 flex flex-col"> | 6 | <view class="h-screen bg-gray-50 flex flex-col"> |
| ... | @@ -8,116 +8,202 @@ | ... | @@ -8,116 +8,202 @@ |
| 8 | <NavHeader title="我的收藏" /> | 8 | <NavHeader title="我的收藏" /> |
| 9 | </view> | 9 | </view> |
| 10 | 10 | ||
| 11 | - <view | 11 | + <!-- LoadMoreList 组件 --> |
| 12 | - v-if="listVisible" | 12 | + <LoadMoreList |
| 13 | - :key="listRenderKey" | 13 | + :list="currentList" |
| 14 | - class="flex-1 min-h-0 overflow-y-auto px-[24rpx] py-[24rpx] pb-[200rpx]" | 14 | + :page="currentPage" |
| 15 | + :page-size="pageSize" | ||
| 16 | + :has-more="hasMore" | ||
| 17 | + :loading="loading" | ||
| 18 | + :loading-more="loadingMore" | ||
| 19 | + key-field="meta_id" | ||
| 20 | + @load-more="handleLoadMore" | ||
| 15 | > | 21 | > |
| 16 | - <!-- Loading State --> | 22 | + <!-- 列表项 --> |
| 17 | - <view v-if="loading" class="flex flex-col items-center justify-center"> | 23 | + <template #item="{ item }"> |
| 18 | - <view class="loading-spinner"></view> | 24 | + <view class="bg-white rounded-[24rpx] p-[24rpx] mb-[24rpx] shadow-sm favorite-item"> |
| 19 | - <view class="text-gray-400 text-[24rpx] mt-3">加载中...</view> | 25 | + <!-- Header with Icon --> |
| 20 | - </view> | 26 | + <view class="flex gap-[24rpx] mb-[12rpx]"> |
| 21 | - | 27 | + <!-- Document Icon --> |
| 22 | - <!-- List Items --> | 28 | + <view class="w-[88rpx] h-[88rpx] mr-[24rpx] flex-shrink-0 flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100 rounded-[20rpx] shadow-inner self-start"> |
| 23 | - <view v-for="(item, index) in favoritesList" :key="item.meta_id" | 29 | + <image :src="getDocumentIcon(item.name)" class="w-[48rpx] h-[48rpx]" mode="aspectFit" /> |
| 24 | - class="bg-white rounded-[24rpx] p-[24rpx] mb-[24rpx] shadow-sm favorite-item" | 30 | + </view> |
| 25 | - :style="{ animationDelay: `${index * 50}ms` }"> | ||
| 26 | - | ||
| 27 | - <!-- Header with Icon --> | ||
| 28 | - <view class="flex gap-[24rpx] mb-[12rpx]"> | ||
| 29 | - <!-- Document Icon --> | ||
| 30 | - <view class="w-[88rpx] h-[88rpx] mr-[24rpx] flex-shrink-0 flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100 rounded-[20rpx] shadow-inner self-start"> | ||
| 31 | - <image :src="getDocumentIcon(item.name)" class="w-[48rpx] h-[48rpx]" mode="aspectFit" /> | ||
| 32 | - </view> | ||
| 33 | 31 | ||
| 34 | - <!-- Title --> | 32 | + <!-- Title --> |
| 35 | - <view class="flex-1 min-w-0"> | 33 | + <view class="flex-1 min-w-0"> |
| 36 | - <view class="text-[30rpx] font-bold text-gray-900 leading-normal mb-1">{{ item.name }}</view> | 34 | + <view class="text-[30rpx] font-bold text-gray-900 leading-normal mb-1">{{ item.name }}</view> |
| 37 | - <view class="text-gray-400 text-[22rpx]">{{ item.size }}</view> | 35 | + <view class="text-gray-400 text-[22rpx]">{{ item.size }}</view> |
| 36 | + </view> | ||
| 38 | </view> | 37 | </view> |
| 39 | - </view> | ||
| 40 | 38 | ||
| 41 | - <!-- Date --> | 39 | + <!-- Date --> |
| 42 | - <view class="text-gray-500 text-[24rpx] mb-[20rpx] text-right"> | 40 | + <view class="text-gray-500 text-[24rpx] mb-[20rpx] text-right"> |
| 43 | - <text>{{ item.created_time }}</text> | 41 | + <text>{{ item.created_time }}</text> |
| 44 | - </view> | 42 | + </view> |
| 45 | 43 | ||
| 46 | - <!-- Divider --> | 44 | + <!-- Divider --> |
| 47 | - <view class="h-[1rpx] bg-gray-100 mb-[20rpx]"></view> | 45 | + <view class="h-[1rpx] bg-gray-100 mb-[20rpx]"></view> |
| 48 | - | ||
| 49 | - <!-- Actions --> | ||
| 50 | - <ListItemActions | ||
| 51 | - :viewable="true" | ||
| 52 | - :deletable="true" | ||
| 53 | - :item-id="String(item.meta_id)" | ||
| 54 | - @view="viewFile({...item, fileName: item.name, downloadUrl: item.src})" | ||
| 55 | - @delete="onDelete(item)" | ||
| 56 | - /> | ||
| 57 | - </view> | ||
| 58 | - | ||
| 59 | - <!-- Empty State --> | ||
| 60 | - <view v-if="!loading && favoritesList.length === 0"> | ||
| 61 | - <nut-empty description="暂无收藏内容" image="empty" /> | ||
| 62 | - </view> | ||
| 63 | - </view> | ||
| 64 | 46 | ||
| 65 | - <!-- TabBar --> | 47 | + <!-- Actions --> |
| 66 | - <!-- <TabBar current="me" /> --> | 48 | + <ListItemActions |
| 49 | + :viewable="true" | ||
| 50 | + :deletable="true" | ||
| 51 | + :item-id="String(item.meta_id)" | ||
| 52 | + @view="viewFile({...item, fileName: item.name, downloadUrl: item.src})" | ||
| 53 | + @delete="onDelete(item)" | ||
| 54 | + /> | ||
| 55 | + </view> | ||
| 56 | + </template> | ||
| 57 | + </LoadMoreList> | ||
| 67 | </view> | 58 | </view> |
| 68 | </template> | 59 | </template> |
| 69 | 60 | ||
| 70 | <script setup> | 61 | <script setup> |
| 71 | -import { ref } from 'vue' | 62 | +import { ref, Ref, onMounted, onUnmounted } from 'vue' |
| 72 | -import Taro from '@tarojs/taro' | 63 | +import Taro, { useLoad } from '@tarojs/taro' |
| 64 | +import LoadMoreList from '@/components/LoadMoreList' | ||
| 73 | import { useFileOperation } from '@/composables/useFileOperation' | 65 | import { useFileOperation } from '@/composables/useFileOperation' |
| 74 | import { getDocumentIcon } from '@/utils/documentIcons' | 66 | import { getDocumentIcon } from '@/utils/documentIcons' |
| 75 | import NavHeader from '@/components/NavHeader.vue' | 67 | import NavHeader from '@/components/NavHeader.vue' |
| 76 | import ListItemActions from '@/components/ListItemActions/index.vue' | 68 | import ListItemActions from '@/components/ListItemActions/index.vue' |
| 77 | import { listAPI, delAPI } from '@/api/favorite' | 69 | import { listAPI, delAPI } from '@/api/favorite' |
| 70 | +import { mockFavoriteListAPI } from '@/utils/mockData' | ||
| 71 | +import eventBus, { Events } from '@/utils/eventBus' | ||
| 72 | + | ||
| 73 | +// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API | ||
| 74 | +const USE_MOCK_DATA = process.env.NODE_ENV === 'development' | ||
| 78 | 75 | ||
| 79 | const { viewFile } = useFileOperation() | 76 | const { viewFile } = useFileOperation() |
| 80 | -const listVisible = ref(true) | 77 | + |
| 81 | -const listRenderKey = ref(0) | 78 | +/** |
| 79 | + * 当前列表数据 | ||
| 80 | + * @type {Ref<Array<any>>} | ||
| 81 | + */ | ||
| 82 | +const currentList = ref([]) | ||
| 83 | + | ||
| 84 | +/** | ||
| 85 | + * 当前页码(从0开始) | ||
| 86 | + * @type {Ref<number>} | ||
| 87 | + */ | ||
| 88 | +const currentPage = ref(0) | ||
| 89 | + | ||
| 90 | +/** | ||
| 91 | + * 每页数量 | ||
| 92 | + * @type {number} | ||
| 93 | + */ | ||
| 94 | +const pageSize = 20 | ||
| 95 | + | ||
| 96 | +/** | ||
| 97 | + * 是否还有更多数据 | ||
| 98 | + * @type {Ref<boolean>} | ||
| 99 | + */ | ||
| 100 | +const hasMore = ref(true) | ||
| 101 | + | ||
| 102 | +/** | ||
| 103 | + * 首次加载状态 | ||
| 104 | + * @type {Ref<boolean>} | ||
| 105 | + */ | ||
| 82 | const loading = ref(false) | 106 | const loading = ref(false) |
| 83 | -const favoritesList = ref([]) | 107 | + |
| 108 | +/** | ||
| 109 | + * 加载更多状态 | ||
| 110 | + * @type {Ref<boolean>} | ||
| 111 | + */ | ||
| 112 | +const loadingMore = ref(false) | ||
| 84 | 113 | ||
| 85 | /** | 114 | /** |
| 86 | * 获取收藏列表 | 115 | * 获取收藏列表 |
| 116 | + * | ||
| 117 | + * @param {Object} params - 请求参数 | ||
| 118 | + * @param {number} params.page - 页码(从0开始) | ||
| 119 | + * @param {number} params.limit - 每页数量 | ||
| 120 | + * @param {boolean} isLoadMore - 是否为加载更多 | ||
| 121 | + * @returns {Promise<void>} | ||
| 87 | */ | 122 | */ |
| 88 | -const fetchFavoritesList = async () => { | 123 | +const fetchFavoritesList = async (params = {}, isLoadMore = false) => { |
| 89 | try { | 124 | try { |
| 90 | - loading.value = true | 125 | + // 如果是加载更多,使用 loadingMore 状态,否则使用 loading 状态 |
| 91 | - const res = await listAPI({ | 126 | + if (isLoadMore) { |
| 92 | - page: '0', | 127 | + loadingMore.value = true |
| 93 | - limit: '100' | 128 | + } else { |
| 94 | - }) | 129 | + loading.value = true |
| 130 | + } | ||
| 131 | + | ||
| 132 | + console.log('[Favorites] 请求参数:', params) | ||
| 133 | + console.log('[Favorites] 使用 Mock 数据:', USE_MOCK_DATA) | ||
| 134 | + | ||
| 135 | + // 根据开关选择使用真实 API 或 Mock 数据 | ||
| 136 | + const res = USE_MOCK_DATA | ||
| 137 | + ? await mockFavoriteListAPI(params) | ||
| 138 | + : await listAPI({ | ||
| 139 | + page: String(params.page), | ||
| 140 | + limit: String(params.limit) | ||
| 141 | + }) | ||
| 95 | 142 | ||
| 96 | if (res.code === 1 && res.data && res.data.list) { | 143 | if (res.code === 1 && res.data && res.data.list) { |
| 97 | - favoritesList.value = res.data.list | 144 | + console.log('[Favorites] 数据:', res.data.list) |
| 145 | + | ||
| 146 | + if (isLoadMore) { | ||
| 147 | + // 加载更多:追加数据 | ||
| 148 | + currentList.value = [...currentList.value, ...res.data.list] | ||
| 149 | + } else { | ||
| 150 | + // 首次加载或刷新:替换数据 | ||
| 151 | + currentList.value = res.data.list | ||
| 152 | + } | ||
| 153 | + | ||
| 154 | + // 判断是否还有更多数据 | ||
| 155 | + hasMore.value = res.data.list.length >= params.limit | ||
| 98 | } else { | 156 | } else { |
| 99 | - favoritesList.value = [] | 157 | + if (!isLoadMore) { |
| 158 | + currentList.value = [] | ||
| 159 | + } | ||
| 100 | Taro.showToast({ | 160 | Taro.showToast({ |
| 101 | title: res.msg || '获取收藏列表失败', | 161 | title: res.msg || '获取收藏列表失败', |
| 102 | icon: 'none' | 162 | icon: 'none' |
| 103 | }) | 163 | }) |
| 104 | } | 164 | } |
| 105 | } catch (err) { | 165 | } catch (err) { |
| 106 | - console.error('获取收藏列表失败:', err) | 166 | + console.error('[Favorites] 获取收藏列表失败:', err) |
| 107 | - favoritesList.value = [] | 167 | + if (!isLoadMore) { |
| 168 | + currentList.value = [] | ||
| 169 | + } | ||
| 108 | Taro.showToast({ | 170 | Taro.showToast({ |
| 109 | title: '网络错误,请稍后重试', | 171 | title: '网络错误,请稍后重试', |
| 110 | icon: 'none' | 172 | icon: 'none' |
| 111 | }) | 173 | }) |
| 112 | } finally { | 174 | } finally { |
| 113 | - loading.value = false | 175 | + if (isLoadMore) { |
| 176 | + loadingMore.value = false | ||
| 177 | + } else { | ||
| 178 | + loading.value = false | ||
| 179 | + } | ||
| 114 | } | 180 | } |
| 115 | } | 181 | } |
| 116 | 182 | ||
| 117 | /** | 183 | /** |
| 118 | * 删除收藏 | 184 | * 删除收藏 |
| 185 | + * | ||
| 186 | + * @param {Object} item - 收藏项 | ||
| 119 | */ | 187 | */ |
| 120 | const onDelete = async (item) => { | 188 | const onDelete = async (item) => { |
| 189 | + if (USE_MOCK_DATA) { | ||
| 190 | + // Mock 模式:直接从列表中移除 | ||
| 191 | + Taro.showModal({ | ||
| 192 | + title: '提示', | ||
| 193 | + content: '确定要删除该收藏吗?(Mock模式)', | ||
| 194 | + success: (res) => { | ||
| 195 | + if (res.confirm) { | ||
| 196 | + const index = currentList.value.findIndex(i => i.meta_id === item.meta_id) | ||
| 197 | + if (index !== -1) { | ||
| 198 | + currentList.value.splice(index, 1) | ||
| 199 | + } | ||
| 200 | + Taro.showToast({ title: '已删除', icon: 'success' }) | ||
| 201 | + } | ||
| 202 | + } | ||
| 203 | + }) | ||
| 204 | + return | ||
| 205 | + } | ||
| 206 | + | ||
| 121 | Taro.showModal({ | 207 | Taro.showModal({ |
| 122 | title: '提示', | 208 | title: '提示', |
| 123 | content: '确定要删除该收藏吗?', | 209 | content: '确定要删除该收藏吗?', |
| ... | @@ -128,9 +214,9 @@ const onDelete = async (item) => { | ... | @@ -128,9 +214,9 @@ const onDelete = async (item) => { |
| 128 | 214 | ||
| 129 | if (delRes.code === 1) { | 215 | if (delRes.code === 1) { |
| 130 | // 从列表中移除 | 216 | // 从列表中移除 |
| 131 | - const index = favoritesList.value.findIndex(i => i.meta_id === item.meta_id) | 217 | + const index = currentList.value.findIndex(i => i.meta_id === item.meta_id) |
| 132 | if (index !== -1) { | 218 | if (index !== -1) { |
| 133 | - favoritesList.value.splice(index, 1) | 219 | + currentList.value.splice(index, 1) |
| 134 | } | 220 | } |
| 135 | 221 | ||
| 136 | Taro.showToast({ title: '已删除', icon: 'success' }) | 222 | Taro.showToast({ title: '已删除', icon: 'success' }) |
| ... | @@ -141,7 +227,7 @@ const onDelete = async (item) => { | ... | @@ -141,7 +227,7 @@ const onDelete = async (item) => { |
| 141 | }) | 227 | }) |
| 142 | } | 228 | } |
| 143 | } catch (err) { | 229 | } catch (err) { |
| 144 | - console.error('删除收藏失败:', err) | 230 | + console.error('[Favorites] 删除收藏失败:', err) |
| 145 | Taro.showToast({ | 231 | Taro.showToast({ |
| 146 | title: '网络错误,请稍后重试', | 232 | title: '网络错误,请稍后重试', |
| 147 | icon: 'none' | 233 | icon: 'none' |
| ... | @@ -152,43 +238,84 @@ const onDelete = async (item) => { | ... | @@ -152,43 +238,84 @@ const onDelete = async (item) => { |
| 152 | }) | 238 | }) |
| 153 | } | 239 | } |
| 154 | 240 | ||
| 155 | -// 获取收藏列表 | 241 | +/** |
| 156 | -fetchFavoritesList() | 242 | + * 处理加载更多事件 |
| 157 | -</script> | 243 | + * |
| 244 | + * @param {number} page - 下一页页码 | ||
| 245 | + * @returns {Promise<void>} | ||
| 246 | + */ | ||
| 247 | +const handleLoadMore = async (page) => { | ||
| 248 | + console.log('[Favorites] 加载更多,页码:', page) | ||
| 158 | 249 | ||
| 159 | -<style lang="less"> | 250 | + // 更新页码 |
| 160 | -@keyframes slideIn { | 251 | + currentPage.value = page |
| 161 | - from { | ||
| 162 | - opacity: 0; | ||
| 163 | - transform: translateY(20rpx); | ||
| 164 | - } | ||
| 165 | 252 | ||
| 166 | - to { | 253 | + // 加载下一页数据 |
| 167 | - opacity: 1; | 254 | + await fetchFavoritesList( |
| 168 | - transform: translateY(0); | 255 | + { page: page, limit: pageSize }, |
| 169 | - } | 256 | + true // 标记为加载更多 |
| 257 | + ) | ||
| 170 | } | 258 | } |
| 171 | 259 | ||
| 172 | -@keyframes spin { | 260 | +/** |
| 173 | - 0% { | 261 | + * @description 刷新收藏列表 |
| 174 | - transform: rotate(0deg); | 262 | + * @returns {Promise<void>} |
| 175 | - } | 263 | + */ |
| 264 | +const refreshList = async () => { | ||
| 265 | + console.log('[Favorites] 刷新列表') | ||
| 176 | 266 | ||
| 177 | - to { | 267 | + // 重置分页状态 |
| 178 | - transform: rotate(360deg); | 268 | + currentPage.value = 0 |
| 179 | - } | 269 | + hasMore.value = true |
| 270 | + | ||
| 271 | + // 重新获取列表 | ||
| 272 | + await fetchFavoritesList({ page: 0, limit: pageSize }) | ||
| 180 | } | 273 | } |
| 181 | 274 | ||
| 275 | +/** | ||
| 276 | + * 页面加载时获取列表(只执行一次) | ||
| 277 | + */ | ||
| 278 | +useLoad(() => { | ||
| 279 | + console.log('[Favorites] 页面加载,获取列表') | ||
| 280 | + | ||
| 281 | + // 重置分页状态 | ||
| 282 | + currentPage.value = 0 | ||
| 283 | + hasMore.value = true | ||
| 284 | + | ||
| 285 | + // 获取收藏列表 | ||
| 286 | + fetchFavoritesList({ page: 0, limit: pageSize }) | ||
| 287 | +}) | ||
| 288 | + | ||
| 289 | +/** | ||
| 290 | + * @description 监听收藏更新事件 | ||
| 291 | + */ | ||
| 292 | +onMounted(() => { | ||
| 293 | + console.log('[Favorites] 注册事件监听') | ||
| 294 | + | ||
| 295 | + // 监听收藏更新事件(收藏/取消收藏) | ||
| 296 | + const unsubscribe = eventBus.on(Events.FAVORITES_UPDATE, async (data) => { | ||
| 297 | + console.log('[Favorites] 收到收藏更新事件:', data) | ||
| 298 | + | ||
| 299 | + // 刷新列表 | ||
| 300 | + await refreshList() | ||
| 301 | + }) | ||
| 302 | + | ||
| 303 | + // 组件卸载时取消监听 | ||
| 304 | + onUnmounted(() => { | ||
| 305 | + unsubscribe() | ||
| 306 | + console.log('[Favorites] 取消事件监听') | ||
| 307 | + }) | ||
| 308 | +}) | ||
| 309 | +</script> | ||
| 310 | + | ||
| 311 | +<style lang="less"> | ||
| 182 | .favorite-item { | 312 | .favorite-item { |
| 183 | - animation: slideIn 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) backwards; | 313 | + transition: all 0.3s ease; |
| 184 | -} | ||
| 185 | 314 | ||
| 186 | -.loading-spinner { | 315 | + &:active { |
| 187 | - width: 64rpx; | 316 | + transform: scale(0.98); |
| 188 | - height: 64rpx; | 317 | + } |
| 189 | - border: 4rpx solid #e5e7eb; | ||
| 190 | - border-top-color: #2563EB; | ||
| 191 | - border-radius: 50%; | ||
| 192 | - animation: spin 1s linear infinite; | ||
| 193 | } | 318 | } |
| 319 | + | ||
| 320 | +/* LoadMoreList 组件已内置样式,包括 Loading 和 Empty State */ | ||
| 194 | </style> | 321 | </style> | ... | ... |
| 1 | <!-- | 1 | <!-- |
| 2 | - * @Date: 2026-02-03 | 2 | + * @Date: 2026-02-08 |
| 3 | - * @Description: 意见反馈列表页面 | 3 | + * @Description: 意见反馈列表页面 - 使用 LoadMoreList 组件重构版本 |
| 4 | --> | 4 | --> |
| 5 | <template> | 5 | <template> |
| 6 | <view class="feedback-list"> | 6 | <view class="feedback-list"> |
| 7 | <NavHeader title="意见反馈" /> | 7 | <NavHeader title="意见反馈" /> |
| 8 | 8 | ||
| 9 | - <!-- Loading State --> | 9 | + <!-- LoadMoreList 组件 --> |
| 10 | - <view v-if="loading" class="flex justify-center items-center py-20"> | 10 | + <LoadMoreList |
| 11 | - <view class="loading-spinner"></view> | 11 | + :list="currentList" |
| 12 | - </view> | 12 | + :page="currentPage" |
| 13 | - | 13 | + :page-size="pageSize" |
| 14 | - <!-- Content --> | 14 | + :has-more="hasMore" |
| 15 | - <view v-else> | 15 | + :loading="loading" |
| 16 | - <!-- Feedback List --> | 16 | + :loading-more="loadingMore" |
| 17 | - <view v-if="feedbackList.length > 0"> | 17 | + key-field="id" |
| 18 | - <view | 18 | + @load-more="handleLoadMore" |
| 19 | - v-for="item in feedbackList" | 19 | + > |
| 20 | - :key="item.id" | 20 | + <!-- 列表项 --> |
| 21 | - class="feedback-item" | 21 | + <template #item="{ item }"> |
| 22 | - > | 22 | + <view class="feedback-item"> |
| 23 | <!-- Header: Type & Status --> | 23 | <!-- Header: Type & Status --> |
| 24 | <view class="feedback-header"> | 24 | <view class="feedback-header"> |
| 25 | <!-- Category Tag --> | 25 | <!-- Category Tag --> |
| ... | @@ -73,25 +73,8 @@ | ... | @@ -73,25 +73,8 @@ |
| 73 | </view> | 73 | </view> |
| 74 | </view> | 74 | </view> |
| 75 | </view> | 75 | </view> |
| 76 | - </view> | 76 | + </template> |
| 77 | - | 77 | + </LoadMoreList> |
| 78 | - <!-- Empty State --> | ||
| 79 | - <view v-else class="empty-state"> | ||
| 80 | - <nut-empty description="暂无反馈记录" image="empty"> | ||
| 81 | - <view class="text-[#9ca3af] text-[24rpx] mt-[10rpx]">您还没有提交过任何意见反馈</view> | ||
| 82 | - </nut-empty> | ||
| 83 | - </view> | ||
| 84 | - | ||
| 85 | - <!-- Load More --> | ||
| 86 | - <view v-if="hasMore && feedbackList.length > 0" class="load-more" @click="loadMore"> | ||
| 87 | - {{ loadingMore ? '加载中...' : '加载更多' }} | ||
| 88 | - </view> | ||
| 89 | - | ||
| 90 | - <!-- No More Data --> | ||
| 91 | - <view v-if="!hasMore && feedbackList.length > 0" class="no-more"> | ||
| 92 | - 没有更多数据了 | ||
| 93 | - </view> | ||
| 94 | - </view> | ||
| 95 | 78 | ||
| 96 | <!-- Fixed Button --> | 79 | <!-- Fixed Button --> |
| 97 | <view class="fixed-button" @click="goToFeedback"> | 80 | <view class="fixed-button" @click="goToFeedback"> |
| ... | @@ -101,32 +84,57 @@ | ... | @@ -101,32 +84,57 @@ |
| 101 | </template> | 84 | </template> |
| 102 | 85 | ||
| 103 | <script setup> | 86 | <script setup> |
| 104 | -import { ref } from 'vue' | 87 | +import { ref, Ref, onMounted, onUnmounted } from 'vue' |
| 105 | import { useGo } from '@/hooks/useGo' | 88 | import { useGo } from '@/hooks/useGo' |
| 89 | +import LoadMoreList from '@/components/LoadMoreList' | ||
| 106 | import NavHeader from '@/components/NavHeader.vue' | 90 | import NavHeader from '@/components/NavHeader.vue' |
| 107 | -import Taro, { useDidShow } from '@tarojs/taro' | 91 | +import Taro, { useLoad } from '@tarojs/taro' |
| 108 | import { listAPI } from '@/api/feedback' | 92 | import { listAPI } from '@/api/feedback' |
| 93 | +import { mockFeedbackListAPI } from '@/utils/mockData' | ||
| 94 | +import eventBus, { Events } from '@/utils/eventBus' | ||
| 109 | 95 | ||
| 110 | -const go = useGo() | 96 | +// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API |
| 97 | +const USE_MOCK_DATA = process.env.NODE_ENV === 'development' | ||
| 111 | 98 | ||
| 112 | -/** @type {import('vue').Ref<boolean>} 加载状态 */ | 99 | +const go = useGo() |
| 113 | -const loading = ref(false) | ||
| 114 | - | ||
| 115 | -const loadingMore = ref(false) | ||
| 116 | 100 | ||
| 117 | -/** @type {import('vue').Ref<Array>} 反馈列表 */ | 101 | +/** |
| 118 | -const feedbackList = ref([]) | 102 | + * 当前列表数据 |
| 103 | + * @type {Ref<Array<any>>} | ||
| 104 | + */ | ||
| 105 | +const currentList = ref([]) | ||
| 119 | 106 | ||
| 120 | -/** @type {import('vue').Ref<number>} 当前页码 */ | 107 | +/** |
| 108 | + * 当前页码(从0开始) | ||
| 109 | + * @type {Ref<number>} | ||
| 110 | + */ | ||
| 121 | const currentPage = ref(0) | 111 | const currentPage = ref(0) |
| 122 | 112 | ||
| 123 | -/** @type {import('vue').Ref<number>} 每页数量 */ | 113 | +/** |
| 124 | -const pageSize = ref(10) | 114 | + * 每页数量 |
| 115 | + * @type {number} | ||
| 116 | + */ | ||
| 117 | +const pageSize = 10 | ||
| 125 | 118 | ||
| 126 | -/** @type {import('vue').Ref<boolean>} 是否有更多数据 */ | 119 | +/** |
| 120 | + * 是否还有更多数据 | ||
| 121 | + * @type {Ref<boolean>} | ||
| 122 | + */ | ||
| 127 | const hasMore = ref(true) | 123 | const hasMore = ref(true) |
| 128 | 124 | ||
| 129 | /** | 125 | /** |
| 126 | + * 首次加载状态 | ||
| 127 | + * @type {Ref<boolean>} | ||
| 128 | + */ | ||
| 129 | +const loading = ref(false) | ||
| 130 | + | ||
| 131 | +/** | ||
| 132 | + * 加载更多状态 | ||
| 133 | + * @type {Ref<boolean>} | ||
| 134 | + */ | ||
| 135 | +const loadingMore = ref(false) | ||
| 136 | + | ||
| 137 | +/** | ||
| 130 | * @description 获取反馈类型标签 | 138 | * @description 获取反馈类型标签 |
| 131 | * @param {string} category 类别值:1=功能建议, 3=问题反馈, 7=其他问题 | 139 | * @param {string} category 类别值:1=功能建议, 3=问题反馈, 7=其他问题 |
| 132 | * @returns {string} 类别标签 | 140 | * @returns {string} 类别标签 |
| ... | @@ -168,9 +176,13 @@ const previewImage = (urls, current) => { | ... | @@ -168,9 +176,13 @@ const previewImage = (urls, current) => { |
| 168 | 176 | ||
| 169 | /** | 177 | /** |
| 170 | * @description 加载反馈列表 | 178 | * @description 加载反馈列表 |
| 171 | - * @param {boolean} isLoadMore 是否为加载更多 | 179 | + * @param {Object} params - 请求参数 |
| 180 | + * @param {number} params.page - 页码(从0开始) | ||
| 181 | + * @param {number} params.limit - 每页数量 | ||
| 182 | + * @param {boolean} isLoadMore - 是否为加载更多 | ||
| 183 | + * @returns {Promise<void>} | ||
| 172 | */ | 184 | */ |
| 173 | -const loadFeedbackList = async (isLoadMore = false) => { | 185 | +const loadFeedbackList = async (params = {}, isLoadMore = false) => { |
| 174 | if (loading.value || loadingMore.value) return | 186 | if (loading.value || loadingMore.value) return |
| 175 | 187 | ||
| 176 | try { | 188 | try { |
| ... | @@ -179,34 +191,44 @@ const loadFeedbackList = async (isLoadMore = false) => { | ... | @@ -179,34 +191,44 @@ const loadFeedbackList = async (isLoadMore = false) => { |
| 179 | } else { | 191 | } else { |
| 180 | loading.value = true | 192 | loading.value = true |
| 181 | currentPage.value = 0 | 193 | currentPage.value = 0 |
| 182 | - feedbackList.value = [] | 194 | + currentList.value = [] |
| 183 | } | 195 | } |
| 184 | 196 | ||
| 185 | - const res = await listAPI({ | 197 | + console.log('[Feedback] 请求参数:', params) |
| 186 | - page: currentPage.value, | 198 | + console.log('[Feedback] 使用 Mock 数据:', USE_MOCK_DATA) |
| 187 | - limit: pageSize.value | 199 | + |
| 188 | - }) | 200 | + // 根据开关选择使用真实 API 或 Mock 数据 |
| 201 | + const res = USE_MOCK_DATA | ||
| 202 | + ? await mockFeedbackListAPI(params) | ||
| 203 | + : await listAPI({ | ||
| 204 | + page: String(params.page), | ||
| 205 | + limit: String(params.limit) | ||
| 206 | + }) | ||
| 189 | 207 | ||
| 190 | if (res.code === 1) { | 208 | if (res.code === 1) { |
| 191 | const newList = res.data.list || [] | 209 | const newList = res.data.list || [] |
| 192 | 210 | ||
| 211 | + console.log('[Feedback] 数据:', newList) | ||
| 212 | + | ||
| 193 | if (isLoadMore) { | 213 | if (isLoadMore) { |
| 194 | - feedbackList.value.push(...newList) | 214 | + currentList.value.push(...newList) |
| 195 | } else { | 215 | } else { |
| 196 | - feedbackList.value = newList | 216 | + currentList.value = newList |
| 197 | } | 217 | } |
| 198 | 218 | ||
| 199 | // 判断是否还有更多数据 | 219 | // 判断是否还有更多数据 |
| 200 | - hasMore.value = newList.length >= pageSize.value | 220 | + hasMore.value = newList.length >= params.limit |
| 201 | - | ||
| 202 | - if (hasMore.value) { | ||
| 203 | - currentPage.value++ | ||
| 204 | - } | ||
| 205 | } else { | 221 | } else { |
| 222 | + if (!isLoadMore) { | ||
| 223 | + currentList.value = [] | ||
| 224 | + } | ||
| 206 | Taro.showToast({ title: res.msg || '加载失败', icon: 'none' }) | 225 | Taro.showToast({ title: res.msg || '加载失败', icon: 'none' }) |
| 207 | } | 226 | } |
| 208 | } catch (err) { | 227 | } catch (err) { |
| 209 | - console.error('加载反馈列表失败:', err) | 228 | + console.error('[Feedback] 加载反馈列表失败:', err) |
| 229 | + if (!isLoadMore) { | ||
| 230 | + currentList.value = [] | ||
| 231 | + } | ||
| 210 | Taro.showToast({ title: '网络异常,请重试', icon: 'none' }) | 232 | Taro.showToast({ title: '网络异常,请重试', icon: 'none' }) |
| 211 | } finally { | 233 | } finally { |
| 212 | loading.value = false | 234 | loading.value = false |
| ... | @@ -215,12 +237,21 @@ const loadFeedbackList = async (isLoadMore = false) => { | ... | @@ -215,12 +237,21 @@ const loadFeedbackList = async (isLoadMore = false) => { |
| 215 | } | 237 | } |
| 216 | 238 | ||
| 217 | /** | 239 | /** |
| 218 | - * @description 加载更多 | 240 | + * @description 处理加载更多事件 |
| 241 | + * @param {number} page - 下一页页码 | ||
| 242 | + * @returns {Promise<void>} | ||
| 219 | */ | 243 | */ |
| 220 | -const loadMore = () => { | 244 | +const handleLoadMore = async (page) => { |
| 221 | - if (!hasMore.value || loadingMore.value) { | 245 | + console.log('[Feedback] 加载更多,页码:', page) |
| 222 | - loadFeedbackList(true) | 246 | + |
| 223 | - } | 247 | + // 更新页码 |
| 248 | + currentPage.value = page | ||
| 249 | + | ||
| 250 | + // 加载下一页数据 | ||
| 251 | + await loadFeedbackList( | ||
| 252 | + { page: page, limit: pageSize }, | ||
| 253 | + true // 标记为加载更多 | ||
| 254 | + ) | ||
| 224 | } | 255 | } |
| 225 | 256 | ||
| 226 | /** | 257 | /** |
| ... | @@ -231,10 +262,53 @@ const goToFeedback = () => { | ... | @@ -231,10 +262,53 @@ const goToFeedback = () => { |
| 231 | } | 262 | } |
| 232 | 263 | ||
| 233 | /** | 264 | /** |
| 234 | - * @description 页面显示时刷新列表(从提交页返回时也会触发) | 265 | + * @description 刷新反馈列表 |
| 266 | + * @returns {Promise<void>} | ||
| 235 | */ | 267 | */ |
| 236 | -useDidShow(() => { | 268 | +const refreshList = async () => { |
| 237 | - loadFeedbackList() | 269 | + console.log('[Feedback] 刷新列表') |
| 270 | + | ||
| 271 | + // 重置分页状态 | ||
| 272 | + currentPage.value = 0 | ||
| 273 | + hasMore.value = true | ||
| 274 | + | ||
| 275 | + // 重新获取列表 | ||
| 276 | + await loadFeedbackList({ page: 0, limit: pageSize }) | ||
| 277 | +} | ||
| 278 | + | ||
| 279 | +/** | ||
| 280 | + * @description 页面加载时获取列表(只执行一次) | ||
| 281 | + */ | ||
| 282 | +useLoad(() => { | ||
| 283 | + console.log('[Feedback] 页面加载,获取列表') | ||
| 284 | + | ||
| 285 | + // 重置分页状态 | ||
| 286 | + currentPage.value = 0 | ||
| 287 | + hasMore.value = true | ||
| 288 | + | ||
| 289 | + // 获取反馈列表 | ||
| 290 | + loadFeedbackList({ page: 0, limit: pageSize }) | ||
| 291 | +}) | ||
| 292 | + | ||
| 293 | +/** | ||
| 294 | + * @description 监听反馈提交成功事件 | ||
| 295 | + */ | ||
| 296 | +onMounted(() => { | ||
| 297 | + console.log('[Feedback] 注册事件监听') | ||
| 298 | + | ||
| 299 | + // 监听反馈提交成功事件 | ||
| 300 | + const unsubscribe = eventBus.on(Events.FEEDBACK_SUBMIT, async (data) => { | ||
| 301 | + console.log('[Feedback] 收到反馈提交事件:', data) | ||
| 302 | + | ||
| 303 | + // 刷新列表 | ||
| 304 | + await refreshList() | ||
| 305 | + }) | ||
| 306 | + | ||
| 307 | + // 组件卸载时取消监听 | ||
| 308 | + onUnmounted(() => { | ||
| 309 | + unsubscribe() | ||
| 310 | + console.log('[Feedback] 取消事件监听') | ||
| 311 | + }) | ||
| 238 | }) | 312 | }) |
| 239 | </script> | 313 | </script> |
| 240 | 314 | ||
| ... | @@ -247,7 +321,7 @@ useDidShow(() => { | ... | @@ -247,7 +321,7 @@ useDidShow(() => { |
| 247 | .feedback-item { | 321 | .feedback-item { |
| 248 | background: white; | 322 | background: white; |
| 249 | border-radius: 24rpx; | 323 | border-radius: 24rpx; |
| 250 | - margin: 16rpx 32rpx; | 324 | + // margin: 16rpx 32rpx; |
| 251 | padding: 32rpx; | 325 | padding: 32rpx; |
| 252 | box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08); | 326 | box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08); |
| 253 | transition: all 0.3s ease; | 327 | transition: all 0.3s ease; |
| ... | @@ -298,42 +372,6 @@ useDidShow(() => { | ... | @@ -298,42 +372,6 @@ useDidShow(() => { |
| 298 | margin-top: 16rpx; | 372 | margin-top: 16rpx; |
| 299 | } | 373 | } |
| 300 | } | 374 | } |
| 301 | - | ||
| 302 | - .empty-state { | ||
| 303 | - text-align: center; | ||
| 304 | - padding: 120rpx 32rpx; | ||
| 305 | - | ||
| 306 | - .empty-icon { | ||
| 307 | - font-size: 120rpx; | ||
| 308 | - color: #d1d5db; | ||
| 309 | - margin-bottom: 32rpx; | ||
| 310 | - } | ||
| 311 | - | ||
| 312 | - .empty-title { | ||
| 313 | - font-size: 36rpx; | ||
| 314 | - color: #6b7280; | ||
| 315 | - margin-bottom: 16rpx; | ||
| 316 | - } | ||
| 317 | - | ||
| 318 | - .empty-desc { | ||
| 319 | - font-size: 28rpx; | ||
| 320 | - color: #9ca3af; | ||
| 321 | - } | ||
| 322 | - } | ||
| 323 | - | ||
| 324 | - .load-more { | ||
| 325 | - text-align: center; | ||
| 326 | - padding: 32rpx; | ||
| 327 | - color: #3b82f6; | ||
| 328 | - font-size: 28rpx; | ||
| 329 | - } | ||
| 330 | - | ||
| 331 | - .no-more { | ||
| 332 | - text-align: center; | ||
| 333 | - padding: 32rpx; | ||
| 334 | - color: #9ca3af; | ||
| 335 | - font-size: 28rpx; | ||
| 336 | - } | ||
| 337 | } | 375 | } |
| 338 | 376 | ||
| 339 | // 固定按钮样式 | 377 | // 固定按钮样式 |
| ... | @@ -359,17 +397,5 @@ useDidShow(() => { | ... | @@ -359,17 +397,5 @@ useDidShow(() => { |
| 359 | } | 397 | } |
| 360 | } | 398 | } |
| 361 | 399 | ||
| 362 | -.loading-spinner { | 400 | +/* LoadMoreList 组件已内置样式,包括 Loading 和 Empty State */ |
| 363 | - width: 40rpx; | ||
| 364 | - height: 40rpx; | ||
| 365 | - border: 4rpx solid #f3f3f3; | ||
| 366 | - border-top: 4rpx solid #3498db; | ||
| 367 | - border-radius: 50%; | ||
| 368 | - animation: spin 1s linear infinite; | ||
| 369 | -} | ||
| 370 | - | ||
| 371 | -@keyframes spin { | ||
| 372 | - 0% { transform: rotate(0deg); } | ||
| 373 | - 100% { transform: rotate(360deg); } | ||
| 374 | -} | ||
| 375 | </style> | 401 | </style> | ... | ... |
| ... | @@ -90,6 +90,7 @@ import NavHeader from '@/components/NavHeader.vue' | ... | @@ -90,6 +90,7 @@ import NavHeader from '@/components/NavHeader.vue' |
| 90 | import Taro from '@tarojs/taro' | 90 | import Taro from '@tarojs/taro' |
| 91 | import { addAPI } from '@/api/feedback' | 91 | import { addAPI } from '@/api/feedback' |
| 92 | import BASE_URL from '@/utils/config' | 92 | import BASE_URL from '@/utils/config' |
| 93 | +import eventBus, { Events } from '@/utils/eventBus' | ||
| 93 | 94 | ||
| 94 | /** | 95 | /** |
| 95 | * 反馈类型选项(对应后端 category 值) | 96 | * 反馈类型选项(对应后端 category 值) |
| ... | @@ -217,6 +218,11 @@ const onSubmit = async () => { | ... | @@ -217,6 +218,11 @@ const onSubmit = async () => { |
| 217 | uploadedImages.value = [] | 218 | uploadedImages.value = [] |
| 218 | selectedType.value = '1' | 219 | selectedType.value = '1' |
| 219 | 220 | ||
| 221 | + // 发送事件通知反馈列表页刷新 | ||
| 222 | + eventBus.emit(Events.FEEDBACK_SUBMIT, { | ||
| 223 | + timestamp: Date.now() | ||
| 224 | + }) | ||
| 225 | + | ||
| 220 | // 延迟返回 | 226 | // 延迟返回 |
| 221 | setTimeout(() => { | 227 | setTimeout(() => { |
| 222 | Taro.navigateBack() | 228 | Taro.navigateBack() | ... | ... |
src/utils/eventBus.js
0 → 100644
| 1 | +/** | ||
| 2 | + * 事件总线工具 | ||
| 3 | + * | ||
| 4 | + * @description 轻量级事件总线,用于跨页面组件通信 | ||
| 5 | + * @module utils/eventBus | ||
| 6 | + * @author Claude Code | ||
| 7 | + * @created 2026-02-08 | ||
| 8 | + */ | ||
| 9 | + | ||
| 10 | +/** | ||
| 11 | + * 事件总线实现 | ||
| 12 | + * @type {Object} | ||
| 13 | + */ | ||
| 14 | +const eventBus = { | ||
| 15 | + /** | ||
| 16 | + * 事件监听器存储 | ||
| 17 | + * @type {Object<string, Array<Function>>} | ||
| 18 | + */ | ||
| 19 | + events: {}, | ||
| 20 | + | ||
| 21 | + /** | ||
| 22 | + * 监听事件 | ||
| 23 | + * | ||
| 24 | + * @param {string} event - 事件名称 | ||
| 25 | + * @param {Function} callback - 回调函数 | ||
| 26 | + * @returns {Function} 取消监听函数 | ||
| 27 | + * | ||
| 28 | + * @example | ||
| 29 | + * const off = eventBus.on('feedback:submit', (data) => { | ||
| 30 | + * console.log('收到反馈提交事件:', data) | ||
| 31 | + * }) | ||
| 32 | + * // 取消监听 | ||
| 33 | + * off() | ||
| 34 | + */ | ||
| 35 | + on(event, callback) { | ||
| 36 | + if (!this.events[event]) { | ||
| 37 | + this.events[event] = [] | ||
| 38 | + } | ||
| 39 | + this.events[event].push(callback) | ||
| 40 | + | ||
| 41 | + // 返回取消监听函数 | ||
| 42 | + return () => this.off(event, callback) | ||
| 43 | + }, | ||
| 44 | + | ||
| 45 | + /** | ||
| 46 | + * 取消监听事件 | ||
| 47 | + * | ||
| 48 | + * @param {string} event - 事件名称 | ||
| 49 | + * @param {Function} callback - 回调函数 | ||
| 50 | + */ | ||
| 51 | + off(event, callback) { | ||
| 52 | + if (!this.events[event]) return | ||
| 53 | + | ||
| 54 | + if (callback) { | ||
| 55 | + // 移除特定的回调 | ||
| 56 | + this.events[event] = this.events[event].filter(cb => cb !== callback) | ||
| 57 | + } else { | ||
| 58 | + // 移除所有回调 | ||
| 59 | + delete this.events[event] | ||
| 60 | + } | ||
| 61 | + }, | ||
| 62 | + | ||
| 63 | + /** | ||
| 64 | + * 发送事件 | ||
| 65 | + * | ||
| 66 | + * @param {string} event - 事件名称 | ||
| 67 | + * @param {*} data - 事件数据 | ||
| 68 | + * | ||
| 69 | + * @example | ||
| 70 | + * eventBus.emit('feedback:submit', { id: 123 }) | ||
| 71 | + */ | ||
| 72 | + emit(event, data) { | ||
| 73 | + if (!this.events[event]) return | ||
| 74 | + | ||
| 75 | + this.events[event].forEach(callback => { | ||
| 76 | + try { | ||
| 77 | + callback(data) | ||
| 78 | + } catch (err) { | ||
| 79 | + console.error(`[EventBus] 事件处理错误 [${event}]:`, err) | ||
| 80 | + } | ||
| 81 | + }) | ||
| 82 | + }, | ||
| 83 | + | ||
| 84 | + /** | ||
| 85 | + * 清空所有事件监听器 | ||
| 86 | + */ | ||
| 87 | + clear() { | ||
| 88 | + this.events = {} | ||
| 89 | + } | ||
| 90 | +} | ||
| 91 | + | ||
| 92 | +/** | ||
| 93 | + * 事件名称常量 | ||
| 94 | + * @enum {string} | ||
| 95 | + */ | ||
| 96 | +export const Events = { | ||
| 97 | + /** 反馈提交成功 */ | ||
| 98 | + FEEDBACK_SUBMIT: 'feedback:submit', | ||
| 99 | + /** 收藏列表更新 */ | ||
| 100 | + FAVORITES_UPDATE: 'favorites:update', | ||
| 101 | + /** 用户信息更新 */ | ||
| 102 | + USER_UPDATE: 'user:update' | ||
| 103 | +} | ||
| 104 | + | ||
| 105 | +export default eventBus |
| ... | @@ -8,6 +8,8 @@ | ... | @@ -8,6 +8,8 @@ |
| 8 | * - listAPI: 产品列表 | 8 | * - listAPI: 产品列表 |
| 9 | * - searchAPI: 搜索(产品+资料) | 9 | * - searchAPI: 搜索(产品+资料) |
| 10 | * - myListAPI: 消息列表 | 10 | * - myListAPI: 消息列表 |
| 11 | + * - favoriteListAPI: 收藏列表 | ||
| 12 | + * - feedbackListAPI: 意见反馈列表 | ||
| 11 | */ | 13 | */ |
| 12 | 14 | ||
| 13 | // ============================================================================ | 15 | // ============================================================================ |
| ... | @@ -593,6 +595,180 @@ export async function mockMessageListAPI(params) { | ... | @@ -593,6 +595,180 @@ export async function mockMessageListAPI(params) { |
| 593 | } | 595 | } |
| 594 | 596 | ||
| 595 | // ============================================================================ | 597 | // ============================================================================ |
| 598 | +// 6. 收藏列表 Mock (favoriteListAPI) | ||
| 599 | +// ============================================================================ | ||
| 600 | + | ||
| 601 | +const FAVORITE_MATERIALS = [ | ||
| 602 | + '财富管理基础知识指南', | ||
| 603 | + '保险产品销售技巧', | ||
| 604 | + '客户关系管理实战', | ||
| 605 | + '家庭资产配置方案', | ||
| 606 | + '税务筹划实用手册', | ||
| 607 | + '退休规划完整教程', | ||
| 608 | + '投资组合管理策略', | ||
| 609 | + '风险控制与合规要求', | ||
| 610 | + '高净值客户开发指南', | ||
| 611 | + '理财产品营销话术', | ||
| 612 | + '基金定投实战技巧', | ||
| 613 | + '保单整理服务流程', | ||
| 614 | + '传承规划案例分析', | ||
| 615 | + '健康险产品对比分析', | ||
| 616 | + '年金保险销售指南', | ||
| 617 | + '重疾险核保知识', | ||
| 618 | + '教育金规划方案', | ||
| 619 | + '房贷规划实务操作', | ||
| 620 | + '家族信托业务介绍', | ||
| 621 | + '私募股权投资指南' | ||
| 622 | +] | ||
| 623 | + | ||
| 624 | +/** | ||
| 625 | + * 生成收藏列表项 | ||
| 626 | + */ | ||
| 627 | +function generateFavoriteItem(id) { | ||
| 628 | + const fileType = FILE_TYPES[Math.floor(Math.random() * FILE_TYPES.length)] | ||
| 629 | + const materialName = FAVORITE_MATERIALS[Math.floor(Math.random() * FAVORITE_MATERIALS.length)] | ||
| 630 | + const now = new Date() | ||
| 631 | + const createDate = new Date(now.getTime() - Math.random() * 90 * 24 * 60 * 60 * 1000) | ||
| 632 | + | ||
| 633 | + return { | ||
| 634 | + meta_id: id, | ||
| 635 | + name: `${materialName}.${fileType.extension}`, | ||
| 636 | + size: generateRandomSize(), | ||
| 637 | + src: `https://placehold.co/100x100/e2e8f0/475569?text=${fileType.extension.toUpperCase()}`, | ||
| 638 | + created_time: formatDate(createDate) | ||
| 639 | + } | ||
| 640 | +} | ||
| 641 | + | ||
| 642 | +/** | ||
| 643 | + * Mock: favoriteListAPI (收藏列表) | ||
| 644 | + */ | ||
| 645 | +export async function mockFavoriteListAPI(params) { | ||
| 646 | + await mockDelay() | ||
| 647 | + | ||
| 648 | + const { page = 0, limit = 20 } = params | ||
| 649 | + const totalPages = 3 | ||
| 650 | + | ||
| 651 | + if (page >= totalPages) { | ||
| 652 | + return { code: 1, msg: 'success', data: { list: [] } } | ||
| 653 | + } | ||
| 654 | + | ||
| 655 | + const list = [] | ||
| 656 | + const startIndex = page * limit | ||
| 657 | + | ||
| 658 | + for (let i = 0; i < limit; i++) { | ||
| 659 | + list.push(generateFavoriteItem(startIndex + i + 1)) | ||
| 660 | + } | ||
| 661 | + | ||
| 662 | + console.log(`[Mock] favoriteListAPI - 第${page}页,共${list.length}条`) | ||
| 663 | + | ||
| 664 | + return { | ||
| 665 | + code: 1, | ||
| 666 | + msg: 'success', | ||
| 667 | + data: { list } | ||
| 668 | + } | ||
| 669 | +} | ||
| 670 | + | ||
| 671 | +// ============================================================================ | ||
| 672 | +// 7. 意见反馈列表 Mock (feedbackListAPI) | ||
| 673 | +// ============================================================================ | ||
| 674 | + | ||
| 675 | +const FEEDBACK_CATEGORIES = ['1', '3', '7'] // 1=功能建议, 3=问题反馈, 7=其他问题 | ||
| 676 | +const FEEDBACK_NOTES = [ | ||
| 677 | + '希望能够增加资料下载功能', | ||
| 678 | + '产品详情页加载速度较慢', | ||
| 679 | + '收藏功能使用不便,建议优化', | ||
| 680 | + '搜索结果不够准确', | ||
| 681 | + '希望能够添加学习进度跟踪', | ||
| 682 | + '界面颜色有点太深了', | ||
| 683 | + '建议添加夜间模式', | ||
| 684 | + '资料分类不够清晰', | ||
| 685 | + '希望能够离线查看资料', | ||
| 686 | + '登录后总是会重新要求登录', | ||
| 687 | + '视频播放有时会卡顿', | ||
| 688 | + '希望能够支持分享到朋友圈', | ||
| 689 | + '字体大小无法调整', | ||
| 690 | + '建议增加资料收藏夹分类', | ||
| 691 | + '消息通知太频繁了', | ||
| 692 | + '希望能够批量管理收藏', | ||
| 693 | + '产品对比功能不够直观', | ||
| 694 | + '建议添加更多实用工具', | ||
| 695 | + '客服回复速度有待提升' | ||
| 696 | +] | ||
| 697 | + | ||
| 698 | +const FEEDBACK_REPLIES = [ | ||
| 699 | + '感谢您的宝贵建议,我们会尽快优化!', | ||
| 700 | + '您反馈的问题我们已经记录,技术团队正在处理中。', | ||
| 701 | + '好的,我们会考虑您的建议。', | ||
| 702 | + '非常感谢您的反馈,这对我们改进产品很有帮助。', | ||
| 703 | + '您提到的问题我们已经收到,会在下个版本中优化。', | ||
| 704 | + '感谢您的支持,我们会继续改进产品体验。' | ||
| 705 | +] | ||
| 706 | + | ||
| 707 | +/** | ||
| 708 | + * 生成反馈列表项 | ||
| 709 | + */ | ||
| 710 | +function generateFeedbackItem(id) { | ||
| 711 | + const category = FEEDBACK_CATEGORIES[Math.floor(Math.random() * FEEDBACK_CATEGORIES.length)] | ||
| 712 | + const note = FEEDBACK_NOTES[Math.floor(Math.random() * FEEDBACK_NOTES.length)] | ||
| 713 | + const status = Math.random() > 0.6 ? 5 : 1 // 60%概率已处理 | ||
| 714 | + const hasReply = status === 5 && Math.random() > 0.3 // 已处理的有70%概率有回复 | ||
| 715 | + const hasImages = Math.random() > 0.7 // 30%概率有图片 | ||
| 716 | + | ||
| 717 | + const now = new Date() | ||
| 718 | + const createDate = new Date(now.getTime() - Math.random() * 60 * 24 * 60 * 60 * 1000) | ||
| 719 | + const replyDate = new Date(createDate.getTime() + Math.random() * 7 * 24 * 60 * 60 * 1000) | ||
| 720 | + | ||
| 721 | + // 生成随机图片 | ||
| 722 | + const images = [] | ||
| 723 | + if (hasImages) { | ||
| 724 | + const imageCount = Math.floor(Math.random() * 3) + 1 | ||
| 725 | + for (let i = 0; i < imageCount; i++) { | ||
| 726 | + images.push(`https://placehold.co/200x200/f3f4f6/9ca3af?text=截图${i + 1}`) | ||
| 727 | + } | ||
| 728 | + } | ||
| 729 | + | ||
| 730 | + return { | ||
| 731 | + id: id, | ||
| 732 | + category: category, | ||
| 733 | + status: status, | ||
| 734 | + note: note, | ||
| 735 | + images: images, | ||
| 736 | + contact: Math.random() > 0.5 ? '138****8888' : '', | ||
| 737 | + reply: hasReply ? FEEDBACK_REPLIES[Math.floor(Math.random() * FEEDBACK_REPLIES.length)] : '', | ||
| 738 | + reply_time: hasReply ? formatDate(replyDate) : '' | ||
| 739 | + } | ||
| 740 | +} | ||
| 741 | + | ||
| 742 | +/** | ||
| 743 | + * Mock: feedbackListAPI (意见反馈列表) | ||
| 744 | + */ | ||
| 745 | +export async function mockFeedbackListAPI(params) { | ||
| 746 | + await mockDelay() | ||
| 747 | + | ||
| 748 | + const { page = 0, limit = 10 } = params | ||
| 749 | + const totalPages = 5 | ||
| 750 | + | ||
| 751 | + if (page >= totalPages) { | ||
| 752 | + return { code: 1, msg: 'success', data: { list: [] } } | ||
| 753 | + } | ||
| 754 | + | ||
| 755 | + const list = [] | ||
| 756 | + const startIndex = page * limit | ||
| 757 | + | ||
| 758 | + for (let i = 0; i < limit; i++) { | ||
| 759 | + list.push(generateFeedbackItem(startIndex + i + 1)) | ||
| 760 | + } | ||
| 761 | + | ||
| 762 | + console.log(`[Mock] feedbackListAPI - 第${page}页,共${list.length}条`) | ||
| 763 | + | ||
| 764 | + return { | ||
| 765 | + code: 1, | ||
| 766 | + msg: 'success', | ||
| 767 | + data: { list } | ||
| 768 | + } | ||
| 769 | +} | ||
| 770 | + | ||
| 771 | +// ============================================================================ | ||
| 596 | // 导出统一 Mock API 调用器 | 772 | // 导出统一 Mock API 调用器 |
| 597 | // ============================================================================ | 773 | // ============================================================================ |
| 598 | 774 | ||
| ... | @@ -614,6 +790,10 @@ export async function mockAPI(apiName, params) { | ... | @@ -614,6 +790,10 @@ export async function mockAPI(apiName, params) { |
| 614 | return await mockSearchAPI(params) | 790 | return await mockSearchAPI(params) |
| 615 | case 'myListAPI': | 791 | case 'myListAPI': |
| 616 | return await mockMessageListAPI(params) | 792 | return await mockMessageListAPI(params) |
| 793 | + case 'favoriteListAPI': | ||
| 794 | + return await mockFavoriteListAPI(params) | ||
| 795 | + case 'feedbackListAPI': | ||
| 796 | + return await mockFeedbackListAPI(params) | ||
| 617 | default: | 797 | default: |
| 618 | console.warn(`[Mock] 未知的 API: ${apiName}`) | 798 | console.warn(`[Mock] 未知的 API: ${apiName}`) |
| 619 | return { code: 0, msg: 'Unknown API', data: null } | 799 | return { code: 0, msg: 'Unknown API', data: null } | ... | ... |
-
Please register or login to post a comment