添加红点

This commit is contained in:
张成
2025-11-21 08:42:58 +08:00
parent 35b9d07a91
commit fe523ac2bc
10 changed files with 814 additions and 25 deletions

View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(dir /s /b *.json)",
"Bash(findstr:*)"
],
"deny": [],
"ask": []
}
}

View File

@@ -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
<!-- pages/message/message.wxml -->
<view class="message-page">
<!-- 评论消息入口 -->
<view class="menu-item" bindtap="goToComments">
<text>评论与回复</text>
<view wx:if="{{commentUnreadCount > 0}}" class="badge">
{{commentUnreadCount}}
</view>
</view>
<!-- 关注消息入口 -->
<view class="menu-item" bindtap="goToFollows">
<text>新增关注</text>
<view wx:if="{{followUnreadCount > 0}}" class="badge">
{{followUnreadCount}}
</view>
</view>
</view>
```
---
### 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`

View File

@@ -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 {

View File

@@ -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) => {
<View
className={`guide-bar-pages-item ${currentPage === item.code ? "guide-bar-pages-item-active" : ""}`}
onClick={() => handlePageChange(item.code)}
key={item.code}
>
<Text>{item.text}</Text>
{/* {item.code === "message" && hasReddot && (
<View className="reddot"></View>
)} */}
</View>
))}
</View>

View File

@@ -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")}
>
<Image
className="tab-icon"
src={require('@/static/message/comment-icon.svg')}
mode="aspectFit"
/>
<View className="tab-icon-wrapper">
<Image
className="tab-icon"
src={require('@/static/message/comment-icon.svg')}
mode="aspectFit"
/>
{commentUnreadCount > 0 && (
<View className="badge">
{commentUnreadCount > 99 ? '99+' : commentUnreadCount}
</View>
)}
</View>
<Text className="tab-text"></Text>
</View>
<View
className={`tab-item ${activeTab === "follow" ? "active" : ""}`}
onClick={() => handleTabClick("follow")}
>
<Image
className="tab-icon"
src={require('@/static/message/follow-icon.svg')}
mode="aspectFit"
/>
<View className="tab-icon-wrapper">
<Image
className="tab-icon"
src={require('@/static/message/follow-icon.svg')}
mode="aspectFit"
/>
{followUnreadCount > 0 && (
<View className="badge">
{followUnreadCount > 99 ? '99+' : followUnreadCount}
</View>
)}
</View>
<Text className="tab-text"></Text>
</View>
</View>

View File

@@ -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({

View File

@@ -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;

View File

@@ -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")}
>
<Image
className="tab-icon"
src={require('@/static/message/comment-icon.svg')}
mode="aspectFit"
/>
<View className="tab-icon-wrapper">
<Image
className="tab-icon"
src={require('@/static/message/comment-icon.svg')}
mode="aspectFit"
/>
{commentUnreadCount > 0 && (
<View className="badge">
{commentUnreadCount > 99 ? '99+' : commentUnreadCount}
</View>
)}
</View>
<Text className="tab-text"></Text>
</View>
<View
className={`tab-item ${activeTab === "follow" ? "active" : ""}`}
onClick={() => handleTabClick("follow")}
>
<Image
className="tab-icon"
src={require('@/static/message/follow-icon.svg')}
mode="aspectFit"
/>
<View className="tab-icon-wrapper">
<Image
className="tab-icon"
src={require('@/static/message/follow-icon.svg')}
mode="aspectFit"
/>
{followUnreadCount > 0 && (
<View className="badge">
{followUnreadCount > 99 ? '99+' : followUnreadCount}
</View>
)}
</View>
<Text className="tab-text"></Text>
</View>
</View>

View File

@@ -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([]);

View File

@@ -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<ApiResponse<ReddotInfo>> {
return httpService.post('/message/reddot_info', {}, { showLoading: false });
}
// 标记消息已读
async markAsRead(type: MarkAsReadType, ids?: number[]): Promise<ApiResponse<MarkAsReadResponse>> {
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();