聊天增加图片功能

This commit is contained in:
yml2213
2026-05-29 12:56:25 +08:00
parent 3b8b65ab56
commit 0f44b024cb
11 changed files with 590 additions and 28 deletions
+1
View File
@@ -11,6 +11,7 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ChatAttachmentImage: typeof import('./src/components/ChatAttachmentImage.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
+10 -4
View File
@@ -74,8 +74,11 @@ export async function fetchChatMessages(id: number, page = 1, pageSize = 100) {
return data.data
}
export async function sendChatMessage(id: number, content: string) {
const { data } = await apiClient.post<ApiResponse<ChatMessage>>(`/chats/${id}/messages`, { content })
export async function sendChatMessage(id: number, content: string, attachmentUrls: string[] = []) {
const { data } = await apiClient.post<ApiResponse<ChatMessage>>(`/chats/${id}/messages`, {
content,
attachment_urls: attachmentUrls,
})
return data.data
}
@@ -103,8 +106,11 @@ export async function fetchAdminChatMessages(id: number, page = 1, pageSize = 10
return data.data
}
export async function sendAdminChatMessage(id: number, content: string) {
const { data } = await apiClient.post<ApiResponse<ChatMessage>>(`/admin/chats/${id}/messages`, { content })
export async function sendAdminChatMessage(id: number, content: string, attachmentUrls: string[] = []) {
const { data } = await apiClient.post<ApiResponse<ChatMessage>>(`/admin/chats/${id}/messages`, {
content,
attachment_urls: attachmentUrls,
})
return data.data
}
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { fetchAdminFileBlob, fetchFileBlobByURL } from '@/api/files'
const props = defineProps<{
source: string
admin?: boolean
}>()
const objectURL = ref('')
const failed = ref(false)
function extractObjectKey(value: string) {
try {
const parsed = new URL(value, window.location.origin)
return parsed.searchParams.get('key') || ''
} catch {
return ''
}
}
function revokeCurrentURL() {
if (!objectURL.value) return
URL.revokeObjectURL(objectURL.value)
objectURL.value = ''
}
async function loadImage() {
revokeCurrentURL()
failed.value = false
if (!props.source) {
failed.value = true
return
}
try {
const key = extractObjectKey(props.source)
const blob = props.admin && key
? await fetchAdminFileBlob(key)
: await fetchFileBlobByURL(props.source)
objectURL.value = URL.createObjectURL(blob)
} catch {
failed.value = true
}
}
function openImage() {
if (!objectURL.value) return
window.open(objectURL.value, '_blank')
}
watch(() => [props.source, props.admin] as const, loadImage, { immediate: true })
onBeforeUnmount(revokeCurrentURL)
</script>
<template>
<button v-if="objectURL" class="chat-image-button" type="button" @click="openImage">
<img :src="objectURL" alt="聊天图片" loading="lazy">
</button>
<span v-else class="chat-image-fallback">{{ failed ? '图片加载失败' : '图片加载中' }}</span>
</template>
<style scoped>
.chat-image-button {
display: block;
max-width: 220px;
padding: 0;
overflow: hidden;
border: 0;
border-radius: 8px;
background: transparent;
cursor: zoom-in;
}
.chat-image-button img {
display: block;
width: 100%;
max-height: 260px;
object-fit: cover;
}
.chat-image-fallback {
display: inline-block;
padding: 8px 10px;
border-radius: 8px;
background: #eef2f7;
color: #6b7280;
font-size: 12px;
}
</style>
+133 -7
View File
@@ -2,7 +2,7 @@
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import { ArrowLeft, Close, Loading, Picture } from '@element-plus/icons-vue'
import {
fetchChat,
fetchChatMessages,
@@ -11,6 +11,8 @@ import {
type ChatConversation,
type ChatMessage,
} from '@/api/chats'
import { uploadFile } from '@/api/files'
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
import { formatDateMinute } from '@/utils/time'
@@ -22,10 +24,14 @@ const conversation = ref<ChatConversation | null>(null)
const messages = ref<ChatMessage[]>([])
const loading = ref(false)
const sending = ref(false)
const uploading = ref(false)
const content = ref('')
const attachments = ref<string[]>([])
const listRef = ref<HTMLElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const conversationID = computed(() => Number(route.params.id || 0))
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
const memberText = computed(() => {
const participants = conversation.value?.participants || []
if (participants.length === 0) return conversation.value?.type === 'general_support' ? '平台客服' : '订单群聊'
@@ -107,12 +113,14 @@ async function loadMessages(scrollToBottom = true) {
async function handleSend() {
const text = content.value.trim()
if (!text || sending.value) return
const imageUrls = [...attachments.value]
if ((!text && imageUrls.length === 0) || sending.value || uploading.value) return
sending.value = true
try {
const sent = await sendChatMessage(conversationID.value, text)
const sent = await sendChatMessage(conversationID.value, text, imageUrls)
appendMessage(sent)
content.value = ''
attachments.value = []
await loadConversation()
} catch {
ElMessage.error('发送失败')
@@ -121,6 +129,45 @@ async function handleSend() {
}
}
function pickImages() {
if (uploading.value || attachments.value.length >= 9) return
fileInputRef.value?.click()
}
async function handleImageChange(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || [])
input.value = ''
if (files.length === 0) return
const slots = 9 - attachments.value.length
if (slots <= 0) {
ElMessage.warning('每条消息最多发送 9 张图片')
return
}
uploading.value = true
try {
for (const file of files.slice(0, slots)) {
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 10 * 1024 * 1024) {
ElMessage.warning(`${file.name} 不符合图片规则`)
continue
}
const uploaded = await uploadFile(file, 'chat')
attachments.value.push(uploaded.url)
}
if (files.length > slots) {
ElMessage.warning('每条消息最多发送 9 张图片')
}
} catch {
ElMessage.error('图片上传失败')
} finally {
uploading.value = false
}
}
function removeAttachment(index: number) {
attachments.value.splice(index, 1)
}
function appendMessage(message: ChatMessage) {
if (messages.value.some(item => item.id === message.id)) return
messages.value = [...messages.value, message]
@@ -200,7 +247,14 @@ function handleKeydown(e: KeyboardEvent) {
<div class="avatar" :class="{ self: item.is_self }">{{ roleLabel(item.sender_role).slice(0, 1) }}</div>
<div class="bubble-wrap" :class="{ self: item.is_self }">
<span class="sender-name">{{ senderLabel(item) }}</span>
<div class="bubble">{{ item.content }}</div>
<div v-if="item.content" class="bubble">{{ item.content }}</div>
<div v-if="item.attachment_urls.length > 0" class="message-attachments">
<ChatAttachmentImage
v-for="url in item.attachment_urls"
:key="url"
:source="url"
/>
</div>
<span class="message-time">{{ formatDateMinute(item.created_at) }}</span>
</div>
</template>
@@ -209,6 +263,22 @@ function handleKeydown(e: KeyboardEvent) {
<!-- Composer -->
<div class="composer">
<div v-if="attachments.length > 0" class="pending-attachments">
<div v-for="(url, index) in attachments" :key="url" class="pending-item">
<ChatAttachmentImage :source="url" />
<button type="button" class="remove-attachment" @click="removeAttachment(index)">
<el-icon :size="14"><Close /></el-icon>
</button>
</div>
</div>
<input
ref="fileInputRef"
class="hidden-file"
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
@change="handleImageChange"
>
<el-input
v-model="content"
type="textarea"
@@ -221,9 +291,14 @@ function handleKeydown(e: KeyboardEvent) {
/>
<div class="composer-actions">
<span class="composer-hint">Enter 发送Shift+Enter 换行</span>
<el-button type="primary" :disabled="!content.trim() || sending" :loading="sending" @click="handleSend">
发送
</el-button>
<div class="composer-buttons">
<el-button :icon="Picture" :loading="uploading" :disabled="attachments.length >= 9" @click="pickImages">
图片
</el-button>
<el-button type="primary" :disabled="!canSend || sending || uploading" :loading="sending" @click="handleSend">
发送
</el-button>
</div>
</div>
</div>
</div>
@@ -376,6 +451,12 @@ function handleKeydown(e: KeyboardEvent) {
color: #0f5132;
}
.message-attachments {
display: grid;
gap: 6px;
margin-top: 6px;
}
.message-time {
margin-top: 4px;
color: #a1a8b4;
@@ -400,6 +481,46 @@ function handleKeydown(e: KeyboardEvent) {
background: #fafbfc;
}
.pending-attachments {
display: flex;
gap: 8px;
margin-bottom: 10px;
overflow-x: auto;
}
.pending-item {
position: relative;
flex: none;
}
.pending-item :deep(.chat-image-button) {
width: 86px;
height: 86px;
}
.pending-item :deep(.chat-image-button img) {
height: 86px;
}
.remove-attachment {
position: absolute;
top: 4px;
right: 4px;
display: grid;
width: 22px;
height: 22px;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(17, 24, 39, 0.72);
color: #fff;
cursor: pointer;
}
.hidden-file {
display: none;
}
.composer-actions {
display: flex;
align-items: center;
@@ -407,6 +528,11 @@ function handleKeydown(e: KeyboardEvent) {
margin-top: 8px;
}
.composer-buttons {
display: flex;
gap: 8px;
}
.composer-hint {
color: #a0aab6;
font-size: 12px;
+141 -4
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Close, Picture } from '@element-plus/icons-vue'
import {
fetchAdminChat,
fetchAdminChatMessages,
@@ -13,6 +14,8 @@ import {
type ChatMessage,
type QuickReply,
} from '@/api/chats'
import { uploadAdminFile } from '@/api/files'
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
import { formatDateMinute } from '@/utils/time'
import TransferDialog from './components/TransferDialog.vue'
@@ -26,8 +29,11 @@ const messages = ref<ChatMessage[]>([])
const loading = ref(false)
const messageLoading = ref(false)
const sending = ref(false)
const uploading = ref(false)
const content = ref('')
const attachments = ref<string[]>([])
const listRef = ref<HTMLElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const filter = ref<'all' | 'mine' | 'unassigned'>('mine')
const transferVisible = ref(false)
const quickReplyVisible = ref(false)
@@ -43,6 +49,7 @@ const activeMembers = computed(() => {
return `${roleLabel(item.role)}${name}`
}).join(' / ')
})
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
function getParticipantRemark(participant: any) {
if (!active.value) return ''
@@ -116,6 +123,7 @@ async function openConversation(item: ChatConversation) {
await loadConversations(false)
remarkEditing.value = false
remarkValue.value = ''
attachments.value = []
} catch {
ElMessage.error('会话详情加载失败')
} finally {
@@ -134,11 +142,15 @@ async function loadMessages(id: number, scroll = true) {
async function handleSend() {
const text = content.value.trim()
if (!active.value || !text || sending.value) return
const imageUrls = [...attachments.value]
if (!active.value || (!text && imageUrls.length === 0) || sending.value || uploading.value) return
sending.value = true
try {
await sendAdminChatMessage(active.value.id, text)
const sent = await sendAdminChatMessage(active.value.id, text, imageUrls)
appendMessage(sent)
content.value = ''
attachments.value = []
await loadConversations(false)
} catch {
ElMessage.error('发送失败')
} finally {
@@ -146,6 +158,51 @@ async function handleSend() {
}
}
function appendMessage(message: ChatMessage) {
if (messages.value.some(item => item.id === message.id)) return
messages.value = [...messages.value, message]
nextTick(() => scrollBottom())
}
function pickImages() {
if (uploading.value || attachments.value.length >= 9) return
fileInputRef.value?.click()
}
async function handleImageChange(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || [])
input.value = ''
if (files.length === 0) return
const slots = 9 - attachments.value.length
if (slots <= 0) {
ElMessage.warning('每条消息最多发送 9 张图片')
return
}
uploading.value = true
try {
for (const file of files.slice(0, slots)) {
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 10 * 1024 * 1024) {
ElMessage.warning(`${file.name} 不符合图片规则`)
continue
}
const uploaded = await uploadAdminFile(file, 'chat')
attachments.value.push(uploaded.url)
}
if (files.length > slots) {
ElMessage.warning('每条消息最多发送 9 张图片')
}
} catch {
ElMessage.error('图片上传失败')
} finally {
uploading.value = false
}
}
function removeAttachment(index: number) {
attachments.value.splice(index, 1)
}
function handleQuickReplySelect(reply: QuickReply) {
content.value = reply.content
quickReplyVisible.value = false
@@ -307,13 +364,29 @@ function getSupportName(item: ChatConversation) {
</template>
<template v-else>
<small>{{ senderLabel(item) }} · {{ formatDateMinute(item.created_at) }}</small>
<p>{{ item.content }}</p>
<p v-if="item.content">{{ item.content }}</p>
<div v-if="item.attachment_urls.length > 0" class="message-attachments">
<ChatAttachmentImage
v-for="url in item.attachment_urls"
:key="url"
:source="url"
admin
/>
</div>
</template>
</div>
</div>
<footer class="composer">
<div class="composer-tools">
<input
ref="fileInputRef"
class="hidden-file"
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
@change="handleImageChange"
>
<el-dropdown trigger="click" @command="handleQuickReplySelect">
<el-button size="small" text>快捷回复</el-button>
<template #dropdown>
@@ -332,6 +405,17 @@ function getSupportName(item: ChatConversation) {
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button size="small" text :icon="Picture" :loading="uploading" :disabled="attachments.length >= 9" @click="pickImages">
图片
</el-button>
</div>
<div v-if="attachments.length > 0" class="pending-attachments">
<div v-for="(url, index) in attachments" :key="url" class="pending-item">
<ChatAttachmentImage :source="url" admin />
<button type="button" class="remove-attachment" @click="removeAttachment(index)">
<el-icon :size="14"><Close /></el-icon>
</button>
</div>
</div>
<div class="composer-input">
<el-input
@@ -343,7 +427,7 @@ function getSupportName(item: ChatConversation) {
placeholder="输入客服回复"
@keydown.enter.exact.prevent="handleSend"
/>
<el-button type="primary" :loading="sending" :disabled="!content.trim()" @click="handleSend">发送</el-button>
<el-button type="primary" :loading="sending" :disabled="!canSend || uploading" @click="handleSend">发送</el-button>
</div>
</footer>
</template>
@@ -565,6 +649,16 @@ function getSupportName(item: ChatConversation) {
background: #dff5eb;
}
.message-attachments {
display: grid;
justify-items: start;
gap: 6px;
}
.message-row.self .message-attachments {
justify-items: end;
}
.message-row.system span {
display: inline-block;
padding: 5px 10px;
@@ -579,10 +673,53 @@ function getSupportName(item: ChatConversation) {
}
.composer-tools {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 14px;
border-bottom: 1px solid #f0f0f0;
}
.hidden-file {
display: none;
}
.pending-attachments {
display: flex;
gap: 8px;
padding: 10px 14px 0;
overflow-x: auto;
}
.pending-item {
position: relative;
flex: none;
}
.pending-item :deep(.chat-image-button) {
width: 84px;
height: 84px;
}
.pending-item :deep(.chat-image-button img) {
height: 84px;
}
.remove-attachment {
position: absolute;
top: 4px;
right: 4px;
display: grid;
width: 22px;
height: 22px;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(17, 24, 39, 0.72);
color: #fff;
cursor: pointer;
}
.composer-input {
display: grid;
grid-template-columns: minmax(0, 1fr) 88px;
+149 -5
View File
@@ -10,6 +10,8 @@ import {
type ChatConversation,
type ChatMessage,
} from '@/api/chats'
import { uploadFile } from '@/api/files'
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
import { formatDateMinute } from '@/utils/time'
@@ -21,10 +23,14 @@ const conversation = ref<ChatConversation | null>(null)
const messages = ref<ChatMessage[]>([])
const loading = ref(false)
const sending = ref(false)
const uploading = ref(false)
const content = ref('')
const attachments = ref<string[]>([])
const listRef = ref<HTMLElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const conversationID = computed(() => Number(route.params.id || 0))
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
const memberText = computed(() => {
const participants = conversation.value?.participants || []
if (participants.length === 0) return conversation.value?.type === 'general_support' ? '平台客服' : '订单群聊'
@@ -100,11 +106,14 @@ async function loadMessages(scrollToBottom = true) {
async function handleSend() {
const text = content.value.trim()
if (!text || sending.value) return
const imageUrls = [...attachments.value]
if ((!text && imageUrls.length === 0) || sending.value || uploading.value) return
sending.value = true
try {
await sendChatMessage(conversationID.value, text)
const sent = await sendChatMessage(conversationID.value, text, imageUrls)
appendMessage(sent)
content.value = ''
attachments.value = []
} catch {
showToast({ message: '发送失败', icon: 'cross' })
} finally {
@@ -112,6 +121,51 @@ async function handleSend() {
}
}
function appendMessage(message: ChatMessage) {
if (messages.value.some(item => item.id === message.id)) return
messages.value = [...messages.value, message]
nextTick(() => scrollBottom())
}
function pickImages() {
if (uploading.value || attachments.value.length >= 9) return
fileInputRef.value?.click()
}
async function handleImageChange(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || [])
input.value = ''
if (files.length === 0) return
const slots = 9 - attachments.value.length
if (slots <= 0) {
showToast('每条消息最多发送 9 张图片')
return
}
uploading.value = true
try {
for (const file of files.slice(0, slots)) {
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 10 * 1024 * 1024) {
showToast(`${file.name} 不符合图片规则`)
continue
}
const uploaded = await uploadFile(file, 'chat')
attachments.value.push(uploaded.url)
}
if (files.length > slots) {
showToast('每条消息最多发送 9 张图片')
}
} catch {
showToast({ message: '图片上传失败', icon: 'cross' })
} finally {
uploading.value = false
}
}
function removeAttachment(index: number) {
attachments.value.splice(index, 1)
}
function scrollBottom() {
const el = listRef.value
if (!el) return
@@ -166,7 +220,14 @@ function senderLabel(message: ChatMessage) {
<div class="avatar">{{ roleLabel(item.sender_role).slice(0, 1) }}</div>
<div class="bubble-wrap">
<span class="sender-name">{{ senderLabel(item) }}</span>
<div class="bubble">{{ item.content }}</div>
<div v-if="item.content" class="bubble">{{ item.content }}</div>
<div v-if="item.attachment_urls.length > 0" class="message-attachments">
<ChatAttachmentImage
v-for="url in item.attachment_urls"
:key="url"
:source="url"
/>
</div>
<span class="message-time">{{ formatDateMinute(item.created_at) }}</span>
</div>
</template>
@@ -174,6 +235,25 @@ function senderLabel(message: ChatMessage) {
</section>
<footer class="composer">
<input
ref="fileInputRef"
class="hidden-file"
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
@change="handleImageChange"
>
<div v-if="attachments.length > 0" class="pending-attachments">
<div v-for="(url, index) in attachments" :key="url" class="pending-item">
<ChatAttachmentImage :source="url" />
<button type="button" class="remove-attachment" @click="removeAttachment(index)">
<van-icon name="cross" :size="12" />
</button>
</div>
</div>
<button class="tool-btn" type="button" :disabled="uploading || attachments.length >= 9" @click="pickImages">
<van-icon name="photo-o" :size="20" />
</button>
<van-field
v-model="content"
class="composer-input"
@@ -184,7 +264,7 @@ function senderLabel(message: ChatMessage) {
placeholder="发送消息"
@keydown.enter.prevent="handleSend"
/>
<button class="send-btn" type="button" :disabled="!content.trim() || sending" @click="handleSend">
<button class="send-btn" type="button" :disabled="!canSend || sending || uploading" @click="handleSend">
<van-icon name="guide-o" :size="20" />
</button>
</footer>
@@ -323,6 +403,16 @@ function senderLabel(message: ChatMessage) {
background: #dff5eb;
}
.message-attachments {
display: grid;
gap: 6px;
margin-top: 6px;
}
.message-attachments :deep(.chat-image-button) {
max-width: min(62vw, 220px);
}
.message-time {
margin-top: 4px;
color: #a1a8b4;
@@ -342,7 +432,7 @@ function senderLabel(message: ChatMessage) {
.composer {
display: grid;
grid-template-columns: minmax(0, 1fr) 42px;
grid-template-columns: 40px minmax(0, 1fr) 42px;
gap: 8px;
align-items: end;
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
@@ -350,12 +440,66 @@ function senderLabel(message: ChatMessage) {
background: #fff;
}
.hidden-file {
display: none;
}
.pending-attachments {
display: flex;
grid-column: 1 / -1;
gap: 8px;
overflow-x: auto;
}
.pending-item {
position: relative;
flex: none;
}
.pending-item :deep(.chat-image-button) {
width: 72px;
height: 72px;
}
.pending-item :deep(.chat-image-button img) {
height: 72px;
}
.remove-attachment {
position: absolute;
top: 3px;
right: 3px;
display: grid;
width: 20px;
height: 20px;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(17, 24, 39, 0.72);
color: #fff;
}
.composer-input {
border: 1px solid #d9e0e8;
border-radius: 8px;
overflow: hidden;
}
.tool-btn {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border: 0;
border-radius: 8px;
background: #eef4ff;
color: #1477ff;
}
.tool-btn:disabled {
color: #9ca3af;
}
.send-btn {
display: grid;
width: 40px;