lessons-learned.md 65.9 KB

Taro + Vue 3 项目开发经验教训总结

本文档总结了在开发 Manulife WeApp 项目过程中遇到的问题、解决方案和最佳实践,为后续 Taro 项目提供参考。

目录


组件抽取与复用

✅ 最佳实践

1. "第 3 次出现原则"(Rule of Three)

原则:当相同代码模式出现 3 次时,必须抽取为 Composable 或组件。

案例 1: useSectionList Composable

  • 问题: onboarding, family-office, signing 三个页面都使用相同的分组列表模式
  • 解决: 创建 src/composables/useSectionList.js
  • 收益: 减少约 60 行重复代码,提升可维护性
// src/composables/useSectionList.js
/**
 * 分组列表页面 Composable
 *
 * @description 统一管理分组列表数据和点击事件
 * @param {Object} sectionData - 分组数据
 * @param {Function} handleClick - 点击回调函数
 * @returns {Object} { sections, onItemClick }
 */
export function useSectionList(sectionData, handleClick) {
  const sections = ref(sectionData)

  const onItemClick = (item) => {
    handleClick(item)
  }

  return { sections, onItemClick }
}

案例 2: useFileOperation Composable

  • 问题: 收藏页和产品详情页都有约 200 行重复的文件操作代码
  • 解决: 创建 src/composables/useFileOperation.js
  • 收益: 减少 ~290 行重复代码,统一修改点

案例 3: useListItemClick Composable

  • 问题: 多个列表页面的点击逻辑分散且重复
  • 解决: 创建 src/composables/useListItemClick.js
  • 收益: 支持上下文感知的行为路由,扩展性强

2. 组件设计原则

案例: SectionCard 默认渐变色优化

  • 问题: 三个页面都重复设置默认渐变色
  • 解决: 在 SectionCard 组件内部使用 computed 属性内置默认值
  • 收益: 修改默认值只需在一处,简化页面代码
<!-- src/components/SectionCard.vue -->
<script setup>
const props = defineProps({
  bgGradient: {
    type: String,
    default: ''
  }
})

// ✅ GOOD - 内置默认渐变色
const computedBgGradient = computed(() => {
  return props.bgGradient || 'linear-gradient(90deg, #EFF6FF 0%, #DBEAFE 100%)'
})
</script>

3. 何时抽取组件

触发条件:

  • ✅ 代码重复 ≥ 2 次 → 警惕
  • ✅ 代码重复 ≥ 3 次 → 必须抽取
  • ✅ v-for 模板超过 5 行 → 提取列表项组件
  • ✅ 组件模板超过 150 行 → 拆分组件
  • ✅ 函数超过 50 行 → 拆分函数

❌ 反模式

1. 过早抽取

// ❌ BAD - 代码只出现 1 次就抽取
function useSpecificFeature() {
  // 只在一个地方使用...
}

// ✅ GOOD - 等待第 3 次出现再抽取
// 第 1、2 次直接实现,第 3 次时抽取

2. 过度抽象

// ❌ BAD - 过度通用的抽象,难以理解
function useGenericList({ config, handlers, options }) {
  // 太多参数,过于复杂
}

// ✅ GOOD - 专注特定场景
function useSectionList(sectionData, handleClick) {
  // 简单直接,易于理解
}

NutUI 组件使用陷阱

❌ 坑 1: NutUI textarea 样式无法覆盖

问题描述:

<!-- ❌ 无法生效 -->
<nut-textarea
  v-model="content"
  class="custom-textarea"
/>
// ❌ 深度选择器无效
.custom-textarea :deep(.nut-textarea__textarea) {
  padding: 24rpx;
}

原因: NutUI textarea 组件使用 Shadow DOM 或内部样式隔离

解决方案: 使用原生小程序 <textarea> 组件

<!-- ✅ 完全控制样式 -->
<textarea
  v-model="content"
  class="custom-textarea"
  maxlength="200"
  @input="handleInput"
/>
<view class="char-count">{{ content.length }}/200</view>

❌ 坑 2: IconFont 动态切换不响应

问题描述:

<!-- ❌ 图标不更新 -->
<IconFont :name="iconName" />
const iconName = ref('heart')
setTimeout(() => {
  iconName.value = 'heart-fill' // 视图不更新!
}, 1000)

原因: NutUI 的 IconFont 组件在某些环境下未正确响应 props 变化

解决方案: 添加 :key 强制重新渲染

<!-- ✅ 添加 key 属性 -->
<IconFont :name="iconName" :key="iconName" />

❌ 坑 3: 嵌套 nut-popup 导致真机层级冲突

问题描述:

<!-- ❌ 真机测试时,内层弹窗被外层弹窗的底部按钮遮挡 -->
<PlanPopup title="申请计划书">
  <!-- 表单内容 -->

  <!-- 嵌套的弹窗 -->
  <nut-popup position="bottom" v-model:visible="showIndustryPicker">
    <nut-picker :columns="industryColumns" />
  </nut-popup>
</PlanPopup>

场景: SchemeA 组件使用 PlanPopup 容器(外层 nut-popup),内部再嵌套一个行业选择器(内层 nut-popup)

原因:

  1. 小程序渲染机制: 微信小程序的双线程渲染导致嵌套弹窗的层级处理异常
  2. 渲染上下文: 外层 nut-popup 创建了独立的渲染上下文,内层即使设置更高 z-index 也可能被遮挡
  3. DOM 顺序: 外层弹窗的底部按钮在 DOM 中位于内层弹窗之后,真机渲染时可能覆盖

解决方案: 将内层弹窗提升到外层弹窗外部

<!-- ✅ 正确:弹窗并列,不嵌套 -->
<PlanPopup title="申请计划书">
  <!-- 表单内容 -->
</PlanPopup>

<!-- 内层弹窗提升到外层,设置更高的 z-index -->
<nut-popup
  position="bottom"
  v-model:visible="showIndustryPicker"
  :z-index="9999"
  :overlay="true"
>
  <nut-picker :columns="industryColumns" />
</nut-popup>

关键点:

  • ✅ 将内层 nut-popup 移到外层组件外部
  • ✅ 设置 :z-index="9999" 确保显示在最上层
  • ✅ 保持 DOM 顺序:外层弹窗 → 内层弹窗(后渲染的在上层)

适用场景:

  • 任何需要在 nut-popup 内部再使用 nut-popup 的情况
  • 特别是选择器(Picker)、对话框(Dialog)等弹窗组件

❌ 坑 4: scroll-view 必须使用 : 绑定语法(Taro/Vue 陷阱) ⭐ 2026-02-08 新增

问题描述:

<!-- ❌ 滚动不生效! -->
<scroll-view scroll-y>
  内容...
</scroll-view>

<!-- ❌ 滚动也不生效! -->
<scroll-view scroll-y="true">
  内容...
</scroll-view>

错误表现:

  • scroll-view 组件显示正常,但无法滚动
  • 没有报错信息,静默失败
  • 调试时发现滚动事件不触发

原因: 在 Taro/Vue 中,布尔属性必须使用 : 绑定语法才能正确传递布尔值

  • scroll-yscroll-y="true" → 传递字符串 "true",scroll-view 无法识别为布尔值
  • :scroll-y="true" → 传递布尔值 true,scroll-view 正确识别为启用滚动

解决方案: 始终使用 : 绑定语法

<!-- ✅ 正确:使用 : 绑定 -->
<scroll-view :scroll-y="true">
  内容...
</scroll-view>

同样适用于其他 scroll-view 布尔属性:

<!-- ✅ 横向滚动 -->
<scroll-view :scroll-x="true">

<!-- ✅ 返回顶部 -->
<scroll-view :scroll-y="true" :enable-back-to-top="true">

