271 lines
9.5 KiB
Vue
271 lines
9.5 KiB
Vue
|
|
<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>
|