Просмотр исходного кода

chore: auto bump version and changelog [skip ci]

iwt 1 месяц назад
Родитель
Сommit
89952dc55c

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -2973,6 +2973,12 @@ log.info("已添加template_id列到tasks表");
 		} catch (Exception e) {
 			log.warn("health_plans 审核字段添加失败(可能已存在): " + e.getMessage());
 		}
+		// 迁移251: health_plans 新增 teacher_id 列(用户指定规划师)
+		try {
+			ensureColumn("health_plans", "teacher_id", "BIGINT DEFAULT NULL COMMENT '指定规划师ID(用户选择)'");
+		} catch (Exception ex) {
+			log.warn("health_plans.teacher_id 添加失败(可能已存在): " + ex.getMessage());
+		}
         ensureColumn("file_record", "file_type", "VARCHAR(50) DEFAULT NULL COMMENT '文件类型: image/pdf/video/audio/other'");
         ensureColumn("file_record", "description", "TEXT COMMENT '文件描述'");
 

+ 14 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java

@@ -64,6 +64,7 @@ public class HealthPlanController {
         String memberName = (String) params.get("memberName");
         Long planId = ParamUtils.getLong(params.get("planId"));
         String status = (String) params.get("status");
+        Long teacherId = ParamUtils.getLong(params.get("teacherId"));
 
         if (familyId == null || dimensions == null || goal == null) {
             return Result.error("familyId, dimensions, goal不能为空");
@@ -77,6 +78,7 @@ public class HealthPlanController {
         plan.setPlanContent(planContent);
         plan.setPlanJson(planJson);
         plan.setMemberName(memberName);
+        plan.setTeacherId(teacherId);
         if (status != null) plan.setStatus(status);
         Long id = healthPlanService.savePlan(plan);
         return Result.success(id);
@@ -113,6 +115,16 @@ public class HealthPlanController {
         return Result.success(plan);
     }
 
+    @Operation(summary = "获取可用规划师列表")
+    @PostMapping("/available-teachers")
+    public Result<List<Map<String, Object>>> availableTeachers(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        if (familyId == null) return Result.error("familyId不能为空");
+        List<Map<String, Object>> list = healthPlanService.getAvailableTeachers(familyId);
+        return Result.success(list);
+    }
+
     // ===== 规划师方案维护接口 =====
 
     @Operation(summary = "规划师查看待审核方案列表")
@@ -126,7 +138,8 @@ public class HealthPlanController {
             return Result.error("无权限");
         }
         if (familyId == null) return Result.error("familyId不能为空");
-        List<HealthPlan> list = healthPlanService.listPendingReviewPlans(familyId);
+        Long teacherId = "admin".equals(role) ? ParamUtils.getLong(params.get("teacherId")) : userId;
+        List<HealthPlan> list = healthPlanService.listPendingReviewPlans(familyId, teacherId);
         return Result.success(list);
     }
 

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthPlan.java

@@ -24,6 +24,7 @@ public class HealthPlan implements Serializable {
     @TableField("reviewed_by") private Long reviewedBy;
     @TableField("reviewed_at") private Date reviewedAt;
     @TableField("review_comment") private String reviewComment;
+    @TableField("teacher_id") private Long teacherId;
     @TableField("created_at") private Date createdAt;
     @TableField("updated_at") private Date updatedAt;
 }

+ 4 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java

@@ -2,6 +2,7 @@ package com.etotem.cfc.service;
 
 import com.etotem.cfc.entity.HealthPlan;
 import java.util.List;
+import java.util.Map;
 
 public interface HealthPlanService {
     Long savePlan(HealthPlan plan);
@@ -11,11 +12,13 @@ public interface HealthPlanService {
     String generatePlan(Long familyId, String memberIds, String dimensions, String goal);
     String regenerateSection(Long familyId, String section, String feedback, String planJsonStr);
     /** 规划师查看待审核方案列表 */
-    List<HealthPlan> listPendingReviewPlans(Long familyId);
+    List<HealthPlan> listPendingReviewPlans(Long familyId, Long teacherId);
     /** 规划师编辑方案内容 */
     HealthPlan updatePlanContent(Long planId, String planContent, String planJson);
     /** 规划师审核通过并发布(同时生成任务) */
     HealthPlan approveAndPublish(Long planId, Long reviewedBy, String comment);
     /** 规划师驳回方案 */
     HealthPlan rejectPlan(Long planId, Long reviewedBy, String comment);
+    /** 获取家庭可用的规划师列表(已绑定或同团队) */
+    List<Map<String, Object>> getAvailableTeachers(Long familyId);
 }

+ 68 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -3,8 +3,12 @@ package com.etotem.cfc.service.impl;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.etotem.cfc.entity.HealthPlan;
 import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMapper;
 import com.etotem.cfc.mapper.HealthPlanMapper;
 import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.HealthPlanService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
@@ -34,6 +38,12 @@ public class HealthPlanServiceImpl implements HealthPlanService {
     @Resource
     private TaskMapper taskMapper;
 
+    @Resource
+    private FamilyMapper familyMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
     @Value("${cfc.langgraph.base-url:}")
     private String langgraphBaseUrl;
 
@@ -328,4 +338,62 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         healthPlanMapper.updateById(plan);
         return plan;
     }
+
+    @Override
+    public List<Map<String, Object>> getAvailableTeachers(Long familyId) {
+        List<Map<String, Object>> result = new java.util.ArrayList<>();
+        // 1. 已绑定的规划师
+        Family family = familyMapper.selectById(familyId);
+        if (family != null && family.getTeacherId() != null) {
+            User teacher = userMapper.selectById(family.getTeacherId());
+            if (teacher != null) {
+                Map<String, Object> item = new HashMap<>();
+                item.put("id", teacher.getId());
+                item.put("name", teacher.getRealName() != null ? teacher.getRealName() : teacher.getNickname());
+                item.put("nickname", teacher.getNickname());
+                item.put("phone", teacher.getPhone());
+                item.put("isBound", true);
+                result.add(item);
+            }
+        }
+        // 2. 同团队的其他规划师(teacherFamilyIds 有重叠的家庭)
+        if (family != null && family.getTeacherId() != null) {
+            User boundTeacher = userMapper.selectById(family.getTeacherId());
+            if (boundTeacher != null && boundTeacher.getTeacherFamilyIds() != null) {
+                String[] otherFamilyIds = boundTeacher.getTeacherFamilyIds().split(",");
+                for (String fid : otherFamilyIds) {
+                    try {
+                        Long otherFamilyId = Long.parseLong(fid.trim());
+                        if (otherFamilyId.equals(familyId)) continue;
+                        Family otherFamily = familyMapper.selectById(otherFamilyId);
+                        if (otherFamily != null && otherFamily.getTeacherId() != null
+                                && otherFamily.getTeacherId().equals(family.getTeacherId())) {
+                            // 同一个规划师,跳过
+                        }
+                    } catch (NumberFormatException ignored) {}
+                }
+            }
+        }
+        // 3. 所有已通过资质的规划师
+        com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<User> w =
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<User>()
+                        .eq(User::getRole, "teacher")
+                        .eq(User::getTeacherStatus, "approved")
+                        .select(User::getId, User::getNickname, User::getRealName, User::getPhone, User::getTeacherNo);
+        List<User> allTeachers = userMapper.selectList(w);
+        java.util.Set<Long> existingIds = result.stream()
+                .map(m -> ((Number) m.get("id")).longValue())
+                .collect(java.util.stream.Collectors.toSet());
+        for (User t : allTeachers) {
+            if (existingIds.contains(t.getId())) continue;
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", t.getId());
+            item.put("name", t.getRealName() != null ? t.getRealName() : t.getNickname());
+            item.put("nickname", t.getNickname());
+            item.put("phone", t.getPhone());
+            item.put("isBound", false);
+            result.add(item);
+        }
+        return result;
+    }
 }

+ 49 - 7
cfc-frontend/pages/health/health-plan-summary.vue

@@ -9,6 +9,25 @@
       </view>
     </view>
 
+    <!-- Step 0: 是否需要规划师 -->
+    <view class="section" v-if="step === 0 && !viewMode">
+      <view class="section-title">👩‍🏫 是否需要规划师审核方案?</view>
+      <view class="hint-text">选择"需要"将进入规划师选择页面,由指定规划师为您审核和定制方案</view>
+      <view class="teacher-choice">
+        <view class="choice-card" :class="{ active: needTeacher }" @click="needTeacher = true">
+          <text class="choice-icon">👩‍🏫</text>
+          <text class="choice-label">需要规划师审核</text>
+          <text class="choice-desc">由专业规划师为您审核并定制方案</text>
+        </view>
+        <view class="choice-card" :class="{ active: !needTeacher }" @click="needTeacher = false">
+          <text class="choice-icon">🤖</text>
+          <text class="choice-label">系统自动生成</text>
+          <text class="choice-desc">AI 自动出方案,直接生效</text>
+        </view>
+      </view>
+      <button class="btn-next full-btn" :disabled="loading" @click="goToStep1">{{ loading ? '加载中...' : '下一步' }}</button>
+    </view>
+
     <!-- Step 1: 选人 + 提需求 -->
     <view class="section" v-if="step === 1 && !viewMode">
       <view class="section-title-row">
@@ -162,6 +181,7 @@ export default {
       editing: false,
       taskSubmitted: false,
       viewMode: false,
+      needTeacher: false,
       planDetail: null,
       planId: null
     }
@@ -173,7 +193,7 @@ export default {
     allConfirmed: function() {
       return this.sections.nutrition.confirmed && this.sections.diet.confirmed && this.sections.exercise.confirmed
     },
-    steps: function() { return ['选人', '总览', '营养', '饮食', '运动', '确认'] },
+    steps: function() { return ['规划师', '选人', '总览', '营养', '饮食', '运动', '确认'] },
     sectionKeys: function() { return ['nutrition', 'diet', 'exercise'] },
     sectionTitles: function() { return ['营养补充建议', '饮食建议', '运动计划'] },
     sectionIcons: function() { return ['💊', '🍽️', '🏃'] },
@@ -327,6 +347,16 @@ export default {
         self.step = 6
       }
     },
+    goToStep1: function() {
+      var self = this
+      if (self.needTeacher) {
+        // 进入规划师选择页
+        var fid = uni.getStorageSync('familyId') || ''
+        uni.navigateTo({ url: '/pages/health/teacher-select?familyId=' + fid })
+      } else {
+        self.step = 2
+      }
+    },
     startEdit: function() {
       this.editing = true
       this.sectionFeedback = ''
@@ -345,16 +375,21 @@ export default {
         dimensions: 'body',
         goal: self.userGoal.trim(),
         planContent: self.buildPlanContent(),
-        planJson: planJson,
-        status: 'confirmed'
+        planJson: planJson
+      }
+      // 根据是否选择规划师决定状态
+      if (self.needTeacher) {
+        var tid = uni.getStorageSync('healthPlan_teacherId')
+        if (tid) saveData.teacherId = parseInt(tid)
+        saveData.status = 'pending_review'
+      } else {
+        saveData.status = 'published'
       }
-      saveData.status = 'pending_review'
       saveHealthPlan(saveData).then(function(res) {
         self.loading = false
         if (res && res.code === 200) {
-          // 方案已提交审核,等待规划师发布
-          uni.showToast({ title: '方案已提交,等待规划师审核发布', icon: 'none', duration: 2500 })
-          setTimeout(function() { uni.navigateBack() }, 1500)
+          uni.showToast({ title: self.needTeacher ? '方案已提交审核' : '方案已生成', icon: 'none', duration: 2000 })
+          setTimeout(function() { uni.navigateBack() }, 1200)
           self.taskSubmitted = true
         } else {
           uni.showToast({ title: res && res.message ? res.message : '保存失败', icon: 'none' })
@@ -445,6 +480,13 @@ export default {
 }
 
 /* 步骤条 */
+
+.teacher-choice { display: flex; gap: 20rpx; margin: 24rpx 0; }
+.choice-card { flex: 1; background: #fff; border-radius: 20rpx; padding: 32rpx 20rpx; text-align: center; border: 3rpx solid #E5E7EB; }
+.choice-card.active { border-color: #3B82F6; background: #EFF6FF; }
+.choice-icon { font-size: 64rpx; display: block; margin-bottom: 12rpx; }
+.choice-label { font-size: 30rpx; font-weight: bold; color: #333; display: block; }
+.choice-desc { font-size: 24rpx; color: #666; margin-top: 8rpx; display: block; }
 .steps-bar {
   display: flex;
   align-items: center;

+ 158 - 0
cfc-frontend/pages/health/teacher-select.vue

@@ -0,0 +1,158 @@
+<template>
+  <view class="page">
+    <!-- 导航栏 -->
+    <view class="nav-bar">
+      <text class="nav-back" @click="goBack">‹ 返回</text>
+      <text class="nav-title">选择规划师</text>
+      <view class="nav-placeholder"></view>
+    </view>
+
+    <!-- 提示 -->
+    <view class="tip-banner">
+      <text class="tip-icon">👩‍🏫</text>
+      <text class="tip-text">选择一位规划师,将在方案生成后为您审核和定制计划</text>
+    </view>
+
+    <!-- 规划师列表 -->
+    <view class="teacher-list" v-if="teachers.length > 0">
+      <view
+        v-for="t in teachers"
+        :key="t.id"
+        class="teacher-card"
+        :class="{ active: selectedTeacherId === t.id }"
+        @click="selectTeacher(t)"
+      >
+        <view class="teacher-avatar">{{ (t.name || '规划师').charAt(0) }}</view>
+        <view class="teacher-info">
+          <text class="teacher-name">{{ t.name || '规划师' }}</text>
+          <text class="teacher-tag" v-if="t.isBound">✓ 已绑定家庭</text>
+        </view>
+        <view class="teacher-check" v-if="selectedTeacherId === t.id">✓</view>
+      </view>
+    </view>
+
+    <!-- 空状态 -->
+    <view class="empty-state" v-else>
+      <text class="empty-icon">🔍</text>
+      <text class="empty-text">暂无可用规划师</text>
+      <text class="empty-hint">请联系管理员为您分配规划师</text>
+    </view>
+
+    <!-- 底部按钮 -->
+    <view class="bottom-bar">
+      <button class="btn-next" :disabled="!selectedTeacherId" @click="confirmSelection">
+        {{ selectedTeacherId ? '确认选择' : '请选择规划师' }}
+      </button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getAvailableTeachers } from '../../utils/api'
+
+export default {
+  data() {
+    return {
+      teachers: [],
+      selectedTeacherId: null,
+      familyId: null,
+      loading: false
+    }
+  },
+  onLoad: function(options) {
+    this.familyId = options.familyId ? parseInt(options.familyId) : null
+    if (!this.familyId) {
+      this.familyId = uni.getStorageSync('familyId') ? parseInt(uni.getStorageSync('familyId')) : null
+    }
+    if (!this.familyId) {
+      uni.showToast({ title: '缺少家庭信息', icon: 'none' })
+      setTimeout(() => uni.navigateBack(), 1000)
+      return
+    }
+    this.loadTeachers()
+  },
+  methods: {
+    async loadTeachers() {
+      this.loading = true
+      try {
+        var self = this
+        getAvailableTeachers({ familyId: this.familyId }).then(function(res) {
+          self.loading = false
+          if (res.code === 200) {
+            self.teachers = res.data || []
+          } else {
+            uni.showToast({ title: res.message || '加载失败', icon: 'none' })
+          }
+        }).catch(function() {
+          self.loading = false
+          uni.showToast({ title: '加载失败,请重试', icon: 'none' })
+        })
+      } catch(e) {
+        this.loading = false
+      }
+    },
+    selectTeacher: function(t) {
+      this.selectedTeacherId = t.id
+    },
+    confirmSelection: function() {
+      if (!this.selectedTeacherId) return
+      uni.setStorageSync('healthPlan_teacherId', this.selectedTeacherId)
+      uni.navigateBack()
+    },
+    goBack: function() {
+      uni.navigateBack()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page { min-height: 100vh; background: #F3F7FA; padding-bottom: 120rpx; }
+.nav-bar {
+  display: flex; align-items: center; justify-content: space-between;
+  padding: 28rpx 32rpx; background: #fff;
+}
+.nav-back { font-size: 32rpx; color: #666; }
+.nav-title { font-size: 34rpx; font-weight: bold; color: #333; }
+.nav-placeholder { width: 80rpx; }
+.tip-banner {
+  display: flex; align-items: center; gap: 12rpx;
+  margin: 24rpx 32rpx; padding: 20rpx 24rpx;
+  background: #FEF3C7; border-radius: 16rpx;
+}
+.tip-icon { font-size: 36rpx; }
+.tip-text { font-size: 26rpx; color: #92400E; flex: 1; }
+.teacher-list { padding: 0 32rpx; }
+.teacher-card {
+  display: flex; align-items: center; gap: 20rpx;
+  background: #fff; border-radius: 16rpx; padding: 24rpx;
+  margin-bottom: 16rpx; border: 2rpx solid #E5E7EB;
+}
+.teacher-card.active { border-color: #3B82F6; background: #EFF6FF; }
+.teacher-avatar {
+  width: 80rpx; height: 80rpx; border-radius: 50%;
+  background: linear-gradient(135deg, #3B82F6, #60A5FA);
+  color: #fff; font-size: 32rpx; font-weight: bold;
+  display: flex; align-items: center; justify-content: center;
+}
+.teacher-info { flex: 1; }
+.teacher-name { font-size: 30rpx; font-weight: bold; color: #333; display: block; }
+.teacher-tag { font-size: 22rpx; color: #10B981; margin-top: 4rpx; display: block; }
+.teacher-check { font-size: 36rpx; color: #3B82F6; font-weight: bold; }
+.empty-state { text-align: center; padding: 120rpx 40rpx; }
+.empty-icon { font-size: 80rpx; display: block; margin-bottom: 20rpx; }
+.empty-text { font-size: 30rpx; color: #666; display: block; }
+.empty-hint { font-size: 24rpx; color: #999; margin-top: 12rpx; display: block; }
+.bottom-bar {
+  position: fixed; bottom: 0; left: 0; right: 0;
+  padding: 24rpx 32rpx; background: #fff;
+  border-top: 1rpx solid #E5E7EB;
+}
+.btn-next {
+  width: 100%; height: 88rpx; line-height: 88rpx;
+  background: linear-gradient(135deg, #3B82F6, #2563EB);
+  color: #fff; font-size: 32rpx; font-weight: bold;
+  border-radius: 44rpx; border: none;
+}
+.btn-next[disabled] { background: #CBD5E1; }
+</style>

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-1da715277ef1aa34417db9a8a32d4a2b22de8909
+814ee2b986e57d75ff338064142ef604a1664230

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1164",
+  "version": "1.0.1165",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1164",
+      "version": "1.0.1165",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1165",
+  "version": "1.0.1166",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 11 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,17 @@
 
 ---
 
+## v1.0.1166 (2026-08-20)
+
+### Bug 修复
+- 修复微信登录 invalid code (40029) —— 不再 onLoad 预取 login code,改为点击授权时实时获取
+
+### 其他
+- 会导致 code2Session 返回 40029 invalid code。login.vue 改为与
+- invite/join.vue 一致的模式:getPhoneNumber 中实时 uni.login 获取新 code。
+- 
+
+
 ## v1.0.1165 (2026-08-19)
 
 ### 新功能

+ 12 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1165
+> 当前版本: v1.0.1166
 
 ## 历史版本
 
@@ -8,6 +8,17 @@
 
 ---
 
+## v1.0.1166 (2026-08-20)
+
+### Bug 修复
+- 修复微信登录 invalid code (40029) —— 不再 onLoad 预取 login code,改为点击授权时实时获取
+
+### 其他
+- 会导致 code2Session 返回 40029 invalid code。login.vue 改为与
+- invite/join.vue 一致的模式:getPhoneNumber 中实时 uni.login 获取新 code。
+- 
+
+
 ## v1.0.1165 (2026-08-19)
 
 ### 新功能