Compare commits

...

2 Commits

Author SHA1 Message Date
Tim
4947978f81 feat: add channel support 2025-08-23 01:31:06 +08:00
Tim
24cc479a56 Merge pull request #700 from nagisa77/codex/create-searchpersondropdown-component
feat: add person search dropdown
2025-08-23 01:06:33 +08:00
12 changed files with 459 additions and 65 deletions

View File

@@ -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);
}
}
}

View File

@@ -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<ChannelDto> 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));
}
}

View File

@@ -59,6 +59,14 @@ public class MessageController {
return ResponseEntity.ok(toDto(message));
}
@PostMapping("/conversations/{conversationId}/messages")
public ResponseEntity<MessageDto> 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<Void> 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;
}
}
}

View File

@@ -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;
}

View File

@@ -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<UserSummaryDto> participants;
private Page<MessageDto> messages;
}

View File

@@ -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<UserSummaryDto> participants;
private LocalDateTime createdAt;

View File

@@ -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;

View File

@@ -28,4 +28,8 @@ public interface MessageConversationRepository extends JpaRepository<MessageConv
"WHERE p.user.id = :userId " +
"ORDER BY COALESCE(lm.createdAt, c.createdAt) DESC")
List<MessageConversation> findConversationsByUserIdOrderByLastMessageDesc(@Param("userId") Long userId);
List<MessageConversation> findByChannelTrue();
long countByChannelTrue();
}

View File

@@ -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<ChannelDto> listChannels(Long userId) {
List<MessageConversation> 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;
}
}

View File

@@ -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<ConversationDto> getConversations(Long userId) {
List<MessageConversation> 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);

View File

