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

1641 lines
42 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

以下是 **「毛球手账」** 的完整技术实现方案,包含数据库 Schema、页面规划和云函数框架。
---
# 一、数据库 SchemauniCloud DB Schema
`uniCloud/database/` 目录下创建以下 `.schema.json` 文件。
## 1. users.schema.json
```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
```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
```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
```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
```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养宠百科
```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`
```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`
```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`
```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微信登录
```javascript
// 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宠物增删改查
```javascript
// 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健康记录增删改查
```javascript
// 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日程管理
```javascript
// 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定时提醒触发器
```javascript
// 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` 中):
```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附近医院查询
```javascript
// 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` 集合 → 索引管理,创建:
```json
{
"location": "2dsphere"
}
```
## 2. manifest.json 小程序配置
```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** 这个主线,跑通"创建宠物 → 记录疫苗 → 生成提醒 → 推送通知"的完整闭环,再逐步扩展发现页和会员功能。
需要我针对某个具体模块(如**微信支付开通会员**、**订阅消息授权引导**、**图片上传云存储**)继续展开吗?