598 lines
14 KiB
Vue
598 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { showToast } from 'vant'
|
||
import {
|
||
fetchChat,
|
||
fetchChatMessages,
|
||
markChatRead,
|
||
sendChatMessage,
|
||
type ChatConversation,
|
||
type ChatMessage,
|
||
} from '@/features/chats/api/chats'
|
||
import { uploadFile } from '@/shared/api/files'
|
||
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
|
||
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
|
||
import { useDesktopNotification } from '@/features/chats/composables/useDesktopNotification'
|
||
import { formatDateMinute } from '@/shared/utils/time'
|
||
|
||
const currentUserId = Number(localStorage.getItem('user_id') || 0)
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
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' ? '平台客服' : '订单群聊'
|
||
return participants.map(item => `${roleLabel(item.role)}:${item.display_name}`).join(' / ')
|
||
})
|
||
|
||
// 桌面通知
|
||
const desktopNotification = useDesktopNotification('user')
|
||
|
||
function handleSSEEvent(event: ChatEvent) {
|
||
if (event.type === 'new_message') {
|
||
const msg = event.message
|
||
if (msg) {
|
||
const isSelf = msg.sender_type === 'user' && msg.sender_id === currentUserId
|
||
if (
|
||
event.conversation_id === conversationID.value &&
|
||
!messages.value.some(m => m.id === msg.id)
|
||
) {
|
||
messages.value = [
|
||
...messages.value,
|
||
{
|
||
id: msg.id,
|
||
conversation_id: msg.conversation_id,
|
||
sender_type: msg.sender_type as ChatMessage['sender_type'],
|
||
sender_id: msg.sender_id,
|
||
sender_role: msg.sender_role as ChatMessage['sender_role'],
|
||
sender_name: msg.sender_name,
|
||
sender_avatar: '',
|
||
is_self: isSelf,
|
||
content_type: msg.content_type as ChatMessage['content_type'],
|
||
content: msg.content,
|
||
attachment_urls: msg.attachment_urls || [],
|
||
created_at: msg.created_at,
|
||
},
|
||
]
|
||
nextTick(() => scrollBottom())
|
||
}
|
||
|
||
// 不是自己发送的消息才发送通知
|
||
if (!isSelf) {
|
||
desktopNotification.notify(event, conversationID.value)
|
||
}
|
||
}
|
||
}
|
||
if (event.type === 'conversation_updated' && event.conversation_id === conversationID.value) {
|
||
loadConversation()
|
||
}
|
||
}
|
||
|
||
const { onEvent } = useChatSSE('user', '/api/chats/events')
|
||
onEvent(handleSSEEvent)
|
||
|
||
onMounted(async () => {
|
||
// 静默请求通知权限(用户交互后才会弹窗)
|
||
desktopNotification.requestPermissionSilently()
|
||
await loadAll()
|
||
})
|
||
|
||
async function loadAll() {
|
||
if (!conversationID.value) return
|
||
loading.value = true
|
||
try {
|
||
const [chat] = await Promise.all([fetchChat(conversationID.value), loadMessages(false)])
|
||
conversation.value = chat
|
||
await markChatRead(conversationID.value)
|
||
} catch {
|
||
showToast({ message: '加载会话失败', icon: 'cross' })
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadConversation() {
|
||
if (!conversationID.value) return
|
||
try {
|
||
conversation.value = await fetchChat(conversationID.value)
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
async function loadMessages(scrollToBottom = true) {
|
||
if (!conversationID.value) return
|
||
const res = await fetchChatMessages(conversationID.value, 1, 100)
|
||
messages.value = res.items
|
||
if (scrollToBottom) {
|
||
await nextTick()
|
||
scrollBottom()
|
||
}
|
||
}
|
||
|
||
async function handleSend() {
|
||
const text = content.value.trim()
|
||
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, imageUrls)
|
||
appendMessage(sent)
|
||
content.value = ''
|
||
attachments.value = []
|
||
} catch {
|
||
showToast({ message: '发送失败', icon: 'cross' })
|
||
} finally {
|
||
sending.value = false
|
||
}
|
||
}
|
||
|
||
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
|
||
await uploadImages(files)
|
||
}
|
||
|
||
async function handlePaste(event: ClipboardEvent) {
|
||
const items = Array.from(event.clipboardData?.items || [])
|
||
const imageFiles = items
|
||
.filter(item => item.type.startsWith('image/'))
|
||
.map(item => item.getAsFile())
|
||
.filter(Boolean) as File[]
|
||
|
||
if (imageFiles.length > 0) {
|
||
event.preventDefault()
|
||
await uploadImages(imageFiles)
|
||
}
|
||
}
|
||
|
||
async function uploadImages(files: File[]) {
|
||
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 > 25 * 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
|
||
el.scrollTop = el.scrollHeight
|
||
}
|
||
|
||
function roleLabel(role: string) {
|
||
const map: Record<string, string> = {
|
||
renter: '租客',
|
||
owner: '号主',
|
||
support: '客服',
|
||
customer: '咨询',
|
||
system: '系统',
|
||
admin: '管理员',
|
||
}
|
||
return map[role] || '成员'
|
||
}
|
||
|
||
function senderLabel(message: ChatMessage) {
|
||
if (message.sender_type === 'system') return '系统'
|
||
// 管理员消息特殊标识
|
||
if (message.sender_role === 'admin') {
|
||
return `管理员 · ${message.sender_name}`
|
||
}
|
||
return `${roleLabel(message.sender_role)} · ${message.sender_name}`
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<main class="mobile-chat">
|
||
<header class="chat-header">
|
||
<button class="icon-btn" type="button" @click="router.back()">
|
||
<van-icon name="arrow-left" :size="20" />
|
||
</button>
|
||
<div class="chat-title">
|
||
<h1>{{ conversation?.title || '客服会话' }}</h1>
|
||
<p>{{ memberText }}</p>
|
||
</div>
|
||
<button
|
||
v-if="conversation?.order_id"
|
||
class="icon-btn"
|
||
type="button"
|
||
@click="router.push(`/m/orders/${conversation.order_id}`)"
|
||
>
|
||
<van-icon name="orders-o" :size="20" />
|
||
</button>
|
||
<span v-else class="icon-placeholder"></span>
|
||
</header>
|
||
|
||
<section ref="listRef" class="message-list" :class="{ loading }">
|
||
<van-loading v-if="loading && messages.length === 0" class="loading-state" />
|
||
<div
|
||
v-for="item in messages"
|
||
:key="item.id"
|
||
class="message-row"
|
||
:class="{
|
||
self: item.is_self,
|
||
system: item.sender_type === 'system',
|
||
admin: item.sender_role === 'admin' && !item.is_self,
|
||
}"
|
||
>
|
||
<template v-if="item.sender_type === 'system'">
|
||
<span class="system-message">{{ item.content }}</span>
|
||
</template>
|
||
<template v-else>
|
||
<div class="avatar">{{ roleLabel(item.sender_role).slice(0, 1) }}</div>
|
||
<div class="bubble-wrap">
|
||
<span
|
||
class="sender-name"
|
||
:class="{ 'admin-label': item.sender_role === 'admin' && !item.is_self }"
|
||
>
|
||
{{ senderLabel(item) }}
|
||
</span>
|
||
<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>
|
||
</div>
|
||
</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"
|
||
type="textarea"
|
||
autosize
|
||
:maxlength="1000"
|
||
rows="1"
|
||
placeholder="发送消息"
|
||
@keydown.enter.prevent="handleSend"
|
||
@paste="handlePaste"
|
||
/>
|
||
<button
|
||
class="send-btn"
|
||
type="button"
|
||
:disabled="!canSend || sending || uploading"
|
||
@click="handleSend"
|
||
>
|
||
<van-icon name="guide-o" :size="20" />
|
||
</button>
|
||
</footer>
|
||
</main>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.mobile-chat {
|
||
display: grid;
|
||
grid-template-rows: 56px minmax(0, 1fr) auto;
|
||
height: 100dvh;
|
||
background: #f3f6fa;
|
||
}
|
||
|
||
.chat-header {
|
||
display: grid;
|
||
grid-template-columns: 44px minmax(0, 1fr) 44px;
|
||
align-items: center;
|
||
border-bottom: 1px solid #e7ecf2;
|
||
background: rgba(255, 255, 255, 0.96);
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.icon-btn {
|
||
display: grid;
|
||
width: 44px;
|
||
height: 44px;
|
||
place-items: center;
|
||
border: 0;
|
||
background: transparent;
|
||
color: #374151;
|
||
}
|
||
|
||
.icon-placeholder {
|
||
display: block;
|
||
width: 44px;
|
||
height: 44px;
|
||
}
|
||
|
||
.chat-title {
|
||
min-width: 0;
|
||
text-align: center;
|
||
}
|
||
|
||
.chat-title h1 {
|
||
margin: 0;
|
||
overflow: hidden;
|
||
color: #111827;
|
||
font-size: 15px;
|
||
font-weight: 800;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.chat-title p {
|
||
margin: 3px 0 0;
|
||
overflow: hidden;
|
||
color: #6b7280;
|
||
font-size: 11px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.message-list {
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
padding: 14px 12px 18px;
|
||
}
|
||
|
||
.loading-state {
|
||
display: block;
|
||
margin: 70px auto;
|
||
}
|
||
|
||
.message-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.message-row.self {
|
||
flex-direction: row-reverse;
|
||
}
|
||
|
||
.message-row.system {
|
||
justify-content: center;
|
||
}
|
||
|
||
.avatar {
|
||
display: grid;
|
||
flex: none;
|
||
width: 34px;
|
||
height: 34px;
|
||
place-items: center;
|
||
border-radius: 50%;
|
||
background: #1477ff;
|
||
color: #fff;
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.message-row.self .avatar {
|
||
background: #10b981;
|
||
}
|
||
|
||
.bubble-wrap {
|
||
display: flex;
|
||
max-width: min(76vw, 330px);
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.message-row.self .bubble-wrap {
|
||
align-items: flex-end;
|
||
}
|
||
|
||
.sender-name {
|
||
margin-bottom: 4px;
|
||
color: #8a94a6;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.bubble {
|
||
max-width: 100%;
|
||
padding: 9px 11px;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
color: #111827;
|
||
font-size: 14px;
|
||
line-height: 1.45;
|
||
word-break: break-word;
|
||
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.05);
|
||
}
|
||
|
||
.message-row.self .bubble {
|
||
background: #dff5eb;
|
||
}
|
||
|
||
.message-row.admin .bubble {
|
||
background: #fff4e6;
|
||
border-left: 3px solid #ff9800;
|
||
}
|
||
|
||
.message-row.admin .avatar {
|
||
background: #ff9800;
|
||
}
|
||
|
||
.admin-label {
|
||
color: #ff9800;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.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;
|
||
font-size: 10px;
|
||
}
|
||
|
||
.system-message {
|
||
max-width: 82%;
|
||
padding: 5px 9px;
|
||
border-radius: 8px;
|
||
background: #e6ebf2;
|
||
color: #6b7280;
|
||
font-size: 11px;
|
||
line-height: 1.4;
|
||
text-align: center;
|
||
}
|
||
|
||
.composer {
|
||
display: grid;
|
||
grid-template-columns: 40px minmax(0, 1fr) 42px;
|
||
gap: 8px;
|
||
align-items: end;
|
||
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
|
||
border-top: 1px solid #e7ecf2;
|
||
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;
|
||
height: 40px;
|
||
place-items: center;
|
||
border: 0;
|
||
border-radius: 8px;
|
||
background: #1477ff;
|
||
color: #fff;
|
||
}
|
||
|
||
.send-btn:disabled {
|
||
background: #c8d1dd;
|
||
}
|
||
</style>
|