FurBall/完整技术实现方案.md

42 KiB
Raw Permalink Blame History

以下是 「毛球手账」 的完整技术实现方案,包含数据库 Schema、页面规划和云函数框架。


一、数据库 SchemauniCloud DB Schema

uniCloud/database/ 目录下创建以下 .schema.json 文件。

1. users.schema.json

{
  "bsonType": "object",
  "required": ["wx_openid", "createdAt"],
  "permission": {
    "read": "doc._id == auth.uid",
    "create": "auth.uid != null",
    "update": "doc._id == auth.uid",
    "delete": false
  },
  "properties": {
    "_id": {
      "description": "用户ID系统自动生成",
      "bsonType": "string"
    },
    "wx_openid": {
      "bsonType": "string",
      "description": "微信openid",
      "trim": "both"
    },
    "wx_unionid": {
      "bsonType": "string",
      "description": "微信unionid"
    },
    "nickName": {
      "bsonType": "string",
      "description": "微信昵称",
      "trim": "both"
    },
    "avatarUrl": {
      "bsonType": "string",
      "description": "微信头像URL"
    },
    "phone": {
      "bsonType": "string",
      "description": "绑定手机号"
    },
    "membership": {
      "bsonType": "object",
      "properties": {
        "type": {
          "bsonType": "string",
          "enum": ["free", "premium"],
          "default": "free"
        },
        "expireAt": {
          "bsonType": "timestamp",
          "description": "会员过期时间"
        }
      }
    },
    "settings": {
      "bsonType": "object",
      "properties": {
        "defaultPetId": {
          "bsonType": "string",
          "description": "默认选中宠物ID"
        },
        "reminderEnabled": {
          "bsonType": "bool",
          "default": true
        }
      }
    },
    "createdAt": {
      "bsonType": "timestamp",
      "defaultValue": {
        "$env": "now"
      }
    },
    "updatedAt": {
      "bsonType": "timestamp",
      "defaultValue": {
        "$env": "now"
      }
    }
  }
}

2. pets.schema.json

{
  "bsonType": "object",
  "required": ["user_id", "name", "type", "createdAt"],
  "permission": {
    "read": "doc.user_id == auth.uid",
    "create": "auth.uid != null",
    "update": "doc.user_id == auth.uid",
    "delete": "doc.user_id == auth.uid"
  },
  "properties": {
    "_id": {
      "bsonType": "string"
    },
    "user_id": {
      "bsonType": "string",
      "description": "关联用户ID",
      "foreignKey": "users._id"
    },
    "name": {
      "bsonType": "string",
      "description": "宠物昵称",
      "minLength": 1,
      "maxLength": 10,
      "trim": "both"
    },
    "type": {
      "bsonType": "string",
      "enum": ["cat", "dog", "other"],
      "description": "宠物类型"
    },
    "breed": {
      "bsonType": "string",
      "description": "品种"
    },
    "gender": {
      "bsonType": "string",
      "enum": ["male", "female", "unknown"],
      "default": "unknown"
    },
    "birthday": {
      "bsonType": "timestamp",
      "description": "出生日期"
    },
    "isBirthdayEstimated": {
      "bsonType": "bool",
      "default": false,
      "description": "生日是否为估算"
    },
    "color": {
      "bsonType": "string",
      "description": "毛色/特征"
    },
    "avatar": {
      "bsonType": "string",
      "description": "宠物头像云存储URL"
    },
    "weight": {
      "bsonType": "array",
      "description": "体重历史记录",
      "items": {
        "bsonType": "object",
        "properties": {
          "value": {
            "bsonType": "double",
            "description": "体重kg"
          },
          "date": {
            "bsonType": "timestamp"
          },
          "note": {
            "bsonType": "string"
          }
        }
      }
    },
    "neutered": {
      "bsonType": "bool",
      "default": false
    },
    "allergies": {
      "bsonType": "array",
      "items": {
        "bsonType": "string"
      }
    },
    "chronicDiseases": {
      "bsonType": "array",
      "items": {
        "bsonType": "string"
      }
    },
    "emergencyContact": {
      "bsonType": "object",
      "properties": {
        "name": {
          "bsonType": "string"
        },
        "phone": {
          "bsonType": "string"
        }
      }
    },
    "status": {
      "bsonType": "string",
      "enum": ["active", "archived"],
      "default": "active"
    },
    "createdAt": {
      "bsonType": "timestamp",
      "defaultValue": {
        "$env": "now"
      }
    },
    "updatedAt": {
      "bsonType": "timestamp",
      "defaultValue": {
        "$env": "now"
      }
    }
  }
}

3. health_records.schema.json