@@ -1,10 +1,12 @@
<template>
<div class="chat-container">
<div v-if="!loading && otherParticipant" class="chat-header">
<div v-if="!loading" class="chat-header">
<NuxtLink to="/message-box" class="back-button">
<i class="fas fa-arrow-left"></i>
</NuxtLink>
<h2 class="participant-name">{{ otherParticipant.username }}</h2>
<h2 class="participant-name">
{{ isChannel ? conversationName : otherParticipant?.username }}
</h2>
</div>
<div class="messages-list" ref="messagesListEl">
@@ -86,11 +88,13 @@ const currentPage = ref(0)
const totalPages = ref(0)
const loadingMore = ref(false)
let scrollInterval = null
const conversationName = ref('')
const isChannel = ref(false)
const hasMoreMessages = computed(() => currentPage.value < totalPages.value - 1)
const otherParticipant = computed(() => {
if (!currentUser.value || participants.value.length === 0) {
if (isChannel.value || !currentUser.value || participants.value.length === 0) {
return null
}
return participants.value.find((p) => p.id !== currentUser.value.id)
@@ -136,6 +140,8 @@ async function fetchMessages(page = 0) {
if (page === 0) {
participants.value = conversationData.participants
conversationName.value = conversationData.name
isChannel.value = conversationData.channel
}
// Since the backend sorts by descending, we need to reverse for correct chat order
@@ -182,27 +188,40 @@ async function loadMoreMessages() {
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,
}),
})
let response
if (isChannel.value) {
response = await fetch(
`${API_BASE_URL}/api/messages/conversations/${conversationId}/messages`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ content }),
},
)
} else {
const recipient = otherParticipant.value
if (!recipient) {
toast.error('无法确定收信人')
return
}
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()

View File

@@ -1,55 +1,104 @@
<template>
<div class="messages-container">
<div v-if="loading" class="loading-message">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
<div class="tabs">
<div :class="['tab', { active: activeTab === 'messages' }]" @click="activeTab = 'messages'">
站内信
</div>
<div :class="['tab', { active: activeTab === 'channels' }]" @click="switchToChannels">
频道
</div>
</div>
<div v-else-if="error" class="error-container">
<div class="error-text">{{ error }}</div>
</div>
<div v-if="!loading" class="search-container">
<SearchPersonDropdown />
</div>
<div v-if="!loading && conversations.length === 0" class="empty-container">
<BasePlaceholder v-if="conversations.length === 0" text="暂无会话" icon="fas fa-inbox" />
</div>
<div
v-if="!loading"
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 v-if="activeTab === 'messages'">
<div v-if="loading" class="loading-message">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</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 v-else-if="error" class="error-container">
<div class="error-text">{{ error }}</div>
</div>
<div v-if="!loading" class="search-container">
<SearchPersonDropdown />
</div>
<div v-if="!loading && conversations.length === 0" class="empty-container">
<BasePlaceholder v-if="conversations.length === 0" text="暂无会话" icon="fas fa-inbox" />
</div>
<div
v-if="!loading"
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="last-message-row">
<div class="last-message">
{{
convo.lastMessage ? stripMarkdownLength(convo.lastMessage.content, 100) : '暂无消息'
}}
<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 v-if="convo.unreadCount > 0" class="unread-count-badge">
{{ convo.unreadCount }}
<div class="last-message-row">
<div class="last-message">
{{
convo.lastMessage ? stripMarkdownLength(convo.lastMessage.content, 100) : '暂无消息'
}}
</div>
<div v-if="convo.unreadCount > 0" class="unread-count-badge">
{{ convo.unreadCount }}
</div>
</div>
</div>
</div>
</div>
<div v-else>
<div v-if="loadingChannels" class="loading-message">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div v-else>
<div v-if="channels.length === 0" class="empty-container">
<BasePlaceholder text="暂无频道" icon="fas fa-inbox" />
</div>
<div
v-for="ch in channels"
:key="ch.id"
class="conversation-item"
@click="goToChannel(ch.id)"
>
<div class="conversation-avatar">
<img
:src="ch.avatar || '/default-avatar.svg'"
:alt="ch.name"
class="avatar-img"
@error="handleAvatarError"
/>
</div>
<div class="conversation-content">
<div class="conversation-header">
<div class="participant-name">
{{ ch.name }}
<span v-if="ch.unreadCount > 0" class="unread-dot"></span>
</div>
<div class="message-time">成员 {{ ch.memberCount }}</div>
</div>
<div class="last-message-row">
<div class="last-message">{{ ch.description }}</div>
</div>
</div>
</div>
</div>
@@ -80,6 +129,10 @@ const { connect, disconnect, subscribe, isConnected } = useWebSocket()
const { fetchUnreadCount: refreshGlobalUnreadCount } = useUnreadCount()
let subscription = null
const activeTab = ref('messages')
const channels = ref([])
const loadingChannels = ref(false)
async function fetchConversations() {
const token = getToken()
if (!token) {
@@ -120,6 +173,50 @@ function handleAvatarError(event) {
event.target.src = '/default-avatar.svg'
}
async function fetchChannels() {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
loadingChannels.value = true
try {
const response = await fetch(`${API_BASE_URL}/api/channels`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) throw new Error('无法加载频道')
channels.value = await response.json()
} catch (e) {
toast.error(e.message)
} finally {
loadingChannels.value = false
}
}
function switchToChannels() {
activeTab.value = 'channels'
if (channels.value.length === 0) {
fetchChannels()
}
}
async function goToChannel(id) {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
try {
await fetch(`${API_BASE_URL}/api/channels/${id}/join`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
router.push(`/message-box/${id}`)
} catch (e) {
toast.error(e.message)
}
}
onActivated(async () => {
loading.value = true
currentUser.value = await fetchCurrentUser()
@@ -169,6 +266,22 @@ function goToConversation(id) {
padding: 20px;
}
.tabs {
display: flex;
border-bottom: 1px solid var(--normal-border-color);
margin-bottom: 16px;
}
.tab {
padding: 8px 16px;
cursor: pointer;
}
.tab.active {
font-weight: 600;
border-bottom: 2px solid var(--primary-color);
}
.loading-message {
display: flex;
justify-content: center;
@@ -291,6 +404,15 @@ function goToConversation(id) {
flex-shrink: 0;
}
.unread-dot {
display: inline-block;
width: 8px;
height: 8px;
background-color: #f56c6c;
border-radius: 50%;
margin-left: 4px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.messages-container {