diff --git a/backend/IM_API/Application/EventHandlers/ConversationEventHandler.cs b/backend/IM_API/Application/EventHandlers/ConversationEventHandler.cs index 721f9bb..40cc4d4 100644 --- a/backend/IM_API/Application/EventHandlers/ConversationEventHandler.cs +++ b/backend/IM_API/Application/EventHandlers/ConversationEventHandler.cs @@ -46,8 +46,10 @@ namespace IM_API.Application.EventHandlers } userAConversation.LastMessage = @event.MessageContent; userAConversation.LastReadMessageId = @event.MessageId; + userAConversation.LastMessageTime = @event.MessageCreated; userBConversation.LastMessage = @event.MessageContent; userBConversation.UnreadCount += 1; + userBConversation.LastMessageTime = @event.MessageCreated; _context.UpdateRange(userAConversation,userBConversation); await _context.SaveChangesAsync(); } diff --git a/backend/IM_API/Application/EventHandlers/SignalREventHandler.cs b/backend/IM_API/Application/EventHandlers/SignalREventHandler.cs index e2f4f16..63f58a6 100644 --- a/backend/IM_API/Application/EventHandlers/SignalREventHandler.cs +++ b/backend/IM_API/Application/EventHandlers/SignalREventHandler.cs @@ -23,7 +23,17 @@ namespace IM_API.Application.EventHandlers { if(@event.ChatType == Models.ChatType.PRIVATE) { - MessageBaseDto messageBaseDto = _mapper.Map(_mapper.Map(@event)); + MessageBaseDto messageBaseDto = new MessageBaseDto + { + MsgId = @event.MessageId.ToString(), + ChatType = @event.ChatType.ToString(), + Content = @event.MessageContent, + GroupMemberId = null, + ReceiverId = @event.MsgRecipientId, + SenderId = @event.MsgSenderId, + TimeStamp = @event.MessageCreated, + Type = @event.MessageMsgType.ToString() + }; await _hub.Clients.Users(@event.MsgRecipientId.ToString()).SendAsync("ReceiveMessage", messageBaseDto); } } diff --git a/backend/IM_API/Configs/MapperConfig.cs b/backend/IM_API/Configs/MapperConfig.cs index 3d863f2..5db0450 100644 --- a/backend/IM_API/Configs/MapperConfig.cs +++ b/backend/IM_API/Configs/MapperConfig.cs @@ -41,7 +41,7 @@ namespace IM_API.Configs CreateMap() .ForMember(dest => dest.RequestUser , opt => opt.MapFrom(src => src.FromUserId)) .ForMember(dest => dest.ResponseUser , opt => opt.MapFrom(src => src.ToUserId)) - .ForMember(dest => dest.Created , opt => opt.MapFrom(src => DateTime.Now)) + .ForMember(dest => dest.Created , opt => opt.MapFrom(src => DateTime.UtcNow)) .ForMember(dest => dest.StateEnum , opt => opt.MapFrom(src => FriendRequestState.Pending)) .ForMember(dest => dest.Description , opt => opt.MapFrom(src => src.Description)) ; @@ -101,7 +101,7 @@ namespace IM_API.Configs .ForMember(dest => dest.TargetId, opt => opt.MapFrom(src => src.MsgRecipientId)) .ForMember(dest => dest.UnreadCount, opt => opt.MapFrom(src => 0)) .ForMember(dest => dest.StreamKey, opt => opt.MapFrom(src => src.StreamKey)) - .ForMember(dest => dest.LastMessageTime, opt => opt.MapFrom(src => DateTime.Now)) + .ForMember(dest => dest.LastMessageTime, opt => opt.MapFrom(src => DateTime.UtcNow)) ; //创建会话对象 diff --git a/backend/IM_API/Hubs/ChatHub.cs b/backend/IM_API/Hubs/ChatHub.cs index 4851c4a..37a26eb 100644 --- a/backend/IM_API/Hubs/ChatHub.cs +++ b/backend/IM_API/Hubs/ChatHub.cs @@ -60,5 +60,17 @@ namespace IM_API.Hubs } return; } + public async Task ClearUnreadCount(int conversationId) + { + if (!Context.User.Identity.IsAuthenticated) + { + await Clients.Caller.SendAsync("ReceiveMessage", new BaseResponse(CodeDefine.AUTH_FAILED)); + Context.Abort(); + return; + } + var userIdStr = Context.User.FindFirstValue(ClaimTypes.NameIdentifier); + await _conversationService.ClearUnreadCountAsync(int.Parse(userIdStr), conversationId); + return; + } } } diff --git a/backend/IM_API/Interface/Services/IConversationService.cs b/backend/IM_API/Interface/Services/IConversationService.cs index 228bbcb..474bb16 100644 --- a/backend/IM_API/Interface/Services/IConversationService.cs +++ b/backend/IM_API/Interface/Services/IConversationService.cs @@ -35,5 +35,12 @@ namespace IM_API.Interface.Services /// /// Task GetConversationByIdAsync(int userId, int conversationId); + /// + /// 清空未读消息 + /// + /// + /// + /// + Task ClearUnreadCountAsync(int userId, int conversationId); } } diff --git a/backend/IM_API/Program.cs b/backend/IM_API/Program.cs index 4b6c5a5..48f2db8 100644 --- a/backend/IM_API/Program.cs +++ b/backend/IM_API/Program.cs @@ -3,6 +3,7 @@ using IM_API.Configs; using IM_API.Filters; using IM_API.Hubs; using IM_API.Models; +using IM_API.Tools; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; @@ -103,6 +104,10 @@ namespace IM_API builder.Services.AddControllers(options => { options.Filters.Add(); + }).AddJsonOptions(options => + { + // ISO 8601 ʽ + options.JsonSerializerOptions.Converters.Add(new UtcDateTimeConverter()); }); builder.Services.AddModelValidation(configuration); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle diff --git a/backend/IM_API/Services/ConversationService.cs b/backend/IM_API/Services/ConversationService.cs index 8a24272..9cfb3cc 100644 --- a/backend/IM_API/Services/ConversationService.cs +++ b/backend/IM_API/Services/ConversationService.cs @@ -118,5 +118,21 @@ namespace IM_API.Services return dto; } #endregion + + public async Task ClearUnreadCountAsync(int userId, int conversationId) + { + var conversation = await _context.Conversations.FirstOrDefaultAsync(x => x.UserId == userId && x.Id == conversationId); + if (conversation is null) throw new BaseException(CodeDefine.CONVERSATION_NOT_FOUND); + var message = await _context.Messages + .Where(x => x.StreamKey == conversation.StreamKey) + .OrderByDescending(x => x.Id) + .FirstOrDefaultAsync(); + conversation.LastReadMessage = message; + conversation.UnreadCount = 0; + _context.Conversations.Update(conversation); + await _context.SaveChangesAsync(); + return true; + + } } } \ No newline at end of file diff --git a/backend/IM_API/Tools/UTCConverter.cs b/backend/IM_API/Tools/UTCConverter.cs new file mode 100644 index 0000000..bc422cd --- /dev/null +++ b/backend/IM_API/Tools/UTCConverter.cs @@ -0,0 +1,18 @@ +using System.Text.Json; + +namespace IM_API.Tools +{ + public class UtcDateTimeConverter : System.Text.Json.Serialization.JsonConverter + { + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.GetDateTime(); + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + // 如果 Kind 已经是 Utc,直接输出。 + // 如果不是,先 SpecifyKind 再输出,确保不会发生时区偏移计算 + var utcDate = value.Kind == DateTimeKind.Utc ? value : DateTime.SpecifyKind(value, DateTimeKind.Utc); + writer.WriteStringValue(utcDate.ToString("yyyy-MM-ddTHH:mm:ssZ")); + } + } +} diff --git a/frontend/web/.env b/frontend/web/.env index e727f6c..4953eda 100644 --- a/frontend/web/.env +++ b/frontend/web/.env @@ -1,2 +1,4 @@ +#VITE_API_BASE_URL = http://localhost:5202/api +#VITE_SIGNALR_BASE_URL = http://localhost:5202/chat VITE_API_BASE_URL = http://localhost:5202/api VITE_SIGNALR_BASE_URL = http://localhost:5202/chat \ No newline at end of file diff --git a/frontend/web/package.json b/frontend/web/package.json index 0ca5cdd..4e58dd6 100644 --- a/frontend/web/package.json +++ b/frontend/web/package.json @@ -7,7 +7,7 @@ "node": "^20.19.0 || >=22.12.0" }, "scripts": { - "dev": "vite", + "dev": "vite --host", "build": "vite build", "preview": "vite preview", "test:unit": "vitest", diff --git a/frontend/web/src/components/addMenu.vue b/frontend/web/src/components/addMenu.vue new file mode 100644 index 0000000..c35642e --- /dev/null +++ b/frontend/web/src/components/addMenu.vue @@ -0,0 +1,180 @@ + + + + + \ No newline at end of file diff --git a/frontend/web/src/components/groups/GroupChatModal.vue b/frontend/web/src/components/groups/GroupChatModal.vue new file mode 100644 index 0000000..b6ce216 --- /dev/null +++ b/frontend/web/src/components/groups/GroupChatModal.vue @@ -0,0 +1,256 @@ + + + + + \ No newline at end of file diff --git a/frontend/web/src/handler/messageHandler.js b/frontend/web/src/handler/messageHandler.js new file mode 100644 index 0000000..3101816 --- /dev/null +++ b/frontend/web/src/handler/messageHandler.js @@ -0,0 +1,15 @@ +import { useConversationStore } from "@/stores/conversation" + +export const messageHandler = (msg) => { + const conversationStore = useConversationStore(); + const conversation = conversationStore.conversations.find(x => x.targetId == msg.senderId || x.targetId == msg.receiverId); + conversation.lastMessage = msg.content; + if (conversation.targetId == msg.receiverId) { + conversation.unreadCount = 0; + } else { + conversation.unreadCount += 1; + } + console.log(conversation) + conversation.dateTime = new Date().toISOString(); + +} \ No newline at end of file diff --git a/frontend/web/src/stores/chat.js b/frontend/web/src/stores/chat.js index f323538..8c22dee 100644 --- a/frontend/web/src/stores/chat.js +++ b/frontend/web/src/stores/chat.js @@ -10,8 +10,20 @@ export const useChatStore = defineStore('chat', { 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)); + + // 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 }); + await messagesDb.save({ ...msg, sessionId, isLoading: false }); this.messages.push({ ...msg, sessionId }) }, /** @@ -32,13 +44,13 @@ export const useChatStore = defineStore('chat', { const serverHistoryMsg = await this.fetchHistoryFromServer(this.activeConversationId, conversation.lastReadMessageId); //对消息进行过滤,防止重复消息 const filterMsg = serverHistoryMsg.filter(m => !this.messages.find(exist => exist.msgId === m.msgId)); - this.messages = [...filterMsg, ...this.messages] + this.pushAndSortMessages([...filterMsg, ...this.messages]); } //拉取新消息 this.fetchNewMsgFromServier(this.activeConversationId).then((newMsg) => { //去重 const filterNewMsg = newMsg.filter(m => !this.messages.find(exist => exist.msgId === m.msgId)); - this.messages = [...filterNewMsg, ...this.messages] + this.pushAndSortMessages([...filterNewMsg, ...this.messages]) }); }, /** @@ -88,7 +100,7 @@ export const useChatStore = defineStore('chat', { } 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.messages = [...newMsgs, ...this.messages] + this.pushAndSortMessages([...newMsgs, ...this.messages]) } } } diff --git a/frontend/web/src/stores/conversation.js b/frontend/web/src/stores/conversation.js index 0b7d8ab..f97ec90 100644 --- a/frontend/web/src/stores/conversation.js +++ b/frontend/web/src/stores/conversation.js @@ -9,6 +9,15 @@ export const useConversationStore = defineStore('conversation', { state: () => ({ conversations: [] }), + // stores/conversation.js + getters: { + // 始终根据时间戳倒序排列 + sortedConversations: (state) => { + return [...state.conversations].sort((a, b) => + new Date(b.dateTime) - new Date(a.dateTime) + ); + } + }, actions: { async addConversation(conversation) { await conversationDb.save(conversation); diff --git a/frontend/web/src/stores/signalr.js b/frontend/web/src/stores/signalr.js index e3f7c71..1392db2 100644 --- a/frontend/web/src/stores/signalr.js +++ b/frontend/web/src/stores/signalr.js @@ -4,6 +4,8 @@ import { useMessage } from "@/components/messages/useAlert"; import { useAuthStore } from "./auth"; import { useChatStore } from "./chat"; import { authService } from "@/services/auth"; +import { generateSessionId } from "@/utils/sessionIdTools"; +import { messageHandler } from "@/handler/messageHandler"; export const useSignalRStore = defineStore('signalr', { state: () => ({ @@ -39,6 +41,7 @@ export const useSignalRStore = defineStore('signalr', { registerHandlers() { const chatStore = useChatStore() this.connection.on('ReceiveMessage', (msg) => { + messageHandler(msg); chatStore.addMessage(msg); }); @@ -46,6 +49,11 @@ export const useSignalRStore = defineStore('signalr', { this.connection.onreconnected(() => { this.isConnected = true }); }, + /** + * 通过signalr发送消息 + * @param {*} msg + * @returns + */ async sendMsg(msg) { const message = useMessage() const chatStore = useChatStore() @@ -59,13 +67,24 @@ export const useSignalRStore = defineStore('signalr', { if (msg.msgId == null) { msg.msgId = self.crypto.randomUUID(); } - await this.connection.invoke("SendMessage", msg); - chatStore.addMessage(msg); + const sessionId = generateSessionId(msg.senderId, msg.receiverId); + this.connection.invoke("SendMessage", msg).then(() => { + const msga = chatStore.messages.find(x => x.msgId == msg.msgId) + if (msga.isLoading) { + msga.isLoading = false; + } + }) + ; + chatStore.addMessage({ ...msg, isLoading: true }, sessionId); + messageHandler(msg); console.log("消息发送成功!"); } catch (err) { console.error("消息发送失败:", err); message.error("消息发送失败"); } + }, + async clearUnreadCount(conversationId) { + await this.connection.invoke("ClearUnreadCount", conversationId) } } }) \ No newline at end of file diff --git a/frontend/web/src/views/Main.vue b/frontend/web/src/views/Main.vue index 0c0f47c..8b46c35 100644 --- a/frontend/web/src/views/Main.vue +++ b/frontend/web/src/views/Main.vue @@ -4,9 +4,15 @@
- 💬 - 👤 - ⚙️ + + + + + + + + + @@ -18,6 +24,7 @@ import { ref, watch } from 'vue' import { useAuthStore } from '@/stores/auth'; import defaultAvatar from '@/assets/default_avatar.png' import { useRouter } from 'vue-router'; +import feather from 'feather-icons'; const router = useRouter(); const authStore = useAuthStore(); @@ -55,13 +62,19 @@ function handleStartChat(contact) { .nav-sidebar { width: 60px; flex-shrink: 0; - background: #282828; + background: #e9e9e9; display: flex; flex-direction: column; align-items: center; padding: 20px 0; gap: 24px; } + +.menuIcon { + background-color: #e9e9e9; + color: black; +} + .user-self { margin-bottom: 10px; } /* 2. 列表区修复 */ diff --git a/frontend/web/src/views/Test.vue b/frontend/web/src/views/Test.vue index 2c63449..2ba7799 100644 --- a/frontend/web/src/views/Test.vue +++ b/frontend/web/src/views/Test.vue @@ -1,30 +1,254 @@ - \ No newline at end of file diff --git a/frontend/web/src/views/contact/ContactList.vue b/frontend/web/src/views/contact/ContactList.vue index 1ffb39d..1ffc987 100644 --- a/frontend/web/src/views/contact/ContactList.vue +++ b/frontend/web/src/views/contact/ContactList.vue @@ -3,7 +3,7 @@