{
  "bsonType": "object",
  "required": ["pet_id", "user_id", "type", "date", "createdAt"],
  "permission": {
    "read": "doc.user_id == auth.uid",
    "create": "auth.uid != null",
    "update": "doc.user_id == auth.uid",
    "delete": "doc.user_id == auth.uid"
  },
  "properties": {
    "_id": {
      "bsonType": "string"
    },
    "pet_id": {
      "bsonType": "string",
      "foreignKey": "pets._id"
    },
    "user_id": {
      "bsonType": "string",
      "foreignKey": "users._id"
    },
    "type": {
      "bsonType": "string",
      "enum": ["vaccine", "deworm", "medical", "checkup", "other"],
      "description": "记录类型:疫苗/驱虫/病历/体检/其他"
    },
    "title": {
      "bsonType": "string",
      "description": "记录标题"
    },
    "vaccineName": {
      "bsonType": "string",
      "description": "疫苗名称"
    },
    "batchNumber": {
      "bsonType": "string",
      "description": "疫苗批次号"
    },
    "dewormType": {
      "bsonType": "string",
      "enum": ["internal", "external", "both"],
      "description": "驱虫类型"
    },
    "brand": {
      "bsonType": "string",
      "description": "品牌(驱虫药/疫苗品牌)"
    },
    "hospital": {
      "bsonType": "string",
      "description": "医院名称"
    },
    "doctor": {
      "bsonType": "string"
    },
    "date": {
      "bsonType": "timestamp",
      "description": "发生日期"
    },
    "nextDate": {
      "bsonType": "timestamp",
      "description": "下次提醒日期"
    },
    "description": {
      "bsonType": "string",
      "description": "描述/主诉"
    },
    "diagnosis": {
      "bsonType": "string",
      "description": "诊断结果"
    },
    "medications": {
      "bsonType": "array",
      "items": {
        "bsonType": "object",
        "properties": {
          "name": {
            "bsonType": "string"
          },
          "dosage": {
            "bsonType": "string"
          },
          "frequency": {
            "bsonType": "string"
          }
        }
      }
    },
    "images": {
      "bsonType": "array",
      "description": "云存储图片URL数组",
      "items": {
        "bsonType": "string"
      }
    },
    "cost": {
      "bsonType": "double",
      "description": "费用"
    },
    "tags": {
      "bsonType": "array",
      "items": {
        "bsonType": "string"
      }
    },
    "createdAt": {
      "bsonType": "timestamp",
      "defaultValue": {
        "$env": "now"
      }
    },
    "updatedAt": {
      "bsonType": "timestamp",
      "defaultValue": {
        "$env": "now"
      }
    }
  }
}

4. schedules.schema.json

{
  "bsonType": "object",
  "required": ["pet_id", "user_id", "title", "scheduledDate", "createdAt"],
  "permission": {
    "read": "doc.user_id == auth.uid",
    "create": "auth.uid != null",
    "update": "doc.user_id == auth.uid",
    "delete": "doc.user_id == auth.uid"
  },
  "properties": {
    "_id": {
      "bsonType": "string"
    },
    "pet_id": {
      "bsonType": "string",
      "foreignKey": "pets._id"
    },
    "user_id": {
      "bsonType": "string",
      "foreignKey": "users._id"
    },
    "type": {
      "bsonType": "string",
      "enum": ["vaccine", "deworm", "checkup", "custom"],
      "description": "日程类型"
    },
    "title": {
      "bsonType": "string",
      "description": "提醒标题"
    },
    "scheduledDate": {
      "bsonType": "timestamp",
      "description": "计划日期"
    },
    "frequency": {
      "bsonType": "string",
      "enum": ["once", "daily", "weekly", "monthly", "yearly"],
      "default": "once"
    },
    "interval": {
      "bsonType": "int",
      "default": 1,
      "description": "每N个frequency"
    },
    "status": {
      "bsonType": "string",
      "enum": ["pending", "completed", "overdue", "skipped"],
      "default": "pending"
    },
    "reminderDays": {
      "bsonType": "array",
      "items": {
        "bsonType": "int"
      },
      "default": [7, 3, 1],
      "description": "提前提醒天数"
    },
    "relatedRecordId": {
      "bsonType": "string",
      "foreignKey": "health_records._id",
      "description": "关联的健康记录"
    },
    "note": {
      "bsonType": "string"
    },
    "lastRemindedAt": {
      "bsonType": "timestamp",
      "description": "上次提醒时间"
    },
    "createdAt": {
      "bsonType": "timestamp",
      "defaultValue": {
        "$env": "now"
      }
    },
    "updatedAt": {
      "bsonType": "timestamp",
      "defaultValue": {
        "$env": "now"
      }
    }
  }
}

5. hospitals.schema.json

{
  "bsonType": "object",
  "required": ["name", "location", "createdAt"],
  "permission": {
    "read": true,
    "create": "auth.uid != null",
    "update": false,
    "delete": false
  },
  "properties": {
    "_id": {
      "bsonType": "string"
    },
    "name": {
      "bsonType": "string",
      "description": "医院名称"
    },
    "address": {
      "bsonType": "string"
    },
    "location": {
      "bsonType": "object",
      "description": "GeoJSON Point",
      "properties": {
        "type": {
          "bsonType": "string",
          "enum": ["Point"],
          "default": "Point"
        },
        "coordinates": {
          "bsonType": "array",
          "items": {
            "bsonType": "double"
          },
          "description": "[longitude, latitude]"
        }
      }
    },
    "phone": {
      "bsonType": "string"
    },
    "hours": {
      "bsonType": "string",
      "description": "营业时间"
    },
    "is24Hour": {
      "bsonType": "bool",
      "default": false
    },
    "tags": {
      "bsonType": "array",
      "items": {
        "bsonType": "string"
      },
      "description": "特色标签"
    },
    "rating": {
      "bsonType": "double",
      "minimum": 0,
      "maximum": 5
    },
    "reviewCount": {
      "bsonType": "int",
      "default": 0
    },
    "verified": {
      "bsonType": "bool",
      "default": false,
      "description": "是否官方认证"
    },
    "status": {
      "bsonType": "string",
      "enum": ["active", "pending", "rejected"],
      "default": "pending"
    },
    "createdAt": {
      "bsonType": "timestamp",
      "defaultValue": {
        "$env": "now"
      }
    }
  }
}

