feat:增加 APP 内消息提醒功能

This commit is contained in:
amos
2025-12-19 16:56:31 +08:00
parent 9d630925f7
commit 2bc3add6e5
8 changed files with 175 additions and 4 deletions

View File

@@ -114,4 +114,20 @@ interface ApiService {
// Version
@GET("version")
suspend fun getLatestVersion(): Response<VersionInfo>
// Notifications
@GET("notifications")
suspend fun getNotifications(
@Query("page") page: Int = 1,
@Query("page_size") pageSize: Int = 20
): Response<List<Notification>>
@GET("notifications/unread")
suspend fun getUnreadCount(): Response<UnreadCountResponse>
@PUT("notifications/{id}/read")
suspend fun markAsRead(@Path("id") id: Long): Response<MessageResponse>
@PUT("notifications/read-all")
suspend fun markAllAsRead(): Response<MessageResponse>
}

View File

@@ -184,3 +184,28 @@ data class VersionInfo(
@SerialName("update_log") val updateLog: String,
@SerialName("force_update") val forceUpdate: Boolean
)
@Serializable
data class NotificationUser(
val id: Long,
val nickname: String,
@SerialName("avatar_url") val avatarUrl: String = ""
)
@Serializable
data class Notification(
val id: Long,
val type: String, // like, comment, reaction
@SerialName("from_user_id") val fromUserId: Long,
@SerialName("from_user") val fromUser: NotificationUser? = null,
@SerialName("post_id") val postId: Long? = null,
@SerialName("comment_id") val commentId: Long? = null,
val content: String = "",
@SerialName("is_read") val isRead: Boolean = false,
@SerialName("created_at") val createdAt: String
)
@Serializable
data class UnreadCountResponse(
val count: Int
)

View File

