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 @@
| 3 | 产品中心 | `src/pages/product-center/index.vue` | `mockProductListAPI` | ✅ 已完成 |
| 4 | 搜索页 | `src/pages/search/index.vue` | `mockSearchAPI` | ✅ 已完成 |
| 5 | 消息列表 | `src/pages/message/index.vue` | `mockMessageListAPI` | ✅ 已完成 |
| 6 | 收藏列表 | `src/pages/favorites/index.vue` | `mockFavoriteListAPI` | ✅ 已完成 |
| 7 | 意见反馈列表 | `src/pages/feedback-list/index.vue` | `mockFeedbackListAPI` | ✅ 已完成 |
---
......@@ -130,6 +132,59 @@ const USE_MOCK_DATA = true // ✅ 已启用
---
### 6. 收藏列表页 ✅
**文件**: `src/pages/favorites/index.vue`
**修改**:
- ✅ 导入 `mockFavoriteListAPI`
- ✅ 添加 `USE_MOCK_DATA = true` 开关 (第 73 行)
- ✅ 使用 LoadMoreList 组件重构
- ✅ 修改 `fetchFavoritesList` 函数 (第 122-180 行)
- ✅ 添加 `handleLoadMore` 函数 (第 246-257 行)
**使用**:
```javascript
// 第 73 行
const USE_MOCK_DATA = true // ✅ 已启用
```
**特性**:
- ✅ 20 种收藏资料模板
- ✅ 随机创建时间(最近90天内)
- ✅ 随机文件大小和类型
- ✅ 支持查看和删除操作
- ✅ 3 页数据,每页 20 条
---
### 7. 意见反馈列表页 ✅
**文件**: `src/pages/feedback-list/index.vue`
**修改**:
- ✅ 导入 `mockFeedbackListAPI`
- ✅ 添加 `USE_MOCK_DATA = true` 开关 (第 96 行)
- ✅ 使用 LoadMoreList 组件重构
- ✅ 修改 `loadFeedbackList` 函数 (第 184-236 行)
- ✅ 添加 `handleLoadMore` 函数 (第 243-254 行)
**使用**:
```javascript
// 第 96 行
const USE_MOCK_DATA = true // ✅ 已启用
```
**特性**:
- ✅ 20 种反馈内容模板
- ✅ 3 种反馈分类(功能建议、问题反馈、其他问题)
- ✅ 60%概率已处理
- ✅ 已处理的反馈有70%概率有回复
- ✅ 30%概率包含截图
- ✅ 5 页数据,每页 10 条
---
## 全局测试步骤
### 1. 启动开发服务器
......@@ -176,6 +231,22 @@ pnpm dev:weapp
4. 查看消息时间格式(YYYY-MM-DD)
5. 滚动到底,显示"没有更多了"
#### 收藏列表页
1. 导航到"我的收藏"页
2. 查看首次加载(20 条数据)
3. 向下滚动加载更多
4. 查看收藏时间格式(YYYY-MM-DD)
5. 滚动到底,显示"没有更多了"
6. 测试删除功能(Mock模式)
#### 意见反馈列表页
1. 导航到"意见反馈"页
2. 查看首次加载(10 条数据)
3. 向下滚动加载更多
4. 查看反馈分类标签(功能建议、问题反馈、其他问题)
5. 查看处理状态(已处理/待处理)
6. 滚动到底,显示"没有更多了"
### 3. 查看 Console 日志
正常情况下会看到:
......@@ -185,6 +256,8 @@ pnpm dev:weapp
[Mock] listAPI - 第0页,共10条
[Mock] searchAPI - 第0页,产品10条,资料10条
[Mock] myListAPI - 第1页,共10条
[Mock] favoriteListAPI - 第0页,共20条
[Mock] feedbackListAPI - 第0页,共10条
```
---
......@@ -202,7 +275,9 @@ sed -i '' 's/const USE_MOCK_DATA = true/const USE_MOCK_DATA = false/g' \
src/pages/material-list/index.vue \
src/pages/product-center/index.vue \
src/pages/search/index.vue \
src/pages/message/index.vue
src/pages/message/index.vue \
src/pages/favorites/index.vue \
src/pages/feedback-list/index.vue
```
### 手动切换
......@@ -217,12 +292,14 @@ const USE_MOCK_DATA = true
const USE_MOCK_DATA = false
```
**需要修改的 5 个文件**:
**需要修改的 7 个文件**:
1. `src/pages/week-hot-material/index.vue`
2. `src/pages/material-list/index.vue`
3. `src/pages/product-center/index.vue`
4. `src/pages/search/index.vue`
5. `src/pages/message/index.vue`
6. `src/pages/favorites/index.vue`
7. `src/pages/feedback-list/index.vue`
---
......@@ -288,6 +365,29 @@ const USE_MOCK_DATA = false
| `is_read` | integer | 是否已读 | 0 或 1 |
| `type` | string | 消息类型 | "notice" 或 "system" |
### 收藏列表
| 字段 | 类型 | 说明 | 示例 |
|------|------|------|------|
| `meta_id` | integer | 文件ID | 1, 2, 3... |
| `name` | string | 文件名称(含扩展名) | "财富管理基础知识指南.pdf" |
| `size` | string | 文件大小 | "2.5MB" |
| `src` | string | 文件URL | "https://cdn.example.com/files/1.pdf" |
| `created_time` | string | 收藏时间 | "2024-01-15" |
### 意见反馈列表
| 字段 | 类型 | 说明 | 示例 |
|------|------|------|------|
| `id` | integer | 反馈ID | 1, 2, 3... |
| `category` | string | 反馈分类 | "1"=功能建议, "3"=问题反馈, "7"=其他问题 |
| `status` | integer | 处理状态 | 1=待处理, 5=已处理 |
| `note` | string | 反馈内容 | "希望能够增加资料下载功能" |
| `images` | Array<string> | 截图URL列表 | ["https://placehold.co/200x200/..."] |
| `contact` | string | 联系方式 | "138****8888" |
| `reply` | string | 客服回复 | "感谢您的宝贵建议..." |
| `reply_time` | string | 回复时间 | "2024-01-15" |
---
## 常见问题
......@@ -345,6 +445,10 @@ const totalPages = 10 // 从 5 改为 10
- [ ] 搜索页产品/资料切换正常
- [ ] 搜索页滚动加载正常
- [ ] 消息列表页滚动加载正常
- [ ] 收藏列表页滚动加载正常
- [ ] 收藏列表页删除功能正常(Mock模式)
- [ ] 意见反馈列表页滚动加载正常
- [ ] 意见反馈列表页分类和状态显示正常
- [ ] 所有页面 Console 日志正确输出
- [ ] 所有页面"没有更多了"正常显示
- [ ] 数据格式正确,无报错
......
......@@ -16,6 +16,7 @@
- [错误处理](#3-错误处理)
- [API 调用错误:使用 fn() 包装](#坑-api-调用了-fn-包装重复-2-次) ⭐ 新增
- [架构设计](#架构设计)
- [跨页面通信](#跨页面通信) ⭐ 新增
- [开发工作流](#开发工作流) ⭐ 新增
- [Mock 数据环境自动切换](#mock-数据环境自动切换模式) ⭐ 新增
......@@ -1041,6 +1042,368 @@ src/
---
## 跨页面通信
### ❌ 坑: useDidShow 导致列表意外刷新
**问题描述**:
在收藏列表页(favorites)和反馈列表页(feedback-list)中,使用 `useDidShow` 钩子会导致列表在关闭图片预览时意外刷新。
**错误表现**:
- 用户点击图片预览(使用 `Taro.previewImage()`)
- 关闭预览后,`useDidShow` 钩子触发
- 列表重新加载,显示 loading 动画
- 用户体验差,感觉应用卡顿
**错误代码**:
```javascript
// ❌ 错误:使用 useDidShow 导致意外刷新
import { useLoad, useDidShow } from '@tarojs/taro'
useLoad(() => {
// 页面首次加载
fetchList({ page: 0, limit: pageSize })
})
useDidShow(() => {
// ❌ 每次页面显示都刷新(包括关闭图片预览时)
refreshList()
})
```
**根本原因**:
- `useDidShow` 在任何页面显示时都会触发
- 关闭 `Taro.previewImage()` 会触发页面显示事件
- 不区分正常的页面返回(需要刷新)和模态框关闭(不需要刷新)
**解决方案**: 仅使用 `useLoad` + 事件总线
```javascript
// ✅ 正确:仅 useLoad 初始化 + 事件总线刷新特定事件
import { useLoad } from '@tarojs/taro'
import { ref, onMounted, onUnmounted } from 'vue'
import eventBus, { Events } from '@/utils/eventBus'
// 首次加载时获取列表
useLoad(() => {
console.log('[Favorites] 页面加载,获取列表')
currentPage.value = 0
hasMore.value = true
fetchList({ page: 0, limit: pageSize })
})
// 监听收藏更新事件(收藏/取消收藏时触发)
onMounted(() => {
console.log('[Favorites] 注册事件监听')
const unsubscribe = eventBus.on(Events.FAVORITES_UPDATE, async (data) => {
console.log('[Favorites] 收到收藏更新事件:', data)
await refreshList()
})
// 组件卸载时取消监听
onUnmounted(() => {
unsubscribe()
console.log('[Favorites] 取消事件监听')
})
})
```
**收益**:
- ✅ 避免了关闭图片预览时的意外刷新
- ✅ 保留了跨页面操作后的自动刷新(如收藏、提交反馈)
- ✅ 用户体验显著提升
**相关文件**:
- `src/pages/favorites/index.vue`(已修复)
- `src/pages/feedback-list/index.vue`(已修复)
### ✅ 最佳实践:事件总线模式
#### 使用场景判断
**关键原则**:跨页面操作需要事件总线,单页面操作使用本地更新
| 操作类型 | 是否跨页面 | 数据同步方式 | 示例 |
|---------|----------|------------|------|
| **收藏操作** | ✅ 是 | 事件总线 | 在产品详情页收藏 → 收藏列表页自动刷新 |
| **提交反馈** | ✅ 是 | 事件总线 | 提交反馈后 → 反馈列表页自动刷新 |
| **删除收藏** | ❌ 否 | 本地更新 | 在收藏列表页删除 → 立即从列表移除 |
#### 事件总线实现
**1. 创建事件总线**(`src/utils/eventBus.js`):
```javascript
/**
* 事件总线工具
*
* @description 轻量级事件总线,用于跨页面组件通信
* @module utils/eventBus
* @author Claude Code
* @created 2026-02-08
*/
const eventBus = {
events: {},
/**
* 监听事件
*
* @param {string} event - 事件名称
* @param {Function} callback - 回调函数
* @returns {Function} 取消监听函数
*/
on(event, callback) {
if (!this.events[event]) {
this.events[event] = []
}
this.events[event].push(callback)
// 返回取消监听函数
return () => this.off(event, callback)
},
/**
* 发送事件
*
* @param {string} event - 事件名称
* @param {*} data - 事件数据
*/
emit(event, data) {
if (!this.events[event]) return
this.events[event].forEach(callback => {
try {
callback(data)
} catch (err) {
console.error(`[EventBus] 事件处理错误 [${event}]:`, err)
}
})
},
/**
* 取消监听事件
*
* @param {string} event - 事件名称
* @param {Function} callback - 回调函数
*/
off(event, callback) {
if (!this.events[event]) return
if (callback) {
// 移除特定的回调
this.events[event] = this.events[event].filter(cb => cb !== callback)
} else {
// 移除所有回调
delete this.events[event]
}
},
/**
* 清空所有事件监听器
*/
clear() {
this.events = {}
}
}
/**
* 事件名称常量
* @enum {string}
*/
export const Events = {
/** 反馈提交成功 */
FEEDBACK_SUBMIT: 'feedback:submit',
/** 收藏列表更新 */
FAVORITES_UPDATE: 'favorites:update',
/** 用户信息更新 */
USER_UPDATE: 'user:update'
}
export default eventBus
```
**2. 发送事件**(在操作页面):
```javascript
// src/pages/feedback/index.vue
import eventBus, { Events } from '@/utils/eventBus'
const onSubmit = async () => {
// ... 提交逻辑
if (res.code === 1) {
Taro.showToast({ title: '提交成功', icon: 'success' })
// 发送事件通知反馈列表页刷新
eventBus.emit(Events.FEEDBACK_SUBMIT, {
timestamp: Date.now()
})
// 返回上一页
setTimeout(() => {
Taro.navigateBack()
}, 1500)
}
}
```
**3. 接收事件**(在列表页面):
```javascript
// src/pages/feedback-list/index.vue
import { ref, onMounted, onUnmounted } from 'vue'
import eventBus, { Events } from '@/utils/eventBus'
onMounted(() => {
console.log('[Feedback] 注册事件监听')
// 监听反馈提交成功事件
const unsubscribe = eventBus.on(Events.FEEDBACK_SUBMIT, async (data) => {
console.log('[Feedback] 收到反馈提交事件:', data)
// 刷新列表
await refreshList()
})
// 组件卸载时取消监听
onUnmounted(() => {
unsubscribe()
console.log('[Feedback] 取消事件监听')
})
})
```
**4. Composable 中发送事件**(收藏操作):
```javascript
// src/composables/useCollectOperation.js
import eventBus, { Events } from '@/utils/eventBus'
export function useCollectOperation(options = {}) {
const { onSuccess, onError } = options
const toggleCollect = async (item, successMsg, errorMsg = '操作失败') => {
try {
// 乐观更新 UI
const newCollectStatus = !item.collected
item.collected = newCollectStatus
const metaId = item.meta_id || item.id
// 调用 API
const res = newCollectStatus
? await addAPI({ meta_id: metaId })
: await delAPI({ meta_id: metaId })
if (res.code === 1) {
Taro.showToast({
title: successMsg || (newCollectStatus ? '已收藏' : '已取消收藏'),
icon: 'success',
duration: 1000
})
// 发送收藏更新事件(通知收藏列表页刷新)
eventBus.emit(Events.FAVORITES_UPDATE, {
metaId,
collected: newCollectStatus,
timestamp: Date.now()
})
onSuccess?.(item, newCollectStatus)
return true
} else {
// API 失败,回滚 UI 状态
item.collected = !newCollectStatus
Taro.showToast({
title: res.msg || errorMsg,
icon: 'none',
duration: 2000
})
onError?.(item, res.msg)
return false
}
} catch (err) {
// 发生错误,回滚 UI 状态
item.collected = !item.collected
console.error('[useCollectOperation] 收藏操作失败:', err)
Taro.showToast({
title: '网络错误,请重试',
icon: 'none',
duration: 2000
})
onError?.(item, err.message)
return false
}
}
return { toggleCollect }
}
```
#### 生命周期最佳实践
**LoadMoreList 页面**(分页列表页面)的生命周期使用规范:
```javascript
// ✅ 正确:LoadMoreList 页面生命周期
import { useLoad } from '@tarojs/taro'
import { ref, onMounted, onUnmounted } from 'vue'
import eventBus, { Events } from '@/utils/eventBus'
// 首次加载时获取列表(只执行一次)
useLoad(() => {
console.log('[Page] 页面加载,获取列表')
currentPage.value = 0
hasMore.value = true
fetchList({ page: 0, limit: pageSize })
})
// 监听跨页面事件(需要刷新时)
onMounted(() => {
const unsubscribe = eventBus.on(Events.SOME_EVENT, async (data) => {
console.log('[Page] 收到事件:', data)
await refreshList()
})
onUnmounted(() => {
unsubscribe()
})
})
```
**关键点**:
- ✅ 使用 `useLoad` 进行一次性初始化
- ✅ 使用事件总线监听跨页面操作
- ✅ 在 `onMounted` 中注册监听器
- ✅ 在 `onUnmounted` 中取消监听器(防止内存泄漏)
- ❌ 不使用 `useDidShow`(避免意外刷新)
**适用范围**:
- ✅ 所有使用 `LoadMoreList` 组件的分页列表页面
- ✅ 需要跨页面通信的场景
- ✅ 需要在特定事件后刷新数据的场景
#### 检查清单
在实现列表页面时,确认:
- [ ] 是否为 LoadMoreList 页面(分页列表)?
- [ ] 是 → 使用 `useLoad` + 事件总线
- [ ] 否 → 根据需求使用 `useLoad` 或 `useDidShow`
- [ ] 是否需要跨页面刷新?
- [ ] 是 → 使用事件总线模式
- [ ] 否 → 使用本地更新即可
- [ ] 是否正确清理事件监听器?
- [ ] 在 `onUnmounted` 中调用 `unsubscribe()`
- [ ] 是否避免了 `useDidShow` 的误用?
- [ ] 不在 LoadMoreList 页面中使用 `useDidShow`
**历史记录**:
- **第 1 次错误**: favorites 页面使用 `useDidShow` 导致关闭图片预览时列表刷新
- **第 2 次错误**: feedback-list 页面使用 `useDidShow` 导致同样问题
- **教训**: LoadMoreList 页面应使用 `useLoad` + 事件总线,避免 `useDidShow` 导致的意外刷新
---
## 开发工作流
### ✅ Mock 数据环境自动切换模式
......@@ -1221,8 +1584,9 @@ const USE_MOCK_DATA = true // 容易导致生产环境误用
6. **代码质量**: 强制 JSDoc 注释,统一命名规范
7. **API 调用规范**: ⚠️ **不要使用 `fn()` 包装 API,直接调用并自己处理错误**
8. **架构设计**: 分层清晰,职责单一
9. **Mock 数据切换**: ⭐ **使用环境变量 `process.env.NODE_ENV` 自动判断,禁止硬编码**
10. **⚠️ 写代码前必查**: 先搜索项目中是否有类似实现,保持写法一致
9. **跨页面通信**: ⭐ **使用事件总线模式,LoadMoreList 页面避免使用 `useDidShow`**(防止意外刷新)
10. **Mock 数据切换**: ⭐ **使用环境变量 `process.env.NODE_ENV` 自动判断,禁止硬编码**
11. **⚠️ 写代码前必查**: 先搜索项目中是否有类似实现,保持写法一致
### 📚 推荐阅读
......@@ -1243,4 +1607,5 @@ const USE_MOCK_DATA = true // 容易导致生产环境误用
**项目**: Manulife WeApp
**更新记录**:
- 2026-02-08: 新增 "跨页面通信" 章节,记录事件总线实现和 useDidShow 陷阱解决方案
- 2026-02-08: 新增 "开发工作流" 章节,记录 Mock 数据环境自动切换模式
......
......@@ -8,6 +8,7 @@
import { addAPI, delAPI } from '../api/favorite.js'
import Taro from '@tarojs/taro'
import eventBus, { Events } from '@/utils/eventBus'
/**
* 使用收藏操作
......@@ -59,6 +60,13 @@ export function useCollectOperation(options = {}) {
duration: 1000
})
// 发送收藏更新事件(通知收藏列表页刷新)
eventBus.emit(Events.FAVORITES_UPDATE, {
metaId,
collected: newCollectStatus,
timestamp: Date.now()
})
// 调用成功回调
onSuccess?.(item, newCollectStatus)
......
<!--
* @Date: 2026-02-05
* @Description: 我的收藏 - 已接入真实API,移除分类逻辑
* @Date: 2026-02-08
* @Description: 我的收藏 - 使用 LoadMoreList 组件重构版本
-->
<template>
<view class="h-screen bg-gray-50 flex flex-col">
......@@ -8,116 +8,202 @@
<NavHeader title="我的收藏" />
</view>
<view
v-if="listVisible"
:key="listRenderKey"
class="flex-1 min-h-0 overflow-y-auto px-[24rpx] py-[24rpx] pb-[200rpx]"
<!-- LoadMoreList 组件 -->
<LoadMoreList
:list="currentList"
:page="currentPage"
:page-size="pageSize"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
key-field="meta_id"
@load-more="handleLoadMore"
>
<!-- Loading State -->
<view v-if="loading" class="flex flex-col items-center justify-center">
<view class="loading-spinner"></view>
<view class="text-gray-400 text-[24rpx] mt-3">加载中...</view>
</view>
<!-- List Items -->
<view v-for="(item, index) in favoritesList" :key="item.meta_id"
class="bg-white rounded-[24rpx] p-[24rpx] mb-[24rpx] shadow-sm favorite-item"
:style="{ animationDelay: `${index * 50}ms` }">
<!-- Header with Icon -->
<view class="flex gap-[24rpx] mb-[12rpx]">
<!-- Document Icon -->
<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">
<image :src="getDocumentIcon(item.name)" class="w-[48rpx] h-[48rpx]" mode="aspectFit" />
</view>
<!-- 列表项 -->
<template #item="{ item }">
<view class="bg-white rounded-[24rpx] p-[24rpx] mb-[24rpx] shadow-sm favorite-item">
<!-- Header with Icon -->
<view class="flex gap-[24rpx] mb-[12rpx]">
<!-- Document Icon -->
<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">
<image :src="getDocumentIcon(item.name)" class="w-[48rpx] h-[48rpx]" mode="aspectFit" />
</view>
<!-- Title -->
<view class="flex-1 min-w-0">
<view class="text-[30rpx] font-bold text-gray-900 leading-normal mb-1">{{ item.name }}</view>
<view class="text-gray-400 text-[22rpx]">{{ item.size }}</view>
<!-- Title -->
<view class="flex-1 min-w-0">
<view class="text-[30rpx] font-bold text-gray-900 leading-normal mb-1">{{ item.name }}</view>
<view class="text-gray-400 text-[22rpx]">{{ item.size }}</view>
</view>
</view>
</view>
<!-- Date -->
<view class="text-gray-500 text-[24rpx] mb-[20rpx] text-right">
<text>{{ item.created_time }}</text>
</view>
<!-- Date -->
<view class="text-gray-500 text-[24rpx] mb-[20rpx] text-right">
<text>{{ item.created_time }}</text>
</view>
<!-- Divider -->
<view class="h-[1rpx] bg-gray-100 mb-[20rpx]"></view>
<!-- Actions -->
<ListItemActions
:viewable="true"
:deletable="true"
:item-id="String(item.meta_id)"
@view="viewFile({...item, fileName: item.name, downloadUrl: item.src})"
@delete="onDelete(item)"
/>
</view>
<!-- Empty State -->
<view v-if="!loading && favoritesList.length === 0">
<nut-empty description="暂无收藏内容" image="empty" />
</view>
</view>
<!-- Divider -->
<view class="h-[1rpx] bg-gray-100 mb-[20rpx]"></view>
<!-- TabBar -->
<!-- <TabBar current="me" /> -->
<!-- Actions -->
<ListItemActions
:viewable="true"
:deletable="true"
:item-id="String(item.meta_id)"
@view="viewFile({...item, fileName: item.name, downloadUrl: item.src})"
@delete="onDelete(item)"
/>
</view>
</template>
</LoadMoreList>
</view>
</template>
<script setup>
import { ref } from 'vue'
import Taro from '@tarojs/taro'
import { ref, Ref, onMounted, onUnmounted } from 'vue'
import Taro, { useLoad } from '@tarojs/taro'
import LoadMoreList from '@/components/LoadMoreList'
import { useFileOperation } from '@/composables/useFileOperation'
import { getDocumentIcon } from '@/utils/documentIcons'
import NavHeader from '@/components/NavHeader.vue'
import ListItemActions from '@/components/ListItemActions/index.vue'
import { listAPI, delAPI } from '@/api/favorite'
import { mockFavoriteListAPI } from '@/utils/mockData'
import eventBus, { Events } from '@/utils/eventBus'
// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API
const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
const { viewFile } = useFileOperation()
const listVisible = ref(true)
const listRenderKey = ref(0)
/**
* 当前列表数据
* @type {Ref<Array<any>>}
*/
const currentList = ref([])
/**
* 当前页码(从0开始)
* @type {Ref<number>}
*/
const currentPage = ref(0)
/**
* 每页数量
* @type {number}
*/
const pageSize = 20
/**
* 是否还有更多数据
* @type {Ref<boolean>}
*/
const hasMore = ref(true)
/**
* 首次加载状态
* @type {Ref<boolean>}
*/
const loading = ref(false)
const favoritesList = ref([])
/**
* 加载更多状态
* @type {Ref<boolean>}
*/
const loadingMore = ref(false)
/**
* 获取收藏列表
*
* @param {Object} params - 请求参数
* @param {number} params.page - 页码(从0开始)
* @param {number} params.limit - 每页数量
* @param {boolean} isLoadMore - 是否为加载更多
* @returns {Promise<void>}
*/
const fetchFavoritesList = async () => {
const fetchFavoritesList = async (params = {}, isLoadMore = false) => {
try {
loading.value = true
const res = await listAPI({
page: '0',
limit: '100'
})
// 如果是加载更多,使用 loadingMore 状态,否则使用 loading 状态
if (isLoadMore) {
loadingMore.value = true
} else {
loading.value = true
}
console.log('[Favorites] 请求参数:', params)
console.log('[Favorites] 使用 Mock 数据:', USE_MOCK_DATA)
// 根据开关选择使用真实 API 或 Mock 数据
const res = USE_MOCK_DATA
? await mockFavoriteListAPI(params)
: await listAPI({
page: String(params.page),
limit: String(params.limit)
})
if (res.code === 1 && res.data && res.data.list) {
favoritesList.value = res.data.list
console.log('[Favorites] 数据:', res.data.list)
if (isLoadMore) {
// 加载更多:追加数据
currentList.value = [...currentList.value, ...res.data.list]
} else {
// 首次加载或刷新:替换数据
currentList.value = res.data.list
}
// 判断是否还有更多数据
hasMore.value = res.data.list.length >= params.limit
} else {
favoritesList.value = []
if (!isLoadMore) {
currentList.value = []
}
Taro.showToast({
title: res.msg || '获取收藏列表失败',
icon: 'none'
})
}
} catch (err) {
console.error('获取收藏列表失败:', err)
favoritesList.value = []
console.error('[Favorites] 获取收藏列表失败:', err)
if (!isLoadMore) {
currentList.value = []
}
Taro.showToast({
title: '网络错误,请稍后重试',
icon: 'none'
})
} finally {
loading.value = false
if (isLoadMore) {
loadingMore.value = false
} else {
loading.value = false
}
}
}
/**
* 删除收藏
*
* @param {Object} item - 收藏项
*/
const onDelete = async (item) => {
if (USE_MOCK_DATA) {
// Mock 模式:直接从列表中移除
Taro.showModal({
title: '提示',
content: '确定要删除该收藏吗?(Mock模式)',
success: (res) => {
if (res.confirm) {
const index = currentList.value.findIndex(i => i.meta_id === item.meta_id)
if (index !== -1) {
currentList.value.splice(index, 1)
}
Taro.showToast({ title: '已删除', icon: 'success' })
}
}
})
return
}
Taro.showModal({
title: '提示',
content: '确定要删除该收藏吗?',
......@@ -128,9 +214,9 @@ const onDelete = async (item) => {
if (delRes.code === 1) {
// 从列表中移除
const index = favoritesList.value.findIndex(i => i.meta_id === item.meta_id)
const index = currentList.value.findIndex(i => i.meta_id === item.meta_id)
if (index !== -1) {
favoritesList.value.splice(index, 1)
currentList.value.splice(index, 1)
}
Taro.showToast({ title: '已删除', icon: 'success' })
......@@ -141,7 +227,7 @@ const onDelete = async (item) => {
})
}
} catch (err) {
console.error('删除收藏失败:', err)
console.error('[Favorites] 删除收藏失败:', err)
Taro.showToast({
title: '网络错误,请稍后重试',
icon: 'none'
......@@ -152,43 +238,84 @@ const onDelete = async (item) => {
})
}
// 获取收藏列表
fetchFavoritesList()
</script>
/**
* 处理加载更多事件
*
* @param {number} page - 下一页页码
* @returns {Promise<void>}
*/
const handleLoadMore = async (page) => {
console.log('[Favorites] 加载更多,页码:', page)
<style lang="less">
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
// 更新页码
currentPage.value = page
to {
opacity: 1;
transform: translateY(0);
}
// 加载下一页数据
await fetchFavoritesList(
{ page: page, limit: pageSize },
true // 标记为加载更多
)
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
/**
* @description 刷新收藏列表
* @returns {Promise<void>}
*/
const refreshList = async () => {
console.log('[Favorites] 刷新列表')
to {
transform: rotate(360deg);
}
// 重置分页状态
currentPage.value = 0
hasMore.value = true
// 重新获取列表
await fetchFavoritesList({ page: 0, limit: pageSize })
}
/**
* 页面加载时获取列表(只执行一次)
*/
useLoad(() => {
console.log('[Favorites] 页面加载,获取列表')
// 重置分页状态
currentPage.value = 0
hasMore.value = true
// 获取收藏列表
fetchFavoritesList({ page: 0, limit: pageSize })
})
/**
* @description 监听收藏更新事件
*/
onMounted(() => {
console.log('[Favorites] 注册事件监听')
// 监听收藏更新事件(收藏/取消收藏)
const unsubscribe = eventBus.on(Events.FAVORITES_UPDATE, async (data) => {
console.log('[Favorites] 收到收藏更新事件:', data)
// 刷新列表
await refreshList()
})
// 组件卸载时取消监听
onUnmounted(() => {
unsubscribe()
console.log('[Favorites] 取消事件监听')
})
})
</script>
<style lang="less">
.favorite-item {
animation: slideIn 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
}
transition: all 0.3s ease;
.loading-spinner {
width: 64rpx;
height: 64rpx;
border: 4rpx solid #e5e7eb;
border-top-color: #2563EB;
border-radius: 50%;
animation: spin 1s linear infinite;
&:active {
transform: scale(0.98);
}
}
/* LoadMoreList 组件已内置样式,包括 Loading 和 Empty State */
</style>
......
<!--
* @Date: 2026-02-03
* @Description: 意见反馈列表页面
* @Date: 2026-02-08
* @Description: 意见反馈列表页面 - 使用 LoadMoreList 组件重构版本
-->
<template>
<view class="feedback-list">
<NavHeader title="意见反馈" />
<!-- Loading State -->
<view v-if="loading" class="flex justify-center items-center py-20">
<view class="loading-spinner"></view>
</view>
<!-- Content -->
<view v-else>
<!-- Feedback List -->
<view v-if="feedbackList.length > 0">
<view
v-for="item in feedbackList"
:key="item.id"
class="feedback-item"
>
<!-- LoadMoreList 组件 -->
<LoadMoreList
:list="currentList"
:page="currentPage"
:page-size="pageSize"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
key-field="id"
@load-more="handleLoadMore"
>
<!-- 列表项 -->
<template #item="{ item }">
<view class="feedback-item">
<!-- Header: Type & Status -->
<view class="feedback-header">
<!-- Category Tag -->
......@@ -73,25 +73,8 @@
</view>
</view>
</view>
</view>
<!-- Empty State -->
<view v-else class="empty-state">
<nut-empty description="暂无反馈记录" image="empty">
<view class="text-[#9ca3af] text-[24rpx] mt-[10rpx]">您还没有提交过任何意见反馈</view>
</nut-empty>
</view>
<!-- Load More -->
<view v-if="hasMore && feedbackList.length > 0" class="load-more" @click="loadMore">
{{ loadingMore ? '加载中...' : '加载更多' }}
</view>
<!-- No More Data -->
<view v-if="!hasMore && feedbackList.length > 0" class="no-more">
没有更多数据了
</view>
</view>
</template>
</LoadMoreList>
<!-- Fixed Button -->
<view class="fixed-button" @click="goToFeedback">
......@@ -101,32 +84,57 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, Ref, onMounted, onUnmounted } from 'vue'
import { useGo } from '@/hooks/useGo'
import LoadMoreList from '@/components/LoadMoreList'
import NavHeader from '@/components/NavHeader.vue'
import Taro, { useDidShow } from '@tarojs/taro'
import Taro, { useLoad } from '@tarojs/taro'
import { listAPI } from '@/api/feedback'
import { mockFeedbackListAPI } from '@/utils/mockData'
import eventBus, { Events } from '@/utils/eventBus'
const go = useGo()
// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API
const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
/** @type {import('vue').Ref<boolean>} 加载状态 */
const loading = ref(false)
const loadingMore = ref(false)
const go = useGo()
/** @type {import('vue').Ref<Array>} 反馈列表 */
const feedbackList = ref([])
/**
* 当前列表数据
* @type {Ref<Array<any>>}
*/
const currentList = ref([])
/** @type {import('vue').Ref<number>} 当前页码 */
/**
* 当前页码(从0开始)
* @type {Ref<number>}
*/
const currentPage = ref(0)
/** @type {import('vue').Ref<number>} 每页数量 */
const pageSize = ref(10)
/**
* 每页数量
* @type {number}
*/
const pageSize = 10
/** @type {import('vue').Ref<boolean>} 是否有更多数据 */
/**
* 是否还有更多数据
* @type {Ref<boolean>}
*/
const hasMore = ref(true)
/**
* 首次加载状态
* @type {Ref<boolean>}
*/
const loading = ref(false)
/**
* 加载更多状态
* @type {Ref<boolean>}
*/
const loadingMore = ref(false)
/**
* @description 获取反馈类型标签
* @param {string} category 类别值:1=功能建议, 3=问题反馈, 7=其他问题
* @returns {string} 类别标签
......@@ -168,9 +176,13 @@ const previewImage = (urls, current) => {
/**
* @description 加载反馈列表
* @param {boolean} isLoadMore 是否为加载更多
* @param {Object} params - 请求参数
* @param {number} params.page - 页码(从0开始)
* @param {number} params.limit - 每页数量
* @param {boolean} isLoadMore - 是否为加载更多
* @returns {Promise<void>}
*/
const loadFeedbackList = async (isLoadMore = false) => {
const loadFeedbackList = async (params = {}, isLoadMore = false) => {
if (loading.value || loadingMore.value) return
try {
......@@ -179,34 +191,44 @@ const loadFeedbackList = async (isLoadMore = false) => {
} else {
loading.value = true
currentPage.value = 0
feedbackList.value = []
currentList.value = []
}
const res = await listAPI({
page: currentPage.value,
limit: pageSize.value
})
console.log('[Feedback] 请求参数:', params)
console.log('[Feedback] 使用 Mock 数据:', USE_MOCK_DATA)
// 根据开关选择使用真实 API 或 Mock 数据
const res = USE_MOCK_DATA
? await mockFeedbackListAPI(params)
: await listAPI({
page: String(params.page),
limit: String(params.limit)
})
if (res.code === 1) {
const newList = res.data.list || []
console.log('[Feedback] 数据:', newList)
if (isLoadMore) {
feedbackList.value.push(...newList)
currentList.value.push(...newList)
} else {
feedbackList.value = newList
currentList.value = newList
}
// 判断是否还有更多数据
hasMore.value = newList.length >= pageSize.value
if (hasMore.value) {
currentPage.value++
}
hasMore.value = newList.length >= params.limit
} else {
if (!isLoadMore) {
currentList.value = []
}
Taro.showToast({ title: res.msg || '加载失败', icon: 'none' })
}
} catch (err) {
console.error('加载反馈列表失败:', err)
console.error('[Feedback] 加载反馈列表失败:', err)
if (!isLoadMore) {
currentList.value = []
}
Taro.showToast({ title: '网络异常,请重试', icon: 'none' })
} finally {
loading.value = false
......@@ -215,12 +237,21 @@ const loadFeedbackList = async (isLoadMore = false) => {
}
/**
* @description 加载更多
* @description 处理加载更多事件
* @param {number} page - 下一页页码
* @returns {Promise<void>}
*/
const loadMore = () => {
if (!hasMore.value || loadingMore.value) {
loadFeedbackList(true)
}
const handleLoadMore = async (page) => {
console.log('[Feedback] 加载更多,页码:', page)
// 更新页码
currentPage.value = page
// 加载下一页数据
await loadFeedbackList(
{ page: page, limit: pageSize },
true // 标记为加载更多
)
}
/**
......@@ -231,10 +262,53 @@ const goToFeedback = () => {
}
/**
* @description 页面显示时刷新列表(从提交页返回时也会触发)
* @description 刷新反馈列表
* @returns {Promise<void>}
*/
useDidShow(() => {
loadFeedbackList()
const refreshList = async () => {
console.log('[Feedback] 刷新列表')
// 重置分页状态
currentPage.value = 0
hasMore.value = true
// 重新获取列表
await loadFeedbackList({ page: 0, limit: pageSize })
}
/**
* @description 页面加载时获取列表(只执行一次)
*/
useLoad(() => {
console.log('[Feedback] 页面加载,获取列表')
// 重置分页状态
currentPage.value = 0
hasMore.value = true
// 获取反馈列表
loadFeedbackList({ page: 0, limit: pageSize })
})
/**
* @description 监听反馈提交成功事件
*/
onMounted(() => {
console.log('[Feedback] 注册事件监听')
// 监听反馈提交成功事件
const unsubscribe = eventBus.on(Events.FEEDBACK_SUBMIT, async (data) => {
console.log('[Feedback] 收到反馈提交事件:', data)
// 刷新列表
await refreshList()
})
// 组件卸载时取消监听
onUnmounted(() => {
unsubscribe()
console.log('[Feedback] 取消事件监听')
})
})
</script>
......@@ -247,7 +321,7 @@ useDidShow(() => {
.feedback-item {
background: white;
border-radius: 24rpx;
margin: 16rpx 32rpx;
// margin: 16rpx 32rpx;
padding: 32rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
......@@ -298,42 +372,6 @@ useDidShow(() => {
margin-top: 16rpx;
}
}
.empty-state {
text-align: center;
padding: 120rpx 32rpx;
.empty-icon {
font-size: 120rpx;
color: #d1d5db;
margin-bottom: 32rpx;
}
.empty-title {
font-size: 36rpx;
color: #6b7280;
margin-bottom: 16rpx;
}
.empty-desc {
font-size: 28rpx;
color: #9ca3af;
}
}
.load-more {
text-align: center;
padding: 32rpx;
color: #3b82f6;
font-size: 28rpx;
}
.no-more {
text-align: center;
padding: 32rpx;
color: #9ca3af;
font-size: 28rpx;
}
}
// 固定按钮样式
......@@ -359,17 +397,5 @@ useDidShow(() => {
}
}
.loading-spinner {
width: 40rpx;
height: 40rpx;
border: 4rpx solid #f3f3f3;
border-top: 4rpx solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* LoadMoreList 组件已内置样式,包括 Loading 和 Empty State */
</style>
......
......@@ -90,6 +90,7 @@ import NavHeader from '@/components/NavHeader.vue'
import Taro from '@tarojs/taro'
import { addAPI } from '@/api/feedback'
import BASE_URL from '@/utils/config'
import eventBus, { Events } from '@/utils/eventBus'
/**
* 反馈类型选项(对应后端 category 值)
......@@ -217,6 +218,11 @@ const onSubmit = async () => {
uploadedImages.value = []
selectedType.value = '1'
// 发送事件通知反馈列表页刷新
eventBus.emit(Events.FEEDBACK_SUBMIT, {
timestamp: Date.now()
})
// 延迟返回
setTimeout(() => {
Taro.navigateBack()
......
/**
* 事件总线工具
*
* @description 轻量级事件总线,用于跨页面组件通信
* @module utils/eventBus
* @author Claude Code
* @created 2026-02-08
*/
/**
* 事件总线实现
* @type {Object}
*/
const eventBus = {
/**
* 事件监听器存储
* @type {Object<string, Array<Function>>}
*/
events: {},
/**
* 监听事件
*
* @param {string} event - 事件名称
* @param {Function} callback - 回调函数
* @returns {Function} 取消监听函数
*
* @example
* const off = eventBus.on('feedback:submit', (data) => {
* console.log('收到反馈提交事件:', data)
* })
* // 取消监听
* off()
*/
on(event, callback) {
if (!this.events[event]) {
this.events[event] = []
}
this.events[event].push(callback)
// 返回取消监听函数
return () => this.off(event, callback)
},
/**
* 取消监听事件
*
* @param {string} event - 事件名称
* @param {Function} callback - 回调函数
*/
off(event, callback) {
if (!this.events[event]) return
if (callback) {
// 移除特定的回调
this.events[event] = this.events[event].filter(cb => cb !== callback)
} else {
// 移除所有回调
delete this.events[event]
}
},
/**
* 发送事件
*
* @param {string} event - 事件名称
* @param {*} data - 事件数据
*
* @example
* eventBus.emit('feedback:submit', { id: 123 })
*/
emit(event, data) {
if (!this.events[event]) return
this.events[event].forEach(callback => {
try {
callback(data)
} catch (err) {
console.error(`[EventBus] 事件处理错误 [${event}]:`, err)
}
})
},
/**
* 清空所有事件监听器
*/
clear() {
this.events = {}
}
}
/**
* 事件名称常量
* @enum {string}
*/
export const Events = {
/** 反馈提交成功 */
FEEDBACK_SUBMIT: 'feedback:submit',
/** 收藏列表更新 */
FAVORITES_UPDATE: 'favorites:update',
/** 用户信息更新 */
USER_UPDATE: 'user:update'
}
export default eventBus
......@@ -8,6 +8,8 @@
* - listAPI: 产品列表
* - searchAPI: 搜索(产品+资料)
* - myListAPI: 消息列表
* - favoriteListAPI: 收藏列表
* - feedbackListAPI: 意见反馈列表
*/
// ============================================================================
......@@ -593,6 +595,180 @@ export async function mockMessageListAPI(params) {
}
// ============================================================================
// 6. 收藏列表 Mock (favoriteListAPI)
// ============================================================================
const FAVORITE_MATERIALS = [
'财富管理基础知识指南',
'保险产品销售技巧',
'客户关系管理实战',
'家庭资产配置方案',
'税务筹划实用手册',
'退休规划完整教程',
'投资组合管理策略',
'风险控制与合规要求',
'高净值客户开发指南',
'理财产品营销话术',
'基金定投实战技巧',
'保单整理服务流程',
'传承规划案例分析',
'健康险产品对比分析',
'年金保险销售指南',
'重疾险核保知识',
'教育金规划方案',
'房贷规划实务操作',
'家族信托业务介绍',
'私募股权投资指南'
]
/**
* 生成收藏列表项
*/
function generateFavoriteItem(id) {
const fileType = FILE_TYPES[Math.floor(Math.random() * FILE_TYPES.length)]
const materialName = FAVORITE_MATERIALS[Math.floor(Math.random() * FAVORITE_MATERIALS.length)]
const now = new Date()
const createDate = new Date(now.getTime() - Math.random() * 90 * 24 * 60 * 60 * 1000)
return {
meta_id: id,
name: `${materialName}.${fileType.extension}`,
size: generateRandomSize(),
src: `https://placehold.co/100x100/e2e8f0/475569?text=${fileType.extension.toUpperCase()}`,
created_time: formatDate(createDate)
}
}
/**
* Mock: favoriteListAPI (收藏列表)
*/
export async function mockFavoriteListAPI(params) {
await mockDelay()
const { page = 0, limit = 20 } = params
const totalPages = 3
if (page >= totalPages) {
return { code: 1, msg: 'success', data: { list: [] } }
}
const list = []
const startIndex = page * limit
for (let i = 0; i < limit; i++) {
list.push(generateFavoriteItem(startIndex + i + 1))
}
console.log(`[Mock] favoriteListAPI - 第${page}页,共${list.length}条`)
return {
code: 1,
msg: 'success',
data: { list }
}
}
// ============================================================================
// 7. 意见反馈列表 Mock (feedbackListAPI)
// ============================================================================
const FEEDBACK_CATEGORIES = ['1', '3', '7'] // 1=功能建议, 3=问题反馈, 7=其他问题
const FEEDBACK_NOTES = [
'希望能够增加资料下载功能',
'产品详情页加载速度较慢',
'收藏功能使用不便,建议优化',
'搜索结果不够准确',
'希望能够添加学习进度跟踪',
'界面颜色有点太深了',
'建议添加夜间模式',
'资料分类不够清晰',
'希望能够离线查看资料',
'登录后总是会重新要求登录',
'视频播放有时会卡顿',
'希望能够支持分享到朋友圈',
'字体大小无法调整',
'建议增加资料收藏夹分类',
'消息通知太频繁了',
'希望能够批量管理收藏',
'产品对比功能不够直观',
'建议添加更多实用工具',
'客服回复速度有待提升'
]
const FEEDBACK_REPLIES = [
'感谢您的宝贵建议,我们会尽快优化!',
'您反馈的问题我们已经记录,技术团队正在处理中。',
'好的,我们会考虑您的建议。',
'非常感谢您的反馈,这对我们改进产品很有帮助。',
'您提到的问题我们已经收到,会在下个版本中优化。',
'感谢您的支持,我们会继续改进产品体验。'
]
/**
* 生成反馈列表项
*/
function generateFeedbackItem(id) {
const category = FEEDBACK_CATEGORIES[Math.floor(Math.random() * FEEDBACK_CATEGORIES.length)]
const note = FEEDBACK_NOTES[Math.floor(Math.random() * FEEDBACK_NOTES.length)]
const status = Math.random() > 0.6 ? 5 : 1 // 60%概率已处理
const hasReply = status === 5 && Math.random() > 0.3 // 已处理的有70%概率有回复
const hasImages = Math.random() > 0.7 // 30%概率有图片
const now = new Date()
const createDate = new Date(now.getTime() - Math.random() * 60 * 24 * 60 * 60 * 1000)
const replyDate = new Date(createDate.getTime() + Math.random() * 7 * 24 * 60 * 60 * 1000)
// 生成随机图片
const images = []
if (hasImages) {
const imageCount = Math.floor(Math.random() * 3) + 1
for (let i = 0; i < imageCount; i++) {
images.push(`https://placehold.co/200x200/f3f4f6/9ca3af?text=截图${i + 1}`)
}
}
return {
id: id,
category: category,
status: status,
note: note,
images: images,
contact: Math.random() > 0.5 ? '138****8888' : '',
reply: hasReply ? FEEDBACK_REPLIES[Math.floor(Math.random() * FEEDBACK_REPLIES.length)] : '',
reply_time: hasReply ? formatDate(replyDate) : ''
}
}
/**
* Mock: feedbackListAPI (意见反馈列表)
*/
export async function mockFeedbackListAPI(params) {
await mockDelay()
const { page = 0, limit = 10 } = params
const totalPages = 5
if (page >= totalPages) {
return { code: 1, msg: 'success', data: { list: [] } }
}
const list = []
const startIndex = page * limit
for (let i = 0; i < limit; i++) {
list.push(generateFeedbackItem(startIndex + i + 1))
}
console.log(`[Mock] feedbackListAPI - 第${page}页,共${list.length}条`)
return {
code: 1,
msg: 'success',
data: { list }
}
}
// ============================================================================
// 导出统一 Mock API 调用器
// ============================================================================
......@@ -614,6 +790,10 @@ export async function mockAPI(apiName, params) {
return await mockSearchAPI(params)
case 'myListAPI':
return await mockMessageListAPI(params)
case 'favoriteListAPI':
return await mockFavoriteListAPI(params)
case 'feedbackListAPI':
return await mockFeedbackListAPI(params)
default:
console.warn(`[Mock] 未知的 API: ${apiName}`)
return { code: 0, msg: 'Unknown API', data: null }
......