Files
hfb_sys/frontend/src/features/chats/views/MobileChatView.vue
T
2026-06-09 20:50:57 +08:00

598 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>