Merge branch 'feature-nxdev' of https://gitea.nxsir.cn/code/IM into feature-nxdev

This commit is contained in:
西街长安 2026-03-15 22:31:23 +08:00
commit 536b360127
14 changed files with 291 additions and 95 deletions

View File

@ -14,7 +14,11 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("IMTest")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
<<<<<<< HEAD
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+eb8455e141ea496a2134ad7c7d9b759b6029dd75")]
=======
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+f7223dc5904286fdf8e117b56c37f58dc431d68b")]
>>>>>>> eb8455e141ea496a2134ad7c7d9b759b6029dd75
[assembly: System.Reflection.AssemblyProductAttribute("IMTest")]
[assembly: System.Reflection.AssemblyTitleAttribute("IMTest")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@ -1 +1,5 @@
<<<<<<< HEAD
546570633bb9288fc2957cbb29a807a45f3e48ba127ec13bc3956d28f5e6ed5b
=======
3c0a5335719f892c65744a9df7eae9fd7b12de9067131f5c9f4cb65f2b27b8d4
>>>>>>> eb8455e141ea496a2134ad7c7d9b759b6029dd75

View File

@ -22,7 +22,7 @@ namespace IM_API.Application.EventHandlers.FriendAddHandler
var usersList = new List<string> {
@event.RequestUserId.ToString(), @event.ResponseUserId.ToString()
};
var res = new HubResponse<MessageBaseDto>("Event", new MessageBaseDto()
var res = new HubResponse<MessageBaseDto>(HubResponseType.FriendAccepted, new MessageBaseDto()
{
ChatType = ChatType.PRIVATE,
Content = "您有新的好友关系已添加",

View File

@ -39,11 +39,44 @@ namespace IM_API.Dtos
Message = codeDefine.Message;
Data = data;
}
public HubResponse(HubResponseType type, T? data)
{
Code = CodeDefine.SUCCESS.Code;
Method = "Event";
Type = type;
Data = data;
}
}
public enum HubResponseType
{
ChatMsg = 1, // 聊天内容
SystemNotice = 2, // 系统通知(如:申请好友成功)
ActionStatus = 3 // 状态变更(如:对方正在输入、已读回执)
// --- 基础聊天 (10-19) ---
ChatMsg = 1, // 普通消息
// --- 系统与通知 (20-29) ---
SystemNotice = 2, // 通用系统通知
FriendRequest = 21, // 好友申请消息
FriendAccepted = 22, // 好友通过通知
UserInLine = 23, // 好友上线/下线通知
// --- 状态变更与交互 (30-39) ---
ActionStatus = 3, // 通用状态(正在输入等)
MsgReadReceipt = 31, // 已读回执
MsgRevoke = 32, // 消息撤回
MsgEdit = 33, // 消息二次编辑更新
// --- 群组管理 (40-49) ---
GroupInvited = 41, // 被邀请入群
GroupMemberUpdate = 42, // 群成员变动(进群/退群)
GroupAnnouncement = 43, // 群公告更新
GroupDismissed = 44, // 群组解散
// --- 实时通信控制 (50-59) ---
RTC_CallRequest = 51, // 音视频通话邀请
RTC_CallHandled = 52, // 音视频接听/挂断/取消状态
// --- 异常与安全 (90-99) ---
ErrorInternal = 91, // 服务器内部错误
TokenExpired = 92, // 登录过期,强制下线
KickedOut = 93 // 被挤下线(异地登录)
}
}

View File

@ -146,7 +146,8 @@ namespace IM_API.Services
public async Task MakeConversationAsync(int userAId, int userBId, ChatType chatType)
{
var userAcExist = await _context.Conversations.AnyAsync(x => x.UserId == userAId && x.TargetId == userBId);
var userAcExist = await _context.Conversations.AnyAsync(
x => x.UserId == userAId && x.TargetId == userBId && x.ChatType == chatType);
if (userAcExist) return;
var streamKey = chatType == ChatType.PRIVATE ?
StreamKeyBuilder.Private(userAId, userBId) : StreamKeyBuilder.Group(userBId);

View File

@ -1,8 +1,8 @@
# 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/
# 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://192.168.5.116:7070/api
VITE_SIGNALR_BASE_URL = http://192.168.5.116:7070/chat/
# VITE_API_BASE_URL = http://192.168.5.116:7070/api
# VITE_SIGNALR_BASE_URL = http://192.168.5.116:7070/chat/

View File

@ -26,7 +26,7 @@ function createWindow() {
createTry(mainWindow);
mainWindow.on('ready-to-show', () => {
mainWindow.show()
// mainWindow.show()
})

View File

@ -25,7 +25,9 @@ export function registerWindowHandler() {
changeSize: () => {
win.setSize(data.width, data.height, true)
win.setResizable(data.resizable)
}
win.center()
},
show: () => win.show()
}
actions[action]?.()
})

