hookehuyr

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>
...@@ -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()
......
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 }
......