diff --git a/backend/src/main/java/com/openisle/controller/NotificationController.java b/backend/src/main/java/com/openisle/controller/NotificationController.java index 5462b94fc..d25d2a808 100644 --- a/backend/src/main/java/com/openisle/controller/NotificationController.java +++ b/backend/src/main/java/com/openisle/controller/NotificationController.java @@ -3,6 +3,8 @@ package com.openisle.controller; import com.openisle.dto.NotificationDto; import com.openisle.dto.NotificationMarkReadRequest; import com.openisle.dto.NotificationUnreadCountDto; +import com.openisle.dto.NotificationPreferenceDto; +import com.openisle.dto.NotificationPreferenceUpdateRequest; import com.openisle.mapper.NotificationMapper; import com.openisle.service.NotificationService; import lombok.RequiredArgsConstructor; @@ -40,4 +42,14 @@ public class NotificationController { public void markRead(@RequestBody NotificationMarkReadRequest req, Authentication auth) { notificationService.markRead(auth.getName(), req.getIds()); } + + @GetMapping("/prefs") + public List prefs(Authentication auth) { + return notificationService.listPreferences(auth.getName()); + } + + @PostMapping("/prefs") + public void updatePref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) { + notificationService.updatePreference(auth.getName(), req.getType(), req.isEnabled()); + } } diff --git a/backend/src/main/java/com/openisle/dto/NotificationPreferenceDto.java b/backend/src/main/java/com/openisle/dto/NotificationPreferenceDto.java new file mode 100644 index 000000000..4d4e6a4d7 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/NotificationPreferenceDto.java @@ -0,0 +1,11 @@ +package com.openisle.dto; + +import com.openisle.model.NotificationType; +import lombok.Data; + +/** User notification preference DTO. */ +@Data +public class NotificationPreferenceDto { + private NotificationType type; + private boolean enabled; +} diff --git a/backend/src/main/java/com/openisle/dto/NotificationPreferenceUpdateRequest.java b/backend/src/main/java/com/openisle/dto/NotificationPreferenceUpdateRequest.java new file mode 100644 index 000000000..ad68bf45f --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/NotificationPreferenceUpdateRequest.java @@ -0,0 +1,11 @@ +package com.openisle.dto; + +import com.openisle.model.NotificationType; +import lombok.Data; + +/** Request to update a single notification preference. */ +@Data +public class NotificationPreferenceUpdateRequest { + private NotificationType type; + private boolean enabled; +} diff --git a/backend/src/main/java/com/openisle/model/User.java b/backend/src/main/java/com/openisle/model/User.java index 43bcbe268..e2001db8c 100644 --- a/backend/src/main/java/com/openisle/model/User.java +++ b/backend/src/main/java/com/openisle/model/User.java @@ -7,6 +7,9 @@ import lombok.Setter; import org.hibernate.annotations.CreationTimestamp; import java.time.LocalDateTime; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.Set; /** * Simple user entity with basic fields and a role. @@ -62,6 +65,15 @@ public class User { @Enumerated(EnumType.STRING) private MedalType displayMedal; + @ElementCollection(targetClass = NotificationType.class) + @CollectionTable(name = "user_disabled_notification_types", joinColumns = @JoinColumn(name = "user_id")) + @Column(name = "notification_type") + @Enumerated(EnumType.STRING) + private Set disabledNotificationTypes = EnumSet.of( + NotificationType.POST_VIEWED, + NotificationType.USER_ACTIVITY + ); + @CreationTimestamp @Column(nullable = false, updatable = false, columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)") diff --git a/backend/src/main/java/com/openisle/service/NotificationService.java b/backend/src/main/java/com/openisle/service/NotificationService.java index 86335151b..aabdb5b03 100644 --- a/backend/src/main/java/com/openisle/service/NotificationService.java +++ b/backend/src/main/java/com/openisle/service/NotificationService.java @@ -1,6 +1,7 @@ package com.openisle.service; import com.openisle.model.*; +import com.openisle.dto.NotificationPreferenceDto; import com.openisle.repository.NotificationRepository; import com.openisle.repository.ReactionRepository; import com.openisle.repository.UserRepository; @@ -20,7 +21,9 @@ import java.util.Set; 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 @@ -138,13 +141,43 @@ public class NotificationService { } } + public List listPreferences(String username) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Set disabled = user.getDisabledNotificationTypes(); + List prefs = new ArrayList<>(); + for (NotificationType nt : NotificationType.values()) { + NotificationPreferenceDto dto = new NotificationPreferenceDto(); + dto.setType(nt); + dto.setEnabled(!disabled.contains(nt)); + prefs.add(dto); + } + return prefs; + } + + public void updatePreference(String username, NotificationType type, boolean enabled) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Set disabled = user.getDisabledNotificationTypes(); + if (enabled) { + disabled.remove(type); + } else { + disabled.add(type); + } + userRepository.save(user); + } + public List listNotifications(String username, Boolean read) { User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Set disabled = user.getDisabledNotificationTypes(); + List list; if (read == null) { - return notificationRepository.findByUserOrderByCreatedAtDesc(user); + list = notificationRepository.findByUserOrderByCreatedAtDesc(user); + } else { + list = notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, read); } - return notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, read); + return list.stream().filter(n -> !disabled.contains(n.getType())).collect(Collectors.toList()); } public void markRead(String username, List ids) { @@ -162,7 +195,9 @@ public class NotificationService { public long countUnread(String username) { User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - return notificationRepository.countByUserAndRead(user, false); + Set disabled = user.getDisabledNotificationTypes(); + return notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, false).stream() + .filter(n -> !disabled.contains(n.getType())).count(); } public void notifyMentions(String content, User fromUser, Post post, Comment comment) { diff --git a/frontend_nuxt/components/GlobalPopups.vue b/frontend_nuxt/components/GlobalPopups.vue index 64088e203..e87867ffc 100644 --- a/frontend_nuxt/components/GlobalPopups.vue +++ b/frontend_nuxt/components/GlobalPopups.vue @@ -6,6 +6,7 @@ text="建站送奶茶活动火热进行中,快来参与吧!" @close="closeMilkTeaPopup" /> + @@ -13,25 +14,30 @@ + + diff --git a/frontend_nuxt/pages/message.vue b/frontend_nuxt/pages/message.vue index 7390f8339..39a8d5195 100644 --- a/frontend_nuxt/pages/message.vue +++ b/frontend_nuxt/pages/message.vue @@ -14,6 +14,12 @@ > 未读 +
+ 消息设置 +
@@ -24,32 +30,192 @@
-
- +
+
+
通知设置
+
+
+ {{ formatType(pref.type) }} +
+
+
- +
@@ -472,9 +497,13 @@ export default { components: { BaseTimeline, BasePlaceholder, NotificationContainer }, setup() { const router = useRouter() + const route = useRoute() const notifications = ref([]) const isLoadingMessage = ref(false) - const selectedTab = ref('unread') + const selectedTab = ref( + ['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread', + ) + const notificationPrefs = ref([]) const filteredNotifications = computed(() => selectedTab.value === 'all' ? notifications.value @@ -547,6 +576,7 @@ export default { return } isLoadingMessage.value = true + notifications.value = [] const res = await fetch(`${API_BASE_URL}/api/notifications`, { headers: { Authorization: `Bearer ${token}`, @@ -684,6 +714,21 @@ export default { } } + const fetchPrefs = async () => { + notificationPrefs.value = await fetchNotificationPreferences() + } + + const togglePref = async (pref) => { + const ok = await updateNotificationPreference(pref.type, !pref.enabled) + if (ok) { + pref.enabled = !pref.enabled + await fetchNotifications() + await fetchUnreadCount() + } else { + toast.error('操作失败') + } + } + const approve = async (id, nid) => { const token = getToken() if (!token) return @@ -742,12 +787,19 @@ export default { return '关注的用户有新动态' case 'MENTION': return '有人提到了你' + case 'REGISTER_REQUEST': + return '有人申请注册' + case 'ACTIVITY_REDEEM': + return '有人申请兑换奶茶' default: return t } } - onMounted(fetchNotifications) + onMounted(() => { + fetchNotifications() + fetchPrefs() + }) return { notifications, @@ -762,6 +814,8 @@ export default { filteredNotifications, markAllRead, authState, + notificationPrefs, + togglePref, } }, } @@ -915,6 +969,38 @@ export default { border-bottom: 2px solid var(--primary-color); } +.message-control-title { + font-size: 16px; + font-weight: bold; + margin-bottom: 15px; +} + +.message-control-container { + padding: 20px; +} + +.message-control-push-item-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 10px; +} + +.message-control-push-item { + font-size: 14px; + margin-bottom: 5px; + padding: 8px 16px; + border: 1px solid var(--normal-border-color); + border-radius: 10px; + cursor: pointer; + transition: all 0.3s ease; +} + +.message-control-push-item.select { + background-color: var(--primary-color); + color: white; +} + @media (max-width: 768px) { .has_read_button { display: none; diff --git a/frontend_nuxt/utils/notification.js b/frontend_nuxt/utils/notification.js index 2e117cbac..16e37aa0f 100644 --- a/frontend_nuxt/utils/notification.js +++ b/frontend_nuxt/utils/notification.js @@ -46,3 +46,35 @@ export async function markNotificationsRead(ids) { return false } } + +export async function fetchNotificationPreferences() { + try { + const token = getToken() + if (!token) return [] + const res = await fetch(`${API_BASE_URL}/api/notifications/prefs`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) return [] + return await res.json() + } catch (e) { + return [] + } +} + +export async function updateNotificationPreference(type, enabled) { + try { + const token = getToken() + if (!token) return false + const res = await fetch(`${API_BASE_URL}/api/notifications/prefs`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ type, enabled }), + }) + return res.ok + } catch (e) { + return false + } +}