推送项目重构代码

This commit is contained in:
2026-05-30 15:32:57 +08:00
parent 3dafaceef2
commit a28ea44cab
53 changed files with 3525 additions and 731 deletions

View File

@@ -0,0 +1,270 @@
<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>