6. articles.schema.json养宠百科

{
  "bsonType": "object",
  "required": ["title", "category", "status", "createdAt"],
  "permission": {
    "read": true,
    "create": false,
    "update": false,
    "delete": false
  },
  "properties": {
    "_id": {
      "bsonType": "string"
    },
    "title": {
      "bsonType": "string"
    },
    "category": {
      "bsonType": "string",
      "enum": ["vaccine", "deworm", "disease", "nutrition", "behavior", "other"]
    },
    "petType": {
      "bsonType": "string",
      "enum": ["cat", "dog", "all"],
      "default": "all"
    },
    "cover": {
      "bsonType": "string"
    },
    "content": {
      "bsonType": "string",
      "description": "富文本内容"
    },
    "summary": {
      "bsonType": "string"
    },
    "tags": {
      "bsonType": "array",
      "items": {
        "bsonType": "string"
      }
    },
    "viewCount": {
      "bsonType": "int",
      "default": 0
    },
    "status": {
      "bsonType": "string",
      "enum": ["published", "draft"],
      "default": "draft"
    },
    "createdAt": {
      "bsonType": "timestamp",
      "defaultValue": {
        "$env": "now"
      }
    }
  }
}

二、核心页面前端组件结构uni-app

目录结构

uni-app-project/
├── pages/
│   ├── index/
│   │   └── index.vue              # 首页Dashboard
│   ├── pet/
│   │   ├── list.vue               # 宠物列表/切换
│   │   ├── detail.vue             # 宠物档案详情
│   │   ├── edit.vue               # 创建/编辑宠物
│   │   └── weight.vue             # 体重记录
│   ├── record/
│   │   ├── list.vue               # 健康记录列表(时间轴)
│   │   ├── detail.vue             # 记录详情
│   │   ├── edit.vue               # 创建/编辑记录
│   │   └── type-select.vue        # 记录类型选择
│   ├── schedule/
│   │   ├── list.vue               # 日程列表
│   │   └── edit.vue               # 创建/编辑提醒
│   ├── discovery/
│   │   ├── index.vue              # 发现页(医院+百科)
│   │   ├── hospital-list.vue      # 附近医院列表
│   │   ├── hospital-detail.vue    # 医院详情
│   │   ├── article-list.vue       # 百科列表
│   │   └── article-detail.vue     # 百科详情
│   └── mine/
│       ├── index.vue              # 我的页面
│       ├── settings.vue           # 设置
│       └── member.vue             # 会员中心
├── components/
│   ├── pet-selector.vue           # 顶部宠物切换器
│   ├── schedule-card.vue          # 日程卡片
│   ├── record-timeline.vue        # 时间轴组件
│   ├── weight-chart.vue           # 体重曲线图echarts/uni-chart
│   ├── quick-action.vue           # 底部快速操作栏
│   ├── empty-state.vue            # 空状态插画
│   └── share-card.vue             # 分享卡片生成
├── static/
│   └── images/                    # 静态资源
├── uniCloud/
│   └── cloudfunctions/            # 云函数
└── App.vue

关键页面组件详解

1. 首页 pages/index/index.vue

<template>
  <view class="container">
    <!-- 顶部欢迎语 -->
    <view class="header">
      <text class="greeting">{{ greeting }}铲屎官</text>
      <image class="avatar" :src="userInfo.avatarUrl" />
    </view>
    
    <!-- 宠物切换器 -->
    <pet-selector 
      :pets="petList" 
      :current="currentPetId"
      @change="onPetChange"
      @add="goToAddPet"
    />
    
    <!-- 今日日程看板 -->
    <view class="section">
      <view class="section-title">
        <text>📅 今日日程</text>
        <text class="more" @click="goToSchedule">查看全部</text>
      </view>
      
      <view v-if="todaySchedules.length > 0">
        <schedule-card 
          v-for="item in todaySchedules" 
          :key="item._id"
          :data="item"
          :pet="getPetById(item.pet_id)"
          @complete="onComplete(item)"
          @postpone="onPostpone(item)"
        />
      </view>
      <empty-state v-else text="今天可以安心撸猫啦~" />
    </view>
    
    <!-- 宠物健康概览 -->
    <view class="section" v-if="currentPet">
      <view class="section-title">
        <text>📊 {{ currentPet.name }} 健康概览</text>
      </view>
      <view class="overview-grid">
        <view class="overview-item" @click="goToWeight">
          <text class="value">{{ latestWeight }}kg</text>
          <text class="label">最新体重</text>
        </view>
        <view class="overview-item" @click="goToRecords('vaccine')">
          <text class="value">{{ nextVaccineDays }}天</text>
          <text class="label">下次疫苗</text>
        </view>
        <view class="overview-item" @click="goToRecords('deworm')">
          <text class="value">{{ nextDewormDays }}天</text>
          <text class="label">下次驱虫</text>
        </view>
      </view>
    </view>
    
    <!-- 快速操作 -->
    <quick-action @action="onQuickAction" />
  </view>
