From 640fbd6ea49dba005291b60d094813e9e5895b8d Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Tue, 19 Aug 2025 13:56:36 +0800 Subject: [PATCH] feat: add paginated notifications with infinite scroll --- .../controller/NotificationController.java | 4 +- .../repository/NotificationRepository.java | 4 + .../openisle/service/NotificationService.java | 9 +- frontend_nuxt/pages/message.vue | 23 +- frontend_nuxt/utils/notification.js | 351 ++++++++++-------- 5 files changed, 217 insertions(+), 174 deletions(-) diff --git a/backend/src/main/java/com/openisle/controller/NotificationController.java b/backend/src/main/java/com/openisle/controller/NotificationController.java index d25d2a808..85f5e4f3b 100644 --- a/backend/src/main/java/com/openisle/controller/NotificationController.java +++ b/backend/src/main/java/com/openisle/controller/NotificationController.java @@ -24,8 +24,10 @@ public class NotificationController { @GetMapping public List list(@RequestParam(value = "read", required = false) Boolean read, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "pageSize", defaultValue = "50") int pageSize, Authentication auth) { - return notificationService.listNotifications(auth.getName(), read).stream() + return notificationService.listNotifications(auth.getName(), read, page, pageSize).stream() .map(notificationMapper::toDto) .collect(Collectors.toList()); } diff --git a/backend/src/main/java/com/openisle/repository/NotificationRepository.java b/backend/src/main/java/com/openisle/repository/NotificationRepository.java index 1e897b3a0..f53dceb4d 100644 --- a/backend/src/main/java/com/openisle/repository/NotificationRepository.java +++ b/backend/src/main/java/com/openisle/repository/NotificationRepository.java @@ -5,6 +5,7 @@ import com.openisle.model.User; import com.openisle.model.Post; import com.openisle.model.Comment; import com.openisle.model.NotificationType; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; @@ -13,6 +14,9 @@ import java.util.List; public interface NotificationRepository extends JpaRepository { List findByUserOrderByCreatedAtDesc(User user); List findByUserAndReadOrderByCreatedAtDesc(User user, boolean read); + + List findByUserOrderByCreatedAtDesc(User user, Pageable pageable); + List findByUserAndReadOrderByCreatedAtDesc(User user, boolean read, Pageable pageable); long countByUserAndRead(User user, boolean read); List findByPost(Post post); List findByComment(Comment comment); diff --git a/backend/src/main/java/com/openisle/service/NotificationService.java b/backend/src/main/java/com/openisle/service/NotificationService.java index 6ecf571e8..b150aef8b 100644 --- a/backend/src/main/java/com/openisle/service/NotificationService.java +++ b/backend/src/main/java/com/openisle/service/NotificationService.java @@ -8,6 +8,8 @@ import com.openisle.repository.UserRepository; import lombok.RequiredArgsConstructor; import com.openisle.service.EmailSender; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.transaction.annotation.Transactional; @@ -180,15 +182,16 @@ public class NotificationService { userRepository.save(user); } - public List listNotifications(String username, Boolean read) { + public List listNotifications(String username, Boolean read, int page, int pageSize) { User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); Set disabled = user.getDisabledNotificationTypes(); + Pageable pageable = PageRequest.of(page, pageSize); List list; if (read == null) { - list = notificationRepository.findByUserOrderByCreatedAtDesc(user); + list = notificationRepository.findByUserOrderByCreatedAtDesc(user, pageable); } else { - list = notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, read); + list = notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, read, pageable); } return list.stream().filter(n -> !disabled.contains(n.getType())).collect(Collectors.toList()); } diff --git a/frontend_nuxt/pages/message.vue b/frontend_nuxt/pages/message.vue index 461d8f4bb..909a41953 100644 --- a/frontend_nuxt/pages/message.vue +++ b/frontend_nuxt/pages/message.vue @@ -48,7 +48,7 @@ diff --git a/frontend_nuxt/utils/notification.js b/frontend_nuxt/utils/notification.js index 7c0245b94..1d7cf3cf7 100644 --- a/frontend_nuxt/utils/notification.js +++ b/frontend_nuxt/utils/notification.js @@ -118,175 +118,190 @@ export async function updateNotificationPreference(type, enabled) { function createFetchNotifications() { const notifications = ref([]) const isLoadingMessage = ref(false) - const fetchNotifications = async () => { + const page = ref(0) + const pageSize = 50 + const allLoaded = ref(false) + const fetchNotifications = async (reset = false) => { const config = useRuntimeConfig() const API_BASE_URL = config.public.apiBaseUrl - if (isLoadingMessage && notifications && markRead) { - try { - const token = getToken() - if (!token) { - toast.error('请先登录') - return - } - isLoadingMessage.value = true + if (isLoadingMessage.value || (allLoaded.value && !reset)) return + try { + const token = getToken() + if (!token) { + toast.error('请先登录') + return + } + if (reset) { notifications.value = [] - const res = await fetch(`${API_BASE_URL}/api/notifications`, { + page.value = 0 + allLoaded.value = false + } + isLoadingMessage.value = true + const res = await fetch( + `${API_BASE_URL}/api/notifications?page=${page.value}&pageSize=${pageSize}`, + { headers: { Authorization: `Bearer ${token}`, }, - }) - isLoadingMessage.value = false - if (!res.ok) { - toast.error('获取通知失败') - return - } - const data = await res.json() - - for (const n of data) { - if (n.type === 'COMMENT_REPLY') { - notifications.value.push({ - ...n, - src: n.comment.author.avatar, - iconClick: () => { - markRead(n.id) - navigateTo(`/users/${n.comment.author.id}`, { replace: true }) - }, - }) - } else if (n.type === 'REACTION') { - notifications.value.push({ - ...n, - emoji: reactionEmojiMap[n.reactionType], - iconClick: () => { - if (n.fromUser) { - markRead(n.id) - navigateTo(`/users/${n.fromUser.id}`, { replace: true }) - } - }, - }) - } else if (n.type === 'POST_VIEWED') { - notifications.value.push({ - ...n, - src: n.fromUser ? n.fromUser.avatar : null, - icon: n.fromUser ? undefined : iconMap[n.type], - iconClick: () => { - if (n.fromUser) { - markRead(n.id) - navigateTo(`/users/${n.fromUser.id}`, { replace: true }) - } - }, - }) - } else if (n.type === 'LOTTERY_WIN') { - notifications.value.push({ - ...n, - icon: iconMap[n.type], - iconClick: () => { - if (n.post) { - markRead(n.id) - router.push(`/posts/${n.post.id}`) - } - }, - }) - } else if (n.type === 'LOTTERY_DRAW') { - notifications.value.push({ - ...n, - icon: iconMap[n.type], - iconClick: () => { - if (n.post) { - markRead(n.id) - router.push(`/posts/${n.post.id}`) - } - }, - }) - } else if (n.type === 'POST_UPDATED') { - notifications.value.push({ - ...n, - src: n.comment.author.avatar, - iconClick: () => { - markRead(n.id) - navigateTo(`/users/${n.comment.author.id}`, { replace: true }) - }, - }) - } else if (n.type === 'USER_ACTIVITY') { - notifications.value.push({ - ...n, - src: n.comment.author.avatar, - iconClick: () => { - markRead(n.id) - navigateTo(`/users/${n.comment.author.id}`, { replace: true }) - }, - }) - } else if (n.type === 'MENTION') { - notifications.value.push({ - ...n, - icon: iconMap[n.type], - iconClick: () => { - if (n.fromUser) { - markRead(n.id) - navigateTo(`/users/${n.fromUser.id}`, { replace: true }) - } - }, - }) - } else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') { - notifications.value.push({ - ...n, - icon: iconMap[n.type], - iconClick: () => { - if (n.fromUser) { - markRead(n.id) - navigateTo(`/users/${n.fromUser.id}`, { replace: true }) - } - }, - }) - } else if (n.type === 'FOLLOWED_POST') { - notifications.value.push({ - ...n, - icon: iconMap[n.type], - iconClick: () => { - if (n.post) { - markRead(n.id) - navigateTo(`/posts/${n.post.id}`, { replace: true }) - } - }, - }) - } else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') { - notifications.value.push({ - ...n, - icon: iconMap[n.type], - iconClick: () => { - if (n.post) { - markRead(n.id) - navigateTo(`/posts/${n.post.id}`, { replace: true }) - } - }, - }) - } else if (n.type === 'POST_REVIEW_REQUEST') { - notifications.value.push({ - ...n, - src: n.fromUser ? n.fromUser.avatar : null, - icon: n.fromUser ? undefined : iconMap[n.type], - iconClick: () => { - if (n.post) { - markRead(n.id) - navigateTo(`/posts/${n.post.id}`, { replace: true }) - } - }, - }) - } else if (n.type === 'REGISTER_REQUEST') { - notifications.value.push({ - ...n, - icon: iconMap[n.type], - iconClick: () => {}, - }) - } else { - notifications.value.push({ - ...n, - icon: iconMap[n.type], - }) - } - } - } catch (e) { - console.error(e) + }, + ) + isLoadingMessage.value = false + if (!res.ok) { + toast.error('获取通知失败') + return } + const data = await res.json() + + for (const n of data) { + if (n.type === 'COMMENT_REPLY') { + notifications.value.push({ + ...n, + src: n.comment.author.avatar, + iconClick: () => { + markRead(n.id) + navigateTo(`/users/${n.comment.author.id}`, { replace: true }) + }, + }) + } else if (n.type === 'REACTION') { + notifications.value.push({ + ...n, + emoji: reactionEmojiMap[n.reactionType], + iconClick: () => { + if (n.fromUser) { + markRead(n.id) + navigateTo(`/users/${n.fromUser.id}`, { replace: true }) + } + }, + }) + } else if (n.type === 'POST_VIEWED') { + notifications.value.push({ + ...n, + src: n.fromUser ? n.fromUser.avatar : null, + icon: n.fromUser ? undefined : iconMap[n.type], + iconClick: () => { + if (n.fromUser) { + markRead(n.id) + navigateTo(`/users/${n.fromUser.id}`, { replace: true }) + } + }, + }) + } else if (n.type === 'LOTTERY_WIN') { + notifications.value.push({ + ...n, + icon: iconMap[n.type], + iconClick: () => { + if (n.post) { + markRead(n.id) + router.push(`/posts/${n.post.id}`) + } + }, + }) + } else if (n.type === 'LOTTERY_DRAW') { + notifications.value.push({ + ...n, + icon: iconMap[n.type], + iconClick: () => { + if (n.post) { + markRead(n.id) + router.push(`/posts/${n.post.id}`) + } + }, + }) + } else if (n.type === 'POST_UPDATED') { + notifications.value.push({ + ...n, + src: n.comment.author.avatar, + iconClick: () => { + markRead(n.id) + navigateTo(`/users/${n.comment.author.id}`, { replace: true }) + }, + }) + } else if (n.type === 'USER_ACTIVITY') { + notifications.value.push({ + ...n, + src: n.comment.author.avatar, + iconClick: () => { + markRead(n.id) + navigateTo(`/users/${n.comment.author.id}`, { replace: true }) + }, + }) + } else if (n.type === 'MENTION') { + notifications.value.push({ + ...n, + icon: iconMap[n.type], + iconClick: () => { + if (n.fromUser) { + markRead(n.id) + navigateTo(`/users/${n.fromUser.id}`, { replace: true }) + } + }, + }) + } else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') { + notifications.value.push({ + ...n, + icon: iconMap[n.type], + iconClick: () => { + if (n.fromUser) { + markRead(n.id) + navigateTo(`/users/${n.fromUser.id}`, { replace: true }) + } + }, + }) + } else if (n.type === 'FOLLOWED_POST') { + notifications.value.push({ + ...n, + icon: iconMap[n.type], + iconClick: () => { + if (n.post) { + markRead(n.id) + navigateTo(`/posts/${n.post.id}`, { replace: true }) + } + }, + }) + } else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') { + notifications.value.push({ + ...n, + icon: iconMap[n.type], + iconClick: () => { + if (n.post) { + markRead(n.id) + navigateTo(`/posts/${n.post.id}`, { replace: true }) + } + }, + }) + } else if (n.type === 'POST_REVIEW_REQUEST') { + notifications.value.push({ + ...n, + src: n.fromUser ? n.fromUser.avatar : null, + icon: n.fromUser ? undefined : iconMap[n.type], + iconClick: () => { + if (n.post) { + markRead(n.id) + navigateTo(`/posts/${n.post.id}`, { replace: true }) + } + }, + }) + } else if (n.type === 'REGISTER_REQUEST') { + notifications.value.push({ + ...n, + icon: iconMap[n.type], + iconClick: () => {}, + }) + } else { + notifications.value.push({ + ...n, + icon: iconMap[n.type], + }) + } + } + + if (data.length < pageSize) { + allLoaded.value = true + } else { + page.value++ + } + } catch (e) { + console.error(e) } } @@ -335,10 +350,16 @@ function createFetchNotifications() { markRead, notifications, isLoadingMessage, - markRead, markAllRead, + allLoaded, } } -export const { fetchNotifications, markRead, notifications, isLoadingMessage, markAllRead } = - createFetchNotifications() +export const { + fetchNotifications, + markRead, + notifications, + isLoadingMessage, + markAllRead, + allLoaded, +} = createFetchNotifications()