mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-26 16:10:49 +08:00
Compare commits
13 Commits
codex/add-
...
feature/da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8657a06f52 | ||
|
|
09900b34aa | ||
|
|
4e1c3f5839 | ||
|
|
d97cc7df5e | ||
|
|
151242f3ba | ||
|
|
b2783a0168 | ||
|
|
c79bcac217 | ||
|
|
9a06da3bc1 | ||
|
|
4a04f4ec17 | ||
|
|
77be2bfebb | ||
|
|
cf4ca89e19 | ||
|
|
094fc78d92 | ||
|
|
da3d2a6a71 |
@@ -4,6 +4,7 @@ import com.openisle.dto.ChannelDto;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.service.ChannelService;
|
||||
import com.openisle.service.MessageService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -15,6 +16,7 @@ import java.util.List;
|
||||
@RequiredArgsConstructor
|
||||
public class ChannelController {
|
||||
private final ChannelService channelService;
|
||||
private final MessageService messageService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
private Long getCurrentUserId(Authentication auth) {
|
||||
@@ -32,4 +34,9 @@ public class ChannelController {
|
||||
public ChannelDto joinChannel(@PathVariable Long channelId, Authentication auth) {
|
||||
return channelService.joinChannel(channelId, getCurrentUserId(auth));
|
||||
}
|
||||
|
||||
@GetMapping("/unread-count")
|
||||
public long unreadCount(Authentication auth) {
|
||||
return messageService.getUnreadChannelCount(getCurrentUserId(auth));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
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.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
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 c FROM MessageConversation c " +
|
||||
"WHERE c.channel = false AND size(c.participants) = 2 " +
|
||||
"AND EXISTS (SELECT 1 FROM c.participants p1 WHERE p1.user = :user1) " +
|
||||
"AND EXISTS (SELECT 1 FROM c.participants p2 WHERE p2.user = :user2) " +
|
||||
"ORDER BY c.createdAt DESC")
|
||||
List<MessageConversation> findConversationsByUsers(@Param("user1") User user1, @Param("user2") User user2);
|
||||
|
||||
@Query("SELECT DISTINCT c FROM MessageConversation c " +
|
||||
"JOIN c.participants p " +
|
||||
@@ -32,4 +31,4 @@ public interface MessageConversationRepository extends JpaRepository<MessageConv
|
||||
List<MessageConversation> findByChannelTrue();
|
||||
|
||||
long countByChannelTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.dto.ChannelDto;
|
||||
import com.openisle.dto.MessageDto;
|
||||
import com.openisle.dto.UserSummaryDto;
|
||||
import com.openisle.model.Message;
|
||||
import com.openisle.model.MessageConversation;
|
||||
import com.openisle.model.MessageParticipant;
|
||||
import com.openisle.model.User;
|
||||
@@ -54,6 +57,9 @@ public class ChannelService {
|
||||
dto.setName(channel.getName());
|
||||
dto.setDescription(channel.getDescription());
|
||||
dto.setAvatar(channel.getAvatar());
|
||||
if (channel.getLastMessage() != null) {
|
||||
dto.setLastMessage(toMessageDto(channel.getLastMessage()));
|
||||
}
|
||||
dto.setMemberCount(channel.getParticipants().size());
|
||||
boolean joined = channel.getParticipants().stream()
|
||||
.anyMatch(p -> p.getUser().getId().equals(userId));
|
||||
@@ -73,4 +79,20 @@ public class ChannelService {
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
private MessageDto toMessageDto(Message message) {
|
||||
MessageDto dto = new MessageDto();
|
||||
dto.setId(message.getId());
|
||||
dto.setContent(message.getContent());
|
||||
dto.setConversationId(message.getConversation().getId());
|
||||
dto.setCreatedAt(message.getCreatedAt());
|
||||
|
||||
UserSummaryDto userDto = new UserSummaryDto();
|
||||
userDto.setId(message.getSender().getId());
|
||||
userDto.setUsername(message.getSender().getUsername());
|
||||
userDto.setAvatar(message.getSender().getAvatar());
|
||||
dto.setSender(userDto);
|
||||
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,9 @@ public class MessageService {
|
||||
long unreadCount = getUnreadMessageCount(participant.getUser().getId());
|
||||
String username = participant.getUser().getUsername();
|
||||
messagingTemplate.convertAndSendToUser(username, "/queue/unread-count", unreadCount);
|
||||
|
||||
long channelUnread = getUnreadChannelCount(participant.getUser().getId());
|
||||
messagingTemplate.convertAndSendToUser(username, "/queue/channel-unread", channelUnread);
|
||||
}
|
||||
|
||||
return message;
|
||||
@@ -151,7 +154,8 @@ public class MessageService {
|
||||
|
||||
private MessageConversation findOrCreateConversation(User user1, User user2) {
|
||||
log.info("Searching for existing conversation between {} and {}", user1.getUsername(), user2.getUsername());
|
||||
return conversationRepository.findConversationByUsers(user1, user2)
|
||||
return conversationRepository.findConversationsByUsers(user1, user2).stream()
|
||||
.findFirst()
|
||||
.orElseGet(() -> {
|
||||
log.info("No existing conversation found. Creating a new one.");
|
||||
MessageConversation conversation = new MessageConversation();
|
||||
@@ -260,10 +264,26 @@ public class MessageService {
|
||||
List<MessageParticipant> participations = participantRepository.findByUserId(userId);
|
||||
long totalUnreadCount = 0;
|
||||
for (MessageParticipant p : participations) {
|
||||
if (p.getConversation().isChannel()) continue;
|
||||
LocalDateTime lastRead = p.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : p.getLastReadAt();
|
||||
// 只计算别人发送给当前用户的未读消息
|
||||
totalUnreadCount += messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(p.getConversation().getId(), lastRead, userId);
|
||||
}
|
||||
return totalUnreadCount;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public long getUnreadChannelCount(Long userId) {
|
||||
List<MessageParticipant> participations = participantRepository.findByUserId(userId);
|
||||
long unreadChannelCount = 0;
|
||||
for (MessageParticipant p : participations) {
|
||||
if (!p.getConversation().isChannel()) continue;
|
||||
LocalDateTime lastRead = p.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : p.getLastReadAt();
|
||||
long unread = messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(p.getConversation().getId(), lastRead, userId);
|
||||
if (unread > 0) {
|
||||
unreadChannelCount++;
|
||||
}
|
||||
}
|
||||
return unreadChannelCount;
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ import ToolTip from '~/components/ToolTip.vue'
|
||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
||||
import { useUnreadCount } from '~/composables/useUnreadCount'
|
||||
import { useChannelUnread } from '~/composables/useChannelUnread'
|
||||
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
||||
import { toast } from '~/main'
|
||||
@@ -108,7 +108,7 @@ const props = defineProps({
|
||||
const isLogin = computed(() => authState.loggedIn)
|
||||
const isMobile = useIsMobile()
|
||||
const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
|
||||
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelUnread()
|
||||
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount()
|
||||
const avatar = ref('')
|
||||
const showSearch = ref(false)
|
||||
const searchDropdown = ref(null)
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { getToken } from '~/utils/auth'
|
||||
|
||||
const hasUnread = ref(false)
|
||||
|
||||
export function useChannelUnread() {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const setFromList = (channels) => {
|
||||
hasUnread.value = Array.isArray(channels) && channels.some((c) => c.unreadCount > 0)
|
||||
}
|
||||
|
||||
const fetchChannelUnread = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
hasUnread.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/channels`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setFromList(data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch channel unread status:', e)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasUnread,
|
||||
fetchChannelUnread,
|
||||
setFromList,
|
||||
}
|
||||
}
|
||||
92
frontend_nuxt/composables/useChannelsUnreadCount.js
Normal file
92
frontend_nuxt/composables/useChannelsUnreadCount.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useWebSocket } from './useWebSocket'
|
||||
import { getToken } from '~/utils/auth'
|
||||
|
||||
const count = ref(0)
|
||||
let isInitialized = false
|
||||
let wsSubscription = null
|
||||
|
||||
export function useChannelsUnreadCount() {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const { subscribe, isConnected, connect } = useWebSocket()
|
||||
|
||||
const fetchChannelUnread = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
count.value = 0
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/channels/unread-count`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
count.value = data
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch channel unread count:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const initialize = () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
count.value = 0
|
||||
return
|
||||
}
|
||||
fetchChannelUnread()
|
||||
if (!isConnected.value) {
|
||||
connect(token)
|
||||
}
|
||||
setupWebSocketListener()
|
||||
}
|
||||
|
||||
const setupWebSocketListener = () => {
|
||||
if (!wsSubscription) {
|
||||
watch(
|
||||
isConnected,
|
||||
(newValue) => {
|
||||
if (newValue && !wsSubscription) {
|
||||
wsSubscription = subscribe('/user/queue/channel-unread', (message) => {
|
||||
const unread = parseInt(message.body, 10)
|
||||
if (!isNaN(unread)) {
|
||||
count.value = unread
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const setFromList = (channels) => {
|
||||
count.value = Array.isArray(channels) ? channels.filter((c) => c.unreadCount > 0).length : 0
|
||||
}
|
||||
|
||||
const hasUnread = computed(() => count.value > 0)
|
||||
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
if (!isInitialized) {
|
||||
isInitialized = true
|
||||
initialize()
|
||||
} else {
|
||||
fetchChannelUnread()
|
||||
if (!isConnected.value) {
|
||||
connect(token)
|
||||
}
|
||||
setupWebSocketListener()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
count,
|
||||
hasUnread,
|
||||
fetchChannelUnread,
|
||||
initialize,
|
||||
setFromList,
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,10 @@ const subscribe = (destination, callback) => {
|
||||
try {
|
||||
const subscription = client.value.subscribe(destination, (message) => {
|
||||
try {
|
||||
if (destination.includes('/queue/unread-count')) {
|
||||
if (
|
||||
destination.includes('/queue/unread-count') ||
|
||||
destination.includes('/queue/channel-unread')
|
||||
) {
|
||||
callback(message)
|
||||
} else {
|
||||
const parsedMessage = JSON.parse(message.body)
|
||||
|
||||
@@ -69,7 +69,7 @@ import { renderMarkdown } from '~/utils/markdown'
|
||||
import MessageEditor from '~/components/MessageEditor.vue'
|
||||
import { useWebSocket } from '~/composables/useWebSocket'
|
||||
import { useUnreadCount } from '~/composables/useUnreadCount'
|
||||
import { useChannelUnread } from '~/composables/useChannelUnread'
|
||||
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
|
||||
import TimeManager from '~/utils/time'
|
||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||
@@ -79,7 +79,7 @@ const route = useRoute()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const { connect, disconnect, subscribe, isConnected } = useWebSocket()
|
||||
const { fetchUnreadCount: refreshGlobalUnreadCount } = useUnreadCount()
|
||||
const { fetchChannelUnread: refreshChannelUnread } = useChannelUnread()
|
||||
const { fetchChannelUnread: refreshChannelUnread } = useChannelsUnreadCount()
|
||||
let subscription = null
|
||||
|
||||
const messages = ref([])
|
||||
|
||||
@@ -94,7 +94,9 @@
|
||||
{{ ch.name }}
|
||||
<span v-if="ch.unreadCount > 0" class="unread-dot"></span>
|
||||
</div>
|
||||
<div class="message-time">成员 {{ ch.memberCount }}</div>
|
||||
<div class="message-time">
|
||||
{{ formatTime(ch.lastMessage?.createdAt || ch.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="last-message-row">
|
||||
<div class="last-message">
|
||||
@@ -102,6 +104,7 @@
|
||||
ch.lastMessage ? stripMarkdownLength(ch.lastMessage.content, 100) : ch.description
|
||||
}}
|
||||
</div>
|
||||
<div class="member-count">成员 {{ ch.memberCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,7 +120,7 @@ import { getToken, fetchCurrentUser } from '~/utils/auth'
|
||||
import { toast } from '~/main'
|
||||
import { useWebSocket } from '~/composables/useWebSocket'
|
||||
import { useUnreadCount } from '~/composables/useUnreadCount'
|
||||
import { useChannelUnread } from '~/composables/useChannelUnread'
|
||||
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
|
||||
import TimeManager from '~/utils/time'
|
||||
import { stripMarkdownLength } from '~/utils/markdown'
|
||||
import SearchPersonDropdown from '~/components/SearchPersonDropdown.vue'
|
||||
@@ -133,7 +136,7 @@ const API_BASE_URL = config.public.apiBaseUrl
|
||||
const { connect, disconnect, subscribe, isConnected } = useWebSocket()
|
||||
const { fetchUnreadCount: refreshGlobalUnreadCount } = useUnreadCount()
|
||||
const { fetchChannelUnread: refreshChannelUnread, setFromList: setChannelUnreadFromList } =
|
||||
useChannelUnread()
|
||||
useChannelsUnreadCount()
|
||||
let subscription = null
|
||||
|
||||
const activeTab = ref('messages')
|
||||
@@ -254,6 +257,9 @@ watch(isConnected, (newValue) => {
|
||||
|
||||
subscription = subscribe(destination, (message) => {
|
||||
fetchConversations()
|
||||
if (activeTab.value === 'channels') {
|
||||
fetchChannels()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -381,6 +387,12 @@ function goToConversation(id) {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.member-count {
|
||||
font-size: 12px;
|
||||
color: gray;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: gray;
|
||||
|
||||
Reference in New Issue
Block a user