</template>

<script>
export default {
  data() {
    return {
      userInfo: {},
      petList: [],
      currentPetId: '',
      todaySchedules: [],
      latestWeight: 0,
      nextVaccineDays: '--',
      nextDewormDays: '--'
    }
  },
  computed: {
    currentPet() {
      return this.petList.find(p => p._id === this.currentPetId)
    },
    greeting() {
      const hour = new Date().getHours()
      if (hour < 12) return '早上好'
      if (hour < 18) return '下午好'
      return '晚上好'
    }
  },
  onShow() {
    this.loadData()
  },
  methods: {
    async loadData() {
      // 并行加载数据
      const [petsRes, scheduleRes] = await Promise.all([
        uniCloud.callFunction({ name: 'petCrud', data: { action: 'list' } }),
        uniCloud.callFunction({ name: 'scheduleManager', data: { action: 'today' } })
      ])
      this.petList = petsRes.result.data
      this.todaySchedules = scheduleRes.result.data
      if (this.petList.length > 0 && !this.currentPetId) {
        this.currentPetId = this.petList[0]._id
      }
      this.loadPetStats()
    },
    async loadPetStats() {
      if (!this.currentPetId) return
      // 加载体重、疫苗、驱虫统计
      const res = await uniCloud.callFunction({
        name: 'petCrud',
        data: { action: 'stats', petId: this.currentPetId }
      })
      const stats = res.result.data
      this.latestWeight = stats.latestWeight || 0
      this.nextVaccineDays = stats.nextVaccineDays || '--'
      this.nextDewormDays = stats.nextDewormDays || '--'
    },
    onPetChange(petId) {
      this.currentPetId = petId
      this.loadPetStats()
      uni.setStorageSync('currentPetId', petId)
    },
    goToAddPet() {
      uni.navigateTo({ url: '/pages/pet/edit' })
    },
    goToSchedule() {
      uni.navigateTo({ url: '/pages/schedule/list' })
    },
    goToWeight() {
      uni.navigateTo({ url: `/pages/pet/weight?petId=${this.currentPetId}` })
    },
    goToRecords(type) {
      uni.navigateTo({ url: `/pages/record/list?petId=${this.currentPetId}&type=${type}` })
    },
    onQuickAction(action) {
      const urls = {
        record: `/pages/record/type-select?petId=${this.currentPetId}`,
        schedule: `/pages/schedule/edit?petId=${this.currentPetId}`,
        hospital: '/pages/discovery/hospital-list'
      }
      uni.navigateTo({ url: urls[action] })
    }
  }
}
</script>

2. 宠物切换器组件 components/pet-selector.vue

<template>
  <scroll-view class="pet-selector" scroll-x>
    <view 
      v-for="pet in pets" 
      :key="pet._id"
      class="pet-item"
      :class="{ active: current === pet._id }"
      @click="$emit('change', pet._id)"
    >
      <image class="pet-avatar" :src="pet.avatar || '/static/default-pet.png'" />
      <text class="pet-name">{{ pet.name }}</text>
      <text class="pet-age">{{ calcAge(pet.birthday) }}</text>
    </view>
    <view class="pet-item add" @click="$emit('add')">
      <view class="add-icon">+</view>
      <text class="pet-name">添加</text>
    </view>
  </scroll-view>
</template>

<script>
export default {
  props: {
    pets: { type: Array, default: () => [] },
    current: String
  },
  methods: {
    calcAge(birthday) {
      if (!birthday) return '未知'
      // 计算年龄逻辑
      const birth = new Date(birthday)
      const now = new Date()
      const years = now.getFullYear() - birth.getFullYear()
      const months = now.getMonth() - birth.getMonth()
      if (years > 0) return `${years}岁`
      return `${Math.max(0, months)}个月`
    }
  }
}
</script>

3. 时间轴记录列表 pages/record/list.vue

<template>
  <view class="container">
    <view class="filter-bar">
      <text 
        v-for="tab in tabs" 
        :key="tab.value"
        :class="{ active: currentType === tab.value }"
        @click="currentType = tab.value"
      >
        {{ tab.label }}
      </text>
    </view>
    
    <view class="timeline">
      <view 
        v-for="(group, date) in groupedRecords" 
        :key="date"
        class="timeline-group"
      >
        <view class="timeline-date">{{ date }}</view>
        <view 
          v-for="record in group" 
          :key="record._id"
          class="timeline-item"
          @click="goToDetail(record._id)"
        >
          <view class="timeline-dot" :class="record.type"></view>
          <view class="timeline-content">
            <view class="timeline-header">
              <text class="title">{{ recordTitle(record) }}</text>
              <text class="pet-tag">{{ getPetName(record.pet_id) }}</text>
            </view>
            <text class="desc">{{ record.description || record.hospital }}</text>
            <view class="timeline-footer" v-if="record.nextDate">
              <text class="next">下次: {{ formatDate(record.nextDate) }}</text>
            </view>
          </view>
        </view>
      </view>
    </view>
    
    <uni-fab 
      horizontal="right"
      vertical="bottom"
      @trigger="onFabTrigger"
      :content="fabContent"
    />
  </view>
