feat:增加 APP 内消息提醒功能
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
package com.memory.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.DoneAll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.memory.app.data.model.Notification
|
||||
import com.memory.app.ui.theme.*
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NotificationScreen(
|
||||
notifications: List<Notification>,
|
||||
isLoading: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onNotificationClick: (Notification) -> Unit,
|
||||
onMarkAllRead: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
// Top Bar
|
||||
TopAppBar(
|
||||
title = { Text("消息通知", fontWeight = FontWeight.Bold) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (notifications.any { !it.isRead }) {
|
||||
IconButton(onClick = onMarkAllRead) {
|
||||
Icon(
|
||||
Icons.Default.DoneAll,
|
||||
contentDescription = "全部已读",
|
||||
tint = Brand500
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
|
||||
if (isLoading && notifications.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = Brand500)
|
||||
}
|
||||
} else if (notifications.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "暂无消息",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
items(notifications) { notification ->
|
||||
NotificationItem(
|
||||
notification = notification,
|
||||
onClick = { onNotificationClick(notification) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationItem(
|
||||
notification: Notification,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.background(
|
||||
if (!notification.isRead)
|
||||
Brand500.copy(alpha = 0.05f)
|
||||
else
|
||||
MaterialTheme.colorScheme.background
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
// Avatar
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (notification.fromUser?.avatarUrl?.isNotEmpty() == true) {
|
||||
AsyncImage(
|
||||
model = notification.fromUser.avatarUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = notification.fromUser?.nickname?.firstOrNull()?.toString() ?: "?",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Title
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = notification.fromUser?.nickname ?: "用户",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = getNotificationAction(notification),
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Content preview
|
||||
if (notification.content.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = notification.content,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
// Time
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = formatNotificationTime(notification.createdAt),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
|
||||
// Unread indicator
|
||||
if (!notification.isRead) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Brand500)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNotificationAction(notification: Notification): String {
|
||||
return when (notification.type) {
|
||||
"like" -> "赞了你的帖子"
|
||||
"comment" -> "评论了你的帖子"
|
||||
"reaction" -> "对你的帖子发送了 ${notification.content}"
|
||||
else -> "与你互动"
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatNotificationTime(createdAt: String): String {
|
||||
return try {
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
val time = LocalDateTime.parse(createdAt.replace("T", " ").substringBefore("+").substringBefore("Z"), formatter)
|
||||
val now = LocalDateTime.now()
|
||||
val minutes = ChronoUnit.MINUTES.between(time, now)
|
||||
val hours = ChronoUnit.HOURS.between(time, now)
|
||||
val days = ChronoUnit.DAYS.between(time, now)
|
||||
|
||||
when {
|
||||
minutes < 1 -> "刚刚"
|
||||
minutes < 60 -> "${minutes}分钟前"
|
||||
hours < 24 -> "${hours}小时前"
|
||||
days < 7 -> "${days}天前"
|
||||
else -> time.format(DateTimeFormatter.ofPattern("MM-dd"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
createdAt.substringBefore("T")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.memory.app.ui.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.memory.app.data.api.ApiClient
|
||||
import com.memory.app.data.model.Notification
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NotificationViewModel : ViewModel() {
|
||||
private val _notifications = MutableStateFlow<List<Notification>>(emptyList())
|
||||
val notifications: StateFlow<List<Notification>> = _notifications
|
||||
|
||||
private val _unreadCount = MutableStateFlow(0)
|
||||
val unreadCount: StateFlow<Int> = _unreadCount
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading
|
||||
|
||||
fun loadNotifications() {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
val response = ApiClient.api.getNotifications()
|
||||
if (response.isSuccessful) {
|
||||
_notifications.value = response.body() ?: emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadUnreadCount() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = ApiClient.api.getUnreadCount()
|
||||
if (response.isSuccessful) {
|
||||
_unreadCount.value = response.body()?.count ?: 0
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun markAsRead(notificationId: Long) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = ApiClient.api.markAsRead(notificationId)
|
||||
if (response.isSuccessful) {
|
||||
_notifications.value = _notifications.value.map {
|
||||
if (it.id == notificationId) it.copy(isRead = true) else it
|
||||
}
|
||||
_unreadCount.value = (_unreadCount.value - 1).coerceAtLeast(0)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun markAllAsRead() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = ApiClient.api.markAllAsRead()
|
||||
if (response.isSuccessful) {
|
||||
_notifications.value = _notifications.value.map { it.copy(isRead = true) }
|
||||
_unreadCount.value = 0
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
162
server/internal/handler/notification.go
Normal file
162
server/internal/handler/notification.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"memory/internal/config"
|
||||
"memory/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type NotificationHandler struct {
|
||||
db *sql.DB
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewNotificationHandler(db *sql.DB, cfg *config.Config) *NotificationHandler {
|
||||
return &NotificationHandler{db: db, cfg: cfg}
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"` // like, comment, reaction
|
||||
FromUserID int64 `json:"from_user_id"`
|
||||
FromUser *struct {
|
||||
ID int64 `json:"id"`
|
||||
Nickname string `json:"nickname"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
} `json:"from_user,omitempty"`
|
||||
PostID *int64 `json:"post_id,omitempty"`
|
||||
CommentID *int64 `json:"comment_id,omitempty"`
|
||||
Content string `json:"content"`
|
||||
IsRead bool `json:"is_read"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// 获取通知列表
|
||||
func (h *NotificationHandler) List(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
rows, err := h.db.Query(`
|
||||
SELECT n.id, n.type, n.from_user_id, n.post_id, n.comment_id, n.content, n.is_read, n.created_at,
|
||||
u.id, u.nickname, u.avatar_url
|
||||
FROM notifications n
|
||||
JOIN users u ON n.from_user_id = u.id
|
||||
WHERE n.user_id = ?
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, userID, pageSize, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
notifications := []Notification{}
|
||||
for rows.Next() {
|
||||
var n Notification
|
||||
var postID, commentID sql.NullInt64
|
||||
var fromUserID, fuID int64
|
||||
var fuNickname, fuAvatarURL string
|
||||
var isRead int
|
||||
|
||||
err := rows.Scan(
|
||||
&n.ID, &n.Type, &fromUserID, &postID, &commentID, &n.Content, &isRead, &n.CreatedAt,
|
||||
&fuID, &fuNickname, &fuAvatarURL,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
n.FromUserID = fromUserID
|
||||
n.IsRead = isRead == 1
|
||||
if postID.Valid {
|
||||
n.PostID = &postID.Int64
|
||||
}
|
||||
if commentID.Valid {
|
||||
n.CommentID = &commentID.Int64
|
||||
}
|
||||
n.FromUser = &struct {
|
||||
ID int64 `json:"id"`
|
||||
Nickname string `json:"nickname"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}{
|
||||
ID: fuID,
|
||||
Nickname: fuNickname,
|
||||
AvatarURL: fuAvatarURL,
|
||||
}
|
||||
|
||||
notifications = append(notifications, n)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, notifications)
|
||||
}
|
||||
|
||||
// 获取未读数量
|
||||
func (h *NotificationHandler) UnreadCount(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
var count int
|
||||
err := h.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM notifications WHERE user_id = ? AND is_read = 0
|
||||
`, userID).Scan(&count)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"count": count})
|
||||
}
|
||||
|
||||
// 标记为已读
|
||||
func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
notificationID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
|
||||
_, err := h.db.Exec(`
|
||||
UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ?
|
||||
`, notificationID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "marked as read"})
|
||||
}
|
||||
|
||||
// 标记全部已读
|
||||
func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
_, err := h.db.Exec(`
|
||||
UPDATE notifications SET is_read = 1 WHERE user_id = ?
|
||||
`, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "all marked as read"})
|
||||
}
|
||||
|
||||
// 创建通知(内部使用)
|
||||
func CreateNotification(db *sql.DB, userID, fromUserID int64, notifType string, postID *int64, commentID *int64, content string) {
|
||||
// 不给自己发通知
|
||||
if userID == fromUserID {
|
||||
return
|
||||
}
|
||||
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO notifications (user_id, type, from_user_id, post_id, comment_id, content)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, userID, notifType, fromUserID, postID, commentID, content)
|
||||
if err != nil {
|
||||
// 忽略错误,通知不是关键功能
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user