Files
front-pc/pages/chat-page/index.vue

658 lines
16 KiB
Vue

<template>
<div>
<el-dialog v-model="dialogVisible" lock-scroll :title="chatTitle" width="900px" :before-close="handleClose" destroy-on-close>
<div class="chat-container">
<!-- Messages area -->
<div ref="messageContainer" class="chat-messages">
<div
v-for="(message, index) in messages"
:key="index"
:class="['message-item', message.fromId === userStore.userInfoRes.id ? 'message-right' : 'message-left']"
>
<!-- 左侧消息 -->
<template v-if="message.fromId !== userStore.userInfoRes.id">
<div class="avatar"><el-avatar :size="40" :src="message.fromId === userStore.userInfoRes.id ? userStore.userInfoRes.avatar : userAvatar" /></div>
<div class="message-content left">
<div v-if="message.msgType === 0" class="text whitespace-pre-wrap">{{ message.content }}</div>
<div v-else-if="message.msgType === 1" class="image-container">
<img :src="message.content" alt="Shared image" class="message-image" />
</div>
<div v-else>{{ message.content.split('/').pop() }}</div>
<!-- <div class="time text-left!">{{ message.createTime && formatTime(message.createTime) }}</div> -->
</div>
</template>
<!-- 右侧消息 -->
<template v-else>
<div class="message-content right">
<div v-if="message.msgType === 0" class="text whitespace-pre-wrap">{{ message.content }}</div>
<div v-else-if="message.msgType === 1" class="image-container">
<img :src="message.content" alt="Shared image" class="message-image" />
</div>
<!-- <div class="time color-#fff!">{{ message.createTime && formatTime(message.createTime) }}</div> -->
</div>
<div class="avatar"><el-avatar :size="40" :src="message.fromId === userStore.userInfoRes.id ? userStore.userInfoRes.avatar : userAvatar" /></div>
</template>
</div>
</div>
<!-- Chat sidebar/info panel -->
<div class="chat-sidebar">
<div class="info-section">
<h3>概述</h3>
<p>{{ chatTitle }}</p>
</div>
<div class="info-section">
<h3>主要内容</h3>
<p>{{ chatDescription }}</p>
</div>
<!-- Participants/emoji section -->
<div class="participants-section">
<div class="emoji-grid">
<div v-for="(emoji, index) in emojis" :key="index" class="emoji-item">
<div class="emoji-avatar">
<img :src="emoji.avatar" alt="emoji" />
</div>
<span>{{ emoji.nickname }}</span>
</div>
</div>
<!-- <div class="view-more" @click="showMoreEmojis"> 查看更多 >> </div> -->
</div>
</div>
<!-- Input area -->
<div class="input-container">
<div class="toolbar">
<div ref="emojiButtonRef" class="emoji-button-container">
<button class="emoji-button" @click="toggleEmojiPanel"
><el-icon class="tool-icon emoji-trigger"><Sunrise /></el-icon
></button>
<div v-if="showEmojiPanel" class="emoji-panel">
<div class="emoji-grid-popup">
<div v-for="emoji in commonEmojis" :key="emoji" class="emoji-item-popup" @click="sendEmojiMessage(emoji)">
{{ emoji }}
</div>
</div>
</div>
</div>
<button class="image-button" @click="triggerImageUpload"
><el-icon class="tool-icon"><Picture /></el-icon
></button>
<input ref="imageInputRef" type="file" accept="image/*" style="display: none" @change="handleImageUpload" />
</div>
<div class="input-wrapper">
<textarea
v-model="newMessage"
type="textarea"
:rows="2"
placeholder="输入消息..."
resize="none"
maxlength="255"
@keydown.enter.prevent="(e: any) => (e.shiftKey ? (newMessage += '\n') : sendMessage(0))"
></textarea>
</div>
<button class="send-button" @click="sendMessage(0)">发送</button>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { upload } from '@/api/common/index'
import { Picture, Sunrise } from '@element-plus/icons-vue'
import { getGroupMembers } from '@/api/channel/index'
import { msgType, PageResultMessageRespVO, MemberUserRespDTO } from '@/api/channel/types'
import { ref, onMounted, nextTick, watch, onUnmounted } from 'vue'
// import dayjs from 'dayjs'
import useUserStore from '@/store/user'
const userStore = useUserStore()
const props = defineProps({
channelId: {
type: String,
required: true,
},
chatTitle: {
type: String,
required: true,
},
chatDescription: {
type: String,
required: true,
},
})
// 模拟数据
const userAvatar = 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
// 添加图片上传相关的ref
const imageInputRef = ref<HTMLInputElement | null>(null)
// Chat data with types
const chatTitle = ref<string>(props.chatTitle)
const chatDescription = ref<string>(props.chatDescription)
// Messages data with type
const messages = ref<PageResultMessageRespVO['list']>([])
// Emojis/participants with type
const emojis = ref<MemberUserRespDTO[]>([])
// Refs with types
const newMessage = ref<string>('')
const messageContainer = ref()
const dialogVisible = defineModel<boolean>('isChat', { required: true })
// 表情面板状态
const showEmojiPanel = ref<boolean>(false)
// 常用表情列表
const commonEmojis = ref<string[]>([
'😀',
'😃',
'😄',
'😁',
'😆',
'😅',
'😂',
'🤣',
'😊',
'😇',
'🙂',
'🙃',
'😉',
'😌',
'😍',
'🥰',
'😘',
'😗',
'😙',
'😚',
'😋',
'😛',
'😝',
'😜',
'🤪',
'🤨',
'🧐',
'🤓',
'😎',
'🤩',
'🥳',
'😏',
'😒',
'😞',
'😔',
'😟',
'😕',
'🙁',
'☹️',
'😣',
])
// 添加 ref 用于表情按钮容器
const emojiButtonRef = ref<HTMLElement | null>(null)
// Methods with type annotations
const sendMessage = (msgType: msgType = 0): void => {
if (!newMessage.value.trim()) return
const message = {
msgType: msgType,
content: newMessage.value,
toId: props.channelId, // 对方userId
userId: userStore.userInfoRes.id, // 当前用户id
fromId: userStore.userInfoRes.id, // 当前用户id
createTime: new Date().toISOString(), // 当前时间戳
}
userStore.mqttClient?.publish(`zbjk_message_group/${props.channelId}`, JSON.stringify(message))
messages.value.push(message)
newMessage.value = ''
nextTick(() => {
scrollToBottom()
})
}
const scrollToBottom = (): void => {
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight
}
}
const handleClose = (done: () => void): void => {
// 取消订阅
userStore.mqttClient?.unsubscribe(`zbjk_message_group/${props.channelId}`)
done()
}
// const formatTime = (time: string | number): string => {
// return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
// }
// 修改点击处理函数
const handleClickOutside = (e: MouseEvent): void => {
if (!emojiButtonRef.value) return
// 如果面板没显示,不需要处理
if (!showEmojiPanel.value) return
// 检查点击是否在表情按钮容器外
if (!emojiButtonRef.value.contains(e.target as Node)) {
showEmojiPanel.value = false
}
}
// 修改切换面板函数
const toggleEmojiPanel = (e: MouseEvent): void => {
e.stopPropagation() // 阻止事件冒泡
showEmojiPanel.value = !showEmojiPanel.value
}
// 修改发送表情消息函数
const sendEmojiMessage = (emoji: string): void => {
newMessage.value += emoji
showEmojiPanel.value = false
}
// 触发图片上传
const triggerImageUpload = (): void => {
if (imageInputRef.value) {
imageInputRef.value.click()
}
}
// 处理图片上传
const handleImageUpload = async (event: Event): Promise<void> => {
const target = event.target as HTMLInputElement
const files = target.files
if (files && files.length > 0) {
const file = files[0]
// 检查文件类型 0:文本 1:图片 2:视频 3:文件
const msgType = file.type.startsWith('image/') ? 1 : file.type.startsWith('video/') ? 2 : 3
const formData = new FormData()
formData.append('fieldName', file?.name)
formData.append('file', file as Blob)
const res = await upload('/prod-api/app-api/infra/file/upload', formData)
if (res.code === 0) {
const imageUrl = res.data
if (msgType === 1) {
// 预加载图片
const img = new Image()
img.src = imageUrl
img.onload = () => {
newMessage.value = imageUrl
sendMessage(msgType)
}
} else {
newMessage.value = imageUrl
sendMessage(msgType)
}
}
// 重置文件输入以允许重复选择同一文件
if (imageInputRef.value) {
imageInputRef.value.value = ''
}
}
}
// 获取群组成员
const getGroupMemberlist = async () => {
const res = await getGroupMembers({ channelId: props.channelId })
if (res.code === 0) {
emojis.value = res.data
}
}
// Lifecycle hooks
onMounted(() => {
scrollToBottom()
document.addEventListener('click', handleClickOutside)
userStore.mqttClient?.onMessage((topic: string, message: any) => {
if (topic.indexOf(`zbjk_message_group`) === -1) return
const msg = JSON.parse(message)
if (msg.userId === userStore.userInfoRes.id) return
console.log('接收消息---------', topic, message)
msg.msgType = Number(msg.msgType)
messages.value.push(msg)
})
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
// Watchers
watch(
() => messages.value,
() => {
nextTick(() => {
scrollToBottom()
})
},
{ deep: true }
)
watch(
() => props.channelId,
(val) => {
if (val) {
getGroupMemberlist()
}
},
{ immediate: true }
)
</script>
<style scoped>
::v-deep(.el-dialog__body) {
padding: 0px !important;
overflow-y: hidden;
}
.chat-container {
display: grid;
grid-template-columns: 1fr 300px;
grid-template-rows: 0px 1fr 160px;
grid-template-areas:
'header header'
'messages sidebar'
'input sidebar';
height: 64vh;
background-color: #f0f2f5;
font-family: Arial, sans-serif;
width: 100%;
margin: 0 auto;
}
.chat-messages {
grid-area: messages;
padding: 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 15px;
border-left: 1px solid #eee;
background-color: #fff;
}
.message-item {
display: flex;
margin-bottom: 15px;
align-items: flex-start;
width: 100%;
}
.message-left {
justify-content: flex-start;
}
.message-right {
justify-content: flex-end;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #eee;
display: flex;
align-items: center;
justify-content: center;
color: #666;
font-weight: bold;
flex-shrink: 0;
}
.message-content {
max-width: 60%;
padding: 10px;
margin: 0 10px;
position: relative;
}
.message-content.left {
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.message-content.right {
background-color: #1a65ff;
border-radius: 12px;
color: #fff;
}
.nickname {
font-size: 12px;
color: #999;
margin-bottom: 5px;
}
.text {
word-break: break-word;
line-height: 1.4;
font-size: 14px;
}
.time {
font-size: 12px;
color: #999;
margin-top: 5px;
text-align: right;
}
/* 文件消息样式 */
.message-content.file {
background-color: #f8f9fa;
padding: 12px;
border: 1px solid #e9ecef;
}
.chat-sidebar {
grid-area: sidebar;
padding: 20px;
background-color: #f8fafc;
border-left: 1px solid #eee;
border-right: 1px solid #eee;
border-bottom: 1px solid #eee;
overflow-y: auto;
}
.info-section {
margin-bottom: 20px;
}
.info-section h3 {
font-size: 16px;
color: #666;
margin-bottom: 10px;
}
.participants-section {
margin-top: 30px;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.emoji-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.2s;
}
.emoji-item:hover {
transform: scale(1.1);
}
.emoji-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
transition: opacity 0.2s;
}
.emoji-item:hover .emoji-avatar {
opacity: 0.8;
}
.emoji-item span {
font-size: 12px;
color: #888;
margin-top: 5px;
}
.view-more {
text-align: right;
margin-top: 10px;
color: #1976d2;
cursor: pointer;
transition: color 0.2s;
}
.view-more:hover {
color: #1565c0;
text-decoration: underline;
}
.input-container {
grid-area: input;
display: flex;
flex-direction: column;
border-top: 1px solid #eee;
background-color: white;
border-left: 1px solid #eee;
border-bottom: 1px solid #eee;
}
.toolbar {
display: flex;
padding: 10px;
gap: 10px;
}
.toolbar button {
background: none;
border: none;
font-size: 20px;
color: #666;
cursor: pointer;
}
.input-wrapper {
flex: 1;
display: flex;
padding: 0 10px;
}
textarea {
flex: 1;
border: none;
resize: none;
padding: 10px;
font-size: 14px;
outline: none;
}
.send-button {
margin: 10px;
padding: 5px 20px;
background-color: #1a65ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
align-self: flex-end;
}
.emoji-button-container {
position: relative;
}
.emoji-panel {
position: absolute;
bottom: 100%;
left: 0;
background: white;
border: 1px solid #eee;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 10px;
z-index: 1000;
margin-bottom: 5px;
}
.emoji-grid-popup {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 5px;
width: 320px;
}
.emoji-item-popup {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 20px;
transition: transform 0.2s;
}
.emoji-item-popup:hover {
transform: scale(1.2);
background-color: #f5f5f5;
border-radius: 4px;
}
.toolbar button {
background: none;
border: none;
font-size: 20px;
color: #666;
cursor: pointer;
padding: 5px 10px;
border-radius: 4px;
transition: background-color 0.2s;
}
.toolbar button:hover {
background-color: #f5f5f5;
}
.message-content.file {
background-color: #f8f9fa;
padding: 12px;
border: 1px solid #e9ecef;
}
.message-image {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
cursor: pointer;
}
.image-container {
display: flex;
flex-direction: column;
gap: 5px;
}
</style>