Merge pull request #512 from nagisa77/feature/message_control

feat: message control
This commit is contained in:
Tim
2025-08-12 17:45:17 +08:00
committed by GitHub
9 changed files with 690 additions and 391 deletions

View File

@@ -3,6 +3,8 @@ package com.openisle.controller;
import com.openisle.dto.NotificationDto; import com.openisle.dto.NotificationDto;
import com.openisle.dto.NotificationMarkReadRequest; import com.openisle.dto.NotificationMarkReadRequest;
import com.openisle.dto.NotificationUnreadCountDto; import com.openisle.dto.NotificationUnreadCountDto;
import com.openisle.dto.NotificationPreferenceDto;
import com.openisle.dto.NotificationPreferenceUpdateRequest;
import com.openisle.mapper.NotificationMapper; import com.openisle.mapper.NotificationMapper;
import com.openisle.service.NotificationService; import com.openisle.service.NotificationService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -40,4 +42,14 @@ public class NotificationController {
public void markRead(@RequestBody NotificationMarkReadRequest req, Authentication auth) { public void markRead(@RequestBody NotificationMarkReadRequest req, Authentication auth) {
notificationService.markRead(auth.getName(), req.getIds()); notificationService.markRead(auth.getName(), req.getIds());
} }
@GetMapping("/prefs")
public List<NotificationPreferenceDto> 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());
}
} }

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -7,6 +7,9 @@ import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime; 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. * Simple user entity with basic fields and a role.
@@ -62,6 +65,15 @@ public class User {
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private MedalType displayMedal; 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<NotificationType> disabledNotificationTypes = EnumSet.of(
NotificationType.POST_VIEWED,
NotificationType.USER_ACTIVITY
);
@CreationTimestamp @CreationTimestamp
@Column(nullable = false, updatable = false, @Column(nullable = false, updatable = false,
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)") columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")

View File

@@ -1,6 +1,7 @@
package com.openisle.service; package com.openisle.service;
import com.openisle.model.*; import com.openisle.model.*;
import com.openisle.dto.NotificationPreferenceDto;
import com.openisle.repository.NotificationRepository; import com.openisle.repository.NotificationRepository;
import com.openisle.repository.ReactionRepository; import com.openisle.repository.ReactionRepository;
import com.openisle.repository.UserRepository; import com.openisle.repository.UserRepository;
@@ -20,7 +21,9 @@ import java.util.Set;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.ArrayList;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.stream.Collectors;
/** Service for creating and retrieving notifications. */ /** Service for creating and retrieving notifications. */
@Service @Service
@@ -138,13 +141,43 @@ public class NotificationService {
} }
} }
public List<NotificationPreferenceDto> listPreferences(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledNotificationTypes();
List<NotificationPreferenceDto> 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<NotificationType> disabled = user.getDisabledNotificationTypes();
if (enabled) {
disabled.remove(type);
} else {
disabled.add(type);
}
userRepository.save(user);
}
public List<Notification> listNotifications(String username, Boolean read) { public List<Notification> listNotifications(String username, Boolean read) {
User user = userRepository.findByUsername(username) User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledNotificationTypes();
List<Notification> list;
if (read == null) { 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<Long> ids) { public void markRead(String username, List<Long> ids) {
@@ -162,7 +195,9 @@ public class NotificationService {
public long countUnread(String username) { public long countUnread(String username) {
User user = userRepository.findByUsername(username) User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
return notificationRepository.countByUserAndRead(user, false); Set<NotificationType> 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) { public void notifyMentions(String content, User fromUser, Post post, Comment comment) {

View File

@@ -6,6 +6,7 @@
text="建站送奶茶活动火热进行中,快来参与吧!" text="建站送奶茶活动火热进行中,快来参与吧!"
@close="closeMilkTeaPopup" @close="closeMilkTeaPopup"
/> />
<NotificationSettingPopup :visible="showNotificationPopup" @close="closeNotificationPopup" />
<MedalPopup :visible="showMedalPopup" :medals="newMedals" @close="closeMedalPopup" /> <MedalPopup :visible="showMedalPopup" :medals="newMedals" @close="closeMedalPopup" />
</div> </div>
</template> </template>
@@ -13,25 +14,30 @@
<script> <script>
import ActivityPopup from '~/components/ActivityPopup.vue' import ActivityPopup from '~/components/ActivityPopup.vue'
import MedalPopup from '~/components/MedalPopup.vue' import MedalPopup from '~/components/MedalPopup.vue'
import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue'
import { API_BASE_URL } from '~/main' import { API_BASE_URL } from '~/main'
import { authState } from '~/utils/auth' import { authState } from '~/utils/auth'
export default { export default {
name: 'GlobalPopups', name: 'GlobalPopups',
components: { ActivityPopup, MedalPopup }, components: { ActivityPopup, MedalPopup, NotificationSettingPopup },
data() { data() {
return { return {
showMilkTeaPopup: false, showMilkTeaPopup: false,
milkTeaIcon: '', milkTeaIcon: '',
showNotificationPopup: false,
showMedalPopup: false, showMedalPopup: false,
newMedals: [], newMedals: [],
} }
}, },
async mounted() { async mounted() {
await this.checkMilkTeaActivity() await this.checkMilkTeaActivity()
if (!this.showMilkTeaPopup) { if (this.showMilkTeaPopup) return
await this.checkNewMedals()
} await this.checkNotificationSetting()
if (this.showNotificationPopup) return
await this.checkNewMedals()
}, },
methods: { methods: {
async checkMilkTeaActivity() { async checkMilkTeaActivity() {
@@ -55,6 +61,18 @@ export default {
if (!process.client) return if (!process.client) return
localStorage.setItem('milkTeaActivityPopupShown', 'true') localStorage.setItem('milkTeaActivityPopupShown', 'true')
this.showMilkTeaPopup = false this.showMilkTeaPopup = false
this.checkNotificationSetting()
},
async checkNotificationSetting() {
if (!process.client) return
if (!authState.loggedIn) return
if (localStorage.getItem('notificationSettingPopupShown')) return
this.showNotificationPopup = true
},
closeNotificationPopup() {
if (!process.client) return
localStorage.setItem('notificationSettingPopupShown', 'true')
this.showNotificationPopup = false
this.checkNewMedals() this.checkNewMedals()
}, },
async checkNewMedals() { async checkNewMedals() {

View File

@@ -0,0 +1,82 @@
<template>
<BasePopup :visible="visible" @close="close">
<div class="notification-popup">
<div class="notification-popup-title">🎉 通知设置上线啦</div>
<div class="notification-popup-text">现在可以在消息 -> 消息设置中调整通知类型</div>
<div class="notification-popup-actions">
<div class="notification-popup-close" @click="close">知道了</div>
<div class="notification-popup-button" @click="gotoSetting">去看看</div>
</div>
</div>
</BasePopup>
</template>
<script>
import BasePopup from '~/components/BasePopup.vue'
import { useRouter } from 'vue-router'
export default {
name: 'NotificationSettingPopup',
components: { BasePopup },
props: {
visible: { type: Boolean, default: false },
},
emits: ['close'],
setup(props, { emit }) {
const router = useRouter()
const gotoSetting = () => {
emit('close')
router.push('/message?tab=control')
}
const close = () => emit('close')
return { gotoSetting, close }
},
}
</script>
<style scoped>
.notification-popup {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 10px;
min-width: 200px;
}
.notification-popup-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
}
.notification-popup-actions {
margin-top: 10px;
display: flex;
flex-direction: row;
gap: 20px;
}
.notification-popup-button {
background-color: var(--primary-color);
color: #fff;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
}
.notification-popup-button:hover {
background-color: var(--primary-color-hover);
}
.notification-popup-close {
cursor: pointer;
color: var(--primary-color);
display: flex;
align-items: center;
}
.notification-popup-close:hover {
text-decoration: underline;
}
</style>

View File

@@ -14,6 +14,12 @@
> >
未读 未读
</div> </div>
<div
:class="['message-tab-item', { selected: selectedTab === 'control' }]"
@click="selectedTab = 'control'"
>
消息设置
</div>
</div> </div>
<div class="message-page-header-right"> <div class="message-page-header-right">
@@ -24,32 +30,192 @@
</div> </div>
</div> </div>
<div v-if="isLoadingMessage" class="loading-message"> <div v-if="selectedTab === 'control'">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch> <div class="message-control-container">
<div class="message-control-title">通知设置</div>
<div class="message-control-push-item-container">
<div
v-for="pref in notificationPrefs"
:key="pref.type"
class="message-control-push-item"
:class="{ select: pref.enabled }"
@click="togglePref(pref)"
>
{{ formatType(pref.type) }}
</div>
</div>
</div>
</div> </div>
<BasePlaceholder <template v-else>
v-else-if="filteredNotifications.length === 0" <div v-if="isLoadingMessage" class="loading-message">
text="暂时没有消息 :)" <l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
icon="fas fa-inbox" </div>
/>
<div class="timeline-container" v-if="filteredNotifications.length > 0"> <BasePlaceholder
<BaseTimeline :items="filteredNotifications"> v-else-if="filteredNotifications.length === 0"
<template #item="{ item }"> text="暂时没有消息 :)"
<div class="notif-content" :class="{ read: item.read }"> icon="fas fa-inbox"
<span v-if="!item.read" class="unread-dot"></span> />
<span class="notif-type">
<template v-if="item.type === 'COMMENT_REPLY' && item.parentComment"> <div class="timeline-container" v-if="filteredNotifications.length > 0">
<NotificationContainer :item="item" :markRead="markRead"> <BaseTimeline :items="filteredNotifications">
<router-link <template #item="{ item }">
class="notif-content-text" <div class="notif-content" :class="{ read: item.read }">
@click="markRead(item.id)" <span v-if="!item.read" class="unread-dot"></span>
:to="`/users/${item.comment.author.id}`" <span class="notif-type">
>{{ item.comment.author.username }} <template v-if="item.type === 'COMMENT_REPLY' && item.parentComment">
</router-link> <NotificationContainer :item="item" :markRead="markRead">
对我的评论 <router-link
<span> class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.comment.author.id}`"
>{{ item.comment.author.username }}
</router-link>
对我的评论
<span>
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
>
{{ stripMarkdownLength(item.parentComment.content, 100) }}
</router-link>
</span>
回复了
<span>
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
</router-link>
</span>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'COMMENT_REPLY' && !item.parentComment">
<NotificationContainer :item="item" :markRead="markRead">
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.comment.author.id}`"
>{{ item.comment.author.username }}
</router-link>
对我的文章
<span>
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</router-link>
</span>
回复了
<span>
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
</router-link>
</span>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'ACTIVITY_REDEEM' && !item.parentComment">
<NotificationContainer :item="item" :markRead="markRead">
<span class="notif-user">{{ item.fromUser.username }} </span>
申请进行奶茶兑换联系方式是{{ item.content }}
</NotificationContainer>
</template>
<template v-else-if="item.type === 'REACTION' && item.post && !item.comment">
<NotificationContainer :item="item" :markRead="markRead">
<span class="notif-user">{{ item.fromUser.username }} </span> 对我的文章
<span>
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</router-link>
</span>
进行了表态
</NotificationContainer>
</template>
<template v-else-if="item.type === 'REACTION' && item.comment">
<NotificationContainer :item="item" :markRead="markRead">
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>{{ item.fromUser.username }}
</router-link>
对我的评论
<span>
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
</router-link>
</span>
进行了表态
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_VIEWED'">
<NotificationContainer :item="item" :markRead="markRead">
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</router-link>
查看了您的帖子
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</router-link>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_UPDATED'">
<NotificationContainer :item="item" :markRead="markRead">
您关注的帖子
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</router-link>
下面有新评论
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
</router-link>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'USER_ACTIVITY' && item.parentComment">
<NotificationContainer :item="item" :markRead="markRead">
你关注的
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.comment.author.id}`"
>
{{ item.comment.author.username }}
</router-link>
对评论
<router-link <router-link
class="notif-content-text" class="notif-content-text"
@click="markRead(item.id)" @click="markRead(item.id)"
@@ -57,9 +223,7 @@
> >
{{ stripMarkdownLength(item.parentComment.content, 100) }} {{ stripMarkdownLength(item.parentComment.content, 100) }}
</router-link> </router-link>
</span> 回复了
回复了
<span>
<router-link <router-link
class="notif-content-text" class="notif-content-text"
@click="markRead(item.id)" @click="markRead(item.id)"
@@ -67,19 +231,19 @@
> >
{{ stripMarkdownLength(item.comment.content, 100) }} {{ stripMarkdownLength(item.comment.content, 100) }}
</router-link> </router-link>
</span> </NotificationContainer>
</NotificationContainer> </template>
</template> <template v-else-if="item.type === 'USER_ACTIVITY'">
<template v-else-if="item.type === 'COMMENT_REPLY' && !item.parentComment"> <NotificationContainer :item="item" :markRead="markRead">
<NotificationContainer :item="item" :markRead="markRead"> 你关注的
<router-link <router-link
class="notif-content-text" class="notif-content-text"
@click="markRead(item.id)" @click="markRead(item.id)"
:to="`/users/${item.comment.author.id}`" :to="`/users/${item.comment.author.id}`"
>{{ item.comment.author.username }} >
</router-link> {{ item.comment.author.username }}
对我的文章 </router-link>
<span> 在文章
<router-link <router-link
class="notif-content-text" class="notif-content-text"
@click="markRead(item.id)" @click="markRead(item.id)"
@@ -87,9 +251,7 @@
> >
{{ stripMarkdownLength(item.post.title, 100) }} {{ stripMarkdownLength(item.post.title, 100) }}
</router-link> </router-link>
</span> 下面评论了
回复了
<span>
<router-link <router-link
class="notif-content-text" class="notif-content-text"
@click="markRead(item.id)" @click="markRead(item.id)"
@@ -97,19 +259,37 @@
> >
{{ stripMarkdownLength(item.comment.content, 100) }} {{ stripMarkdownLength(item.comment.content, 100) }}
</router-link> </router-link>
</span> </NotificationContainer>
</NotificationContainer> </template>
</template> <template v-else-if="item.type === 'MENTION' && item.comment">
<template v-else-if="item.type === 'ACTIVITY_REDEEM' && !item.parentComment"> <NotificationContainer :item="item" :markRead="markRead">
<NotificationContainer :item="item" :markRead="markRead"> <router-link
<span class="notif-user">{{ item.fromUser.username }} </span> class="notif-content-text"
申请进行奶茶兑换联系方式是{{ item.content }} @click="markRead(item.id)"
</NotificationContainer> :to="`/users/${item.fromUser.id}`"
</template> >
<template v-else-if="item.type === 'REACTION' && item.post && !item.comment"> {{ item.fromUser.username }}
<NotificationContainer :item="item" :markRead="markRead"> </router-link>
<span class="notif-user">{{ item.fromUser.username }} </span> 对我的文章 在评论中提到了你
<span> <router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
</router-link>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'MENTION'">
<NotificationContainer :item="item" :markRead="markRead">
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</router-link>
在帖子
<router-link <router-link
class="notif-content-text" class="notif-content-text"
@click="markRead(item.id)" @click="markRead(item.id)"
@@ -117,339 +297,184 @@
> >
{{ stripMarkdownLength(item.post.title, 100) }} {{ stripMarkdownLength(item.post.title, 100) }}
</router-link> </router-link>
</span> 中提到了你
进行了表态 </NotificationContainer>
</NotificationContainer> </template>
</template> <template v-else-if="item.type === 'USER_FOLLOWED'">
<template v-else-if="item.type === 'REACTION' && item.comment"> <NotificationContainer :item="item" :markRead="markRead">
<NotificationContainer :item="item" :markRead="markRead">
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>{{ item.fromUser.username }}
</router-link>
对我的评论
<span>
<router-link <router-link
class="notif-content-text" class="notif-content-text"
@click="markRead(item.id)" @click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`" :to="`/users/${item.fromUser.id}`"
> >
{{ stripMarkdownLength(item.comment.content, 100) }} {{ item.fromUser.username }}
</router-link> </router-link>
</span> 开始关注你了
进行了表态 </NotificationContainer>
</NotificationContainer> </template>
</template> <template v-else-if="item.type === 'USER_UNFOLLOWED'">
<template v-else-if="item.type === 'POST_VIEWED'"> <NotificationContainer :item="item" :markRead="markRead">
<NotificationContainer :item="item" :markRead="markRead"> <router-link
<router-link class="notif-content-text"
class="notif-content-text" @click="markRead(item.id)"
@click="markRead(item.id)" :to="`/users/${item.fromUser.id}`"
:to="`/users/${item.fromUser.id}`" >
> {{ item.fromUser.username }}
{{ item.fromUser.username }} </router-link>
</router-link> 取消关注你了
查看了您的帖子 </NotificationContainer>
<router-link </template>
class="notif-content-text" <template v-else-if="item.type === 'FOLLOWED_POST'">
@click="markRead(item.id)" <NotificationContainer :item="item" :markRead="markRead">
:to="`/posts/${item.post.id}`" 你关注的
> <router-link
{{ stripMarkdownLength(item.post.title, 100) }} class="notif-content-text"
</router-link> @click="markRead(item.id)"
</NotificationContainer> :to="`/users/${item.fromUser.id}`"
</template> >
<template v-else-if="item.type === 'POST_UPDATED'"> {{ item.fromUser.username }}
<NotificationContainer :item="item" :markRead="markRead"> </router-link>
您关注的帖子 发布了文章
<router-link <router-link
class="notif-content-text" class="notif-content-text"
@click="markRead(item.id)" @click="markRead(item.id)"
:to="`/posts/${item.post.id}`" :to="`/posts/${item.post.id}`"
> >
{{ stripMarkdownLength(item.post.title, 100) }} {{ stripMarkdownLength(item.post.title, 100) }}
</router-link> </router-link>
下面有新评论 </NotificationContainer>
<router-link </template>
class="notif-content-text" <template v-else-if="item.type === 'POST_SUBSCRIBED'">
@click="markRead(item.id)" <NotificationContainer :item="item" :markRead="markRead">
:to="`/posts/${item.post.id}#comment-${item.comment.id}`" <router-link
> class="notif-content-text"
{{ stripMarkdownLength(item.comment.content, 100) }} @click="markRead(item.id)"
</router-link> :to="`/users/${item.fromUser.id}`"
</NotificationContainer> >
</template> {{ item.fromUser.username }}
<template v-else-if="item.type === 'USER_ACTIVITY' && item.parentComment"> </router-link>
<NotificationContainer :item="item" :markRead="markRead"> 订阅了你的文章
你关注的 <router-link
<router-link class="notif-content-text"
class="notif-content-text" @click="markRead(item.id)"
@click="markRead(item.id)" :to="`/posts/${item.post.id}`"
:to="`/users/${item.comment.author.id}`" >
> {{ stripMarkdownLength(item.post.title, 100) }}
{{ item.comment.author.username }} </router-link>
</router-link> </NotificationContainer>
对评论 </template>
<router-link <template v-else-if="item.type === 'POST_UNSUBSCRIBED'">
class="notif-content-text" <NotificationContainer :item="item" :markRead="markRead">
@click="markRead(item.id)" <router-link
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`" class="notif-content-text"
> @click="markRead(item.id)"
{{ stripMarkdownLength(item.parentComment.content, 100) }} :to="`/users/${item.fromUser.id}`"
</router-link> >
回复了 {{ item.fromUser.username }}
<router-link </router-link>
class="notif-content-text" 取消订阅了你的文章
@click="markRead(item.id)" <router-link
:to="`/posts/${item.post.id}#comment-${item.comment.id}`" class="notif-content-text"
> @click="markRead(item.id)"
{{ stripMarkdownLength(item.comment.content, 100) }} :to="`/posts/${item.post.id}`"
</router-link> >
</NotificationContainer> {{ stripMarkdownLength(item.post.title, 100) }}
</template> </router-link>
<template v-else-if="item.type === 'USER_ACTIVITY'"> </NotificationContainer>
<NotificationContainer :item="item" :markRead="markRead"> </template>
你关注的 <template v-else-if="item.type === 'POST_REVIEW_REQUEST' && item.fromUser">
<router-link <NotificationContainer :item="item" :markRead="markRead">
class="notif-content-text" <router-link
@click="markRead(item.id)" class="notif-content-text"
:to="`/users/${item.comment.author.id}`" @click="markRead(item.id)"
> :to="`/users/${item.fromUser.id}`"
{{ item.comment.author.username }} >
</router-link> {{ item.fromUser.username }}
在文章 </router-link>
<router-link 发布了帖子
class="notif-content-text" <router-link
@click="markRead(item.id)" class="notif-content-text"
:to="`/posts/${item.post.id}`" @click="markRead(item.id)"
> :to="`/posts/${item.post.id}`"
{{ stripMarkdownLength(item.post.title, 100) }} >
</router-link> {{ stripMarkdownLength(item.post.title, 100) }}
下面评论了 </router-link>
<router-link 请审核
class="notif-content-text" </NotificationContainer>
@click="markRead(item.id)" </template>
:to="`/posts/${item.post.id}#comment-${item.comment.id}`" <template v-else-if="item.type === 'POST_REVIEW_REQUEST'">
> <NotificationContainer :item="item" :markRead="markRead">
{{ stripMarkdownLength(item.comment.content, 100) }} 您发布的帖子
</router-link> <router-link
</NotificationContainer> class="notif-content-text"
</template> @click="markRead(item.id)"
<template v-else-if="item.type === 'MENTION' && item.comment"> :to="`/posts/${item.post.id}`"
<NotificationContainer :item="item" :markRead="markRead"> >
<router-link {{ stripMarkdownLength(item.post.title, 100) }}
class="notif-content-text" </router-link>
@click="markRead(item.id)" 已提交审核
:to="`/users/${item.fromUser.id}`" </NotificationContainer>
> </template>
{{ item.fromUser.username }} <template v-else-if="item.type === 'REGISTER_REQUEST'">
</router-link> <NotificationContainer :item="item" :markRead="markRead">
在评论中提到了你 {{ item.fromUser.username }} 希望注册为会员理由是{{ item.content }}
<router-link <template #actions v-if="authState.role === 'ADMIN'">
class="notif-content-text" <div v-if="!item.read" class="optional-buttons">
@click="markRead(item.id)" <div
:to="`/posts/${item.post.id}#comment-${item.comment.id}`" class="mark-approve-button-item"
> @click="approve(item.fromUser.id, item.id)"
{{ stripMarkdownLength(item.comment.content, 100) }} >
</router-link> 同意
</NotificationContainer> </div>
</template> <div
<template v-else-if="item.type === 'MENTION'"> class="mark-reject-button-item"
<NotificationContainer :item="item" :markRead="markRead"> @click="reject(item.fromUser.id, item.id)"
<router-link >
class="notif-content-text" 拒绝
@click="markRead(item.id)" </div>
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</router-link>
在帖子
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</router-link>
中提到了你
</NotificationContainer>
</template>
<template v-else-if="item.type === 'USER_FOLLOWED'">
<NotificationContainer :item="item" :markRead="markRead">
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</router-link>
开始关注你了
</NotificationContainer>
</template>
<template v-else-if="item.type === 'USER_UNFOLLOWED'">
<NotificationContainer :item="item" :markRead="markRead">
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</router-link>
取消关注你了
</NotificationContainer>
</template>
<template v-else-if="item.type === 'FOLLOWED_POST'">
<NotificationContainer :item="item" :markRead="markRead">
你关注的
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</router-link>
发布了文章
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</router-link>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_SUBSCRIBED'">
<NotificationContainer :item="item" :markRead="markRead">
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</router-link>
订阅了你的文章
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</router-link>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_UNSUBSCRIBED'">
<NotificationContainer :item="item" :markRead="markRead">
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</router-link>
取消订阅了你的文章
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</router-link>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_REVIEW_REQUEST' && item.fromUser">
<NotificationContainer :item="item" :markRead="markRead">
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</router-link>
发布了帖子
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</router-link>
请审核
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_REVIEW_REQUEST'">
<NotificationContainer :item="item" :markRead="markRead">
您发布的帖子
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</router-link>
已提交审核
</NotificationContainer>
</template>
<template v-else-if="item.type === 'REGISTER_REQUEST'">
<NotificationContainer :item="item" :markRead="markRead">
{{ item.fromUser.username }} 希望注册为会员理由是{{ item.content }}
<template #actions v-if="authState.role === 'ADMIN'">
<div v-if="!item.read" class="optional-buttons">
<div
class="mark-approve-button-item"
@click="approve(item.fromUser.id, item.id)"
>
同意
</div> </div>
<div <div v-else class="has_read_button" @click="markRead(item.id)">已读</div>
class="mark-reject-button-item" </template>
@click="reject(item.fromUser.id, item.id)" </NotificationContainer>
> </template>
拒绝 <template v-else-if="item.type === 'POST_REVIEWED' && item.approved">
</div> <NotificationContainer :item="item" :markRead="markRead">
</div> 您发布的帖子
<div v-else class="has_read_button" @click="markRead(item.id)">已读</div> <router-link
</template> class="notif-content-text"
</NotificationContainer> @click="markRead(item.id)"
</template> :to="`/posts/${item.post.id}`"
<template v-else-if="item.type === 'POST_REVIEWED' && item.approved"> >
<NotificationContainer :item="item" :markRead="markRead"> {{ stripMarkdownLength(item.post.title, 100) }}
您发布的帖子 </router-link>
<router-link 已审核通过
class="notif-content-text" </NotificationContainer>
@click="markRead(item.id)" </template>
:to="`/posts/${item.post.id}`" <template v-else-if="item.type === 'POST_REVIEWED' && item.approved === false">
> <NotificationContainer :item="item" :markRead="markRead">
{{ stripMarkdownLength(item.post.title, 100) }} 您发布的帖子
</router-link> <router-link
已审核通过 class="notif-content-text"
</NotificationContainer> @click="markRead(item.id)"
</template> :to="`/posts/${item.post.id}`"
<template v-else-if="item.type === 'POST_REVIEWED' && item.approved === false"> >
<NotificationContainer :item="item" :markRead="markRead"> {{ stripMarkdownLength(item.post.title, 100) }}
您发布的帖子 </router-link>
<router-link 已被管理员拒绝
class="notif-content-text" </NotificationContainer>
@click="markRead(item.id)" </template>
:to="`/posts/${item.post.id}`" <template v-else>
> <NotificationContainer :item="item" :markRead="markRead">
{{ stripMarkdownLength(item.post.title, 100) }} {{ formatType(item.type) }}
</router-link> </NotificationContainer>
已被管理员拒绝 </template>
</NotificationContainer> </span>
</template> <span class="notif-time">{{ TimeManager.format(item.createdAt) }}</span>
<template v-else> </div>
<NotificationContainer :item="item" :markRead="markRead"> </template>
{{ formatType(item.type) }} </BaseTimeline>
</NotificationContainer> </div>
</template> </template>
</span>
<span class="notif-time">{{ TimeManager.format(item.createdAt) }}</span>
</div>
</template>
</BaseTimeline>
</div>
</div> </div>
</template> </template>
@@ -472,9 +497,13 @@ export default {
components: { BaseTimeline, BasePlaceholder, NotificationContainer }, components: { BaseTimeline, BasePlaceholder, NotificationContainer },
setup() { setup() {
const router = useRouter() const router = useRouter()
const route = useRoute()
const notifications = ref([]) const notifications = ref([])
const isLoadingMessage = ref(false) 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(() => const filteredNotifications = computed(() =>
selectedTab.value === 'all' selectedTab.value === 'all'
? notifications.value ? notifications.value
@@ -547,6 +576,7 @@ export default {
return return
} }
isLoadingMessage.value = true isLoadingMessage.value = true
notifications.value = []
const res = await fetch(`${API_BASE_URL}/api/notifications`, { const res = await fetch(`${API_BASE_URL}/api/notifications`, {
headers: { headers: {
Authorization: `Bearer ${token}`, 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 approve = async (id, nid) => {
const token = getToken() const token = getToken()
if (!token) return if (!token) return
@@ -742,12 +787,19 @@ export default {
return '关注的用户有新动态' return '关注的用户有新动态'
case 'MENTION': case 'MENTION':
return '有人提到了你' return '有人提到了你'
case 'REGISTER_REQUEST':
return '有人申请注册'
case 'ACTIVITY_REDEEM':
return '有人申请兑换奶茶'
default: default:
return t return t
} }
} }
onMounted(fetchNotifications) onMounted(() => {
fetchNotifications()
fetchPrefs()
})
return { return {
notifications, notifications,
@@ -762,6 +814,8 @@ export default {
filteredNotifications, filteredNotifications,
markAllRead, markAllRead,
authState, authState,
notificationPrefs,
togglePref,
} }
}, },
} }
@@ -915,6 +969,38 @@ export default {
border-bottom: 2px solid var(--primary-color); 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) { @media (max-width: 768px) {
.has_read_button { .has_read_button {
display: none; display: none;

View File

@@ -46,3 +46,35 @@ export async function markNotificationsRead(ids) {
return false 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
}
}