mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-24 07:00:49 +08:00
feat:【站内信】
This commit is contained in:
@@ -26,6 +26,10 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
|
||||
@@ -92,7 +92,7 @@ public class SecurityConfig {
|
||||
cfg.setAllowedHeaders(List.of("*"));
|
||||
cfg.setAllowCredentials(true);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", cfg);
|
||||
source.registerCorsConfiguration("/**", cfg);
|
||||
return source;
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ public class SecurityConfig {
|
||||
.exceptionHandling(eh -> eh.accessDeniedHandler(customAccessDeniedHandler))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.requestMatchers("/ws/**").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/comments/**").permitAll()
|
||||
@@ -172,7 +173,7 @@ public class SecurityConfig {
|
||||
response.getWriter().write("{\"error\": \"Invalid or expired token\"}");
|
||||
return;
|
||||
}
|
||||
} else if (!uri.startsWith("/api/auth") && !publicGet) {
|
||||
} else if (!uri.startsWith("/api/auth") && !publicGet && !uri.startsWith("/ws")) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\": \"Missing token\"}");
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import com.openisle.service.JwtService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.messaging.Message;
|
||||
import org.springframework.messaging.MessageChannel;
|
||||
import org.springframework.messaging.simp.config.ChannelRegistration;
|
||||
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
||||
import org.springframework.messaging.simp.stomp.StompCommand;
|
||||
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
|
||||
import org.springframework.messaging.support.ChannelInterceptor;
|
||||
import org.springframework.messaging.support.MessageHeaderAccessor;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
||||
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
@RequiredArgsConstructor
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final UserDetailsService userDetailsService;
|
||||
|
||||
@Override
|
||||
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||||
// Enable a simple memory-based message broker to carry the messages back to the client on destinations prefixed with "/topic" and "/queue"
|
||||
config.enableSimpleBroker("/topic", "/queue");
|
||||
// Set user destination prefix for personal messages
|
||||
config.setUserDestinationPrefix("/user");
|
||||
// Designates the "/app" prefix for messages that are bound for @MessageMapping-annotated methods.
|
||||
config.setApplicationDestinationPrefixes("/app");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
// Registers the "/ws" endpoint, enabling SockJS fallback options so that alternate transports may be used if WebSocket is not available.
|
||||
registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureClientInboundChannel(ChannelRegistration registration) {
|
||||
registration.interceptors(new ChannelInterceptor() {
|
||||
@Override
|
||||
public Message<?> preSend(Message<?> message, MessageChannel channel) {
|
||||
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
|
||||
|
||||
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
|
||||
System.out.println("WebSocket CONNECT command received");
|
||||
String authHeader = accessor.getFirstNativeHeader("Authorization");
|
||||
System.out.println("Authorization header: " + (authHeader != null ? "present" : "missing"));
|
||||
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7);
|
||||
try {
|
||||
String username = jwtService.validateAndGetSubject(token);
|
||||
System.out.println("JWT validated for user: " + username);
|
||||
var userDetails = userDetailsService.loadUserByUsername(username);
|
||||
Authentication auth = new UsernamePasswordAuthenticationToken(
|
||||
userDetails, null, userDetails.getAuthorities());
|
||||
accessor.setUser(auth);
|
||||
System.out.println("WebSocket user set: " + username);
|
||||
} catch (Exception e) {
|
||||
System.err.println("JWT validation failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
} else if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
|
||||
System.out.println("WebSocket SUBSCRIBE to: " + accessor.getDestination());
|
||||
System.out.println("WebSocket user during subscribe: " + (accessor.getUser() != null ? accessor.getUser().getName() : "null"));
|
||||
}
|
||||
return message;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.ConversationDetailDto;
|
||||
import com.openisle.dto.ConversationDto;
|
||||
import com.openisle.dto.CreateConversationRequest;
|
||||
import com.openisle.dto.CreateConversationResponse;
|
||||
import com.openisle.dto.MessageDto;
|
||||
import com.openisle.dto.UserSummaryDto;
|
||||
import com.openisle.model.Message;
|
||||
import com.openisle.model.MessageConversation;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.service.MessageService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/messages")
|
||||
@RequiredArgsConstructor
|
||||
public class MessageController {
|
||||
|
||||
private final MessageService messageService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
// This is a placeholder for getting the current user's ID
|
||||
private Long getCurrentUserId(Authentication auth) {
|
||||
User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalArgumentException("Sender not found"));
|
||||
// In a real application, you would get this from the Authentication object
|
||||
return user.getId();
|
||||
}
|
||||
|
||||
@GetMapping("/conversations")
|
||||
public ResponseEntity<List<ConversationDto>> getConversations(Authentication auth) {
|
||||
List<ConversationDto> conversations = messageService.getConversations(getCurrentUserId(auth));
|
||||
return ResponseEntity.ok(conversations);
|
||||
}
|
||||
|
||||
@GetMapping("/conversations/{conversationId}")
|
||||
public ResponseEntity<ConversationDetailDto> getMessages(@PathVariable Long conversationId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
Authentication auth) {
|
||||
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
|
||||
ConversationDetailDto conversationDetails = messageService.getConversationDetails(conversationId, getCurrentUserId(auth), pageable);
|
||||
return ResponseEntity.ok(conversationDetails);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<MessageDto> sendMessage(@RequestBody MessageRequest req, Authentication auth) {
|
||||
Message message = messageService.sendMessage(getCurrentUserId(auth), req.getRecipientId(), req.getContent());
|
||||
return ResponseEntity.ok(toDto(message));
|
||||
}
|
||||
|
||||
@PostMapping("/conversations/{conversationId}/read")
|
||||
public ResponseEntity<Void> markAsRead(@PathVariable Long conversationId, Authentication auth) {
|
||||
messageService.markConversationAsRead(conversationId, getCurrentUserId(auth));
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@PostMapping("/conversations")
|
||||
public ResponseEntity<CreateConversationResponse> findOrCreateConversation(@RequestBody CreateConversationRequest req, Authentication auth) {
|
||||
MessageConversation conversation = messageService.findOrCreateConversation(getCurrentUserId(auth), req.getRecipientId());
|
||||
return ResponseEntity.ok(new CreateConversationResponse(conversation.getId()));
|
||||
}
|
||||
|
||||
private MessageDto toDto(Message message) {
|
||||
MessageDto dto = new MessageDto();
|
||||
dto.setId(message.getId());
|
||||
dto.setContent(message.getContent());
|
||||
dto.setCreatedAt(message.getCreatedAt());
|
||||
|
||||
dto.setConversationId(message.getConversation().getId());
|
||||
|
||||
UserSummaryDto senderDto = new UserSummaryDto();
|
||||
senderDto.setId(message.getSender().getId());
|
||||
senderDto.setUsername(message.getSender().getUsername());
|
||||
senderDto.setAvatar(message.getSender().getAvatar());
|
||||
dto.setSender(senderDto);
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
@GetMapping("/unread-count")
|
||||
public ResponseEntity<Long> getUnreadCount(Authentication auth) {
|
||||
return ResponseEntity.ok(messageService.getUnreadMessageCount(getCurrentUserId(auth)));
|
||||
}
|
||||
|
||||
// A simple request DTO
|
||||
static class MessageRequest {
|
||||
private Long recipientId;
|
||||
private String content;
|
||||
|
||||
public Long getRecipientId() {
|
||||
return recipientId;
|
||||
}
|
||||
|
||||
public void setRecipientId(Long recipientId) {
|
||||
this.recipientId = recipientId;
|
||||
}
|
||||
|
||||
public String getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public void setContent(String content) {
|
||||
this.content = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.data.domain.Page;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class ConversationDetailDto {
|
||||
private Long id;
|
||||
private List<UserSummaryDto> participants;
|
||||
private Page<MessageDto> messages;
|
||||
}
|
||||
17
backend/src/main/java/com/openisle/dto/ConversationDto.java
Normal file
17
backend/src/main/java/com/openisle/dto/ConversationDto.java
Normal file
@@ -0,0 +1,17 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class ConversationDto {
|
||||
private Long id;
|
||||
private MessageDto lastMessage;
|
||||
private List<UserSummaryDto> participants;
|
||||
private LocalDateTime createdAt;
|
||||
private long unreadCount;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class CreateConversationRequest {
|
||||
private Long recipientId;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class CreateConversationResponse {
|
||||
private Long conversationId;
|
||||
}
|
||||
13
backend/src/main/java/com/openisle/dto/MessageDto.java
Normal file
13
backend/src/main/java/com/openisle/dto/MessageDto.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class MessageDto {
|
||||
private Long id;
|
||||
private String content;
|
||||
private UserSummaryDto sender;
|
||||
private Long conversationId;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
10
backend/src/main/java/com/openisle/dto/UserSummaryDto.java
Normal file
10
backend/src/main/java/com/openisle/dto/UserSummaryDto.java
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class UserSummaryDto {
|
||||
private Long id;
|
||||
private String username;
|
||||
private String avatar;
|
||||
}
|
||||
35
backend/src/main/java/com/openisle/model/Message.java
Normal file
35
backend/src/main/java/com/openisle/model/Message.java
Normal file
@@ -0,0 +1,35 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "messages")
|
||||
public class Message {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "conversation_id")
|
||||
private MessageConversation conversation;
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "sender_id")
|
||||
private User sender;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
private String content;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "message_conversations")
|
||||
public class MessageConversation {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "last_message_id")
|
||||
private Message lastMessage;
|
||||
|
||||
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private Set<MessageParticipant> participants = new HashSet<>();
|
||||
|
||||
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private Set<Message> messages = new HashSet<>();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "message_participants")
|
||||
public class MessageParticipant {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "conversation_id")
|
||||
private MessageConversation conversation;
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id")
|
||||
private User user;
|
||||
|
||||
@Column
|
||||
private LocalDateTime lastReadAt;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.MessageConversation;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import com.openisle.model.User;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.Optional;
|
||||
import com.openisle.model.User;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface MessageConversationRepository extends JpaRepository<MessageConversation, Long> {
|
||||
@Query("SELECT c FROM MessageConversation c JOIN c.participants p1 JOIN c.participants p2 WHERE p1.user = :user1 AND p2.user = :user2")
|
||||
Optional<MessageConversation> findConversationByUsers(@Param("user1") User user1, @Param("user2") User user2);
|
||||
|
||||
@Query("SELECT DISTINCT c FROM MessageConversation c " +
|
||||
"JOIN c.participants p " +
|
||||
"LEFT JOIN FETCH c.lastMessage lm " +
|
||||
"LEFT JOIN FETCH lm.sender " +
|
||||
"LEFT JOIN FETCH c.participants cp " +
|
||||
"LEFT JOIN FETCH cp.user " +
|
||||
"WHERE p.user.id = :userId " +
|
||||
"ORDER BY COALESCE(lm.createdAt, c.createdAt) DESC")
|
||||
List<MessageConversation> findConversationsByUserIdOrderByLastMessageDesc(@Param("userId") Long userId);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.MessageParticipant;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface MessageParticipantRepository extends JpaRepository<MessageParticipant, Long> {
|
||||
Optional<MessageParticipant> findByConversationIdAndUserId(Long conversationId, Long userId);
|
||||
List<MessageParticipant> findByUserId(Long userId);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.Message;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface MessageRepository extends JpaRepository<Message, Long> {
|
||||
List<Message> findByConversationIdOrderByCreatedAtAsc(Long conversationId);
|
||||
|
||||
Page<Message> findByConversationId(Long conversationId, Pageable pageable);
|
||||
|
||||
long countByConversationIdAndCreatedAtAfter(Long conversationId, java.time.LocalDateTime createdAt);
|
||||
|
||||
// 只计算不是指定用户发送的消息(即别人发给当前用户的消息)
|
||||
long countByConversationIdAndCreatedAtAfterAndSenderIdNot(Long conversationId, java.time.LocalDateTime createdAt, Long senderId);
|
||||
}
|
||||
217
backend/src/main/java/com/openisle/service/MessageService.java
Normal file
217
backend/src/main/java/com/openisle/service/MessageService.java
Normal file
@@ -0,0 +1,217 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.Message;
|
||||
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 com.openisle.dto.ConversationDetailDto;
|
||||
import com.openisle.dto.ConversationDto;
|
||||
import com.openisle.dto.MessageDto;
|
||||
import com.openisle.dto.UserSummaryDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
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
|
||||
@Slf4j
|
||||
public class MessageService {
|
||||
|
||||
private final MessageRepository messageRepository;
|
||||
private final MessageConversationRepository conversationRepository;
|
||||
private final MessageParticipantRepository participantRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
@Transactional
|
||||
public Message sendMessage(Long senderId, Long recipientId, String content) {
|
||||
log.info("Attempting to send message from user {} to user {}", senderId, recipientId);
|
||||
User sender = userRepository.findById(senderId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Sender not found"));
|
||||
User recipient = userRepository.findById(recipientId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Recipient not found"));
|
||||
|
||||
log.info("Finding or creating conversation for users {} and {}", sender.getUsername(), recipient.getUsername());
|
||||
MessageConversation conversation = findOrCreateConversation(sender, recipient);
|
||||
log.info("Conversation found or created with ID: {}", conversation.getId());
|
||||
|
||||
Message message = new Message();
|
||||
message.setConversation(conversation);
|
||||
message.setSender(sender);
|
||||
message.setContent(content);
|
||||
message = messageRepository.save(message);
|
||||
log.info("Message saved with ID: {}", message.getId());
|
||||
|
||||
conversation.setLastMessage(message);
|
||||
conversationRepository.save(conversation);
|
||||
log.info("Conversation {} updated with last message ID {}", conversation.getId(), message.getId());
|
||||
|
||||
// Broadcast the new message to subscribed clients
|
||||
MessageDto messageDto = toDto(message);
|
||||
String conversationDestination = "/topic/conversation/" + conversation.getId();
|
||||
messagingTemplate.convertAndSend(conversationDestination, messageDto);
|
||||
log.info("Message {} broadcasted to destination: {}", message.getId(), conversationDestination);
|
||||
|
||||
// Also notify the recipient on their personal channel to update the conversation list
|
||||
String userDestination = "/topic/user/" + recipient.getId() + "/messages";
|
||||
messagingTemplate.convertAndSend(userDestination, messageDto);
|
||||
log.info("Message {} notification sent to destination: {}", message.getId(), userDestination);
|
||||
|
||||
// Notify recipient of new unread count
|
||||
long unreadCount = getUnreadMessageCount(recipientId);
|
||||
log.info("Calculating unread count for user {}: {}", recipientId, unreadCount);
|
||||
|
||||
// Send using username instead of user ID for WebSocket routing
|
||||
String recipientUsername = recipient.getUsername();
|
||||
messagingTemplate.convertAndSendToUser(recipientUsername, "/queue/unread-count", unreadCount);
|
||||
log.info("Sent unread count {} to user {} (username: {}) via WebSocket destination: /user/{}/queue/unread-count",
|
||||
unreadCount, recipientId, recipientUsername, recipientUsername);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private MessageDto toDto(Message message) {
|
||||
MessageDto dto = new MessageDto();
|
||||
dto.setId(message.getId());
|
||||
dto.setContent(message.getContent());
|
||||
dto.setConversationId(message.getConversation().getId());
|
||||
dto.setCreatedAt(message.getCreatedAt());
|
||||
|
||||
UserSummaryDto userSummaryDto = new UserSummaryDto();
|
||||
userSummaryDto.setId(message.getSender().getId());
|
||||
userSummaryDto.setUsername(message.getSender().getUsername());
|
||||
userSummaryDto.setAvatar(message.getSender().getAvatar());
|
||||
dto.setSender(userSummaryDto);
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
public MessageConversation findOrCreateConversation(Long user1Id, Long user2Id) {
|
||||
User user1 = userRepository.findById(user1Id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User1 not found"));
|
||||
User user2 = userRepository.findById(user2Id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User2 not found"));
|
||||
return findOrCreateConversation(user1, user2);
|
||||
}
|
||||
|
||||
private MessageConversation findOrCreateConversation(User user1, User user2) {
|
||||
log.info("Searching for existing conversation between {} and {}", user1.getUsername(), user2.getUsername());
|
||||
return conversationRepository.findConversationByUsers(user1, user2)
|
||||
.orElseGet(() -> {
|
||||
log.info("No existing conversation found. Creating a new one.");
|
||||
MessageConversation conversation = new MessageConversation();
|
||||
conversation = conversationRepository.save(conversation);
|
||||
log.info("New conversation created with ID: {}", conversation.getId());
|
||||
|
||||
MessageParticipant participant1 = new MessageParticipant();
|
||||
participant1.setConversation(conversation);
|
||||
participant1.setUser(user1);
|
||||
participantRepository.save(participant1);
|
||||
log.info("Participant {} added to conversation {}", user1.getUsername(), conversation.getId());
|
||||
|
||||
MessageParticipant participant2 = new MessageParticipant();
|
||||
participant2.setConversation(conversation);
|
||||
participant2.setUser(user2);
|
||||
participantRepository.save(participant2);
|
||||
log.info("Participant {} added to conversation {}", user2.getUsername(), conversation.getId());
|
||||
|
||||
return conversation;
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ConversationDto> getConversations(Long userId) {
|
||||
List<MessageConversation> conversations = conversationRepository.findConversationsByUserIdOrderByLastMessageDesc(userId);
|
||||
return conversations.stream().map(c -> toDto(c, userId)).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private ConversationDto toDto(MessageConversation conversation, Long userId) {
|
||||
ConversationDto dto = new ConversationDto();
|
||||
dto.setId(conversation.getId());
|
||||
dto.setCreatedAt(conversation.getCreatedAt());
|
||||
if (conversation.getLastMessage() != null) {
|
||||
dto.setLastMessage(toDto(conversation.getLastMessage()));
|
||||
}
|
||||
dto.setParticipants(conversation.getParticipants().stream()
|
||||
.map(p -> {
|
||||
UserSummaryDto userDto = new UserSummaryDto();
|
||||
userDto.setId(p.getUser().getId());
|
||||
userDto.setUsername(p.getUser().getUsername());
|
||||
userDto.setAvatar(p.getUser().getAvatar());
|
||||
return userDto;
|
||||
})
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
MessageParticipant self = conversation.getParticipants().stream()
|
||||
.filter(p -> p.getUser().getId().equals(userId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("Participant not found in conversation"));
|
||||
|
||||
LocalDateTime lastRead = self.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : self.getLastReadAt();
|
||||
// 只计算别人发送给当前用户的未读消息
|
||||
long unreadCount = messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(conversation.getId(), lastRead, userId);
|
||||
dto.setUnreadCount(unreadCount);
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ConversationDetailDto getConversationDetails(Long conversationId, Long userId, Pageable pageable) {
|
||||
markConversationAsRead(conversationId, userId);
|
||||
|
||||
MessageConversation conversation = conversationRepository.findById(conversationId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Conversation not found"));
|
||||
|
||||
Page<Message> messagesPage = messageRepository.findByConversationId(conversationId, pageable);
|
||||
Page<MessageDto> messageDtoPage = messagesPage.map(this::toDto);
|
||||
|
||||
List<UserSummaryDto> participants = conversation.getParticipants().stream()
|
||||
.map(p -> {
|
||||
UserSummaryDto userDto = new UserSummaryDto();
|
||||
userDto.setId(p.getUser().getId());
|
||||
userDto.setUsername(p.getUser().getUsername());
|
||||
userDto.setAvatar(p.getUser().getAvatar());
|
||||
return userDto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
ConversationDetailDto detailDto = new ConversationDetailDto();
|
||||
detailDto.setId(conversation.getId());
|
||||
detailDto.setParticipants(participants);
|
||||
detailDto.setMessages(messageDtoPage);
|
||||
|
||||
return detailDto;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void markConversationAsRead(Long conversationId, Long userId) {
|
||||
MessageParticipant participant = participantRepository.findByConversationIdAndUserId(conversationId, userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Participant not found"));
|
||||
participant.setLastReadAt(LocalDateTime.now());
|
||||
participantRepository.save(participant);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public long getUnreadMessageCount(Long userId) {
|
||||
List<MessageParticipant> participations = participantRepository.findByUserId(userId);
|
||||
long totalUnreadCount = 0;
|
||||
for (MessageParticipant p : participations) {
|
||||
LocalDateTime lastRead = p.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : p.getLastReadAt();
|
||||
// 只计算别人发送给当前用户的未读消息
|
||||
totalUnreadCount += messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(p.getConversation().getId(), lastRead, userId);
|
||||
}
|
||||
return totalUnreadCount;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
<button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
|
||||
<span v-if="isMobile && unreadMessageCount > 0" class="menu-unread-dot"></span>
|
||||
</div>
|
||||
<NuxtLink class="logo-container" :to="`/`" @click="refrechData">
|
||||
<img
|
||||
@@ -47,6 +47,13 @@
|
||||
</div>
|
||||
</ToolTip>
|
||||
|
||||
<ToolTip v-if="isLogin" content="站内信" placement="bottom">
|
||||
<div class="messages-icon" @click="goToMessages">
|
||||
<i class="fas fa-envelope"></i>
|
||||
<span v-if="unreadMessageCount > 0" class="unread-badge">{{ unreadMessageCount }}</span>
|
||||
</div>
|
||||
</ToolTip>
|
||||
|
||||
<DropdownMenu v-if="isLogin" ref="userMenu" :items="headerMenuItems">
|
||||
<template #trigger>
|
||||
<div class="avatar-container">
|
||||
@@ -75,7 +82,7 @@ import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||
import ToolTip from '~/components/ToolTip.vue'
|
||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||
import { useUnreadCount } from '~/composables/useUnreadCount'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
||||
import { toast } from '~/main'
|
||||
@@ -93,7 +100,7 @@ const props = defineProps({
|
||||
|
||||
const isLogin = computed(() => authState.loggedIn)
|
||||
const isMobile = useIsMobile()
|
||||
const unreadCount = computed(() => notificationState.unreadCount)
|
||||
const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
|
||||
const avatar = ref('')
|
||||
const showSearch = ref(false)
|
||||
const searchDropdown = ref(null)
|
||||
@@ -182,15 +189,18 @@ const goToNewPost = () => {
|
||||
}
|
||||
|
||||
const refrechData = async () => {
|
||||
await fetchUnreadCount()
|
||||
window.dispatchEvent(new Event('refresh-home'))
|
||||
}
|
||||
|
||||
const goToMessages = () => {
|
||||
navigateTo('/messages');
|
||||
};
|
||||
|
||||
const headerMenuItems = computed(() => [
|
||||
{ text: '设置', onClick: goToSettings },
|
||||
{ text: '个人主页', onClick: goToProfile },
|
||||
{ text: '退出', onClick: goToLogout },
|
||||
])
|
||||
]);
|
||||
|
||||
/** 其余逻辑保持不变 */
|
||||
const iconClass = computed(() => {
|
||||
@@ -215,9 +225,8 @@ onMounted(async () => {
|
||||
}
|
||||
const updateUnread = async () => {
|
||||
if (authState.loggedIn) {
|
||||
await fetchUnreadCount()
|
||||
} else {
|
||||
notificationState.unreadCount = 0
|
||||
// Initialize the unread count composable
|
||||
fetchUnreadCount();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +235,7 @@ onMounted(async () => {
|
||||
|
||||
watch(
|
||||
() => authState.loggedIn,
|
||||
async () => {
|
||||
async (isLoggedIn) => {
|
||||
await updateAvatar()
|
||||
await updateUnread()
|
||||
},
|
||||
@@ -379,9 +388,27 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.rss-icon,
|
||||
.new-post-icon {
|
||||
.new-post-icon,
|
||||
.messages-icon {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -10px;
|
||||
background-color: #ff4d4f;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
padding: 2px 5px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rss-icon {
|
||||
|
||||
183
frontend_nuxt/components/MessageEditor.vue
Normal file
183
frontend_nuxt/components/MessageEditor.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div class="message-editor-container">
|
||||
<div class="message-editor-wrapper">
|
||||
<div :id="editorId" ref="vditorElement"></div>
|
||||
</div>
|
||||
<div class="message-bottom-container">
|
||||
<div class="message-submit" :class="{ disabled: isDisabled }" @click="submit">
|
||||
<template v-if="!loading"> 发送 </template>
|
||||
<template v-else> <i class="fa-solid fa-spinner fa-spin"></i> 发送中... </template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed, onMounted, onUnmounted, ref, useId, watch } from 'vue'
|
||||
import { clearVditorStorage } from '~/utils/clearVditorStorage'
|
||||
import { themeState } from '~/utils/theme'
|
||||
import {
|
||||
createVditor,
|
||||
getEditorTheme as getEditorThemeUtil,
|
||||
getPreviewTheme as getPreviewThemeUtil,
|
||||
} from '~/utils/vditor'
|
||||
import '~/assets/global.css'
|
||||
|
||||
export default {
|
||||
name: 'MessageEditor',
|
||||
emits: ['submit'],
|
||||
props: {
|
||||
editorId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const vditorInstance = ref(null)
|
||||
const text = ref('')
|
||||
const editorId = ref(props.editorId)
|
||||
if (!editorId.value) {
|
||||
editorId.value = 'editor-' + useId()
|
||||
}
|
||||
const getEditorTheme = getEditorThemeUtil
|
||||
const getPreviewTheme = getPreviewThemeUtil
|
||||
const applyTheme = () => {
|
||||
if (vditorInstance.value) {
|
||||
vditorInstance.value.setTheme(getEditorTheme(), getPreviewTheme())
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = computed(() => props.loading || props.disabled || !text.value.trim())
|
||||
|
||||
const submit = () => {
|
||||
if (!vditorInstance.value || isDisabled.value) return
|
||||
const value = vditorInstance.value.getValue()
|
||||
emit('submit', value, () => {
|
||||
if (!vditorInstance.value) return
|
||||
vditorInstance.value.setValue('')
|
||||
text.value = ''
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
vditorInstance.value = createVditor(editorId.value, {
|
||||
placeholder: '输入消息...',
|
||||
height: 150,
|
||||
toolbar: [
|
||||
'emoji',
|
||||
'bold',
|
||||
'italic',
|
||||
'strike',
|
||||
'link',
|
||||
'|',
|
||||
'list',
|
||||
'|',
|
||||
'line',
|
||||
'quote',
|
||||
'code',
|
||||
'inline-code',
|
||||
'|',
|
||||
'upload',
|
||||
],
|
||||
preview: {
|
||||
actions: [],
|
||||
markdown: { toc: false },
|
||||
},
|
||||
input(value) {
|
||||
text.value = value
|
||||
},
|
||||
after() {
|
||||
if (props.loading || props.disabled) {
|
||||
vditorInstance.value.disabled()
|
||||
}
|
||||
applyTheme()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearVditorStorage()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(val) => {
|
||||
if (!vditorInstance.value) return
|
||||
if (val) {
|
||||
vditorInstance.value.disabled()
|
||||
} else if (!props.disabled) {
|
||||
vditorInstance.value.enable()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(val) => {
|
||||
if (!vditorInstance.value) return
|
||||
if (val) {
|
||||
vditorInstance.value.disabled()
|
||||
} else if (!props.loading) {
|
||||
vditorInstance.value.enable()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => themeState.mode,
|
||||
() => {
|
||||
applyTheme()
|
||||
},
|
||||
)
|
||||
|
||||
return { submit, isDisabled, editorId }
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-editor-container {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.message-bottom-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
padding: 10px;
|
||||
background-color: var(--bg-color-soft);
|
||||
border-top: 1px solid var(--border-color);
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
.message-submit {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.message-submit.disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.message-submit:not(.disabled):hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
</style>
|
||||
93
frontend_nuxt/composables/useUnreadCount.js
Normal file
93
frontend_nuxt/composables/useUnreadCount.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
import { useWebSocket } from './useWebSocket';
|
||||
import { getToken } from '~/utils/auth';
|
||||
|
||||
const count = ref(0);
|
||||
let isInitialized = false;
|
||||
let wsSubscription = null;
|
||||
|
||||
export function useUnreadCount() {
|
||||
const config = useRuntimeConfig();
|
||||
const API_BASE_URL = config.public.apiBaseUrl;
|
||||
const { subscribe, isConnected, connect } = useWebSocket();
|
||||
|
||||
const fetchUnreadCount = async () => {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
count.value = 0;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/messages/unread-count`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
count.value = data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch unread count:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const initialize = async () => {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
count.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// 总是获取最新的未读数量
|
||||
fetchUnreadCount();
|
||||
|
||||
// 确保WebSocket连接
|
||||
if (!isConnected.value) {
|
||||
connect(token);
|
||||
}
|
||||
|
||||
// 设置WebSocket监听
|
||||
await setupWebSocketListener();
|
||||
};
|
||||
|
||||
const setupWebSocketListener = async () => {
|
||||
// 只有在还没有订阅的情况下才设置监听
|
||||
if (!wsSubscription) {
|
||||
|
||||
watch(isConnected, (newValue) => {
|
||||
if (newValue && !wsSubscription) {
|
||||
const destination = `/user/queue/unread-count`;
|
||||
wsSubscription = subscribe(destination, (message) => {
|
||||
const unreadCount = parseInt(message.body, 10);
|
||||
if (!isNaN(unreadCount)) {
|
||||
count.value = unreadCount;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, { immediate: true });
|
||||
}
|
||||
};
|
||||
|
||||
// 自动初始化逻辑 - 确保每次调用都能获取到未读数量并设置监听
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
if (!isInitialized) {
|
||||
isInitialized = true;
|
||||
initialize(); // 完整初始化,包括WebSocket监听
|
||||
} else {
|
||||
// 即使已经初始化,也要确保获取最新的未读数量并确保WebSocket监听存在
|
||||
fetchUnreadCount();
|
||||
|
||||
// 确保WebSocket连接和监听都存在
|
||||
if (!isConnected.value) {
|
||||
connect(token);
|
||||
}
|
||||
setupWebSocketListener();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
count,
|
||||
fetchUnreadCount,
|
||||
initialize,
|
||||
};
|
||||
}
|
||||
85
frontend_nuxt/composables/useWebSocket.js
Normal file
85
frontend_nuxt/composables/useWebSocket.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import { ref } from 'vue';
|
||||
import { Client } from '@stomp/stompjs';
|
||||
import SockJS from 'sockjs-client/dist/sockjs.min.js';
|
||||
import { useRuntimeConfig } from '#app';
|
||||
|
||||
const client = ref(null);
|
||||
const isConnected = ref(false);
|
||||
|
||||
const connect = (token) => {
|
||||
if (isConnected.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const API_BASE_URL = config.public.apiBaseUrl;
|
||||
const socketUrl = `${API_BASE_URL}/ws`;
|
||||
|
||||
const socket = new SockJS(socketUrl);
|
||||
const stompClient = new Client({
|
||||
webSocketFactory: () => socket,
|
||||
connectHeaders: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
debug: function (str) {
|
||||
},
|
||||
reconnectDelay: 5000,
|
||||
heartbeatIncoming: 4000,
|
||||
heartbeatOutgoing: 4000,
|
||||
});
|
||||
|
||||
stompClient.onConnect = (frame) => {
|
||||
isConnected.value = true;
|
||||
};
|
||||
|
||||
stompClient.onStompError = (frame) => {
|
||||
console.error('WebSocket STOMP error:', frame);
|
||||
};
|
||||
|
||||
stompClient.activate();
|
||||
client.value = stompClient;
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
if (client.value) {
|
||||
isConnected.value = false;
|
||||
client.value.deactivate();
|
||||
client.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const subscribe = (destination, callback) => {
|
||||
|
||||
if (!isConnected.value || !client.value || !client.value.connected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = client.value.subscribe(destination, (message) => {
|
||||
try {
|
||||
if (destination.includes('/queue/unread-count')) {
|
||||
callback(message);
|
||||
} else {
|
||||
const parsedMessage = JSON.parse(message.body);
|
||||
callback(parsedMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
callback(message);
|
||||
}
|
||||
});
|
||||
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function useWebSocket() {
|
||||
return {
|
||||
client,
|
||||
isConnected,
|
||||
connect,
|
||||
disconnect,
|
||||
subscribe,
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,8 @@
|
||||
"vue-echarts": "^7.0.3",
|
||||
"vue-toastification": "^2.0.0-rc.5",
|
||||
"flatpickr": "^4.6.13",
|
||||
"vue-flatpickr-component": "^12.0.0"
|
||||
"vue-flatpickr-component": "^12.0.0",
|
||||
"@stomp/stompjs": "^7.0.0",
|
||||
"sockjs-client": "^1.6.1"
|
||||
}
|
||||
}
|
||||
|
||||
490
frontend_nuxt/pages/messages/[id].vue
Normal file
490
frontend_nuxt/pages/messages/[id].vue
Normal file
@@ -0,0 +1,490 @@
|
||||
<template>
|
||||
<div class="chat-container">
|
||||
<div v-if="!loading && otherParticipant" class="chat-header">
|
||||
<NuxtLink to="/messages" class="back-button">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
</NuxtLink>
|
||||
<h2 class="participant-name">{{ otherParticipant.username }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="messages-list" ref="messagesListEl">
|
||||
<div v-if="loading" class="loading-container">加载中...</div>
|
||||
<div v-else-if="error" class="error-container">{{ error }}</div>
|
||||
<template v-else>
|
||||
<div class="load-more-container" v-if="hasMoreMessages">
|
||||
<button @click="loadMoreMessages" :disabled="loadingMore" class="load-more-button">
|
||||
{{ loadingMore ? '加载中...' : '查看更多消息' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(msg, index) in messages"
|
||||
:key="msg.id"
|
||||
:ref="el => { if (index === messages.length - 1) lastMessageEl = el }"
|
||||
class="message-item"
|
||||
:class="{ sent: isSentByCurrentUser(msg), received: !isSentByCurrentUser(msg) }"
|
||||
>
|
||||
<img
|
||||
:src="msg.sender.avatar"
|
||||
alt="avatar"
|
||||
class="message-avatar"
|
||||
@error="handleAvatarError"
|
||||
/>
|
||||
<div class="message-content">
|
||||
<div class="message-bubble">
|
||||
<div class="message-text" v-html="renderMarkdown(msg.content)"></div>
|
||||
</div>
|
||||
<div class="message-timestamp">
|
||||
{{ formatMessageTime(msg.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="message-input-area">
|
||||
<MessageEditor
|
||||
:loading="sending"
|
||||
@submit="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick, computed, watch, onActivated, onDeactivated } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { getToken, fetchCurrentUser } from '~/utils/auth';
|
||||
import { toast } from '~/main';
|
||||
import { formatMessageTime } from '~/utils/messageTime';
|
||||
import { renderMarkdown } from '~/utils/markdown';
|
||||
import MessageEditor from '~/components/MessageEditor.vue';
|
||||
import { useWebSocket } from '~/composables/useWebSocket';
|
||||
import { useUnreadCount } from '~/composables/useUnreadCount';
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const route = useRoute();
|
||||
const API_BASE_URL = config.public.apiBaseUrl;
|
||||
const { connect, disconnect, subscribe, isConnected } = useWebSocket();
|
||||
const { fetchUnreadCount: refreshGlobalUnreadCount } = useUnreadCount();
|
||||
let subscription = null;
|
||||
|
||||
const messages = ref([]);
|
||||
const participants = ref([]);
|
||||
const loading = ref(true);
|
||||
const sending = ref(false);
|
||||
const error = ref(null);
|
||||
const conversationId = route.params.id;
|
||||
const currentUser = ref(null);
|
||||
const messagesListEl = ref(null);
|
||||
let lastMessageEl = null;
|
||||
const currentPage = ref(0);
|
||||
const totalPages = ref(0);
|
||||
const loadingMore = ref(false);
|
||||
let scrollInterval = null;
|
||||
|
||||
const hasMoreMessages = computed(() => currentPage.value < totalPages.value - 1);
|
||||
|
||||
const otherParticipant = computed(() => {
|
||||
if (!currentUser.value || participants.value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return participants.value.find(p => p.id !== currentUser.value.id);
|
||||
});
|
||||
|
||||
function isSentByCurrentUser(message) {
|
||||
return message.sender.id === currentUser.value?.id;
|
||||
}
|
||||
|
||||
function handleAvatarError(event) {
|
||||
event.target.src = '/default-avatar.svg';
|
||||
}
|
||||
|
||||
// No changes needed here, as renderMarkdown is now imported.
|
||||
// The old function is removed.
|
||||
|
||||
async function fetchMessages(page = 0) {
|
||||
if (page === 0) {
|
||||
loading.value = true;
|
||||
messages.value = [];
|
||||
} else {
|
||||
loadingMore.value = true;
|
||||
}
|
||||
error.value = null;
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
toast.error('请先登录');
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/messages/conversations/${conversationId}?page=${page}&size=20`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) throw new Error('无法加载消息');
|
||||
|
||||
const conversationData = await response.json();
|
||||
const pageData = conversationData.messages;
|
||||
|
||||
if (page === 0) {
|
||||
participants.value = conversationData.participants;
|
||||
}
|
||||
|
||||
// Since the backend sorts by descending, we need to reverse for correct chat order
|
||||
const newMessages = pageData.content.reverse();
|
||||
|
||||
const list = messagesListEl.value;
|
||||
const oldScrollHeight = list ? list.scrollHeight : 0;
|
||||
|
||||
if (page === 0) {
|
||||
messages.value = newMessages;
|
||||
} else {
|
||||
messages.value = [...newMessages, ...messages.value];
|
||||
}
|
||||
|
||||
currentPage.value = pageData.number;
|
||||
totalPages.value = pageData.totalPages;
|
||||
|
||||
// Scrolling is now fully handled by the watcher
|
||||
await nextTick();
|
||||
if (page > 0 && list) {
|
||||
const newScrollHeight = list.scrollHeight;
|
||||
list.scrollTop = newScrollHeight - oldScrollHeight;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreMessages() {
|
||||
if (hasMoreMessages.value && !loadingMore.value) {
|
||||
await fetchMessages(currentPage.value + 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(content, clearInput) {
|
||||
if (!content.trim()) return;
|
||||
|
||||
const recipient = otherParticipant.value;
|
||||
if (!recipient) {
|
||||
toast.error('无法确定收信人');
|
||||
return;
|
||||
}
|
||||
|
||||
sending.value = true;
|
||||
const token = getToken();
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipientId: recipient.id,
|
||||
content: content,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error('发送失败');
|
||||
|
||||
const newMessage = await response.json();
|
||||
messages.value.push(newMessage);
|
||||
clearInput();
|
||||
|
||||
// Use a more reliable scroll approach
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function markConversationAsRead() {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
try {
|
||||
await fetch(`${API_BASE_URL}/api/messages/conversations/${conversationId}/read`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
// After marking as read, refresh the global unread count
|
||||
refreshGlobalUnreadCount();
|
||||
} catch (e) {
|
||||
console.error('Failed to mark conversation as read', e);
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (messagesListEl.value) {
|
||||
const element = messagesListEl.value;
|
||||
// 強制滾動到底部,使用 smooth 行為確保視覺效果
|
||||
element.scrollTop = element.scrollHeight;
|
||||
|
||||
// 再次確認滾動位置
|
||||
setTimeout(() => {
|
||||
if (element.scrollTop < element.scrollHeight - element.clientHeight) {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
watch(messages, async (newMessages) => {
|
||||
if (newMessages.length === 0) return;
|
||||
|
||||
await nextTick();
|
||||
|
||||
// Simple, reliable scroll to bottom
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
}, { deep: true });
|
||||
|
||||
onMounted(async () => {
|
||||
currentUser.value = await fetchCurrentUser();
|
||||
if (currentUser.value) {
|
||||
await fetchMessages(0);
|
||||
await markConversationAsRead();
|
||||
const token = getToken();
|
||||
if (token && !isConnected.value) {
|
||||
connect(token);
|
||||
}
|
||||
} else {
|
||||
toast.error('请先登录');
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
watch(isConnected, (newValue) => {
|
||||
if (newValue) {
|
||||
// 等待一小段时间确保连接稳定
|
||||
setTimeout(() => {
|
||||
subscription = subscribe(`/topic/conversation/${conversationId}`, (message) => {
|
||||
// 避免重复显示当前用户发送的消息
|
||||
if (message.sender.id !== currentUser.value.id) {
|
||||
messages.value.push(message);
|
||||
// 实时收到消息时自动标记为已读
|
||||
markConversationAsRead();
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
onActivated(async () => {
|
||||
// This will be called every time the component is activated (navigated to)
|
||||
if (currentUser.value) {
|
||||
await fetchMessages(0);
|
||||
await markConversationAsRead();
|
||||
|
||||
// 確保滾動到底部 - 使用多重延遲策略
|
||||
await nextTick();
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 300);
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 500);
|
||||
|
||||
if (!isConnected.value) {
|
||||
const token = getToken();
|
||||
if (token) connect(token);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
if (subscription) {
|
||||
subscription.unsubscribe();
|
||||
subscription = null;
|
||||
}
|
||||
disconnect();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (subscription) {
|
||||
subscription.unsubscribe();
|
||||
subscription = null;
|
||||
}
|
||||
disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 80px); /* Adjust based on your header/footer height */
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-color);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: var(--bg-color-soft);
|
||||
}
|
||||
|
||||
.back-button {
|
||||
font-size: 18px;
|
||||
color: var(--text-color-primary);
|
||||
margin-right: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.participant-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.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 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.load-more-container {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.load-more-button {
|
||||
background-color: var(--bg-color-soft);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color-primary);
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.load-more-button:hover {
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
max-width: 75%;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 10px 15px;
|
||||
border-radius: 18px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.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);
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* Sent messages */
|
||||
.message-item.sent {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message-item.sent .message-content {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-item.sent .message-bubble {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message-item.sent .message-timestamp {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Received messages */
|
||||
.message-item.received {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.message-item.received .message-content {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message-item.received .message-bubble {
|
||||
background-color: var(--bg-color-soft);
|
||||
border: 1px solid var(--border-color);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.loading-container, .error-container {
|
||||
text-align: center;
|
||||
padding: 50px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
375
frontend_nuxt/pages/messages/index.vue
Normal file
375
frontend_nuxt/pages/messages/index.vue
Normal file
@@ -0,0 +1,375 @@
|
||||
<template>
|
||||
<div class="messages-container">
|
||||
<div class="messages-header">
|
||||
<h1 class="messages-title">站内信</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-container">
|
||||
<div class="loading-text">加载中...</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-container">
|
||||
<div class="error-text">{{ error }}</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="conversations.length === 0" class="empty-container">
|
||||
<div class="empty-text">暂无会话</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="conversations-list">
|
||||
<div
|
||||
v-for="convo in conversations"
|
||||
:key="convo.id"
|
||||
class="conversation-item"
|
||||
@click="goToConversation(convo.id)"
|
||||
>
|
||||
<div class="conversation-avatar">
|
||||
<img
|
||||
:src="getOtherParticipant(convo)?.avatar || '/default-avatar.svg'"
|
||||
:alt="getOtherParticipant(convo)?.username || '用户'"
|
||||
class="avatar-img"
|
||||
@error="handleAvatarError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="conversation-content">
|
||||
<div class="conversation-header">
|
||||
<div class="participant-name">
|
||||
{{ getOtherParticipant(convo)?.username || '未知用户' }}
|
||||
</div>
|
||||
<div class="message-time">
|
||||
{{ formatTime(convo.lastMessage?.createdAt || convo.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="last-message-row">
|
||||
<div class="last-message">
|
||||
{{ convo.lastMessage ? convo.lastMessage.content : '暂无消息' }}
|
||||
</div>
|
||||
<div v-if="convo.unreadCount > 0" class="unread-count-badge">
|
||||
{{ convo.unreadCount }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, onActivated } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { getToken, fetchCurrentUser } from '~/utils/auth'
|
||||
import { toast } from '~/main'
|
||||
import { formatMessageTime } from '~/utils/messageTime'
|
||||
import { useWebSocket } from '~/composables/useWebSocket';
|
||||
import { useUnreadCount } from '~/composables/useUnreadCount';
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const conversations = ref([]);
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
const router = useRouter();
|
||||
const currentUser = ref(null);
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const { connect, disconnect, subscribe, isConnected } = useWebSocket();
|
||||
const { fetchUnreadCount: refreshGlobalUnreadCount } = useUnreadCount();
|
||||
let subscription = null;
|
||||
|
||||
async function fetchConversations() {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
toast.error('请先登录');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/messages/conversations`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
conversations.value = data;
|
||||
} catch (e) {
|
||||
error.value = '无法加载会话列表。';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取对话中的另一个参与者(非当前用户)
|
||||
function getOtherParticipant(conversation) {
|
||||
if (!currentUser.value || !conversation.participants) return null
|
||||
return conversation.participants.find(p => p.id !== currentUser.value.id)
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(timeString) {
|
||||
if (!timeString) return ''
|
||||
return formatMessageTime(timeString)
|
||||
}
|
||||
|
||||
// 头像加载失败处理
|
||||
function handleAvatarError(event) {
|
||||
event.target.src = '/default-avatar.svg'
|
||||
}
|
||||
|
||||
onActivated(async () => {
|
||||
loading.value = true;
|
||||
currentUser.value = await fetchCurrentUser();
|
||||
|
||||
if (currentUser.value) {
|
||||
await fetchConversations();
|
||||
refreshGlobalUnreadCount(); // Refresh global count when entering the list
|
||||
const token = getToken();
|
||||
if (token && !isConnected.value) {
|
||||
connect(token);
|
||||
}
|
||||
} else {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
watch(isConnected, (newValue) => {
|
||||
|
||||
if (newValue && currentUser.value) {
|
||||
const destination = `/topic/user/${currentUser.value.id}/messages`;
|
||||
|
||||
// 清理旧的订阅
|
||||
if (subscription) {
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
|
||||
subscription = subscribe(destination, (message) => {
|
||||
fetchConversations();
|
||||
});
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (subscription) {
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
disconnect();
|
||||
});
|
||||
|
||||
function goToConversation(id) {
|
||||
router.push(`/messages/${id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.messages-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.messages-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.messages-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading-container, .error-container, .empty-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.loading-text, .error-text, .empty-text {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #e53e3e;
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 美化滚动条 */
|
||||
.conversations-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.conversations-list::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.conversations-list::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.conversations-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f7fafc;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.conversation-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background-color: #f7fafc;
|
||||
}
|
||||
|
||||
.conversation-avatar {
|
||||
flex-shrink: 0;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.conversation-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conversation-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.participant-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
truncate: true;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: #a0aec0;
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.last-message-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.last-message {
|
||||
font-size: 14px;
|
||||
color: #4a5568;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-grow: 1;
|
||||
padding-right: 10px; /* Add some space between message and badge */
|
||||
}
|
||||
|
||||
.unread-count-badge {
|
||||
background-color: #f56c6c;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
line-height: 1.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.messages-container {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.messages-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.participant-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.last-message {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.messages-container {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.conversation-avatar {
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 大屏幕设备 */
|
||||
@media (min-width: 1024px) {
|
||||
.conversations-list {
|
||||
max-height: 700px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -27,7 +27,15 @@
|
||||
>
|
||||
<i class="fas fa-user-minus"></i>
|
||||
取消关注
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isMine"
|
||||
class="profile-page-header-subscribe-button"
|
||||
@click="sendMessage"
|
||||
>
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
发私信
|
||||
</div>
|
||||
<LevelProgress
|
||||
:exp="levelInfo.exp"
|
||||
:current-level="levelInfo.currentLevel"
|
||||
@@ -537,6 +545,28 @@ const unsubscribeUser = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/messages/conversations`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
recipientId: user.value.id,
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const result = await response.json();
|
||||
router.push(`/messages/${result.conversationId}`);
|
||||
} catch (e) {
|
||||
toast.error('无法发起私信');
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const gotoTag = (tag) => {
|
||||
const value = encodeURIComponent(tag.id ?? tag.name)
|
||||
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
||||
|
||||
1
frontend_nuxt/public/default-avatar.svg
Normal file
1
frontend_nuxt/public/default-avatar.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg t="1755789348718" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13787" width="400" height="400"><path d="M152.773168 306.474932h45.030559c37.525466 0 56.288199 18.762733 56.288198 56.288198v45.024199c0 37.525466-18.762733 56.288199-56.288198 56.288199h-45.030559c-37.525466 0-56.281839-18.762733-56.281839-56.288199v-45.030559c0-37.525466 18.762733-56.288199 56.288199-56.288199z" fill="#4D4132" p-id="13788"></path><path d="M409.294708 763.229814h228.968944v146.285714c0 63.22723-51.263602 114.484472-114.484472 114.484472-63.23359 0-114.484472-51.257242-114.484472-114.484472v-146.285714z" fill="#C5AC95" p-id="13789"></path><path d="M73.97605 520.357366c0 55.957466 45.361292 101.318758 101.318757 101.318758 55.951106 0 101.312398-45.361292 101.312398-101.318758 0-55.951106-45.361292-101.312398-101.318758-101.312397-55.951106 0-101.312398 45.361292-101.312397 101.318758z" fill="#C9AB90" p-id="13790"></path><path d="M490.48964 2.531379c186.520646 0 337.710112 151.195826 337.710112 337.716472v382.740671c0 99.474286-80.63523 180.109516-180.109516 180.109515H287.858484c-74.599354 0-135.078957-60.485963-135.078956-135.085317V340.247851C152.773168 153.727205 303.968994 2.531379 490.48964 2.531379z" fill="#EBD3BD" p-id="13791"></path><path d="M400.434882 509.099727c124.342857 0 225.140075 93.241242 225.140075 208.259975 0 5.679702-0.25441 11.308522-0.731429 16.880099H176.019876a195.278708 195.278708 0 0 1-0.731429-16.880099c0-115.018733 100.797217-208.259975 225.146435-208.259975zM805.684472 306.474932h45.030559c37.525466 0 56.288199 18.762733 56.288199 56.288198v45.024199c0 37.525466-18.762733 56.288199-56.288199 56.288199h-45.030559c-37.525466 0-56.288199-18.762733-56.288199-56.288199v-45.030559c0-37.525466 18.762733-56.288199 56.288199-56.288199z" fill="#4D4132" p-id="13792"></path><path d="M749.402634 520.357366c0 55.957466 45.361292 101.318758 101.312397 101.318758s101.318758-45.361292 101.318758-101.318758c0-55.951106-45.367652-101.312398-101.318758-101.312397s-101.318758 45.361292-101.318758 101.318758z" fill="#EBD3BD" p-id="13793"></path><path d="M805.684472 509.099727a45.030559 45.030559 0 1 0 90.061118 0.01908 45.030559 45.030559 0 0 0-90.061118-0.01908z" fill="#E89E80" p-id="13794"></path><path d="M175.288447 374.01441a90.061118 90.061118 0 1 0 180.115876 0c0-49.737143-40.323975-90.054758-90.061118-90.054758s-90.054758 40.323975-90.054758 90.061118z" fill="#FFFFFF" p-id="13795"></path><path d="M220.319006 379.64323a39.401739 39.401739 0 1 0 78.803478 0 39.401739 39.401739 0 0 0-78.803478 0z" fill="#514141" p-id="13796"></path><path d="M490.48964 374.01441c0 49.737143 40.323975 90.061118 90.061118 90.061118s90.048398-40.323975 90.048397-90.061118-40.317615-90.054758-90.054757-90.054758-90.061118 40.323975-90.061118 90.061118z" fill="#FFFFFF" p-id="13797"></path><path d="M535.520199 379.64323a39.401739 39.401739 0 1 0 78.797118 0 39.401739 39.401739 0 0 0-78.803478 0z" fill="#514141" p-id="13798"></path><path d="M394.806062 362.75677a40.18405 40.18405 0 0 1 37.754435 26.458634l41.99036 115.47031A78.803478 78.803478 0 0 1 400.504845 610.412124h-17.789615a78.803478 78.803478 0 0 1-72.920249-108.633043l46.207205-112.970733a41.920398 41.920398 0 0 1 38.797516-26.051578z" fill="#E89E80" p-id="13799"></path><path d="M165.36646 190.807453m38.16149 0l101.763975 0q38.161491 0 38.161491 38.161491l0 0q0 38.161491-38.161491 38.161491l-101.763975 0q-38.161491 0-38.16149-38.161491l0 0q0-38.161491 38.16149-38.161491Z" fill="#4D4132" p-id="13800"></path><path d="M483.378882 190.807453m38.161491 0l127.204969 0q38.161491 0 38.16149 38.161491l0 0q0 38.161491-38.16149 38.161491l-127.204969 0q-38.161491 0-38.161491-38.161491l0 0q0-38.161491 38.161491-38.161491Z" fill="#4D4132" p-id="13801"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
12
frontend_nuxt/utils/messageTime.js
Normal file
12
frontend_nuxt/utils/messageTime.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export function formatMessageTime(input) {
|
||||
const date = new Date(input)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
const year = date.getFullYear()
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const day = date.getDate().toString().padStart(2, '0')
|
||||
const hours = date.getHours().toString().padStart(2, '0')
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
Reference in New Issue
Block a user