【灶台导航】个人中心模块开发实战

从零到一,构建微信小程序的用户画像、烹饪历史与私人菜谱系统

一、模块概览

个人中心是灶台导航的核心枢纽,承载了用户身份、家庭画像、烹饪记录、私人菜谱、偏好设置等能力。整个模块涉及 1个主页面 + 4个子页面 + 1个云函数 + 向量数据库同步 ,数据流从云端到界面环环相扣。

功能清单

子模块 功能点
个人主页 登录/编辑资料、头像上传、家庭成员预览、快捷入口
家庭成员 增删改、口味偏好、忌口管理、角色标签(成人/儿童/老人)
烹饪历史 分页列表、3天自动过期、长按删除、一键清空
私人菜谱 增删改、表单解析、向量数据库同步、AI语义检索
偏好设置 用餐人数步进器、语音播报开关、计时提醒方式、默认成员

二、用户身份:无感登录的设计哲学

微信小程序云开发有一个关键特性:只要用户打开了小程序,cloud.getWXContext().OPENID 就自动可用 ,不需要调用 wx.login() 或任何授权弹窗。

这意味着我们的"登录"其实不是认证,而是个性化------让用户设置昵称和头像。

云端自动建档

每次云函数调用前,ensureUserExists 会检查用户文档是否存在:

javascript 复制代码
async function ensureUserExists(openid) {
  try {
    await db.collection('users').doc(openid).get()
  } catch (e) {
    // 文档不存在,自动创建
    const selfMemberId = `m_self_${openid.slice(-6)}`
    await db.collection('users').add({
      data: {
        _id: openid,          // openid 直接作为文档主键
        nickname: '',
        avatarUrl: '',
        members: [{
          memberId: selfMemberId,
          name: '自己',
          role: 'adult',
          preferences: [],
          allergies: [],
          dislikes: []
        }],
        settings: {
          defaultPeople: 2,
          voiceBroadcast: true,
          timerAlertType: 'vibrate',
          defaultRole: ''
        }
      }
    })
  }
}

设计要点

  • _id = openid,天然隔离用户数据,查询时不需要额外索引
  • 自动创建默认成员"自己",避免空状态
  • 默认设置预填充,用户开箱即用

前端登录态同步

登录状态的判断很简单------有没有昵称:

javascript 复制代码
syncLoginState() {
  const app = getApp()
  const userInfo = app.globalData.userInfo
  this.setData({
    isLoggedIn: !!(userInfo && userInfo.nickname)
  })
}

没有昵称时,个人中心头部显示"点击登录";有了昵称就显示用户信息。功能完全不受影响,因为 openid 始终存在。

头像上传到云存储

微信提供的 open-type="chooseAvatar" 按钮可以合规获取用户头像,但返回的是临时路径,需要上传到云存储才能持久化:

javascript 复制代码
async confirmLogin() {
  const { tempNickname, tempAvatarUrl } = this.data
  if (!tempNickname.trim()) {
    wx.showToast({ title: '请输入昵称', icon: 'none' })
    return
  }

  let avatarUrl = tempAvatarUrl
  // 临时路径需要上传到云存储
  if (avatarUrl && !avatarUrl.startsWith('cloud://')) {
    const res = await wx.cloud.uploadFile({
      cloudPath: `avatars/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.png`,
      filePath: avatarUrl
    })
    avatarUrl = res.fileID
  }

  await callFunction('userProfile', {
    action: 'updateProfile',
    data: { nickname: tempNickname, avatarUrl }
  })

  getApp().setUserInfo(tempNickname, avatarUrl)
}

cloudPath 用时间戳 + 随机串保证唯一,上传后拿到 fileIDcloud:// 开头)存入数据库。


三、家庭画像:让AI懂你的胃

家庭成员是灶台导航的特色功能。每个成员有角色(成人/儿童/老人)、口味偏好和忌口,这些数据在 AI 对话时会被注入上下文:

