Răsfoiți Sursa

feat(diet): 饮食偏好调研 重构为3步引导问卷 - 数据层+进度条+Step1忌口过敏

iwt 3 săptămâni în urmă
părinte
comite
ec2c275290
1 a modificat fișierele cu 283 adăugiri și 226 ștergeri
  1. 283 226
      cfc-frontend/pages/diet/preferences.vue

+ 283 - 226
cfc-frontend/pages/diet/preferences.vue

@@ -1,170 +1,166 @@
 <template>
   <view class="preferences-page">
+    <!-- 顶部进度条 -->
+    <view class="progress-bar">
+      <view class="progress-track">
+        <view class="progress-fill" :style="'width:' + progressPercent + '%'"></view>
+      </view>
+      <view class="progress-labels">
+        <text class="progress-label" :class="{ 'progress-label-active': currentStep >= 1 }">忌口·过敏</text>
+        <text class="progress-label" :class="{ 'progress-label-active': currentStep >= 2 }">口味·偏好</text>
+        <text class="progress-label" :class="{ 'progress-label-active': currentStep >= 3 }">健康目标</text>
+      </view>
+    </view>
 
+    <!-- 步骤内容 -->
     <scroll-view class="content" scroll-y>
-      <!-- 硬性要求 -->
-      <view class="card">
-        <view class="card-accent"></view>
-        <view class="card-body">
-          <text class="card-title">硬性要求(必填)</text>
-          
-          <view class="form-group">
-            <text class="form-label">过敏原 *</text>
-            <view class="tag-group">
-              <view class="tag-btn" v-for="(tag, index) in allergies" :key="index" @tap="removeTag('allergies', index)">
-                <text>{{ tag }}</text>
-                <text class="tag-remove">×</text>
-              </view>
-            </view>
-            <view class="tag-input-row">
-              <input class="tag-input" placeholder="输入后回车添加" v-model="tagInput" @confirm="addTag('allergies')" />
-            </view>
+      <!-- ===== Step 1 忌口与过敏 ===== -->
+      <view v-if="currentStep === 1">
+        <view class="step-head">
+          <text class="step-icon">🚫</text>
+          <view class="step-head-text">
+            <text class="step-title">哪些食物绝对不能碰?</text>
+            <text class="step-sub">为了家人的安全,过敏原和忌口请务必填写</text>
           </view>
+        </view>
 
-          <view class="form-group">
-            <text class="form-label">绝对忌口 *</text>
-            <view class="tag-group">
-              <view class="tag-btn" v-for="(tag, index) in absoluteAvoid" :key="index" @tap="removeTag('absoluteAvoid', index)">
-                <text>{{ tag }}</text>
-                <text class="tag-remove">×</text>
-              </view>
-            </view>
-            <view class="tag-input-row">
-              <input class="tag-input" placeholder="输入后回车添加" v-model="tagInput" @confirm="addTag('absoluteAvoid')" />
+        <view class="card">
+          <text class="card-title">过敏原 *</text>
+          <view class="chip-group">
+            <view class="chip" v-for="(item, i) in presetAllergies" :key="getChipKey('a', i)"
+              :class="{ 'chip-active': allergies.indexOf(item) >= 0 }"
+              @tap="toggleChip('allergies', item)">
+              <text>{{ item }}</text>
             </view>
           </view>
-
-          <view class="form-group">
-            <text class="form-label">宗教饮食</text>
-            <view class="radio-group">
-              <view class="radio-item" :class="{'radio-active': religiousDiet === 'none'}" @tap="religiousDiet = 'none'">
-                <text>无限制</text>
-              </view>
-              <view class="radio-item" :class="{'radio-active': religiousDiet === 'halal'}" @tap="religiousDiet = 'halal'">
-                <text>清真</text>
-              </view>
-              <view class="radio-item" :class="{'radio-active': religiousDiet === 'vegetarian'}" @tap="religiousDiet = 'vegetarian'">
-                <text>素食</text>
-              </view>
-              <view class="radio-item" :class="{'radio-active': religiousDiet === 'vegan'}" @tap="religiousDiet = 'vegan'">
-                <text>纯素</text>
-              </view>
-            </view>
+          <view class="custom-row" v-if="customField === 'allergies'">
+            <input class="custom-input" placeholder="输入其他过敏原" v-model="customInput"
+              confirm-type="done" @confirm="addCustom" />
+            <view class="custom-confirm" @tap="addCustom"><text>添加</text></view>
+          </view>
+          <view class="custom-add" v-else @tap="openCustom('allergies')">
+            <text>+ 自定义添加</text>
           </view>
         </view>
