|
@@ -14,9 +14,12 @@
|
|
|
:class="['message-row', msg.fromUserId === currentUserId ? 'own' : 'other']"
|
|
:class="['message-row', msg.fromUserId === currentUserId ? 'own' : 'other']"
|
|
|
>
|
|
>
|
|
|
<el-avatar :src="msg.fromUserAvatar" :size="32" />
|
|
<el-avatar :src="msg.fromUserAvatar" :size="32" />
|
|
|
- <div class="message-bubble">
|
|
|
|
|
- <p>{{ msg.content }}</p>
|
|
|
|
|
- <el-image v-if="msg.imageUrl" :src="msg.imageUrl" fit="cover" class="msg-image" />
|
|
|
|
|
|
|
+ <div class="message-body">
|
|
|
|
|
+ <div class="message-bubble">
|
|
|
|
|
+ <el-image v-if="msg.imageUrl" :src="msg.imageUrl" fit="cover" class="msg-image" :preview-src-list="[msg.imageUrl]" preview-teleported />
|
|
|
|
|
+ <p v-if="msg.content">{{ msg.content }}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span class="message-time">{{ formatTime(msg.createdAt) }}</span>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
<div v-if="loading" class="loading-more">
|
|
<div v-if="loading" class="loading-more">
|
|
@@ -25,6 +28,16 @@
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div class="input-area">
|
|
<div class="input-area">
|
|
|
|
|
+ <input ref="fileInputRef" type="file" accept="image/*" hidden @change="handleFileChange" />
|
|
|
|
|
+ <div v-if="currentImageUrl" class="image-preview">
|
|
|
|
|
+ <el-image :src="currentImageUrl" fit="cover" class="preview-image" />
|
|
|
|
|
+ <el-button size="small" circle @click="currentImageUrl = ''" class="preview-remove">
|
|
|
|
|
+ <el-icon><Close /></el-icon>
|
|
|
|
|
+ </el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <el-button circle @click="fileInputRef?.click()">
|
|
|
|
|
+ <el-icon><Picture /></el-icon>
|
|
|
|
|
+ </el-button>
|
|
|
<el-input
|
|
<el-input
|
|
|
v-model="text"
|
|
v-model="text"
|
|
|
type="textarea"
|
|
type="textarea"
|
|
@@ -42,8 +55,10 @@
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
import { ref, onMounted, nextTick } from 'vue'
|
|
import { ref, onMounted, nextTick } from 'vue'
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
-import { ArrowLeft, Loading } from '@element-plus/icons-vue'
|
|
|
|
|
|
|
+import { ElMessage } from 'element-plus'
|
|
|
|
|
+import { ArrowLeft, Close, Loading, Picture } from '@element-plus/icons-vue'
|
|
|
import { getMessages } from '../api/endpoints/messages'
|
|
import { getMessages } from '../api/endpoints/messages'
|
|
|
|
|
+import { getAttachment } from '../api/endpoints/attachment'
|
|
|
import { useUserStore } from '../store'
|
|
import { useUserStore } from '../store'
|
|
|
import type { MessageVO } from '../api/models'
|
|
import type { MessageVO } from '../api/models'
|
|
|
|
|
|
|
@@ -55,6 +70,47 @@ const messages = ref<MessageVO[]>([])
|
|
|
const text = ref('')
|
|
const text = ref('')
|
|
|
const loading = ref(false)
|
|
const loading = ref(false)
|
|
|
const partnerName = ref('')
|
|
const partnerName = ref('')
|
|
|
|
|
+const fileInputRef = ref<HTMLInputElement | null>(null)
|
|
|
|
|
+const currentImageUrl = ref('')
|
|
|
|
|
+
|
|
|
|
|
+const formatTime = (iso?: string) => {
|
|
|
|
|
+ if (!iso) return ''
|
|
|
|
|
+ const d = new Date(iso)
|
|
|
|
|
+ const pad = (n: number) => n.toString().padStart(2, '0')
|
|
|
|
|
+ const now = new Date()
|
|
|
|
|
+ const sameYear = d.getFullYear() === now.getFullYear()
|
|
|
|
|
+ if (sameYear && d.getDate() === now.getDate() && d.getMonth() === now.getMonth()) {
|
|
|
|
|
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
|
|
|
+ }
|
|
|
|
|
+ if (sameYear) {
|
|
|
|
|
+ return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
|
|
|
+ }
|
|
|
|
|
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handleFileChange = async (e: Event) => {
|
|
|
|
|
+ const target = e.target as HTMLInputElement
|
|
|
|
|
+ const file = target.files?.[0]
|
|
|
|
|
+ if (!file) return
|
|
|
|
|
+ if (!file.type.startsWith('image/')) {
|
|
|
|
|
+ ElMessage.error('只能上传图片')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (file.size > 10 * 1024 * 1024) {
|
|
|
|
|
+ ElMessage.error('图片大小不能超过 10MB')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await getAttachment().uploadImage({ file })
|
|
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
|
|
+ currentImageUrl.value = res.data
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // 拦截器已展示错误信息
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ target.value = ''
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
const currentUserId = userStore.state.userId
|
|
const currentUserId = userStore.state.userId
|
|
|
const targetUserId = Number(route.params.userId)
|
|
const targetUserId = Number(route.params.userId)
|
|
@@ -87,17 +143,18 @@ const loadMessages = async () => {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const handleSend = async () => {
|
|
const handleSend = async () => {
|
|
|
- if (!text.value.trim()) return
|
|
|
|
|
|
|
+ if (!text.value.trim() && !currentImageUrl.value) return
|
|
|
|
|
+ const content = text.value.trim() || undefined
|
|
|
|
|
+ const imageUrl = currentImageUrl.value || undefined
|
|
|
|
|
+ currentImageUrl.value = ''
|
|
|
|
|
+ text.value = ''
|
|
|
try {
|
|
try {
|
|
|
- const res = await getMessages().sendMessage({ toUserId: targetUserId, content: text.value.trim() })
|
|
|
|
|
|
|
+ const res = await getMessages().sendMessage({ toUserId: targetUserId, content, imageUrl })
|
|
|
if (res.code === 200) {
|
|
if (res.code === 200) {
|
|
|
- messages.value.push({
|
|
|
|
|
- id: res.data,
|
|
|
|
|
- fromUserId: currentUserId,
|
|
|
|
|
- content: text.value.trim(),
|
|
|
|
|
- createdAt: new Date().toISOString(),
|
|
|
|
|
- } as MessageVO)
|
|
|
|
|
- text.value = ''
|
|
|
|
|
|
|
+ const msg: MessageVO = { id: res.data, fromUserId: currentUserId ?? undefined, createdAt: new Date().toISOString() }
|
|
|
|
|
+ if (content) msg.content = content
|
|
|
|
|
+ if (imageUrl) msg.imageUrl = imageUrl
|
|
|
|
|
+ messages.value.push(msg)
|
|
|
await scrollToBottom()
|
|
await scrollToBottom()
|
|
|
}
|
|
}
|
|
|
} catch {
|
|
} catch {
|
|
@@ -141,12 +198,21 @@ onMounted(loadMessages)
|
|
|
.message-row.own {
|
|
.message-row.own {
|
|
|
flex-direction: row-reverse;
|
|
flex-direction: row-reverse;
|
|
|
}
|
|
}
|
|
|
-.message-bubble {
|
|
|
|
|
|
|
+.message-body {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 2px;
|
|
|
max-width: 70%;
|
|
max-width: 70%;
|
|
|
|
|
+}
|
|
|
|
|
+.message-row.own .message-body {
|
|
|
|
|
+ align-items: flex-end;
|
|
|
|
|
+}
|
|
|
|
|
+.message-bubble {
|
|
|
padding: 10px 14px;
|
|
padding: 10px 14px;
|
|
|
border-radius: 12px;
|
|
border-radius: 12px;
|
|
|
font-size: 14px;
|
|
font-size: 14px;
|
|
|
line-height: 1.5;
|
|
line-height: 1.5;
|
|
|
|
|
+ word-break: break-word;
|
|
|
}
|
|
}
|
|
|
.message-row.other .message-bubble {
|
|
.message-row.other .message-bubble {
|
|
|
background: #f0f0f0;
|
|
background: #f0f0f0;
|
|
@@ -157,6 +223,11 @@ onMounted(loadMessages)
|
|
|
color: #fff;
|
|
color: #fff;
|
|
|
border-bottom-right-radius: 4px;
|
|
border-bottom-right-radius: 4px;
|
|
|
}
|
|
}
|
|
|
|
|
+.message-time {
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ color: #c0c4cc;
|
|
|
|
|
+ padding: 0 4px;
|
|
|
|
|
+}
|
|
|
.msg-image {
|
|
.msg-image {
|
|
|
width: 120px;
|
|
width: 120px;
|
|
|
height: 120px;
|
|
height: 120px;
|
|
@@ -178,4 +249,22 @@ onMounted(loadMessages)
|
|
|
.input-area .el-button {
|
|
.input-area .el-button {
|
|
|
align-self: flex-end;
|
|
align-self: flex-end;
|
|
|
}
|
|
}
|
|
|
|
|
+.image-preview {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ align-self: flex-end;
|
|
|
|
|
+}
|
|
|
|
|
+.preview-image {
|
|
|
|
|
+ width: 60px;
|
|
|
|
|
+ height: 60px;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+}
|
|
|
|
|
+.preview-remove {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: -8px;
|
|
|
|
|
+ right: -8px;
|
|
|
|
|
+ padding: 0;
|
|
|
|
|
+ width: 18px;
|
|
|
|
|
+ height: 18px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
</style>
|
|
</style>
|