diff --git a/backend/src/main/java/com/openisle/controller/NotificationController.java b/backend/src/main/java/com/openisle/controller/NotificationController.java index d25d2a808..b9cb282ba 100644 --- a/backend/src/main/java/com/openisle/controller/NotificationController.java +++ b/backend/src/main/java/com/openisle/controller/NotificationController.java @@ -23,9 +23,19 @@ public class NotificationController { private final NotificationMapper notificationMapper; @GetMapping - public List list(@RequestParam(value = "read", required = false) Boolean read, + public List list(@RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "30") int size, Authentication auth) { - return notificationService.listNotifications(auth.getName(), read).stream() + return notificationService.listNotifications(auth.getName(), null, page, size).stream() + .map(notificationMapper::toDto) + .collect(Collectors.toList()); + } + + @GetMapping("/unread") + public List listUnread(@RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "30") int size, + Authentication auth) { + return notificationService.listNotifications(auth.getName(), false, page, size).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..d0a68ca68 100644 --- a/backend/src/main/java/com/openisle/repository/NotificationRepository.java +++ b/backend/src/main/java/com/openisle/repository/NotificationRepository.java @@ -6,6 +6,8 @@ import com.openisle.model.Post; import com.openisle.model.Comment; import com.openisle.model.NotificationType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import java.util.List; @@ -13,7 +15,12 @@ import java.util.List; public interface NotificationRepository extends JpaRepository { List findByUserOrderByCreatedAtDesc(User user); List findByUserAndReadOrderByCreatedAtDesc(User user, boolean read); + Page findByUserOrderByCreatedAtDesc(User user, Pageable pageable); + Page findByUserAndReadOrderByCreatedAtDesc(User user, boolean read, Pageable pageable); + Page findByUserAndTypeNotInOrderByCreatedAtDesc(User user, java.util.Collection types, Pageable pageable); + Page findByUserAndReadAndTypeNotInOrderByCreatedAtDesc(User user, boolean read, java.util.Collection types, Pageable pageable); long countByUserAndRead(User user, boolean read); + long countByUserAndReadAndTypeNotIn(User user, boolean read, java.util.Collection types); 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..91b9847d3 100644 --- a/backend/src/main/java/com/openisle/service/NotificationService.java +++ b/backend/src/main/java/com/openisle/service/NotificationService.java @@ -23,7 +23,6 @@ import java.util.HashSet; import java.util.List; import java.util.ArrayList; import java.util.concurrent.Executor; -import java.util.stream.Collectors; /** Service for creating and retrieving notifications. */ @Service @@ -180,17 +179,26 @@ public class NotificationService { userRepository.save(user); } - public List listNotifications(String username, Boolean read) { + public List listNotifications(String username, Boolean read, int page, int size) { User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); Set disabled = user.getDisabledNotificationTypes(); - List list; + org.springframework.data.domain.Pageable pageable = org.springframework.data.domain.PageRequest.of(page, size); + org.springframework.data.domain.Page result; if (read == null) { - list = notificationRepository.findByUserOrderByCreatedAtDesc(user); + if (disabled.isEmpty()) { + result = notificationRepository.findByUserOrderByCreatedAtDesc(user, pageable); + } else { + result = notificationRepository.findByUserAndTypeNotInOrderByCreatedAtDesc(user, disabled, pageable); + } } else { - list = notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, read); + if (disabled.isEmpty()) { + result = notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, read, pageable); + } else { + result = notificationRepository.findByUserAndReadAndTypeNotInOrderByCreatedAtDesc(user, read, disabled, pageable); + } } - return list.stream().filter(n -> !disabled.contains(n.getType())).collect(Collectors.toList()); + return result.getContent(); } public void markRead(String username, List ids) { @@ -209,8 +217,10 @@ public class NotificationService { User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); Set disabled = user.getDisabledNotificationTypes(); - return notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, false).stream() - .filter(n -> !disabled.contains(n.getType())).count(); + if (disabled.isEmpty()) { + return notificationRepository.countByUserAndRead(user, false); + } + return notificationRepository.countByUserAndReadAndTypeNotIn(user, false, disabled); } public void notifyMentions(String content, User fromUser, Post post, Comment comment) { diff --git a/backend/src/test/java/com/openisle/controller/NotificationControllerTest.java b/backend/src/test/java/com/openisle/controller/NotificationControllerTest.java index 6b74440a1..c3cea2d30 100644 --- a/backend/src/test/java/com/openisle/controller/NotificationControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/NotificationControllerTest.java @@ -45,7 +45,7 @@ class NotificationControllerTest { p.setId(2L); n.setPost(p); n.setCreatedAt(LocalDateTime.now()); - when(notificationService.listNotifications("alice", null)) + when(notificationService.listNotifications("alice", null, 0, 30)) .thenReturn(List.of(n)); NotificationDto dto = new NotificationDto(); @@ -62,6 +62,24 @@ class NotificationControllerTest { .andExpect(jsonPath("$[0].post.id").value(2)); } + @Test + void listUnreadNotifications() throws Exception { + Notification n = new Notification(); + n.setId(5L); + n.setType(NotificationType.POST_VIEWED); + when(notificationService.listNotifications("alice", false, 0, 30)) + .thenReturn(List.of(n)); + + NotificationDto dto = new NotificationDto(); + dto.setId(5L); + when(notificationMapper.toDto(n)).thenReturn(dto); + + mockMvc.perform(get("/api/notifications/unread") + .principal(new UsernamePasswordAuthenticationToken("alice","p"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(5)); + } + @Test void markReadEndpoint() throws Exception { mockMvc.perform(post("/api/notifications/read") diff --git a/backend/src/test/java/com/openisle/service/NotificationServiceTest.java b/backend/src/test/java/com/openisle/service/NotificationServiceTest.java index a01d3c95b..e6be00fea 100644 --- a/backend/src/test/java/com/openisle/service/NotificationServiceTest.java +++ b/backend/src/test/java/com/openisle/service/NotificationServiceTest.java @@ -11,6 +11,9 @@ import org.mockito.Mockito; import java.util.List; import java.util.Optional; +import java.util.HashSet; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -62,15 +65,17 @@ class NotificationServiceTest { User user = new User(); user.setId(2L); user.setUsername("bob"); + user.setDisabledNotificationTypes(new HashSet<>()); when(uRepo.findByUsername("bob")).thenReturn(Optional.of(user)); Notification n = new Notification(); - when(nRepo.findByUserOrderByCreatedAtDesc(user)).thenReturn(List.of(n)); + when(nRepo.findByUserOrderByCreatedAtDesc(eq(user), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(n))); - List list = service.listNotifications("bob", null); + List list = service.listNotifications("bob", null, 0, 10); assertEquals(1, list.size()); - verify(nRepo).findByUserOrderByCreatedAtDesc(user); + verify(nRepo).findByUserOrderByCreatedAtDesc(eq(user), any(Pageable.class)); } @Test @@ -87,6 +92,7 @@ class NotificationServiceTest { User user = new User(); user.setId(3L); user.setUsername("carl"); + user.setDisabledNotificationTypes(new HashSet<>()); when(uRepo.findByUsername("carl")).thenReturn(Optional.of(user)); when(nRepo.countByUserAndRead(user, false)).thenReturn(5L); @@ -96,6 +102,56 @@ class NotificationServiceTest { verify(nRepo).countByUserAndRead(user, false); } + @Test + void listNotificationsFiltersDisabledTypes() { + NotificationRepository nRepo = mock(NotificationRepository.class); + UserRepository uRepo = mock(UserRepository.class); + ReactionRepository rRepo = mock(ReactionRepository.class); + EmailSender email = mock(EmailSender.class); + PushNotificationService push = mock(PushNotificationService.class); + Executor executor = Runnable::run; + NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor); + org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com"); + + User user = new User(); + user.setId(4L); + user.setUsername("dana"); + when(uRepo.findByUsername("dana")).thenReturn(Optional.of(user)); + + Notification n = new Notification(); + when(nRepo.findByUserAndTypeNotInOrderByCreatedAtDesc(eq(user), eq(user.getDisabledNotificationTypes()), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(n))); + + List list = service.listNotifications("dana", null, 0, 10); + + assertEquals(1, list.size()); + verify(nRepo).findByUserAndTypeNotInOrderByCreatedAtDesc(eq(user), eq(user.getDisabledNotificationTypes()), any(Pageable.class)); + } + + @Test + void countUnreadFiltersDisabledTypes() { + NotificationRepository nRepo = mock(NotificationRepository.class); + UserRepository uRepo = mock(UserRepository.class); + ReactionRepository rRepo = mock(ReactionRepository.class); + EmailSender email = mock(EmailSender.class); + PushNotificationService push = mock(PushNotificationService.class); + Executor executor = Runnable::run; + NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor); + org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com"); + + User user = new User(); + user.setId(5L); + user.setUsername("erin"); + when(uRepo.findByUsername("erin")).thenReturn(Optional.of(user)); + when(nRepo.countByUserAndReadAndTypeNotIn(eq(user), eq(false), eq(user.getDisabledNotificationTypes()))) + .thenReturn(2L); + + long count = service.countUnread("erin"); + + assertEquals(2L, count); + verify(nRepo).countByUserAndReadAndTypeNotIn(eq(user), eq(false), eq(user.getDisabledNotificationTypes())); + } + @Test void createRegisterRequestNotificationsDeletesOldOnes() { NotificationRepository nRepo = mock(NotificationRepository.class); diff --git a/frontend_nuxt/components/InfiniteLoadMore.vue b/frontend_nuxt/components/InfiniteLoadMore.vue new file mode 100644 index 000000000..441c0ecf4 --- /dev/null +++ b/frontend_nuxt/components/InfiniteLoadMore.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/frontend_nuxt/pages/index.vue b/frontend_nuxt/pages/index.vue index 287b6c8ae..476ce7757 100644 --- a/frontend_nuxt/pages/index.vue +++ b/frontend_nuxt/pages/index.vue @@ -102,25 +102,33 @@ +
热门帖子功能开发中,敬请期待。
分类浏览功能开发中,敬请期待。
-
- -
+ + + + @@ -644,8 +670,6 @@ onActivated(() => { .message-page { background-color: var(--background-color); overflow-x: hidden; - height: calc(100vh - var(--header-height)); - overflow-y: auto; } .message-page-header { diff --git a/frontend_nuxt/utils/loadMore.js b/frontend_nuxt/utils/loadMore.js deleted file mode 100644 index d7d6813aa..000000000 --- a/frontend_nuxt/utils/loadMore.js +++ /dev/null @@ -1,38 +0,0 @@ -import { ref, onMounted, onUnmounted, onActivated, nextTick } from 'vue' - -export function useScrollLoadMore(loadMore, offset = 50) { - const savedScrollTop = ref(0) - - const handleScroll = () => { - if (!process.client) return - const scrollTop = window.scrollY || document.documentElement.scrollTop - const scrollHeight = document.documentElement.scrollHeight - const windowHeight = window.innerHeight - savedScrollTop.value = scrollTop - if (scrollHeight - (scrollTop + windowHeight) <= offset) { - loadMore() - } - } - - onMounted(() => { - if (process.client) { - window.addEventListener('scroll', handleScroll, { passive: true }) - } - }) - - onUnmounted(() => { - if (process.client) { - window.removeEventListener('scroll', handleScroll) - } - }) - - onActivated(() => { - if (process.client) { - nextTick(() => { - window.scrollTo({ top: savedScrollTop.value }) - }) - } - }) - - return { savedScrollTop } -} diff --git a/frontend_nuxt/utils/notification.js b/frontend_nuxt/utils/notification.js index 7c0245b94..512b4267a 100644 --- a/frontend_nuxt/utils/notification.js +++ b/frontend_nuxt/utils/notification.js @@ -118,175 +118,162 @@ export async function updateNotificationPreference(type, enabled) { function createFetchNotifications() { const notifications = ref([]) const isLoadingMessage = ref(false) - const fetchNotifications = async () => { + const hasMore = ref(true) + + const fetchNotifications = async ({ + page = 0, + size = 30, + unread = false, + append = 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 - notifications.value = [] - const res = await fetch(`${API_BASE_URL}/api/notifications`, { + try { + const token = getToken() + if (!token) { + toast.error('请先登录') + return + } + if (!append) notifications.value = [] + isLoadingMessage.value = true + const res = await fetch( + `${API_BASE_URL}/api/notifications${unread ? '/unread' : ''}?page=${page}&size=${size}`, + { 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() + const arr = [] + + for (const n of data) { + if (n.type === 'COMMENT_REPLY') { + arr.push({ + ...n, + src: n.comment.author.avatar, + iconClick: () => { + markRead(n.id) + navigateTo(`/users/${n.comment.author.id}`, { replace: true }) + }, + }) + } else if (n.type === 'REACTION') { + arr.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') { + arr.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' || n.type === 'LOTTERY_DRAW') { + arr.push({ + ...n, + icon: iconMap[n.type], + iconClick: () => { + if (n.post) { + markRead(n.id) + navigateTo(`/posts/${n.post.id}`) + } + }, + }) + } else if (n.type === 'POST_UPDATED' || n.type === 'USER_ACTIVITY') { + arr.push({ + ...n, + src: n.comment.author.avatar, + iconClick: () => { + markRead(n.id) + navigateTo(`/users/${n.comment.author.id}`, { replace: true }) + }, + }) + } else if (n.type === 'MENTION') { + arr.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') { + arr.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' || + n.type === 'POST_SUBSCRIBED' || + n.type === 'POST_UNSUBSCRIBED' + ) { + arr.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') { + arr.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') { + arr.push({ + ...n, + icon: iconMap[n.type], + iconClick: () => {}, + }) + } else { + arr.push({ + ...n, + icon: iconMap[n.type], + }) + } + } + + if (append) notifications.value.push(...arr) + else notifications.value = arr + hasMore.value = data.length === size + } catch (e) { + console.error(e) + isLoadingMessage.value = false } } @@ -335,10 +322,16 @@ function createFetchNotifications() { markRead, notifications, isLoadingMessage, - markRead, markAllRead, + hasMore, } } -export const { fetchNotifications, markRead, notifications, isLoadingMessage, markAllRead } = - createFetchNotifications() +export const { + fetchNotifications, + markRead, + notifications, + isLoadingMessage, + markAllRead, + hasMore, +} = createFetchNotifications()