Ver Fonte

feat: GAP-2026-11-01-home-greet-shortcut-and-my-messages 首页问候条快捷入口+我的咨询消息

- feat(guide): TeacherMessage 新增瞬态字段 teacherName,/api/guide/messages/my 按 teacherId 回填规划师昵称(含单元测试 TeacherMessageControllerMyTest)
- feat(profile): 新增我的咨询消息页 my-messages.vue 并注册分包,utils/api.js 新增 getMyGuideMessages
- feat(home): index-home 问候条整体可点击进家庭成员页(铃铛 @click.stop),小铃铛改跳我的咨询消息
- docs(api): API_REFERENCE.md 记录 /api/guide/messages/* 各端点及 /my teacherName 回显
E2E Test Bot há 1 semana atrás
pai
commit
5d750c7a82

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/TeacherMessageController.java

@@ -132,6 +132,15 @@ public class TeacherMessageController {
     @PostMapping("/my")
     public Result<List<TeacherMessage>> getMyMessages(@RequestAttribute("userId") Long parentId) {
         List<TeacherMessage> messages = teacherMessageService.getMessagesByParent(parentId);
+        // 回填规划师昵称(与 /list 填充家庭主账号昵称同模式)
+        for (TeacherMessage msg : messages) {
+            if (msg.getTeacherId() != null) {
+                User teacher = userMapper.selectById(msg.getTeacherId());
+                if (teacher != null) {
+                    msg.setTeacherName(teacher.getNickname());
+                }
+            }
+        }
         return Result.success(messages);
     }
 }

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/TeacherMessage.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.entity;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
@@ -30,4 +31,7 @@ public class TeacherMessage implements Serializable {
     private Date createdAt;         // 创建时间
 
     private Date repliedAt;         // 回复时间
+
+    @TableField(exist = false)
+    private String teacherName;   // 规划师昵称(回显用,不入库)
 }

+ 61 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/guide/TeacherMessageControllerMyTest.java

@@ -0,0 +1,61 @@
+package com.etotem.cfc.controller.guide;
+
+import com.etotem.cfc.entity.TeacherMessage;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.TeacherMessageService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.http.MediaType;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.Arrays;
+import java.util.Date;
+
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+public class TeacherMessageControllerMyTest {
+
+    @Mock private TeacherMessageService teacherMessageService;
+    @Mock private UserMapper userMapper;
+
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+        TeacherMessageController controller = new TeacherMessageController();
+        ReflectionTestUtils.setField(controller, "teacherMessageService", teacherMessageService);
+        ReflectionTestUtils.setField(controller, "userMapper", userMapper);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    @Test
+    public void myMessages_enrichesTeacherName() throws Exception {
+        TeacherMessage msg = new TeacherMessage();
+        msg.setId(1L); msg.setParentId(10L); msg.setTeacherId(5L);
+        msg.setContent("孩子最近注意力不集中"); msg.setReplyContent("建议增加运动量");
+        msg.setStatus("replied"); msg.setCreatedAt(new Date());
+        when(teacherMessageService.getMessagesByParent(10L)).thenReturn(Arrays.asList(msg));
+
+        User teacher = new User(); teacher.setId(5L); teacher.setNickname("王规划师");
+        when(userMapper.selectById(5L)).thenReturn(teacher);
+
+        mockMvc.perform(post("/api/guide/messages/my")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{}")
+                        .requestAttr("userId", 10L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data[0].teacherName").value("王规划师"));
+
+        verify(teacherMessageService).getMessagesByParent(10L);
+    }
+}

+ 6 - 0
cfc-frontend/pages.json

@@ -1900,6 +1900,12 @@
           "style": {
             "navigationBarTitleText": ""
           }
+        },
+        {
+          "path": "my-messages",
+          "style": {
+            "navigationBarTitleText": "我的咨询消息"
+          }
         }
       ]
     },

+ 15 - 6
cfc-frontend/pages/index-home/index.vue

@@ -52,7 +52,7 @@
     <OnboardingProgressBar ref="onboardingProgress" />
 
     <!-- ⓪.b 问候条 — 家庭成员 + 按时间问候语(设计稿 §2 视觉焦点) -->
-    <view class="greet-bar">
+    <view class="greet-bar" @click="goFamilyMembers">
       <view class="greet-avatars">
         <view
           v-for="m in greetAvatarMembers"
@@ -72,10 +72,13 @@
         </view>
       </view>
       <view class="greet-main">
-        <text class="greet-title">{{ greetTitle }}</text>
-        <text class="greet-line">{{ greetLine }}</text>
+        <view class="greet-text">
+          <text class="greet-title">{{ greetTitle }}</text>
+          <text class="greet-line">{{ greetLine }}</text>
+        </view>
+        <text class="greet-arrow">›</text>
       </view>
-      <view class="greet-bell" @click="goNotifications">
+      <view class="greet-bell" @click.stop="goNotifications">
         <image class="greet-bell-icon" src="/static/icons/bell.svg" mode="aspectFit" />
         <text v-if="unreadNoticeCount > 0" class="greet-bell-badge">{{ unreadNoticeCount }}</text>
       </view>
@@ -1809,7 +1812,11 @@ title: '添加家庭成员',
     },
     // ===== ⓪.b 问候条 — 通知入口 =====
     goNotifications: function() {
-      uni.navigateTo({ url: '/pages/teacher/messages' })
+      uni.navigateTo({ url: '/pages/profile/my-messages' })
+    },
+    // 问候条点击 → 家庭成员页
+    goFamilyMembers: function() {
+      this.navTo('/pages/profile-extra/family-members')
     },
     // 问候条头像加载失败 → 记录并降级为首字文字头像
     _onGreetAvatarError: function(key) {
@@ -3008,7 +3015,9 @@ $c-warm-tint:     #FFF3E7;
   color: $brand-primary-deep;
   font-weight: 600;
 }
-.greet-main { flex: 1; min-width: 0; }
+.greet-main { flex: 1; min-width: 0; display: flex; align-items: center; }
+.greet-text { flex: 1; min-width: 0; }
+.greet-arrow { margin-left: 8rpx; font-size: calc(36rpx * var(--font-scale, 1)); color: $facade-text-muted; }
 .greet-title {
   display: block;
   font-size: calc(38rpx * var(--font-scale, 1));

+ 97 - 0
cfc-frontend/pages/profile/my-messages.vue

@@ -0,0 +1,97 @@
+<template>
+  <view class="container" :style="{ '--font-scale': fontScale }">
+    <view v-if="loading && messages.length === 0" class="state-box">
+      <text class="state-text">加载中...</text>
+    </view>
+    <view v-else-if="messages.length === 0" class="state-box">
+      <text class="state-icon">💬</text>
+      <text class="state-text">暂无咨询消息</text>
+    </view>
+    <view v-else class="msg-list">
+      <view class="msg-item" v-for="msg in messages" :key="msg.id">
+        <view class="msg-head">
+          <text class="msg-teacher">{{ msg.teacherName || '规划师' }}</text>
+          <text class="msg-status" :class="msg.status === 'replied' ? 'st-replied' : 'st-pending'">
+            {{ msg.status === 'replied' ? '已回复' : '待回复' }}
+          </text>
+        </view>
+        <view class="msg-body">
+          <text class="msg-label">我的咨询</text>
+          <text class="msg-content">{{ msg.content }}</text>
+        </view>
+        <view class="msg-reply" v-if="msg.replyContent">
+          <text class="msg-label">规划师回复</text>
+          <text class="msg-content reply">{{ msg.replyContent }}</text>
+        </view>
+        <text class="msg-time">{{ formatTime(msg.createdAt) }}</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getMyGuideMessages } from '../../utils/api.js'
+import { parseDate } from '../../utils/format.js'
+import fontScaleMixin from '@/mixins/font-scale.js'
+
+export default {
+  mixins: [fontScaleMixin],
+  data() {
+    return {
+      loading: true,
+      messages: []
+    }
+  },
+  onLoad() {
+    this.loadMessages()
+  },
+  methods: {
+    async loadMessages() {
+      try {
+        var res = await getMyGuideMessages()
+        this.messages = (res && res.code === 200 && res.data instanceof Array) ? res.data : []
+      } catch (e) {
+        this.messages = []
+      } finally {
+        this.loading = false
+      }
+    },
+    formatTime(dateStr) {
+      if (!dateStr) return ''
+      var d = parseDate(dateStr)
+      if (!d) return ''
+      var now = new Date()
+      var diff = now - d
+      var minutes = Math.floor(diff / 60000)
+      if (minutes < 1) return '刚刚'
+      if (minutes < 60) return minutes + '分钟前'
+      var hours = Math.floor(minutes / 60)
+      if (hours < 24) return hours + '小时前'
+      var days = Math.floor(hours / 24)
+      if (days === 1) return '昨天'
+      if (days < 7) return days + '天前'
+      return (d.getMonth() + 1) + '月' + d.getDate() + '日'
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.container { min-height: 100vh; padding: $uni-space-page; background: $facade-fill-2; }
+.state-box { display: flex; flex-direction: column; align-items: center; padding: 120rpx 0; }
+.state-icon { font-size: calc(80rpx * var(--font-scale, 1)); margin-bottom: 20rpx; }
+.state-text { font-size: calc(26rpx * var(--font-scale, 1)); color: $facade-text-muted; }
+.msg-list { display: flex; flex-direction: column; gap: 20rpx; }
+.msg-item { background: $app-surface; border-radius: 20rpx; padding: 28rpx; }
+.msg-head { display: flex; align-items: center; justify-content: space-between; }
+.msg-teacher { font-size: calc(30rpx * var(--font-scale, 1)); font-weight: bold; color: $facade-text-strong; }
+.msg-status { font-size: calc(22rpx * var(--font-scale, 1)); padding: 4rpx 16rpx; border-radius: 20rpx; }
+.st-replied { color: $status-success; background: rgba(16,185,129,0.12); }
+.st-pending { color: $status-warning; background: rgba(251,191,36,0.14); }
+.msg-body { margin-top: 16rpx; }
+.msg-label { font-size: calc(22rpx * var(--font-scale, 1)); color: $facade-text-muted; display: block; margin-bottom: 6rpx; }
+.msg-content { font-size: calc(28rpx * var(--font-scale, 1)); color: $facade-text-body; display: block; line-height: 1.6; }
+.msg-reply { margin-top: 16rpx; padding: 16rpx; background: $facade-fill-ghost; border-radius: 12rpx; }
+.msg-content.reply { color: $facade-text-strong; }
+.msg-time { font-size: calc(22rpx * var(--font-scale, 1)); color: $facade-text-muted; display: block; margin-top: 12rpx; }
+</style>

+ 5 - 0
cfc-frontend/utils/api.js

@@ -1454,6 +1454,11 @@ export const getGuideMessages = () => {
   return request('/api/guide/messages/list', 'POST')
 }
 
+// 家庭主账号获取自己的咨询消息
+export const getMyGuideMessages = () => {
+  return request('/api/guide/messages/my', 'POST')
+}
+
 export const getGuideOrders = () => {
   return request('/api/guide/orders/list', 'POST')
 }

+ 18 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -1673,6 +1673,24 @@ interactionType 取值:`PHONE` / `WECHAT` / `MEETING` / `OTHER`。
 
 ---
 
+### 4.47.1 成长规划师咨询消息(`/api/guide/messages/*`)
+
+家长(家庭主账号)与成长规划师之间的咨询消息接口。
+
+| 端点 | 权限 | 说明 |
+|---|---|---|
+| `POST /api/guide/messages/send` | 家长(需 `familyId`) | 发送咨询,请求体 `{ teacherId, content }` |
+| `POST /api/guide/messages/{messageId}/reply` | 仅 `role=teacher` + 认证闸门 | 规划师回复,请求体 `{ replyContent }` |
+| `POST /api/guide/messages/list` | 仅 `role=teacher` | 规划师获取咨询列表(分页 `{ page, size }`),响应条目在 `content` 尾部追加 `[家庭主账号:<昵称>]` |
+| `POST /api/guide/messages/unread-count` | 仅 `role=teacher` | 规划师未读消息数 |
+| `POST /api/guide/messages/my` | 家长(按自身 `parentId` 查询,无 role 限制) | 家庭主账号获取自己的咨询记录,响应 `TeacherMessage` 列表 |
+
+- **`POST /api/guide/messages/my`**:家庭主账号获取自己的咨询记录。响应 `TeacherMessage` 列表,每条含 `teacherName`(规划师昵称,按 `teacherId` 从 `users` 表回填,查不到时留空由前端兜底「规划师」)。不新增路由、不改鉴权(`/my` 无 role 限制,按 `parentId` 查自己的记录)。
+
+> 文档更新:2026-11-01(`/my` 新增 `teacherName` 回显,见 `specs/2026-11-01-home-greet-shortcut-and-my-messages-design.md`)。
+
+---
+
 ### 4.48 指标统一层(`/api/indicator/*` + `/api/admin/indicator/*`)
 
 指标变化曲线 / 指标速查 / 指标关联的统一读接口与管理端接口。读接口 `subjectId` 必填且做家庭归属校验(`family_members.id = subjectId AND family_id = 当前用户家庭`,由 `JwtInterceptor` 注入 `@RequestAttribute("familyId")`);管理端接口校验 `@RequestAttribute("role")`,仅 `role=admin` 可访问,否则「无权操作」。