复制代码
[当前用户上下文] 当前用餐人:小明;口味偏好:清淡、甜;忌口:花生、辣椒

数据结构

javascript 复制代码
{
  memberId: 'm_1715xxxx_a3f2b1',   // 唯一标识
  name: '小明',
  role: 'child',                     // adult | child | elder
  preferences: ['清淡', '甜'],       // 口味偏好
  allergies: ['花生', '辣椒'],       // 忌口
  dislikes: []                        // 不爱吃的
}

角色标签的动态渲染

三种角色对应三种颜色的标签,在个人中心预览和成员管理页统一使用:

javascript 复制代码
getRoleLabel(role) {
  const map = {
    adult: { label: '成人', color: '#FF7E45' },
    child: { label: '儿童', color: '#4CAF50' },
    elder: { label: '老人', color: '#9C27B0' }
  }
  return map[role] || map.adult
}

WXML 中动态绑定样式:

xml 复制代码
<view class="member-chip" wx:for="{{familyMembers}}" wx:key="memberId"
      style="background: {{item.roleLabel.color}}20; color: {{item.roleLabel.color}}">
  <text>{{item.roleLabel.label}}</text>
  <text>{{item.name}}</text>
</view>

这里用到了一个小技巧:颜色值拼接透明度后缀 color + '20' 生成半透明背景色,标签和文字同色系,视觉统一。


四、烹饪历史:自动过期的列表实践

烹饪历史的设计有两个核心需求:自动清理过期数据用户主动删除

服务端时间过滤 + 异步清理

云函数查询时只返回最近3天的记录,同时异步清理过期数据:

javascript 复制代码
async function getHistory(openid, params) {
  const limit = params.limit || 10
  const offset = params.offset || 0

  // 只查询最近3天
  const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000)

  const whereCondition = {
    openid,
    cookDate: _.gte(threeDaysAgo)
  }

  const { total } = await db.collection('history').where(whereCondition).count()
  const { data } = await db.collection('history')
    .where(whereCondition)
    .orderBy('cookDate', 'desc')
    .skip(offset)
    .limit(limit)
    .get()

  // 异步清理过期记录,不阻塞响应
  cleanExpiredHistory(openid, threeDaysAgo).catch(e => {
    console.warn('[userProfile] 清理过期烹饪历史失败:', e.message)
  })

  return { code: 0, message: 'success', data: { total, records: data } }
}

为什么异步清理? 清理操作可能涉及多条记录删除,如果 await 等待会显著拖慢接口响应。用户要的是数据列表,清理是"顺便"做的事,失败也不影响主流程。

分页加载

前端实现了经典的 下拉刷新 + 上拉加载更多

javascript 复制代码
// 下拉刷新
onPullDownRefresh() {
  this.setData({ page: 1, records: [], hasMore: true })
  this.loadHistory(true).then(() => wx.stopPullDownRefresh())
},

// 上拉加载更多
onReachBottom() {
  if (this.data.hasMore && !this.data.isLoading) {
    this.setData({ page: this.data.page + 1 })
    this.loadHistory(false)
  }
},

// 加载逻辑
async loadHistory(isRefresh) {
  const limit = this.data.limit
  const offset = (this.data.page - 1) * this.data.limit
  const res = await callFunction('userProfile', {
    action: 'getHistory', data: { limit, offset }
  })

  if (res.success) {
    const newRecords = res.data.records || []
    const hasMore = (this.data.page * this.data.limit) < res.data.total

    this.setData({
      records: isRefresh ? newRecords : [...this.data.records, ...newRecords],
      hasMore
    })
  }
}

isRefresh 控制是替换还是追加,避免下拉刷新时数据重复。

长按删除 + 一键清空

交互上采用长按删除单条 + 清空按钮全部删除的双模式:

xml 复制代码
<!-- 长按删除 -->
<view class="history-card" bindlongpress="deleteRecord" data-id="{{item._id}}">

<!-- 清空按钮 -->
<view class="clear-all-btn" bindtap="clearAllHistory">清空全部</view>

