feat:增加 APP 内消息提醒功能
This commit is contained in:
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user