</template>

<script>
export default {
  data() {
    return {
      petId: '',
      currentType: 'all',
      tabs: [
        { label: '全部', value: 'all' },
        { label: '疫苗', value: 'vaccine' },
        { label: '驱虫', value: 'deworm' },
        { label: '病历', value: 'medical' }
      ],
      records: [],
      petMap: {}
    }
  },
  onLoad(options) {
    this.petId = options.petId || ''
    this.loadRecords()
  },
  computed: {
    groupedRecords() {
      // 按日期分组
      const groups = {}
      this.filteredRecords.forEach(r => {
        const date = this.formatDate(r.date, 'YYYY-MM-DD')
        if (!groups[date]) groups[date] = []
        groups[date].push(r)
      })
      return groups
    },
    filteredRecords() {
      if (this.currentType === 'all') return this.records
      return this.records.filter(r => r.type === this.currentType)
    },
    fabContent() {
      return [
        { text: '疫苗', iconPath: '/static/vaccine.png' },
        { text: '驱虫', iconPath: '/static/deworm.png' },
        { text: '病历', iconPath: '/static/medical.png' }
      ]
    }
  },
  methods: {
    async loadRecords() {
      const res = await uniCloud.callFunction({
        name: 'recordCrud',
        data: {
          action: 'list',
          petId: this.petId,
          type: this.currentType === 'all' ? '' : this.currentType
        }
      })
      this.records = res.result.data
      // 构建宠物名称映射
      this.petMap = {}
      this.records.forEach(r => {
        if (!this.petMap[r.pet_id]) {
          this.petMap[r.pet_id] = r.petName || '未知'
        }
      })
    },
    recordTitle(record) {
      const titles = {
        vaccine: record.vaccineName || '疫苗接种',
        deworm: `${record.brand || ''} 驱虫`,
        medical: record.diagnosis || '就诊记录',
        checkup: '体检'
      }
      return titles[record.type] || record.title
    },
    getPetName(petId) {
      return this.petMap[petId] || ''
    },
    goToDetail(id) {
      uni.navigateTo({ url: `/pages/record/detail?id=${id}` })
    },
    onFabTrigger(e) {
      const types = ['vaccine', 'deworm', 'medical']
      uni.navigateTo({
        url: `/pages/record/edit?type=${types[e.index]}&petId=${this.petId}`
      })
    },
    formatDate(date, fmt = 'YYYY-MM-DD') {
      // 日期格式化工具
      const d = new Date(date)
      const o = {
        'Y+': d.getFullYear(),
        'M+': d.getMonth() + 1,
        'D+': d.getDate()
      }
      let str = fmt
      for (let k in o) {
        str = str.replace(new RegExp(k), match => {
          const v = o[k]
          return match.length > 1 ? (v < 10 ? '0' + v : v) : v
        })
      }
      return str
    }
  }
}
</script>

三、云函数代码框架

uniCloud/cloudfunctions/ 目录下创建以下云函数。

1. login微信登录

// uniCloud/cloudfunctions/login/index.js
const db = uniCloud.database()

exports.main = async (event, context) => {
  const { code, userInfo } = event
  
  try {
    // 1. 获取openid
    const loginRes = await uniCloud.getWXContext()
    const { OPENID, UNIONID } = loginRes
    
    if (!OPENID) {
      return { code: -1, message: '获取openid失败' }
    }
    
    // 2. 查询或创建用户
    const userCollection = db.collection('users')
    let user = await userCollection.where({ wx_openid: OPENID }).get()
    
    let userId
    if (user.data.length === 0) {
      // 新用户
      const createRes = await userCollection.add({
        wx_openid: OPENID,
        wx_unionid: UNIONID || '',
        nickName: userInfo?.nickName || '',
        avatarUrl: userInfo?.avatarUrl || '',
        phone: '',
        membership: {
          type: 'free',
          expireAt: null
        },
        settings: {
          reminderEnabled: true
        },
        createdAt: Date.now(),
        updatedAt: Date.now()
      })
      userId = createRes.id
    } else {
      // 更新用户信息
      userId = user.data[0]._id
      await userCollection.doc(userId).update({
        nickName: userInfo?.nickName || user.data[0].nickName,
        avatarUrl: userInfo?.avatarUrl || user.data[0].avatarUrl,
        updatedAt: Date.now()
      })
    }
    
    // 3. 生成自定义登录token可选用于后续请求鉴权
    const token = await uniCloud.createToken({
      uid: userId
    })
    
    return {
      code: 0,
      message: '登录成功',
      data: {
        uid: userId,
        token,
        isNewUser: user.data.length === 0
      }
    }
  } catch (err) {
    return { code: -1, message: err.message }
  }
}

2. petCrud宠物增删改查

