前端:
1、优化消息排序逻辑 2、新增加载历史消息 3、修复已知问题 后端: 1、优化消息排序逻辑 2、增加用户信息缓存机制 3、修改日期类型为DateTimeOffset改善时区信息丢失问题 3、修复了已知问题 数据库: 1、新增SequenceId字段用于消息排序 2、新增ClientMsgId字段用于客户端消息回执
This commit is contained in:
+4
-4
@@ -1,4 +1,4 @@
|
||||
#VITE_API_BASE_URL = http://localhost:5202/api
|
||||
#VITE_SIGNALR_BASE_URL = http://localhost:5202/chat/
|
||||
VITE_API_BASE_URL = https://im.test.nxsir.cn/api
|
||||
VITE_SIGNALR_BASE_URL = https://im.test.nxsir.cn/chat/
|
||||
VITE_API_BASE_URL = http://localhost:5202/api
|
||||
VITE_SIGNALR_BASE_URL = http://localhost:5202/chat/
|
||||
# VITE_API_BASE_URL = https://im.test.nxsir.cn/api
|
||||
# VITE_SIGNALR_BASE_URL = https://im.test.nxsir.cn/chat/
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="history-loading-container">
|
||||
<div v-if="loading" class="state-wrapper loading">
|
||||
<svg class="spinner" viewBox="0 0 50 50">
|
||||
<circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
|
||||
</svg>
|
||||
<span>正在获取历史消息...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="state-wrapper error" @click="$emit('retry')">
|
||||
<span>加载失败,点击重试</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="finished" class="state-wrapper finished">
|
||||
<span>— 已显示全部消息 —</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
finished: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
error: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits(['retry']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.history-loading-container {
|
||||
width: 100%;
|
||||
padding: 15px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.state-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff4d4f;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.finished {
|
||||
color: #ccc;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* 简单的 CSS 旋转动画 */
|
||||
.spinner {
|
||||
animation: rotate 2s linear infinite;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.spinner .path {
|
||||
stroke: #409eff;
|
||||
stroke-linecap: round;
|
||||
animation: dash 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes dash {
|
||||
0% { stroke-dasharray: 1, 150; stroke-dashoffset: 0; }
|
||||
50% { stroke-dasharray: 90, 150; stroke-dashoffset: -35; }
|
||||
100% { stroke-dasharray: 90, 150; stroke-dashoffset: -124; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,4 @@
|
||||
export const MESSAGE_TYPE = Object.freeze({
|
||||
PRIVATE: 'PRIVATE',
|
||||
GROUP: 'GROUP'
|
||||
})
|
||||
@@ -25,11 +25,18 @@ export const messageService = {
|
||||
* @param {*} pageSize
|
||||
* @returns
|
||||
*/
|
||||
getHistoryMessages: (conversationId, msgId, pageSize = 10) => request.get(`/message/getmessageList?conversationId=${conversationId}&msgId=${msgId}&pageSize=${pageSize}`),
|
||||
//getHistoryMessages: (conversationId, msgId, pageSize = 10) => request.get(`/message/getmessageList?conversationId=${conversationId}&msgId=${msgId}&pageSize=${pageSize}`),
|
||||
/**
|
||||
* 获取最新消息
|
||||
* @param {*} conversationId
|
||||
* 获取消息
|
||||
* @param {*} conversationId 会话ID
|
||||
* @param {Number} cursor 锚点(对应sequenceId),查询最新消息传null
|
||||
* @param {Number} direction 方向 0为查历史消息 1为查锚点后的消息,查询最新消息传0 需配合cursor
|
||||
* @param {number} limit 单次查询消息数
|
||||
* @returns
|
||||
*/
|
||||
getMessages: (conversationId) => request.get(`/message/getmessageList?conversationId=${conversationId}`)
|
||||
getMessages: (conversationId, cursor, direction, limit) => request.get(
|
||||
`/message/getmessageList?conversationId=${conversationId}${cursor ? '&cursor=' + cursor : ''}&direction=${direction}&limit=${limit}`
|
||||
),
|
||||
|
||||
sendMessage: (msg) => request.post('/Message/SendMessage', msg)
|
||||
}
|
||||
@@ -1,30 +1,41 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { messagesDb } from "@/utils/db/messageDB";
|
||||
import { messageService } from "@/services/message";
|
||||
import { useConversationStore } from "./conversation";
|
||||
|
||||
export const useChatStore = defineStore('chat', {
|
||||
state: () => ({
|
||||
activeSessionId: null,
|
||||
activeConversationId: null,
|
||||
activeSessionId: null,
|
||||
maxSequenceId: null,
|
||||
isEnded: false,
|
||||
messages: [],
|
||||
pageSize: 20
|
||||
}),
|
||||
actions: {
|
||||
// 抽取统一的排序去重方法
|
||||
pushAndSortMessages(newMsgs) {
|
||||
const combined = [...this.messages, ...newMsgs];
|
||||
// 1. 根据 msgId 或唯一 key 去重
|
||||
const uniqueMap = new Map();
|
||||
combined.forEach(m => uniqueMap.set(m.msgId || m.id, m));
|
||||
async pushAndSortMessagesAsync(newMsgs, sessionId, shouldSaveToDb = true) {
|
||||
if (shouldSaveToDb) {
|
||||
for (const m of newMsgs) {
|
||||
await messagesDb.save({ ...m, sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 转换为数组并按时间戳升序排序 (旧的在前,新的在后)
|
||||
this.messages = Array.from(uniqueMap.values()).sort((a, b) => {
|
||||
return new Date(a.timeStamp).getTime() - new Date(b.timeStamp).getTime();
|
||||
});
|
||||
},
|
||||
async addMessage(msg, sessionId) {
|
||||
await messagesDb.save({ ...msg, sessionId, isLoading: false });
|
||||
this.messages.push({ ...msg, sessionId })
|
||||
if (sessionId == this.activeSessionId) {
|
||||
const combined = [...this.messages, ...newMsgs];
|
||||
// 1. 根据 msgId 或唯一 key 去重
|
||||
const uniqueMap = new Map();
|
||||
combined.forEach(m => uniqueMap.set(m.msgId || m.sequenceId, m));
|
||||
|
||||
// 2. 转换为数组并按sequenceId升序排序 (旧的在前,新的在后)
|
||||
this.messages = Array.from(uniqueMap.values()).sort((a, b) => {
|
||||
return a.sequenceId - b.sequenceId;
|
||||
});
|
||||
this.maxSequenceId = this.messages.reduce((max, m) =>
|
||||
m.sequenceId > max ? m.sequenceId : max,
|
||||
null // 初始值
|
||||
);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 切换会话加载当前会话消息列表
|
||||
@@ -34,38 +45,26 @@ export const useChatStore = defineStore('chat', {
|
||||
this.activeSessionId = sessionId;
|
||||
this.activeConversationId = conversationId;
|
||||
this.messages = [];
|
||||
this.isEnded = false;
|
||||
//先从浏览器缓存加载一部分消息列表
|
||||
const localHistory = await messagesDb.getPageMessages(sessionId, new Date().toISOString(), this.pageSize);
|
||||
const localHistory = await messagesDb.getLatestMessages(sessionId, this.pageSize);
|
||||
console.log(localHistory)
|
||||
if (localHistory.length > 0) {
|
||||
this.messages = localHistory;
|
||||
} else {
|
||||
//如果本地没有消息数据则从后端拉取数据
|
||||
const conversation = (await messageService.getConversationById(this.activeConversationId)).data;
|
||||
const serverHistoryMsg = await this.fetchHistoryFromServer(this.activeConversationId, conversation.lastReadMessageId);
|
||||
//对消息进行过滤,防止重复消息
|
||||
const filterMsg = serverHistoryMsg.filter(m => !this.messages.find(exist => exist.msgId === m.msgId));
|
||||
this.pushAndSortMessages([...filterMsg, ...this.messages]);
|
||||
this.maxSequenceId = this.messages.reduce((max, m) =>
|
||||
m.sequenceId > max ? m.sequenceId : max,
|
||||
null // 初始值
|
||||
);
|
||||
}
|
||||
//拉取新消息
|
||||
this.fetchNewMsgFromServier(this.activeConversationId).then((newMsg) => {
|
||||
//去重
|
||||
const filterNewMsg = newMsg.filter(m => !this.messages.find(exist => exist.msgId === m.msgId));
|
||||
this.pushAndSortMessages([...filterNewMsg, ...this.messages])
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 从服务器加载新消息
|
||||
* @param {*} sessionId
|
||||
* @returns
|
||||
*/
|
||||
async fetchNewMsgFromServier(conversationId) {
|
||||
const newMsg = (await messageService.getMessages(conversationId)).data;
|
||||
async fetchNewMsgFromServier(conversationId, sequenceId) {
|
||||
const newMsg = (await messageService.getMessages(conversationId, sequenceId, sequenceId ? 1 : 0, this.pageSize)).data;
|
||||
if (newMsg.length > 0) {
|
||||
const sessionId = this.activeSessionId;
|
||||
await Promise.all(newMsg.map(msg =>
|
||||
messagesDb.save({ ...msg, sessionId })
|
||||
));
|
||||
return newMsg;
|
||||
} else {
|
||||
return [];
|
||||
@@ -77,14 +76,11 @@ export const useChatStore = defineStore('chat', {
|
||||
* @param {*} msgId
|
||||
* @returns
|
||||
*/
|
||||
async fetchHistoryFromServer(conversationId, msgId) {
|
||||
const res = (await messageService.getHistoryMessages(conversationId, msgId, this.pageSize)).data;
|
||||
async fetchHistoryFromServer(conversationId, sequenceId) {
|
||||
const res = (await messageService.getMessages(conversationId, sequenceId, 0, this.pageSize)).data;
|
||||
|
||||
if (res.length > 0) {
|
||||
const sessionId = this.activeSessionId;
|
||||
await Promise.all(res.map(msg =>
|
||||
messagesDb.save({ ...msg, sessionId })
|
||||
));
|
||||
return res;
|
||||
} else {
|
||||
return [];
|
||||
@@ -94,14 +90,28 @@ export const useChatStore = defineStore('chat', {
|
||||
* 加载更多历史消息
|
||||
*/
|
||||
async loadMoreMessages() {
|
||||
const lastTimeStamp = this.messages.length > 0 ? this.messages[0].timeStamp : new Date().toISOString();
|
||||
const history = await messagesDb.getPageMessages(this.activeSessionId, lastTimeStamp, this.pageSize);
|
||||
if (history.length > 0) {
|
||||
this.messages = [...history, ...this.messages]
|
||||
} else {
|
||||
const fetchMsg = await this.fetchHistoryFromServer(this.conversationId, this.messages[0].msgId);
|
||||
const newMsgs = fetchMsg.filter(m => !this.messages.find(exist => exist.msgId === m.msgId));
|
||||
this.pushAndSortMessages([...newMsgs, ...this.messages])
|
||||
let minSequenceId = 0;
|
||||
if (!this.messages || this.messages.length === 0) return;
|
||||
minSequenceId = this.messages.reduce((min, m) =>
|
||||
(m.sequenceId < min ? m.sequenceId : min),
|
||||
this.messages[0].sequenceId // 使用第一项作为初始参考值
|
||||
);
|
||||
const dbCacheList = await messagesDb.getPageMessages(this.activeSessionId, minSequenceId, this.pageSize)
|
||||
const dbMaxSequenceId = dbCacheList.reduce((max, m) =>
|
||||
m.sequenceId > max ? m.sequenceId : max,
|
||||
null // 初始值
|
||||
);
|
||||
if (dbCacheList.length < this.pageSize) {
|
||||
const newList = await this.fetchHistoryFromServer(this.activeConversationId, minSequenceId)
|
||||
if (newList.length === 0) this.isEnded = true;
|
||||
await this.pushAndSortMessagesAsync(newList, this.activeSessionId, true);
|
||||
} else if (dbMaxSequenceId < minSequenceId - 1) {
|
||||
const newList = await this.fetchHistoryFromServer(this.activeConversationId, minSequenceId)
|
||||
if (newList.length === 0) this.isEnded = true;
|
||||
await this.pushAndSortMessagesAsync(newList, this.activeSessionId, true);
|
||||
}
|
||||
else {
|
||||
await this.pushAndSortMessagesAsync(dbCacheList, this.activeSessionId, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const useConversationStore = defineStore('conversation', {
|
||||
try {
|
||||
const covnersationsCache = await conversationDb.getAll();
|
||||
if (covnersationsCache && covnersationsCache.length > 0) {
|
||||
covnersationsCache.sort((a, b) => {
|
||||
this.conversations = covnersationsCache.sort((a, b) => {
|
||||
return new Date(a.dateTime) - new Date(b.dateTime);
|
||||
})
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export const useConversationStore = defineStore('conversation', {
|
||||
console.log('读取本地会话缓存失败:', e);
|
||||
}
|
||||
}
|
||||
await this.fetchConversationsFromServier()
|
||||
//await this.fetchConversationsFromServier()
|
||||
},
|
||||
/**
|
||||
* 从服务器加载新消息
|
||||
|
||||
@@ -8,6 +8,8 @@ import { generateSessionId } from "@/utils/sessionIdTools";
|
||||
import { messageHandler } from "@/handler/messageHandler";
|
||||
import { useBrowserNotification } from "@/services/useBrowserNotification";
|
||||
import { useConversationStore } from "./conversation";
|
||||
import { SignalRMessageHandler } from "@/utils/signalr/SignalMessageHandler";
|
||||
import { signalRConnectionEventHandler } from "@/utils/signalr/signalRConnectionEventHandler";
|
||||
|
||||
export const useSignalRStore = defineStore('signalr', {
|
||||
state: () => ({
|
||||
@@ -21,44 +23,43 @@ export const useSignalRStore = defineStore('signalr', {
|
||||
const url = import.meta.env.VITE_SIGNALR_BASE_URL || 'http://localhost:5202/chat/';
|
||||
this.connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl(url,
|
||||
{
|
||||
{
|
||||
|
||||
accessTokenFactory: async () => {
|
||||
if (authStore.isTokenExpired) {
|
||||
const res = await authService.refresh(authStore.refreshToken)
|
||||
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo)
|
||||
accessTokenFactory: async () => {
|
||||
if (authStore.isTokenExpired) {
|
||||
const res = await authService.refresh(authStore.refreshToken)
|
||||
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo)
|
||||
}
|
||||
return authStore.token;
|
||||
}
|
||||
return authStore.token;
|
||||
}
|
||||
})
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
this.registerHandlers();
|
||||
try {
|
||||
await this.connection.start();
|
||||
this.isConnected = true;
|
||||
signalRConnectionEventHandler();
|
||||
console.log('SignalR建立通信成功!')
|
||||
} catch (e) {
|
||||
message.error('与服务器建立通信失败,请检查网络连接...');
|
||||
}
|
||||
},
|
||||
registerHandlers() {
|
||||
const chatStore = useChatStore()
|
||||
const browserNotification = useBrowserNotification();
|
||||
|
||||
|
||||
this.connection.on('ReceiveMessage', (msg) => {
|
||||
const sessionId = generateSessionId(msg.senderId, msg.receiverId);
|
||||
messageHandler(msg);
|
||||
chatStore.addMessage(msg, sessionId);
|
||||
const conversation = useConversationStore().conversations.find(x => x.targetId == msg.senderId);
|
||||
browserNotification.send(`${conversation.targetName}发来一条消息`, {
|
||||
body: msg.content,
|
||||
icon: conversation.targetAvatar
|
||||
});
|
||||
console.log(msg)
|
||||
SignalRMessageHandler(msg)
|
||||
});
|
||||
|
||||
this.connection.onclose(() => { this.isConnected = false });
|
||||
this.connection.onreconnected(() => { this.isConnected = true });
|
||||
this.connection.onclose(() => {
|
||||
this.isConnected = false;
|
||||
});
|
||||
this.connection.onreconnected(() => {
|
||||
this.isConnected = true;
|
||||
signalRConnectionEventHandler();
|
||||
});
|
||||
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export const GetLocalIso = (date) => {
|
||||
// 考虑到时区偏差,手动构造符合 C# 要求的本地 ISO 字符串
|
||||
const offset = -date.getTimezoneOffset();
|
||||
const diff = offset >= 0 ? '+' : '-';
|
||||
const pad = (num) => String(num).padStart(2, '0');
|
||||
|
||||
return date.getFullYear() +
|
||||
'-' + pad(date.getMonth() + 1) +
|
||||
'-' + pad(date.getDate()) +
|
||||
'T' + pad(date.getHours()) +
|
||||
':' + pad(date.getMinutes()) +
|
||||
':' + pad(date.getSeconds());
|
||||
}
|
||||
@@ -5,13 +5,15 @@ const STORE_NAME = 'messages';
|
||||
const CONVERSARION_STORE_NAME = 'conversations';
|
||||
const CONTACT_STORE_NAME = 'contacts';
|
||||
|
||||
export const dbPromise = openDB(DBNAME, 5, {
|
||||
export const dbPromise = openDB(DBNAME, 7, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, { keyPath: 'msgId' });
|
||||
store.createIndex('by-sessionId', 'sessionId');
|
||||
store.createIndex('by-time', 'timeStamp');
|
||||
store.createIndex('by-session-time', ['sessionId', 'timeStamp']);
|
||||
store.createIndex('by-sequenceId', 'sequenceId');
|
||||
store.createIndex('by-session-sequenceId', ['sessionId', 'sequenceId']);
|
||||
|
||||
}
|
||||
if (!db.objectStoreNames.contains(CONVERSARION_STORE_NAME)) {
|
||||
const store = db.createObjectStore(CONVERSARION_STORE_NAME, { keyPath: 'id' });
|
||||
|
||||
@@ -12,15 +12,15 @@ export const messagesDb = {
|
||||
async clearAll() {
|
||||
return (await dbPromise).clear(STORE_NAME);
|
||||
},
|
||||
async getPageMessages(sessionId, beforeTimeStamp, limit = 20) {
|
||||
async getPageMessages(sessionId, beforeSequenceId, limit = 20) {
|
||||
const db = await dbPromise;
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const index = tx.store.index('by-session-time'); // 使用复合索引
|
||||
const index = tx.store.index('by-session-sequenceId'); // 使用复合索引
|
||||
|
||||
// 定义范围:从 [sessionId, 最早时间] 到 [sessionId, beforeTimeStamp)
|
||||
// 注意:IDBKeyRange.bound([sessionId, ""], [sessionId, beforeTimeStamp], false, true)
|
||||
// 或者简单使用 upperbound 限制最大值
|
||||
const range = IDBKeyRange.upperBound([sessionId, beforeTimeStamp], true);
|
||||
const range = IDBKeyRange.upperBound([sessionId, beforeSequenceId], true);
|
||||
|
||||
// 'prev' 表示从最新的往回找(倒序)
|
||||
let cursor = await index.openCursor(range, 'prev');
|
||||
@@ -36,5 +36,29 @@ export const messagesDb = {
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
async getLatestMessages(sessionId, limit) {
|
||||
const db = await dbPromise;
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const index = tx.store.index('by-session-sequenceId');
|
||||
|
||||
// 关键点:范围只限定 sessionId,不限 sequenceId 的上限
|
||||
// 复合索引中,[sessionId, []] 到 [sessionId, [Infinity]] 会覆盖该 session 下所有数据
|
||||
const range = IDBKeyRange.bound([sessionId, 0], [sessionId, Infinity]);
|
||||
|
||||
// 使用 'prev' 游标,从最大的 sequenceId 开始往前找
|
||||
let cursor = await index.openCursor(range, 'prev');
|
||||
const results = [];
|
||||
|
||||
while (cursor && results.length < limit) {
|
||||
// 虽然有 bound 约束,但为了防御性编程,依然建议检查 sessionId
|
||||
if (cursor.value.sessionId !== sessionId) break;
|
||||
|
||||
results.push(cursor.value);
|
||||
cursor = await cursor.continue();
|
||||
}
|
||||
|
||||
// 因为是倒序捞出来的(20, 19, 18...),最后要反转一下变成升序给界面渲染
|
||||
return results.reverse();
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,12 @@
|
||||
* @param {string|number} id1 用户A的ID
|
||||
* @param {string|number} id2 用户B的ID
|
||||
*/
|
||||
export const generateSessionId = (id1, id2) => {
|
||||
export const generateSessionId = (id1, id2, isGroup = false) => {
|
||||
// 1. 转换为字符串并放入数组
|
||||
// 2. 排序(确保顺序一致性)
|
||||
// 3. 用下划线或其他分隔符拼接
|
||||
if (isGroup) {
|
||||
return `g:${id2}`;
|
||||
}
|
||||
return [String(id1), String(id2)].sort().join('_');
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useBrowserNotification } from "@/services/useBrowserNotification";
|
||||
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
|
||||
import { messageHandler } from "@/handler/messageHandler";
|
||||
import { generateSessionId } from "../sessionIdTools";
|
||||
import { useConversationStore } from "@/stores/conversation";
|
||||
|
||||
export const SignalRMessageHandler = (data) => {
|
||||
const msg = data.data;
|
||||
const chatStore = useChatStore()
|
||||
const browserNotification = useBrowserNotification();
|
||||
const sessionId = generateSessionId(msg.senderId, msg.receiverId);
|
||||
messageHandler(msg);
|
||||
chatStore.pushAndSortMessagesAsync([msg], sessionId);
|
||||
const conversation = useConversationStore().conversations.find(x => x.targetId == msg.senderId);
|
||||
browserNotification.send(`${conversation.targetName}发来一条消息`, {
|
||||
body: msg.content,
|
||||
icon: conversation.targetAvatar
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useConversationStore } from "@/stores/conversation"
|
||||
|
||||
export const signalRConnectionEventHandler = () => {
|
||||
const conversationStore = useConversationStore();
|
||||
conversationStore.fetchConversationsFromServier().then(res => {
|
||||
conversationStore.conversations.forEach(element => {
|
||||
element.isInitialized = false;
|
||||
});
|
||||
})
|
||||
}
|
||||
@@ -9,15 +9,16 @@
|
||||
</header>
|
||||
|
||||
<div class="chat-history" ref="historyRef">
|
||||
<HistoryLoading ref="loadingRef" :loading="isLoading" :finished="isFinished" :error="hasError" @retry="loadHistoryMsg"/>
|
||||
<div v-for="m in chatStore.messages" :key="m.id" :class="['msg', m.senderId == myInfo.id ? 'mine' : 'other']">
|
||||
<img :src="m.senderId == myInfo.id ? (myInfo?.avatar || defaultAvatar) : defaultAvatar" class="avatar-chat" />
|
||||
<img :src="m.senderId == myInfo.id ? (myInfo?.avatar || defaultAvatar) : getAvatar(m.senderId) ?? defaultAvatar" class="avatar-chat" />
|
||||
|
||||
<div class="msg-content">
|
||||
<div class="bubble">
|
||||
<div v-if="m.type === 'Text'">{{ m.content }}</div>
|
||||
<div v-else-if="m.type === 'emoji'" class="emoji-msg">{{ m.content }}</div>
|
||||
<div class="status" v-if="m.senderId == myInfo.id">
|
||||
<i v-if="m.isFail" style="color: red;" v-html="feather.icons['alert-circle'].toSvg({width:18, height: 18})"></i>
|
||||
<i v-if="m.isError" style="color: red;" v-html="feather.icons['alert-circle'].toSvg({width:18, height: 18})"></i>
|
||||
<i v-if="m.isLoading" class="loaderIcon" v-html="feather.icons['loader'].toSvg({width:18, height: 18})"></i>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,7 +50,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, onMounted, watch } from 'vue';
|
||||
import { ref, computed, nextTick, onMounted, watch, onUnmounted } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import defaultAvatar from '@/assets/default_avatar.png';
|
||||
import { messageService } from '@/services/message';
|
||||
@@ -60,6 +61,11 @@ import { useSignalRStore } from '@/stores/signalr';
|
||||
import { useConversationStore } from '@/stores/conversation';
|
||||
import feather from 'feather-icons';
|
||||
import { onBeforeRouteUpdate } from 'vue-router';
|
||||
import { MESSAGE_TYPE } from '@/constants/MessageType';
|
||||
import { GetLocalIso } from '@/utils/dateTool';
|
||||
import HistoryLoading from '@/components/messages/HistoryLoading.vue';
|
||||
import { useMessage } from '@/components/messages/useAlert';
|
||||
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus';
|
||||
|
||||
const props = defineProps({
|
||||
id:{
|
||||
@@ -74,12 +80,47 @@ const conversationStore = useConversationStore();
|
||||
|
||||
const input = ref(''); // 输入框内容
|
||||
const historyRef = ref(null); // 绑定 DOM 用于滚动
|
||||
const loadingRef = ref(null)
|
||||
const myInfo = useAuthStore().userInfo;
|
||||
|
||||
const conversationInfo = ref(null)
|
||||
|
||||
// --- 消息数据 ---
|
||||
const messages = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const isFinished = ref(false);
|
||||
const hasError = ref(false);
|
||||
let observer = null;
|
||||
|
||||
|
||||
const loadHistoryMsg = async () => {
|
||||
// 1. 如果正在加载,或者已经彻底没数据了,才拦截
|
||||
if (isLoading.value || isFinished.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
hasError.value = false;
|
||||
|
||||
try {
|
||||
|
||||
const oldHeight = historyRef.value.scrollHeight;
|
||||
// 假设你的 store 方法会返回新加载的消息数量,或者直接内部更新 isEnded
|
||||
await chatStore.loadMoreMessages();
|
||||
const newHeight = historyRef.value.scrollHeight;
|
||||
|
||||
historyRef.value.scrollTop = newHeight - oldHeight;
|
||||
// 2. 核心:根据 Store 里的状态同步组件状态
|
||||
// 只有当服务器明确告诉你“没消息了”,才设为 true
|
||||
if (chatStore.isEnded) {
|
||||
isFinished.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
// 3. 发生错误时,不要设置 isFinished = true
|
||||
hasError.value = true;
|
||||
console.error("加载历史消息失败:", error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => chatStore.messages,
|
||||
@@ -91,6 +132,10 @@ watch(
|
||||
{deep: true}
|
||||
);
|
||||
|
||||
const getAvatar = (userId) => {
|
||||
return conversationStore.conversations.find(x => x.targetId == userId).targetAvatar;
|
||||
}
|
||||
|
||||
// 自动滚动到底部
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick(); // 等待 DOM 更新后执行
|
||||
@@ -105,16 +150,38 @@ async function sendText() {
|
||||
// 根据 C# MessageBaseDto 构造的示例对象
|
||||
const msg = {
|
||||
type: "Text", // 消息类型,例如 'Text', 'Image', 'File'
|
||||
chatType: "PRIVATE", // 'PRIVATE' 或 'GROUP'
|
||||
chatType: conversationInfo.value.chatType, // 'PRIVATE' 或 'GROUP'
|
||||
senderId: conversationInfo.value.userId, // 当前用户ID (对应 int)
|
||||
receiverId: conversationInfo.value.targetId, // 接收者ID (对应 int)
|
||||
content: input.value,
|
||||
timeStamp: new Date().toISOString() // 对应 DateTime,建议存标准 ISO 字符串
|
||||
timeStamp: new Date(), // 对应 DateTime
|
||||
msgId: self.crypto.randomUUID()
|
||||
};
|
||||
input.value = ''; // 清空输入框
|
||||
//设置消息为加载状态
|
||||
msg.isLoading = true;
|
||||
//将临时消息推送到消息列表(存库,方便后续重试)
|
||||
await chatStore.pushAndSortMessagesAsync([msg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true);
|
||||
//更新当前会话最新消息
|
||||
conversationInfo.value.lastMessage = msg.content;
|
||||
//从列表取出消息
|
||||
let updateMsg = msg;
|
||||
try{
|
||||
const res = await messageService.sendMessage(msg);
|
||||
if(res.code != SYSTEM_BASE_STATUS.SUCCESS){
|
||||
updateMsg.isError = true;
|
||||
}else{
|
||||
//发送成功将后端生成的sequenceId更新
|
||||
updateMsg = res.data;
|
||||
}
|
||||
}catch{
|
||||
updateMsg.isError = true;
|
||||
}finally{
|
||||
updateMsg.isLoading = false;
|
||||
chatStore.pushAndSortMessagesAsync([updateMsg], generateSessionId(msg.senderId, msg.receiverId), true);
|
||||
|
||||
msg.isLoading = false;
|
||||
await signalRStore.sendMsg(msg);
|
||||
input.value = ''; // 清空输入框
|
||||
msg.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 通话模拟
|
||||
@@ -145,19 +212,122 @@ async function loadConversation(conversationId) {
|
||||
|
||||
const initChat = async (newId) => {
|
||||
await loadConversation(newId);
|
||||
const sessionid = generateSessionId(conversationInfo.value.userId, conversationInfo.value.targetId)
|
||||
if(conversationInfo.value){
|
||||
const sessionid = generateSessionId(
|
||||
conversationInfo.value.userId, conversationInfo.value.targetId, conversationInfo.value.chatType == MESSAGE_TYPE.GROUP)
|
||||
await chatStore.swtichSession(sessionid,newId);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 监听路由参数
|
||||
watch(
|
||||
() => props.id,
|
||||
async (newId) => {
|
||||
await initChat(newId)
|
||||
// 1. 监听源:同时监听 ID 和 初始化状态
|
||||
// 这里必须写得极其小心,防止在 find 不到的时候报错
|
||||
() => {
|
||||
// 如果没有 ID,或者 Store 还没加载,返回一个安全的默认值
|
||||
if (!conversationStore.conversations.length) {
|
||||
return [props.id,null];
|
||||
}
|
||||
|
||||
// 尝试找到当前会话
|
||||
const session = conversationStore.conversations.find(x => x.id == Number(props.id));
|
||||
|
||||
// 返回 [ID, 状态],用了 ?. 即使 session 是 undefined 也只会返回 undefined,不会报错
|
||||
return [props.id, session?.isInitialized];
|
||||
},
|
||||
{ immediate: true } // 组件第一次挂载(刷新页面进入)时会立即执行一次
|
||||
)
|
||||
|
||||
// 2. 回调逻辑
|
||||
async ([newId, isInited], [oldId, oldInited]) => {
|
||||
// 基础防守:如果 ID 无效,直接跳过
|
||||
if (!newId) return;
|
||||
|
||||
try {
|
||||
// 场景 A:路由切换 (ID 变了)
|
||||
// 这里的逻辑是:只要切了 ID,先加载本地的给用户看
|
||||
//if (newId !== oldId) {
|
||||
// 注意:这里只是加载本地缓存,不负责去服务器拉新
|
||||
// 真正的“拉新”逻辑交给下面的 isInited 判断
|
||||
await initChat(newId);
|
||||
//}
|
||||
|
||||
// 场景 B:检测到需要补洞 (isInited 变为 false)
|
||||
// 无论是“刚进页面”还是“SignalR重连”导致的,都会命中这里
|
||||
if (isInited === false) {
|
||||
console.log(`[同步触发] 会话 ${newId} 需要补洞...`);
|
||||
|
||||
// 1. 获取当前断点(本地最新一条消息的 ID)
|
||||
const currentMax = chatStore.maxSequenceId;
|
||||
|
||||
// 2. 去服务器拉取增量数据
|
||||
const msgList = await chatStore.fetchNewMsgFromServier(newId, currentMax);
|
||||
|
||||
const session = conversationStore.conversations.find(x => x.id == Number(newId));
|
||||
if(msgList && msgList.length > 0){
|
||||
const minSequenceId = Math.min(...msgList.map(m => m.sequenceId));
|
||||
const locaMaxSequenceId = chatStore.maxSequenceId;
|
||||
if(locaMaxSequenceId < (minSequenceId - 1)){
|
||||
chatStore.messages = [];;
|
||||
}
|
||||
await chatStore.pushAndSortMessagesAsync(msgList, generateSessionId(session.userId, session.targetId, session.chatType == MESSAGE_TYPE.GROUP), true);
|
||||
}
|
||||
// 3. 如果有新消息,存入 Store
|
||||
|
||||
|
||||
|
||||
// 4. 关键步骤:手动把状态回正!
|
||||
// 必须重新 find 一次对象,确保引用是对的
|
||||
if (session) {
|
||||
session.isInitialized = true;
|
||||
console.log(`[同步完成] 会话 ${newId} 状态已重置为 true`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("同步流程异常:", err);
|
||||
}
|
||||
},
|
||||
{ deep: true } // 加上 deep 确保能监听到对象属性变化
|
||||
);
|
||||
|
||||
const initObs = async () => {
|
||||
await nextTick();
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
|
||||
// 只有当 Loading 组件进入视口,且满足触发条件
|
||||
if (entry.isIntersecting) {
|
||||
loadHistoryMsg();
|
||||
}
|
||||
}, {
|
||||
root: historyRef.value, // 指定监听容器
|
||||
threshold: 0.1 // 露出 10% 就算触发
|
||||
});
|
||||
|
||||
// 开始观察 Loading 组件
|
||||
if (loadingRef.value) {
|
||||
// 如果 sentinel 是组件,需要拿它底下的 $el
|
||||
observer.observe(loadingRef.value.$el || loadingRef.value);
|
||||
}
|
||||
// 3. 【主动侦测逻辑】
|
||||
// 如果当前没有在加载,且还没有结束,且内容还没有撑开容器
|
||||
const el = historyRef.value;
|
||||
if (el && !isLoading.value && !isFinished.value) {
|
||||
if (el.scrollHeight <= el.clientHeight) {
|
||||
console.log('检测到首屏内容不足,主动拉取历史记录');
|
||||
loadHistoryMsg();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await initChat(props.id)
|
||||
await initObs();
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (observer) observer.disconnect();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<div v-for="s in filteredSessions" :key="s.id"
|
||||
class="list-item" :class="{active: activeId === s.id}" @click="selectSession(s)">
|
||||
<div class="avatar-container">
|
||||
<img :src="s.targetAvatar ?? defaultAvatar" class="avatar-std" />
|
||||
<img :src="s.targetAvatar ? s.targetAvatar : defaultAvatar" class="avatar-std" />
|
||||
<span v-if="s.unreadCount > 0" class="unread-badge">{{ s.unreadCount ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="info">
|
||||
|
||||
Reference in New Issue
Block a user