From fe523ac2bc568213b4a1991ddd0af4015bb38d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=88=90?= Date: Fri, 21 Nov 2025 08:42:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=BA=A2=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 10 + _doc/前端红点功能接口文档.md | 568 ++++++++++++++++++ src/components/GuideBar/index.scss | 16 + src/components/GuideBar/index.tsx | 30 +- .../components/MessagePageContent.tsx | 51 +- src/other_pages/comment_reply/index.tsx | 13 + src/other_pages/message/index.scss | 34 ++ src/other_pages/message/index.tsx | 58 +- src/other_pages/new_follow/index.tsx | 19 +- src/services/messageService.ts | 40 ++ 10 files changed, 814 insertions(+), 25 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 _doc/前端红点功能接口文档.md create mode 100644 src/services/messageService.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..e5cefe4 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(dir /s /b *.json)", + "Bash(findstr:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/_doc/前端红点功能接口文档.md b/_doc/前端红点功能接口文档.md new file mode 100644 index 0000000..5250c2b --- /dev/null +++ b/_doc/前端红点功能接口文档.md @@ -0,0 +1,568 @@ +# 前端红点功能接口文档 + +## 一、功能概述 + +消息红点功能用于提示用户有未读的评论/回复和新增关注消息。 + +**红点显示条件**: +- 有别人回复给我的评论/回复未读 +- 有新用户关注我未读 + +--- + +## 二、接口列表 + +| 接口 | 用途 | +|------|------| +| `POST /api/message/reddot_info` | 获取红点信息(数量统计) | +| `POST /api/message/mark_as_read` | 标记消息已读(统一接口) | +| `POST /api/comments/my_activities` | 获取评论/回复消息列表 | +| `POST /api/user_follow/new_fans_list` | 获取新增关注列表 | + +--- + +## 三、核心接口详情 + +### 1. 获取红点信息 ⭐ + +**接口**: `POST /api/message/reddot_info` + +**请求参数**: 无 + +**响应示例**: +```json +{ + "code": 200, + "message": "获取成功", + "data": { + "comment_unread_count": 5, // 评论/回复未读数量 + "follow_unread_count": 3, // 新增关注未读数量 + "total_unread_count": 8, // 总未读数量 + "has_reddot": true // 是否显示红点 + } +} +``` + +**字段说明**: +- `comment_unread_count`: Number,评论/回复未读数量 +- `follow_unread_count`: Number,新增关注未读数量 +- `total_unread_count`: Number,总未读数量 +- `has_reddot`: Boolean,是否显示红点 + - `true`: 有未读消息,显示红点 + - `false`: 无未读消息,隐藏红点 + +**使用场景**: +- TabBar 显示红点和未读数 +- 消息页面显示各类消息的未读数 +- 应用启动时检查红点状态 + +--- + +### 2. 标记消息已读 ⭐ + +**接口**: `POST /api/message/mark_as_read` + +**请求参数**: +```json +{ + "type": "comment", // 消息类型: comment-评论, follow-关注, all-全部 + "ids": [123, 456, 789] // 消息ID数组(type为all时可不传) +} +``` + +**type 参数说明**: +- `"comment"`: 标记指定评论/回复为已读,需传 `ids`(评论ID数组) +- `"follow"`: 标记指定关注为已读,需传 `ids`(关注者用户ID数组) +- `"all"`: 标记所有未读消息为已读,不需要传 `ids` + +**响应示例**: +```json +{ + "code": 200, + "message": "获取成功", + "data": { + "updated_count": 8, // 总更新数量 + "detail": { + "comment_count": 5, // 评论更新数量 + "follow_count": 3 // 关注更新数量 + }, + "message": "标记成功" + } +} +``` + +**使用示例**: + +1. 标记指定评论为已读: +```json +{ + "type": "comment", + "ids": [123, 456] +} +``` + +2. 标记指定关注为已读: +```json +{ + "type": "follow", + "ids": [789, 012] +} +``` + +3. 标记全部消息为已读: +```json +{ + "type": "all" +} +``` + +--- + +## 四、辅助接口 + +### 3. 获取评论/回复消息列表 + +**接口**: `POST /api/comments/my_activities` + +**请求参数**: +```json +{ + "page": 1, + "pageSize": 20 +} +``` + +**响应示例**: +```json +{ + "code": 200, + "message": "获取成功", + "data": { + "rows": [ + { + "id": 456, + "type": "reply", + "activity_type": "received_reply", + "content": "回复内容", + "create_time": "2025-11-20 10:00:00", + "is_read": 0, // 👈 0-未读, 1-已读 + "user": { + "id": 789, + "nickname": "回复者昵称", + "avatar_url": "头像地址" + }, + "game": { + "id": 101, + "title": "球局标题" + } + } + ], + "count": 50 + } +} +``` + +**字段说明**: +- `is_read`: 0-未读, 1-已读 +- `activity_type`: + - `"my_activity"` - 我发表的 + - `"received_reply"` - 别人回复我的(需要处理的) + +--- + +### 4. 获取新增关注列表 + +**接口**: `POST /api/user_follow/new_fans_list` + +**请求参数**: +```json +{ + "page": 1, + "page_size": 20 +} +``` + +**响应示例**: +```json +{ + "code": 200, + "message": "获取成功", + "data": { + "list": [ + { + "id": 123, + "nickname": "粉丝昵称", + "avatar_url": "头像地址", + "follow_time": "2025-11-20 09:00:00", + "is_mutual": false, + "is_read": 0 // 👈 0-未读, 1-已读 + } + ], + "total": 30 + } +} +``` + +--- + +## 五、前端使用示例 + +### 1. 显示 TabBar 红点 + +```javascript +// app.js 或全局方法 +async function checkAndShowReddot() { + const res = await request({ + url: '/api/message/reddot_info', + method: 'POST' + }) + + const { has_reddot, total_unread_count } = res.data + + if (has_reddot) { + // 显示红点和数字 + wx.setTabBarBadge({ + index: 2, // 消息Tab的索引 + text: total_unread_count > 99 ? '99+' : String(total_unread_count) + }) + } else { + // 隐藏红点 + wx.removeTabBarBadge({ + index: 2 + }) + } +} + +// App 启动时检查 +App({ + onLaunch() { + this.checkReddot() + }, + + checkReddot() { + checkAndShowReddot() + } +}) +``` + +--- + +### 2. 消息页面显示分类红点 + +```javascript +// pages/message/message.js +Page({ + data: { + commentUnreadCount: 0, + followUnreadCount: 0 + }, + + onShow() { + this.loadReddotInfo() + }, + + async loadReddotInfo() { + const res = await request({ + url: '/api/message/reddot_info', + method: 'POST' + }) + + this.setData({ + commentUnreadCount: res.data.comment_unread_count, + followUnreadCount: res.data.follow_unread_count + }) + } +}) +``` + +```html + + + + + 评论与回复 + + {{commentUnreadCount}} + + + + + + 新增关注 + + {{followUnreadCount}} + + + +``` + +--- + +### 3. 评论消息列表(进入时自动标记已读) + +```javascript +// pages/message/comment-list.js +Page({ + data: { + commentList: [] + }, + + async onLoad() { + await this.loadComments() + await this.markAllAsRead() + }, + + // 加载评论列表 + async loadComments() { + const res = await request({ + url: '/api/comments/my_activities', + method: 'POST', + data: { page: 1, pageSize: 20 } + }) + + // 筛选出别人回复给我的 + const receivedReplies = res.data.rows.filter( + item => item.activity_type === 'received_reply' + ) + + this.setData({ commentList: receivedReplies }) + }, + + // 标记所有评论为已读 + async markAllAsRead() { + const unreadIds = this.data.commentList + .filter(item => item.is_read === 0) + .map(item => item.id) + + if (unreadIds.length > 0) { + await request({ + url: '/api/message/mark_as_read', + method: 'POST', + data: { + type: 'comment', + ids: unreadIds + } + }) + } + }, + + onUnload() { + // 离开页面时刷新红点 + getApp().checkReddot() + } +}) +``` + +--- + +### 4. 关注消息列表(进入时自动标记已读) + +```javascript +// pages/message/follow-list.js +Page({ + data: { + fansList: [] + }, + + async onLoad() { + await this.loadFans() + await this.markAllAsRead() + }, + + async loadFans() { + const res = await request({ + url: '/api/user_follow/new_fans_list', + method: 'POST', + data: { page: 1, page_size: 20 } + }) + + this.setData({ fansList: res.data.list }) + }, + + async markAllAsRead() { + const unreadFanIds = this.data.fansList + .filter(item => item.is_read === 0) + .map(item => item.id) + + if (unreadFanIds.length > 0) { + await request({ + url: '/api/message/mark_as_read', + method: 'POST', + data: { + type: 'follow', + ids: unreadFanIds + } + }) + } + }, + + onUnload() { + getApp().checkReddot() + } +}) +``` + +--- + +### 5. 一键清空所有未读 + +```javascript +// 清空所有未读消息 +async function markAllMessagesAsRead() { + wx.showLoading({ title: '处理中...' }) + + try { + await request({ + url: '/api/message/mark_as_read', + method: 'POST', + data: { type: 'all' } + }) + + wx.showToast({ title: '已全部标记为已读', icon: 'success' }) + + // 刷新红点 + getApp().checkReddot() + + } catch (err) { + wx.showToast({ title: '操作失败', icon: 'none' }) + } finally { + wx.hideLoading() + } +} +``` + +--- + +## 六、最佳实践 + +### 1. 红点刷新时机 + +```javascript +// 推荐刷新时机 +const REFRESH_TIMING = { + onLaunch: true, // App启动时 + onShow: true, // App从后台进入前台 + onTabSwitch: true, // 切换到消息Tab + afterMarkRead: true, // 标记已读后 + onPullRefresh: true // 下拉刷新 +} +``` + +### 2. 防抖优化 + +```javascript +// utils/debounce.js +let timer = null +export function debounceCheckReddot() { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + getApp().checkReddot() + }, 300) +} +``` + +### 3. 错误处理 + +```javascript +async function safeMarkAsRead(type, ids) { + try { + await request({ + url: '/api/message/mark_as_read', + method: 'POST', + data: { type, ids } + }) + } catch (err) { + // 标记已读失败不影响用户体验,静默处理 + console.error('标记已读失败:', err) + } +} +``` + +--- + +## 七、完整流程图 + +``` +用户打开App + ↓ +调用 /message/reddot_info + ↓ +获取红点数据 + ↓ +┌─────────────────┐ +│ has_reddot? │ +└────┬────────┬───┘ + │ │ + true false + ↓ ↓ +显示红点 隐藏红点 +带数字 + │ + │ 用户点击 + ↓ +进入消息页面 + ↓ +显示各分类未读数 + │ + │ 点击某分类 + ↓ +加载消息列表 + ↓ +标记已读 + ↓ +┌─────────────────┐ +│ 选择标记方式 │ +└─┬─────────┬─────┘ + │ │ +按ID 全部 +标记 标记 + ↓ ↓ +调用 mark_as_read +type=comment/follow +ids=[...] + │ │ + └────┬────┘ + ↓ + 标记成功 + ↓ + 刷新红点 +``` + +--- + +## 八、接口对比说明 + +### 旧方案 vs 新方案 + +| 功能 | 旧方案 | 新方案 | +|------|--------|--------| +| 获取红点 | `/api/user/detail` | `/api/message/reddot_info` | +| 标记评论已读 | `/api/comments/mark_as_read` | `/api/message/mark_as_read` (type=comment) | +| 标记关注已读 | `/api/user_follow/mark_as_read` | `/api/message/mark_as_read` (type=follow) | +| 标记全部已读 | ❌ 无 | `/api/message/mark_as_read` (type=all) | + +**新方案优势**: +- ✅ 统一的接口设计 +- ✅ 减少接口数量 +- ✅ 支持一键清空所有未读 +- ✅ 返回详细的未读数统计 +- ✅ 更清晰的职责划分 + +--- + +## 九、注意事项 + +1. **权限控制**: 只能标记属于自己的消息 +2. **分页处理**: 翻页时注意标记已读 +3. **实时性**: 标记后刷新红点状态 +4. **错误处理**: 标记失败可静默处理 +5. **type 参数**: 必须是 `comment`、`follow` 或 `all` + +--- + +## 十、联系后端 + +**相关文件**: +- 消息接口: `api/controller_front/msg_message.js` ⭐ +- 评论接口: `api/controller_front/gme_comments.js` +- 关注接口: `api/controller_front/user_follow.js` diff --git a/src/components/GuideBar/index.scss b/src/components/GuideBar/index.scss index bafea3e..020cc18 100644 --- a/src/components/GuideBar/index.scss +++ b/src/components/GuideBar/index.scss @@ -33,6 +33,7 @@ backdrop-filter: blur(16px); &-item { + position: relative; display: flex; width: 76px; height: 48px; @@ -46,6 +47,21 @@ font-style: normal; font-weight: 600; line-height: 20px; /* 125% */ + + .reddot { + position: absolute; + top: 10px; + right: 16px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + // padding: 0 2px; + width: 8px; + height: 8px; + background: #FF2541; + border-radius: 10px; + } } &-item-active { diff --git a/src/components/GuideBar/index.tsx b/src/components/GuideBar/index.tsx index f5248ba..b41a269 100644 --- a/src/components/GuideBar/index.tsx +++ b/src/components/GuideBar/index.tsx @@ -1,12 +1,36 @@ +import { useState, useEffect } from "react"; import { View, Text } from "@tarojs/components"; -import Taro from "@tarojs/taro"; +import Taro, { useDidShow } from "@tarojs/taro"; import { redirectTo } from "@/utils/navigation"; +import messageService from "@/services/messageService"; import "./index.scss"; import PublishMenu from "../PublishMenu"; export type currentPageType = "games" | "message" | "personal"; const GuideBar = (props) => { const { currentPage, guideBarClassName, onPublishMenuVisibleChange, onTabChange } = props; + const [hasReddot, setHasReddot] = useState(false); + + // 获取红点状态 + const checkReddot = async () => { + try { + const res = await messageService.getReddotInfo(); + if (res.code === 0) { + setHasReddot(res.data.has_reddot || false); + } + } catch (e) { + console.error("获取红点状态失败:", e); + } + }; + + useEffect(() => { + checkReddot(); + }, []); + + // 每次页面显示时刷新红点状态 + useDidShow(() => { + checkReddot(); + }); const guideItems = [ { @@ -64,8 +88,12 @@ const GuideBar = (props) => { handlePageChange(item.code)} + key={item.code} > {item.text} + {/* {item.code === "message" && hasReddot && ( + + )} */} ))} diff --git a/src/main_pages/components/MessagePageContent.tsx b/src/main_pages/components/MessagePageContent.tsx index 3762905..dbd0563 100644 --- a/src/main_pages/components/MessagePageContent.tsx +++ b/src/main_pages/components/MessagePageContent.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { View, Text, Image, ScrollView } from "@tarojs/components"; import { EmptyState } from "@/components"; import noticeService from "@/services/noticeService"; +import messageService from "@/services/messageService"; import { formatRelativeTime } from "@/utils/timeUtils"; import Taro from "@tarojs/taro"; import { useGlobalState } from "@/store/global"; @@ -32,6 +33,8 @@ const MessagePageContent = () => { const [loading, setLoading] = useState(false); const [reachedBottom, setReachedBottom] = useState(false); const [refreshing, setRefreshing] = useState(false); + const [commentUnreadCount, setCommentUnreadCount] = useState(0); + const [followUnreadCount, setFollowUnreadCount] = useState(0); const getNoticeList = async () => { if (loading) return; @@ -52,8 +55,22 @@ const MessagePageContent = () => { } }; + // 获取红点信息 + const getReddotInfo = async () => { + try { + const res = await messageService.getReddotInfo(); + if (res.code === 0) { + setCommentUnreadCount(res.data.comment_unread_count || 0); + setFollowUnreadCount(res.data.follow_unread_count || 0); + } + } catch (e) { + console.error("获取红点信息失败:", e); + } + }; + useEffect(() => { getNoticeList(); + getReddotInfo(); }, []); const filteredMessages = messageList; @@ -127,22 +144,36 @@ const MessagePageContent = () => { className={`tab-item ${activeTab === "comment" ? "active" : ""}`} onClick={() => handleTabClick("comment")} > - + + + {commentUnreadCount > 0 && ( + + {commentUnreadCount > 99 ? '99+' : commentUnreadCount} + + )} + 评论和回复 handleTabClick("follow")} > - + + + {followUnreadCount > 0 && ( + + {followUnreadCount > 99 ? '99+' : followUnreadCount} + + )} + 新增关注 diff --git a/src/other_pages/comment_reply/index.tsx b/src/other_pages/comment_reply/index.tsx index e7bdc41..d13bddc 100644 --- a/src/other_pages/comment_reply/index.tsx +++ b/src/other_pages/comment_reply/index.tsx @@ -3,6 +3,7 @@ import { View, Text, ScrollView, Image, Input } from "@tarojs/components"; import { withAuth, EmptyState, GeneralNavbar } from "@/components"; import { useGlobalState } from "@/store/global"; import commentService, { CommentActivity } from "@/services/commentService"; +import messageService from "@/services/messageService"; import { formatShortRelativeTime } from "@/utils/timeUtils"; import Taro from "@tarojs/taro"; import "./index.scss"; @@ -69,6 +70,18 @@ const CommentReply = () => { })); setCommentList(mappedList); + + // 获取未读评论ID并标记已读 + const unreadIds = res.data.rows + .filter((item: any) => item.is_read === 0 && item.activity_type === 'received_reply') + .map((item: any) => item.id); + + if (unreadIds.length > 0) { + // 使用统一接口标记已读 + messageService.markAsRead('comment', unreadIds).catch(e => { + console.error("标记评论已读失败:", e); + }); + } } } catch (e) { Taro.showToast({ diff --git a/src/other_pages/message/index.scss b/src/other_pages/message/index.scss index 4a43415..f03a5a2 100644 --- a/src/other_pages/message/index.scss +++ b/src/other_pages/message/index.scss @@ -26,6 +26,40 @@ align-items: center; width: 161px; + .tab-icon-wrapper { + position: relative; + width: 56px; + height: 56px; + + .tab-icon { + width: 56px; + height: 56px; + border-radius: 56px; + transition: all 0.3s; + } + + .badge { + position: absolute; + top: -4px; + right: -8px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 0 4.5px; + min-width: 20px; + height: 20px; + background: #FF2541; + border-radius: 10px; + font-family: "PingFang SC"; + font-weight: 600; + font-size: 10px; + line-height: 20px; + color: #ffffff; + text-align: center; + box-sizing: border-box; + } + } .tab-icon { width: 56px; diff --git a/src/other_pages/message/index.tsx b/src/other_pages/message/index.tsx index db5e9bc..80d0963 100644 --- a/src/other_pages/message/index.tsx +++ b/src/other_pages/message/index.tsx @@ -3,8 +3,9 @@ import { View, Text, Image, ScrollView } from "@tarojs/components"; import GuideBar from "@/components/GuideBar"; import { withAuth, EmptyState, GeneralNavbar } from "@/components"; import noticeService from "@/services/noticeService"; +import messageService from "@/services/messageService"; import { formatRelativeTime } from "@/utils/timeUtils"; -import Taro from "@tarojs/taro"; +import Taro, { useDidShow } from "@tarojs/taro"; import "./index.scss"; // 消息类型定义 @@ -30,6 +31,8 @@ const Message = () => { const [loading, setLoading] = useState(false); const [reachedBottom, setReachedBottom] = useState(false); const [refreshing, setRefreshing] = useState(false); + const [commentUnreadCount, setCommentUnreadCount] = useState(0); + const [followUnreadCount, setFollowUnreadCount] = useState(0); // 获取消息列表 const getNoticeList = async () => { @@ -51,10 +54,29 @@ const Message = () => { } }; + // 获取红点信息 + const getReddotInfo = async () => { + try { + const res = await messageService.getReddotInfo(); + if (res.code === 0) { + setCommentUnreadCount(res.data.comment_unread_count || 0); + setFollowUnreadCount(res.data.follow_unread_count || 0); + } + } catch (e) { + console.error("获取红点信息失败:", e); + } + }; + useEffect(() => { getNoticeList(); + getReddotInfo(); }, []); + // 每次页面显示时刷新红点信息 + useDidShow(() => { + getReddotInfo(); + }); + // 过滤系统消息 const filteredMessages = messageList; @@ -140,22 +162,36 @@ const Message = () => { className={`tab-item ${activeTab === "comment" ? "active" : ""}`} onClick={() => handleTabClick("comment")} > - + + + {commentUnreadCount > 0 && ( + + {commentUnreadCount > 99 ? '99+' : commentUnreadCount} + + )} + 评论和回复 handleTabClick("follow")} > - + + + {followUnreadCount > 0 && ( + + {followUnreadCount > 99 ? '99+' : followUnreadCount} + + )} + 新增关注 diff --git a/src/other_pages/new_follow/index.tsx b/src/other_pages/new_follow/index.tsx index 3ebed4a..5d38102 100644 --- a/src/other_pages/new_follow/index.tsx +++ b/src/other_pages/new_follow/index.tsx @@ -3,6 +3,7 @@ import { View, Text, ScrollView, Image } from "@tarojs/components"; import { withAuth, EmptyState, GeneralNavbar } from "@/components"; import { useGlobalState } from "@/store/global"; import FollowService from "@/services/followService"; +import messageService from "@/services/messageService"; import { formatShortRelativeTime } from "@/utils/timeUtils"; import Taro from "@tarojs/taro"; import "./index.scss"; @@ -34,11 +35,11 @@ const NewFollow = () => { // 获取新增关注列表 const getFollowList = async () => { if (loading) return; - + setLoading(true); try { const res = await FollowService.get_new_fans_list(1, 20); - + if (res.list && res.list.length > 0) { // 映射数据 const mappedList = res.list.map((item: any) => ({ @@ -52,8 +53,20 @@ const NewFollow = () => { time: item.follow_time, is_mutual: item.is_mutual || false, })); - + setFollowList(mappedList); + + // 获取未读关注ID并标记已读 + const unreadFanIds = res.list + .filter((item: any) => item.is_read === 0) + .map((item: any) => item.id); + + if (unreadFanIds.length > 0) { + // 使用统一接口标记已读 + messageService.markAsRead('follow', unreadFanIds).catch(e => { + console.error("标记关注已读失败:", e); + }); + } } else { // 如果没有数据,设置为空数组以显示空状态 setFollowList([]); diff --git a/src/services/messageService.ts b/src/services/messageService.ts new file mode 100644 index 0000000..142dd23 --- /dev/null +++ b/src/services/messageService.ts @@ -0,0 +1,40 @@ +import httpService, { ApiResponse } from './httpService'; + +// 红点信息响应接口 +export interface ReddotInfo { + comment_unread_count: number; // 评论/回复未读数量 + follow_unread_count: number; // 新增关注未读数量 + total_unread_count: number; // 总未读数量 + has_reddot: boolean; // 是否显示红点 +} + +// 标记已读类型 +export type MarkAsReadType = 'comment' | 'follow' | 'all'; + +// 标记已读响应 +export interface MarkAsReadResponse { + updated_count: number; + detail: { + comment_count: number; + follow_count: number; + }; + message: string; +} + +class MessageService { + // 获取红点信息 + async getReddotInfo(): Promise> { + return httpService.post('/message/reddot_info', {}, { showLoading: false }); + } + + // 标记消息已读 + async markAsRead(type: MarkAsReadType, ids?: number[]): Promise> { + const data: { type: MarkAsReadType; ids?: number[] } = { type }; + if (ids && ids.length > 0) { + data.ids = ids; + } + return httpService.post('/message/mark_as_read', data, { showLoading: false }); + } +} + +export default new MessageService();