// uniCloud/cloudfunctions/petCrud/index.js
const db = uniCloud.database()
const $ = db.command.aggregate

exports.main = async (event, context) => {
  const { action, data, petId } = event
  const { uid } = context // 通过uni-id获取当前用户ID
  
  const petCollection = db.collection('pets')
  
  try {
    switch (action) {
      case 'create': {
        const res = await petCollection.add({
          ...data,
          user_id: uid,
          createdAt: Date.now(),
          updatedAt: Date.now()
        })
        return { code: 0, data: { id: res.id } }
      }
      
      case 'list': {
        const res = await petCollection.where({
          user_id: uid,
          status: 'active'
        }).orderBy('createdAt', 'desc').get()
        return { code: 0, data: res.data }
      }
      
      case 'detail': {
        const res = await petCollection.doc(petId).get()
        if (res.data[0].user_id !== uid) {
          return { code: 403, message: '无权限' }
        }
        return { code: 0, data: res.data[0] }
      }
      
      case 'update': {
        const pet = await petCollection.doc(petId).get()
        if (pet.data[0].user_id !== uid) {
          return { code: 403, message: '无权限' }
        }
        await petCollection.doc(petId).update({
          ...data,
          updatedAt: Date.now()
        })
        return { code: 0, message: '更新成功' }
      }
      
      case 'delete': {
        // 软删除
        await petCollection.doc(petId).update({
          status: 'archived',
          updatedAt: Date.now()
        })
        return { code: 0, message: '删除成功' }
      }
      
      case 'stats': {
        // 获取宠物统计信息
        const pet = await petCollection.doc(petId).get()
        const petData = pet.data[0]
        
        // 最新体重
        const latestWeight = petData.weight?.length > 0 
          ? petData.weight[petData.weight.length - 1].value 
          : 0
        
        // 查询下次疫苗和驱虫从schedules表
        const scheduleCollection = db.collection('schedules')
        const now = Date.now()
        
        const nextVaccine = await scheduleCollection.where({
          pet_id: petId,
          user_id: uid,
          type: 'vaccine',
          status: 'pending',
          scheduledDate: db.command.gte(now)
        }).orderBy('scheduledDate', 'asc').limit(1).get()
        
        const nextDeworm = await scheduleCollection.where({
          pet_id: petId,
          user_id: uid,
          type: 'deworm',
          status: 'pending',
          scheduledDate: db.command.gte(now)
        }).orderBy('scheduledDate', 'asc').limit(1).get()
        
        const calcDays = (date) => {
          if (!date) return '--'
          const days = Math.ceil((date - now) / (1000 * 60 * 60 * 24))
          return days
        }
        
        return {
          code: 0,
          data: {
            latestWeight,
            nextVaccineDays: calcDays(nextVaccine.data[0]?.scheduledDate),
            nextDewormDays: calcDays(nextDeworm.data[0]?.scheduledDate)
          }
        }
      }
      
      default:
        return { code: -1, message: '未知操作' }
    }
  } catch (err) {
    return { code: -1, message: err.message }
  }
}

3. recordCrud健康记录增删改查

// uniCloud/cloudfunctions/recordCrud/index.js
const db = uniCloud.database()

exports.main = async (event, context) => {
  const { action, data, recordId, petId, type } = event
  const { uid } = context
  
  const recordCollection = db.collection('health_records')
  const scheduleCollection = db.collection('schedules')
  
  try {
    switch (action) {
      case 'create': {
        const recordData = {
          ...data,
          user_id: uid,
          createdAt: Date.now(),
          updatedAt: Date.now()
        }
        
        const res = await recordCollection.add(recordData)
        
        // 如果有nextDate自动创建日程提醒
        if (data.nextDate) {
          await scheduleCollection.add({
            pet_id: data.pet_id,
            user_id: uid,
            type: data.type,
            title: this.getScheduleTitle(data),
            scheduledDate: data.nextDate,
            status: 'pending',
            relatedRecordId: res.id,
            createdAt: Date.now()
          })
        }
        
        return { code: 0, data: { id: res.id } }
      }
      
      case 'list': {
        let where = { user_id: uid }
        if (petId) where.pet_id = petId
        if (type) where.type = type
        
        const res = await recordCollection.where(where)
          .orderBy('date', 'desc')
          .get()
        
        // 关联宠物名称
        const petIds = [...new Set(res.data.map(r => r.pet_id))]
        const petCollection = db.collection('pets')
        const pets = await petCollection.where({
          _id: db.command.in(petIds)
        }).get()
        const petMap = {}
        pets.data.forEach(p => petMap[p._id] = p.name)
        
        const records = res.data.map(r => ({
          ...r,
          petName: petMap[r.pet_id] || '未知'
        }))
        
        return { code: 0, data: records }
      }
      
      case 'detail': {
        const res = await recordCollection.doc(recordId).get()
        if (res.data[0].user_id !== uid) {
          return { code: 403, message: '无权限' }
        }
        return { code: 0, data: res.data[0] }
      }
      
      case 'update': {
        const record = await recordCollection.doc(recordId).get()
        if (record.data[0].user_id !== uid) {
          return { code: 403, message: '无权限' }
        }
        
        await recordCollection.doc(recordId).update({
          ...data,
          updatedAt: Date.now()
        })
        
        // 同步更新关联的schedule
        if (data.nextDate && record.data[0].relatedRecordId) {
          await scheduleCollection.where({
            relatedRecordId: recordId
          }).update({
            scheduledDate: data.nextDate,
            updatedAt: Date.now()
          })
        }
        
        return { code: 0, message: '更新成功' }
      }
      
      case 'delete': {
        const record = await recordCollection.doc(recordId).get()
        if (record.data[0].user_id !== uid) {
          return { code: 403, message: '无权限' }
        }
        
        await recordCollection.doc(recordId).remove()
        
        // 删除关联schedule
        if (record.data[0].relatedRecordId) {
          await scheduleCollection.where({
            relatedRecordId: recordId
          }).remove()
        }
        
        return { code: 0, message: '删除成功' }
      }
      
      default:
        return { code: -1, message: '未知操作' }
    }
  } catch (err) {
    return { code: -1, message: err.message }
  }
}

