From 1b892828f13ceb80ba01de3aa268077ce0afb62a Mon Sep 17 00:00:00 2001
From: Tim <135014430+nagisa77@users.noreply.github.com>
Date: Wed, 20 Aug 2025 16:23:22 +0800
Subject: [PATCH 01/62] Update README.md
---
README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README.md b/README.md
index e1d721d01..16e4516b7 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,5 @@
+
高效的开源社区前后端端平台
From 06ffb180fedfc33fad66a38f085977a9e8f1c9bd Mon Sep 17 00:00:00 2001
From: Tim <135014430+nagisa77@users.noreply.github.com>
Date: Wed, 20 Aug 2025 16:24:05 +0800
Subject: [PATCH 02/62] Update README.md
---
README.md | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 16e4516b7..362c7f841 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,9 @@
-
高效的开源社区前后端端平台
-
+
## 💡 简介
From f60f184c8448123c121d4cef85fe9cfe702bf04f Mon Sep 17 00:00:00 2001
From: Tim <135014430+nagisa77@users.noreply.github.com>
Date: Wed, 20 Aug 2025 16:24:33 +0800
Subject: [PATCH 03/62] Update README.md
---
README.md | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 362c7f841..60ed15bb0 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,7 @@
-
高效的开源社区前后端端平台
-
+
From fa1148bc4e4cc8208346c4b135dff34403cb9521 Mon Sep 17 00:00:00 2001
From: Tim <135014430+nagisa77@users.noreply.github.com>
Date: Wed, 20 Aug 2025 16:25:12 +0800
Subject: [PATCH 04/62] Update README.md
---
README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README.md b/README.md
index 60ed15bb0..f6552472d 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,6 @@
+
高效的开源社区前后端端平台
From 66035447a8a054e8add0f791b36d413791265651 Mon Sep 17 00:00:00 2001
From: Tim
Date: Wed, 20 Aug 2025 16:28:28 +0800
Subject: [PATCH 05/62] feat: add CONTRIBUTING
---
frontend_nuxt/CONTRIBUTING.md | 116 ++++++++++++++++++++++++++++++++++
1 file changed, 116 insertions(+)
create mode 100644 frontend_nuxt/CONTRIBUTING.md
diff --git a/frontend_nuxt/CONTRIBUTING.md b/frontend_nuxt/CONTRIBUTING.md
new file mode 100644
index 000000000..9b9804d2c
--- /dev/null
+++ b/frontend_nuxt/CONTRIBUTING.md
@@ -0,0 +1,116 @@
+#### **⚠️注意:仅想修改前端的朋友可不用部署后端服务**
+
+## 如何部署
+
+> Step1 先克隆仓库
+
+```shell
+git clone https://github.com/nagisa77/OpenIsle.git
+cd OpenIsle
+```
+
+> Step2 后端部署
+
+```shell
+cd backend
+```
+
+以IDEA编辑器为例,IDEA打开backend文件夹。
+
+- 设置VM Option,最好运行在其他端口,非8080,这里设置8081
+
+```shell
+-Dserver.port=8081
+```
+
+
+
+- 设置jdk版本为java 17
+
+
+
+- 本机配置MySQL服务(网上很多教程,忽略)
+- 设置环境变量.env 文件 或.properties 文件(二选一)
+
+1. 环境变量文件生成
+
+```shell
+cp open-isle.env.example open-isle.env
+```
+
+修改环境变量,留下需要的,比如你要开发Google登录业务,就需要谷歌相关的变量,数据库是一定要的
+
+
+
+应用环境文件, 选择刚刚的`open-isle.env`
+
+
+
+2. 直接修改 .properities 文件
+
+位置src/main/application.properties, 数据库需要修改标红处,其他按需修改
+
+
+
+处理完环境问题直接跑起来就能通了
+
+
+
+> Step3 前端部署
+
+前端可以依赖本机部署的后端,也可以直接调用线上的后端接口
+
+```shell
+cd ../frontend_nuxt/
+```
+
+copy环境.env文件
+
+```shell
+cp .env.staging.example .env
+```
+
+1. 依赖本机部署的后端:打开本文件夹,修改.env 修改为瞄准本机后端端口
+
+```yaml
+; 本地部署后端
+NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
+; 预发环境后端
+; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
+; 生产环境后端
+; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
+```
+
+2. 依赖预发环境后台环境
+
+**(⚠️强烈推荐只部署前端的朋友使用该环境)**
+
+```yaml
+; 本地部署后端
+; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
+; 预发环境后端
+NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
+; 生产环境后端
+; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
+```
+
+4. 依赖线上后台环境
+
+```yaml
+; 本地部署后端
+; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
+; 预发环境后端
+; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
+; 生产环境后端
+NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
+```
+
+```shell
+# 安装依赖
+npm install --verbose
+
+# 运行前端服务
+npm run dev
+```
+
+如此一来,浏览器访问 http://127.0.0.1:3000 即可访问前端页面
From d717ce03c16f4b9ee4a556a1575ecad21b66d573 Mon Sep 17 00:00:00 2001
From: Tim
Date: Wed, 20 Aug 2025 16:29:45 +0800
Subject: [PATCH 06/62] feat: add CONTRIBUTING
---
frontend_nuxt/CONTRIBUTING.md => CONTRIBUTING.md | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename frontend_nuxt/CONTRIBUTING.md => CONTRIBUTING.md (100%)
diff --git a/frontend_nuxt/CONTRIBUTING.md b/CONTRIBUTING.md
similarity index 100%
rename from frontend_nuxt/CONTRIBUTING.md
rename to CONTRIBUTING.md
From 81e3a80d351a4a19c037e6704e6dd6638550a36a Mon Sep 17 00:00:00 2001
From: Tim <135014430+nagisa77@users.noreply.github.com>
Date: Wed, 20 Aug 2025 16:31:49 +0800
Subject: [PATCH 07/62] Update README.md
---
README.md | 31 ++-----------------------------
1 file changed, 2 insertions(+), 29 deletions(-)
diff --git a/README.md b/README.md
index f6552472d..8fa54b96b 100644
--- a/README.md
+++ b/README.md
@@ -10,36 +10,9 @@
OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。
-## 🚧 开发
+## 🚧 开发 & 部署
-### 后端
-
-1. 确保安装 JDK 17 及 Maven
-2. 信息配置修改 `src/main/resources/application.properties`,或通过环境变量设置数据库等参数
-3. 执行 `mvn clean package` 生成包,之后使用 `java -jar target/openisle-0.0.1-SNAPSHOT.jar`启动,或在开发时直接使用 `mvn spring-boot:run`
-
-### 前端
-
-1. 进入前端目录
- ```bash
- cd frontend_nuxt
- ```
-2. 安装依赖
- ```bash
- npm install
- ```
-3. 启动开发服务
- ```bash
- npm run dev
- ```
-
- 生产版本使用如下命令编译:
-
- ```bash
- npm run build
- ```
-
- 会在 `.output` 目录生成文件,配合线上网站方式部署
+详细见 [Contributing](https://github.com/nagisa77/OpenIsle?tab=contributing-ov-file)
## ✨ 项目特点
From 4969a759aa3d3043d05025eb4a1bbb8e2d533b52 Mon Sep 17 00:00:00 2001
From: Tim
Date: Wed, 20 Aug 2025 19:33:31 +0800
Subject: [PATCH 08/62] =?UTF-8?q?fix:=20=E5=B7=B2=E5=85=B3=E9=97=AD?=
=?UTF-8?q?=E7=9A=84=E5=B8=96=E5=AD=90=E4=B8=8D=E9=9C=80=E8=A6=81=E5=B1=95?=
=?UTF-8?q?=E7=A4=BA=E8=AE=A2=E9=98=85=E6=8C=89=E9=92=AE=20#651?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend_nuxt/pages/posts/[id]/index.vue | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue
index 128b8a208..b117e6e20 100644
--- a/frontend_nuxt/pages/posts/[id]/index.vue
+++ b/frontend_nuxt/pages/posts/[id]/index.vue
@@ -17,7 +17,7 @@
已拒绝
已关闭
@@ -27,7 +27,7 @@
@@ -1067,6 +1067,7 @@ onMounted(async () => {
white-space: nowrap;
}
+.article-closed-button,
.article-subscribe-button-text,
.article-unsubscribe-button-text {
white-space: nowrap;
From 91ffacc3358886c785cd23bb5f940b0ed9b48437 Mon Sep 17 00:00:00 2001
From: Tim
Date: Wed, 20 Aug 2025 19:43:31 +0800
Subject: [PATCH 09/62] =?UTF-8?q?fix:=20=E5=B7=B2=E7=BB=8F=E5=8A=A0?=
=?UTF-8?q?=E8=BD=BD=E7=9A=84=E5=B8=96=E5=AD=90=20=E9=87=8D=E6=96=B0?=
=?UTF-8?q?=E8=BF=9B=E5=85=A5=20=E6=B2=A1=E6=9C=89=E6=89=A7=E8=A1=8C?=
=?UTF-8?q?=E8=AF=84=E8=AE=BA=E5=AE=9A=E4=BD=8D=E9=80=BB=E8=BE=91=20#652?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend_nuxt/pages/posts/[id]/index.vue | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue
index b117e6e20..45f4e9bbe 100644
--- a/frontend_nuxt/pages/posts/[id]/index.vue
+++ b/frontend_nuxt/pages/posts/[id]/index.vue
@@ -876,12 +876,7 @@ const gotoProfile = () => {
navigateTo(`/users/${author.value.id}`, { replace: true })
}
-onActivated(async () => {
- await refreshPost()
- await fetchComments()
-})
-
-onMounted(async () => {
+const initPage = async () => {
await fetchComments()
const hash = location.hash
const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null
@@ -889,6 +884,14 @@ onMounted(async () => {
updateCurrentIndex()
window.addEventListener('scroll', updateCurrentIndex)
jumpToHashComment()
+}
+
+onActivated(async () => {
+ await initPage()
+})
+
+onMounted(async () => {
+ await initPage()
})
From 959b0f6a489e5e63a21d794b77a13547e2022e97 Mon Sep 17 00:00:00 2001
From: Tim <135014430+nagisa77@users.noreply.github.com>
Date: Wed, 20 Aug 2025 20:21:31 +0800
Subject: [PATCH 10/62] feat: notify authors when admin deletes post
---
.../com/openisle/model/NotificationType.java | 2 +
.../com/openisle/service/PostService.java | 9 +++-
.../com/openisle/service/PostServiceTest.java | 52 +++++++++++++++++++
frontend_nuxt/pages/message.vue | 20 +++++++
frontend_nuxt/utils/notification.js | 13 +++++
5 files changed, 95 insertions(+), 1 deletion(-)
diff --git a/backend/src/main/java/com/openisle/model/NotificationType.java b/backend/src/main/java/com/openisle/model/NotificationType.java
index 132af5176..d8e3a99a1 100644
--- a/backend/src/main/java/com/openisle/model/NotificationType.java
+++ b/backend/src/main/java/com/openisle/model/NotificationType.java
@@ -14,6 +14,8 @@ public enum NotificationType {
POST_REVIEW_REQUEST,
/** Your post under review was approved or rejected */
POST_REVIEWED,
+ /** An administrator deleted your post */
+ POST_DELETED,
/** A subscribed post received a new comment */
POST_UPDATED,
/** Someone subscribed to your post */
diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java
index e9a205fb9..f0238d29e 100644
--- a/backend/src/main/java/com/openisle/service/PostService.java
+++ b/backend/src/main/java/com/openisle/service/PostService.java
@@ -579,7 +579,9 @@ public class PostService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
- if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) {
+ User author = post.getAuthor();
+ boolean adminDeleting = !user.getId().equals(author.getId()) && user.getRole() == Role.ADMIN;
+ if (!user.getId().equals(author.getId()) && user.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Unauthorized");
}
for (Comment c : commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post)) {
@@ -596,7 +598,12 @@ public class PostService {
future.cancel(false);
}
}
+ String title = post.getTitle();
postRepository.delete(post);
+ if (adminDeleting) {
+ notificationService.createNotification(author, NotificationType.POST_DELETED,
+ null, null, null, user, null, title);
+ }
}
public java.util.List getPostsByIds(java.util.List ids) {
diff --git a/backend/src/test/java/com/openisle/service/PostServiceTest.java b/backend/src/test/java/com/openisle/service/PostServiceTest.java
index e1dbfd297..4ad621b18 100644
--- a/backend/src/test/java/com/openisle/service/PostServiceTest.java
+++ b/backend/src/test/java/com/openisle/service/PostServiceTest.java
@@ -61,6 +61,58 @@ class PostServiceTest {
verify(postRepo).delete(post);
}
+ @Test
+ void deletePostByAdminNotifiesAuthor() {
+ PostRepository postRepo = mock(PostRepository.class);
+ UserRepository userRepo = mock(UserRepository.class);
+ CategoryRepository catRepo = mock(CategoryRepository.class);
+ TagRepository tagRepo = mock(TagRepository.class);
+ LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
+ NotificationService notifService = mock(NotificationService.class);
+ SubscriptionService subService = mock(SubscriptionService.class);
+ CommentService commentService = mock(CommentService.class);
+ CommentRepository commentRepo = mock(CommentRepository.class);
+ ReactionRepository reactionRepo = mock(ReactionRepository.class);
+ PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class);
+ NotificationRepository notificationRepo = mock(NotificationRepository.class);
+ PostReadService postReadService = mock(PostReadService.class);
+ ImageUploader imageUploader = mock(ImageUploader.class);
+ TaskScheduler taskScheduler = mock(TaskScheduler.class);
+ EmailSender emailSender = mock(EmailSender.class);
+ ApplicationContext context = mock(ApplicationContext.class);
+
+ PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
+ notifService, subService, commentService, commentRepo,
+ reactionRepo, subRepo, notificationRepo, postReadService,
+ imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
+ when(context.getBean(PostService.class)).thenReturn(service);
+
+ Post post = new Post();
+ post.setId(1L);
+ post.setTitle("T");
+ post.setContent("");
+ User author = new User();
+ author.setId(2L);
+ author.setRole(Role.USER);
+ post.setAuthor(author);
+
+ User admin = new User();
+ admin.setId(1L);
+ admin.setRole(Role.ADMIN);
+
+ when(postRepo.findById(1L)).thenReturn(Optional.of(post));
+ when(userRepo.findByUsername("admin")).thenReturn(Optional.of(admin));
+ when(commentRepo.findByPostAndParentIsNullOrderByCreatedAtAsc(post)).thenReturn(List.of());
+ when(reactionRepo.findByPost(post)).thenReturn(List.of());
+ when(subRepo.findByPost(post)).thenReturn(List.of());
+ when(notificationRepo.findByPost(post)).thenReturn(List.of());
+
+ service.deletePost(1L, "admin");
+
+ verify(notifService).createNotification(eq(author), eq(NotificationType.POST_DELETED), isNull(),
+ isNull(), isNull(), eq(admin), isNull(), eq("T"));
+ }
+
@Test
void createPostRespectsRateLimit() {
PostRepository postRepo = mock(PostRepository.class);
diff --git a/frontend_nuxt/pages/message.vue b/frontend_nuxt/pages/message.vue
index 4b89b6ec5..096d3b935 100644
--- a/frontend_nuxt/pages/message.vue
+++ b/frontend_nuxt/pages/message.vue
@@ -495,6 +495,24 @@
已被管理员拒绝
+
+
+ 管理员
+
+
+ {{ item.fromUser.username }}
+
+
+ 删除了您的帖子
+
+ {{ stripMarkdownLength(item.content, 100) }}
+
+
+
{{ formatType(item.type) }}
@@ -647,6 +665,8 @@ const formatType = (t) => {
return '抽奖中奖了'
case 'LOTTERY_DRAW':
return '抽奖已开奖'
+ case 'POST_DELETED':
+ return '帖子被删除'
default:
return t
}
diff --git a/frontend_nuxt/utils/notification.js b/frontend_nuxt/utils/notification.js
index 512b4267a..a7f3c3016 100644
--- a/frontend_nuxt/utils/notification.js
+++ b/frontend_nuxt/utils/notification.js
@@ -26,6 +26,7 @@ const iconMap = {
LOTTERY_WIN: 'fas fa-trophy',
LOTTERY_DRAW: 'fas fa-bullhorn',
MENTION: 'fas fa-at',
+ POST_DELETED: 'fas fa-trash',
}
export async function fetchUnreadCount() {
@@ -185,6 +186,18 @@ function createFetchNotifications() {
}
},
})
+ } else if (n.type === 'POST_DELETED') {
+ arr.push({
+ ...n,
+ src: n.fromUser ? n.fromUser.avatar : null,
+ icon: n.fromUser ? undefined : iconMap[n.type],
+ iconClick: () => {
+ if (n.fromUser) {
+ markRead(n.id)
+ navigateTo(`/users/${n.fromUser.id}`, { replace: true })
+ }
+ },
+ })
} else if (n.type === 'LOTTERY_WIN' || n.type === 'LOTTERY_DRAW') {
arr.push({
...n,
From 3fc6929075d2da8c27d8ca4dc5b1632a04c27bee Mon Sep 17 00:00:00 2001
From: Tim <135014430+nagisa77@users.noreply.github.com>
Date: Wed, 20 Aug 2025 20:46:08 +0800
Subject: [PATCH 11/62] Remove unread message after marking read
---
frontend_nuxt/pages/message.vue | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/frontend_nuxt/pages/message.vue b/frontend_nuxt/pages/message.vue
index 4b89b6ec5..fefc6f9e5 100644
--- a/frontend_nuxt/pages/message.vue
+++ b/frontend_nuxt/pages/message.vue
@@ -524,7 +524,7 @@ import {
fetchNotifications,
fetchUnreadCount,
isLoadingMessage,
- markRead,
+ markRead as markNotificationRead,
notifications,
markAllRead,
hasMore,
@@ -579,6 +579,14 @@ const togglePref = async (pref) => {
}
}
+const markRead = async (id) => {
+ await markNotificationRead(id)
+ if (selectedTab.value === 'unread') {
+ const index = notifications.value.findIndex((n) => n.id === id)
+ if (index !== -1) notifications.value.splice(index, 1)
+ }
+}
+
const approve = async (id, nid) => {
const token = getToken()
if (!token) return
From 99644046fcb66e2703804cfaa6a717d3dc340c6a Mon Sep 17 00:00:00 2001
From: Tim
Date: Wed, 20 Aug 2025 20:55:22 +0800
Subject: [PATCH 12/62] =?UTF-8?q?fix:=20=E6=9C=AC=E5=9C=B0ui=E4=BC=98?=
=?UTF-8?q?=E5=85=88=E5=B7=B2=E8=AF=BB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend_nuxt/components/GlobalPopups.vue | 2 --
frontend_nuxt/pages/message.vue | 4 ++--
frontend_nuxt/utils/notification.js | 26 +++++++++++------------
3 files changed, 15 insertions(+), 17 deletions(-)
diff --git a/frontend_nuxt/components/GlobalPopups.vue b/frontend_nuxt/components/GlobalPopups.vue
index f5d87b2eb..3c31e8d9c 100644
--- a/frontend_nuxt/components/GlobalPopups.vue
+++ b/frontend_nuxt/components/GlobalPopups.vue
@@ -95,7 +95,6 @@ const closeMilkTeaPopup = () => {
if (!process.client) return
localStorage.setItem('milkTeaActivityPopupShown', 'true')
showMilkTeaPopup.value = false
- checkNotificationSetting()
}
const checkNotificationSetting = async () => {
@@ -108,7 +107,6 @@ const closeNotificationPopup = () => {
if (!process.client) return
localStorage.setItem('notificationSettingPopupShown', 'true')
showNotificationPopup.value = false
- checkNewMedals()
}
const checkNewMedals = async () => {
if (!process.client) return
diff --git a/frontend_nuxt/pages/message.vue b/frontend_nuxt/pages/message.vue
index 861799d01..3a8098f3b 100644
--- a/frontend_nuxt/pages/message.vue
+++ b/frontend_nuxt/pages/message.vue
@@ -542,7 +542,7 @@ import {
fetchNotifications,
fetchUnreadCount,
isLoadingMessage,
- markRead as markNotificationRead,
+ markNotificationRead,
notifications,
markAllRead,
hasMore,
@@ -598,7 +598,7 @@ const togglePref = async (pref) => {
}
const markRead = async (id) => {
- await markNotificationRead(id)
+ markNotificationRead(id)
if (selectedTab.value === 'unread') {
const index = notifications.value.findIndex((n) => n.id === id)
if (index !== -1) notifications.value.splice(index, 1)
diff --git a/frontend_nuxt/utils/notification.js b/frontend_nuxt/utils/notification.js
index a7f3c3016..e476d9fb1 100644
--- a/frontend_nuxt/utils/notification.js
+++ b/frontend_nuxt/utils/notification.js
@@ -159,7 +159,7 @@ function createFetchNotifications() {
...n,
src: n.comment.author.avatar,
iconClick: () => {
- markRead(n.id)
+ markNotificationRead(n.id)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
@@ -169,7 +169,7 @@ function createFetchNotifications() {
emoji: reactionEmojiMap[n.reactionType],
iconClick: () => {
if (n.fromUser) {
- markRead(n.id)
+ markNotificationRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
@@ -181,7 +181,7 @@ function createFetchNotifications() {
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
- markRead(n.id)
+ markNotificationRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
@@ -193,7 +193,7 @@ function createFetchNotifications() {
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
- markRead(n.id)
+ markNotificationRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
@@ -204,7 +204,7 @@ function createFetchNotifications() {
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
- markRead(n.id)
+ markNotificationRead(n.id)
navigateTo(`/posts/${n.post.id}`)
}
},
@@ -214,7 +214,7 @@ function createFetchNotifications() {
...n,
src: n.comment.author.avatar,
iconClick: () => {
- markRead(n.id)
+ markNotificationRead(n.id)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
@@ -224,7 +224,7 @@ function createFetchNotifications() {
icon: iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
- markRead(n.id)
+ markNotificationRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
@@ -235,7 +235,7 @@ function createFetchNotifications() {
icon: iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
- markRead(n.id)
+ markNotificationRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
@@ -250,7 +250,7 @@ function createFetchNotifications() {
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
- markRead(n.id)
+ markNotificationRead(n.id)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
@@ -262,7 +262,7 @@ function createFetchNotifications() {
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.post) {
- markRead(n.id)
+ markNotificationRead(n.id)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
@@ -290,7 +290,7 @@ function createFetchNotifications() {
}
}
- const markRead = async (id) => {
+ const markNotificationRead = async (id) => {
if (!id) return
const n = notifications.value.find((n) => n.id === id)
if (!n || n.read) return
@@ -332,7 +332,7 @@ function createFetchNotifications() {
}
return {
fetchNotifications,
- markRead,
+ markNotificationRead,
notifications,
isLoadingMessage,
markAllRead,
@@ -342,7 +342,7 @@ function createFetchNotifications() {
export const {
fetchNotifications,
- markRead,
+ markNotificationRead,
notifications,
isLoadingMessage,
markAllRead,
From b7f5d8485ca3bec8cb5f87c47124ff718666b278 Mon Sep 17 00:00:00 2001
From: Tim
Date: Thu, 21 Aug 2025 10:15:20 +0800
Subject: [PATCH 13/62] =?UTF-8?q?fix:=E3=80=8C=E7=AB=99=E7=82=B9=E7=BB=9F?=
=?UTF-8?q?=E8=AE=A1=E3=80=8D=E6=96=B0=E5=A2=9Eloading=20#664?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend_nuxt/pages/about/stats.vue | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/frontend_nuxt/pages/about/stats.vue b/frontend_nuxt/pages/about/stats.vue
index 74d02898b..7b881d67d 100644
--- a/frontend_nuxt/pages/about/stats.vue
+++ b/frontend_nuxt/pages/about/stats.vue
@@ -1,5 +1,8 @@
+
+
+
From cfce4d7d1dfbb7c38de93d6a567a6c82c91a026b Mon Sep 17 00:00:00 2001
From: Tim
Date: Thu, 21 Aug 2025 10:22:33 +0800
Subject: [PATCH 14/62] =?UTF-8?q?fix:=20=E5=85=A8=E5=B1=80=E7=A7=BB?=
=?UTF-8?q?=E9=99=A4process.client=E3=80=81process.server=20#669?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend_nuxt/components/GlobalPopups.vue | 16 ++++++++--------
frontend_nuxt/components/InfiniteLoadMore.vue | 2 +-
frontend_nuxt/components/TagSelect.vue | 2 +-
frontend_nuxt/composables/useToast.js | 10 +++++-----
frontend_nuxt/pages/index.vue | 2 +-
frontend_nuxt/pages/posts/[id]/index.vue | 9 +++++----
frontend_nuxt/plugins/auth-fetch.client.ts | 2 +-
frontend_nuxt/plugins/toastification.client.ts | 2 +-
frontend_nuxt/utils/auth.js | 12 ++++++------
9 files changed, 29 insertions(+), 28 deletions(-)
diff --git a/frontend_nuxt/components/GlobalPopups.vue b/frontend_nuxt/components/GlobalPopups.vue
index 3c31e8d9c..90a0f00c7 100644
--- a/frontend_nuxt/components/GlobalPopups.vue
+++ b/frontend_nuxt/components/GlobalPopups.vue
@@ -50,7 +50,7 @@ onMounted(async () => {
})
const checkMilkTeaActivity = async () => {
- if (!process.client) return
+ if (!import.meta.client) return
if (localStorage.getItem('milkTeaActivityPopupShown')) return
try {
const res = await fetch(`${API_BASE_URL}/api/activities`)
@@ -68,7 +68,7 @@ const checkMilkTeaActivity = async () => {
}
const checkInviteCodeActivity = async () => {
- if (!process.client) return
+ if (!import.meta.client) return
if (localStorage.getItem('inviteCodeActivityPopupShown')) return
try {
const res = await fetch(`${API_BASE_URL}/api/activities`)
@@ -86,30 +86,30 @@ const checkInviteCodeActivity = async () => {
}
const closeInviteCodePopup = () => {
- if (!process.client) return
+ if (!import.meta.client) return
localStorage.setItem('inviteCodeActivityPopupShown', 'true')
showInviteCodePopup.value = false
}
const closeMilkTeaPopup = () => {
- if (!process.client) return
+ if (!import.meta.client) return
localStorage.setItem('milkTeaActivityPopupShown', 'true')
showMilkTeaPopup.value = false
}
const checkNotificationSetting = async () => {
- if (!process.client) return
+ if (!import.meta.client) return
if (!authState.loggedIn) return
if (localStorage.getItem('notificationSettingPopupShown')) return
showNotificationPopup.value = true
}
const closeNotificationPopup = () => {
- if (!process.client) return
+ if (!import.meta.client) return
localStorage.setItem('notificationSettingPopupShown', 'true')
showNotificationPopup.value = false
}
const checkNewMedals = async () => {
- if (!process.client) return
+ if (!import.meta.client) return
if (!authState.loggedIn || !authState.userId) return
try {
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${authState.userId}`)
@@ -127,7 +127,7 @@ const checkNewMedals = async () => {
}
}
const closeMedalPopup = () => {
- if (!process.client) return
+ if (!import.meta.client) return
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
newMedals.value.forEach((m) => seen.add(m.type))
localStorage.setItem('seenMedals', JSON.stringify([...seen]))
diff --git a/frontend_nuxt/components/InfiniteLoadMore.vue b/frontend_nuxt/components/InfiniteLoadMore.vue
index 441c0ecf4..c4e1a9e0a 100644
--- a/frontend_nuxt/components/InfiniteLoadMore.vue
+++ b/frontend_nuxt/components/InfiniteLoadMore.vue
@@ -40,7 +40,7 @@ const stopObserver = () => {
}
const startObserver = () => {
- if (!process.client || props.pause || done.value) return
+ if (!import.meta.client || props.pause || done.value) return
stopObserver()
io = new IntersectionObserver(
async (entries) => {
diff --git a/frontend_nuxt/components/TagSelect.vue b/frontend_nuxt/components/TagSelect.vue
index b80f506a2..14de024a7 100644
--- a/frontend_nuxt/components/TagSelect.vue
+++ b/frontend_nuxt/components/TagSelect.vue
@@ -63,7 +63,7 @@ const isImageIcon = (icon) => {
}
const buildTagsUrl = (kw = '') => {
- const base = API_BASE_URL || (process.client ? window.location.origin : '')
+ const base = API_BASE_URL || (import.meta.client ? window.location.origin : '')
const url = new URL('/api/tags', base)
if (kw) url.searchParams.set('keyword', kw)
diff --git a/frontend_nuxt/composables/useToast.js b/frontend_nuxt/composables/useToast.js
index 900aa1f74..e8daff063 100644
--- a/frontend_nuxt/composables/useToast.js
+++ b/frontend_nuxt/composables/useToast.js
@@ -1,7 +1,7 @@
// 导出一个便捷的 toast 对象
export const toast = {
success: async (message) => {
- if (process.client) {
+ if (import.meta.client) {
try {
const { useToast } = await import('vue-toastification')
const toastInstance = useToast()
@@ -12,7 +12,7 @@ export const toast = {
}
},
error: async (message) => {
- if (process.client) {
+ if (import.meta.client) {
try {
const { useToast } = await import('vue-toastification')
const toastInstance = useToast()
@@ -23,7 +23,7 @@ export const toast = {
}
},
warning: async (message) => {
- if (process.client) {
+ if (import.meta.client) {
try {
const { useToast } = await import('vue-toastification')
const toastInstance = useToast()
@@ -34,7 +34,7 @@ export const toast = {
}
},
info: async (message) => {
- if (process.client) {
+ if (import.meta.client) {
try {
const { useToast } = await import('vue-toastification')
const toastInstance = useToast()
@@ -48,7 +48,7 @@ export const toast = {
// 导出 useToast composable
export const useToast = () => {
- if (process.client) {
+ if (import.meta.client) {
return new Promise(async (resolve) => {
try {
const { useToast: useVueToast } = await import('vue-toastification')
diff --git a/frontend_nuxt/pages/index.vue b/frontend_nuxt/pages/index.vue
index 476ce7757..fd0434dc4 100644
--- a/frontend_nuxt/pages/index.vue
+++ b/frontend_nuxt/pages/index.vue
@@ -338,7 +338,7 @@ watch([selectedCategory, selectedTags], () => {
watch(selectedTopic, (val) => {
loadOptions()
selectedTopicCookie.value = val
- if (process.client) localStorage.setItem('homeTab', val)
+ if (import.meta.client) localStorage.setItem('homeTab', val)
})
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue
index 45f4e9bbe..33ecd8f61 100644
--- a/frontend_nuxt/pages/posts/[id]/index.vue
+++ b/frontend_nuxt/pages/posts/[id]/index.vue
@@ -295,7 +295,7 @@ const commentSort = ref('NEWEST')
const isFetchingComments = ref(false)
const isMobile = useIsMobile()
-const headerHeight = process.client
+const headerHeight = import.meta.client
? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0
: 0
@@ -309,7 +309,7 @@ useHead(() => ({
],
}))
-if (process.client) {
+if (import.meta.client) {
onBeforeUnmount(() => {
window.removeEventListener('scroll', updateCurrentIndex)
if (countdownTimer) clearInterval(countdownTimer)
@@ -355,7 +355,7 @@ const updateCountdown = () => {
countdown.value = `${h}:${m}:${s}`
}
const startCountdown = () => {
- if (!process.client) return
+ if (!import.meta.client) return
if (countdownTimer) clearInterval(countdownTimer)
updateCountdown()
countdownTimer = setInterval(updateCountdown, 1000)
@@ -515,7 +515,7 @@ watchEffect(() => {
})
// 404 客户端跳转
-// if (postError.value?.statusCode === 404 && process.client) {
+// if (postError.value?.statusCode === 404 && import.meta.client) {
// router.replace('/404')
// }
@@ -877,6 +877,7 @@ const gotoProfile = () => {
}
const initPage = async () => {
+ scrollTo(0, 0)
await fetchComments()
const hash = location.hash
const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null
diff --git a/frontend_nuxt/plugins/auth-fetch.client.ts b/frontend_nuxt/plugins/auth-fetch.client.ts
index 0a07440b8..dae8d6e3b 100644
--- a/frontend_nuxt/plugins/auth-fetch.client.ts
+++ b/frontend_nuxt/plugins/auth-fetch.client.ts
@@ -1,7 +1,7 @@
import { clearToken } from '~/utils/auth'
export default defineNuxtPlugin(() => {
- if (process.client) {
+ if (import.meta.client) {
const originalFetch = window.fetch
window.fetch = async (input, init) => {
const response = await originalFetch(input, init)
diff --git a/frontend_nuxt/plugins/toastification.client.ts b/frontend_nuxt/plugins/toastification.client.ts
index 2a9fd3f31..789a8f5a9 100644
--- a/frontend_nuxt/plugins/toastification.client.ts
+++ b/frontend_nuxt/plugins/toastification.client.ts
@@ -4,7 +4,7 @@ import '~/assets/toast.css'
export default defineNuxtPlugin(async (nuxtApp) => {
// 确保只在客户端环境中注册插件
- if (process.client) {
+ if (import.meta.client) {
try {
// 使用动态导入来避免 CommonJS 模块问题
const { default: Toast, POSITION } = await import('vue-toastification')
diff --git a/frontend_nuxt/utils/auth.js b/frontend_nuxt/utils/auth.js
index a4f87e15e..6fb558c13 100644
--- a/frontend_nuxt/utils/auth.js
+++ b/frontend_nuxt/utils/auth.js
@@ -12,7 +12,7 @@ export const authState = reactive({
role: null,
})
-if (process.client) {
+if (import.meta.client) {
authState.loggedIn =
localStorage.getItem(TOKEN_KEY) !== null && localStorage.getItem(TOKEN_KEY) !== ''
authState.userId = localStorage.getItem(USER_ID_KEY)
@@ -21,18 +21,18 @@ if (process.client) {
}
export function getToken() {
- return process.client ? localStorage.getItem(TOKEN_KEY) : null
+ return import.meta.client ? localStorage.getItem(TOKEN_KEY) : null
}
export function setToken(token) {
- if (process.client) {
+ if (import.meta.client) {
localStorage.setItem(TOKEN_KEY, token)
authState.loggedIn = true
}
}
export function clearToken() {
- if (process.client) {
+ if (import.meta.client) {
localStorage.removeItem(TOKEN_KEY)
clearUserInfo()
authState.loggedIn = false
@@ -40,7 +40,7 @@ export function clearToken() {
}
export function setUserInfo({ id, username }) {
- if (process.client) {
+ if (import.meta.client) {
authState.userId = id
authState.username = username
if (arguments[0] && arguments[0].role) {
@@ -53,7 +53,7 @@ export function setUserInfo({ id, username }) {
}
export function clearUserInfo() {
- if (process.client) {
+ if (import.meta.client) {
localStorage.removeItem(USER_ID_KEY)
localStorage.removeItem(USERNAME_KEY)
localStorage.removeItem(ROLE_KEY)
From 79fe8b59976741095862588a2c3a9e45a2d88fb8 Mon Sep 17 00:00:00 2001
From: Tim <135014430+nagisa77@users.noreply.github.com>
Date: Thu, 21 Aug 2025 11:04:22 +0800
Subject: [PATCH 15/62] feat: add point history
---
.../openisle/controller/AuthController.java | 10 +-
.../controller/CommentController.java | 2 +-
.../controller/PointHistoryController.java | 28 +++
.../openisle/controller/PostController.java | 2 +-
.../com/openisle/dto/PointHistoryDto.java | 23 ++
.../openisle/mapper/PointHistoryMapper.java | 34 +++
.../java/com/openisle/model/PointHistory.java | 49 ++++
.../com/openisle/model/PointHistoryType.java | 10 +
.../repository/PointHistoryRepository.java | 12 +
.../com/openisle/service/InviteService.java | 4 +-
.../com/openisle/service/PointService.java | 59 +++--
frontend_nuxt/pages/points.vue | 213 ++++++++++++++----
12 files changed, 384 insertions(+), 62 deletions(-)
create mode 100644 backend/src/main/java/com/openisle/controller/PointHistoryController.java
create mode 100644 backend/src/main/java/com/openisle/dto/PointHistoryDto.java
create mode 100644 backend/src/main/java/com/openisle/mapper/PointHistoryMapper.java
create mode 100644 backend/src/main/java/com/openisle/model/PointHistory.java
create mode 100644 backend/src/main/java/com/openisle/model/PointHistoryType.java
create mode 100644 backend/src/main/java/com/openisle/repository/PointHistoryRepository.java
diff --git a/backend/src/main/java/com/openisle/controller/AuthController.java b/backend/src/main/java/com/openisle/controller/AuthController.java
index bad3abcfe..f643e7807 100644
--- a/backend/src/main/java/com/openisle/controller/AuthController.java
+++ b/backend/src/main/java/com/openisle/controller/AuthController.java
@@ -53,7 +53,7 @@ public class AuthController {
try {
User user = userService.registerWithInvite(
req.getUsername(), req.getEmail(), req.getPassword());
- inviteService.consume(req.getInviteToken());
+ inviteService.consume(req.getInviteToken(), user.getUsername());
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(user.getUsername()),
@@ -154,7 +154,7 @@ public class AuthController {
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
- inviteService.consume(req.getInviteToken());
+ inviteService.consume(req.getInviteToken(), user.getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
@@ -229,7 +229,7 @@ public class AuthController {
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
- inviteService.consume(req.getInviteToken());
+ inviteService.consume(req.getInviteToken(), user.getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
@@ -276,7 +276,7 @@ public class AuthController {
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
- inviteService.consume(req.getInviteToken());
+ inviteService.consume(req.getInviteToken(), user.getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
@@ -323,7 +323,7 @@ public class AuthController {
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
- inviteService.consume(req.getInviteToken());
+ inviteService.consume(req.getInviteToken(), user.getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
diff --git a/backend/src/main/java/com/openisle/controller/CommentController.java b/backend/src/main/java/com/openisle/controller/CommentController.java
index 09e998607..77b3b0b16 100644
--- a/backend/src/main/java/com/openisle/controller/CommentController.java
+++ b/backend/src/main/java/com/openisle/controller/CommentController.java
@@ -47,7 +47,7 @@ public class CommentController {
Comment comment = commentService.addComment(auth.getName(), postId, req.getContent());
CommentDto dto = commentMapper.toDto(comment);
dto.setReward(levelService.awardForComment(auth.getName()));
- dto.setPointReward(pointService.awardForComment(auth.getName(),postId));
+ dto.setPointReward(pointService.awardForComment(auth.getName(), postId, comment.getId()));
log.debug("createComment succeeded for comment {}", comment.getId());
return ResponseEntity.ok(dto);
}
diff --git a/backend/src/main/java/com/openisle/controller/PointHistoryController.java b/backend/src/main/java/com/openisle/controller/PointHistoryController.java
new file mode 100644
index 000000000..a547d309a
--- /dev/null
+++ b/backend/src/main/java/com/openisle/controller/PointHistoryController.java
@@ -0,0 +1,28 @@
+package com.openisle.controller;
+
+import com.openisle.dto.PointHistoryDto;
+import com.openisle.mapper.PointHistoryMapper;
+import com.openisle.service.PointService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@RestController
+@RequestMapping("/api/point-histories")
+@RequiredArgsConstructor
+public class PointHistoryController {
+ private final PointService pointService;
+ private final PointHistoryMapper pointHistoryMapper;
+
+ @GetMapping
+ public List list(Authentication auth) {
+ return pointService.listHistory(auth.getName()).stream()
+ .map(pointHistoryMapper::toDto)
+ .collect(Collectors.toList());
+ }
+}
diff --git a/backend/src/main/java/com/openisle/controller/PostController.java b/backend/src/main/java/com/openisle/controller/PostController.java
index 3dd4662e0..86d8fd9d1 100644
--- a/backend/src/main/java/com/openisle/controller/PostController.java
+++ b/backend/src/main/java/com/openisle/controller/PostController.java
@@ -45,7 +45,7 @@ public class PostController {
draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
dto.setReward(levelService.awardForPost(auth.getName()));
- dto.setPointReward(pointService.awardForPost(auth.getName()));
+ dto.setPointReward(pointService.awardForPost(auth.getName(), post.getId()));
return ResponseEntity.ok(dto);
}
diff --git a/backend/src/main/java/com/openisle/dto/PointHistoryDto.java b/backend/src/main/java/com/openisle/dto/PointHistoryDto.java
new file mode 100644
index 000000000..cae0b6f6b
--- /dev/null
+++ b/backend/src/main/java/com/openisle/dto/PointHistoryDto.java
@@ -0,0 +1,23 @@
+package com.openisle.dto;
+
+import com.openisle.model.PointHistoryType;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+@Getter
+@Setter
+public class PointHistoryDto {
+ private Long id;
+ private PointHistoryType type;
+ private int amount;
+ private int balance;
+ private Long postId;
+ private String postTitle;
+ private Long commentId;
+ private String commentContent;
+ private Long fromUserId;
+ private String fromUserName;
+ private LocalDateTime createdAt;
+}
diff --git a/backend/src/main/java/com/openisle/mapper/PointHistoryMapper.java b/backend/src/main/java/com/openisle/mapper/PointHistoryMapper.java
new file mode 100644
index 000000000..9a3881d5a
--- /dev/null
+++ b/backend/src/main/java/com/openisle/mapper/PointHistoryMapper.java
@@ -0,0 +1,34 @@
+package com.openisle.mapper;
+
+import com.openisle.dto.PointHistoryDto;
+import com.openisle.model.PointHistory;
+import org.springframework.stereotype.Component;
+
+@Component
+public class PointHistoryMapper {
+ public PointHistoryDto toDto(PointHistory history) {
+ PointHistoryDto dto = new PointHistoryDto();
+ dto.setId(history.getId());
+ dto.setType(history.getType());
+ dto.setAmount(history.getAmount());
+ dto.setBalance(history.getBalance());
+ dto.setCreatedAt(history.getCreatedAt());
+ if (history.getPost() != null) {
+ dto.setPostId(history.getPost().getId());
+ dto.setPostTitle(history.getPost().getTitle());
+ }
+ if (history.getComment() != null) {
+ dto.setCommentId(history.getComment().getId());
+ dto.setCommentContent(history.getComment().getContent());
+ if (history.getComment().getPost() != null && dto.getPostId() == null) {
+ dto.setPostId(history.getComment().getPost().getId());
+ dto.setPostTitle(history.getComment().getPost().getTitle());
+ }
+ }
+ if (history.getFromUser() != null) {
+ dto.setFromUserId(history.getFromUser().getId());
+ dto.setFromUserName(history.getFromUser().getUsername());
+ }
+ return dto;
+ }
+}
diff --git a/backend/src/main/java/com/openisle/model/PointHistory.java b/backend/src/main/java/com/openisle/model/PointHistory.java
new file mode 100644
index 000000000..347d4c75a
--- /dev/null
+++ b/backend/src/main/java/com/openisle/model/PointHistory.java
@@ -0,0 +1,49 @@
+package com.openisle.model;
+
+import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/** Point change history for a user. */
+@Entity
+@Getter
+@Setter
+@NoArgsConstructor
+@Table(name = "point_histories")
+public class PointHistory {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private PointHistoryType type;
+
+ @Column(nullable = false)
+ private int amount;
+
+ @Column(nullable = false)
+ private int balance;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "post_id")
+ private Post post;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "comment_id")
+ private Comment comment;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "from_user_id")
+ private User fromUser;
+
+ @Column(name = "created_at", nullable = false)
+ private LocalDateTime createdAt;
+}
diff --git a/backend/src/main/java/com/openisle/model/PointHistoryType.java b/backend/src/main/java/com/openisle/model/PointHistoryType.java
new file mode 100644
index 000000000..ceda76185
--- /dev/null
+++ b/backend/src/main/java/com/openisle/model/PointHistoryType.java
@@ -0,0 +1,10 @@
+package com.openisle.model;
+
+public enum PointHistoryType {
+ POST,
+ COMMENT,
+ POST_LIKED,
+ COMMENT_LIKED,
+ INVITE,
+ SYSTEM_ONLINE
+}
diff --git a/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java b/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java
new file mode 100644
index 000000000..ac1ee7096
--- /dev/null
+++ b/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java
@@ -0,0 +1,12 @@
+package com.openisle.repository;
+
+import com.openisle.model.PointHistory;
+import com.openisle.model.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface PointHistoryRepository extends JpaRepository {
+ List findByUserOrderByIdDesc(User user);
+ long countByUser(User user);
+}
diff --git a/backend/src/main/java/com/openisle/service/InviteService.java b/backend/src/main/java/com/openisle/service/InviteService.java
index cd0f895a3..158772d34 100644
--- a/backend/src/main/java/com/openisle/service/InviteService.java
+++ b/backend/src/main/java/com/openisle/service/InviteService.java
@@ -45,10 +45,10 @@ public class InviteService {
return invite != null && invite.getUsageCount() < 3;
}
- public void consume(String token) {
+ public void consume(String token, String newUserName) {
InviteToken invite = inviteTokenRepository.findById(token).orElseThrow();
invite.setUsageCount(invite.getUsageCount() + 1);
inviteTokenRepository.save(invite);
- pointService.awardForInvite(invite.getInviter().getUsername());
+ pointService.awardForInvite(invite.getInviter().getUsername(), newUserName);
}
}
diff --git a/backend/src/main/java/com/openisle/service/PointService.java b/backend/src/main/java/com/openisle/service/PointService.java
index be46b1fc6..ea6507d98 100644
--- a/backend/src/main/java/com/openisle/service/PointService.java
+++ b/backend/src/main/java/com/openisle/service/PointService.java
@@ -1,7 +1,6 @@
package com.openisle.service;
-import com.openisle.model.PointLog;
-import com.openisle.model.User;
+import com.openisle.model.*;
import com.openisle.repository.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -16,19 +15,22 @@ public class PointService {
private final PointLogRepository pointLogRepository;
private final PostRepository postRepository;
private final CommentRepository commentRepository;
+ private final PointHistoryRepository pointHistoryRepository;
- public int awardForPost(String userName) {
+ public int awardForPost(String userName, Long postId) {
User user = userRepository.findByUsername(userName).orElseThrow();
PointLog log = getTodayLog(user);
if (log.getPostCount() > 1) return 0;
log.setPostCount(log.getPostCount() + 1);
pointLogRepository.save(log);
- return addPoint(user, 30);
+ Post post = postRepository.findById(postId).orElseThrow();
+ return addPoint(user, 30, PointHistoryType.POST, post, null, null);
}
- public int awardForInvite(String userName) {
+ public int awardForInvite(String userName, String inviteeName) {
User user = userRepository.findByUsername(userName).orElseThrow();
- return addPoint(user, 500);
+ User invitee = userRepository.findByUsername(inviteeName).orElseThrow();
+ return addPoint(user, 500, PointHistoryType.INVITE, null, null, invitee);
}
private PointLog getTodayLog(User user) {
@@ -45,20 +47,38 @@ public class PointService {
});
}
- private int addPoint(User user, int amount) {
+ private int addPoint(User user, int amount, PointHistoryType type,
+ Post post, Comment comment, User fromUser) {
user.setPoint(user.getPoint() + amount);
userRepository.save(user);
+ recordHistory(user, type, amount, post, comment, fromUser);
return amount;
}
+ private void recordHistory(User user, PointHistoryType type, int amount,
+ Post post, Comment comment, User fromUser) {
+ PointHistory history = new PointHistory();
+ history.setUser(user);
+ history.setType(type);
+ history.setAmount(amount);
+ history.setBalance(user.getPoint());
+ history.setPost(post);
+ history.setComment(comment);
+ history.setFromUser(fromUser);
+ history.setCreatedAt(java.time.LocalDateTime.now());
+ pointHistoryRepository.save(history);
+ }
+
// 同时为评论者和发帖人增加积分,返回值为评论者增加的积分数
// 注意需要考虑发帖和回复是同一人的场景
- public int awardForComment(String commenterName, Long postId) {
+ public int awardForComment(String commenterName, Long postId, Long commentId) {
// 标记评论者是否已达到积分奖励上限
boolean isTheRewardCapped = false;
// 根据帖子id找到发帖人
- User poster = postRepository.findById(postId).orElseThrow().getAuthor();
+ Post post = postRepository.findById(postId).orElseThrow();
+ User poster = post.getAuthor();
+ Comment comment = commentRepository.findById(commentId).orElseThrow();
// 获取评论者的加分日志
User commenter = userRepository.findByUsername(commenterName).orElseThrow();
@@ -74,15 +94,15 @@ public class PointService {
} else {
log.setCommentCount(log.getCommentCount() + 1);
pointLogRepository.save(log);
- return addPoint(commenter, 10);
+ return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null);
}
} else {
- addPoint(poster, 10);
+ addPoint(poster, 10, PointHistoryType.COMMENT, post, comment, commenter);
// 如果发帖人与评论者不是同一个,则根据是否达到积分上限来判断评论者加分情况
if (isTheRewardCapped) {
return 0;
} else {
- return addPoint(commenter, 10);
+ return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null);
}
}
}
@@ -101,7 +121,8 @@ public class PointService {
}
// 如果不是同一个,则为发帖人加分
- return addPoint(poster, 10);
+ Post post = postRepository.findById(postId).orElseThrow();
+ return addPoint(poster, 10, PointHistoryType.POST_LIKED, post, null, reactioner);
}
// 考虑点赞者和评论者是同一个的情况
@@ -118,7 +139,17 @@ public class PointService {
}
// 如果不是同一个,则为发帖人加分
- return addPoint(commenter, 10);
+ Comment comment = commentRepository.findById(commentId).orElseThrow();
+ Post post = comment.getPost();
+ return addPoint(commenter, 10, PointHistoryType.COMMENT_LIKED, post, comment, reactioner);
+ }
+
+ public java.util.List listHistory(String userName) {
+ User user = userRepository.findByUsername(userName).orElseThrow();
+ if (pointHistoryRepository.countByUser(user) == 0) {
+ recordHistory(user, PointHistoryType.SYSTEM_ONLINE, 0, null, null, null);
+ }
+ return pointHistoryRepository.findByUserOrderByIdDesc(user);
}
}
diff --git a/frontend_nuxt/pages/points.vue b/frontend_nuxt/pages/points.vue
index 31ae9a7d1..6f8e79fba 100644
--- a/frontend_nuxt/pages/points.vue
+++ b/frontend_nuxt/pages/points.vue
@@ -1,62 +1,151 @@
-
- 🎉 积分规则
-
-
{{ rule }}
+
+
+ 积分兑换
+
+
+ 积分历史
-
-
-
-
-
-
- 我的积分:{{
- point
- }}
-
-
+
+
-
-
-
![good.name]()
-
{{ good.name }}
-
-
- {{ good.cost }} 积分
-
-
- 兑换
-
+
+
-
-
+
+
+
+ 我的积分:{{
+ point
+ }}
+
+
+
+
+
+
![good.name]()
+
{{ good.name }}
+
+
+ {{ good.cost }} 积分
+
+
+ 兑换
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 发送帖子
+ {{
+ item.postTitle
+ }}
+ ,获得{{ item.amount }}积分
+
+
+ 发送评论
+ {{ stripMarkdownLength(item.commentContent, 100) }}
+ ,获得{{ item.amount }}积分
+
+
+ 帖子
+ {{
+ item.postTitle
+ }}
+ 被
+ {{
+ item.fromUserName
+ }}
+ 按赞,获得{{ item.amount }}积分
+
+
+ 评论
+ {{ stripMarkdownLength(item.commentContent, 100) }}
+ 被
+ {{
+ item.fromUserName
+ }}
+ 按赞,获得{{ item.amount }}积分
+
+
+ 邀请了好友
+ {{
+ item.fromUserName
+ }},加入社区,获得 {{ item.amount }} 积分
+
+
+ 积分历史系统上线,你目前的积分是 {{ item.balance }}
+
+
+
+
+
+
+
+
diff --git a/frontend_nuxt/pages/message.vue b/frontend_nuxt/pages/message.vue
index 3a8098f3b..7d0a54723 100644
--- a/frontend_nuxt/pages/message.vue
+++ b/frontend_nuxt/pages/message.vue
@@ -33,15 +33,13 @@
通知设置
-
-
- {{ formatType(pref.type) }}
+
+
+
{{ formatType(pref.type) }}
+
togglePref(pref, val)"
+ />
@@ -550,6 +548,7 @@ import {
updateNotificationPreference,
} from '~/utils/notification'
import TimeManager from '~/utils/time'
+import BaseSwitch from '~/components/BaseSwitch.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
@@ -582,10 +581,10 @@ const fetchPrefs = async () => {
notificationPrefs.value = await fetchNotificationPreferences()
}
-const togglePref = async (pref) => {
- const ok = await updateNotificationPreference(pref.type, !pref.enabled)
+const togglePref = async (pref, value) => {
+ const ok = await updateNotificationPreference(pref.type, value)
if (ok) {
- pref.enabled = !pref.enabled
+ pref.enabled = value
await fetchNotifications({
page: page.value,
size: pageSize,
@@ -846,26 +845,21 @@ onActivated(async () => {
padding: 20px;
}
-.message-control-push-item-container {
+.message-control-item-container {
display: flex;
- flex-direction: row;
- flex-wrap: wrap;
+ flex-direction: column;
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-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ max-width: 200px;
}
-.message-control-push-item.select {
- background-color: var(--primary-color);
- color: white;
+.message-control-item-label {
+ font-size: 14px;
}
@media (max-width: 768px) {
diff --git a/frontend_nuxt/pages/settings.vue b/frontend_nuxt/pages/settings.vue
index 7178ee347..cc2028e38 100644
--- a/frontend_nuxt/pages/settings.vue
+++ b/frontend_nuxt/pages/settings.vue
@@ -38,10 +38,7 @@
@@ -76,6 +73,7 @@ import { ref, onMounted, watch } from 'vue'
import AvatarCropper from '~/components/AvatarCropper.vue'
import BaseInput from '~/components/BaseInput.vue'
import Dropdown from '~/components/Dropdown.vue'
+import BaseSwitch from '~/components/BaseSwitch.vue'
import { toast } from '~/main'
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
import { frostedState, setFrosted } from '~/utils/frosted'
@@ -318,51 +316,6 @@ const save = async () => {
max-width: 200px;
}
-.switch {
- position: relative;
- display: inline-block;
- width: 40px;
- height: 20px;
-}
-
-.switch input {
- opacity: 0;
- width: 0;
- height: 0;
-}
-
-.slider {
- position: absolute;
- cursor: pointer;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background-color: #ccc;
- transition: 0.2s;
- border-radius: 20px;
-}
-
-.slider:before {
- position: absolute;
- content: '';
- height: 16px;
- width: 16px;
- left: 2px;
- bottom: 2px;
- background-color: white;
- transition: 0.2s;
- border-radius: 50%;
-}
-
-input:checked + .slider {
- background-color: var(--primary-color);
-}
-
-input:checked + .slider:before {
- transform: translateX(20px);
-}
-
.profile-section {
margin-bottom: 30px;
}
From 0a722c81c5ee84a1a0e8921132dc366f8fb7e65e Mon Sep 17 00:00:00 2001
From: tim
Date: Thu, 21 Aug 2025 13:37:02 +0800
Subject: [PATCH 18/62] =?UTF-8?q?fix:=20=E5=BE=AE=E4=BF=A1=E9=BB=91?=
=?UTF-8?q?=E5=90=8D=E5=8D=95=E7=94=B3=E8=AF=89=20#676?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend_nuxt/public/tencent2707107139169774686.txt | 1 +
1 file changed, 1 insertion(+)
create mode 100644 frontend_nuxt/public/tencent2707107139169774686.txt
diff --git a/frontend_nuxt/public/tencent2707107139169774686.txt b/frontend_nuxt/public/tencent2707107139169774686.txt
new file mode 100644
index 000000000..71aded954
--- /dev/null
+++ b/frontend_nuxt/public/tencent2707107139169774686.txt
@@ -0,0 +1 @@
+1839503219847005265
\ No newline at end of file
From c08ecb5e3343770b98d50eb00dd92cb11d84dd88 Mon Sep 17 00:00:00 2001
From: Tim <135014430+nagisa77@users.noreply.github.com>
Date: Thu, 21 Aug 2025 13:38:53 +0800
Subject: [PATCH 19/62] Record point redemption in history
---
.../java/com/openisle/model/PointHistoryType.java | 3 ++-
.../java/com/openisle/service/PointMallService.java | 11 +++++++++++
frontend_nuxt/pages/points.vue | 4 ++++
3 files changed, 17 insertions(+), 1 deletion(-)
diff --git a/backend/src/main/java/com/openisle/model/PointHistoryType.java b/backend/src/main/java/com/openisle/model/PointHistoryType.java
index ceda76185..7e71b1bef 100644
--- a/backend/src/main/java/com/openisle/model/PointHistoryType.java
+++ b/backend/src/main/java/com/openisle/model/PointHistoryType.java
@@ -6,5 +6,6 @@ public enum PointHistoryType {
POST_LIKED,
COMMENT_LIKED,
INVITE,
- SYSTEM_ONLINE
+ SYSTEM_ONLINE,
+ REDEEM
}
diff --git a/backend/src/main/java/com/openisle/service/PointMallService.java b/backend/src/main/java/com/openisle/service/PointMallService.java
index c930ca7f6..0f3965b52 100644
--- a/backend/src/main/java/com/openisle/service/PointMallService.java
+++ b/backend/src/main/java/com/openisle/service/PointMallService.java
@@ -3,8 +3,11 @@ package com.openisle.service;
import com.openisle.exception.FieldException;
import com.openisle.exception.NotFoundException;
import com.openisle.model.PointGood;
+import com.openisle.model.PointHistory;
+import com.openisle.model.PointHistoryType;
import com.openisle.model.User;
import com.openisle.repository.PointGoodRepository;
+import com.openisle.repository.PointHistoryRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -18,6 +21,7 @@ public class PointMallService {
private final PointGoodRepository pointGoodRepository;
private final UserRepository userRepository;
private final NotificationService notificationService;
+ private final PointHistoryRepository pointHistoryRepository;
public List listGoods() {
return pointGoodRepository.findAll();
@@ -32,6 +36,13 @@ public class PointMallService {
user.setPoint(user.getPoint() - good.getCost());
userRepository.save(user);
notificationService.createPointRedeemNotifications(user, good.getName() + ": " + contact);
+ PointHistory history = new PointHistory();
+ history.setUser(user);
+ history.setType(PointHistoryType.REDEEM);
+ history.setAmount(-good.getCost());
+ history.setBalance(user.getPoint());
+ history.setCreatedAt(java.time.LocalDateTime.now());
+ pointHistoryRepository.save(history);
return user.getPoint();
}
}
diff --git a/frontend_nuxt/pages/points.vue b/frontend_nuxt/pages/points.vue
index 663928c66..bc1a60294 100644
--- a/frontend_nuxt/pages/points.vue
+++ b/frontend_nuxt/pages/points.vue
@@ -136,6 +136,9 @@
}}
加入社区 🎉,获得 {{ item.amount }} 积分
+
+ 兑换商品,消耗 {{ -item.amount }} 积分
+
积分历史系统上线
你目前的积分是 {{ item.balance }}
@@ -188,6 +191,7 @@ const iconMap = {
COMMENT_LIKED: 'fas fa-thumbs-up',
INVITE: 'fas fa-user-plus',
SYSTEM_ONLINE: 'fas fa-clock',
+ REDEEM: 'fas fa-gift',
}
onMounted(async () => {
From 5f5b6f84a8613263cdf3d4d9ff1e228c5c1a8813 Mon Sep 17 00:00:00 2001
From: Tim <135014430+nagisa77@users.noreply.github.com>
Date: Thu, 21 Aug 2025 14:28:33 +0800
Subject: [PATCH 20/62] feat: add markdown comments and link to rss
---
.../openisle/controller/RssController.java | 22 +++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/backend/src/main/java/com/openisle/controller/RssController.java b/backend/src/main/java/com/openisle/controller/RssController.java
index 4970ebcc1..8fe6a3fa4 100644
--- a/backend/src/main/java/com/openisle/controller/RssController.java
+++ b/backend/src/main/java/com/openisle/controller/RssController.java
@@ -1,7 +1,10 @@
package com.openisle.controller;
import com.openisle.model.Post;
+import com.openisle.model.Comment;
+import com.openisle.model.CommentSort;
import com.openisle.service.PostService;
+import com.openisle.service.CommentService;
import lombok.RequiredArgsConstructor;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
@@ -31,6 +34,7 @@ import java.util.regex.Pattern;
@RequiredArgsConstructor
public class RssController {
private final PostService postService;
+ private final CommentService commentService;
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
@@ -103,6 +107,19 @@ public class RssController {
enclosure = absolutifyUrl(enclosure, base);
}
+ // Top comments in Markdown
+ List
topComments = commentService
+ .getCommentsForPost(p.getId(), CommentSort.MOST_INTERACTIONS);
+ topComments = topComments.subList(0, Math.min(10, topComments.size()));
+ StringBuilder commentMd = new StringBuilder();
+ for (Comment c : topComments) {
+ commentMd.append("> @")
+ .append(nullSafe(c.getAuthor().getUsername()))
+ .append(": ")
+ .append(nullSafe(c.getContent()).replace("\r", ""))
+ .append("\n\n");
+ }
+
sb.append("- ");
elem(sb, "title", cdata(nullSafe(p.getTitle())));
elem(sb, "link", link);
@@ -117,6 +134,11 @@ public class RssController {
sb.append("");
}
+ // Markdown comments
+ elem(sb, "commentsMarkdown", cdata(commentMd.toString()));
+ // Markdown original link
+ elem(sb, "originalLinkMarkdown", cdata("[原文链接](" + link + ")"));
+
sb.append("
");
}
From 92e630df229a0f5d343f48b6df222ed469a70c52 Mon Sep 17 00:00:00 2001
From: Tim
Date: Thu, 21 Aug 2025 15:26:53 +0800
Subject: [PATCH 21/62] feat: comment
---
.../openisle/controller/RssController.java | 84 +++++++++++++++----
1 file changed, 66 insertions(+), 18 deletions(-)
diff --git a/backend/src/main/java/com/openisle/controller/RssController.java b/backend/src/main/java/com/openisle/controller/RssController.java
index 8fe6a3fa4..7dc6122f7 100644
--- a/backend/src/main/java/com/openisle/controller/RssController.java
+++ b/backend/src/main/java/com/openisle/controller/RssController.java
@@ -107,18 +107,11 @@ public class RssController {
enclosure = absolutifyUrl(enclosure, base);
}
- // Top comments in Markdown
+ // 6) 构造优雅的附加区块(原文链接 + 精选评论),编入
List topComments = commentService
.getCommentsForPost(p.getId(), CommentSort.MOST_INTERACTIONS);
topComments = topComments.subList(0, Math.min(10, topComments.size()));
- StringBuilder commentMd = new StringBuilder();
- for (Comment c : topComments) {
- commentMd.append("> @")
- .append(nullSafe(c.getAuthor().getUsername()))
- .append(": ")
- .append(nullSafe(c.getContent()).replace("\r", ""))
- .append("\n\n");
- }
+ String footerHtml = buildFooterHtml(base, link, topComments);
sb.append("- ");
elem(sb, "title", cdata(nullSafe(p.getTitle())));
@@ -127,18 +120,16 @@ public class RssController {
elem(sb, "pubDate", toRfc1123Gmt(p.getCreatedAt().atZone(ZoneId.systemDefault())));
// 摘要
elem(sb, "description", cdata(plain));
- // 全文(HTML)
- sb.append("");
+ // 全文(HTML):正文 + 优雅的 Markdown 区块(已转 HTML)
+ sb.append("");
// 首图 enclosure(图片类型)
if (enclosure != null) {
sb.append("");
}
- // Markdown comments
- elem(sb, "commentsMarkdown", cdata(commentMd.toString()));
- // Markdown original link
- elem(sb, "originalLinkMarkdown", cdata("[原文链接](" + link + ")"));
-
sb.append("
");
}
@@ -158,8 +149,12 @@ public class RssController {
private static String sanitizeHtml(String html) {
if (html == null) return "";
Safelist wl = Safelist.relaxed()
- .addTags("pre", "code", "figure", "figcaption", "picture", "source",
- "table","thead","tbody","tr","th","td","h1","h2","h3","h4","h5","h6")
+ .addTags(
+ "pre","code","figure","figcaption","picture","source",
+ "table","thead","tbody","tr","th","td",
+ "h1","h2","h3","h4","h5","h6",
+ "hr","blockquote"
+ )
.addAttributes("a", "href", "title", "target", "rel")
.addAttributes("img", "src", "alt", "title", "width", "height")
.addAttributes("source", "srcset", "type", "media")
@@ -268,6 +263,59 @@ public class RssController {
return "image/jpeg";
}
+ /* ===================== 附加区块(原文链接 + 精选评论) ===================== */
+
+ /**
+ * 将“原文链接 + 精选评论(最多 10 条)”以优雅的 Markdown 形式渲染为 HTML,
+ * 并做 sanitize + 绝对化,然后拼入 content:encoded 尾部。
+ */
+ private static String buildFooterHtml(String baseUrl, String originalLink, List topComments) {
+ StringBuilder md = new StringBuilder(256);
+
+ // 分割线
+ md.append("\n\n---\n\n");
+
+ // 原文链接(强调 + 可点击)
+ md.append("**原文链接:** ")
+ .append("[").append(originalLink).append("](").append(originalLink).append(")")
+ .append("\n\n");
+
+ // 精选评论(仅当有评论时展示)
+ if (topComments != null && !topComments.isEmpty()) {
+ md.append("### 精选评论(Top ").append(Math.min(10, topComments.size())).append(")\n\n");
+ for (Comment c : topComments) {
+ String author = usernameOf(c);
+ String content = nullSafe(c.getContent()).replace("\r", "");
+ // 使用引用样式展示,提升可读性
+ md.append("> @").append(author).append(": ").append(content).append("\n\n");
+ }
+ }
+
+ // 渲染为 HTML,并保持和正文一致的处理流程
+ String html = renderMarkdown(md.toString());
+ String safe = sanitizeHtml(html);
+ return absolutifyHtml(safe, baseUrl);
+ }
+
+ private static String usernameOf(Comment c) {
+ if (c == null) return "匿名";
+ try {
+ Object authorObj = c.getAuthor();
+ if (authorObj == null) return "匿名";
+ // 反射避免直接依赖实体字段名变化(也可直接强转到具体类型)
+ String username;
+ try {
+ username = (String) authorObj.getClass().getMethod("getUsername").invoke(authorObj);
+ } catch (Exception e) {
+ username = null;
+ }
+ if (username == null || username.isEmpty()) return "匿名";
+ return username;
+ } catch (Exception ignored) {
+ return "匿名";
+ }
+ }
+
/* ===================== 时间/字符串/XML ===================== */
private static String toRfc1123Gmt(ZonedDateTime zdt) {
From 41a5eda311d64d8838d8c8d5f9287eb76c87ccff Mon Sep 17 00:00:00 2001
From: Tim <135014430+nagisa77@users.noreply.github.com>
Date: Thu, 21 Aug 2025 16:10:53 +0800
Subject: [PATCH 22/62] feat: support featured medals
---
.../openisle/controller/PostController.java | 23 +++++++++++++++
.../com/openisle/dto/FeaturedMedalDto.java | 12 ++++++++
.../java/com/openisle/model/MedalType.java | 1 +
.../com/openisle/model/NotificationType.java | 2 ++
.../com/openisle/model/PointHistoryType.java | 1 +
.../openisle/repository/PostRepository.java | 2 ++
.../com/openisle/service/MedalService.java | 20 +++++++++++++
.../com/openisle/service/PointService.java | 6 ++++
.../com/openisle/service/PostService.java | 28 ++++++++++++++++++-
.../openisle/service/MedalServiceTest.java | 2 +-
frontend_nuxt/components/AchievementList.vue | 3 ++
frontend_nuxt/pages/index.vue | 8 ++++--
frontend_nuxt/pages/message.vue | 15 ++++++++++
frontend_nuxt/pages/points.vue | 9 ++++++
frontend_nuxt/utils/medal.js | 1 +
frontend_nuxt/utils/notification.js | 12 ++++++++
16 files changed, 141 insertions(+), 4 deletions(-)
create mode 100644 backend/src/main/java/com/openisle/dto/FeaturedMedalDto.java
diff --git a/backend/src/main/java/com/openisle/controller/PostController.java b/backend/src/main/java/com/openisle/controller/PostController.java
index 86d8fd9d1..3ea7334e2 100644
--- a/backend/src/main/java/com/openisle/controller/PostController.java
+++ b/backend/src/main/java/com/openisle/controller/PostController.java
@@ -171,4 +171,27 @@ public class PostController {
return postService.listPostsByLatestReply(ids, tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
}
+
+ @GetMapping("/featured")
+ public List featuredPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
+ @RequestParam(value = "categoryIds", required = false) List categoryIds,
+ @RequestParam(value = "tagId", required = false) Long tagId,
+ @RequestParam(value = "tagIds", required = false) List tagIds,
+ @RequestParam(value = "page", required = false) Integer page,
+ @RequestParam(value = "pageSize", required = false) Integer pageSize,
+ Authentication auth) {
+ List ids = categoryIds;
+ if (categoryId != null) {
+ ids = java.util.List.of(categoryId);
+ }
+ List tids = tagIds;
+ if (tagId != null) {
+ tids = java.util.List.of(tagId);
+ }
+ if (auth != null) {
+ userVisitService.recordVisit(auth.getName());
+ }
+ return postService.listFeaturedPosts(ids, tids, page, pageSize)
+ .stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
+ }
}
diff --git a/backend/src/main/java/com/openisle/dto/FeaturedMedalDto.java b/backend/src/main/java/com/openisle/dto/FeaturedMedalDto.java
new file mode 100644
index 000000000..2e5cbaf9e
--- /dev/null
+++ b/backend/src/main/java/com/openisle/dto/FeaturedMedalDto.java
@@ -0,0 +1,12 @@
+package com.openisle.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FeaturedMedalDto extends MedalDto {
+ private long currentFeaturedCount;
+ private long targetFeaturedCount;
+}
+
diff --git a/backend/src/main/java/com/openisle/model/MedalType.java b/backend/src/main/java/com/openisle/model/MedalType.java
index 00553511d..c6509cebb 100644
--- a/backend/src/main/java/com/openisle/model/MedalType.java
+++ b/backend/src/main/java/com/openisle/model/MedalType.java
@@ -3,6 +3,7 @@ package com.openisle.model;
public enum MedalType {
COMMENT,
POST,
+ FEATURED,
CONTRIBUTOR,
SEED,
PIONEER
diff --git a/backend/src/main/java/com/openisle/model/NotificationType.java b/backend/src/main/java/com/openisle/model/NotificationType.java
index d8e3a99a1..c4b4e0e25 100644
--- a/backend/src/main/java/com/openisle/model/NotificationType.java
+++ b/backend/src/main/java/com/openisle/model/NotificationType.java
@@ -40,6 +40,8 @@ public enum NotificationType {
LOTTERY_WIN,
/** Your lottery post was drawn */
LOTTERY_DRAW,
+ /** Your post was featured */
+ POST_FEATURED,
/** You were mentioned in a post or comment */
MENTION
}
diff --git a/backend/src/main/java/com/openisle/model/PointHistoryType.java b/backend/src/main/java/com/openisle/model/PointHistoryType.java
index 7e71b1bef..af03d989c 100644
--- a/backend/src/main/java/com/openisle/model/PointHistoryType.java
+++ b/backend/src/main/java/com/openisle/model/PointHistoryType.java
@@ -6,6 +6,7 @@ public enum PointHistoryType {
POST_LIKED,
COMMENT_LIKED,
INVITE,
+ FEATURE,
SYSTEM_ONLINE,
REDEEM
}
diff --git a/backend/src/main/java/com/openisle/repository/PostRepository.java b/backend/src/main/java/com/openisle/repository/PostRepository.java
index 58083b193..a072c83f1 100644
--- a/backend/src/main/java/com/openisle/repository/PostRepository.java
+++ b/backend/src/main/java/com/openisle/repository/PostRepository.java
@@ -97,6 +97,8 @@ public interface PostRepository extends JpaRepository {
long countDistinctByTags_Id(Long tagId);
+ long countByAuthor_IdAndRssExcludedFalse(Long userId);
+
@Query("SELECT t.id, COUNT(DISTINCT p) FROM Post p JOIN p.tags t WHERE t.id IN :tagIds GROUP BY t.id")
List
\ No newline at end of file
+
diff --git a/frontend_nuxt/pages/messages/[id].vue b/frontend_nuxt/pages/messages/[id].vue
deleted file mode 100644
index f9ef6d074..000000000
--- a/frontend_nuxt/pages/messages/[id].vue
+++ /dev/null
@@ -1,490 +0,0 @@
-
-
-
-
-
-
加载中...
-
{{ error }}
-
-
-
-
-
-
![avatar]()
-
-
-
- {{ TimeManager.format(msg.createdAt) }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/frontend_nuxt/pages/users/[id].vue b/frontend_nuxt/pages/users/[id].vue
index 4523565df..8ac524306 100644
--- a/frontend_nuxt/pages/users/[id].vue
+++ b/frontend_nuxt/pages/users/[id].vue
@@ -27,15 +27,11 @@
>
取消关注
-
-
+
+
{
recipientId: user.value.id,
}),
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
- });
- const result = await response.json();
- router.push(`/messages/${result.conversationId}`);
+ })
+ const result = await response.json()
+ router.push(`/message-box/${result.conversationId}`)
} catch (e) {
- toast.error('无法发起私信');
- console.error(e);
+ toast.error('无法发起私信')
+ console.error(e)
}
-};
+}
const gotoTag = (tag) => {
const value = encodeURIComponent(tag.id ?? tag.name)
From 02273e018f899e69039b433618c4344eaa915207 Mon Sep 17 00:00:00 2001
From: Tim
Date: Fri, 22 Aug 2025 13:11:12 +0800
Subject: [PATCH 34/62] =?UTF-8?q?fix:=20=E5=89=8D=E7=AB=AFui=E9=87=8D?=
=?UTF-8?q?=E6=9E=84=E5=AE=8C=E6=88=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend_nuxt/components/HeaderComponent.vue | 4 +-
frontend_nuxt/components/MessageEditor.vue | 3 +-
frontend_nuxt/pages/message-box/[id].vue | 48 ++++---------
frontend_nuxt/pages/message-box/index.vue | 71 ++++++++++----------
4 files changed, 49 insertions(+), 77 deletions(-)
diff --git a/frontend_nuxt/components/HeaderComponent.vue b/frontend_nuxt/components/HeaderComponent.vue
index 5ec4446fb..5963eb5bc 100644
--- a/frontend_nuxt/components/HeaderComponent.vue
+++ b/frontend_nuxt/components/HeaderComponent.vue
@@ -47,9 +47,9 @@
-
+
-
+
{{
unreadMessageCount
}}
diff --git a/frontend_nuxt/components/MessageEditor.vue b/frontend_nuxt/components/MessageEditor.vue
index 865b1c127..d58a0b0bf 100644
--- a/frontend_nuxt/components/MessageEditor.vue
+++ b/frontend_nuxt/components/MessageEditor.vue
@@ -147,7 +147,6 @@ export default {
.message-editor-container {
border: 1px solid var(--border-color);
border-radius: 8px;
- margin-top: 20px;
}
.message-bottom-container {
@@ -180,4 +179,4 @@ export default {
.message-submit:not(.disabled):hover {
background-color: var(--primary-color-hover);
}
-
\ No newline at end of file
+
diff --git a/frontend_nuxt/pages/message-box/[id].vue b/frontend_nuxt/pages/message-box/[id].vue
index 023eaea85..ff054ad85 100644
--- a/frontend_nuxt/pages/message-box/[id].vue
+++ b/frontend_nuxt/pages/message-box/[id].vue
@@ -22,7 +22,7 @@
{{ TimeManager.format(item.createdAt) }}
@@ -335,21 +335,22 @@ onUnmounted(() => {
.chat-container {
display: flex;
flex-direction: column;
- height: calc(100vh - 80px); /* Adjust based on your header/footer height */
margin: 0 auto;
- border: 1px solid var(--border-color);
- border-radius: 8px;
- overflow: hidden;
- background-color: var(--bg-color);
+ overflow: auto;
+ height: calc(100vh - var(--header-height));
position: relative;
}
.chat-header {
display: flex;
+ position: sticky;
+ top: 0;
+ z-index: 100;
align-items: center;
padding: 10px 20px;
- border-bottom: 1px solid var(--border-color);
- background-color: var(--bg-color-soft);
+ border-bottom: 1px solid var(--normal-border-color);
+ background-color: var(--background-color-blur);
+ backdrop-filter: var(--blur-10);
}
.back-button {
@@ -366,17 +367,13 @@ onUnmounted(() => {
}
.messages-list {
- position: absolute;
- top: 60px; /* Header height */
- bottom: 250px; /* Increased space for input area */
- left: 0;
- right: 0;
overflow-y: auto;
padding: 20px;
- padding-bottom: 40px; /* Extra padding at bottom */
+ padding-bottom: 100px;
display: flex;
flex-direction: column;
gap: 20px;
+ margin-bottom: 10px;
}
.load-more-container {
@@ -415,16 +412,6 @@ onUnmounted(() => {
flex-direction: column;
}
-.message-text {
- font-size: 15px;
- line-height: 1.5;
- word-wrap: break-word;
-}
-
-.message-text :deep(p) {
- margin: 0;
-}
-
.message-timestamp {
font-size: 11px;
color: var(--text-color-secondary);
@@ -432,7 +419,6 @@ onUnmounted(() => {
opacity: 0.6;
}
-/* Sent messages */
.message-item.sent {
align-self: flex-end;
flex-direction: row-reverse;
@@ -458,17 +444,7 @@ onUnmounted(() => {
}
.message-input-area {
- position: absolute;
- bottom: 0;
- left: 0;
- right: 0;
- min-height: 200px;
- max-height: 400px;
- padding: 10px 20px;
- border-top: 1px solid var(--border-color);
- background-color: var(--bg-color);
- box-sizing: border-box;
- overflow: visible;
+ margin-left: 20px;
}
.loading-container,
diff --git a/frontend_nuxt/pages/message-box/index.vue b/frontend_nuxt/pages/message-box/index.vue
index 6bb4958ee..36c4d5244 100644
--- a/frontend_nuxt/pages/message-box/index.vue
+++ b/frontend_nuxt/pages/message-box/index.vue
@@ -12,41 +12,39 @@
暂无会话
-
-
-
-
![]()
+
+
+
![]()
+
+
+
+
-
-
-
+
Date: Sat, 23 Aug 2025 01:31:06 +0800
Subject: [PATCH 52/62] feat: add channel support
---
.../openisle/config/ChannelInitializer.java | 32 +++
.../controller/ChannelController.java | 35 +++
.../controller/MessageController.java | 20 ++
.../java/com/openisle/dto/ChannelDto.java | 16 ++
.../openisle/dto/ConversationDetailDto.java | 3 +
.../com/openisle/dto/ConversationDto.java | 3 +
.../openisle/model/MessageConversation.java | 12 +
.../MessageConversationRepository.java | 4 +
.../com/openisle/service/ChannelService.java | 76 +++++++
.../com/openisle/service/MessageService.java | 54 ++++-
frontend_nuxt/pages/message-box/[id].vue | 61 +++--
frontend_nuxt/pages/message-box/index.vue | 208 ++++++++++++++----
12 files changed, 459 insertions(+), 65 deletions(-)
create mode 100644 backend/src/main/java/com/openisle/config/ChannelInitializer.java
create mode 100644 backend/src/main/java/com/openisle/controller/ChannelController.java
create mode 100644 backend/src/main/java/com/openisle/dto/ChannelDto.java
create mode 100644 backend/src/main/java/com/openisle/service/ChannelService.java
diff --git a/backend/src/main/java/com/openisle/config/ChannelInitializer.java b/backend/src/main/java/com/openisle/config/ChannelInitializer.java
new file mode 100644
index 000000000..a31feee70
--- /dev/null
+++ b/backend/src/main/java/com/openisle/config/ChannelInitializer.java
@@ -0,0 +1,32 @@
+package com.openisle.config;
+
+import com.openisle.model.MessageConversation;
+import com.openisle.repository.MessageConversationRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class ChannelInitializer implements CommandLineRunner {
+ private final MessageConversationRepository conversationRepository;
+
+ @Override
+ public void run(String... args) {
+ if (conversationRepository.countByChannelTrue() == 0) {
+ MessageConversation chat = new MessageConversation();
+ chat.setChannel(true);
+ chat.setName("吹水群");
+ chat.setDescription("吹水聊天");
+ chat.setAvatar("/default-avatar.svg");
+ conversationRepository.save(chat);
+
+ MessageConversation tech = new MessageConversation();
+ tech.setChannel(true);
+ tech.setName("技术讨论群");
+ tech.setDescription("讨论技术相关话题");
+ tech.setAvatar("/default-avatar.svg");
+ conversationRepository.save(tech);
+ }
+ }
+}
diff --git a/backend/src/main/java/com/openisle/controller/ChannelController.java b/backend/src/main/java/com/openisle/controller/ChannelController.java
new file mode 100644
index 000000000..69dcc8f97
--- /dev/null
+++ b/backend/src/main/java/com/openisle/controller/ChannelController.java
@@ -0,0 +1,35 @@
+package com.openisle.controller;
+
+import com.openisle.dto.ChannelDto;
+import com.openisle.model.User;
+import com.openisle.repository.UserRepository;
+import com.openisle.service.ChannelService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/channels")
+@RequiredArgsConstructor
+public class ChannelController {
+ private final ChannelService channelService;
+ private final UserRepository userRepository;
+
+ private Long getCurrentUserId(Authentication auth) {
+ User user = userRepository.findByUsername(auth.getName())
+ .orElseThrow(() -> new IllegalArgumentException("User not found"));
+ return user.getId();
+ }
+
+ @GetMapping
+ public List
listChannels(Authentication auth) {
+ return channelService.listChannels(getCurrentUserId(auth));
+ }
+
+ @PostMapping("/{channelId}/join")
+ public ChannelDto joinChannel(@PathVariable Long channelId, Authentication auth) {
+ return channelService.joinChannel(channelId, getCurrentUserId(auth));
+ }
+}
diff --git a/backend/src/main/java/com/openisle/controller/MessageController.java b/backend/src/main/java/com/openisle/controller/MessageController.java
index c599fc785..bf2b2b1ad 100644
--- a/backend/src/main/java/com/openisle/controller/MessageController.java
+++ b/backend/src/main/java/com/openisle/controller/MessageController.java
@@ -59,6 +59,14 @@ public class MessageController {
return ResponseEntity.ok(toDto(message));
}
+ @PostMapping("/conversations/{conversationId}/messages")
+ public ResponseEntity sendMessageToConversation(@PathVariable Long conversationId,
+ @RequestBody ChannelMessageRequest req,
+ Authentication auth) {
+ Message message = messageService.sendMessageToConversation(getCurrentUserId(auth), conversationId, req.getContent());
+ return ResponseEntity.ok(toDto(message));
+ }
+
@PostMapping("/conversations/{conversationId}/read")
public ResponseEntity markAsRead(@PathVariable Long conversationId, Authentication auth) {
messageService.markConversationAsRead(conversationId, getCurrentUserId(auth));
@@ -114,4 +122,16 @@ public class MessageController {
this.content = content;
}
}
+
+ static class ChannelMessageRequest {
+ private String content;
+
+ public String getContent() {
+ return content;
+ }
+
+ public void setContent(String content) {
+ this.content = content;
+ }
+ }
}
\ No newline at end of file
diff --git a/backend/src/main/java/com/openisle/dto/ChannelDto.java b/backend/src/main/java/com/openisle/dto/ChannelDto.java
new file mode 100644
index 000000000..9e1536600
--- /dev/null
+++ b/backend/src/main/java/com/openisle/dto/ChannelDto.java
@@ -0,0 +1,16 @@
+package com.openisle.dto;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class ChannelDto {
+ private Long id;
+ private String name;
+ private String description;
+ private String avatar;
+ private long memberCount;
+ private boolean joined;
+ private long unreadCount;
+}
diff --git a/backend/src/main/java/com/openisle/dto/ConversationDetailDto.java b/backend/src/main/java/com/openisle/dto/ConversationDetailDto.java
index 96f548c45..6b0c9e97c 100644
--- a/backend/src/main/java/com/openisle/dto/ConversationDetailDto.java
+++ b/backend/src/main/java/com/openisle/dto/ConversationDetailDto.java
@@ -8,6 +8,9 @@ import java.util.List;
@Data
public class ConversationDetailDto {
private Long id;
+ private String name;
+ private boolean channel;
+ private String avatar;
private List participants;
private Page messages;
}
\ No newline at end of file
diff --git a/backend/src/main/java/com/openisle/dto/ConversationDto.java b/backend/src/main/java/com/openisle/dto/ConversationDto.java
index 17796f2c9..fdc83e639 100644
--- a/backend/src/main/java/com/openisle/dto/ConversationDto.java
+++ b/backend/src/main/java/com/openisle/dto/ConversationDto.java
@@ -10,6 +10,9 @@ import java.util.List;
@Setter
public class ConversationDto {
private Long id;
+ private String name;
+ private boolean channel;
+ private String avatar;
private MessageDto lastMessage;
private List participants;
private LocalDateTime createdAt;
diff --git a/backend/src/main/java/com/openisle/model/MessageConversation.java b/backend/src/main/java/com/openisle/model/MessageConversation.java
index 9f9c94971..dfcda4e0c 100644
--- a/backend/src/main/java/com/openisle/model/MessageConversation.java
+++ b/backend/src/main/java/com/openisle/model/MessageConversation.java
@@ -20,6 +20,18 @@ public class MessageConversation {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ // Indicates whether this conversation represents a public channel
+ @Column(nullable = false)
+ private boolean channel = false;
+
+ // Channel metadata
+ private String name;
+
+ @Column(columnDefinition = "TEXT")
+ private String description;
+
+ private String avatar;
+
@CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
diff --git a/backend/src/main/java/com/openisle/repository/MessageConversationRepository.java b/backend/src/main/java/com/openisle/repository/MessageConversationRepository.java
index ef8bacb6c..492854bfc 100644
--- a/backend/src/main/java/com/openisle/repository/MessageConversationRepository.java
+++ b/backend/src/main/java/com/openisle/repository/MessageConversationRepository.java
@@ -28,4 +28,8 @@ public interface MessageConversationRepository extends JpaRepository findConversationsByUserIdOrderByLastMessageDesc(@Param("userId") Long userId);
+
+ List findByChannelTrue();
+
+ long countByChannelTrue();
}
\ No newline at end of file
diff --git a/backend/src/main/java/com/openisle/service/ChannelService.java b/backend/src/main/java/com/openisle/service/ChannelService.java
new file mode 100644
index 000000000..1ec1c9e25
--- /dev/null
+++ b/backend/src/main/java/com/openisle/service/ChannelService.java
@@ -0,0 +1,76 @@
+package com.openisle.service;
+
+import com.openisle.dto.ChannelDto;
+import com.openisle.model.MessageConversation;
+import com.openisle.model.MessageParticipant;
+import com.openisle.model.User;
+import com.openisle.repository.MessageConversationRepository;
+import com.openisle.repository.MessageParticipantRepository;
+import com.openisle.repository.MessageRepository;
+import com.openisle.repository.UserRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class ChannelService {
+ private final MessageConversationRepository conversationRepository;
+ private final MessageParticipantRepository participantRepository;
+ private final MessageRepository messageRepository;
+ private final UserRepository userRepository;
+
+ @Transactional(readOnly = true)
+ public List listChannels(Long userId) {
+ List channels = conversationRepository.findByChannelTrue();
+ return channels.stream().map(c -> toDto(c, userId)).collect(Collectors.toList());
+ }
+
+ @Transactional
+ public ChannelDto joinChannel(Long channelId, Long userId) {
+ MessageConversation channel = conversationRepository.findById(channelId)
+ .orElseThrow(() -> new IllegalArgumentException("Channel not found"));
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> new IllegalArgumentException("User not found"));
+ participantRepository.findByConversationIdAndUserId(channelId, userId)
+ .orElseGet(() -> {
+ MessageParticipant p = new MessageParticipant();
+ p.setConversation(channel);
+ p.setUser(user);
+ MessageParticipant saved = participantRepository.save(p);
+ channel.getParticipants().add(saved);
+ return saved;
+ });
+ return toDto(channel, userId);
+ }
+
+ private ChannelDto toDto(MessageConversation channel, Long userId) {
+ ChannelDto dto = new ChannelDto();
+ dto.setId(channel.getId());
+ dto.setName(channel.getName());
+ dto.setDescription(channel.getDescription());
+ dto.setAvatar(channel.getAvatar());
+ dto.setMemberCount(channel.getParticipants().size());
+ boolean joined = channel.getParticipants().stream()
+ .anyMatch(p -> p.getUser().getId().equals(userId));
+ dto.setJoined(joined);
+ if (joined) {
+ MessageParticipant participant = channel.getParticipants().stream()
+ .filter(p -> p.getUser().getId().equals(userId))
+ .findFirst().orElse(null);
+ LocalDateTime lastRead = participant.getLastReadAt() == null
+ ? LocalDateTime.of(1970, 1, 1, 0, 0)
+ : participant.getLastReadAt();
+ long unread = messageRepository
+ .countByConversationIdAndCreatedAtAfterAndSenderIdNot(channel.getId(), lastRead, userId);
+ dto.setUnreadCount(unread);
+ } else {
+ dto.setUnreadCount(0);
+ }
+ return dto;
+ }
+}
diff --git a/backend/src/main/java/com/openisle/service/MessageService.java b/backend/src/main/java/com/openisle/service/MessageService.java
index 3e668742e..7f28d4809 100644
--- a/backend/src/main/java/com/openisle/service/MessageService.java
+++ b/backend/src/main/java/com/openisle/service/MessageService.java
@@ -82,6 +82,49 @@ public class MessageService {
return message;
}
+ @Transactional
+ public Message sendMessageToConversation(Long senderId, Long conversationId, String content) {
+ User sender = userRepository.findById(senderId)
+ .orElseThrow(() -> new IllegalArgumentException("Sender not found"));
+ MessageConversation conversation = conversationRepository.findById(conversationId)
+ .orElseThrow(() -> new IllegalArgumentException("Conversation not found"));
+
+ // Join the conversation if not already a participant (useful for channels)
+ participantRepository.findByConversationIdAndUserId(conversationId, senderId)
+ .orElseGet(() -> {
+ MessageParticipant p = new MessageParticipant();
+ p.setConversation(conversation);
+ p.setUser(sender);
+ return participantRepository.save(p);
+ });
+
+ Message message = new Message();
+ message.setConversation(conversation);
+ message.setSender(sender);
+ message.setContent(content);
+ message = messageRepository.save(message);
+
+ conversation.setLastMessage(message);
+ conversationRepository.save(conversation);
+
+ MessageDto messageDto = toDto(message);
+ String conversationDestination = "/topic/conversation/" + conversation.getId();
+ messagingTemplate.convertAndSend(conversationDestination, messageDto);
+
+ // Notify all participants except sender for updates
+ for (MessageParticipant participant : conversation.getParticipants()) {
+ if (participant.getUser().getId().equals(senderId)) continue;
+ String userDestination = "/topic/user/" + participant.getUser().getId() + "/messages";
+ messagingTemplate.convertAndSend(userDestination, messageDto);
+
+ long unreadCount = getUnreadMessageCount(participant.getUser().getId());
+ String username = participant.getUser().getUsername();
+ messagingTemplate.convertAndSendToUser(username, "/queue/unread-count", unreadCount);
+ }
+
+ return message;
+ }
+
private MessageDto toDto(Message message) {
MessageDto dto = new MessageDto();
dto.setId(message.getId());
@@ -134,12 +177,18 @@ public class MessageService {
@Transactional(readOnly = true)
public List getConversations(Long userId) {
List conversations = conversationRepository.findConversationsByUserIdOrderByLastMessageDesc(userId);
- return conversations.stream().map(c -> toDto(c, userId)).collect(Collectors.toList());
+ return conversations.stream()
+ .filter(c -> !c.isChannel())
+ .map(c -> toDto(c, userId))
+ .collect(Collectors.toList());
}
private ConversationDto toDto(MessageConversation conversation, Long userId) {
ConversationDto dto = new ConversationDto();
dto.setId(conversation.getId());
+ dto.setChannel(conversation.isChannel());
+ dto.setName(conversation.getName());
+ dto.setAvatar(conversation.getAvatar());
dto.setCreatedAt(conversation.getCreatedAt());
if (conversation.getLastMessage() != null) {
dto.setLastMessage(toDto(conversation.getLastMessage()));
@@ -189,6 +238,9 @@ public class MessageService {
ConversationDetailDto detailDto = new ConversationDetailDto();
detailDto.setId(conversation.getId());
+ detailDto.setName(conversation.getName());
+ detailDto.setChannel(conversation.isChannel());
+ detailDto.setAvatar(conversation.getAvatar());
detailDto.setParticipants(participants);
detailDto.setMessages(messageDtoPage);
diff --git a/frontend_nuxt/pages/message-box/[id].vue b/frontend_nuxt/pages/message-box/[id].vue
index 737310efc..a33d6eb19 100644
--- a/frontend_nuxt/pages/message-box/[id].vue
+++ b/frontend_nuxt/pages/message-box/[id].vue
@@ -1,10 +1,12 @@
-