@@ -25,6 +25,7 @@ import com.memory.app.data.model.User
import com.memory.app.ui.screen.*
import com.memory.app.ui.theme.*
import com.memory.app.ui.viewmodel.HomeViewModel
import com.memory.app.ui.viewmodel.NotificationViewModel
import com.memory.app.ui.viewmodel.PostDetailViewModel
import com.memory.app.ui.viewmodel.ProfileViewModel
@@ -57,6 +58,7 @@ fun MainNavigation(
var editingPost by remember { mutableStateOf<com.memory.app.data.model.Post?>(null) }
val homeViewModel: HomeViewModel = viewModel()
val profileViewModel: ProfileViewModel = viewModel()
val notificationViewModel: NotificationViewModel = viewModel()
val context = LocalContext.current
val isPosting by homeViewModel.isPosting.collectAsState()
val postSuccess by homeViewModel.postSuccess.collectAsState()
@@ -65,10 +67,14 @@ fun MainNavigation(
val profilePostCount by profileViewModel.postCount.collectAsState()
val profileLikeCount by profileViewModel.likeCount.collectAsState()
val profileDayCount by profileViewModel.dayCount.collectAsState()
val unreadCount by notificationViewModel.unreadCount.collectAsState()
val notifications by notificationViewModel.notifications.collectAsState()
val notificationLoading by notificationViewModel.isLoading.collectAsState()
LaunchedEffect(user) {
profileViewModel.setUser(user)
profileViewModel.loadProfile()
notificationViewModel.loadUnreadCount()
}
LaunchedEffect(postSuccess) {
@@ -118,6 +124,7 @@ fun MainNavigation(
likeCount = profileLikeCount.takeIf { it > 0 } ?: likeCount,
dayCount = profileDayCount,
isDarkTheme = isDarkTheme,
unreadCount = unreadCount,
onEditProfile = { },
onLogout = onLogout,
onUpdateNickname = { nickname ->
@@ -127,7 +134,30 @@ fun MainNavigation(
profileViewModel.updateAvatar(context, uri)
},
onToggleTheme = onToggleTheme,
onCheckUpdate = onCheckUpdate
onCheckUpdate = onCheckUpdate,
onNotificationClick = {
navController.navigate("notifications")
}
)
}
composable("notifications") {
LaunchedEffect(Unit) {
notificationViewModel.loadNotifications()
}
NotificationScreen(
notifications = notifications,
isLoading = notificationLoading,
onBack = { navController.popBackStack() },
onNotificationClick = { notification ->
notificationViewModel.markAsRead(notification.id)
notification.postId?.let { postId ->
navController.navigate("post/$postId")
}
},
onMarkAllRead = {
notificationViewModel.markAllAsRead()
}
)
}

View File

@@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.outlined.DarkMode
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.LightMode
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.SystemUpdate
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -24,6 +25,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -43,12 +45,14 @@ fun ProfileScreen(
likeCount: Int,
dayCount: Int = 1,
isDarkTheme: Boolean = false,
unreadCount: Int = 0,
onEditProfile: () -> Unit,
onLogout: () -> Unit,
onUpdateNickname: ((String) -> Unit)? = null,
onUpdateAvatar: ((Uri) -> Unit)? = null,
onToggleTheme: (() -> Unit)? = null,
onCheckUpdate: (() -> Unit)? = null
onCheckUpdate: (() -> Unit)? = null,
onNotificationClick: (() -> Unit)? = null
) {
var isEditing by remember { mutableStateOf(false) }
var editedNickname by remember(user) { mutableStateOf(user?.nickname ?: "") }
@@ -130,6 +134,53 @@ fun ProfileScreen(
)
}
}
// Notification Button
Surface(
onClick = { onNotificationClick?.invoke() },
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Box {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.Notifications,
contentDescription = "消息",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "消息",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
}
// Unread badge
if (unreadCount > 0) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.offset(x = 4.dp, y = (-4).dp)
.size(18.dp)
.clip(CircleShape)
.background(ErrorRed),
contentAlignment = Alignment.Center
) {
Text(
text = if (unreadCount > 99) "99+" else unreadCount.toString(),
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
// Logout Button

View File

@@ -149,5 +149,25 @@ func migrate(db *sql.DB) error {
)
`)
// 迁移:创建通知表
db.Exec(`
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL,
from_user_id INTEGER NOT NULL,
post_id INTEGER,
comment_id INTEGER,
content TEXT DEFAULT '',
is_read INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
)
`)
db.Exec("CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id)")
db.Exec("CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications(is_read)")
return nil
}

View File

@@ -49,6 +49,12 @@ func (h *CommentHandler) Create(c *gin.Context) {
}
commentID, _ := result.LastInsertId()
// 创建通知
var postOwnerID int64
h.db.QueryRow("SELECT user_id FROM posts WHERE id = ?", postID).Scan(&postOwnerID)
CreateNotification(h.db, postOwnerID, userID, "comment", &postID, &commentID, req.Content)
c.JSON(http.StatusCreated, gin.H{"id": commentID})
}

View File

@@ -290,12 +290,20 @@ func (h *PostHandler) Like(c *gin.Context) {
postID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
userID := middleware.GetUserID(c)
_, err := h.db.Exec("INSERT OR IGNORE INTO likes (post_id, user_id) VALUES (?, ?)", postID, userID)
result, err := h.db.Exec("INSERT OR IGNORE INTO likes (post_id, user_id) VALUES (?, ?)", postID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to like"})
return
}
// 创建通知
rowsAffected, _ := result.RowsAffected()
if rowsAffected > 0 {
var postOwnerID int64
h.db.QueryRow("SELECT user_id FROM posts WHERE id = ?", postID).Scan(&postOwnerID)
CreateNotification(h.db, postOwnerID, userID, "like", &postID, nil, "")
}
c.JSON(http.StatusOK, gin.H{"message": "liked"})
}
@@ -322,7 +330,7 @@ func (h *PostHandler) AddReaction(c *gin.Context) {
return
}
_, err := h.db.Exec(
result, err := h.db.Exec(
"INSERT OR IGNORE INTO reactions (post_id, user_id, emoji) VALUES (?, ?, ?)",
postID, userID, req.Emoji,
)
@@ -331,6 +339,14 @@ func (h *PostHandler) AddReaction(c *gin.Context) {
return
}
// 创建通知
rowsAffected, _ := result.RowsAffected()
if rowsAffected > 0 {
var postOwnerID int64
h.db.QueryRow("SELECT user_id FROM posts WHERE id = ?", postID).Scan(&postOwnerID)
CreateNotification(h.db, postOwnerID, userID, "reaction", &postID, nil, req.Emoji)
}
c.JSON(http.StatusOK, gin.H{"message": "reaction added"})
}

View File

@@ -39,6 +39,7 @@ func Setup(db *sql.DB, cfg *config.Config) *gin.Engine {
userHandler := handler.NewUserHandler(db, cfg)
searchHandler := handler.NewSearchHandler(db, cfg)
uploadHandler := handler.NewUploadHandler(cfg)
notificationHandler := handler.NewNotificationHandler(db, cfg)
// R2 文件代理 (公开访问)
r.GET("/files/*filepath", uploadHandler.GetFile)
@@ -85,6 +86,12 @@ func Setup(db *sql.DB, cfg *config.Config) *gin.Engine {
// 上传
auth.POST("/upload", uploadHandler.Upload)
// 通知
auth.GET("/notifications", notificationHandler.List)
auth.GET("/notifications/unread", notificationHandler.UnreadCount)
auth.PUT("/notifications/:id/read", notificationHandler.MarkAsRead)
auth.PUT("/notifications/read-all", notificationHandler.MarkAllAsRead)
// 管理员接口
admin := auth.Group("/admin")
admin.Use(middleware.AdminMiddleware())