diff --git a/backend/src/main/java/com/openisle/controller/NotificationController.java b/backend/src/main/java/com/openisle/controller/NotificationController.java index d25d2a808..1149bff37 100644 --- a/backend/src/main/java/com/openisle/controller/NotificationController.java +++ b/backend/src/main/java/com/openisle/controller/NotificationController.java @@ -23,9 +23,17 @@ 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, Authentication auth) { - return notificationService.listNotifications(auth.getName(), read).stream() + return notificationService.listNotifications(auth.getName(), null, page).stream() + .map(notificationMapper::toDto) + .collect(Collectors.toList()); + } + + @GetMapping("/unread") + public List listUnread(@RequestParam(value = "page", defaultValue = "0") int page, + Authentication auth) { + return notificationService.listNotifications(auth.getName(), false, page).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..65fddca4c 100644 --- a/backend/src/main/java/com/openisle/repository/NotificationRepository.java +++ b/backend/src/main/java/com/openisle/repository/NotificationRepository.java @@ -5,6 +5,8 @@ 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.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; @@ -13,6 +15,8 @@ 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); 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..088d0f61a 100644 --- a/backend/src/main/java/com/openisle/service/NotificationService.java +++ b/backend/src/main/java/com/openisle/service/NotificationService.java @@ -180,15 +180,16 @@ public class NotificationService { userRepository.save(user); } - public List listNotifications(String username, Boolean read) { + public List listNotifications(String username, Boolean read, int page) { User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); Set disabled = user.getDisabledNotificationTypes(); List list; + var pageable = org.springframework.data.domain.PageRequest.of(page, 50); if (read == null) { - list = notificationRepository.findByUserOrderByCreatedAtDesc(user); + list = notificationRepository.findByUserOrderByCreatedAtDesc(user, pageable).getContent(); } else { - list = notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, read); + list = notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, read, pageable).getContent(); } 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 061eedd37..30eed2239 100644 --- a/frontend_nuxt/pages/message.vue +++ b/frontend_nuxt/pages/message.vue @@ -53,13 +53,13 @@ -
- +
+
+ +
diff --git a/frontend_nuxt/utils/notification.js b/frontend_nuxt/utils/notification.js index 7c0245b94..28403331e 100644 --- a/frontend_nuxt/utils/notification.js +++ b/frontend_nuxt/utils/notification.js @@ -4,9 +4,7 @@ import { toast } from '~/composables/useToast' import { authState, getToken } from '~/utils/auth' import { reactionEmojiMap } from '~/utils/reactions' -export const notificationState = reactive({ - unreadCount: 0, -}) +export const notificationState = reactive({ unreadCount: 0 }) const iconMap = { POST_VIEWED: 'fas fa-eye', @@ -57,7 +55,6 @@ export async function markNotificationsRead(ids) { try { const config = useRuntimeConfig() const API_BASE_URL = config.public.apiBaseUrl - const token = getToken() if (!token || !ids || ids.length === 0) return false const res = await fetch(`${API_BASE_URL}/api/notifications/read`, { @@ -78,7 +75,6 @@ export async function fetchNotificationPreferences() { try { const config = useRuntimeConfig() const API_BASE_URL = config.public.apiBaseUrl - const token = getToken() if (!token) return [] const res = await fetch(`${API_BASE_URL}/api/notifications/prefs`, { @@ -112,193 +108,219 @@ export async function updateNotificationPreference(type, enabled) { } /** - * 处理信息的高阶函数 - * @returns + * 提供通知列表的分页获取与状态管理 */ function createFetchNotifications() { - const notifications = ref([]) - const isLoadingMessage = ref(false) - const fetchNotifications = async () => { - 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`, { - headers: { - Authorization: `Bearer ${token}`, + const allNotifications = ref([]) + const unreadNotifications = ref([]) + const pageAll = ref(0) + const pageUnread = ref(0) + const isLoadingAll = ref(false) + const isLoadingUnread = ref(false) + + const processAndPush = (data, target) => { + for (const n of data) { + if (n.type === 'COMMENT_REPLY') { + target.push({ + ...n, + src: n.comment.author.avatar, + iconClick: () => { + markRead(n.id) + navigateTo(`/users/${n.comment.author.id}`, { replace: true }) }, }) - 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) + } else if (n.type === 'REACTION') { + target.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') { + target.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') { + target.push({ + ...n, + icon: iconMap[n.type], + iconClick: () => { + if (n.post) { + markRead(n.id) + navigateTo(`/posts/${n.post.id}`, { replace: true }) + } + }, + }) + } else if (n.type === 'LOTTERY_DRAW') { + target.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_UPDATED') { + target.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') { + target.push({ + ...n, + src: n.comment.author.avatar, + iconClick: () => { + markRead(n.id) + navigateTo(`/users/${n.comment.author.id}`, { replace: true }) + }, + }) + } else if (n.type === 'MENTION') { + target.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') { + target.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') { + target.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') { + target.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') { + target.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') { + target.push({ + ...n, + icon: iconMap[n.type], + iconClick: () => {}, + }) + } else { + target.push({ + ...n, + icon: iconMap[n.type], + }) } } } + const fetchAllNotifications = async () => { + const config = useRuntimeConfig() + const API_BASE_URL = config.public.apiBaseUrl + const token = getToken() + if (!token) { + toast.error('请先登录') + return { done: true } + } + isLoadingAll.value = true + const res = await fetch(`${API_BASE_URL}/api/notifications?page=${pageAll.value}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + isLoadingAll.value = false + if (!res.ok) { + toast.error('获取通知失败') + return { done: true } + } + const data = await res.json() + processAndPush(data, allNotifications.value) + pageAll.value++ + return { done: data.length < 50 } + } + + const fetchUnreadNotifications = async () => { + const config = useRuntimeConfig() + const API_BASE_URL = config.public.apiBaseUrl + const token = getToken() + if (!token) { + toast.error('请先登录') + return { done: true } + } + isLoadingUnread.value = true + const res = await fetch(`${API_BASE_URL}/api/notifications/unread?page=${pageUnread.value}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + isLoadingUnread.value = false + if (!res.ok) { + toast.error('获取通知失败') + return { done: true } + } + const data = await res.json() + processAndPush(data, unreadNotifications.value) + pageUnread.value++ + return { done: data.length < 50 } + } + const markRead = async (id) => { if (!id) return - const n = notifications.value.find((n) => n.id === id) - if (!n || n.read) return - n.read = true + const nAll = allNotifications.value.find((n) => n.id === id) + const idxUnread = unreadNotifications.value.findIndex((n) => n.id === id) + const unreadItem = idxUnread !== -1 ? unreadNotifications.value[idxUnread] : null + if (nAll) nAll.read = true + if (idxUnread !== -1) unreadNotifications.value.splice(idxUnread, 1) if (notificationState.unreadCount > 0) notificationState.unreadCount-- const ok = await markNotificationsRead([id]) if (!ok) { - n.read = false + if (nAll) nAll.read = false + if (idxUnread !== -1 && unreadItem) unreadNotifications.value.splice(idxUnread, 0, unreadItem) notificationState.unreadCount++ } else { fetchUnreadCount() @@ -306,20 +328,28 @@ function createFetchNotifications() { } const markAllRead = async () => { - // 除了 REGISTER_REQUEST 类型消息 - const idsToMark = notifications.value - .filter((n) => n.type !== 'REGISTER_REQUEST' && !n.read) - .map((n) => n.id) + const idsToMark = [ + ...new Set( + [...allNotifications.value, ...unreadNotifications.value] + .filter((n) => n.type !== 'REGISTER_REQUEST' && !n.read) + .map((n) => n.id), + ), + ] if (idsToMark.length === 0) return - notifications.value.forEach((n) => { + allNotifications.value.forEach((n) => { if (n.type !== 'REGISTER_REQUEST') n.read = true }) - notificationState.unreadCount = notifications.value.filter((n) => !n.read).length + const prevUnread = [...unreadNotifications.value] + unreadNotifications.value = unreadNotifications.value.filter( + (n) => n.type === 'REGISTER_REQUEST', + ) + notificationState.unreadCount = unreadNotifications.value.length const ok = await markNotificationsRead(idsToMark) if (!ok) { - notifications.value.forEach((n) => { + allNotifications.value.forEach((n) => { if (idsToMark.includes(n.id)) n.read = false }) + unreadNotifications.value = prevUnread await fetchUnreadCount() return } @@ -330,15 +360,40 @@ function createFetchNotifications() { toast.success('已读所有消息') } } + + const resetAll = () => { + pageAll.value = 0 + allNotifications.value = [] + } + + const resetUnread = () => { + pageUnread.value = 0 + unreadNotifications.value = [] + } + return { - fetchNotifications, - markRead, - notifications, - isLoadingMessage, + fetchAllNotifications, + fetchUnreadNotifications, markRead, markAllRead, + allNotifications, + unreadNotifications, + isLoadingAll, + isLoadingUnread, + resetAll, + resetUnread, } } -export const { fetchNotifications, markRead, notifications, isLoadingMessage, markAllRead } = - createFetchNotifications() +export const { + fetchAllNotifications, + fetchUnreadNotifications, + markRead, + markAllRead, + allNotifications, + unreadNotifications, + isLoadingAll, + isLoadingUnread, + resetAll, + resetUnread, +} = createFetchNotifications()