Parcourir la source

S-cfc-30 growth-history: 新增成长历史记录页,profile.vue跳转指向历史页

- 新增 pages/growth/history.vue:聚合展示饮食/运动/睡眠/心情打卡记录与成长档案记录,支持分类筛选、按时间倒序、空态提示、点击跳转详情
- pages.json 注册 pages/growth/history (navigationBarTitleText: 成长历史记录)
- profile.vue goToGrowth() 由跳成长首页改为跳成长历史记录页
E2E Test Bot il y a 6 jours
Parent
commit
da2727f53

+ 6 - 0
cfc-frontend/pages.json

@@ -594,6 +594,12 @@
           "style": {
             "navigationBarTitleText": "成长画像"
           }
+        },
+        {
+          "path": "history",
+          "style": {
+            "navigationBarTitleText": "成长历史记录"
+          }
         }
       ]
     },

+ 667 - 0
cfc-frontend/pages/growth/history.vue

@@ -0,0 +1,667 @@
+<template>
+  <view class="container" :style="{ '--font-scale': fontScale }">
+    <login-sheet ref="loginSheet" @success="onLoginSuccess" />
+    <view v-if="!isLoggedIn">
+      <view class="gg-page">
+        <view class="gg-hero">
+          <text class="gg-eyebrow">浠艾福 · 成长历史记录</text>
+          <text class="gg-hero-line gg-hero-line-top">看见改变</text>
+          <text class="gg-hero-line">正在发生</text>
+          <view class="gg-hero-bar"></view>
+          <text class="gg-hero-sub">记录不是目的,看见改变才是。每一份饮食、运动、睡眠、心情的记录,都在为成长提供依据。</text>
+        </view>
+        <view class="gg-guide">
+          <LoginGuideCard
+            title="登录后查看完整成长历史"
+            :items="[
+              '查看饮食、运动、睡眠、心情的所有打卡记录',
+              '查看成长档案测评记录',
+              '筛选分类、按时间排序'
+            ]" />
+        </view>
+      </view>
+    </view>
+
+    <view v-else>
+      <!-- 筛选栏 -->
+      <view class="filter-bar">
+        <view class="filter-tabs">
+          <view 
+            class="filter-tab" 
+            :class="{ active: currentFilter === 'all' }" 
+            @click="setFilter('all')"
+          >
+            全部
+          </view>
+          <view 
+            class="filter-tab" 
+            :class="{ active: currentFilter === 'diet' }" 
+            @click="setFilter('diet')"
+          >
+            🍽️ 饮食
+          </view>
+          <view 
+            class="filter-tab" 
+            :class="{ active: currentFilter === 'exercise' }" 
+            @click="setFilter('exercise')"
+          >
+            🏃 运动
+          </view>
+          <view 
+            class="filter-tab" 
+            :class="{ active: currentFilter === 'sleep' }" 
+            @click="setFilter('sleep')"
+          >
+            😴 睡眠
+          </view>
+          <view 
+            class="filter-tab" 
+            :class="{ active: currentFilter === 'emotion' }" 
+            @click="setFilter('emotion')"
+          >
+            😊 心情
+          </view>
+          <view 
+            class="filter-tab" 
+            :class="{ active: currentFilter === 'growth' }" 
+            @click="setFilter('growth')"
+          >
+            📈 成长档案
+          </view>
+        </view>
+      </view>
+
+      <!-- 记录列表 -->
+      <view class="records-section">
+        <view v-if="loading" class="loading-state">
+          <view class="loading-spinner"></view>
+          <text class="loading-text">加载中...</text>
+        </view>
+
+        <view v-else-if="filteredRecords.length === 0" class="empty-state">
+          <text class="empty-icon">📭</text>
+          <text class="empty-title">暂无记录</text>
+          <text class="empty-desc">{{ emptyMessage }}</text>
+        </view>
+
+        <view v-else class="records-list">
+          <view 
+            class="record-card" 
+            v-for="(record, index) in filteredRecords" 
+            :key="getRecordKey(record, index)"
+            @click="goToDetail(record)"
+          >
+            <view class="record-type-badge" :class="'type-' + record.type">{{ record.typeLabel }}</view>
+            <view class="record-content">
+              <view class="record-header">
+                <text class="record-title">{{ record.title }}</text>
+                <text class="record-date">{{ record.formattedDate }}</text>
+              </view>
+              <view class="record-meta" v-if="record.meta">
+                <text class="meta-item" v-for="(item, i) in record.meta" :key="i">
+                  {{ item }}
+                </text>
+              </view>
+              <view class="record-summary" v-if="record.summary">
+                {{ record.summary }}
+              </view>
+            </view>
+            <text class="record-chevron">›</text>
+          </view>
+        </view>
+
+        <!-- 加载更多 -->
+        <view class="load-more" v-if="!loading && hasMore && filteredRecords.length > 0" @click="loadMore">
+          <text class="load-more-text">加载更多</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import LoginGuideCard from '../../components/LoginGuideCard.vue'
+import { 
+  healthCheckinList, 
+  getHealthMealList, 
+  getExerciseRecords, 
+  getSleepRecords, 
+  getEmotionCheckinList, 
+  getGrowthRecordList,
+  getGrowthRecordDetail 
+} from '../../utils/api.js'
+import { parseDate, recordDateOf } from '../../utils/format.js'
+import fontScaleMixin from '@/mixins/font-scale.js'
+
+export default {
+  mixins: [fontScaleMixin],
+  components: { LoginGuideCard },
+  data() {
+    return {
+      memberId: '',
+      isLoggedIn: false,
+      currentFilter: 'all',
+      loading: false,
+      loadingMore: false,
+      page: 1,
+      pageSize: 20,
+      hasMore: true,
+      // 原始数据
+      allDietRecords: [],
+      allExerciseRecords: [],
+      allSleepRecords: [],
+      allEmotionRecords: [],
+      allGrowthRecords: [],
+      // 合并后的记录(用于显示)
+      mergedRecords: [],
+    }
+  },
+  computed: {
+    filteredRecords() {
+      if (this.currentFilter === 'all') {
+        return this.mergedRecords
+      }
+      return this.mergedRecords.filter(r => r.type === this.currentFilter)
+    },
+    emptyMessage() {
+      const messages = {
+        all: '暂无任何成长记录,快去打卡吧!',
+        diet: '暂无饮食记录',
+        exercise: '暂无运动记录',
+        sleep: '暂无睡眠记录',
+        emotion: '暂无心情记录',
+        growth: '暂无成长档案记录'
+      }
+      return messages[this.currentFilter] || messages.all
+    }
+  },
+  onLoad() {
+    // 初始化时不加载,等 onShow
+  },
+  onShow() {
+    this.isLoggedIn = !!uni.getStorageSync('token')
+    if (this.isLoggedIn) {
+      this.memberId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId') || ''
+      if (!this._dataLoaded) {
+        this._dataLoaded = true
+        this.loadAllData()
+      }
+    }
+  },
+  methods: {
+    setFilter(filter) {
+      this.currentFilter = filter
+    },
+    async loadAllData() {
+      this.loading = true
+      try {
+        // 并行加载所有类型的记录
+        await Promise.all([
+          this.loadDietRecords(),
+          this.loadExerciseRecords(),
+          this.loadSleepRecords(),
+          this.loadEmotionRecords(),
+          this.loadGrowthRecords()
+        ])
+        this.mergeAndSortRecords()
+      } catch (e) {
+        console.error('加载成长历史失败', e)
+        uni.showToast({ title: '加载失败', icon: 'none' })
+      } finally {
+        this.loading = false
+      }
+    },
+    async loadDietRecords() {
+      try {
+        const res = await getHealthMealList(this.memberId ? { memberId: this.memberId } : {})
+        if (res.code === 200 && res.data) {
+          this.allDietRecords = res.data.map(item => this.formatDietRecord(item))
+        }
+      } catch (e) {
+        console.error('加载饮食记录失败', e)
+      }
+    },
+    async loadExerciseRecords() {
+      try {
+        const res = await getExerciseRecords(this.memberId ? { memberId: this.memberId } : {})
+        if (res.code === 200 && res.data) {
+          this.allExerciseRecords = res.data.map(item => this.formatExerciseRecord(item))
+        }
+      } catch (e) {
+        console.error('加载运动记录失败', e)
+      }
+    },
+    async loadSleepRecords() {
+      try {
+        const res = await getSleepRecords(this.memberId ? { memberId: this.memberId } : {})
+        if (res.code === 200 && res.data) {
+          this.allSleepRecords = res.data.map(item => this.formatSleepRecord(item))
+        }
+      } catch (e) {
+        console.error('加载睡眠记录失败', e)
+      }
+    },
+    async loadEmotionRecords() {
+      try {
+        const res = await getEmotionCheckinList({ page: 1, size: 100, memberId: this.memberId })
+        if (res.code === 200 && res.data) {
+          this.allEmotionRecords = res.data.map(item => this.formatEmotionRecord(item))
+        }
+      } catch (e) {
+        console.error('加载心情记录失败', e)
+      }
+    },
+    async loadGrowthRecords() {
+      try {
+        const res = await getGrowthRecordList()
+        if (res.code === 200 && res.data) {
+          this.allGrowthRecords = res.data.map(item => this.formatGrowthRecord(item))
+        }
+      } catch (e) {
+        console.error('加载成长档案失败', e)
+      }
+    },
+    formatDietRecord(item) {
+      const date = recordDateOf(item)
+      return {
+        id: item.id,
+        type: 'diet',
+        typeLabel: '饮食',
+        title: this.mealTypeLabel(item.mealType) || '饮食记录',
+        date: date,
+        formattedDate: date ? this.formatDisplayDate(date) : '',
+        meta: [
+          item.foods && item.foods.length > 0 ? `包含 ${item.foods.length} 种食物` : '',
+          item.totalCalories ? `约 ${item.totalCalories} kcal` : ''
+        ].filter(Boolean),
+        summary: item.remark || '',
+        rawData: item
+      }
+    },
+    formatExerciseRecord(item) {
+      const date = recordDateOf(item)
+      return {
+        id: item.id,
+        type: 'exercise',
+        typeLabel: '运动',
+        title: item.exerciseName || '运动记录',
+        date: date,
+        formattedDate: date ? this.formatDisplayDate(date) : '',
+        meta: [
+          item.duration ? `时长 ${item.duration} 分钟` : '',
+          item.calories ? `消耗 ${item.calories} kcal` : ''
+        ].filter(Boolean),
+        summary: item.remark || '',
+        rawData: item
+      }
+    },
+    formatSleepRecord(item) {
+      const date = recordDateOf(item)
+      return {
+        id: item.id,
+        type: 'sleep',
+        typeLabel: '睡眠',
+        title: '睡眠记录',
+        date: date,
+        formattedDate: date ? this.formatDisplayDate(date) : '',
+        meta: [
+          item.startTime && item.endTime ? `${this.formatTime(item.startTime)} - ${this.formatTime(item.endTime)}` : '',
+          item.duration ? `时长 ${this.formatDuration(item.duration)}` : '',
+          item.quality ? `质量 ${item.quality}` : ''
+        ].filter(Boolean),
+        summary: item.remark || '',
+        rawData: item
+      }
+    },
+    formatEmotionRecord(item) {
+      const date = recordDateOf(item)
+      return {
+        id: item.id,
+        type: 'emotion',
+        typeLabel: '心情',
+        title: item.emotionLabel || '心情记录',
+        date: date,
+        formattedDate: date ? this.formatDisplayDate(date) : '',
+        meta: [
+          item.intensity ? `强度 ${item.intensity}` : '',
+          item.tags && item.tags.length > 0 ? `标签: ${item.tags.join('、')}` : ''
+        ].filter(Boolean),
+        summary: item.note || '',
+        rawData: item
+      }
+    },
+    formatGrowthRecord(item) {
+      const date = item.reportDate || item.createdAt
+      return {
+        id: item.id,
+        type: 'growth',
+        typeLabel: '成长档案',
+        title: item.recordTitle || '成长档案',
+        date: date,
+        formattedDate: date ? this.formatDisplayDate(date) : '',
+        meta: [
+          item.overallScore != null ? `综合得分: ${item.overallScore}` : '',
+          item.sourceType ? `来源: ${this.sourceTypeLabel(item.sourceType)}` : ''
+        ].filter(Boolean),
+        summary: item.assessmentSummary || item.growthSuggestions || '',
+        rawData: item
+      }
+    },
+    mergeAndSortRecords() {
+      const allRecords = [
+        ...this.allDietRecords,
+        ...this.allExerciseRecords,
+        ...this.allSleepRecords,
+        ...this.allEmotionRecords,
+        ...this.allGrowthRecords
+      ]
+      // 按日期倒序排序
+      allRecords.sort((a, b) => {
+        if (!a.date && !b.date) return 0
+        if (!a.date) return 1
+        if (!b.date) return -1
+        return new Date(b.date) - new Date(a.date)
+      })
+      this.mergedRecords = allRecords
+    },
+    formatDisplayDate(dateStr) {
+      if (!dateStr) return ''
+      const d = parseDate(dateStr)
+      if (!d) return dateStr.substring(0, 10)
+      const month = d.getMonth() + 1
+      const day = d.getDate()
+      const hour = d.getHours()
+      const minute = d.getMinutes()
+      return `${month}月${day}日 ${this.pad(hour)}:${this.pad(minute)}`
+    },
+    formatTime(timeStr) {
+      if (!timeStr) return ''
+      const d = parseDate(timeStr)
+      if (!d) return timeStr.substring(11, 16)
+      return `${this.pad(d.getHours())}:${this.pad(d.getMinutes())}`
+    },
+    formatDuration(minutes) {
+      if (!minutes) return ''
+      const h = Math.floor(minutes / 60)
+      const m = minutes % 60
+      return h > 0 ? `${h}小时${m}分` : `${m}分钟`
+    },
+    mealTypeLabel(type) {
+      const m = { breakfast: '早餐', lunch: '午餐', dinner: '晚餐', snack: '加餐', extra: '零食' }
+      if (!type) return '饮食'
+      const parts = String(type).split(',')
+      const labels = []
+      for (let i = 0; i < parts.length; i++) {
+        const v = parts[i] && parts[i].trim()
+        if (!v) continue
+        labels.push(m[v] || v)
+      }
+      return labels.length > 0 ? labels.join('、') : '饮食'
+    },
+    sourceTypeLabel(type) {
+      const map = { manual: '手动创建', assessment: '测评录入', upload: '报告上传', supplement: '补充材料' }
+      return map[type] || type
+    },
+    pad(n) {
+      return n < 10 ? '0' + n : '' + n
+    },
+    getRecordKey(record, index) {
+      return record.id || ('idx-' + index)
+    },
+    goToDetail(record) {
+      if (!record) return
+      const type = record.type
+      const raw = record.rawData
+      let url = ''
+      
+      if (type === 'diet') {
+        url = '/pages/growth/diet-checkin?recordId=' + raw.id + '&viewOnly=1'
+      } else if (type === 'exercise') {
+        url = '/pages/growth/exercise-checkin?recordId=' + raw.id + '&viewOnly=1'
+      } else if (type === 'sleep') {
+        url = '/pages/growth/sleep-checkin?recordId=' + raw.id + '&viewOnly=1'
+      } else if (type === 'emotion') {
+        url = '/pages/mind-detail/emotion-checkin?recordId=' + raw.id + '&viewOnly=1'
+      } else if (type === 'growth') {
+        url = '/pages/growth/detail?recordId=' + raw.id
+      }
+      
+      if (url && this.memberId) {
+        url += (url.includes('?') ? '&' : '?') + 'memberId=' + this.memberId
+      }
+      
+      if (url) {
+        uni.navigateTo({ url })
+      }
+    },
+    async loadMore() {
+      // 暂时不实现分页加载,因为已经一次性加载了所有数据
+      uni.showToast({ title: '已加载全部记录', icon: 'none' })
+    },
+    onLoginSuccess() {
+      this.isLoggedIn = true
+      this.memberId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId') || ''
+      this._dataLoaded = false
+      this.loadAllData()
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.container {
+  background: $facade-brand-tint;
+  min-height: 100vh;
+  padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
+}
+
+// ==================== 游客态 ====================
+.gg-page { background: $app-bg; min-height: 100vh; padding: 28rpx 0 40rpx; }
+
+.gg-hero { margin: 0 32rpx 28rpx; display: flex; flex-direction: column; }
+.gg-eyebrow { font-size: calc(22rpx * var(--font-scale, 1)); font-weight: 600; color: $brand-primary; letter-spacing: 2rpx; }
+.gg-hero-line { font-size: calc(52rpx * var(--font-scale, 1)); font-weight: 700; color: $facade-text-strong; line-height: 1.28; }
+.gg-hero-line-top { margin-top: 16rpx; }
+.gg-hero-bar { width: 84rpx; height: 8rpx; border-radius: 999rpx; margin: 20rpx 0 18rpx; background: linear-gradient(135deg, $brand-primary, $brand-primary-dark); }
+.gg-hero-sub { font-size: calc(24rpx * var(--font-scale, 1)); color: $facade-text-body; line-height: 1.7; }
+
+.gg-guide { margin: 0 32rpx 8rpx; }
+
+// ==================== 已登录态 ====================
+.filter-bar {
+  position: sticky;
+  top: 0;
+  z-index: 100;
+  background: $app-surface;
+  border-bottom: 1rpx solid $app-border-subtle;
+  padding: 16rpx 24rpx;
+}
+
+.filter-tabs {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12rpx;
+  justify-content: flex-start;
+}
+
+.filter-tab {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  padding: 8rpx 16rpx;
+  border-radius: 20rpx;
+  background: $facade-fill-2;
+  color: $facade-text-body;
+  white-space: nowrap;
+  transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease;
+}
+
+.filter-tab.active {
+  background: linear-gradient(135deg, $brand-primary, $brand-primary-dark);
+  color: $app-surface;
+}
+
+.filter-tab:active {
+  opacity: 0.85;
+  transform: scale(0.98);
+}
+
+.records-section {
+  padding: 0 24rpx 40rpx;
+}
+
+.loading-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 80rpx 0;
+}
+
+.loading-spinner {
+  width: 48rpx;
+  height: 48rpx;
+  border: 3rpx solid $facade-fill-2;
+  border-top-color: $brand-primary;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+.loading-text {
+  margin-top: 20rpx;
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+}
+
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 100rpx 0;
+}
+
+.empty-icon {
+  font-size: calc(80rpx * var(--font-scale, 1));
+  margin-bottom: 20rpx;
+}
+
+.empty-title {
+  font-size: calc(30rpx * var(--font-scale, 1));
+  font-weight: 600;
+  color: $facade-text-strong;
+  margin-bottom: 10rpx;
+}
+
+.empty-desc {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+  text-align: center;
+}
+
+.records-list {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+
+.record-card {
+  display: flex;
+  flex-direction: row;
+  align-items: flex-start;
+  background: $app-surface;
+  border-radius: 20rpx;
+  padding: 20rpx;
+  border: 1rpx solid $app-border-subtle;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
+}
+
+.record-card:active {
+  opacity: 0.85;
+  background: $facade-fill-2;
+}
+
+.record-type-badge {
+  font-size: calc(20rpx * var(--font-scale, 1));
+  font-weight: 600;
+  padding: 4rpx 10rpx;
+  border-radius: 12rpx;
+  margin-right: 16rpx;
+  flex-shrink: 0;
+  margin-top: 4rpx;
+}
+
+.type-diet { background: $dim-body-tint; color: $dim-body-deep; }
+.type-exercise { background: $dim-action-tint; color: $dim-action-deep; }
+.type-sleep { background: $dim-wisdom-tint; color: $dim-wisdom-deep; }
+.type-emotion { background: $dim-mind-tint; color: $dim-mind-deep; }
+.type-growth { background: $accent-indigo-tint; color: $accent-indigo; }
+
+.record-content {
+  flex: 1;
+  min-width: 0;
+}
+
+.record-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  margin-bottom: 8rpx;
+}
+
+.record-title {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  font-weight: 600;
+  color: $facade-text-strong;
+  flex: 1;
+  margin-right: 12rpx;
+}
+
+.record-date {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+  flex-shrink: 0;
+  white-space: nowrap;
+}
+
+.record-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+  margin-bottom: 8rpx;
+}
+
+.meta-item {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-text-body;
+}
+
+.record-summary {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+  line-height: 1.5;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+}
+
+.record-chevron {
+  font-size: calc(32rpx * var(--font-scale, 1));
+  color: $facade-text-ghost;
+  margin-left: 12rpx;
+  flex-shrink: 0;
+}
+
+.load-more {
+  display: flex;
+  justify-content: center;
+  padding: 32rpx 0;
+}
+
+.load-more-text {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $brand-primary;
+}
+</style>

+ 1 - 1
cfc-frontend/pages/profile-main/profile.vue

@@ -833,7 +833,7 @@ export default {
       uni.navigateTo({ url: '/pages/rewards/badge' })
     },
     goToGrowth: function() {
-      uni.navigateTo({ url: '/pages/growth-main/index' })
+      uni.navigateTo({ url: '/pages/growth/history' })
     },
     goToChallenges: function() {
       uni.navigateTo({ url: '/pages/health/challenge-manage' })