From e8e7b9a2450dfa5f55ab14d550f39f7222cb94d3 Mon Sep 17 00:00:00 2001 From: tim Date: Sat, 23 Aug 2025 00:53:32 +0800 Subject: [PATCH 01/13] feat: add search drop down --- frontend_nuxt/assets/global.css | 2 +- frontend_nuxt/components/BaseTimeline.vue | 6 +++++ frontend_nuxt/pages/message-box/[id].vue | 28 ++++++++++++----------- frontend_nuxt/pages/message-box/index.vue | 10 ++++++++ 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/frontend_nuxt/assets/global.css b/frontend_nuxt/assets/global.css index edfad14a8..4f7a3a895 100644 --- a/frontend_nuxt/assets/global.css +++ b/frontend_nuxt/assets/global.css @@ -18,7 +18,7 @@ --background-color-blur: rgba(255, 255, 255, 0.57); --menu-border-color: lightgray; --normal-border-color: lightgray; - --menu-selected-background-color: rgba(208, 250, 255, 0.659); + --menu-selected-background-color: rgba(228, 228, 228, 0.884); --menu-text-color: black; --scroller-background-color: rgba(130, 175, 180, 0.5); /* --normal-background-color: rgb(241, 241, 241); */ diff --git a/frontend_nuxt/components/BaseTimeline.vue b/frontend_nuxt/components/BaseTimeline.vue index 41d6064a2..dac7e55cf 100644 --- a/frontend_nuxt/components/BaseTimeline.vue +++ b/frontend_nuxt/components/BaseTimeline.vue @@ -41,6 +41,12 @@ export default { margin-top: 10px; } +.timeline-item:hover { + background-color: var(--menu-selected-background-color); + transition: background-color 0.2s; + border-radius: 10px; +} + .timeline-icon { position: sticky; top: 0; diff --git a/frontend_nuxt/pages/message-box/[id].vue b/frontend_nuxt/pages/message-box/[id].vue index af0086974..60532f7ea 100644 --- a/frontend_nuxt/pages/message-box/[id].vue +++ b/frontend_nuxt/pages/message-box/[id].vue @@ -8,13 +8,15 @@
-
加载中...
+
+ +
{{ error }}
@@ -57,6 +64,7 @@ import { useWebSocket } from '~/composables/useWebSocket' import { useUnreadCount } from '~/composables/useUnreadCount' import TimeManager from '~/utils/time' import BaseTimeline from '~/components/BaseTimeline.vue' +import BasePlaceholder from '~/components/BasePlaceholder.vue' const config = useRuntimeConfig() const route = useRoute() diff --git a/frontend_nuxt/pages/message-box/index.vue b/frontend_nuxt/pages/message-box/index.vue index 9de3b0c65..4fb24affa 100644 --- a/frontend_nuxt/pages/message-box/index.vue +++ b/frontend_nuxt/pages/message-box/index.vue @@ -8,14 +8,14 @@
{{ error }}
-
-
暂无会话
-
-
+
+ +
+
-
- - 关注 -
-
- - 取消关注 -
-
- - 发私信 + { color: #666; } +.profile-page-header-user-info-buttons { + display: flex; + flex-direction: row; + gap: 10px; +} + .profile-page-header-subscribe-button { display: flex; flex-direction: row; From 7e951203418cb927e0c21ca3d3f3971fe2c15f10 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Sat, 23 Aug 2025 01:05:28 +0800 Subject: [PATCH 03/13] feat: add person search dropdown --- .../components/SearchPersonDropdown.vue | 198 ++++++++++++++++++ frontend_nuxt/pages/message-box/index.vue | 4 +- 2 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 frontend_nuxt/components/SearchPersonDropdown.vue diff --git a/frontend_nuxt/components/SearchPersonDropdown.vue b/frontend_nuxt/components/SearchPersonDropdown.vue new file mode 100644 index 000000000..1ff229bad --- /dev/null +++ b/frontend_nuxt/components/SearchPersonDropdown.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/frontend_nuxt/pages/message-box/index.vue b/frontend_nuxt/pages/message-box/index.vue index 9de3b0c65..0a1578600 100644 --- a/frontend_nuxt/pages/message-box/index.vue +++ b/frontend_nuxt/pages/message-box/index.vue @@ -13,7 +13,7 @@
- +
Date: Sat, 23 Aug 2025 01:31:06 +0800 Subject: [PATCH 04/13] 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 @@