关键点:

  • ⚠️ 永远不要省略 : 符号,即使是布尔值
  • ⚠️ 永远不要使用 scroll-y="true" 字符串形式
  • 始终使用 :scroll-y="true" 绑定形式
  • ✅ 这也是 Taro 框架的一个重要陷阱,容易疏忽

✅ NutUI 最佳实践

  1. 优先使用原生组件: 当 NutUI 组件样式限制时
  2. 添加 key 属性: 动态切换图标时
  3. 避免嵌套弹窗: 始终将内层弹窗提升到外层外部
  4. 深度样式覆盖: 尝试 :deep() 选择器
  5. 查阅文档: 确认 NutUI 版本和已知问题

生命周期钩子使用陷阱

❌ 坑: 误用 useShow 而非 useDidShow(重复 2 次)

问题描述:

// ❌ 错误:使用了 Vue 3 的 useShow(在 Taro 中不可用)
import { useShow } from '@tarojs/taro'

useShow(() => {
  fetchUserProfile()
})

错误表现:

  • IDE 提示 "useShow 未使用"(因为 Taro 中没有这个钩子)
  • 页面返回时不会触发刷新

正确做法:

// ✅ 正确:使用 Taro 的 useDidShow
import Taro, { useLoad, useDidShow } from '@tarojs/taro'

useLoad(() => {
  // 页面首次加载时触发
  fetchUserProfile()
})

useDidShow(() => {
  // 每次页面显示时触发(包括从其他页面返回)
  fetchUserProfile()
})

Taro 生命周期钩子对照表:

生命周期 Taro 钩子 用途 备注
页面加载 useLoad 首次进入页面时触发 只触发一次
页面显示 useDidShow 每次页面显示时触发 包括从其他页面返回
页面渲染完成 useReady 首次渲染完成后触发 可操作 DOM
页面隐藏 useDidHide 页面隐藏时触发 清理定时器等
页面卸载 useUnload 页面卸载时触发 清理资源

⚠️ 重要检查清单(写代码前必须执行):

  1. 搜索现有用法: 在项目中搜索关键字,确认其他页面是如何使用的

    # 在终端执行
    grep -r "useDidShow\|useShow" src/pages/
    
  2. 参考现有页面: 查看项目中已有的页面实现

    # 例如查看 feedback-list 页面
    cat src/pages/feedback-list/index.vue | grep "import.*@tarojs/taro"
    
  3. 统一命名规范: 本项目统一使用 Taro 的钩子

    • useDidShow(Taro 官方)
    • useShow(Vue 3 Composition API,在小程序中不适用)

历史记录:

  • 第 1 次错误:在 src/pages/mine/index.vue 中使用 useShow
  • 第 2 次错误:在同一位置再次使用 useShow(未检查项目现有用法)
  • 教训: ⚠️ 写代码前必须先搜索项目中是否已有类似实现

最佳实践:

// ✅ 推荐的导入方式(一行导入所有需要的钩子)
import Taro, { useLoad, useDidShow, useReady } from '@tarojs/taro'

// ✅ 页面加载时获取数据
useLoad((options) => {
  console.log('页面参数:', options)
  fetchData()
})

// ✅ 页面显示时刷新数据(从其他页面返回时)
useDidShow(() => {
  refreshData()
})

静态资源加载问题

❌ 坑: SVG 图标加载失败(500 错误)

问题描述:

// ❌ 字符串路径导致加载失败
export const getDocumentIcon = (type) => {
  const icons = {
    pdf: '/assets/images/icon/doc/doc.svg',  // ❌ 500 错误
    doc: '/assets/images/icon/doc/doc.svg',
    // ...
  }
  return icons[type]
}

原因: Taro 构建工具无法正确处理字符串路径的静态资源引用

解决方案: 使用 ES6 import 导入

// ✅ 使用 import 导入
import pdfIcon from '@/assets/images/icon/doc/pdf.svg'
import docIcon from '@/assets/images/icon/doc/doc.svg'
// ...

export const getDocumentIcon = (type) => {
  const icons = {
    pdf: pdfIcon,
    doc: docIcon,
    // ...
  }
  return icons[type]
}

✅ 静态资源处理规则

资源类型 引用方式 示例
SVG 图标 import 导入 import icon from '@/assets/icon.svg'
图片 import 导入或字符串路径 两种方式都支持
字体 配置在 config/index.js 参考项目配置
远程资源 字符串 URL https://cdn.example.com/image.png

样式处理策略

✅ TailwindCSS vs Less 使用指南

使用 TailwindCSS(80% 场景)

适用场景:

  • 布局(flex、grid、absolute)
  • 间距(padding、margin、gap)
  • 排版(font-size、font-weight、text-align)
  • 颜色(bg-、text-、border-*)
  • 响应式设计(sm:、md:、lg:)

示例:

<div class="
  flex items-center justify-between  <!-- 布局 -->
  p-4 mb-2                           <!-- 间距 -->
  bg-white rounded-lg shadow-md       <!-- 颜色、圆角、阴影 -->
">
  <h1 class="text-xl font-bold">标题</h1>
</div>

使用 Less(20% 场景)

