Add mention suggestions and admin list

This commit is contained in:
Tim
2025-07-31 17:57:25 +08:00
parent a02129b8f9
commit c541306494
9 changed files with 146 additions and 3 deletions
+30
View File
@@ -0,0 +1,30 @@
import { API_BASE_URL } from '../main'
export async function fetchFollowings(username) {
if (!username) return []
try {
const res = await fetch(`${API_BASE_URL}/api/users/${username}/following`)
return res.ok ? await res.json() : []
} catch (e) {
return []
}
}
export async function fetchAdmins() {
try {
const res = await fetch(`${API_BASE_URL}/api/users/admins`)
return res.ok ? await res.json() : []
} catch (e) {
return []
}
}
export async function searchUsers(keyword) {
if (!keyword) return []
try {
const res = await fetch(`${API_BASE_URL}/api/search/users?keyword=${encodeURIComponent(keyword)}`)
return res.ok ? await res.json() : []
} catch (e) {
return []
}
}
+29 -1
View File
@@ -1,7 +1,8 @@
import Vditor from 'vditor' import Vditor from 'vditor'
import 'vditor/dist/index.css' import 'vditor/dist/index.css'
import { API_BASE_URL } from '../main' import { API_BASE_URL } from '../main'
import { getToken } from './auth' import { getToken, authState } from './auth'
import { searchUsers, fetchFollowings, fetchAdmins } from './user'
export function getEditorTheme() { export function getEditorTheme() {
return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'classic' return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'classic'
@@ -19,11 +20,38 @@ export function createVditor(editorId, options = {}) {
after after
} = options } = options
const fetchMentions = async (value) => {
if (!value) {
const [followings, admins] = await Promise.all([
fetchFollowings(authState.username),
fetchAdmins()
])
const combined = [...followings, ...admins]
const seen = new Set()
return combined.filter(u => {
if (seen.has(u.id)) return false
seen.add(u.id)
return true
})
}
return searchUsers(value)
}
return new Vditor(editorId, { return new Vditor(editorId, {
placeholder, placeholder,
height: 'auto', height: 'auto',
theme: getEditorTheme(), theme: getEditorTheme(),
preview: Object.assign({ theme: { current: getPreviewTheme() } }, preview), preview: Object.assign({ theme: { current: getPreviewTheme() } }, preview),
hint: {
delay: 200,
at: async (value) => {
const list = await fetchMentions(value)
return list.map(u => ({
value: u.username,
html: `<span>@${u.username}</span>`
}))
}
},
cdn: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor', cdn: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor',
toolbar: [ toolbar: [
'emoji', 'emoji',
+38 -1
View File
@@ -147,6 +147,29 @@
</router-link> </router-link>
</NotificationContainer> </NotificationContainer>
</template> </template>
<template v-else-if="item.type === 'MENTION' && 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>
在评论中提到了你
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}#comment-${item.comment.id}`">
{{ sanitizeDescription(item.comment.content) }}
</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 class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
{{ sanitizeDescription(item.post.title) }}
</router-link>
中提到了你
</NotificationContainer>
</template>
<template v-else-if="item.type === 'USER_FOLLOWED'"> <template v-else-if="item.type === 'USER_FOLLOWED'">
<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}`"> <router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
@@ -327,7 +350,8 @@ export default {
POST_SUBSCRIBED: 'fas fa-bookmark', POST_SUBSCRIBED: 'fas fa-bookmark',
POST_UNSUBSCRIBED: 'fas fa-bookmark', POST_UNSUBSCRIBED: 'fas fa-bookmark',
REGISTER_REQUEST: 'fas fa-user-clock', REGISTER_REQUEST: 'fas fa-user-clock',
ACTIVITY_REDEEM: 'fas fa-coffee' ACTIVITY_REDEEM: 'fas fa-coffee',
MENTION: 'fas fa-at'
} }
const reactionEmojiMap = { const reactionEmojiMap = {
@@ -420,6 +444,17 @@ export default {
router.push(`/users/${n.comment.author.id}`) router.push(`/users/${n.comment.author.id}`)
} }
}) })
} else if (n.type === 'MENTION') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
router.push(`/users/${n.fromUser.id}`)
}
}
})
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') { } else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
notifications.value.push({ notifications.value.push({
...n, ...n,
@@ -539,6 +574,8 @@ export default {
return '有人取消关注你' return '有人取消关注你'
case 'USER_ACTIVITY': case 'USER_ACTIVITY':
return '关注的用户有新动态' return '关注的用户有新动态'
case 'MENTION':
return '有人提到了你'
default: default:
return t return t
} }
@@ -196,6 +196,16 @@ public class UserController {
.collect(java.util.stream.Collectors.toList()); .collect(java.util.stream.Collectors.toList());
} }
/**
* List all administrator users.
*/
@GetMapping("/admins")
public java.util.List<UserDto> admins() {
return userService.getAdmins().stream()
.map(this::toDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/all") @GetMapping("/{identifier}/all")
public ResponseEntity<UserAggregateDto> userAggregate(@PathVariable("identifier") String identifier, public ResponseEntity<UserAggregateDto> userAggregate(@PathVariable("identifier") String identifier,
@RequestParam(value = "postsLimit", required = false) Integer postsLimit, @RequestParam(value = "postsLimit", required = false) Integer postsLimit,
@@ -31,5 +31,7 @@ public enum NotificationType {
/** A user requested registration approval */ /** A user requested registration approval */
REGISTER_REQUEST, REGISTER_REQUEST,
/** A user redeemed an activity reward */ /** A user redeemed an activity reward */
ACTIVITY_REDEEM ACTIVITY_REDEEM,
/** You were mentioned in a post or comment */
MENTION
} }
@@ -64,6 +64,7 @@ public class CommentService {
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null, null, null, null); notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null, null, null, null);
} }
} }
notificationService.notifyMentions(content, author, post, comment);
return comment; return comment;
} }
@@ -102,6 +103,7 @@ public class CommentService {
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment, null, null, null, null); notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment, null, null, null, null);
} }
} }
notificationService.notifyMentions(content, author, parent.getPost(), comment);
return comment; return comment;
} }
@@ -11,6 +11,11 @@ import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern;
import java.util.regex.Matcher;
import java.util.Set;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
@@ -28,6 +33,8 @@ public class NotificationService {
@Value("${app.website-url}") @Value("${app.website-url}")
private String websiteUrl; private String websiteUrl;
private static final Pattern MENTION_PATTERN = Pattern.compile("@([A-Za-z0-9_]+)");
private String buildPayload(String body, String url) { private String buildPayload(String body, String url) {
// try { // try {
// return new ObjectMapper().writeValueAsString(Map.of( // return new ObjectMapper().writeValueAsString(Map.of(
@@ -144,4 +151,22 @@ public class NotificationService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
return notificationRepository.countByUserAndRead(user, false); return notificationRepository.countByUserAndRead(user, false);
} }
public void notifyMentions(String content, User fromUser, Post post, Comment comment) {
if (content == null || fromUser == null) {
return;
}
Matcher matcher = MENTION_PATTERN.matcher(content);
Set<String> names = new HashSet<>();
while (matcher.find()) {
names.add(matcher.group(1));
}
for (String name : names) {
userRepository.findByUsername(name).ifPresent(target -> {
if (!target.getId().equals(fromUser.getId())) {
createNotification(target, NotificationType.MENTION, post, comment, null, fromUser, null, null);
}
});
}
}
} }
@@ -139,6 +139,7 @@ public class PostService {
null); null);
} }
} }
notificationService.notifyMentions(content, author, post, null);
return post; return post;
} }
@@ -407,6 +408,7 @@ public class PostService {
post.setTags(new java.util.HashSet<>(tags)); post.setTags(new java.util.HashSet<>(tags));
Post updated = postRepository.save(post); Post updated = postRepository.save(post);
imageUploader.adjustReferences(oldContent, content); imageUploader.adjustReferences(oldContent, content);
notificationService.notifyMentions(content, user, updated, null);
return updated; return updated;
} }
@@ -185,4 +185,11 @@ public class UserService {
user.setPassword(passwordEncoder.encode(newPassword)); user.setPassword(passwordEncoder.encode(newPassword));
return userRepository.save(user); return userRepository.save(user);
} }
/**
* Get all administrator accounts.
*/
public java.util.List<User> getAdmins() {
return userRepository.findByRole(Role.ADMIN);
}
} }