-      </view>
 
-      <!-- 软性偏好 -->
-      <view class="card">
-        <view class="card-accent"></view>
-        <view class="card-body">
-          <text class="card-title">软性偏好(可选)</text>
-          
-          <view class="form-group">
-            <text class="form-label">辣度偏好</text>
-            <view class="slider-row">
-              <text class="slider-label">不辣</text>
-              <slider class="slider" min="0" max="5" :value="spiceLevel" @change="spiceLevel = $event.detail.value" show-value />
-              <text class="slider-label">特辣</text>
+        <view class="card">
+          <text class="card-title">绝对忌口 *</text>
+          <view class="chip-group">
+            <view class="chip" v-for="(item, i) in presetAvoid" :key="getChipKey('v', i)"
+              :class="{ 'chip-active': absoluteAvoid.indexOf(item) >= 0 }"
+              @tap="toggleChip('absoluteAvoid', item)">
+              <text>{{ item }}</text>
             </view>
           </view>
-
-          <view class="form-group">
-            <text class="form-label">口味偏好</text>
-            <view class="tag-group">
-              <view class="tag-btn" v-for="(tag, index) in flavorPref" :key="index" @tap="removeTag('flavorPref', index)">
-                <text>{{ tag }}</text>
-                <text class="tag-remove">×</text>
-              </view>
-            </view>
-            <view class="tag-input-row">
-              <input class="tag-input" placeholder="酸/甜/咸/鲜/苦" v-model="tagInput" @confirm="addTag('flavorPref')" />
-            </view>
+          <view class="custom-row" v-if="customField === 'absoluteAvoid'">
+            <input class="custom-input" placeholder="输入其他忌口" v-model="customInput"
+              confirm-type="done" @confirm="addCustom" />
+            <view class="custom-confirm" @tap="addCustom"><text>添加</text></view>
           </view>
-
-          <view class="form-group">
-            <text class="form-label">菜系偏好</text>
-            <view class="tag-group">
-              <view class="tag-btn" v-for="(tag, index) in cuisinePref" :key="index" @tap="removeTag('cuisinePref', index)">
-                <text>{{ tag }}</text>
-                <text class="tag-remove">×</text>
-              </view>
-            </view>
-            <view class="tag-input-row">
-              <input class="tag-input" placeholder="川/粤/淮扬/日料/西餐" v-model="tagInput" @confirm="addTag('cuisinePref')" />
-            </view>
+          <view class="custom-add" v-else @tap="openCustom('absoluteAvoid')">
+            <text>+ 自定义添加</text>
           </view>
         </view>
-      </view>
 
-      <!-- 健康目标 -->
-      <view class="card">
-        <view class="card-accent"></view>
-        <view class="card-body">
-          <text class="card-title">健康目标</text>
-          <text class="hint-text" v-if="systemGoals.length > 0">系统推荐目标(基于健康报告):</text>
-          <view class="tag-group" v-if="systemGoals.length > 0">
-            <view class="tag-btn system-tag" v-for="(goal, index) in systemGoals" :key="index">
-              <text>{{ goal }}</text>
-            </view>
-          </view>
-          <view class="form-group" style="margin-top: 20rpx;">
-            <text class="form-label">自定义目标</text>
-            <view class="tag-group">
-              <view class="tag-btn" v-for="(tag, index) in healthGoals" :key="index" @tap="removeTag('healthGoals', index)">
-                <text>{{ tag }}</text>
-                <text class="tag-remove">×</text>
-              </view>
-            </view>
-            <view class="tag-input-row">
-              <input class="tag-input" placeholder="减脂/增肌/控糖/肠道调理" v-model="tagInput" @confirm="addTag('healthGoals')" />
+        <view class="card">
+          <text class="card-title">宗教饮食</text>
+          <view class="chip-group">
+            <view class="chip" v-for="(opt, i) in religiousOptions" :key="getChipKey('r', i)"
+              :class="{ 'chip-active': religiousDiet === opt.value }"
+              @tap="religiousDiet = opt.value">
+              <text>{{ opt.label }}</text>
             </view>
           </view>
-          <view class="confirm-btn" @tap="confirmGoals">
-            <text>确认目标</text>
-          </view>
         </view>
       </view>
+      <!-- /Step 1 -->
 
