From c5413064942bef116d0a9407cca0f9f1f08da9a2 Mon Sep 17 00:00:00 2001
From: Tim <135014430+nagisa77@users.noreply.github.com>
Date: Thu, 31 Jul 2025 17:57:25 +0800
Subject: [PATCH] Add mention suggestions and admin list
---
open-isle-cli/src/utils/user.js | 30 ++++++++++++++
open-isle-cli/src/utils/vditor.js | 30 +++++++++++++-
open-isle-cli/src/views/MessagePageView.vue | 39 ++++++++++++++++++-
.../openisle/controller/UserController.java | 10 +++++
.../com/openisle/model/NotificationType.java | 4 +-
.../com/openisle/service/CommentService.java | 2 +
.../openisle/service/NotificationService.java | 25 ++++++++++++
.../com/openisle/service/PostService.java | 2 +
.../com/openisle/service/UserService.java | 7 ++++
9 files changed, 146 insertions(+), 3 deletions(-)
create mode 100644 open-isle-cli/src/utils/user.js
diff --git a/open-isle-cli/src/utils/user.js b/open-isle-cli/src/utils/user.js
new file mode 100644
index 000000000..a7469067e
--- /dev/null
+++ b/open-isle-cli/src/utils/user.js
@@ -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 []
+ }
+}
diff --git a/open-isle-cli/src/utils/vditor.js b/open-isle-cli/src/utils/vditor.js
index 2a46a0a52..3b1a5b755 100644
--- a/open-isle-cli/src/utils/vditor.js
+++ b/open-isle-cli/src/utils/vditor.js
@@ -1,7 +1,8 @@
import Vditor from 'vditor'
import 'vditor/dist/index.css'
import { API_BASE_URL } from '../main'
-import { getToken } from './auth'
+import { getToken, authState } from './auth'
+import { searchUsers, fetchFollowings, fetchAdmins } from './user'
export function getEditorTheme() {
return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'classic'
@@ -19,11 +20,38 @@ export function createVditor(editorId, options = {}) {
after
} = 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, {
placeholder,
height: 'auto',
theme: getEditorTheme(),
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: `@${u.username}`
+ }))
+ }
+ },
cdn: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor',
toolbar: [
'emoji',
diff --git a/open-isle-cli/src/views/MessagePageView.vue b/open-isle-cli/src/views/MessagePageView.vue
index 93b7c64d2..675fbfc6f 100644
--- a/open-isle-cli/src/views/MessagePageView.vue
+++ b/open-isle-cli/src/views/MessagePageView.vue
@@ -147,6 +147,29 @@
+
+
+
+ {{ item.fromUser.username }}
+
+ 在评论中提到了你:
+
+ {{ sanitizeDescription(item.comment.content) }}
+
+
+
+
+
+
+ {{ item.fromUser.username }}
+
+ 在帖子
+
+ {{ sanitizeDescription(item.post.title) }}
+
+ 中提到了你
+
+
@@ -327,7 +350,8 @@ export default {
POST_SUBSCRIBED: 'fas fa-bookmark',
POST_UNSUBSCRIBED: 'fas fa-bookmark',
REGISTER_REQUEST: 'fas fa-user-clock',
- ACTIVITY_REDEEM: 'fas fa-coffee'
+ ACTIVITY_REDEEM: 'fas fa-coffee',
+ MENTION: 'fas fa-at'
}
const reactionEmojiMap = {
@@ -420,6 +444,17 @@ export default {
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') {
notifications.value.push({
...n,
@@ -539,6 +574,8 @@ export default {
return '有人取消关注你'
case 'USER_ACTIVITY':
return '关注的用户有新动态'
+ case 'MENTION':
+ return '有人提到了你'
default:
return t
}
diff --git a/src/main/java/com/openisle/controller/UserController.java b/src/main/java/com/openisle/controller/UserController.java
index 4809e0e67..1aef3a650 100644
--- a/src/main/java/com/openisle/controller/UserController.java
+++ b/src/main/java/com/openisle/controller/UserController.java
@@ -196,6 +196,16 @@ public class UserController {
.collect(java.util.stream.Collectors.toList());
}
+ /**
+ * List all administrator users.
+ */
+ @GetMapping("/admins")
+ public java.util.List admins() {
+ return userService.getAdmins().stream()
+ .map(this::toDto)
+ .collect(java.util.stream.Collectors.toList());
+ }
+
@GetMapping("/{identifier}/all")
public ResponseEntity userAggregate(@PathVariable("identifier") String identifier,
@RequestParam(value = "postsLimit", required = false) Integer postsLimit,
diff --git a/src/main/java/com/openisle/model/NotificationType.java b/src/main/java/com/openisle/model/NotificationType.java
index fb5672af3..8679530ff 100644
--- a/src/main/java/com/openisle/model/NotificationType.java
+++ b/src/main/java/com/openisle/model/NotificationType.java
@@ -31,5 +31,7 @@ public enum NotificationType {
/** A user requested registration approval */
REGISTER_REQUEST,
/** A user redeemed an activity reward */
- ACTIVITY_REDEEM
+ ACTIVITY_REDEEM,
+ /** You were mentioned in a post or comment */
+ MENTION
}
diff --git a/src/main/java/com/openisle/service/CommentService.java b/src/main/java/com/openisle/service/CommentService.java
index 49d11c037..ce74c7d75 100644
--- a/src/main/java/com/openisle/service/CommentService.java
+++ b/src/main/java/com/openisle/service/CommentService.java
@@ -64,6 +64,7 @@ public class CommentService {
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null, null, null, null);
}
}
+ notificationService.notifyMentions(content, author, post, comment);
return comment;
}
@@ -102,6 +103,7 @@ public class CommentService {
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment, null, null, null, null);
}
}
+ notificationService.notifyMentions(content, author, parent.getPost(), comment);
return comment;
}
diff --git a/src/main/java/com/openisle/service/NotificationService.java b/src/main/java/com/openisle/service/NotificationService.java
index 2e5af02b4..a1bf8e5bc 100644
--- a/src/main/java/com/openisle/service/NotificationService.java
+++ b/src/main/java/com/openisle/service/NotificationService.java
@@ -11,6 +11,11 @@ import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
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.concurrent.Executor;
@@ -28,6 +33,8 @@ public class NotificationService {
@Value("${app.website-url}")
private String websiteUrl;
+ private static final Pattern MENTION_PATTERN = Pattern.compile("@([A-Za-z0-9_]+)");
+
private String buildPayload(String body, String url) {
// try {
// return new ObjectMapper().writeValueAsString(Map.of(
@@ -144,4 +151,22 @@ public class NotificationService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
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 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);
+ }
+ });
+ }
+ }
}
diff --git a/src/main/java/com/openisle/service/PostService.java b/src/main/java/com/openisle/service/PostService.java
index e1a05b34e..2e124f3f4 100644
--- a/src/main/java/com/openisle/service/PostService.java
+++ b/src/main/java/com/openisle/service/PostService.java
@@ -139,6 +139,7 @@ public class PostService {
null);
}
}
+ notificationService.notifyMentions(content, author, post, null);
return post;
}
@@ -407,6 +408,7 @@ public class PostService {
post.setTags(new java.util.HashSet<>(tags));
Post updated = postRepository.save(post);
imageUploader.adjustReferences(oldContent, content);
+ notificationService.notifyMentions(content, user, updated, null);
return updated;
}
diff --git a/src/main/java/com/openisle/service/UserService.java b/src/main/java/com/openisle/service/UserService.java
index 5872791c8..8e6a1d5ed 100644
--- a/src/main/java/com/openisle/service/UserService.java
+++ b/src/main/java/com/openisle/service/UserService.java
@@ -185,4 +185,11 @@ public class UserService {
user.setPassword(passwordEncoder.encode(newPassword));
return userRepository.save(user);
}
+
+ /**
+ * Get all administrator accounts.
+ */
+ public java.util.List getAdmins() {
+ return userRepository.findByRole(Role.ADMIN);
+ }
}