删除操作都有二次确认弹窗,防止误操作:

javascript 复制代码
deleteRecord(e) {
  const recordId = e.currentTarget.dataset.id
  wx.showModal({
    title: '删除记录',
    content: '确定要删除这条烹饪记录吗?',
    confirmColor: '#FF7E45',
    success: async (res) => {
      if (res.confirm) {
        const result = await callFunction('userProfile', {
          action: 'deleteHistory', data: { recordId }
        })
        if (result.success) {
          wx.showToast({ title: '已删除', icon: 'success' })
          this.setData({ page: 1, records: [] })
          this.loadHistory(true)
        }
      }
    }
  })
}

删除后全量刷新而非局部移除,保证分页数据一致性。


五、私人菜谱:从表单解析到向量检索

私人菜谱是最复杂的子模块,涉及自由文本解析、结构化存储、向量数据库同步三个层面。

表单解析:自由文本 → 结构化数据

小程序不适合做复杂的动态表单(添加食材行、删除步骤等),所以我们选择了文本域输入 + 规则解析的方案:

javascript 复制代码
// 解析食材:每行一个,"食材名 用量" 格式
function parseIngredients(text) {
  if (!text || !text.trim()) return []
  return text.trim().split('\n')
    .filter(line => line.trim())
    .map(line => {
      const parts = line.trim().split(/[,,\s]+/)
      const name = parts[0] || ''
      const amount = parts.slice(1).join(' ') || '适量'
      return { name, amount }
    })
}

// 解析步骤:每行一步,自动去除序号前缀
function parseSteps(text) {
  if (!text || !text.trim()) return []
  return text.trim().split('\n')
    .filter(line => line.trim())
    .map((line, i) => ({
      stepNo: i + 1,
      description: line.trim().replace(/^\d+[.、)\]]\s*/, ''),
      duration: 0,
      tips: ''
    }))
}

反向的 join* 函数用于编辑时回填:

javascript 复制代码
function joinIngredients(ingredients) {
  return (ingredients || []).map(i => `${i.name} ${i.amount}`).join('\n')
}

function joinSteps(steps) {
  return (steps || []).map((s, i) => `${i + 1}. ${s.description}`).join('\n')
}

这套双向转换让用户在文本框里自由书写,提交时自动结构化,编辑时再还原为文本。

向量数据库同步:让AI能搜到私人菜谱

私人菜谱不只是存到数据库,还要同步到 Qdrant 向量数据库,这样 AI 对话时就能通过语义检索找到用户的私人菜谱:

javascript 复制代码
async function addPrivateRecipe(openid, recipeData) {
  // 1. 存入数据库
  const { _id } = await db.collection('recipes').add({
    data: {
      openid,
      name: recipeData.name,
      isPrivate: true,
      // ... 其他字段
    }
  })

  // 2. 异步同步到向量数据库(不阻塞响应)
  qdrantPrivate.upsertPrivateRecipe(recipe, openid).catch(err => {
    console.error('[userProfile] Qdrant同步失败:', err)
  })

  return { code: 0, message: '保存成功', data: null }
}

向量化时,拼接菜谱的文本信息生成 embedding:

javascript 复制代码
async function upsertPrivateRecipe(recipe, openid) {
  // 拼接可检索文本(不含步骤和用量,聚焦语义关键词)
  const searchText = [
    recipe.name,
    recipe.description,
    (recipe.tags || []).join(' '),
    recipe.category,
    (recipe.ingredients || []).map(i => i.name).join(' ')
  ].filter(Boolean).join(' ')

  // 调用 Embedding API
  const vector = await getEmbedding(searchText)

  // 用确定性 ID 写入 Qdrant
  const pointId = hashCode(recipe.recipeId) % 2147483647
  await qdrantClient.upsert('private_recipes', {
    points: [{
      id: pointId,
      vector,
      payload: { recipeId: recipe.recipeId, openid, name: recipe.name }
    }]
  })
}

