UniApp + SignalR + Asp.net Core 做一个聊天IM,含emoji 表情包
功能是模仿Boss 直聘,主要是求职者与招聘者的聊天功能,聊天的时候索要联系方式,加黑名单,举报,标记/置顶这些,跟聊天的功能无关,我也懒得剔除了,主要是实现方式和样式保留好,收藏,以后说不定还要用到,核心还是聊天功能的实现
以上的聊天功能使用了Ubest 框架创建的 UniApp,在线聊天的后台技术使用了SignalR,上代码,首先是 UniApp 端
<route lang="json5" type="page">
{layout: 'default',style: {navigationBarTitleText: '',},
}
</route>
<template><view class="chat"><!-- 顶部标题 --><view class="topTabbar"><!-- 返回按钮 --><wd-buttonclass="back-button"type="text"icon="arrow-left"size="small"@click="goback()"></wd-button><wd-row><wd-col :span="24" style="text-align: center; padding: 0 60rpx">{{ recruiterInfo ? `${recruiterInfo.surname} ${recruiterInfo.givenName}` : '' }}</wd-col></wd-row><wd-row><wd-tabbar v-model="tabbar" @change="handleChange"><wd-tabbar-item:title="$t('chat.message.exchangeContacts')"icon="phone"></wd-tabbar-item><wd-tabbar-item :title="$t('chat.message.pin')" icon="pin"></wd-tabbar-item><wd-tabbar-item :title="$t('chat.message.unsuitable')" icon="close"></wd-tabbar-item><wd-tabbar-item :title="$t('chat.message.report')" icon="warning"></wd-tabbar-item><wd-tabbar-item:title="$t('chat.message.blacklist')"icon="usergroup-clear"></wd-tabbar-item></wd-tabbar></wd-row></view><scroll-view:style="{ height: `calc(100vh + ${2 * inputHeight}rpx)` }"id="scrollview"scroll-y:scroll-top="scrollTop"class="scroll-view"><!-- 聊天主体 --><view id="msglistview" class="chat-body"><!-- 聊天记录 --><view v-for="(item, index) in msgList" :key="item.id"><!-- 系统消息 --><view class="item system" v-if="item.isSystem"><view class="content system">{{ item.content }}</view></view><!-- 自己发的消息 --><view class="item self" v-else-if="item.isSelf"><!-- 文字内容 --><view class="content right">{{ item.content }}</view><!-- 头像 --><wd-imgclass="avatar":src="item.image || '/static/images/default-avatar.png'"width="78rpx"height="78rpx"radius="50%"></wd-img></view><!-- 对方发的消息 --><view class="item Ai" v-else><!-- 头像 --><wd-imgclass="avatar":src="item.image || '/static/images/default-avatar.png'"width="78rpx"height="78rpx"radius="50%"></wd-img><!-- 文字内容 --><view class="content left">{{ item.content }}</view></view></view></view></scroll-view><!-- 底部消息发送栏 --><!-- 用来占位,防止聊天消息被发送框遮挡 --><view class="chat-bottom" :style="{ height: `${inputHeight}rpx` }"><view class="send-msg" :style="{ bottom: `${keyboardHeight - 60}rpx` }"><view class="uni-textarea"><div class="textarea-container"><!-- <button class="embed-btn left-btn" @click="handleEmbedButtonClick"><wd-icon name="chat1" size="22px"></wd-icon></button> --><wd-button type="icon" icon="chat1" @click="handleEmbedButtonClick"></wd-button><textareav-model="chatMsg"maxlength="300"confirm-type="send"@confirm="handleSend":placeholder="$t('chat.message.placeholder')":show-confirm-bar="false":adjust-position="false"@linechange="sendHeight"@focus="focus"@blur="blur"auto-height></textarea><wd-button type="icon" icon="dong" @click="toggleEmojiPicker"></wd-button></div></view><button @click="handleSend" class="send-btn">{{ $t('chat.message.sendBtn') }}</button></view></view><!-- emoji表情选择器 --><view v-if="showEmojiPicker" class="emoji-picker-container" @click.stop="stopPropagation"><scroll-view scroll-y class="emoji-scroll-view"><view class="emoji-category"><view class="emoji-grid"><viewv-for="emoji in emojiList":key="emoji.name"class="emoji-item"@click="selectEmoji(emoji, $event)"><span class="emoji-char">{{ emoji.char }}</span></view></view></view></scroll-view></view><wd-action-sheet:title="$t('chat.commonPhrase.title')"v-model="showCommonPhrase"@close="closeCommonPhrase"><!-- 常用语列表 --><wd-row v-if="!showAddPhraseInput"><wd-col span="24" style="text-align: right; height: 60rpx; padding: 0 15rpx; margin: 0px"><wd-button type="icon" icon="add-circle" @click="showAddPhraseForm"></wd-button></wd-col></wd-row><!-- 常用语列表项 --><wd-rowv-if="!showAddPhraseInput && commonPhrases.length > 0"v-for="(phrase, index) in commonPhrases":key="index"><wd-col span="20" style="padding: 10rpx 15rpx"><view class="phrase-item" @click="selectCommonPhrase(phrase)">{{ phrase.commonText }}</view></wd-col><wd-col span="4" style="padding: 10rpx 5rpx"><wd-buttontype="icon"icon="delete"size="small"@click.stop="deleteCommonPhrase(phrase)"></wd-button></wd-col></wd-row><!-- 空状态提示 --><wd-row v-if="!showAddPhraseInput && commonPhrases.length === 0"><wd-col span="24" style="text-align: center; padding: 30rpx 0"><text style="color: #999">{{ $t('chat.commonPhrase.empty') }}</text></wd-col></wd-row><!-- 添加常用语表单 --><wd-row v-if="showAddPhraseInput"><wd-col span="24" style="padding: 15rpx"><wd-inputv-model="newPhraseInput":placeholder="$t('chat.commonPhrase.addPlaceholder')"></wd-input></wd-col><wd-col span="24" style="text-align: right; padding: 10rpx 15rpx"><wd-buttonsize="small"type="success"@click="showAddPhraseInput = false"style="margin-right: 10px">{{ $t('common.cancel') }}</wd-button><wd-button size="small" type="primary" @click="addCommonPhrase">{{ $t('common.confirm') }}</wd-button></wd-col></wd-row></wd-action-sheet></view>
</template>
<script lang="ts" setup>
import { ref, computed, onUpdated, Ref, onMounted } from 'vue'
import { useUserStore } from '@/store'
import { storeToRefs } from 'pinia'
import { getCommonPhrases, createCommonPhrase, removeCommonPhrase } from '@/api/commonPhrase'
import { useToast } from 'wot-design-uni'
import { i18n } from '@/locale/index'
import * as signalR from '@microsoft/signalr'
import { useMessage } from 'wot-design-uni'
import type { CommonPhraseEntity } from '@/api/commonPhrase.typings'
import { RoleEnum } from '@/typings'// emoji类型定义
interface Emoji {name: stringchar: stringcategory: string
}
const message = useMessage()const tabbar = ref(-1)
// 用户信息
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)// 常用语相关状态
const showCommonPhrase = ref(false)
// 常用语列表应该是CommonPhraseEntity类型的数组
const commonPhrases = ref<CommonPhraseEntity[]>([])
const newPhraseInput = ref('')
const showAddPhraseInput = ref(false)// 打开常用语面板
const handleEmbedButtonClick = () => {showCommonPhrase.value = trueloadCommonPhrases()
}// 关闭常用语面板
const closeommonPhrase = () => {showCommonPhrase.value = falseshowAddPhraseInput.value = false
}// 加载常用语列表
const loadCommonPhrases = async () => {try {const phrases = await getCommonPhrases(userInfo.value?.id)// 确保data始终是数组类型commonPhrases.value = Array.isArray(phrases.data)? phrases.data: phrases.data? [phrases.data]: []} catch (error) {console.error('加载常用语失败:', error)}
}// 选择常用语
const selectCommonPhrase = (phrase: CommonPhraseEntity) => {chatMsg.value += phrase.commonTextshowCommonPhrase.value = false
}// 显示添加常用语输入框
const showAddPhraseForm = () => {showAddPhraseInput.value = truenewPhraseInput.value = ''
}// 添加新常用语
const addCommonPhrase = async () => {if (!newPhraseInput.value.trim()) {return}// 获取当前用户角色const currentRole = Number(userInfo.value?.currentRole) || 0try {// 构造CommonPhraseEntity类型的参数const newPhrase: CommonPhraseEntity = {Id: 0,UserId: userInfo.value?.id || 0,Role: currentRole,CommonText: newPhraseInput.value.trim(),SortOrder: commonPhrases.value.length + 1,LastEditTime: new Date().toISOString(),}await createCommonPhrase(newPhrase)newPhraseInput.value = ''loadCommonPhrases()showAddPhraseInput.value = false} catch (error) {console.error('添加常用语失败:', error)}
}// 删除常用语
const deleteCommonPhrase = async (phrase: CommonPhraseEntity) => {try {await removeCommonPhrase(phrase.id)loadCommonPhrases()} catch (error) {console.error('删除常用语失败:', error)}
}// SignalR连接对象
let hubConnection: signalR.HubConnection | null = null// 连接状态
const connectionState = ref<signalR.HubConnectionState>(signalR.HubConnectionState.Disconnected)// SignalR URL
const hubUrl = `${import.meta.env.VITE_SERVER_BASEURL}/chatHub`// 启动连接
async function startConnection() {// 防止重复连接请求if (connectionState.value === signalR.HubConnectionState.Connecting ||connectionState.value === signalR.HubConnectionState.Connected) {console.log(`Connection already in progress or connected: ${connectionState.value}`)return}connectionState.value = signalR.HubConnectionState.Connecting// 关闭已有的连接if (hubConnection) {console.log('Closing existing connection...')await hubConnection.stop()hubConnection = nullconsole.log('Existing connection closed')}try {// 检查token是否存在const token = uni.getStorageSync('token')if (!token) {console.warn('No authentication token found')toast.show('未找到认证信息,请重新登录')return}// 验证hubUrlif (!hubUrl) {console.error('Hub URL is not defined')toast.show('服务器地址未配置')return}// 创建新的SignalR连接hubConnection = new signalR.HubConnectionBuilder().withUrl(hubUrl, {accessTokenFactory: () => token,skipNegotiation: true,transport: signalR.HttpTransportType.WebSockets,}).withAutomaticReconnect().build()// 接收消息事件hubConnection.on('ReceiveMessage', (senderId, message) => {const newMessage: MessageItem = {id: Date.now().toString(),senderId: senderId.toString(),receiverId: currentUserId.value,content: message,sentTime: new Date().toISOString(),isSelf: senderId.toString() === currentUserId.value.toString(),image: recruiterInfo.value?.image || '',}msgList.value.push(newMessage)// 滚动到底部setTimeout(() => {scrollToBottom().catch((err) =>console.error('Error in ReceiveMessage scrollToBottom:', err),)}, 100)})// 接收交换联系方式请求事件hubConnection.on('ReceiveContactRequest', (senderId, senderInfo) => {// 只有当当前页面是与请求方的聊天界面时才弹出对话框if (receiverId.value && receiverId.value.toString() === senderId.toString()) {message.confirm({msg: i18n.global.t('chat.message.receiveContactRequestMsg', {senderName: senderInfo?.name || '对方',}),title: i18n.global.t('chat.message.receiveContactRequestTitle'),}).then(() => {// 同意交换联系方式sendSocketMessage('ApproveContactRequest', senderId).then((success) => {if (success) {toast.show(i18n.global.t('chat.message.approveContactSuccess'))// 获取对方联系方式if (senderInfo?.contactInfo) {// 这里可以添加显示对方联系方式的逻辑toast.show(`已获取对方联系方式: ${senderInfo.contactInfo}`)}} else {toast.show(i18n.global.t('chat.message.approveContactFailed'))}})}).catch(() => {// 拒绝交换联系方式sendSocketMessage('RejectContactRequest', senderId)toast.show(i18n.global.t('chat.message.rejectContactSuccess'))})}})// 接收同意交换联系方式响应事件hubConnection.on('ContactRequestApproved', (senderId, contactInfo) => {if (receiverId.value && receiverId.value.toString() === senderId.toString()) {// 创建交换联系方式成功系统消息const exchangeSuccessMessage: MessageItem = {id: Date.now().toString(),senderId: 'system',receiverId: '',content: i18n.global.t('chat.message.exchangeContactsSuccess'),sentTime: new Date().toISOString(),isSelf: false,isSystem: true,image: '',}msgList.value.push(exchangeSuccessMessage)if (contactInfo) {// 创建获取联系方式系统消息const contactInfoMessage: MessageItem = {id: Date.now().toString() + '_contact',senderId: 'system',receiverId: '',content: i18n.global.t('chat.message.getContactInfoSuccess', { contactInfo }),sentTime: new Date().toISOString(),isSelf: false,isSystem: true,image: '',}msgList.value.push(contactInfoMessage)}}})// 接收拒绝交换联系方式响应事件hubConnection.on('ContactRequestRejected', (senderId) => {if (receiverId.value && receiverId.value.toString() === senderId.toString()) {// 创建联系方式请求被拒绝系统消息const rejectedMessage: MessageItem = {id: Date.now().toString(),senderId: 'system',receiverId: '',content: i18n.global.t('chat.message.contactRequestRejected'),sentTime: new Date().toISOString(),isSelf: false,isSystem: true,image: '',}msgList.value.push(rejectedMessage)}})// 监听被阻断事件(防骚扰机制)hubConnection.on('Blocked', (message) => {// 创建系统消息const systemMessage: MessageItem = {id: Date.now().toString(),senderId: 'system',receiverId: '',content: i18n.global.t('chat.message.blocked'),sentTime: new Date().toISOString(),isSelf: false,isSystem: true,image: '',}msgList.value.push(systemMessage)// 滚动到底部setTimeout(() => {scrollToBottom().catch((err) => console.error('Error in Blocked scrollToBottom:', err))}, 100)})// 监听黑名单消息阻断事件hubConnection.on('BlacklistMessageBlocked', (type) => {// 根据类型选择不同的国际化提示const content =Number(type) === 1? i18n.global.t('chat.message.youAddedBlacklist'): i18n.global.t('chat.message.otherAddedBlacklist')// 创建系统消息const systemMessage: MessageItem = {id: Date.now().toString(),senderId: 'system',receiverId: '',content,sentTime: new Date().toISOString(),isSelf: false,isSystem: true,image: '',}msgList.value.push(systemMessage)// 滚动到底部setTimeout(() => {scrollToBottom().catch((err) =>console.error('Error in BlacklistMessageBlocked scrollToBottom:', err),)}, 100)})// 监听联系方式请求已发送事件hubConnection.on('ContactRequestAlreadySent', (senderId) => {// 设置标志,表示已接收到此事件contactRequestAlreadySent.value = trueconst contactAlreadySentMessage: MessageItem = {id: Date.now().toString(),senderId: 'system',receiverId: '',content: i18n.global.t('chat.message.contactAlreadyExchanged'),sentTime: new Date().toISOString(),isSelf: false,isSystem: true,image: '',}msgList.value.push(contactAlreadySentMessage)// 滚动到底部setTimeout(() => {scrollToBottom().catch((err) =>console.error('Error in ContactRequestAlreadySent scrollToBottom:', err),)}, 100)})// 监听连接状态变化hubConnection.onreconnecting((error) => {connectionState.value = signalR.HubConnectionState.Reconnecting})hubConnection.onreconnected((connectionId) => {connectionState.value = signalR.HubConnectionState.Connected})hubConnection.onclose((error) => {connectionState.value = signalR.HubConnectionState.Disconnectedif (error) {toast.show(`Connection lost: ${error.message}. Reconnecting...`)}})// 启动连接await hubConnection.start().then(() => console.log('Connected to SignalR Hub')).catch((err) => console.error('Connection failed:', err))connectionState.value = signalR.HubConnectionState.Connected} catch (error) {connectionState.value = signalR.HubConnectionState.Disconnected// 检查网络状态uni.getNetworkType({success: (res) => {console.log('Network type:', res.networkType)},})console.log(`Failed to connect: ${error instanceof Error ? error.message : String(error)}`)}
}// 发送SignalR消息
async function sendSocketMessage(methodName: string, ...args: any[]) {if (!hubConnection || connectionState.value !== signalR.HubConnectionState.Connected) {console.error(`[${new Date().toISOString()}] Cannot send message: Connection is not in Connected state (current: ${connectionState.value})`,)toast.show(i18n.global.t('chat.message.notConnected'))return false}try {console.log(`[${new Date().toISOString()}] Sending message: ${methodName}`, args)// 捕获服务器返回的结果const result = await hubConnection.invoke(methodName, ...args)console.log(`[${new Date().toISOString()}] Message sent successfully: ${methodName}, result:`,result,)// 返回服务器返回的结果return result} catch (error) {console.error(`[${new Date().toISOString()}] Failed to send message: ${methodName}`, error)toast.show(i18n.global.t('chat.message.sendError'))return false}
}type MessageItem = {id: stringsenderId: stringreceiverId: stringcontent: stringsentTime: stringisSelf: booleanisSystem?: booleanimage: string
}type RecruiterInfo = {id: stringsurname: stringgivenName: stringimage?: string
}// 状态定义
const keyboardHeight = ref(0)
const bottomHeight = ref(0)
const scrollTop = ref(0)
const chatMsg = ref('')
const recruiterInfo = ref<RecruiterInfo | null>(null)
const msgList = ref<MessageItem[]>([])
const isBlocked = ref(false)
const blockedMessage = ref('')
// 用于跟踪是否已接收到"联系方式已交换"的事件
const contactRequestAlreadySent = ref(false)
// 用于控制emoji表情选择器的显示和隐藏
const showEmojiPicker = ref(false)
// emoji列表
const emojiList = ref<Emoji[]>([])const toast = useToast()// 切换emoji表情选择器的显示和隐藏
const toggleEmojiPicker = () => {showEmojiPicker.value = !showEmojiPicker.value// 关闭常用语面板showCommonPhrase.value = false
}// 阻止表情选择器内部点击事件冒泡
const stopPropagation = (event: Event) => {event.stopPropagation()
}// 选择emoji并添加到输入框
const selectEmoji = (emoji: Emoji, event: Event) => {// 阻止事件冒泡,防止触发其他关闭表情面板的逻辑event.stopPropagation()chatMsg.value += emoji.char// 点击表情后关闭表情面板showEmojiPicker.value = false// 滚动到底部setTimeout(() => {scrollToBottom().catch((err) => console.error('Error in selectEmoji scrollToBottom:', err))}, 100)
}// 从API加载emoji数据
const loadEmojiData = async () => {try {const response = await fetch('https://unpkg.com/emoji.json@16.0.0/emoji.json')const data = await response.json()// 打印第一个item查看数据结构console.log('API返回的emoji数据结构:', data[0])// 处理emoji数据,转换为我们需要的格式emojiList.value = data.slice(0, 100).map((item: any) => ({name: item.slug || item.name || 'emoji',char: item.character || item.char || '',category: item.category || 'unknown',})).filter((emoji: Emoji) => emoji.char)console.log('处理后的emoji列表:', emojiList.value)} catch (error) {console.error('加载emoji数据失败:', error)// 添加一些默认emoji作为备用emojiList.value = [{ name: 'smile', char: '😊', category: 'face' },{ name: 'heart', char: '❤️', category: 'heart' },{ name: 'thumbsup', char: '👍', category: 'hand' },{ name: 'laugh', char: '😂', category: 'face' },{ name: 'love', char: '😍', category: 'face' },{ name: 'clap', char: '👏', category: 'hand' },]}
}// 在组件挂载时加载emoji数据
onMounted(() => {loadEmojiData()
})// 当前用户ID (从本地存储获取,实际应用中应从身份验证系统获取)
const currentUserId = ref('')// 从本地存储获取用户ID
function getCurrentUserId() {try {const userInfo = uni.getStorageSync('userInfo')if (userInfo) {currentUserId.value = userInfo.id || ''}} catch (e) {console.error('Error getting user info:', e)}
}// 对方用户ID
const receiverId = ref('')// 计算属性
const windowHeight = computed(() => {return rpxTopx(uni.getSystemInfoSync().windowHeight)
})const inputHeight = computed(() => {return bottomHeight.value + keyboardHeight.value
})// Define the keyboard height change handler
const keyboardHeightChangeHandler = (res: any) => {keyboardHeight.value = rpxTopx(res.height)if (keyboardHeight.value < 0) keyboardHeight.value = 0
}// 生命周期
onLoad(() => {// 获取当前用户IDgetCurrentUserId()// 获取页面参数const pages = getCurrentPages()const currentPage = pages[pages.length - 1]const options = currentPage.options// 解析传递过来的招聘者信息if (options && options.recruiterInfo) {try {const decodedInfo = decodeURIComponent(options.recruiterInfo)recruiterInfo.value = JSON.parse(decodedInfo)receiverId.value = recruiterInfo.value?.id || ''} catch (error) {console.error('Error parsing recruiterInfo:', error)}} else {console.error('No recruiterInfo in options')toast.show('未找到招聘者信息')}// 初始化消息列表initMsgList()// 启动 WebSocket 连接startConnection()// 重新连接事件监听function onReconnecting() {console.log('WebSocket reconnecting...')connectionState.value = signalR.HubConnectionState.Reconnectingtoast.show('Reconnecting to server...')}// 重新连接成功事件监听 (模拟)function onReconnected() {console.log('WebSocket reconnected')connectionState.value = signalR.HubConnectionState.Connectedtoast.show('Reconnected to server.')}// Register the event listeneruni.onKeyboardHeightChange(keyboardHeightChangeHandler)
})onUnload(async () => {if (hubConnection) {try {await hubConnection.stop()hubConnection = nullconsole.log('Disconnected from SignalR server')} catch (error) {console.error('Error closing SignalR connection:', error)}}// Unregister the keyboard height change handleruni.offKeyboardHeightChange(keyboardHeightChangeHandler)
})onUpdated(() => {// 正确处理异步函数scrollToBottom().catch((err) => console.error('Error in onUpdated scrollToBottom:', err))
})// 引入用户API
import { getUserInfoById } from '@/api/login'
// 引入联系人关系API
import { addToBlacklist, markAsNotSuitable, togglePinOrFollow } from '@/api/contactRelationship'// 验证接收者ID是否存在
async function verifyReceiverId(receiverId: string) {console.log(`=== Verifying receiverId: ${receiverId} ===`)try {// 使用现有API检查用户是否存在const userInfo = await getUserInfoById(parseInt(receiverId))const exists = !!userInfoconsole.log(`Receiver verification result: ${exists ? 'Exists' : 'Does not exist'}`)return exists} catch (error) {console.error(`Failed to verify receiverId: ${receiverId}`, error)return false}
}// 初始化消息列表
function initMsgList() {// 实际应用中应从API获取历史消息msgList.value = []
}// 方法定义
function goback() {uni.switchTab({url: '/pages/tutorship/tutorship',})
}function focus() {// 正确处理异步函数scrollToBottom().catch((err) => console.error('Error in focus scrollToBottom:', err))
}// 滚动到底部
async function scrollToBottom() {console.log('scrollToBottom function called')try {// 等待DOM更新await new Promise((resolve) => setTimeout(resolve, 50))console.log('DOM updated, proceeding with scroll')// 获取滚动元素和内容元素const query = uni.createSelectorQuery().in(getCurrentInstance())query.select('.scroll-view').boundingClientRect()query.select('.chat-body').boundingClientRect()const res = await query.exec()if (res && res[0] && res[1]) {console.log('Scrolling to bottom with values:', res[1].height, res[0].height)// 直接使用像素值,不进行rpx转换scrollTop.value = res[1].height - res[0].height + 40 // 增加偏移量到40像素console.log('Set scrollTop to:', scrollTop.value)} else {console.warn('Could not get element dimensions for scrolling')}} catch (err) {console.error('Error scrolling to bottom:', err)}
}function blur() {scrollToBottom()
}// px转换成rpx
function rpxTopx(px: number): number {const deviceWidth = uni.getSystemInfoSync().windowWidthconst rpx = (750 / deviceWidth) * Number(px)return Math.floor(rpx)
}// 监视聊天发送栏高度
function sendHeight() {setTimeout(() => {const query = uni.createSelectorQuery()query.select('.send-msg').boundingClientRect()query.exec((res) => {if (res && res[0]) {bottomHeight.value = rpxTopx(res[0].height)}})}, 10)
}// 发送消息
async function handleSend() {//如果消息不为空if (chatMsg.value && !/^\s+$/.test(chatMsg.value)) {if (!receiverId.value) {toast.show(i18n.global.t('chat.message.selectReceiver'))return}// 检查连接状态const isConnected = connectionState.value === signalR.HubConnectionState.Connectedif (!isConnected) {if (connectionState.value === signalR.HubConnectionState.Connecting) {toast.show('Connecting to server. Please wait...')} else {toast.show('Connection not established. Reconnecting...')// 尝试重新连接startConnection()}return}try {// 发送消息到服务器const success = await sendSocketMessage('SendPrivateMessage',receiverId.value.toString(),chatMsg.value,)console.log(`success:`, success)if (success) {// 创建新消息并添加到列表const newMessage: MessageItem = {id: Date.now().toString(),senderId: currentUserId.value,receiverId: receiverId.value,content: chatMsg.value,sentTime: new Date().toISOString(),isSelf: true,image: userInfo.value?.image || '/static/images/default-avatar.png',}msgList.value.push(newMessage)// 清空输入框chatMsg.value = ''// 滚动到底部setTimeout(() => {scrollToBottom().catch((err) => console.error('Error in handleSend scrollToBottom:', err))}, 100)} else {//toast.show('Failed to send message. Please try again.')}} catch (err) {console.error('Error sending message:', err)//toast.show('Failed to send message. Please try again.')}} else {toast.show(i18n.global.t('chat.message.emptyError'))}
}// 回复消息
function handleReply(senderId: string) {receiverId.value = senderIdblockedMessage.value = ''// 聚焦到输入框const textarea = uni.createSelectorQuery().select('textarea')textarea.focus()
}function handleChange({ value }: { value: string }) {const tabIndex = parseInt(value)switch (tabIndex) {case 0:// 交换联系方式handleExchangeContacts()breakcase 1:// 置顶handlePinConversation()breakcase 2:// 不合适handleMarkUnsuitable()breakcase 3:// 举报handleReport()breakcase 4:// 黑名单handleAddToBlacklist()breakdefault:console.warn(`Unknown tab index: ${tabIndex}`)}
}// 交换联系方式处理函数
function handleExchangeContacts() {// 在发送请求前重置标志contactRequestAlreadySent.value = falsemessage.confirm({msg: i18n.global.t('chat.message.confirmExchangeContacts'),title: i18n.global.t('chat.message.exchangeContactsTitle'),}).then(() => {// 发送交换联系方式请求if (receiverId.value) {sendSocketMessage('ExchangeContactRequest', receiverId.value).then((success) => {if (success) {// 创建交换联系方式请求成功系统消息const requestSuccessMessage: MessageItem = {id: Date.now().toString(),senderId: 'system',receiverId: '',content: i18n.global.t('chat.message.requestSendSuccess'),sentTime: new Date().toISOString(),isSelf: false,isSystem: true,image: '',}msgList.value.push(requestSuccessMessage)} else {// 如果是因为"已交换过联系方式"而失败,则不显示失败消息// 因为ContactRequestAlreadySent事件处理器会显示专门的消息if (!contactRequestAlreadySent.value) {// 创建交换联系方式请求失败系统消息const requestFailedMessage: MessageItem = {id: Date.now().toString(),senderId: 'system',receiverId: '',content: i18n.global.t('chat.message.requestSendFailed'),sentTime: new Date().toISOString(),isSelf: false,isSystem: true,image: '',}msgList.value.push(requestFailedMessage)} else {// 重置标志,以便下一次操作contactRequestAlreadySent.value = false}}})} else {// 创建未找到接收方信息系统消息const noReceiverMessage: MessageItem = {id: Date.now().toString(),senderId: 'system',receiverId: '',content: '未找到接收方信息',sentTime: new Date().toISOString(),isSelf: false,isSystem: true,image: '',}msgList.value.push(noReceiverMessage)}}).catch(() => {})
}// 置顶处理函数
function handlePinConversation() {message.confirm({msg: i18n.global.t('chat.message.confirmPinConversation'),title: i18n.global.t('chat.message.pinConversationTitle'),}).then(() => {if (!currentUserId.value || !receiverId.value) {toast.error(i18n.global.t('chat.message.selectReceiver'))return}// 调用togglePinOrFollow接口togglePinOrFollow(parseInt(currentUserId.value), parseInt(receiverId.value), true).then((result) => {if (result.Success) {toast.show(i18n.global.t('chat.message.pinConversationSuccess'))} else {toast.error(result.Message || i18n.global.t('message.relation.togglePinOrFollowError'))}}).catch(() => {toast.error(i18n.global.t('message.relation.togglePinOrFollowError'))})}).catch(() => {})
}// 标记不合适处理函数
function handleMarkUnsuitable() {message.confirm({msg: i18n.global.t('chat.message.confirmMarkUnsuitable'),title: i18n.global.t('chat.message.markUnsuitableTitle'),}).then(() => {if (!currentUserId.value || !receiverId.value) {toast.error(i18n.global.t('chat.message.selectReceiver'))return}markAsNotSuitable(parseInt(currentUserId.value), parseInt(receiverId.value)).then((result) => {if (result.Success) {toast.show(i18n.global.t('chat.message.markUnsuitableSuccess'))} else {toast.error(result.Message || i18n.global.t('message.relation.markNotSuitableError'))}}).catch(() => {toast.error(i18n.global.t('message.relation.markNotSuitableError'))})}).catch(() => {})
}// 举报处理函数
function handleReport() {message.confirm({msg: i18n.global.t('chat.message.confirmReport'),title: i18n.global.t('chat.message.reportTitle'),}).then(() => {// 跳转到举报页面,并传递用户ID参数uni.navigateTo({url: `/pages/chat/report?currentUserId=${currentUserId.value}&receiverId=${receiverId.value}`,})}).catch(() => {})
}// 添加到黑名单处理函数
function handleAddToBlacklist() {message.confirm({msg: i18n.global.t('chat.message.confirmAddToBlacklist'),title: i18n.global.t('chat.message.addToBlacklistTitle'),}).then(() => {if (!receiverId.value) {toast.show(i18n.global.t('chat.message.selectReceiver'))return}addToBlacklist(parseInt(currentUserId.value), parseInt(receiverId.value)).then((result) => {if (result.Success) {toast.show(i18n.global.t('chat.message.addToBlacklistSuccess'))// 更新黑名单状态blockedMessage.value = i18n.global.t('chat.message.youAddedBlacklist')} else {toast.error(result.Message || i18n.global.t('message.relation.blacklistError'))}}).catch((error) => {console.error('Failed to add to blacklist:', error)toast.error(i18n.global.t('message.relation.blacklistError'))})}).catch(() => {})
}
// 关闭常用语面板
const closeCommonPhrase = () => {showCommonPhrase.value = falseshowAddPhraseInput.value = false
}
</script>
<style lang="scss" scoped>
/* emoji表情选择器样式 */
.emoji-picker-container {position: fixed;bottom: 100rpx;left: 0;right: 0;z-index: 9999;background-color: #fff;border-top: 1px solid #eee;height: 500rpx;
}.emoji-scroll-view {height: 100%;
}.emoji-category {padding: 20rpx;
}.emoji-grid {display: grid;grid-template-columns: repeat(8, 1fr);gap: 10rpx;
}.emoji-item {display: flex;align-items: center;justify-content: center;height: 80rpx;cursor: pointer;
}.emoji-char {font-size: 44rpx;line-height: 1;display: inline-block;
}.emoji-item:active {background-color: #f0f0f0;border-radius: 8rpx;
}.emoji-btn {width: 80rpx;height: 80rpx;display: flex;align-items: center;justify-content: center;background: none;border: none;color: #666;margin-left: 10rpx;
}
.wd-action-sheet__header {height: 50px !important;
}/* 常用语样式 */
.phrase-item {padding: 10rpx;background-color: #f5f5f5;border-radius: 8rpx;font-size: 28rpx;color: #333;
}.phrase-item:hover {background-color: #e6e6e6;
}wd-input {width: 100%;
}wd-button[size='small'] {margin-left: 10rpx;
}
.uni-scroll-view-content {background-color: #f6f6f6;
}$chatContentbgc: #c2dcff;
$sendBtnbgc: #4f7df5;view,
button,
text,
input,
textarea {margin: 0;padding: 0;box-sizing: border-box;
}/* 聊天消息 */
.chat {height: 100%;.topTabbar {width: 100%;height: auto;min-height: 90rpx;display: flex;flex-direction: column;position: fixed;top: 0;left: 0;background-color: #fff;z-index: 999;padding: 0 20rpx;.back-button {position: absolute;left: 0rpx;top: 0rpx;padding: 10rpx;z-index: 1000;}.back-button:active {background-color: rgba(0, 0, 0, 0.1);border-radius: 50%;}}.scroll-view {// 移动背景颜色声明到嵌套规则之前background-color: #f6f6f6;margin-top: 90rpx; // 为顶部导航栏腾出空间padding-right: 20rpx; // 增加右侧padding到20rpx,避免内容被滚动条遮挡// 只在聊天记录区域显示滚动条::-webkit-scrollbar {width: 6rpx;height: 0 !important;-webkit-appearance: none;background: transparent;}::-webkit-scrollbar-thumb {border-radius: 3rpx;background-color: rgba(0, 0, 0, 0.2);}// background-color: orange;.chat-body {display: flex;flex-direction: column;padding-top: 23rpx;padding-bottom: 100rpx;// background-color:skyblue;.self {justify-content: flex-end;}.item {display: flex;padding: 23rpx 30rpx;// background-color: greenyellow;&.system {justify-content: center;}.right {background-color: $chatContentbgc;}.left {background-color: #ffffff;}.system {background-color: #f0f0f0;color: #666666;text-align: center;}// 聊天消息的三角形.right::after {position: absolute;display: inline-block;content: '';width: 0;height: 0;left: 100%;top: 10px;border: 12rpx solid transparent;border-left: 12rpx solid $chatContentbgc;}.left::after {position: absolute;display: inline-block;content: '';width: 0;height: 0;top: 10px;right: 100%;border: 12rpx solid transparent;border-right: 12rpx solid #ffffff;}.content {position: relative;max-width: 486rpx;border-radius: 8rpx;word-wrap: break-word;padding: 24rpx 24rpx;margin: 0 24rpx;border-radius: 5px;font-size: 32rpx;font-family: PingFang SC;font-weight: 500;color: #333333;line-height: 42rpx;}.reply-btn {margin-top: 8rpx;font-size: 24rpx;color: #4f7df5;text-align: right;padding-right: 8rpx;}.avatar {display: flex;justify-content: center;width: 78rpx;height: 78rpx;background: $sendBtnbgc;border-radius: 50rpx;overflow: hidden;image {align-self: center;}}}}}/* 底部聊天发送栏 */.chat-bottom {width: 100%;height: 100rpx;background: #f4f5f7;transition: all 0.1s ease;position: fixed;bottom: 0;left: 0;z-index: 1;.send-msg {display: flex;align-items: center;padding: 16rpx 30rpx;width: 100%;min-height: 100rpx;background: #fff;transition: all 0.1s ease;z-index: 1;}.uni-textarea {padding-bottom: 0rpx;.textarea-container {display: flex;align-items: center;}.embed-btn.left-btn {margin-right: 10rpx;width: 120rpx;height: 60rpx;background: #f1f1f1;border-radius: 30rpx;font-size: 24rpx;color: #333333;display: flex;align-items: center;justify-content: center;border: none;}textarea {width: 417rpx;min-height: 60rpx;max-height: 500rpx;background: #f1f1f1;border-radius: 30rpx;font-size: 28rpx;font-family: PingFang SC;color: #333333;line-height: 60rpx;padding: 5rpx 8rpx;text-indent: 30rpx;}}.send-btn {display: flex;align-items: center;justify-content: center;margin-bottom: 0rpx;margin-left: 25rpx;width: 120rpx;height: 60rpx;background: #ed5a65;border-radius: 30rpx;font-size: 24rpx;font-family: PingFang SC;font-weight: 700;color: #ffffff;line-height: 24rpx;z-index: 1;outline: 2rpx solid #ffffff;box-shadow: 0 2rpx 10rpx rgba(237, 90, 101, 0.5);}}
}
</style>
Asp.net Core 部分,自然是创建一个 SignalR 的一个 Hub
using CacheManager.Core;
using FreeWorking.Business.App;
using FreeWorking.Domain;
using FreeWorking.Domain.Attribute;
using FreeWorking.Domain.Entities.App;
using FreeWorking.Domain.Enums;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;namespace FreeWorking.App.Api.SignalR
{[AuthorizeRoles(RoleEnum.JobSeeker, RoleEnum.Recruiter)]public class ChatHub : Hub{private readonly ChatMessageService _chatMessageService;private readonly ContactRequestService _contactRequestService;private readonly IFreeWorkingDatabase _database;private readonly ICacheManager<object> _cache;private readonly ContactedRelationshipService _contactedRelationshipService;private readonly NotContactedRelationshipService _notContactedRelationshipService;public ChatHub(ChatMessageService chatMessageService, ContactRequestService contactRequestService, IFreeWorkingDatabase database, ICacheManager<object> cache, ContactedRelationshipService contactedRelationshipService, NotContactedRelationshipService notContactedRelationshipService){_chatMessageService = chatMessageService;_contactRequestService = contactRequestService;_database = database;_cache = cache;_contactedRelationshipService = contactedRelationshipService;_notContactedRelationshipService = notContactedRelationshipService;OnlineUsers.Initialize(cache); // 初始化在线用户管理的缓存}// 发送交换联系方式请求public async Task<bool> ExchangeContactRequest(long receiverId){var senderId = Context.UserIdentifier!;var findSender = await _database.Set<UserEntity>().FirstOrDefaultAsync(u => u.Id == receiverId);var senderRole = findSender!.CurrentRole;// 获取用户信息var senderUser = await _database.Set<UserEntity>().FirstOrDefaultAsync(u => u.Id.ToString() == senderId);var receiverUser = await _database.Set<UserEntity>().FirstOrDefaultAsync(u => u.Id == receiverId);// 检查黑名单关系if (!await CheckBlacklistRelationship(senderUser, receiverUser)){return false;}// 检查是否已经发送过请求var existingRequest = await _contactRequestService.SingleExpressAsync(t =>((t.RecruiterId.ToString() == senderId && t.SeekerId == receiverId) ||(t.RecruiterId == receiverId && t.SeekerId.ToString() == senderId)) &&t.ApprovalStatus == ApprovalStatusEnum.Pending &&t.RequestItem == RequestItemTypeEnum.ContactInfo);if (existingRequest != null){await Clients.Caller.SendAsync("ContactRequestAlreadySent");return false;}// 创建新的联系方式请求var contactRequest = new ContactRequestEntity{RecruiterId = senderRole == RoleEnum.Recruiter ? long.Parse(senderId) : receiverId,SeekerId = senderRole == RoleEnum.JobSeeker ? long.Parse(senderId) : receiverId,RequestItem = RequestItemTypeEnum.ContactInfo,InitiatorRole = (RoleEnum)senderRole!,RequestTime = DateTime.UtcNow,ApprovalStatus = ApprovalStatusEnum.Pending};await _contactRequestService.CreateAsync(contactRequest);// 检查接收方是否在线if (OnlineUsers.IsUserOnline(receiverId.ToString())){await Clients.User(receiverId.ToString()).SendAsync("ReceiveContactRequest", senderId);}return true;}// 同意交换联系方式请求public async Task ApproveContactRequest(string senderId){var receiverId = Context.UserIdentifier!;//var receiverRole = Context.User.FindFirst(ClaimTypes.Role)?.Value == RoleEnum.Recruiter.ToString() ? RoleEnum.Recruiter : RoleEnum.JobSeeker;// 查找对应的请求var contactRequest = await _contactRequestService.SingleExpressAsync(t =>((t.RecruiterId.ToString() == senderId && t.SeekerId.ToString() == receiverId) ||(t.RecruiterId.ToString() == receiverId && t.SeekerId.ToString() == senderId)) &&t.ApprovalStatus == ApprovalStatusEnum.Pending &&t.RequestItem == RequestItemTypeEnum.ContactInfo);if (contactRequest == null){return;}// 更新请求状态contactRequest.ApprovalStatus = ApprovalStatusEnum.Approved;contactRequest.ProcessTime = DateTime.UtcNow;await _contactRequestService.UpdateAsync(contactRequest);// 获取双方联系方式var receiverInfo = await GetUserContactInfo(receiverId);var senderInfo = await GetUserContactInfo(senderId);// 通知发送方请求已被同意,并发送接收方的联系方式if (OnlineUsers.IsUserOnline(senderId)){await Clients.User(senderId).SendAsync("ContactRequestApproved", receiverId, receiverInfo);}// 通知接收方(当前用户),并发送发送方的联系方式await Clients.Caller.SendAsync("ContactRequestApproved", senderId, senderInfo);// 更新双方关系状态为已交换联系方式long jobSeekerId = contactRequest.SeekerId;long employerId = contactRequest.RecruiterId;await _contactedRelationshipService.UpdateRelationshipStatusToContactExchangedAsync(jobSeekerId, employerId);}// 拒绝交换联系方式请求public async Task RejectContactRequest(string senderId){var receiverId = Context.UserIdentifier!;// 查找对应的请求var contactRequest = await _contactRequestService.SingleExpressAsync(t =>((t.RecruiterId.ToString() == senderId && t.SeekerId.ToString() == receiverId) ||(t.RecruiterId.ToString() == receiverId && t.SeekerId.ToString() == senderId)) &&t.ApprovalStatus == ApprovalStatusEnum.Pending &&t.RequestItem == RequestItemTypeEnum.ContactInfo);if (contactRequest == null){return;}// 更新请求状态contactRequest.ApprovalStatus = ApprovalStatusEnum.Rejected;contactRequest.ProcessTime = DateTime.UtcNow;await _contactRequestService.UpdateAsync(contactRequest);// 通知发送方请求已被拒绝if (OnlineUsers.IsUserOnline(senderId)){await Clients.User(senderId).SendAsync("ContactRequestRejected", receiverId);}}// 获取用户联系方式信息private async Task<string> GetUserContactInfo(string userId){// 从数据库中获取用户信息var user = await _database.Set<UserEntity>().FirstOrDefaultAsync(u => u.Id.ToString() == userId);if (user == null){return "未找到用户信息";}// 构建联系方式信息var contactInfo = new List<string>();if (!string.IsNullOrEmpty(user.PhoneNumber)){contactInfo.Add($"电话: {user.PhoneNumber}");}if (!string.IsNullOrEmpty(user.Email)){contactInfo.Add($"邮箱: {user.Email}");}// 如果没有联系方式,返回默认消息return contactInfo.Any() ? string.Join(",", contactInfo) : "未设置联系方式";}// 发送私聊消息(含防骚扰机制和黑名单检查)public async Task<bool> SendPrivateMessage(string receiverId, string message){var senderId = Context.UserIdentifier!;var sessionKey = $"{senderId}_{receiverId}";// 获取用户信息var senderUser = await _database.Set<UserEntity>().FirstOrDefaultAsync(u => u.Id.ToString() == senderId);var receiverUser = await _database.Set<UserEntity>().FirstOrDefaultAsync(u => u.Id.ToString() == receiverId);// 检查黑名单关系if (!await CheckBlacklistRelationship(senderUser, receiverUser)){return false;}// 获取消息计数var (messageSendCount, messageReplyCount) = await GetMessageCounts(senderId, receiverId);// 如果对方已回复,删除未联系关系记录//if (messageReplyCount > 0 && senderUser != null && receiverUser != null)//{// await DeleteNotContactedRelationship(senderUser, receiverUser);//}// 防骚扰检查if (!await CheckAntiHarassment(messageSendCount, messageReplyCount)){return false;}// 保存并发送消息return await SaveAndSendMessage(senderId, receiverId, message, sessionKey);}// 检查黑名单关系private async Task<bool> CheckBlacklistRelationship(UserEntity? senderUser, UserEntity? receiverUser){if (senderUser == null || receiverUser == null){return true; // 用户不存在,不进行检查}// 判断双方角色,确定查询条件if (senderUser.CurrentRole == RoleEnum.JobSeeker && receiverUser.CurrentRole == RoleEnum.Recruiter){// 求职者向招聘者发送消息// 检查未联系关系表var notContactedRelationship = await _database.Set<NotContactedRelationshipEntity>().FirstOrDefaultAsync(r => r.JobSeekerId == senderUser.Id && r.RecruiterId == receiverUser.Id && r.Removed == false);if (notContactedRelationship != null && notContactedRelationship.JobSeekerToRecruiterRelation == NotContactedRelationStatusEnum.Rejected){//您已将对方加入黑名单,无法发送消息await Clients.Caller.SendAsync("BlacklistMessageBlocked", 1);return false;}if (notContactedRelationship != null && notContactedRelationship.RecruiterToJobSeekerRelation == NotContactedRelationStatusEnum.Rejected){//对方已将您加入黑名单,无法发送消息await Clients.Caller.SendAsync("BlacklistMessageBlocked", 2);return false;}// 检查已联系关系表var contactedRelationship = await _database.Set<ContactedRelationshipEntity>().FirstOrDefaultAsync(r => r.JobSeekerId == senderUser.Id && r.EmployerId == receiverUser.Id);if (contactedRelationship != null && contactedRelationship.JobSeekerRelationStatus == ContactedRelationStatusEnum.Blocked){//您已将对方加入黑名单,无法发送消息await Clients.Caller.SendAsync("BlacklistMessageBlocked", 1);return false;}if (contactedRelationship != null && contactedRelationship.EmployerRelationStatus == ContactedRelationStatusEnum.Blocked){//对方已将您加入黑名单,无法发送消息await Clients.Caller.SendAsync("BlacklistMessageBlocked", 2);return false;}}else if (senderUser.CurrentRole == RoleEnum.Recruiter && receiverUser.CurrentRole == RoleEnum.JobSeeker){// 招聘者向求职者发送消息// 检查未联系关系表var notContactedRelationship = await _database.Set<NotContactedRelationshipEntity>().FirstOrDefaultAsync(r => r.RecruiterId == senderUser.Id && r.JobSeekerId == receiverUser.Id && r.Removed == false);if (notContactedRelationship != null && notContactedRelationship.RecruiterToJobSeekerRelation == NotContactedRelationStatusEnum.Rejected){await Clients.Caller.SendAsync("BlacklistMessageBlocked", "您已将对方加入黑名单,无法发送消息");return false;}if (notContactedRelationship != null && notContactedRelationship.JobSeekerToRecruiterRelation == NotContactedRelationStatusEnum.Rejected){await Clients.Caller.SendAsync("BlacklistMessageBlocked", "对方已将您加入黑名单,无法发送消息");return false;}// 检查已联系关系表var contactedRelationship = await _database.Set<ContactedRelationshipEntity>().FirstOrDefaultAsync(r => r.EmployerId == senderUser.Id && r.JobSeekerId == receiverUser.Id);if (contactedRelationship != null && contactedRelationship.EmployerRelationStatus == ContactedRelationStatusEnum.Blocked){await Clients.Caller.SendAsync("BlacklistMessageBlocked", "您已将对方加入黑名单,无法发送消息");return false;}if (contactedRelationship != null && contactedRelationship.JobSeekerRelationStatus == ContactedRelationStatusEnum.Blocked){await Clients.Caller.SendAsync("BlacklistMessageBlocked", "对方已将您加入黑名单,无法发送消息");return false;}}return true;}// 获取消息计数private async Task<(int SendCount, int ReplyCount)> GetMessageCounts(string senderId, string receiverId){var sendCount = await _chatMessageService.CountAsync(t =>t.SenderId == senderId && t.ReceiverId == receiverId);var replyCount = await _chatMessageService.CountAsync(t =>t.SenderId == receiverId && t.ReceiverId == senderId);return (sendCount, replyCount);}// 删除未联系关系记录private async Task DeleteNotContactedRelationship(UserEntity senderUser, UserEntity receiverUser){if (senderUser.CurrentRole == RoleEnum.JobSeeker && receiverUser.CurrentRole == RoleEnum.Recruiter){// 删除求职者与招聘者的未联系关系var relationship = await _database.Set<NotContactedRelationshipEntity>().FirstOrDefaultAsync(r => r.JobSeekerId == senderUser.Id && r.RecruiterId == receiverUser.Id && r.Removed == false);if (relationship != null){await _notContactedRelationshipService.RemoveAsync(relationship);}}else if (senderUser.CurrentRole == RoleEnum.Recruiter && receiverUser.CurrentRole == RoleEnum.JobSeeker){// 删除招聘者与求职者的未联系关系var relationship = await _database.Set<NotContactedRelationshipEntity>().FirstOrDefaultAsync(r => r.RecruiterId == senderUser.Id && r.JobSeekerId == receiverUser.Id && r.Removed == false);if (relationship != null){await _notContactedRelationshipService.RemoveAsync(relationship);}}}// 防骚扰检查private async Task<bool> CheckAntiHarassment(int sendCount, int replyCount){if (sendCount > 0 && replyCount == 0){await Clients.Caller.SendAsync("Blocked", "请等待对方回复后再发送新消息");return false;}return true;}// 保存并发送消息private async Task<bool> SaveAndSendMessage(string senderId, string receiverId, string message, string sessionKey){// 保存消息(含15天过期时间)var chatMessage = new ChatMessageEntity{SenderId = senderId,ReceiverId = receiverId,Content = message,SentTime = DateTime.UtcNow,ExpireTime = DateTime.UtcNow.AddDays(15),IsDelivered = false};await _chatMessageService.CreateAsync(chatMessage);// 标记为等待回复状态_cache.Put(sessionKey, receiverId);// 检查接收方在线状态if (OnlineUsers.IsUserOnline(receiverId)){await Clients.User(receiverId).SendAsync("ReceiveMessage", senderId, message);chatMessage.IsDelivered = true;await _chatMessageService.UpdateAsync(chatMessage);}return true;}// 用户连接时处理离线消息和历史消息public override async Task OnConnectedAsync(){var userId = Context.UserIdentifier!;OnlineUsers.AddUser(userId);// 获取并按时间顺序发送历史消息和未送达消息// 1. 获取所有未送达消息var undeliveredMessages = await _chatMessageService.ListExpressAsync(m =>(m.ReceiverId == userId || m.SenderId == userId) && m.IsDelivered == false);// 2. 获取所有已送达消息(用于按对话分组)var deliveredMessages = await _chatMessageService.ListExpressAsync(m =>(m.ReceiverId == userId || m.SenderId == userId) && m.IsDelivered == true);// 3. 合并所有消息var allMessages = new List<ChatMessageEntity>();if (deliveredMessages != null)allMessages.AddRange(deliveredMessages);if (undeliveredMessages != null)allMessages.AddRange(undeliveredMessages);// 4. 按对话ID分组(对话ID由两个用户ID组成,按字母顺序排序确保一致性)var conversationGroups = allMessages.GroupBy(m =>{var ids = new[] { m.SenderId, m.ReceiverId };Array.Sort(ids);return $"{ids[0]}_{ids[1]}";}).ToList();// 5. 对每个对话的消息按时间顺序排序并发送foreach (var group in conversationGroups){var sortedMessages = group.OrderBy(m => m.SentTime).ToList();// 只发送每个对话的最近30条消息//if (sortedMessages.Count > 30)//{// sortedMessages = sortedMessages.Skip(sortedMessages.Count - 30).ToList();//}foreach (var msg in sortedMessages){await Clients.Caller.SendAsync("ReceiveMessage", msg.SenderId, msg.Content);// 标记未送达消息为已送达if (!msg.IsDelivered){msg.IsDelivered = true;await _chatMessageService.UpdateAsync(msg);}}}await base.OnConnectedAsync();}// 用户断开连接处理public override async Task OnDisconnectedAsync(Exception? exception){var userId = Context.UserIdentifier!;OnlineUsers.RemoveUser(userId);await base.OnDisconnectedAsync(exception);}}// 在线用户管理(使用缓存)public static class OnlineUsers{private static ICacheManager<object> _cache;// 设置缓存管理器public static void Initialize(ICacheManager<object> cache){_cache = cache;}private static string GetUserOnlineKey(string userId) => $"online_user:{userId}";public static void AddUser(string userId){var cacheItem = new CacheItem<object>(GetUserOnlineKey(userId),true,ExpirationMode.Absolute,TimeSpan.FromMinutes(30));_cache.Put(cacheItem); // 设置30分钟过期}public static void RemoveUser(string userId) =>_cache.Remove(GetUserOnlineKey(userId));public static bool IsUserOnline(string userId) =>_cache.Get<bool?>(GetUserOnlineKey(userId)) ?? false;}
}