From 9209ebea4cd601d9b91c51e5ca8bc7b57d2b18e4 Mon Sep 17 00:00:00 2001 From: CH-122 <1521930938@qq.com> Date: Wed, 13 Aug 2025 15:40:40 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E6=8F=92=E4=BB=B6=E4=BB=A5=E6=94=AF=E6=8C=81=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E9=93=BE=E6=8E=A5=E5=9C=A8=E6=96=B0=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E6=89=93=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend_nuxt/utils/markdown.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/frontend_nuxt/utils/markdown.js b/frontend_nuxt/utils/markdown.js index 4c2b586bd..8495ce2bb 100644 --- a/frontend_nuxt/utils/markdown.js +++ b/frontend_nuxt/utils/markdown.js @@ -50,6 +50,29 @@ function tiebaEmojiPlugin(md) { }) } +// 链接在新窗口打开 +function linkPlugin(md) { + const defaultRender = md.renderer.rules.link_open || function(tokens, idx, options, env, self) { + return self.renderToken(tokens, idx, options) + } + + md.renderer.rules.link_open = function (tokens, idx, options, env, self) { + const token = tokens[idx] + const hrefIndex = token.attrIndex('href') + + if (hrefIndex >= 0) { + const href = token.attrs[hrefIndex][1] + // 如果是外部链接,添加 target="_blank" 和 rel="noopener noreferrer" + if (href.startsWith('http://') || href.startsWith('https://')) { + token.attrPush(['target', '_blank']) + token.attrPush(['rel', 'noopener noreferrer']) + } + } + + return defaultRender(tokens, idx, options, env, self) + } +} + const md = new MarkdownIt({ html: false, linkify: true, @@ -67,6 +90,7 @@ const md = new MarkdownIt({ md.use(mentionPlugin) md.use(tiebaEmojiPlugin) +md.use(linkPlugin) // 添加链接插件 export function renderMarkdown(text) { return md.render(text || '') From 8fa715477b11fd3493157ce023a8506ceddd4cf3 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Wed, 13 Aug 2025 15:57:59 +0800 Subject: [PATCH 2/4] feat: add lottery win notification --- .../com/openisle/model/NotificationType.java | 2 ++ .../com/openisle/service/PostService.java | 4 +++ frontend_nuxt/pages/message.vue | 27 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/backend/src/main/java/com/openisle/model/NotificationType.java b/backend/src/main/java/com/openisle/model/NotificationType.java index 8679530ff..97a9c7368 100644 --- a/backend/src/main/java/com/openisle/model/NotificationType.java +++ b/backend/src/main/java/com/openisle/model/NotificationType.java @@ -32,6 +32,8 @@ public enum NotificationType { REGISTER_REQUEST, /** A user redeemed an activity reward */ ACTIVITY_REDEEM, + /** You won a lottery post */ + LOTTERY_WIN, /** You were mentioned in a post or comment */ MENTION } diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index 14f94d968..0b53ecb5d 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -69,6 +69,8 @@ public class PostService { private final EmailSender emailSender; private final ApplicationContext applicationContext; private final ConcurrentMap> scheduledFinalizations = new ConcurrentHashMap<>(); + @Value("${app.website-url:https://www.open-isle.com}") + private String websiteUrl; @org.springframework.beans.factory.annotation.Autowired public PostService(PostRepository postRepository, @@ -249,6 +251,8 @@ public class PostService { if (w.getEmail() != null) { emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"); } + notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null); + notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId())); } }); } diff --git a/frontend_nuxt/pages/message.vue b/frontend_nuxt/pages/message.vue index 39a8d5195..f154dd7be 100644 --- a/frontend_nuxt/pages/message.vue +++ b/frontend_nuxt/pages/message.vue @@ -185,6 +185,19 @@ + + @@ -119,6 +121,10 @@ const CommentItem = { type: Boolean, default: false, }, + postAuthorId: { + type: [Number, String], + required: true, + }, }, setup(props, { emit }) { const router = useRouter() @@ -171,12 +177,22 @@ const CommentItem = { }) const isAuthor = computed(() => authState.username === props.comment.userName) + const isPostAuthor = computed(() => Number(authState.userId) === Number(props.postAuthorId)) const isAdmin = computed(() => authState.role === 'ADMIN') - const commentMenuItems = computed(() => - isAuthor.value || isAdmin.value - ? [{ text: '删除评论', color: 'red', onClick: () => deleteComment() }] - : [], - ) + const commentMenuItems = computed(() => { + const items = [] + if (isAuthor.value || isAdmin.value) { + items.push({ text: '删除评论', color: 'red', onClick: () => deleteComment() }) + } + if (isAdmin.value || isPostAuthor.value) { + if (props.comment.pinned) { + items.push({ text: '取消置顶', onClick: () => unpinComment() }) + } else { + items.push({ text: '置顶', onClick: () => pinComment() }) + } + } + return items + }) const deleteComment = async () => { const token = getToken() if (!token) { @@ -196,6 +212,46 @@ const CommentItem = { toast.error('操作失败') } } + const pinComment = async () => { + const token = getToken() + if (!token) { + toast.error('请先登录') + return + } + const url = isAdmin.value + ? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/pin` + : `${API_BASE_URL}/api/comments/${props.comment.id}/pin` + const res = await fetch(url, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) + if (res.ok) { + props.comment.pinned = true + toast.success('已置顶') + } else { + toast.error('操作失败') + } + } + const unpinComment = async () => { + const token = getToken() + if (!token) { + toast.error('请先登录') + return + } + const url = isAdmin.value + ? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/unpin` + : `${API_BASE_URL}/api/comments/${props.comment.id}/unpin` + const res = await fetch(url, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) + if (res.ok) { + props.comment.pinned = false + toast.success('已取消置顶') + } else { + toast.error('操作失败') + } + } const submitReply = async (parentUserName, text, clear) => { if (!text.trim()) return isWaitingForReply.value = true @@ -284,6 +340,9 @@ const CommentItem = { isWaitingForReply, commentMenuItems, deleteComment, + pinComment, + unpinComment, + isPostAuthor, lightboxVisible, lightboxIndex, lightboxImgs, @@ -370,6 +429,12 @@ export default CommentItem margin-left: 10px; } +.pin-icon { + font-size: 12px; + margin-left: 10px; + opacity: 0.6; +} + @keyframes highlight { from { background-color: yellow; diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index 8e05e5d80..4b59bb9b2 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -195,6 +195,7 @@ :comment="item" :level="0" :default-show-replies="item.openReplies" + :post-author-id="author.id" @deleted="onCommentDeleted" /> @@ -405,6 +406,7 @@ export default { avatar: c.author.avatar, text: c.content, reactions: c.reactions || [], + pinned: !!c.pinnedAt, reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)), openReplies: level === 0, src: c.author.avatar,