-      <view class="submit-area">
-        <view class="submit-btn" @tap="savePreferences">
-          <text>{{ submitting ? '提交中...' : '保存偏好' }}</text>
-        </view>
-      </view>
+      <!-- Step 2、Step 3 在此追加(任务 2) -->
     </scroll-view>
+
+    <!-- 底部按钮栏 -->
+    <view class="footer-bar">
+      <view class="footer-btn footer-btn-ghost" v-if="currentStep > 1" @tap="goPrev">
+        <text>上一步</text>
+      </view>
+      <view class="footer-btn footer-btn-primary" @tap="goNext">
+        <text>{{ submitting ? '提交中...' : (isLastStep ? '完成' : '下一步') }}</text>
+      </view>
+    </view>
   </view>
 </template>
 
 <script>
 import { getDietPreferences, saveDietPreferences } from '@/utils/api.js'
 
+// 预设选项(常量,非响应式)
+const PRESET_ALLERGIES = ['海鲜', '花生', '乳糖', '鸡蛋', '牛奶', '豆类', '麸质', '坚果', '芒果', '其他']
+const PRESET_AVOID = ['牛肉', '羊肉', '猪肉', '内脏', '油腻', '辛辣', '生冷', '腌制']
+const PRESET_FLAVOR = ['酸', '甜', '咸', '鲜', '苦', '清淡', '浓郁']
+const PRESET_CUISINE = ['川', '粤', '鲁', '淮扬', '日料', '西餐', '东南亚', '韩餐']
+const PRESET_COOKING = ['蒸', '煮', '炒', '烤', '炖', '凉拌', '煎', '炸']
+const PRESET_MUSTEAT = ['蔬菜', '水果', '粗粮', '鱼类', '鸡蛋', '豆制品', '菌菇']
+const PRESET_GOALS = ['减脂', '增肌', '控糖', '肠道调理', '增强免疫']
+
 export default {
   data() {
     return {
       familyMemberId: null,
+      currentStep: 1,
+      // 数据字段(与后端 DTO 对齐)
       allergies: [],
       absoluteAvoid: [],
       religiousDiet: 'none',
       spiceLevel: 0,
       flavorPref: [],
       cuisinePref: [],
+      cookingMethods: [],
       healthGoals: [],
-      systemGoals: [],
-      tagInput: '',
-      submitting: false
+      mustEat: [],
+      goalConfirmed: 0,
+      // 自定义输入
+      customInput: '',
+      customField: null,
+      // UI 状态
+      submitting: false,
+      loading: true
     }
   },
-  onLoad() {
+  computed: {
+    // 预设选项映射(供模板 v-for)
+    presetAllergies: function() { return PRESET_ALLERGIES },
+    presetAvoid: function() { return PRESET_AVOID },
+    presetFlavor: function() { return PRESET_FLAVOR },
+    presetCuisine: function() { return PRESET_CUISINE },
+    presetCooking: function() { return PRESET_COOKING },
+    presetMustEat: function() { return PRESET_MUSTEAT },
+    presetGoals: function() { return PRESET_GOALS },
+    religiousOptions: function() {
+      return [
+        { value: 'none', label: '无限制' },
+        { value: 'halal', label: '清真' },
+        { value: 'vegetarian', label: '素食' },
+        { value: 'vegan', label: '纯素' }
+      ]
+    },
+    // 进度条百分比
+    progressPercent: function() {
+      var total = 3
+      var done = this.currentStep - 1
+      if (this.currentStep === 3) done = 3
+      return Math.round((done / total) * 100)
+    },
+    isLastStep: function() { return this.currentStep === 3 }
+  },
+  onLoad: function() {
     this.familyMemberId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId')
     this.loadPreferences()
   },
   methods: {
-    goBack() {
-      uni.navigateBack()
-    },
-    loadPreferences() {
+    loadPreferences: function() {
       var self = this
       getDietPreferences({ memberId: self.familyMemberId }).then(function(res) {
+        self.loading = false
         if (res) {
           self.allergies = res.allergies || []
           self.absoluteAvoid = res.absoluteAvoid || []
@@ -172,30 +168,63 @@ export default {
           self.spiceLevel = res.spiceLevel || 0
           self.flavorPref = res.flavorPref || []
           self.cuisinePref = res.cuisinePref || []
+          self.cookingMethods = res.cookingMethods || []
           self.healthGoals = res.healthGoals || []
-          self.systemGoals = res.systemGoals || []
+          self.mustEat = res.mustEat || []
+          self.goalConfirmed = res.goalConfirmed || 0
         }
+      }).catch(function() {
+        self.loading = false
       })
     },
-    addTag(field) {
-      var value = this.tagInput.trim()
-      if (value) {
+    // ——— chip 选中态(模板用内联 indexOf,方法供 toggle 用) ———
+    getChipKey: function(prefix, index) {
+      return prefix + '-' + index
+    },
+    toggleChip: function(field, value) {
+      var idx = this[field].indexOf(value)
+      if (idx >= 0) {
+        this[field].splice(idx, 1)
+      } else {
         this[field].push(value)
-        this.tagInput = ''
       }
     },
-    removeTag(field, index) {
-      this[field].splice(index, 1)
+    // ——— 自定义输入追加(@confirm 触发) ———
+    openCustom: function(field) {
+      this.customField = field
+      this.customInput = ''
     },
-    confirmGoals() {
-      this.savePreferences()
+    addCustom: function() {
+      var value = (this.customInput || '').trim()
+      if (!value) return
+      if (this[this.customField].indexOf(value) < 0) {
+        this[this.customField].push(value)
+      }
+      this.customInput = ''
+      this.customField = null
     },
-    savePreferences() {
-      var self = this
-      if (this.allergies.length === 0 && this.absoluteAvoid.length === 0) {
-        uni.showToast({ title: '请填写过敏原或忌口', icon: 'none' })
-        return
+    // ——— 步骤导航 ———
+    goNext: function() {
+      if (this.currentStep === 1) {
+        if (this.allergies.length === 0 && this.absoluteAvoid.length === 0) {
+          uni.showToast({ title: '请至少填写一项过敏原或忌口', icon: 'none' })
+          return
+        }
+      }
+      if (this.currentStep < 3) {
+        this.currentStep += 1
+      } else {
+        this.savePreferences()
+      }
+    },
+    goPrev: function() {
+      if (this.currentStep > 1) {
+        this.currentStep -= 1
       }
+    },
+    // ——— 提交 ———
+    savePreferences: function() {
+      var self = this
       this.submitting = true
       saveDietPreferences({
         allergies: this.allergies,
@@ -204,7 +233,9 @@ export default {
         spiceLevel: this.spiceLevel,
         flavorPref: this.flavorPref,
         cuisinePref: this.cuisinePref,
-        healthGoals: this.healthGoals.concat(this.systemGoals),
+        cookingMethods: this.cookingMethods,
+        healthGoals: this.healthGoals,
+        mustEat: this.mustEat,
         goalConfirmed: 1
       }).then(function(res) {
         self.submitting = false
@@ -224,144 +255,170 @@ export default {
 <style scoped>
 .preferences-page {
   min-height: 100vh;
-  background-color: #F5F7FA;
+  background-color: #FFF7ED;
   display: flex;
   flex-direction: column;
 }
-.nav-placeholder {
-  width: 80rpx;
+
+/* 进度条 */
+.progress-bar {
+  padding: 24rpx 30rpx 0;
+  background-color: #FFF7ED;
+}
+.progress-track {
+  height: 12rpx;
+  background-color: #E8E8E8;
+  border-radius: 6rpx;
+  overflow: hidden;
+}
+.progress-fill {
+  height: 100%;
+  background-color: #F97316;
+  border-radius: 6rpx;
+  transition: width 0.3s ease;
+}
+.progress-labels {
+  display: flex;
+  justify-content: space-between;
+  margin-top: 16rpx;
+  padding-bottom: 20rpx;
+}
+.progress-label {
+  font-size: 22rpx;
+  color: #BBB;
+  flex: 1;
+  text-align: center;
 }
+.progress-label-active {
+  color: #F97316;
+  font-weight: 600;
+}
+
+/* 内容区 */
 .content {
   flex: 1;
-  padding: 30rpx;
+  padding: 20rpx 30rpx 120rpx;
 }
-.card {
+
+/* 步骤标题 */
+.step-head {
   display: flex;
-  background-color: #FFFFFF;
-  border-radius: 20rpx;
-  margin-bottom: 30rpx;
-  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
-  overflow: hidden;
+  align-items: flex-start;
+  margin-bottom: 24rpx;
 }
-.card-accent {
-  width: 8rpx;
-  background-color: #FF8C42;
+.step-icon {
+  font-size: 48rpx;
+  margin-right: 16rpx;
   flex-shrink: 0;
 }
-.card-body {
+.step-head-text {
   flex: 1;
-  padding: 30rpx;
 }
-.card-title {
-  font-size: 30rpx;
-  font-weight: 600;
-  color: #333;
-  margin-bottom: 24rpx;
+.step-title {
+  font-size: 34rpx;
+  font-weight: 700;
+  color: #1A1A1A;
+  display: block;
+  margin-bottom: 8rpx;
+}
+.step-sub {
+  font-size: 24rpx;
+  color: #999;
   display: block;
 }
-.form-group {
+
+/* 卡片 */
+.card {
+  background-color: #FFFFFF;
+  border-radius: 20rpx;
+  padding: 28rpx 24rpx;
   margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
 }
-.form-label {
-  font-size: 26rpx;
+.card-title {
+  font-size: 28rpx;
+  font-weight: 600;
   color: #333;
-  font-weight: 500;
-  margin-bottom: 12rpx;
+  margin-bottom: 20rpx;
   display: block;
 }
-.hint-text {
-  font-size: 24rpx;
-  color: #999;
-  margin-bottom: 12rpx;
-  display: block;
-}
-.tag-group {
+
+/* chips */
+.chip-group {
   display: flex;
   flex-wrap: wrap;
   gap: 16rpx;
-  margin-bottom: 12rpx;
+  margin-bottom: 16rpx;
 }
-.tag-btn {
-  padding: 12rpx 20rpx;
-  border: 1rpx solid #ddd;
+.chip {
+  padding: 12rpx 24rpx;
+  border: 1rpx solid #E0E0E0;
   border-radius: 30rpx;
   font-size: 24rpx;
   color: #666;
   background-color: #FAFAFA;
-  display: flex;
-  align-items: center;
-  gap: 8rpx;
 }
-.tag-btn.system-tag {
+.chip-active {
+  border-color: #F97316;
+  color: #F97316;
   background-color: #FFF5EB;
-  border-color: #FF8C42;
-  color: #FF8C42;
 }
-.tag-remove {
-  font-size: 28rpx;
-  color: #999;
-  line-height: 1;
-}
-.tag-input-row {
-  margin-top: 12rpx;
+
+/* 自定义输入 */
+.custom-row {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  margin-bottom: 12rpx;
 }
-.tag-input {
-  border: 1rpx solid #ddd;
+.custom-input {
+  flex: 1;
+  height: 64rpx;
+  padding: 0 24rpx;
+  border: 1rpx solid #E0E0E0;
   border-radius: 30rpx;
-  padding: 12rpx 20rpx;
   font-size: 26rpx;
   background-color: #FAFAFA;
 }
-.radio-group {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 16rpx;
-}
-.radio-item {
-  padding: 12rpx 24rpx;
-  border: 1rpx solid #ddd;
+.custom-confirm {
+  padding: 12rpx 28rpx;
+  background-color: #F97316;
   border-radius: 30rpx;
-  font-size: 24rpx;
-  color: #666;
-  background-color: #FAFAFA;
+  font-size: 26rpx;
+  color: #fff;
 }
-.radio-active {
-  border-color: #FF8C42;
-  color: #FF8C42;
-  background-color: #FFF5EB;
+.custom-add {
+  font-size: 24rpx;
+  color: #F97316;
+  padding: 8rpx 0;
 }
-.slider-row {
+
+/* 底栏 */
+.footer-bar {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
   display: flex;
-  align-items: center;
+  padding: 20rpx 30rpx calc(20rpx + env(safe-area-inset-bottom));
+  background-color: #FFFFFF;
+  box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
   gap: 20rpx;
 }
-.slider-label {
-  font-size: 24rpx;
-  color: #666;
-  width: 60rpx;
-}
-.slider {
+.footer-btn {
   flex: 1;
-}
-.confirm-btn {
-  background-color: #E8F5E9;
-  color: #4CAF50;
   text-align: center;
-  padding: 20rpx 0;
-  border-radius: 12rpx;
-  font-size: 26rpx;
-  margin-top: 20rpx;
-}
-.submit-area {
-  padding: 40rpx 30rpx;
-}
-.submit-btn {
-  background-color: #FF8C42;
-  color: #FFFFFF;
-  text-align: center;
-  padding: 28rpx 0;
+  padding: 24rpx 0;
   border-radius: 16rpx;
   font-size: 30rpx;
   font-weight: 600;
 }
+.footer-btn-ghost {
+  background-color: #F5F5F5;
+  color: #999;
+}
+.footer-btn-primary {
+  background-color: #F97316;
+  color: #FFFFFF;
+}
 </style>