// 辅助方法
function getScheduleTitle(data) {
  const titles = {
    vaccine: `${data.vaccineName || '疫苗'}接种`,
    deworm: `${data.brand || ''}驱虫`,
    checkup: '体检',
    medical: '复查'
  }
  return titles[data.type] || '健康提醒'
}

4. scheduleManager日程管理

// uniCloud/cloudfunctions/scheduleManager/index.js
const db = uniCloud.database()

exports.main = async (event, context) => {
  const { action, data, scheduleId, petId } = event
  const { uid } = context
  
  const scheduleCollection = db.collection('schedules')
  const now = Date.now()
  
  try {
    switch (action) {
      case 'create': {
        const res = await scheduleCollection.add({
          ...data,
          user_id: uid,
          status: 'pending',
          createdAt: now,
          updatedAt: now
        })
        return { code: 0, data: { id: res.id } }
      }
      
      case 'list': {
        let where = { user_id: uid }
        if (petId) where.pet_id = petId
        
        const res = await scheduleCollection.where(where)
          .orderBy('scheduledDate', 'asc')
          .get()
        return { code: 0, data: res.data }
      }
      
      case 'today': {
        // 获取今日及逾期的日程
        const startOfDay = new Date().setHours(0, 0, 0, 0)
        const endOfDay = new Date().setHours(23, 59, 59, 999)
        
        const res = await scheduleCollection.where({
          user_id: uid,
          status: db.command.in(['pending', 'overdue']),
          scheduledDate: db.command.lte(endOfDay)
        }).orderBy('scheduledDate', 'asc').get()
        
        return { code: 0, data: res.data }
      }
      
      case 'upcoming': {
        // 获取未来7天的日程
        const endOfWeek = now + 7 * 24 * 60 * 60 * 1000
        
        const res = await scheduleCollection.where({
          user_id: uid,
          status: 'pending',
          scheduledDate: db.command.gte(now).and(db.command.lte(endOfWeek))
        }).orderBy('scheduledDate', 'asc').get()
        
        return { code: 0, data: res.data }
      }
      
      case 'complete': {
        await scheduleCollection.doc(scheduleId).update({
          status: 'completed',
          updatedAt: now
        })
        return { code: 0, message: '已完成' }
      }
      
      case 'postpone': {
        const { days } = data
        const schedule = await scheduleCollection.doc(scheduleId).get()
        const newDate = schedule.data[0].scheduledDate + days * 24 * 60 * 60 * 1000
        
        await scheduleCollection.doc(scheduleId).update({
          scheduledDate: newDate,
          status: 'pending',
          updatedAt: now
        })
        return { code: 0, message: `已推迟${days}天` }
      }
      
      case 'skip': {
        await scheduleCollection.doc(scheduleId).update({
          status: 'skipped',
          updatedAt: now
        })
        return { code: 0, message: '已忽略' }
      }
      
      case 'delete': {
        await scheduleCollection.doc(scheduleId).remove()
        return { code: 0, message: '删除成功' }
      }
      
      default:
        return { code: -1, message: '未知操作' }
    }
  } catch (err) {
    return { code: -1, message: err.message }
  }
}

5. reminderPush定时提醒触发器

// uniCloud/cloudfunctions/reminderPush/index.js
// 配置在uniCloud后台设置定时触发器每天上午9:00执行

const db = uniCloud.database()