确定性ID的设计 :用 hashCode(recipeId) 生成 Qdrant 中的 point ID,保证同一个菜谱多次 upsert 是更新而非新增。这是向量数据库与关系型数据库的一个重要区别------没有天然的唯一键约束。


六、偏好设置:即时持久化的交互模式

设置页的交互设计有一个关键决策:没有保存按钮,每次变更立即写入云端

javascript 复制代码
onVoiceToggle(e) {
  const voiceBroadcast = e.detail.value
  this.setData({ voiceBroadcast })
  this.saveSetting('voiceBroadcast', voiceBroadcast)
},

saveSetting(key, value) {
  callFunction('userProfile', {
    action: 'updateSettings',
    data: { [key]: value }    // 动态属性名,只传变更的字段
  })
}

云函数端用点号路径更新嵌套字段,避免覆盖整个 settings 对象:

javascript 复制代码
async function updateSettings(openid, settingsData) {
  const updateData = {}
  const allowedFields = ['defaultPeople', 'voiceBroadcast', 'timerAlertType', 'defaultRole']

  allowedFields.forEach(field => {
    if (settingsData[field] !== undefined) {
      updateData[`settings.${field}`] = settingsData[field]
    }
  })

  await db.collection('users').doc(openid).update({ data: updateData })
}

settings.voiceBroadcast 这种点号路径是云数据库支持的深层更新语法,只修改指定字段而不影响其他设置。

用餐人数步进器

没用 input type="number",而是实现了 +/- 步进器,触感更好:

xml 复制代码
<view class="stepper">
  <view class="stepper-btn {{defaultPeople <= 1 ? 'disabled' : ''}}" bindtap="decreasePeople">-</view>
  <view class="stepper-value">{{defaultPeople}}人</view>
  <view class="stepper-btn {{defaultPeople >= 20 ? 'disabled' : ''}}" bindtap="increasePeople">+</view>
</view>
javascript 复制代码
decreasePeople() {
  if (this.data.defaultPeople <= 1) return
  const defaultPeople = this.data.defaultPeople - 1
  this.setData({ defaultPeople })
  this.saveSetting('defaultPeople', defaultPeople)
}

每次点击都即时保存,用户无感知延迟。


七、数据安全:所有权校验的统一模式

所有删除和修改操作都遵循查询 → 校验 → 执行三步模式:

javascript 复制代码
async function deleteHistory(openid, recordId) {
  // 1. 查询
  const { data } = await db.collection('history').doc(recordId).get()
  // 2. 校验所有权
  if (!data || data.openid !== openid) {
    return { code: 1003, message: '记录不存在', data: null }
  }
  // 3. 执行删除
  await db.collection('history').doc(recordId).remove()
  return { code: 0, message: '已删除', data: null }
}

私人菜谱的校验更严格,同时验证 openidisPrivate

javascript 复制代码
const recipe = data
if (!recipe || recipe.openid !== openid || !recipe.isPrivate) {
  return { code: 1003, message: '菜谱不存在或无权限', data: null }
}

这防止了通过篡改请求参数越权操作他人数据的可能。


八、统一封装:callFunction 的优雅抽象

整个模块的云函数调用都经过 utils/cloud.js 的统一封装:

javascript 复制代码
async function callFunction(name, data = {}) {
  try {
    const res = await wx.cloud.callFunction({ name, data })
    const result = res.result

    if (result && result.code === 0) {
      return { success: true, data: result.data, message: 'success' }
    } else {
      const msg = (result && result.message) || '操作失败'
      return { success: false, data: null, message: msg }
    }
  } catch (error) {
    return { success: false, data: null, message: '网络错误,请重试' }
  }
}

统一的好处

  • 业务代码只需 if (res.success) 判断,不用关心 coderesult 嵌套
  • 网络错误和业务错误统一处理
  • 错误消息对用户友好,不是原始的 err.message

九、视觉体系:灶台味的统一色板

整个个人中心模块遵循一套"灶台"色系:

