Browse Source

feat(fatloss-miniprogram): add remaining pages (ai-coach, profile/goal, weight-chart, food-query)

Sisyphus 3 ngày trước cách đây
mục cha
commit
e976e45800

+ 79 - 0
xiaofat-miniprogram/pages/ai-coach/index.vue

@@ -0,0 +1,79 @@
+<template>
+  <view class="chat-page">
+    <scroll-view class="chat-body" scroll-y :scroll-top="scrollTop">
+      <view v-for="(msg, idx) in messages" :key="idx" class="msg-item" :class="msg.role">
+        <view class="bubble">{{ msg.content }}</view>
+      </view>
+    </scroll-view>
+
+    <view class="chat-input">
+      <input class="input" v-model="inputText" placeholder="输入您的问题..." @confirm="send" confirm-type="send" />
+      <button class="send-btn" size="mini" @click="send">发送</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { aiCoachSend } from '../../utils/api.js'
+export default {
+  data() {
+    return {
+      inputText: '',
+      messages: [],
+      scrollTop: 0
+    }
+  },
+  onLoad() {
+    this.messages.push({
+      role: 'assistant',
+      content: '你好,我是你的减脂AI教练,请告诉我你的目标和当前情况,我会帮你制定合适的减脂计划。'
+    })
+  },
+  methods: {
+    scrollToBottom() {
+      setTimeout(() => {
+        this.scrollTop = this.messages.length * 1000
+      }, 100)
+    },
+    send() {
+      if (!this.inputText.trim()) return
+      var content = this.inputText.trim()
+      this.messages.push({ role: 'user', content: content })
+      this.inputText = ''
+      this.scrollToBottom()
+
+      var payload = {
+        content: content,
+        history: this.messages.slice(0, -1).map(m => ({ role: m.role, content: m.content }))
+      }
+      aiCoachSend(payload).then(res => {
+        if (res.data && res.data.response) {
+          this.messages.push({ role: 'assistant', content: res.data.response })
+        } else if (res.data && res.data.reply) {
+          this.messages.push({ role: 'assistant', content: res.data.reply })
+        } else {
+          this.messages.push({ role: 'assistant', content: '抱歉,我暂时无法回答这个问题。' })
+        }
+        this.scrollToBottom()
+      }).catch(() => {
+        this.messages.push({ role: 'assistant', content: '网络异常,请稍后重试。' })
+        this.scrollToBottom()
+      })
+    }
+  }
+}
+</script>
+
+<style>
+.chat-page { display: flex; flex-direction: column; height: 100vh; background: #f8f8f8; }
+.chat-body { flex: 1; padding: 30rpx; box-sizing: border-box; }
+.msg-item { margin-bottom: 30rpx; display: flex; }
+.msg-item.user { justify-content: flex-end; }
+.msg-item.assistant { justify-content: flex-start; }
+.bubble { max-width: 70%; padding: 20rpx 30rpx; border-radius: 16rpx; word-wrap: break-word; }
+.user .bubble { background: #F97316; color: #fff; }
+.assistant .bubble { background: #fff; color: #333; }
+.chat-input { display: flex; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #eee; align-items: center; }
+.input { flex: 1; height: 80rpx; padding: 0 20rpx; background: #f5f5f5; border-radius: 40rpx; margin-right: 20rpx; }
+.send-btn { background: #F97316; color: #fff; border-radius: 40rpx; }
+</style>

+ 48 - 0
xiaofat-miniprogram/pages/diet/food-query.vue

@@ -0,0 +1,48 @@
+<template>
+  <view class="query-page">
+    <view class="title">食物热量查询</view>
+    <view class="search-box">
+      <input class="input" v-model="keyword" placeholder="搜索食物(示例功能)" />
+      <button class="btn" size="mini" @click="search">搜索</button>
+    </view>
+    <view class="tip">* 此页为占位页,后续可接入公共食物库 API</view>
+    <view class="list" v-if="result.length > 0">
+      <view class="item" v-for="(item, idx) in result" :key="idx">
+        <text class="name">{{ item.name }}</text>
+        <text class="cal">{{ item.cal }} kcal/100g</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  data() {
+    return {
+      keyword: '',
+      result: []
+    }
+  },
+  methods: {
+    search() {
+      if (!this.keyword.trim()) return
+      this.result = [
+        { name: this.keyword.trim(), cal: 100 }
+      ]
+    }
+  }
+}
+</script>
+
+<style>
+.query-page { padding: 30rpx; }
+.title { font-size: 36rpx; font-weight: 600; margin-bottom: 30rpx; }
+.search-box { display: flex; margin-bottom: 30rpx; }
+.input { flex: 1; height: 80rpx; padding: 0 20rpx; background: #fff; border-radius: 40rpx; margin-right: 20rpx; }
+.btn { background: #F97316; color: #fff; border-radius: 40rpx; }
+.tip { font-size: 24rpx; color: #999; margin-bottom: 30rpx; }
+.list { background: #fff; border-radius: 16rpx; }
+.item { display: flex; justify-content: space-between; padding: 30rpx; border-bottom: 1rpx solid #f5f5f5; }
+.name { font-weight: 600; }
+.cal { color: #F97316; }
+</style>

+ 169 - 0
xiaofat-miniprogram/pages/profile/index.vue

@@ -0,0 +1,169 @@
+<template>
+  <view class="profile-page">
+    <view class="header">
+      <text class="title">我的</text>
+    </view>
+
+    <view class="section" v-if="!activeGoal">
+      <view class="section-title">设置减脂目标</view>
+      <view class="form">
+        <view class="form-item">
+          <text class="label">起始体重(kg)</text>
+          <input class="input" type="digit" v-model="goal.startWeight" placeholder="请输入起始体重" />
+        </view>
+        <view class="form-item">
+          <text class="label">目标体重(kg)</text>
+          <input class="input" type="digit" v-model="goal.targetWeight" placeholder="请输入目标体重" />
+        </view>
+        <view class="form-item">
+          <text class="label">目标体脂率(%)</text>
+          <input class="input" type="digit" v-model="goal.targetBodyFat" placeholder="选填" />
+        </view>
+        <view class="form-item">
+          <text class="label">开始日期</text>
+          <picker mode="date" :value="goal.startDate" @change="onStartDateChange">
+            <view class="picker">{{ goal.startDate }}</view>
+          </picker>
+        </view>
+        <view class="form-item">
+          <text class="label">目标日期</text>
+          <picker mode="date" :value="goal.targetDate" @change="onTargetDateChange">
+            <view class="picker">{{ goal.targetDate }}</view>
+          </picker>
+        </view>
+      </view>
+      <button class="submit-btn" @click="createGoal">保存目标</button>
+    </view>
+
+    <view class="section" v-else>
+      <view class="section-title">当前目标</view>
+      <view class="goal-info">
+        <view class="info-row">
+          <text class="info-label">起始体重</text>
+          <text class="info-value">{{ activeGoal.startWeight }} kg</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">目标体重</text>
+          <text class="info-value">{{ activeGoal.targetWeight }} kg</text>
+        </view>
+        <view class="info-row" v-if="activeGoal.targetBodyFat">
+          <text class="info-label">目标体脂率</text>
+          <text class="info-value">{{ activeGoal.targetBodyFat }} %</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">开始日期</text>
+          <text class="info-value">{{ formatDate(activeGoal.startDate) }}</text>
+        </view>
+        <view class="info-row" v-if="activeGoal.targetDate">
+          <text class="info-label">目标日期</text>
+          <text class="info-value">{{ formatDate(activeGoal.targetDate) }}</text>
+        </view>
+      </view>
+      <view class="btn-group">
+        <button class="btn" size="mini" @click="complete">完成目标</button>
+        <button class="btn btn-abandon" size="mini" @click="abandon">放弃目标</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getActiveGoal, createGoal, completeGoal, abandonGoal } from '../../utils/api.js'
+import { today, formatDate } from '../../utils/format.js'
+export default {
+  data() {
+    return {
+      activeGoal: null,
+      goal: {
+        startWeight: '',
+        targetWeight: '',
+        targetBodyFat: '',
+        startDate: today(),
+        targetDate: today()
+      }
+    }
+  },
+  onShow() {
+    this.loadGoal()
+  },
+  methods: {
+    formatDate(date) {
+      return formatDate(date)
+    },
+    loadGoal() {
+      getActiveGoal().then(res => {
+        if (res.data) this.activeGoal = res.data
+      })
+    },
+    onStartDateChange(e) {
+      this.goal.startDate = e.detail.value
+    },
+    onTargetDateChange(e) {
+      this.goal.targetDate = e.detail.value
+    },
+    createGoal() {
+      if (!this.goal.startWeight || !this.goal.targetWeight) {
+        uni.showToast({ title: '请填写必填项', icon: 'none' })
+        return
+      }
+      var data = {
+        startWeight: this.goal.startWeight,
+        targetWeight: this.goal.targetWeight,
+        startDate: this.goal.startDate
+      }
+      if (this.goal.targetBodyFat) data.targetBodyFat = this.goal.targetBodyFat
+      if (this.goal.targetDate) data.targetDate = this.goal.targetDate
+      createGoal(data).then(() => {
+        uni.showToast({ title: '保存成功', icon: 'success' })
+        this.loadGoal()
+      })
+    },
+    complete() {
+      uni.showModal({
+        title: '提示',
+        content: '确定完成当前目标吗?',
+        success: (res) => {
+          if (res.confirm && this.activeGoal) {
+            completeGoal(this.activeGoal.id).then(() => {
+              this.loadGoal()
+            })
+          }
+        }
+      })
+    },
+    abandon() {
+      uni.showModal({
+        title: '提示',
+        content: '确定放弃当前目标吗?',
+        success: (res) => {
+          if (res.confirm && this.activeGoal) {
+            abandonGoal(this.activeGoal.id).then(() => {
+              this.loadGoal()
+            })
+          }
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style>
+.profile-page { padding: 30rpx; }
+.header { margin-bottom: 30rpx; }
+.title { font-size: 36rpx; font-weight: 600; }
+.section { background: #fff; padding: 30rpx; border-radius: 16rpx; margin-bottom: 30rpx; }
+.section-title { font-size: 32rpx; font-weight: 600; margin-bottom: 30rpx; }
+.form-item { display: flex; align-items: center; padding: 24rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.label { width: 220rpx; color: #333; }
+.input { flex: 1; text-align: right; }
+.picker { flex: 1; text-align: right; color: #333; }
+.submit-btn { background: #F97316; color: #fff; border-radius: 40rpx; margin-top: 30rpx; }
+.goal-info { margin-bottom: 30rpx; }
+.info-row { display: flex; justify-content: space-between; padding: 20rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.info-label { color: #666; }
+.info-value { color: #333; font-weight: 600; }
+.btn-group { display: flex; gap: 20rpx; }
+.btn { flex: 1; background: #F97316; color: #fff; border-radius: 40rpx; }
+.btn-abandon { background: #999; }
+</style>

+ 60 - 0
xiaofat-miniprogram/pages/weight/chart.vue

@@ -0,0 +1,60 @@
+<template>
+  <view class="chart-page">
+    <view class="title">体重趋势</view>
+    <view class="empty" v-if="list.length === 0">
+      <text>暂无数据,先去记录体重吧</text>
+    </view>
+    <view class="tip" v-else>体重变化趋势(最近记录)</view>
+    <view class="list">
+      <view class="item" v-for="item in sortedList" :key="item.id">
+        <text class="date">{{ format(item.measureDate || item.createTime) }}</text>
+        <text class="weight">{{ item.weight }} kg</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getWeightList } from '../../utils/api.js'
+import { formatDate } from '../../utils/format.js'
+export default {
+  data() {
+    return {
+      list: []
+    }
+  },
+  computed: {
+    sortedList() {
+      return [...this.list].sort((a,b) => {
+        var ta = new Date(a.measureDate || a.createTime).getTime()
+        var tb = new Date(b.measureDate || b.createTime).getTime()
+        return ta - tb
+      })
+    }
+  },
+  onShow() {
+    this.load()
+  },
+  methods: {
+    load() {
+      getWeightList().then(res => {
+        if (res.data) this.list = res.data
+      })
+    },
+    format(date) {
+      return formatDate(date)
+    }
+  }
+}
+</script>
+
+<style>
+.chart-page { padding: 30rpx; }
+.title { font-size: 36rpx; font-weight: 600; margin-bottom: 30rpx; }
+.empty { text-align: center; color: #999; padding: 60rpx; }
+.tip { color: #666; margin-bottom: 20rpx; }
+.list { background: #fff; border-radius: 16rpx; }
+.item { display: flex; justify-content: space-between; padding: 30rpx; border-bottom: 1rpx solid #f5f5f5; }
+.date { color: #666; }
+.weight { font-weight: 600; color: #F97316; }
+</style>