适用场景:

  • 组件特定样式(需要 scoped
  • 深度选择器(:deep()
  • 动画和过渡
  • 伪元素(::before::after
  • 复杂的计算表达式

示例:

<style lang="less" scoped>
.custom-card {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  border-radius: 16px;

  // 深度选择器修改第三方组件
  :deep(.nut-button) {
    background-color: rgba(255, 255, 255, 0.2);
  }

  // 伪元素
  &::before {
    content: '';
    position: absolute;
    // ...
  }

  // 动画
  @keyframes slide-in {
    from { transform: translateX(-100%); }
    to { transform: translateX(0); }
  }
}
</style>

❌ 坑: LESS 修饰符类与基础类样式堆叠 ⭐ 2026-02-08 新增

问题描述:

在 LoadMoreList 组件中,基础类 .load-more-content 设置了 padding: 32rpx(所有边),修饰符类 .load-more-content.scrollable 又添加了 padding-bottom: calc(160rpx + env(safe-area-inset-bottom)),导致底部 padding 堆叠,约为 32rpx + 160rpx + safe-area-inset-bottom ≈ 192rpx + safe-area,底部空白过高。

错误代码:

.load-more-content {
  // 基础 padding: 所有 4 边都是 32rpx
  padding: 32rpx;

  // 可滚动状态
  &.scrollable {
    // ❌ 只添加 padding-bottom,会与基础 padding 堆叠
    padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
  }
}

错误表现:

  • 底部空白过高,约为正常的 2 倍
  • 用户需要滚动很长距离才能看到最后的加载提示
  • 在 iPhone X 等有安全区域的设备上更加明显

原因: LESS 嵌套选择器中,修饰符类的属性会与基础类堆叠(叠加)而不是覆盖

  • 基础类: padding: 32rpx (上下左右都是 32rpx)
  • 修饰符类: padding-bottom: calc(160rpx + env(safe-area-inset-bottom))
  • 结果: padding-top: 32rpx, padding-left: 32rpx, padding-right: 32rpx, padding-bottom: 32rpx + 160rpx + safe-area

解决方案: 在修饰符类中覆盖整个 padding 属性,而不是只添加子属性

.load-more-content {
  // 基础 padding: 所有 4 边都是 32rpx
  padding: 32rpx;

  // 可滚动状态
  &.scrollable {
    // ✅ 覆盖整个 padding 属性,防止堆叠
    // 顶部 32rpx, 左右 32rpx, 底部 160rpx + safe-area
    padding: 32rpx 32rpx calc(160rpx + env(safe-area-inset-bottom));
  }
}

padding 简写格式说明:

padding: [top] [right] [bottom] [left];
  • 32rpx 32rpx calc(160rpx + env(safe-area-inset-bottom)) =
    • 顶部: 32rpx
    • 右边: 32rpx
    • 底部: calc(160rpx + env(safe-area-inset-bottom))
    • 左边: 32rpx (与右边相同)

关键点:

  • ✅ 使用 padding 简写属性覆盖整个 padding,而不是只写 padding-bottom
  • ✅ 明确指定所有 4 个边的值,避免隐式继承
  • ⚠️ 规则: 需要覆盖基础类的 padding/margin/border 等属性时,重写整个属性而不是只写子属性

适用场景:

  • ✅ 任何 LESS/SCSS 嵌套选择器中
  • ✅ 修饰符类需要覆盖基础类的 padding、margin、border 等属性时
  • ✅ 需要完全替换而不是堆叠样式的场景

最佳实践:

// ✅ GOOD - 明确覆盖
.component {
  padding: 16px;

  &.modifier {
    // 覆盖整个属性,防止堆叠
    padding: 8px 16px;
  }
}

// ❌ BAD - 可能堆叠
.component {
  padding: 16px;

  &.modifier {
    // 只添加一个方向,其他方向会堆叠
    padding-bottom: 8px;
  }
}

相关文件:

  • src/components/LoadMoreList/index.vue:423 (已修复)

历史记录:

  • 第 1 次: 在 material-list 页面发现底部 padding 过高
  • 教训: ⚠️ LESS 嵌套选择器中,修饰符类的属性会与基础类堆叠,需要覆盖整个属性而不是只写子属性

❌ 坑 5: LoadMoreList 页面的双重滚动问题 ⭐ 2026-02-08 新增

问题描述:

使用 LoadMoreList 组件的页面出现双重滚动问题:

  • 整个页面可以滚动
  • LoadMoreList 内部的 scroll-view 也可以滚动
  • 用户滚动时体验混乱

错误代码:

<!-- ❌ 页面容器没有限制高度和溢出 -->
<template>
  <view class="bg-[#F9FAFB]">
    <LoadMoreList
      :list="currentList"
      :page="currentPage"
      @load-more="handleLoadMore"
    >
      <!-- 列表项 -->
    </LoadMoreList>
  </view>
</template>
// ❌ 缺少高度和溢出控制
.feedback-list {
  min-height: 100vh;  // ❌ 使用 min-height 而非 height
  // ❌ 缺少 overflow: hidden
}

错误表现:

  • 页面级和组件级都可以滚动
  • 用户滚动时不确定是哪个在滚动
  • 固定元素(如导航栏、筛选栏)可能随页面滚动消失

原因分析:

  1. 页面容器未限制高度: 使用 min-height: 100vh 或没有设置高度
  2. 未禁用页面级溢出: 缺少 overflow: hidden
  3. 滚动上下文混乱: 浏览器无法确定应该滚动哪个容器

解决方案: 在页面容器添加 height: 100vhoverflow: hidden

<!-- ✅ 正确:页面容器固定高度并禁用溢出 -->
<template>
  <!-- ✅ TailwindCSS 方式 -->
  <view class="h-screen overflow-hidden bg-[#F9FAFB]">
    <LoadMoreList
      :list="currentList"
      :page="currentPage"
      @load-more="handleLoadMore"
    >
      <!-- 列表项 -->
    </LoadMoreList>
  </view>
</template>
// ✅ Less 方式
.feedback-list {
  height: 100vh;       // ✅ 固定高度
  overflow: hidden;    // ✅ 禁用页面级滚动
}

关键点:

  • ✅ 页面容器必须设置 height: 100vh(固定高度)
  • ✅ 页面容器必须设置 overflow: hidden(禁用页面级滚动)
  • ✅ 让 LoadMoreList 内部的 scroll-view 处理所有滚动
  • ✅ 如果页面有固定顶部(如导航栏、搜索栏),放在 LoadMoreList 的 #header 插槽中

修复的页面(共 4 个):

  1. src/pages/feedback-list/index.vue
  2. src/pages/favorites/index.vue
  3. src/pages/material-list/index.vue
  4. src/pages/product-center/index.vue

已正确的页面(共 3 个):

  1. src/pages/search/index.vue(已正确配置)
  2. src/pages/message/index.vue(LoadMoreList 是根元素)
  3. src/pages/week-hot-material/index.vue(LoadMoreList 是根元素)

最佳实践:

<!-- ✅ 推荐的 LoadMoreList 页面结构 -->
<template>
  <!-- 外层容器:固定高度,禁用溢出 -->
  <view class="h-screen overflow-hidden bg-[#F9FAFB]">
    <LoadMoreList
      :list="currentList"
      :page="currentPage"
      :page-size="pageSize"
      :has-more="hasMore"
      :loading="loading"
      :loading-more="loadingMore"
      key-field="id"
      @load-more="handleLoadMore"
    >
      <!-- 固定头部(可选) -->
      <template #header>
        <view class="sticky top-0 bg-white z-10">
          <NavHeader title="页面标题" />
          <SearchBar v-model="searchValue" />
        </view>
      </template>

      <!-- 列表项 -->
      <template #item="{ item }">
        <ProductCard :product="item" />
      </template>
    </LoadMoreList>
  </view>
</template>

检查清单: 使用 LoadMoreList 组件时,确认:

  • 页面容器设置了 height: 100vh(或 h-screen
  • 页面容器设置了 overflow: hidden(或 overflow-hidden
  • 固定元素(导航栏、搜索栏)放在 #header 插槽中
  • 只有 LoadMoreList 内部的 scroll-view 可以滚动

适用场景:

  • ✅ 所有使用 LoadMoreList 组件的分页列表页面
  • ✅ 需要固定顶部元素的滚动页面
  • ✅ 需要防止双重滚动的任何场景

⚠️ 双设计宽度系统

项目配置:

// config/index.js
const designWidth = {
  750: [375, 375],  // NutUI 组件: 375px 基准
  750: [750, 750]   // 其他所有页面: 750px 基准
}

使用规则:

  • NutUI 组件 → 参考 375px 设计稿
  • 自定义页面 → 参考 750px 设计稿

示例:

<!-- NutUI 组件: 使用 375px 设计稿 -->
<nut-button :custom-style="buttonStyle">按钮</nut-button>

<script>
const buttonStyle = {
  fontSize: '14px',  // 375px 设计稿: 14px
  padding: '8px 16px'
}
</script>

<!-- 自定义元素: 使用 750px 设计稿 -->
<view class="custom-box">
  内容
</view>

<style lang="less" scoped>
.custom-box {
  width: 750px;  // 750px 设计稿: 750px
  height: 200px;
}
</style>

性能优化

✅ 1. 响应式数据优化

问题: Vue 3 对包含组件对象的响应式数据进行深度代理,导致性能问题

解决方案: 使用 shallowRef + markRaw

import { shallowRef, markRaw } from 'vue'

// ❌ BAD - 深度响应式
const menuItems = ref([
  {
    icon: markRaw(IconFont),  // 组件对象
    name: 'heart',
    title: '我的收藏'
  }
  // ...
])

// ✅ GOOD - 浅层响应式
const menuItems = shallowRef([
  {
    icon: markRaw(IconFont),  // 标记为原始对象
    name: 'heart',
    title: '我的收藏'
  }
  // ...
])

收益:

  • 消除 "Component that was made a reactive object" 警告
  • 避免不必要的深度代理
  • 提升页面初始化和渲染性能

✅ 2. 页面滚动优化

问题: 整页滚动,顶部筛选区域会随列表滚动消失

解决方案: 固定顶部 + 列表独立滚动

<template>
  <view class="page-container">
    <!-- 固定顶部筛选 -->
    <view class="fixed-header">
      <search-bar />
      <filter-tabs />
    </view>

    <!-- 独立滚动列表 -->
    <scroll-view
      scroll-y
      class="scrollable-list"
      :style="{ height: listHeight }"
    >
      <view v-for="item in list" :key="item.id">
        {{ item.title }}
      </view>
    </scroll-view>
  </view>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const listHeight = ref('calc(100vh - 200rpx)')  // 减去固定区域高度

onMounted(() => {
  // 动态计算列表高度
  const query = Taro.createSelectorQuery()
  query.select('.fixed-header').boundingClientRect()
  query.exec((res) => {
    const headerHeight = res[0].height
    listHeight.value = `calc(100vh - ${headerHeight}px)`
  })
})
</script>

<style lang="less" scoped>
.page-container {
  height: 100vh;
  display: flex;
  flex-direction: column;
}

.fixed-header {
  position: sticky;
  top: 0;
  z-index: 10;
  background-color: #fff;
}

.scrollable-list {
  flex: 1;
  overflow-y: auto;
}
</style>

收益:

  • 顶部筛选区域始终可见
  • 列表滚动更流畅
  • 用户体验显著提升

✅ 3. 图片优化

策略:

  1. 使用 CDN 参数优化图片
  2. 图片懒加载
  3. 响应式图片
// src/utils/image.js
/**
 * 优化 CDN 图片 URL
 *
 * @param {string} url - 原始 URL
 * @param {Object} options - 优化参数
 * @param {number} options.width - 宽度
 * @param {number} options.quality - 质量(1-100)
 */
export function optimizeImageUrl(url, options = {}) {
  if (!url || !url.includes('cdn.ipadbiz.cn')) {
    return url
  }

  const { width = 750, quality = 70 } = options
  const params = new URLSearchParams({
    'imageMogr2/thumbnail': `${width}x`,
    strip: '',
    quality: quality.toString()
  })

  return `${url}?${params.toString()}`
}

代码质量

✅ 1. JSDoc 注释规范

强制要求:

  • ✅ 所有函数必须有 JSDoc 注释
  • ✅ 包含 @description 说明功能
  • ✅ 所有参数都有 @param 说明
  • ✅ 返回值有 @returns 说明
  • ✅ 复杂逻辑需要详细注释

示例:

/**
 * 获取文档图标
 *
 * @description 根据文件类型返回对应的 SVG 图标路径
 * @param {string} type - 文件类型(pdf、doc、xls、ppt、txt、img、video、zip、unknown)
 * @returns {string} SVG 图标路径
 *
 * @example
 * const icon = getDocumentIcon('pdf')
 * // 返回: '/assets/images/icon/doc/pdf.svg'
 */
export function getDocumentIcon(type) {
  const iconMap = {
    pdf: pdfIcon,
    doc: docIcon,
    // ...
  }
  return iconMap[type] || unknownIcon
}

✅ 2. 命名规范

文件命名:

  • 组件: PascalCase.vue(多单词)
  • 页面: index.vue + index.config.js
  • Composables: useXxx.js
  • 工具函数: xxxXxx.js(camelCase)

变量命名:

  • 常量: UPPER_SNAKE_CASE
  • 响应式变量: camelCase
  • 组件引用: PascalCase

示例:

// ✅ GOOD
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024  // 常量
const userList = ref([])                  // 响应式变量
import UserCard from '@/components/UserCard.vue'  // 组件

// ❌ BAD
const max_upload_size = 10 * 1024 * 1024
const UserList = ref([])
import userCard from '@/components/UserCard.vue'

✅ 3. 错误处理

原则:

  • 所有 async 函数必须有 try-catch
  • 用户友好的错误提示
  • 日志记录便于调试

示例:

/**
 * 获取产品列表
 *
 * @description 从 API 获取产品列表数据
 * @param {Object} params - 查询参数
 * @returns {Promise<Object>} 产品列表数据
 */
export async function fetchProductList(params) {
  try {
    const { data } = await getProductListAPI(params)

    // 检查 API 响应码
    if (data.code !== 1) {
      Taro.showToast({
        title: data.msg || '获取失败',
        icon: 'none'
      })
      return null
    }

    return data.data
  } catch (err) {
    console.error('获取产品列表失败:', err)
    Taro.showToast({
      title: '网络异常,请重试',
      icon: 'none'
    })
    return null
  }
}

❌ 坑: API 调用使用了 fn() 包装(重复 2 次)

问题描述:

// ❌ 错误:使用了 fn() 包装 API 调用
import { fileListAPI } from '@/api/file'
import { fn } from '@/api/fn'

const res = await fn(fileListAPI(params))

if (res.code === 1) {
  data.value = res.data
} else {
  throw new Error(res.msg || '请求失败')
}

错误表现:

  • 重复检查 res.code === 1fn() 内部检查一次,页面又检查一次)
  • 与项目中其他页面的写法不一致(product-detail、index、message 等页面直接调用 API)
  • 可能导致意外的日志输出(即使成功也打印"接口请求失败")

原因分析:

  1. fn() 函数的作用

    • 内部已经处理了失败情况并显示了 toast
    • 返回的是 { code, data, msg } 格式
    • 适用于需要统一错误处理的场景
  2. 为什么不应该用 fn()

    • 项目中大部分页面(product-detail、index、message 等)都是直接调用 API
    • 直接调用代码更清晰,更容易理解
    • 避免了重复检查和不一致的写法
  3. 历史记录

    • 第 1 次错误:在 src/pages/category-list/index.vue 中使用 fn()
    • 第 2 次错误:在 src/pages/material-list/index.vue 中使用 fn()
    • 教训: ⚠️ 写代码前必须先搜索项目中是否已有类似实现,保持写法一致

正确做法:

// ✅ 正确:直接调用 API,自己处理错误
import { fileListAPI } from '@/api/file'
import Taro from '@tarojs/taro'

const res = await fileListAPI(params)

if (res.code === 1 && res.data) {
  // 成功处理
  data.value = res.data
} else {
  // 失败处理:手动显示 toast
  Taro.showToast({
    title: res.msg || '请求失败',
    icon: 'none',
    duration: 2000
  })
}

对比其他页面的写法(product-detail、index、message 等):

// src/pages/product-detail/index.vue:143
const res = await detailAPI({ i: id })

if (res.code === 1 && res.data) {
  productDetail.value = res.data
} else {
  Taro.showToast({
    title: res.msg || '获取产品详情失败',
    icon: 'none',
    duration: 2000
  })
}

// src/pages/index/index.vue:245
const res = await listAPI({ recommend: 'hot' })

if (res.code === 1 && res.data && res.data.list) {
  hotProducts.value = res.data.list
}

⚠️ 重要检查清单(写 API 调用代码前必须执行):

  1. 搜索现有用法:在项目中搜索相同 API 的调用方式

    # 在终端执行
    grep -r "fileListAPI" src/pages/
    
  2. 参考现有页面:查看项目中已有的页面实现

    # 例如查看 product-detail 页面
    cat src/pages/product-detail/index.vue | grep -A 10 "await.*API("
    
  3. 统一命名规范:本项目统一使用直接调用 API 的方式

    • const res = await fileListAPI(params)(直接调用)
    • const res = await fn(fileListAPI(params))(使用 fn() 包装)

最佳实践:

// ✅ 推荐的 API 调用方式
import { fileListAPI } from '@/api/file'
import Taro from '@tarojs/taro'

const fetchList = async (params) => {
  try {
    const res = await fileListAPI(params)

    if (res.code === 1 && res.data) {
      // 成功处理
      return res.data
    } else {
      // 失败处理
      Taro.showToast({
        title: res.msg || '请求失败',
        icon: 'none',
        duration: 2000
      })
      return null
    }
  } catch (err) {
    console.error('请求失败:', err)
    Taro.showToast({
      title: '网络异常,请重试',
      icon: 'none',
      duration: 2000
    })
    return null
  }
}

Vue 3 响应式数据和表单状态管理

❌ 坑:v-model 双向绑定导致表单数据丢失或重置失败

场景:用户填写表单后关闭弹窗(未提交),再次打开时数据依然存在,或者在输入过程中数据意外丢失。

问题根因

  1. v-model 每次更新都创建新对象 ```javascript // 父组件 const formData = ref({})

// v-model 更新时(子组件 emit) formData.value = {age: 30} // ← 每次都是新对象!


2. **reactive() 只在初始化时读取 props**
   ```javascript
   // 子组件
   const form = reactive(props.modelValue || {})

   // 问题:
   // - 只在组件创建时读取一次 props.modelValue
   // - 之后 props.modelValue 变化,form 不会自动更新
  1. watch 监听策略错误 ```javascript // ❌ 错误:每次 props 变化都清空并复制 watch(() => props.modelValue, (newVal) => { Object.keys(form).forEach(key => delete form[key]) Object.assign(form, newVal) })

// 问题: // - v-model 更新创建新对象,触发 watch // - 清空操作导致用户输入丢失 // - 可能触发无限循环:form → emit → props → watch → form


**✅ 解决方案**:区分"重置"和"正常更新"

```javascript
// ✅ 正确:只在重置时清空,正常更新时只合并新字段
let previousModelValue = null

watch(
  () => props.modelValue,
  (newVal) => {
    if (!newVal) {
      // null 或 undefined:清空
      Object.keys(form).forEach(key => delete form[key])
      previousModelValue = null
      return
    }

    // 判断是否是重置(从有数据变为空对象)
    const isReset = previousModelValue &&
                    Object.keys(previousModelValue).length > 0 &&
                    Object.keys(newVal).length === 0

    if (isReset) {
      // 父组件重置了:清空表单
      Object.keys(form).forEach(key => delete form[key])
      previousModelValue = newVal
    } else {
      // 正常更新:只合并新字段,不删除已有字段
      // 这很重要!因为用户可能刚填写了某些字段,其他字段还没更新
      Object.keys(newVal).forEach(key => {
        form[key] = newVal[key]
      })
      previousModelValue = newVal
    }
  },
  { immediate: true }
)

关键要点

  1. ✅ 不要用引用判断(newVal !== previousModelValue),因为 v-model 每次都创建新对象
  2. ✅ 用内容判断:从有数据 → 空对象 = 重置
  3. ✅ 正常更新时只合并新字段,保留已有字段
  4. ✅ 不要用 { deep: true } 监听 props(可能导致循环)

涉及文件

  • src/components/PlanFormContainer.vue - 父组件
  • src/components/PlanTemplates/LifeInsuranceTemplate.vue - 子组件
  • src/components/PlanTemplates/CriticalIllnessTemplate.vue - 子组件
  • src/components/PlanTemplates/SavingsTemplate.vue - 子组件

调试时间:约 1.5 小时(3 次尝试) 影响:表单数据丢失、重置失败


架构设计

✅ 1. 统一的列表点击处理

问题: 不同列表页面的点击逻辑分散,难以维护

解决方案: 创建 useListItemClick Composable

// src/composables/useListItemClick.js
import { ListType } from '@/constants/list'

/**
 * 列表项点击处理 Composable
 *
 * @description 根据列表类型智能分发点击行为
 * @param {ListType} type - 列表类型
 * @param {Object} options - 配置选项
 * @param {Function} options.beforeClick - 点击前钩子
 * @param {Function} options.afterClick - 点击后钩子
 * @returns {Function} 点击处理函数
 */
export function useListItemClick(type, options = {}) {
  const { beforeClick, afterClick } = options

  const handleClick = async (item) => {
    // 执行点击前钩子
    if (beforeClick) {
      const shouldContinue = await beforeClick(item)
      if (!shouldContinue) return
    }

    // 根据类型分发行为
    switch (type) {
      case ListType.FILE:
        await handleFileClick(item)
        break
      case ListType.PRODUCT:
        await handleProductClick(item)
        break
      case ListType.SEARCH:
        await handleSearchClick(item)
        break
      default:
        console.warn('未知的列表类型:', type)
    }

    // 执行点击后钩子
    if (afterClick) {
      afterClick(item)
    }
  }

  return { handleClick }
}

使用示例:

<script setup>
import { useListItemClick } from '@/composables/useListItemClick'
import { ListType } from '@/constants/list'

const { handleClick } = useListItemClick(ListType.PRODUCT, {
  beforeClick: (item) => {
    console.log('点击前:', item)
    return true
  },
  afterClick: (item) => {
    console.log('点击后:', item)
  }
})
</script>

<template>
  <view v-for="item in products" :key="item.id" @tap="handleClick(item)">
    {{ item.name }}
  </view>
</template>

✅ 2. 统一的文件操作

问题: 文件下载、预览、打开等逻辑在多个页面重复

解决方案: 创建 useFileOperation Composable

// src/composables/useFileOperation.js
/**
 * 文件操作 Composable
 *
 * @description 封装文件下载、打开、预览等核心逻辑
 * @returns {Object} 文件操作方法
 */
export function useFileOperation() {
  const hasShownOfficeTip = ref(false)

  /**
   * 查看文件
   *
   * @param {Object} file - 文件对象
   * @param {string} file.url - 文件 URL
   * @param {string} file.name - 文件名
   */
  const viewFile = async (file) => {
    const ext = getFileExtension(file.name)

    // Office 文档提示
    if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)) {
      if (!hasShownOfficeTip.value) {
        Taro.showModal({
          title: '提示',
          content: 'Office 文档建议使用电脑端查看',
          confirmText: '继续',
          cancelText: '取消'
        }).then((res) => {
          if (res.confirm) {
            hasShownOfficeTip.value = true
            openFile(file)
          }
        })
        return
      }
    }

    await openFile(file)
  }

  /**
   * 下载文件
   *
   * @param {Object} file - 文件对象
   */
  const downloadFile = async (file) => {
    Taro.showLoading({ title: '下载中...' })
    try {
      const { tempFilePath } = await Taro.downloadFile({
        url: file.url
      })
      Taro.openDocument({
        filePath: tempFilePath
      })
    } catch (err) {
      console.error('下载失败:', err)
      Taro.showToast({
        title: '下载失败',
        icon: 'none'
      })
    } finally {
      Taro.hideLoading()
    }
  }

  return {
    viewFile,
    downloadFile
  }
}

✅ 3. 分层架构

推荐架构:

src/
├── api/              # API 层 - 接口定义
├── composables/      # 逻辑层 - 可复用逻辑
├── components/       # 组件层 - UI 组件
├── pages/            # 页面层 - 页面组件
├── stores/           # 状态层 - 全局状态
└── utils/            # 工具层 - 工具函数

原则:

  • API 层只负责接口调用
  • Composables 负责业务逻辑复用
  • Components 负责纯 UI 展示
  • Pages 组装 Components 和 Composables

跨页面通信

❌ 坑: useDidShow 导致列表意外刷新

问题描述: 在收藏列表页(favorites)和反馈列表页(feedback-list)中,使用 useDidShow 钩子会导致列表在关闭图片预览时意外刷新。

错误表现:

  • 用户点击图片预览(使用 Taro.previewImage()
  • 关闭预览后,useDidShow 钩子触发
  • 列表重新加载,显示 loading 动画
  • 用户体验差,感觉应用卡顿

错误代码:

// ❌ 错误:使用 useDidShow 导致意外刷新
import { useLoad, useDidShow } from '@tarojs/taro'

useLoad(() => {
  // 页面首次加载
  fetchList({ page: 0, limit: pageSize })
})

useDidShow(() => {
  // ❌ 每次页面显示都刷新(包括关闭图片预览时)
  refreshList()
})

根本原因:

  • useDidShow 在任何页面显示时都会触发
  • 关闭 Taro.previewImage() 会触发页面显示事件
  • 不区分正常的页面返回(需要刷新)和模态框关闭(不需要刷新)

解决方案: 仅使用 useLoad + 事件总线

// ✅ 正确:仅 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):

/**
 * 事件总线工具
 *
 * @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. 发送事件(在操作页面):

// 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. 接收事件(在列表页面):

// 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 中发送事件(收藏操作):

// 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 页面(分页列表页面)的生命周期使用规范:

// ✅ 正确: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 + 事件总线
    • 否 → 根据需求使用 useLoaduseDidShow
  • 是否需要跨页面刷新?
    • 是 → 使用事件总线模式
    • 否 → 使用本地更新即可
  • 是否正确清理事件监听器?
    • onUnmounted 中调用 unsubscribe()
  • 是否避免了 useDidShow 的误用?
    • 不在 LoadMoreList 页面中使用 useDidShow

历史记录:

  • 第 1 次错误: favorites 页面使用 useDidShow 导致关闭图片预览时列表刷新
  • 第 2 次错误: feedback-list 页面使用 useDidShow 导致同样问题
  • 教训: LoadMoreList 页面应使用 useLoad + 事件总线,避免 useDidShow 导致的意外刷新

开发工作流

✅ Mock 数据环境自动切换模式

问题描述

在开发过程中,使用 Mock 数据进行前端开发可以提高效率,但手动切换 Mock 数据和真实 API 容易出错:

// ❌ BAD - 硬编码开关,容易忘记切换
const USE_MOCK_DATA = true  // 开发时用 true,部署时忘记改成 false

const res = USE_MOCK_DATA
  ? await mockWeekHotAPI(params)
  : await weekHotAPI(params)

// 风险:部署到生产环境时仍然使用 Mock 数据

错误表现

  • 🔴 开发完成后忘记关闭 Mock 数据开关
  • 🔴 生产环境返回假数据,导致严重问题
  • 🔴 需要手动在每个页面中切换,容易遗漏

解决方案:基于环境变量自动切换

使用 process.env.NODE_ENV 判断当前环境,自动选择使用 Mock 数据还是真实 API:

// ✅ GOOD - 环境变量自动判断
const USE_MOCK_DATA = process.env.NODE_ENV === 'development'

const res = USE_MOCK_DATA
  ? await mockWeekHotAPI(params)
  : await weekHotAPI(params)

console.log('[Week Hot] 使用 Mock 数据:', USE_MOCK_DATA)

环境说明

环境 NODE_ENV 命令 Mock 数据
开发环境 'development' pnpm dev:weapp ✅ 启用
生产环境 'production' pnpm build:weapp ❌ 禁用

实施步骤

  1. 修改所有使用 Mock 数据的页面(共 5 个):

    • src/pages/search/index.vue(line 152)
    • src/pages/week-hot-material/index.vue(line 76)
    • src/pages/message/index.vue(line 57)
    • src/pages/material-list/index.vue(line 131)
    • src/pages/product-center/index.vue(line 159)
  2. 统一修改代码

    // 修改前
    const USE_MOCK_DATA = true  // ❌ 硬编码
    

// 修改后 const USE_MOCK_DATA = process.env.NODE_ENV === 'development' // ✅ 环境判断


3. **添加日志**(可选,便于调试):
   ```javascript
   console.log('[PageName] 使用 Mock 数据:', USE_MOCK_DATA)

收益

开发环境

  • 快速迭代,无需等待后端接口
  • 测试分页、加载更多等前端逻辑
  • 模拟各种数据场景

生产环境

  • 自动切换到真实 API
  • 无需手动修改代码
  • 避免假数据上线风险

安全性

  • 无法在生产环境误用 Mock 数据
  • 一次配置,永久生效

使用示例

<script setup>
import { ref } from 'vue'
import { weekHotAPI } from '@/api/file'
import { mockWeekHotAPI } from '@/utils/mockData'

// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API
const USE_MOCK_DATA = process.env.NODE_ENV === 'development'

const list = ref([])
const loading = ref(false)

const fetchList = async (params) => {
  loading.value = true

  try {
    console.log('[Week Hot] 使用 Mock 数据:', USE_MOCK_DATA)

    // 根据开关选择使用真实 API 或 Mock 数据
    const res = USE_MOCK_DATA
      ? await mockWeekHotAPI(params)
      : await weekHotAPI(params)

    if (res.code === 1 && res.data) {
      list.value = res.data.list
    }
  } catch (err) {
    console.error('获取列表失败:', err)
  } finally {
    loading.value = false
  }
}
</script>

注意事项

  1. 确保 Mock 数据结构一致

    • Mock 数据的返回格式必须与真实 API 一致
    • 特别是 { code, data, msg } 结构
    • 确保分页字段名称一致
  2. 生产构建前检查

    # 开发环境(使用 Mock)
    pnpm dev:weapp
    

# 生产构建前检查环境变量 pnpm build:weapp # 确认打包后的代码使用真实 API


3. **代码审查清单**:
   - [ ] 所有 Mock 数据开关都改为 `process.env.NODE_ENV === 'development'`
   - [ ] 添加了调试日志
   - [ ] Mock 数据结构符合真实 API

#### 相关文件

- Mock 数据定义:`src/utils/mockData.js`
- 使用 Mock 的页面(5 个):
  - `src/pages/search/index.vue`
  - `src/pages/week-hot-material/index.vue`
  - `src/pages/message/index.vue`
  - `src/pages/material-list/index.vue`
  - `src/pages/product-center/index.vue`

#### 最佳实践总结

⚠️ **强制要求**:所有使用 Mock 数据的页面都必须使用环境变量判断,禁止硬编码开关。

```javascript
// ✅ 推荐写法
const USE_MOCK_DATA = process.env.NODE_ENV === 'development'

// ❌ 禁止写法
const USE_MOCK_DATA = true  // 容易导致生产环境误用

❌ 坑:复杂功能修改的系统性问题 ⭐ 2026-02-09 新增

问题描述

在修改复杂功能(如计划书模块的嵌套弹窗)时,出现了严重的系统性问题:

  • 🔴 修改不系统:改一处忘一处,导致连锁错误
  • 🔴 缺少验证:没有主动添加调试日志并验证
  • 🔴 沟通低效:反复让用户复制粘贴错误信息

错误模式

典型的错误流程

1. Claude Code: "我修改了文件 A"
2. User: [复制错误信息]
3. Claude Code: "哦,我忘记修改文件 B 了"
4. User: [复制错误信息]
5. Claude Code: "哦,我忘记修改文件 C 了"
6. ... 循环往复

问题根源

  1. 没有全局视图:只看到局部修改,没有整体规划
  2. 缺少验证机制:修改后不主动验证,等待用户反馈
  3. 沟通方式错误:依赖用户反馈,而不是主动检查

解决方案:系统化修改流程

对于复杂功能的修改,必须遵循以下流程

1. 📋 理解完整需求(开始修改前)
## 修改前检查清单

### 理解需求
- [ ] 画出完整的数据流图
- [ ] 列出所有受影响的文件
- [ ] 确认修改的边界
- [ ] 识别可能的副作用

### 影响分析
- [ ] 哪些文件会修改?
- [ ] 哪些组件会受影响?
- [ ] 数据流会如何变化?
- [ ] 是否有现成的类似实现?
2. 🧪 设计验证方案(修改前)
## 验证方案

### 调试日志规划
在代码关键位置添加 console.log:
```javascript
// 示例:计划书弹窗
console.log('[PlanPopup] 父弹窗状态变化:', { showParentFooter, showChildPopup })
console.log('[PlanPopup] provide 数据:', { popupControl })
console.log('[ChildPopup] 接收到的 provide:', injectedPopupControl)
console.log('[ChildPopup] 关闭子弹窗,通知父弹窗')

测试步骤

  1. 打开计划书页面
  2. 点击"申请计划书"
  3. 观察控制台输出(应该看到父弹窗状态)
  4. 点击"选择行业"
  5. 观察控制台输出(应该看到子弹窗状态和父弹窗 footer 隐藏)
  6. 关闭子弹窗
  7. 观察控制台输出(应该看到父弹窗 footer 恢复) ```
3. ✋ 修改代码(一次性完成)
## 修改清单

### 受影响的文件
- [ ] `src/components/PlanPopup/index.vue` - 父弹窗逻辑
- [ ] `src/components/PlanFields/IndustryPicker.vue` - 子弹窗逻辑
- [ ] `src/pages/plan/index.vue` - 页面集成

### 修改内容
**文件 1: PlanPopup/index.vue**
- 添加 `provide` 传递控制方法
- 响应 `popupControl``closeParent` 调用
- 添加 console.log

**文件 2: IndustryPicker.vue**
- 添加 `inject` 接收控制方法
- 确认时调用 `popupControl.closeParent()`
- 添加 console.log

**文件 3: plan/index.vue**
- 无需修改(仅用于验证)
4. 🧞 提供验证指令
## 验证指令

请按以下步骤验证修改:

### 步骤 1:启动项目
```bash
pnpm dev:weapp

步骤 2:导航到计划书页面

在微信开发者工具中打开计划书页面

步骤 3:执行测试流程

  1. 点击"申请计划书"按钮
  2. 点击"选择行业"字段
  3. 选择任意行业
  4. 点击"确定"按钮

步骤 4:检查控制台输出

预期输出

[PlanPopup] 父弹窗状态变化: { showParentFooter: true, showChildPopup: false }
[PlanPopup] provide 数据: { popupControl: { closeParent: [Function] } }
[ChildPopup] 接收到的 provide: { closeParent: [Function] }
[ChildPopup] 关闭子弹窗,通知父弹窗
[PlanPopup] 收到关闭子弹窗通知,恢复 footer

步骤 5:观察页面行为

预期行为

  • ✅ 父弹窗的 footer 在子弹窗打开时隐藏
  • ✅ 父弹窗的 footer 在子弹窗关闭时恢复
  • ✅ 没有层级冲突(子弹窗完全覆盖父弹窗底部)

如果遇到错误

请复制:

  1. 控制台完整输出
  2. 页面截图(显示层级问题) ```
5. 📝 等待反馈

关键原则

  • ✅ 一次性修改所有文件,不要分批
  • ✅ 添加详细的调试日志
  • ✅ 提供完整的验证步骤
  • ✅ 主动说明预期结果
  • ❌ 不要修改一个文件就问"对不对"
  • ❌ 不要反复询问"有没有错误"
  • ❌ 不要让用户反复复制粘贴

实施要点

✅ 我会做的

  • ✅ 修改前列出所有受影响的文件
  • ✅ 添加 console.log 标注关键数据流
  • ✅ 给出完整的测试步骤
  • ✅ 主动说明"请运行后把 console 输出发给我"
  • ✅ 一次性修改所有相关文件

❌ 我不会做的

  • ❌ 修改一个文件就问你"对不对"
  • ❌ 让你反复复制粘贴错误
  • ❌ 没有整体计划就开始修改

收益

提升质量

  • ✅ 一次性修改完整,减少返工
  • ✅ 有验证方案,快速定位问题
  • ✅ 减少沟通成本,提升效率

提升体验

  • ✅ 用户不需要反复复制粘贴
  • ✅ 验证步骤清晰明确
  • ✅ 问题定位快速准确

适用场景

必须使用系统化流程的场景

  • 🔴 修改嵌套组件交互
  • 🔴 修改状态管理逻辑
  • 🔴 修改认证/权限流程
  • 🔴 修改跨页面通信
  • 🔴 涉及 3 个以上文件的修改

可以简化的场景

  • 🟢 修改单个文件的样式
  • 🟢 修改文案或简单逻辑
  • 🟢 添加 console.log 调试
  • 🟢 修复明显的 Bug

历史案例

反面案例(2026-02-09)

❌ 错误流程:
1. 修改 PlanPopup.vue → 用户测试 → 报错
2. 修改 IndustryPicker.vue → 用户测试 → 报错
3. 修改 PlanPopup.vue → 用户测试 → 又报错
4. ... 循环往复,浪费时间

问题:
- 没有全局视图
- 没有一次性修改所有文件
- 没有添加调试日志
- 没有提供验证步骤

正面案例(应该这样)

✅ 正确流程:
1. 分析需求 → 列出 3 个受影响的文件
2. 设计验证方案 → 规划 5 个调试日志点
3. 一次性修改 3 个文件 → 添加 5 个 console.log
4. 提供验证步骤 → 说明预期输出
5. 用户测试 → 一次性成功

收益:
- 减少返工
- 快速定位问题
- 提升效率

相关文件

  • 计划书模块:src/pages/plan/index.vue
  • 计划弹窗:src/components/PlanPopup/index.vue
  • 表单字段:src/components/PlanFields/

最佳实践总结

⚠️ 强制要求:修改复杂功能时,必须先规划后实施,一次性修改所有相关文件,并提供完整的验证方案。

// ✅ 推荐流程
1. 理解需求  画出数据流图
2. 规划修改  列出所有受影响文件
3. 设计验证  规划调试日志点
4. 一次性修改  修改所有文件 + 添加日志
5. 提供验证  给出测试步骤和预期结果
6. 等待反馈  不要催促,不要反复询问

// ❌ 错误流程
1. 修改文件 A  "对不对"  报错
2. 修改文件 B  "对不对"  又报错
3. 修改文件 A  "对不对"  还报错
4. ... 循环往复,浪费时间

组件开发案例:AmountKeyboard 数字键盘组件 ⭐ 新增

问题描述

需要创建一个保额输入组件,点击后弹出数字键盘进行输入,而不是直接使用输入框。

遇到的坑和解决方案

坑 1: Vue 渲染错误 - Invalid vnode type

错误信息:

[Vue warn]: Invalid vnode type when creating vnode: undefined

原因: 使用了错误的组件名 nut-numberkeyboard (驼峰式)

解决方案: 使用正确的 kebab-case 命名 <nut-number-keyboard>

官方示例:

<nut-number-keyboard v-model:visible="show" type="rightColumn" />

坑 2: 键盘被底部按钮遮挡

现象: 数字键盘的底部按钮被页面底部按钮遮挡

错误尝试: 使用 z-index CSS 调整(无效)

正确方案: 使用 GlobalPopupManager 系统

import { useGlobalPopup } from './GlobalPopupManager.js'

const { registerPopup, activatePopup, deactivatePopup } = useGlobalPopup()

// 注册弹窗
const keyboardPopupId = ref(null)
onMounted(() => {
  keyboardPopupId.value = registerPopup()
})

// 监听键盘状态
watch(showKeyboard, (newValue) => {
  if (newValue) {
    activatePopup(keyboardPopupId.value)
  } else {
    deactivatePopup(keyboardPopupId.value)
  }
})

坑 3: 数字输入只能录入单个数字

现象: 输入 "123" 时,只显示最后一个数字

原因: nut-number-keyboard@input 事件传入的是单个字符,需要累加

错误代码:

const onInput = (val) => {
  inputValue.value = val  // ❌ 直接替换
}

正确代码:

const onInput = (val) => {
  if (!inputValue.value) {
    inputValue.value = String(val)
  } else {
    inputValue.value = String(inputValue.value) + String(val)
  }
}

坑 4: 输入验证导致无法输入

现象: 初始化为 "0.00" 后,所有输入都被忽略

原因: 验证逻辑判断 parts[1].length >= 2,认为已有2位小数

解决方案: 初始化时使用空字符串,而不是 "0.00"

const openKeyboard = () => {
  if (props.modelValue !== null && props.modelValue !== undefined) {
    inputValue.value = (props.modelValue / 100).toFixed(2)
  } else {
    inputValue.value = ''  // ✅ 空字符串,不是 '0.00'
  }
}

验证逻辑:

const onInput = (val) => {
  // 检查小数点
  if (val === '.' && inputValue.value.includes('.')) {
    return  // 已有小数点,忽略
  }

  // 检查小数位
  if (val >= '0' && val <= '9') {
    const parts = inputValue.value.split('.')
    if (parts.length === 2 && parts[1]?.length >= 2) {
      return  // 已有2位小数,忽略
    }
  }

  // 累加输入
  if (!inputValue.value) {
    inputValue.value = String(val)
  } else {
    inputValue.value = String(inputValue.value) + String(val)
  }
}

坑 5: 键盘自动关闭问题

现象: 键盘刚打开就立即关闭

原因: @close 事件在打开时被误触发

解决方案: 添加时间判断,过滤误触发

const keyboardOpenTime = ref(0)

watch(showKeyboard, (newValue) => {
  if (newValue) {
    keyboardOpenTime.value = Date.now()  // 记录打开时间
  }
})

const onClose = () => {
  const timeSinceOpen = Date.now() - keyboardOpenTime.value
  if (timeSinceOpen < 500) {
    return  // 忽略误触发
  }
  showKeyboard.value = false
}

最终方案: 移除 @close 监听,在 watch(showKeyboard) 中同步关闭金额弹窗


坑 6: 字符串累加被当作数字相加

现象: 输入 1、2、3 后显示 6(1+2+3)

原因: JavaScript += 运算符将字符串当作数字相加

解决方案: 确保两边都是字符串类型

inputValue.value = String(inputValue.value) + String(val)

坑 7: 保存后再次编辑显示问题

问题: 保存 "12.23",删除后保存变成 "12" 而不是 "12.00"

原因: parseFloat("12") 丢失小数点后的零

解决方案: 使用 toFixed(2) 保留两位小数

const onConfirm = () => {
  let yuan = parseFloat(inputValue.value || '0')
  if (!Number.isNaN(yuan)) {
    const formattedYuan = parseFloat(yuan.toFixed(2))
    const cents = Math.round(formattedYuan * 100)
    emit('update:modelValue', cents)
  }
}

✅ 最终实现要点

  1. 组件结构: 点击区域 + 金额弹窗 + 数字键盘三层结构
  2. 事件处理: @input 累加,@delete 删除,@confirm 保存
  3. 输入验证: 限制小数点(1个)和小数位(2位)
  4. 状态管理: watch(showKeyboard) 同步关闭金额弹窗
  5. 美观设计: 渐变背景 + 圆形装饰 + 大号数字显示

案例 4: 状态标记组件模式

问题: 多个页面需要展示不同状态的业务对象(计划书、订单等)

解决方案: 创建通用的状态标记组件模式

<!-- 状态标记组件模式 -->
<template>
  <view class="plan-item">
    <!-- 状态标记:右上角显示 -->
    <view class="ml-[24rpx]">
      <view
        :class="[
          'status-badge',
          item.status === 'processing' ? 'status-processing' : 'status-generated'
        ]"
      >
        {{ item.status === 'processing' ? '生成中' : '已完成' }}
      </view>
    </view>
  </view>
</template>

<style lang="less" scoped>
// 通用状态标记样式
.status-badge {
  padding: 8rpx 16rpx;
  border-radius: 9999rpx;  // 圆角胶囊形状
  font-size: 22rpx;
  font-weight: 500;
  white-space: nowrap;
}

// 处理中状态:黄色背景
.status-processing {
  background-color: #FEF3C7;
  color: #D97706;
}

// 已完成状态:绿色背景
.status-generated {
  background-color: #D1FAE5;
  color: #059669;
}
</style>

核心要点:

  1. 状态映射函数: 将后端状态值映射为前端统一状态值

    const mapOrderStatus = (status) => {
     // 后端状态值 -> 前端状态值
     const statusMap = {
       '3': 'processing',  // 生成中
       '5': 'completed'    // 已完成
     }
     return statusMap[status] || 'unknown'
    }
    
  2. 条件类名绑定: 使用 :class 动态切换样式

    :class="[
     'status-badge',
     item.status === 'processing' ? 'status-processing' : 'status-generated'
    ]"
    
  3. 视觉设计原则:

    • 颜色语义化: 处理中用黄色,已完成用绿色
    • 圆角胶囊: border-radius: 9999rpx 实现完全圆角
    • 内边距适中: padding: 8rpx 16rpx 确保文字不拥挤
    • 字体大小: 22rpx 既不突兀也清晰可读
  4. 位置布局: 放在卡片右上角,使用 ml-[24rpx] 与标题区隔开

收益:

  • 统一的状态展示模式,易于复用
  • 清晰的视觉反馈,用户一目了然
  • 易于扩展新状态(如"失败"、"待审核"等)

使用场景: 计划书页面、订单列表、审批流程等


总结

🎯 核心经验

  1. "第 3 次出现原则": 代码重复 3 次时必须抽取
  2. NutUI 陷阱: textarea、IconFont 等组件有坑,优先使用原生组件
  3. ⭐ 数字键盘组件: @input 传入单个字符需要累加,注意类型转换
  4. 静态资源: SVG 图标必须使用 import 导入
  5. 样式策略: TailwindCSS(80%) + Less(20%) 混合使用
  6. 性能优化: shallowRef + markRaw 处理组件对象响应式
  7. 代码质量: 强制 JSDoc 注释,统一命名规范
  8. API 调用规范: ⚠️ 不要使用 fn() 包装 API,直接调用并自己处理错误
  9. 架构设计: 分层清晰,职责单一
  10. 跨页面通信: ⭐ 使用事件总线模式,LoadMoreList 页面避免使用 useDidShow(防止意外刷新)
  11. Mock 数据切换: ⭐ 使用环境变量 process.env.NODE_ENV 自动判断,禁止硬编码
  12. ⚠️ 写代码前必查: 先搜索项目中是否有类似实现,保持写法一致
  13. 状态标记模式: ⭐ 使用条件类名 + 语义化颜色展示业务状态(如"生成中"用黄色,"已完成"用绿色)

📚 推荐阅读

🔄 持续更新

本文档会随着项目开发持续更新,记录新的经验教训。


最后更新: 2026-02-11 维护者: Claude Code 项目: Manulife WeApp

更新记录:

  • 2026-02-11: 新增"状态标记组件模式"案例,记录计划书卡片状态标记实现经验
  • 2026-02-08: 新增 "跨页面通信" 章节,记录事件总线实现和 useDidShow 陷阱解决方案
  • 2026-02-08: 新增 "开发工作流" 章节,记录 Mock 数据环境自动切换模式
  • 2026-02-09: 新增 "AmountKeyboard 组件开发案例"