用途 色值 说明
主色 #FF7E45 暖橙色,按钮/强调
渐变起始 #FF7E45 顶部渐变
渐变终止 #FF5722 深橙色
页面背景 #FDF5E6 麦色/米色
卡片阴影 0 2rpx 12rpx rgba(0,0,0,0.04) 轻投影
图标背景 各类淡色 成员=#FFF3ED、历史=#E8F5E9、菜谱=#FFF8E1

成员角色标签的三色体系贯穿始终:

css 复制代码
/* 成人 - 暖橙 */
.member-chip[data-role="adult"] { background: #FF7E4520; color: #FF7E45; }
/* 儿童 - 清绿 */
.member-chip[data-role="child"] { background: #4CAF5020; color: #4CAF50; }
/* 老人 - 雅紫 */
.member-chip[data-role="elder"] { background: #9C27B020; color: #9C27B0; }

颜色 + 透明度后缀(20 = 12.5% alpha)生成半透明背景,标签和文字同色系,简洁统一。


十、踩过的坑

1. 云函数 action 名不匹配

前端调用 action: 'clearAllSessions',但云函数里写的是 action === 'clearAll',导致请求穿透到 message 校验,报"消息内容不能为空"。

教训:action 名是前后端隐式契约,最好定义常量统一管理。

2. 微信 chooseAvatar 返回临时路径

open-type="chooseAvatar" 返回的是 http://tmp/... 临时路径,重启小程序后失效。必须上传到云存储获取 cloud:// 路径才能持久化:

javascript 复制代码
if (avatarUrl && !avatarUrl.startsWith('cloud://')) {
  const res = await wx.cloud.uploadFile({
    cloudPath: `avatars/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.png`,
    filePath: avatarUrl
  })
  avatarUrl = res.fileID
}

3. Qdrant 同步失败不影响主流程

向量数据库同步可能因为网络或 API 限制失败,但这不应该阻塞用户操作。所有 Qdrant 调用都用 fire-and-forget 模式:

javascript 复制代码
qdrantPrivate.upsertPrivateRecipe(recipe, openid).catch(err => {
  console.error('[userProfile] Qdrant同步失败:', err)
})

菜谱已存入数据库,向量同步失败只影响 AI 检索,下次更新时会重新同步。

4. 分页删除后的数据不一致

删除单条记录后,如果只是从本地数组 splice 移除,后续的分页 offset 就会错位。所以删除后选择全量刷新

javascript 复制代码
this.setData({ page: 1, records: [] })
this.loadHistory(true)   // 从第一页重新加载

总结

个人中心模块的开发,本质上是在解决三个问题:

  1. 身份归一:用 openid 作为天然主键,"登录"只是个性化的锦上添花
  2. 数据生命周期:3天/7天自动过期 + 用户主动删除,兼顾存储成本和用户控制感
  3. 结构化与自由的平衡:表单用自由文本输入 + 规则解析,降低用户填写门槛;存储用结构化对象,保证程序可处理

项目地址 :Gitee/ZaoTaiNavigation
团队名称 :倒灶了队
更新时间:2026年5月

相关推荐
wanger617 小时前
AI Agent
前端·javascript·人工智能
帝博格T-bag8 小时前
一、分享序言
notepad++
JiaWen技术圈8 小时前
Expo Router 和 React Native 的区别
javascript·react native·react.js
a1117768 小时前
动森UI组件(开源 html animal-island-ui )
前端·javascript·ui·开源·html
ljt27249606619 小时前
Vue笔记(六)--响应式
javascript·vue.js·笔记
threelab9 小时前
Three.js 黑洞引力效果着色器 | 三维可视化 / AI 提示词
开发语言·javascript·着色器
এ慕ོ冬℘゜9 小时前
JS 前端基础高频面试题
开发语言·前端·javascript
放下华子我只抽RuiKe59 小时前
React 从入门到生产(八):测试与部署
前端·javascript·深度学习·react.js·前端框架·ecmascript·集成学习