exports.main = async (event, context) => {
  const now = Date.now()
  const today = new Date()
  today.setHours(0, 0, 0, 0)
  const tomorrow = today.getTime() + 24 * 60 * 60 * 1000
  
  try {
    const scheduleCollection = db.collection('schedules')
    const userCollection = db.collection('users')
    
    // 1. 标记逾期
    await scheduleCollection.where({
      status: 'pending',
      scheduledDate: db.command.lt(today)
    }).update({
      status: 'overdue',
      updatedAt: now
    })
    
    // 2. 查找今天需要提醒的日程
    // 提醒逻辑:在 reminderDays 中的某一天,且今天就是提醒日
    const schedules = await scheduleCollection.where({
      status: db.command.in(['pending', 'overdue']),
      scheduledDate: db.command.gte(today).and(db.command.lt(tomorrow + 7 * 24 * 60 * 60 * 1000))
    }).get()
    
    // 3. 按用户分组,批量发送订阅消息
    const userSchedules = {}
    schedules.data.forEach(s => {
      if (!userSchedules[s.user_id]) {
        userSchedules[s.user_id] = []
      }
      userSchedules[s.user_id].push(s)
    })
    
    for (const userId in userSchedules) {
      const user = await userCollection.doc(userId).get()
      const openid = user.data[0]?.wx_openid
      
      if (!openid) continue
      
      // 获取该用户今天需要提醒的日程
      const todaySchedules = userSchedules[userId].filter(s => {
        const scheduledDate = new Date(s.scheduledDate)
        scheduledDate.setHours(0, 0, 0, 0)
        const diffDays = Math.ceil((scheduledDate - today) / (24 * 60 * 60 * 1000))
        return s.reminderDays.includes(diffDays) && diffDays >= 0
      })
      
      if (todaySchedules.length === 0) continue
      
      // 构建订阅消息(需先在微信公众平台申请模板)
      const templateId = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' // 你的模板ID
      
      for (const schedule of todaySchedules) {
        try {
          await uniCloud.openapi.weixin.subscribeMessage.send({
            touser: openid,
            templateId: templateId,
            page: `pages/schedule/list?petId=${schedule.pet_id}`,
            data: {
              thing1: { value: schedule.title },           // 事项
              time2: { value: formatDate(schedule.scheduledDate) }, // 时间
              thing3: { value: schedule.status === 'overdue' ? '已逾期,请尽快处理' : '即将到期' } // 备注
            },
            miniprogramState: 'formal'
          })
          
          // 更新最后提醒时间
          await scheduleCollection.doc(schedule._id).update({
            lastRemindedAt: now
          })
        } catch (err) {
          console.error(`发送失败: ${userId}`, err)
        }
      }
    }
    
    return {
      code: 0,
      message: '提醒处理完成',
      data: {
        totalChecked: schedules.data.length,
        notifiedUsers: Object.keys(userSchedules).length
      }
    }
  } catch (err) {
    return { code: -1, message: err.message }
  }
}

function formatDate(timestamp) {
  const d = new Date(timestamp)
  return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}日`
}

定时触发器配置(在 reminderPush 云函数的 package.json 中):

{
  "name": "reminderPush",
  "version": "1.0.0",
  "description": "每日健康提醒推送",
  "main": "index.js",
  "extensions": {
    "uni-cloud-jql": {}
  },
  "cloudfunction-config": {
    "concurrency": 1,
    "triggers": [
      {
        "name": "dailyReminder",
        "type": "timer",
        "config": "0 0 9 * * * *"
      }
    ]
  }
}

说明:0 0 9 * * * * 表示每天上午9:00执行UTC+8 时区)。

6. nearbyHospitals附近医院查询

// uniCloud/cloudfunctions/nearbyHospitals/index.js
const db = uniCloud.database()

exports.main = async (event, context) => {
  const { longitude, latitude, maxDistance = 5000, limit = 20 } = event
  // maxDistance 单位默认5km
  
  try {
    const hospitalCollection = db.collection('hospitals')
    
    const res = await hospitalCollection.where({
      status: 'active',
      location: db.command.geoNear({
        geometry: db.Geo.Point(longitude, latitude),
        maxDistance: maxDistance,
        distanceField: 'distance'
      })
    }).limit(limit).get()
    
    // 格式化距离
    const hospitals = res.data.map(h => ({
      ...h,
      distance: h.distance ? Math.round(h.distance) : null,
      distanceText: h.distance 
        ? (h.distance < 1000 ? `${Math.round(h.distance)}m` : `${(h.distance / 1000).toFixed(1)}km`)
        : '未知'
    }))
    
    return {
      code: 0,
      data: hospitals
    }
  } catch (err) {
    return { code: -1, message: err.message }
  }
}

四、关键配置补充

1. 地理位置索引(必须)

在 uniCloud 控制台 → 数据库 → hospitals 集合 → 索引管理,创建:

{
  "location": "2dsphere"
}

2. manifest.json 小程序配置

{
  "mp-weixin": {
    "appid": "你的小程序appid",
    "setting": {
      "urlCheck": false
    },
    "permission": {
      "scope.userLocation": {
        "desc": "你的位置信息将用于查找附近宠物医院"
      }
    },
    "requiredPrivateInfos": [
      "getLocation"
    ]
  }
}

3. 云存储目录规划

cloudstorage/
├── pet-avatars/          # 宠物头像
├── record-images/        # 病历/疫苗证照片
├── article-covers/       # 百科文章封面
└── share-cards/          # 生成的分享卡片

这套框架覆盖了 MVP 阶段 90% 的核心功能。建议你先实现 login → petCrud → recordCrud → scheduleManager → reminderPush 这个主线,跑通"创建宠物 → 记录疫苗 → 生成提醒 → 推送通知"的完整闭环,再逐步扩展发现页和会员功能。

需要我针对某个具体模块(如微信支付开通会员订阅消息授权引导图片上传云存储)继续展开吗?