View File

@ -8,6 +8,7 @@ const api = {
maximize: () => ipcRenderer.send('window-action', 'maximize'),
close: () => ipcRenderer.send('window-action', 'close'),
closeThis: () => ipcRenderer.send('window-action', 'closeThis'),
show: () => ipcRenderer.send('window-action', 'show'),
isMaximized: () => ipcRenderer.send('window-action', 'isMaximized'),
newWindow: (route, data, width, height) => ipcRenderer.send('window-new', { route, data, width, height }),
getWindowData: (winId) => ipcRenderer.invoke('get-window-data', winId),

View File

@ -50,6 +50,7 @@ function toggleMaximize() {
function close() {
window.api.window.close()
emits('close')
emits('close')
}
</script>

View File

@ -0,0 +1,34 @@
export const NOTIFICATION_TYPE = Object.freeze({
// --- 基础聊天 (10-19) ---
ChatMsg: 1,
FileMsg: 11,
EmojiSticker: 12,
ForwardMsg: 13,
// --- 系统与通知 (20-29) ---
SystemNotice: 2,
FriendRequest: 21,
FriendAccepted: 22,
UserInLine: 23,
// --- 状态变更与交互 (30-39) ---
ActionStatus: 3,
MsgReadReceipt: 31,
MsgRevoke: 32,
MsgEdit: 33,
// --- 群组管理 (40-49) ---
GroupInvited: 41,
GroupMemberUpdate: 42,
GroupAnnouncement: 43,
GroupDismissed: 44,
// --- 实时通信控制 (50-59) ---
RTC_CallRequest: 51,
RTC_CallHandled: 52,
// --- 异常与安全 (90-99) ---
ErrorInternal: 91,
TokenExpired: 92,
KickedOut: 93
})

View File

@ -1,22 +1,28 @@
import { useBrowserNotification } from "@/services/useBrowserNotification";
import { useBrowserNotification } from '@/services/useBrowserNotification'
import { useChatStore } from "@/stores/chat";
import { useChatStore } from '@/stores/chat'
import { messageHandler } from "@/handler/messageHandler";
import { generateSessionId } from "../sessionIdTools";
import { useConversationStore } from "@/stores/conversation";
import { MESSAGE_TYPE } from "@/constants/MessageType";
import { messageHandler } from '@/handler/messageHandler'
import { generateSessionId } from '../sessionIdTools'
import { useConversationStore } from '@/stores/conversation'
import { MESSAGE_TYPE } from '@/constants/MessageType'
import { NOTIFICATION_TYPE } from '../../constants/notificationType'
export const SignalRMessageHandler = (data) => {
const msg = data.data;
const chatStore = useChatStore()
const browserNotification = useBrowserNotification();
const sessionId = generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP);
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
});
}
const msg = data.data
const type = data.type
const chatStore = useChatStore()
const browserNotification = useBrowserNotification()
const sessionId = generateSessionId(
msg.senderId,
msg.receiverId,
msg.chatType == MESSAGE_TYPE.GROUP
)
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
})
}

View File

