增加历史记录功能
This commit is contained in:
@@ -174,6 +174,61 @@ public class MessageController {
|
|||||||
return ResponseEntity.ok(Map.of("data", result));
|
return ResponseEntity.ok(Map.of("data", result));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/history")
|
||||||
|
public ResponseEntity<?> getHistoryMessages(
|
||||||
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@RequestParam Long userId,
|
||||||
|
@RequestParam(required = false) String beforeTime,
|
||||||
|
@RequestParam(defaultValue = "20") int limit) {
|
||||||
|
|
||||||
|
List<Message> messages;
|
||||||
|
if (beforeTime != null && !beforeTime.isEmpty()) {
|
||||||
|
LocalDateTime before = LocalDateTime.parse(beforeTime, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
|
||||||
|
messages = messageService.getHistoryMessages(principal.getUserId(), userId, before, limit);
|
||||||
|
} else {
|
||||||
|
messages = messageService.getRecentMessages(principal.getUserId(), userId, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<Long> userIds = new HashSet<>();
|
||||||
|
userIds.add(principal.getUserId());
|
||||||
|
userIds.add(userId);
|
||||||
|
for (Message msg : messages) {
|
||||||
|
userIds.add(msg.getFromUserId());
|
||||||
|
userIds.add(msg.getToUserId());
|
||||||
|
}
|
||||||
|
Map<Long, User> userMap = new HashMap<>();
|
||||||
|
for (Long uid : userIds) {
|
||||||
|
User u = userService.findById(uid);
|
||||||
|
if (u != null) userMap.put(uid, u);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, Object>> result = messages.stream().map(msg -> {
|
||||||
|
Map<String, Object> m = new HashMap<>();
|
||||||
|
m.put("id", msg.getId());
|
||||||
|
m.put("fromUserId", msg.getFromUserId());
|
||||||
|
m.put("toUserId", msg.getToUserId());
|
||||||
|
m.put("content", msg.getContent());
|
||||||
|
m.put("type", msg.getType());
|
||||||
|
m.put("fileName", msg.getFileName());
|
||||||
|
m.put("fileSize", msg.getFileSize());
|
||||||
|
m.put("isRead", msg.getIsRead());
|
||||||
|
m.put("createTime", msg.getCreateTime() != null ? msg.getCreateTime().toString() : "");
|
||||||
|
|
||||||
|
User fromUser = userMap.get(msg.getFromUserId());
|
||||||
|
if (fromUser != null) {
|
||||||
|
m.put("fromUsername", fromUser.getUsername());
|
||||||
|
m.put("fromNickname", fromUser.getNickname() != null ? fromUser.getNickname() : fromUser.getUsername());
|
||||||
|
m.put("fromAvatar", fromUser.getAvatar());
|
||||||
|
m.put("fromSignature", fromUser.getSignature());
|
||||||
|
m.put("fromPhone", fromUser.getPhone());
|
||||||
|
m.put("fromEmail", fromUser.getEmail());
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of("data", result));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/users")
|
@GetMapping("/users")
|
||||||
public ResponseEntity<?> getUsers(@AuthenticationPrincipal UserPrincipal principal) {
|
public ResponseEntity<?> getUsers(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
List<User> users = userService.getAllUsersExcept(principal.getUserId());
|
List<User> users = userService.getAllUsersExcept(principal.getUserId());
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -21,11 +22,18 @@ public class MessageService {
|
|||||||
private StringRedisTemplate redisTemplate;
|
private StringRedisTemplate redisTemplate;
|
||||||
|
|
||||||
private static final String CONV_DELETED_KEY = "msg:del:";
|
private static final String CONV_DELETED_KEY = "msg:del:";
|
||||||
|
private static final int DEFAULT_DAYS = 30; // 默认显示最近30天
|
||||||
|
|
||||||
public List<Message> getMessages(Long userId1, Long userId2) {
|
public List<Message> getMessages(Long userId1, Long userId2) {
|
||||||
|
return getMessages(userId1, userId2, DEFAULT_DAYS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Message> getMessages(Long userId1, Long userId2, int days) {
|
||||||
String convKey = buildConvKey(userId1, userId2);
|
String convKey = buildConvKey(userId1, userId2);
|
||||||
String deletedKey = CONV_DELETED_KEY + userId1 + ":" + convKey;
|
String deletedKey = CONV_DELETED_KEY + userId1 + ":" + convKey;
|
||||||
|
|
||||||
|
LocalDateTime since = LocalDateTime.now().minusDays(days);
|
||||||
|
|
||||||
return messageMapper.selectList(
|
return messageMapper.selectList(
|
||||||
new LambdaQueryWrapper<Message>()
|
new LambdaQueryWrapper<Message>()
|
||||||
.and(wrapper -> wrapper
|
.and(wrapper -> wrapper
|
||||||
@@ -33,10 +41,61 @@ public class MessageService {
|
|||||||
.or()
|
.or()
|
||||||
.eq(Message::getFromUserId, userId2).eq(Message::getToUserId, userId1)
|
.eq(Message::getFromUserId, userId2).eq(Message::getToUserId, userId1)
|
||||||
)
|
)
|
||||||
|
.ge(Message::getCreateTime, since)
|
||||||
.orderByAsc(Message::getCreateTime)
|
.orderByAsc(Message::getCreateTime)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取历史消息(30天之前的)
|
||||||
|
*/
|
||||||
|
public List<Message> getHistoryMessages(Long userId1, Long userId2, LocalDateTime beforeTime, int limit) {
|
||||||
|
return messageMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<Message>()
|
||||||
|
.and(wrapper -> wrapper
|
||||||
|
.eq(Message::getFromUserId, userId1).eq(Message::getToUserId, userId2)
|
||||||
|
.or()
|
||||||
|
.eq(Message::getFromUserId, userId2).eq(Message::getToUserId, userId1)
|
||||||
|
)
|
||||||
|
.lt(Message::getCreateTime, beforeTime)
|
||||||
|
.orderByDesc(Message::getCreateTime)
|
||||||
|
.last("LIMIT " + limit)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最近的消息(分页加载)
|
||||||
|
*/
|
||||||
|
public List<Message> getRecentMessages(Long userId1, Long userId2, int limit) {
|
||||||
|
return messageMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<Message>()
|
||||||
|
.and(wrapper -> wrapper
|
||||||
|
.eq(Message::getFromUserId, userId1).eq(Message::getToUserId, userId2)
|
||||||
|
.or()
|
||||||
|
.eq(Message::getFromUserId, userId2).eq(Message::getToUserId, userId1)
|
||||||
|
)
|
||||||
|
.orderByDesc(Message::getCreateTime)
|
||||||
|
.last("LIMIT " + limit)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最早的30天消息,用于判断是否有更早的消息
|
||||||
|
*/
|
||||||
|
public LocalDateTime getEarliestMessageTime(Long userId1, Long userId2) {
|
||||||
|
List<Message> messages = messageMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<Message>()
|
||||||
|
.and(wrapper -> wrapper
|
||||||
|
.eq(Message::getFromUserId, userId1).eq(Message::getToUserId, userId2)
|
||||||
|
.or()
|
||||||
|
.eq(Message::getFromUserId, userId2).eq(Message::getToUserId, userId1)
|
||||||
|
)
|
||||||
|
.orderByAsc(Message::getCreateTime)
|
||||||
|
.last("LIMIT 1")
|
||||||
|
);
|
||||||
|
return messages.isEmpty() ? null : messages.get(0).getCreateTime();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除与某个用户的聊天记录(双向,本人会看不到)
|
* 删除与某个用户的聊天记录(双向,本人会看不到)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
export const getMessages = (params) => request.get('/messages', { params })
|
export const getMessages = (params) => request.get('/messages', { params })
|
||||||
|
|
||||||
|
export const getHistoryMessages = (params) => request.get('/messages/history', { params })
|
||||||
|
|
||||||
export const sendMessage = (data) => request.post('/messages', data)
|
export const sendMessage = (data) => request.post('/messages', data)
|
||||||
|
|
||||||
export const getUnreadCount = () => request.get('/messages/unreadCount')
|
export const getUnreadCount = () => request.get('/messages/unreadCount')
|
||||||
|
|||||||
@@ -78,7 +78,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 消息列表 -->
|
<!-- 消息列表 -->
|
||||||
<div class="chat-messages" ref="messagesRef">
|
<div class="chat-messages" ref="messagesRef" @scroll="handleScroll">
|
||||||
|
<div class="history-loader" v-if="hasMoreHistory" @click="loadMoreHistory">
|
||||||
|
<el-button link type="primary" :loading="loadingHistory">
|
||||||
|
加载更多消息
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
<div v-if="loadingMessages" class="messages-loading">
|
<div v-if="loadingMessages" class="messages-loading">
|
||||||
<el-icon class="is-loading"><Loading /></el-icon> 加载中..
|
<el-icon class="is-loading"><Loading /></el-icon> 加载中..
|
||||||
</div>
|
</div>
|
||||||
@@ -227,7 +232,7 @@ import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { ChatLineRound, User, Search, Loading, Picture, Paperclip, Download, ChatDotRound, Promotion, Delete } from '@element-plus/icons-vue'
|
import { ChatLineRound, User, Search, Loading, Picture, Paperclip, Download, ChatDotRound, Promotion, Delete } from '@element-plus/icons-vue'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { getUsers, getMessages, sendMessage as sendMessageApi, uploadChatFile, getUnreadList, deleteConversation } from '@/api/message'
|
import { getUsers, getMessages, getHistoryMessages, sendMessage as sendMessageApi, uploadChatFile, getUnreadList, deleteConversation } from '@/api/message'
|
||||||
import { chatService } from '@/services/chat'
|
import { chatService } from '@/services/chat'
|
||||||
|
|
||||||
const props = defineProps({ modelValue: Boolean })
|
const props = defineProps({ modelValue: Boolean })
|
||||||
@@ -250,6 +255,8 @@ const currentUserNickname = ref('')
|
|||||||
const currentContact = ref(null)
|
const currentContact = ref(null)
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const loadingMessages = ref(false)
|
const loadingMessages = ref(false)
|
||||||
|
const loadingHistory = ref(false)
|
||||||
|
const hasMoreHistory = ref(false)
|
||||||
const emojiVisible = ref(false)
|
const emojiVisible = ref(false)
|
||||||
const messagesRef = ref(null)
|
const messagesRef = ref(null)
|
||||||
const imageInputRef = ref(null)
|
const imageInputRef = ref(null)
|
||||||
@@ -380,10 +387,11 @@ const loadUnreadChats = async () => {
|
|||||||
const selectContact = async (contact) => {
|
const selectContact = async (contact) => {
|
||||||
currentContact.value = contact
|
currentContact.value = contact
|
||||||
contact.unread = 0
|
contact.unread = 0
|
||||||
|
hasMoreHistory.value = false
|
||||||
loadingMessages.value = true
|
loadingMessages.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getMessages({ userId: contact.id })
|
const res = await getMessages({ userId: contact.id })
|
||||||
messages.value[contact.id] = (res.data || []).map(msg => {
|
const list = (res.data || []).map(msg => {
|
||||||
const isSelf = String(msg.fromUserId) === String(userStore.userId)
|
const isSelf = String(msg.fromUserId) === String(userStore.userId)
|
||||||
return {
|
return {
|
||||||
...msg,
|
...msg,
|
||||||
@@ -393,6 +401,9 @@ const selectContact = async (contact) => {
|
|||||||
fromColor: colors[Math.abs(Number(isSelf ? userStore.userId : msg.fromUserId) % colors.length)]
|
fromColor: colors[Math.abs(Number(isSelf ? userStore.userId : msg.fromUserId) % colors.length)]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
messages.value[contact.id] = list
|
||||||
|
// 如果消息条数 >= 20,可能还有更早的历史
|
||||||
|
hasMoreHistory.value = list.length >= 20
|
||||||
updateRecentChat(contact, '')
|
updateRecentChat(contact, '')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('加载消息失败')
|
ElMessage.error('加载消息失败')
|
||||||
@@ -402,6 +413,51 @@ const selectContact = async (contact) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadMoreHistory = async () => {
|
||||||
|
if (!currentContact.value || loadingHistory.value || !hasMoreHistory.value) return
|
||||||
|
const list = messages.value[currentContact.value.id]
|
||||||
|
if (!list || list.length === 0) return
|
||||||
|
|
||||||
|
loadingHistory.value = true
|
||||||
|
// 记录当前滚动高度,加载后恢复
|
||||||
|
const prevScrollHeight = messagesRef.value?.scrollHeight || 0
|
||||||
|
try {
|
||||||
|
const firstMsg = list[0]
|
||||||
|
const beforeTime = firstMsg.createTime || firstMsg.id
|
||||||
|
const res = await getHistoryMessages({ userId: currentContact.value.id, beforeTime, limit: 20 })
|
||||||
|
const historyList = (res.data || []).map(msg => {
|
||||||
|
const isSelf = String(msg.fromUserId) === String(userStore.userId)
|
||||||
|
return {
|
||||||
|
...msg,
|
||||||
|
isSelf,
|
||||||
|
sending: false,
|
||||||
|
failed: false,
|
||||||
|
fromColor: colors[Math.abs(Number(isSelf ? userStore.userId : msg.fromUserId) % colors.length)]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (historyList.length === 0) {
|
||||||
|
hasMoreHistory.value = false
|
||||||
|
} else {
|
||||||
|
// 插入到消息列表前面
|
||||||
|
messages.value[currentContact.value.id] = [...historyList, ...list]
|
||||||
|
// 如果返回条数少于 limit,没有更多了
|
||||||
|
hasMoreHistory.value = historyList.length >= 20
|
||||||
|
// 恢复滚动位置
|
||||||
|
nextTick(() => {
|
||||||
|
if (messagesRef.value) {
|
||||||
|
const newScrollHeight = messagesRef.value.scrollHeight
|
||||||
|
messagesRef.value.scrollTop = newScrollHeight - prevScrollHeight
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('加载历史消息失败')
|
||||||
|
} finally {
|
||||||
|
loadingHistory.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updateRecentChat = (contact, lastMsg) => {
|
const updateRecentChat = (contact, lastMsg) => {
|
||||||
let chat = recentChats.value.find(c => c.id === contact.id)
|
let chat = recentChats.value.find(c => c.id === contact.id)
|
||||||
if (!chat) {
|
if (!chat) {
|
||||||
@@ -600,6 +656,16 @@ const scrollToBottom = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 滚动到顶部时加载更多历史消息
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!messagesRef.value || !currentContact.value) return
|
||||||
|
const { scrollTop } = messagesRef.value
|
||||||
|
// 滚动到顶部 50px 以内时触发
|
||||||
|
if (scrollTop < 50 && hasMoreHistory.value && !loadingHistory.value) {
|
||||||
|
loadMoreHistory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ======== WebSocket ========
|
// ======== WebSocket ========
|
||||||
|
|
||||||
const onDialogOpen = () => {
|
const onDialogOpen = () => {
|
||||||
@@ -683,7 +749,7 @@ onUnmounted(() => { if (unsubscribeWs) unsubscribeWs() })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.chat-container { display: flex; height: 600px; gap: 12px; }
|
.chat-container { display: flex; height: calc(70vh - 120px); gap: 12px; }
|
||||||
.chat-sidebar { width: 280px; display: flex; flex-direction: column; border-right: 1px solid #e4e7ed; }
|
.chat-sidebar { width: 280px; display: flex; flex-direction: column; border-right: 1px solid #e4e7ed; }
|
||||||
.sidebar-tabs { display: flex; border-bottom: 1px solid #e4e7ed; }
|
.sidebar-tabs { display: flex; border-bottom: 1px solid #e4e7ed; }
|
||||||
.sidebar-tab { flex: 1; padding: 12px; text-align: center; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.3s; display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 13px; }
|
.sidebar-tab { flex: 1; padding: 12px; text-align: center; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.3s; display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 13px; }
|
||||||
@@ -782,5 +848,17 @@ onUnmounted(() => { if (unsubscribeWs) unsubscribeWs() })
|
|||||||
.chat-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #909399; gap: 12px; }
|
.chat-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #909399; gap: 12px; }
|
||||||
.chat-empty p { margin: 0; font-size: 14px; }
|
.chat-empty p { margin: 0; font-size: 14px; }
|
||||||
|
|
||||||
.chat-dialog :deep(.el-dialog) { height: 720px; margin: auto !important; top: 50% !important; transform: translateY(-50%) !important; }
|
.history-loader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-dialog :deep(.el-dialog) {
|
||||||
|
height: 70vh;
|
||||||
|
margin: auto !important;
|
||||||
|
top: 50% !important;
|
||||||
|
transform: translateY(-50%) !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user