Files
fad_oa/ruoyi-ui/src/components/HomeModules/modules/ImChatPanel.vue
2026-05-30 15:32:57 +08:00

271 lines
9.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="im-chat-panel" v-loading="loading">
<div v-if="errorMsg" class="im-error">
<i class="el-icon-warning-outline"></i>
{{ errorMsg }}
<el-button v-if="canRetry" type="text" @click="init">重试</el-button>
</div>
<div v-else class="im-layout">
<!-- 左侧会话列表 -->
<div class="conv-list">
<div v-if="!conversations.length && !loading" class="empty">暂无会话</div>
<div v-for="conv in conversations" :key="conv.conversationID"
class="conv-item" :class="{ active: current && current.conversationID === conv.conversationID }"
@click="openConv(conv)">
<div class="conv-avatar" :style="avatarStyle(conv)">{{ avatarText(conv) }}</div>
<div class="conv-body">
<div class="conv-title">
<span class="conv-name">{{ conv.showName || '未命名' }}</span>
<span class="conv-time">{{ formatTime(conv.latestMsgSendTime) }}</span>
</div>
<div class="conv-snippet">
<span class="snippet">{{ snippet(conv) }}</span>
<span v-if="conv.unreadCount > 0" class="badge">{{ conv.unreadCount > 99 ? '99+' : conv.unreadCount }}</span>
</div>
</div>
</div>
</div>
<!-- 右侧消息 -->
<div class="msg-pane">
<template v-if="current">
<div class="msg-header">{{ current.showName || '未命名' }}</div>
<div class="msg-list" ref="msgListRef">
<div v-for="m in messages" :key="m.clientMsgID"
class="msg-row" :class="{ mine: m.sendID === myUserId }">
<div class="msg-bubble">
<div class="msg-meta">{{ m.senderNickname || m.sendID }} · {{ formatTime(m.sendTime) }}</div>
<div class="msg-text">{{ renderText(m) }}</div>
</div>
</div>
<div v-if="!messages.length" class="empty">无消息</div>
</div>
<div class="msg-input">
<el-input v-model="draft" size="mini" placeholder="输入消息,回车发送"
@keyup.enter.native="send" />
<el-button type="primary" size="mini" :disabled="!draft.trim()" @click="send">发送</el-button>
</div>
</template>
<div v-else class="empty pick-conv">从左侧选择一个会话</div>
</div>
</div>
</div>
</template>
<script>
import { getImCredentials } from '@/api/system/im'
import { im, imBus, MessageType } from '@/utils/imClient'
export default {
name: 'ImChatPanel',
data () {
return {
loading: false,
errorMsg: '',
canRetry: false,
conversations: [],
current: null,
messages: [],
draft: '',
myUserId: ''
}
},
created () {
this.init()
imBus.$on('new-message', this.onNewMessage)
imBus.$on('conv-changed', this.refreshConversations)
imBus.$on('disconnected', () => { this.errorMsg = 'IM 断线,尝试重连…'; this.canRetry = true })
},
beforeDestroy () {
imBus.$off('new-message', this.onNewMessage)
imBus.$off('conv-changed', this.refreshConversations)
},
methods: {
async init () {
this.loading = true
this.errorMsg = ''
try {
const res = await getImCredentials()
if (!res || res.code !== 200 || !res.data) {
this.errorMsg = (res && res.msg) || '获取 IM 凭据失败'
this.canRetry = true
return
}
const cred = res.data
const ok = await im.login(cred)
if (!ok) {
const e = im.lastError
const detail = e && (e.errMsg || e.message || JSON.stringify(e))
this.errorMsg = '登录 IM 失败:' + (detail || '未知原因(查看 Console')
this.canRetry = true
return
}
this.myUserId = cred.imUserId
await this.refreshConversations()
} catch (e) {
this.errorMsg = e.message || '初始化失败'
this.canRetry = true
} finally {
this.loading = false
}
},
async refreshConversations () {
this.conversations = await im.getConversations(0, 50)
},
async openConv (conv) {
this.current = conv
this.messages = await im.getMessages(conv.conversationID, 30)
this.$nextTick(() => this.scrollBottom())
im.markRead(conv.conversationID)
},
async send () {
if (!this.current || !this.draft.trim()) return
const text = this.draft
this.draft = ''
try {
await im.sendText(this.current, text)
const newMessages = await im.getMessages(this.current.conversationID, 30)
this.messages = newMessages
this.$nextTick(() => this.scrollBottom())
} catch (e) {
this.$message.error('发送失败:' + (e.message || e))
}
},
onNewMessage (msg) {
// 当前会话的消息追加
if (this.current && msg && this.current.conversationID === msg.conversationID) {
this.messages.push(msg)
this.$nextTick(() => this.scrollBottom())
im.markRead(this.current.conversationID)
}
// 异步刷新会话列表(未读、最新消息)
this.refreshConversations()
},
scrollBottom () {
const el = this.$refs.msgListRef
if (el) el.scrollTop = el.scrollHeight
},
snippet (conv) {
const t = conv.latestMsg
if (!t) return ''
try {
const m = JSON.parse(t)
if (m.textElem) return m.textElem.content
if (m.pictureElem) return '[图片]'
if (m.voiceElem) return '[语音]'
if (m.fileElem) return '[文件]'
if (m.contentType === 110) return '[系统通知] ' + (m.customElem && JSON.parse(m.customElem.data || '{}').title || '')
return ''
} catch (e) { return '' }
},
renderText (m) {
if (!m) return ''
if (m.contentType === MessageType.TextMessage) return m.textElem && m.textElem.content
if (m.contentType === MessageType.PictureMessage) return '[图片]'
if (m.contentType === MessageType.VoiceMessage) return '[语音]'
if (m.contentType === MessageType.FileMessage) return '[文件] ' + (m.fileElem && m.fileElem.fileName || '')
if (m.contentType === 110) {
try {
const data = JSON.parse(m.customElem && m.customElem.data || '{}')
return '[系统通知] ' + (data.title || '') + ' · ' + (data.description || '')
} catch (e) { return '[系统通知]' }
}
return '[暂不支持的消息]'
},
avatarText (conv) {
const s = (conv.showName || '?').trim()
return s.charAt(s.length - 1) || '?'
},
avatarStyle (conv) {
const COLORS = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399', '#a06bf3']
const hash = (conv.conversationID || '').split('').reduce((a, c) => a + c.charCodeAt(0), 0)
return { background: COLORS[hash % COLORS.length] }
},
formatTime (t) {
if (!t) return ''
const d = new Date(typeof t === 'number' && t < 1e12 ? t * 1000 : t)
const now = new Date()
if (d.toDateString() === now.toDateString()) {
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0')
}
return (d.getMonth() + 1) + '/' + d.getDate()
}
}
}
</script>
<style lang="scss" scoped>
.im-chat-panel { height: 100%; min-height: 240px; font-size: 12px; }
.im-error {
padding: 24px; text-align: center; color: #f56c6c;
i { font-size: 24px; display: block; margin-bottom: 6px; }
}
.im-layout { display: flex; height: 100%; gap: 8px; }
.conv-list {
flex: 0 0 38%;
border-right: 1px solid #ebeef5;
overflow-y: auto;
padding-right: 4px;
}
.empty { color: #c0c4cc; text-align: center; padding: 24px 0; }
.pick-conv { padding-top: 80px; }
.conv-item {
display: flex; gap: 6px; padding: 6px 4px; cursor: pointer;
border-radius: 4px;
transition: background .15s;
&:hover { background: #f5f7fa; }
&.active { background: #ecf5ff; }
}
.conv-avatar {
flex: 0 0 32px; width: 32px; height: 32px; border-radius: 50%;
color: #fff; font-size: 13px; font-weight: 600;
display: flex; align-items: center; justify-content: center;
}
.conv-body { flex: 1; min-width: 0; }
.conv-title {
display: flex; justify-content: space-between; align-items: center;
.conv-name { font-weight: 600; color: #303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.conv-time { color: #909399; font-size: 10px; flex-shrink: 0; margin-left: 4px; }
}
.conv-snippet {
display: flex; justify-content: space-between; align-items: center;
.snippet { color: #909399; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.badge {
background: #f56c6c; color: #fff;
border-radius: 9px; padding: 0 5px;
font-size: 10px; line-height: 14px; height: 14px; min-width: 14px;
text-align: center; flex-shrink: 0; margin-left: 4px;
}
}
.msg-pane {
flex: 1; display: flex; flex-direction: column; min-width: 0;
}
.msg-header {
padding: 6px 8px; border-bottom: 1px solid #ebeef5;
font-weight: 600; color: #303133;
}
.msg-list {
flex: 1; overflow-y: auto; padding: 8px 4px;
}
.msg-row {
margin-bottom: 6px;
display: flex;
}
.msg-row.mine { justify-content: flex-end; }
.msg-bubble {
max-width: 80%;
background: #f0f0f0;
border-radius: 4px;
padding: 4px 8px;
}
.msg-row.mine .msg-bubble { background: #ecf5ff; }
.msg-meta { color: #909399; font-size: 10px; margin-bottom: 2px; }
.msg-text { color: #303133; word-break: break-word; }
.msg-input {
display: flex; gap: 4px; padding: 6px 4px;
border-top: 1px solid #ebeef5;
}
.msg-input .el-input { flex: 1; }
</style>