@ -2,7 +2,7 @@
<div class="im-container">
<nav class="nav-sidebar">
<div class="user-self">
<async-image :raw-url="myInfo?.avatar" class="avatar-std"/>
<async-image :raw-url="myInfo?.avatar" class="avatar-std" />
<!-- <img :src="myInfo?.avatar ?? defaultAvatar" class="avatar-std" /> -->
</div>
<router-link class="nav-item" to="/messages" active-class="active">
@ -26,6 +26,7 @@ import { useAuthStore } from '@/stores/auth';
import { useRouter } from 'vue-router';
import feather from 'feather-icons';
import AsyncImage from '../components/AsyncImage.vue';
import { isElectron } from '../utils/electronHelper';
const router = useRouter();
const authStore = useAuthStore();
@ -46,11 +47,14 @@ function handleStartChat(contact) {
}
onMounted(async () => {
window.api.window.setMainSize(900, 670)
if (isElectron()) {
window.api.window.setMainSize(900, 670)
window.api.window.show()
}
const { useSignalRStore } = await import('../stores/signalr');
const authStore = useAuthStore();
const signalRStore = useSignalRStore();
if(authStore.token){
if (authStore.token) {
signalRStore.initSignalR();
}
})
@ -66,7 +70,7 @@ onMounted(async () => {
background: #fff;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 12px 48px rgba(0,0,0,0.1);
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.1);
}
/* 导航栏 */
@ -90,8 +94,11 @@ onMounted(async () => {
-webkit-app-region: no-drag;
}
.user-self { margin-bottom: 10px;/* 允许拖动整个窗口 */
-webkit-app-region:no-drag; }
.user-self {
margin-bottom: 10px;
/* 允许拖动整个窗口 */
-webkit-app-region: no-drag;
}
/* 2. 列表区修复 */
.list-panel {
@ -107,6 +114,7 @@ onMounted(async () => {
.search-section {
padding: 20px 12px 10px 12px;
}
.search-box {
display: flex;
align-items: center;
@ -115,7 +123,12 @@ onMounted(async () => {
border-radius: 4px;
gap: 5px;
}
.search-icon { font-size: 12px; color: #666; }
.search-icon {
font-size: 12px;
color: #666;
}
.search-box input {
background: transparent;
border: none;
@ -124,7 +137,10 @@ onMounted(async () => {
width: 100%;
}
.scroll-area { flex: 1; overflow-y: auto; }
.scroll-area {
flex: 1;
overflow-y: auto;
}
/* 3. 聊天主面板修复 */
.chat-panel {
@ -159,7 +175,9 @@ onMounted(async () => {
}
/* 别人发的:默认靠左 */
.msg.other { flex-direction: row; }
.msg.other {
flex-direction: row;
}
/* 本人发的:翻转排列方向,靠右显示 */
.msg.mine {
@ -184,11 +202,18 @@ onMounted(async () => {
line-height: 1.6;
word-break: break-all;
position: relative;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.other .bubble { background: #fff; color: #333; }
.mine .bubble { background: #95ec69; color: #000; }
.other .bubble {
background: #fff;
color: #333;
}
.mine .bubble {
background: #95ec69;
color: #000;
}
.msg-time {
font-size: 11px;
@ -197,11 +222,26 @@ onMounted(async () => {
}
/* 头像样式统一 */
:deep(.avatar-std) { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; }
.avatar-chat { width: 38px; height: 38px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
:deep(.avatar-std) {
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: cover;
}
.avatar-chat {
width: 38px;
height: 38px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
}
/* 未读气泡 */
.avatar-container { position: relative; }
.avatar-container {
position: relative;
}
.unread-badge {
position: absolute;
top: -5px;
@ -229,8 +269,20 @@ onMounted(async () => {
flex-direction: column;
}
.toolbar { display: flex; gap: 12px; margin-bottom: 5px; font-size: 20px; color: #666; }
.toolbar button { background: none; border: none; cursor: pointer; opacity: 0.7; }
.toolbar {
display: flex;
gap: 12px;
margin-bottom: 5px;
font-size: 20px;
color: #666;
}
.toolbar button {
background: none;
border: none;
cursor: pointer;
opacity: 0.7;
}
textarea {
flex: 1;
@ -242,7 +294,11 @@ textarea {
padding: 5px 0;
}
.send-row { display: flex; justify-content: flex-end; }
.send-row {
display: flex;
justify-content: flex-end;
}
.send-btn {
background: #f5f5f5;
color: #07c160;
@ -252,18 +308,67 @@ textarea {
cursor: pointer;
font-size: 13px;
}
.send-btn:hover { background: #e2e2e2; }
.send-btn:hover {
background: #e2e2e2;
}
/* 列表美化 */
.list-item { display: flex; padding: 12px; gap: 12px; cursor: pointer; }
.list-item.active { background: #c6c6c6; }
.list-item:hover:not(.active) { background: #ddd; }
.info { flex: 1; overflow: hidden; }
.name-row { display: flex; justify-content: space-between; align-items: center; }
.name { font-size: 14px; font-weight: 500; }
.time { font-size: 11px; color: #999; }
.last-msg { font-size: 12px; color: #888; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.list-item {
display: flex;
padding: 12px;
gap: 12px;
cursor: pointer;
}
.nav-item { font-size: 24px; cursor: pointer; opacity: 0.5; background-color: #fff; border-radius: 5px; text-decoration: none;}
.nav-item.active { opacity: 1; }
.list-item.active {
background: #c6c6c6;
}
.list-item:hover:not(.active) {
background: #ddd;
}
.info {
flex: 1;
overflow: hidden;
}
.name-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.name {
font-size: 14px;
font-weight: 500;
}
.time {
font-size: 11px;
color: #999;
}
.last-msg {
font-size: 12px;
color: #888;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-item {
font-size: 24px;
cursor: pointer;
opacity: 0.5;
background-color: #fff;
border-radius: 5px;
text-decoration: none;
}
.nav-item.active {
opacity: 1;
}
</style>

View File

@ -2,7 +2,7 @@
<div class="login-container">
<div class="login-box">
<WindowControls :resizable="false"/>
<WindowControls :resizable="false" />
<div class="window-header">
<div class="brand">
<span class="logo-dot"></span>
@ -20,19 +20,9 @@
<form @submit.prevent="handleLogin" class="form-body">
<div class="input-area">
<input
type="text"
v-model="form.username"
placeholder="账号/邮箱"
class="classic-input"
/>
<input type="text" v-model="form.username" placeholder="账号/邮箱" class="classic-input" />
<div class="divider"></div>
<input
type="password"
v-model="form.password"
placeholder="密码"
class="classic-input"
/>
<input type="password" v-model="form.password" placeholder="密码" class="classic-input" />
</div>
<div class="options">
@ -67,6 +57,7 @@ import useVuelidate from '@vuelidate/core'
import { useAuthStore } from '@/stores/auth'
import { useSignalRStore } from '@/stores/signalr'
import WindowControls from '../../components/WindowControls.vue'
import { isElectron } from '../../utils/electronHelper'
const message = useMessage();
const router = useRouter();
@ -80,48 +71,52 @@ const form = reactive({
})
const rules = {
username:{
required:helpers.withMessage('用户名不能为空', required),
maxLength:helpers.withMessage('用户名最大20字符', maxLength(20))
username: {
required: helpers.withMessage('用户名不能为空', required),
maxLength: helpers.withMessage('用户名最大20字符', maxLength(20))
},
password:{
required:helpers.withMessage('密码不能为空', required),
maxLength:helpers.withMessage('密码最大50字符', maxLength(50))
password: {
required: helpers.withMessage('密码不能为空', required),
maxLength: helpers.withMessage('密码最大50字符', maxLength(50))
}
};
const v$ = useVuelidate(rules,form);
const v$ = useVuelidate(rules, form);
const handleLogin = async () => {
const isFormCorrect = await v$.value.$validate()
if (!isFormCorrect) {
if (v$.value.$errors.length > 0) {
message.error(v$.value.$errors[0].$message)
}
return
if (v$.value.$errors.length > 0) {
message.error(v$.value.$errors[0].$message)
}
return
}
try{
try {
loading.value = true;
const res = await authService.login(form);
if(res.code === 0){ // Assuming 0 is success
if (res.code === 0) { // Assuming 0 is success
message.success('登录成功')
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo);
signalRStore.initSignalR();
router.push('/messages')
}else{
} else {
message.error(res.message || '登录失败')
}
} catch (e) {
console.error(e)
} finally{
} finally {
loading.value = false;
}
}
onMounted(() => {
window.api.window.setMainSize(360, 532, false)
if (isElectron()) {
window.api.window.setMainSize(360, 532, false)
window.api.window.show()
}
feather.replace()
})
</script>
@ -140,29 +135,37 @@ onMounted(() => {
/* 2. 核心登录框:双端适配的关键 */
.login-box {
width: 100%;
max-width: 420px; /* Web 端限制最大宽度 */
max-width: 420px;
/* Web 端限制最大宽度 */
height: 100%;
max-height: 540px; /* Web 端限制最大高度 */
max-height: 540px;
/* Web 端限制最大高度 */
background: #ffffff;
display: flex;
flex-direction: column;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.05); /* Web 端浮动感 */
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.05);
/* Web 端浮动感 */
transition: all 0.3s ease;
}
/* 3. 桌面端Electron适配当窗口较小时即客户端模式 */
@media (max-width: 480px) or (max-height: 580px) {
.login-container {
background-color: #fff; /* 客户端通常不需要容器背景 */
background-color: #fff;
/* 客户端通常不需要容器背景 */
}
.login-box {
max-width: 100%;
max-height: 100%;
box-shadow: none; /* 客户端全屏模式不需要投影 */
box-shadow: none;
/* 客户端全屏模式不需要投影 */
border-radius: 0;
}
.window-header {
display: flex; /* 客户端显示拖拽区 */
display: flex;
/* 客户端显示拖拽区 */
}
}
@ -170,7 +173,8 @@ onMounted(() => {
.window-header {
height: 40px;
padding: 0 16px;
display: none; /* Web 端默认隐藏 */
display: none;
/* Web 端默认隐藏 */
align-items: center;
-webkit-app-region: drag;
}
@ -208,7 +212,8 @@ onMounted(() => {
height: 72px;
background: #f0f7ff;
color: #0099ff;
border-radius: 20px; /* 稍微方圆一点更现代 */
border-radius: 20px;
/* 稍微方圆一点更现代 */
display: inline-flex;
align-items: center;
justify-content: center;