853 lines
23 KiB
Vue
853 lines
23 KiB
Vue
|
|
<script setup>
|
|||
|
|
import {
|
|||
|
|
LoginStore,
|
|||
|
|
MessageInputStore,
|
|||
|
|
MessageListStore,
|
|||
|
|
MessageStatus,
|
|||
|
|
MessageType
|
|||
|
|
} from 'tuikit-atomicx-vue3/chat'
|
|||
|
|
import { formatTrtcChatError } from '~/utils/trtc-chat/error-map'
|
|||
|
|
|
|||
|
|
const props = defineProps({
|
|||
|
|
context: {
|
|||
|
|
type: Object,
|
|||
|
|
default: () => ({})
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const emit = defineEmits(['close'])
|
|||
|
|
|
|||
|
|
const config = useRuntimeConfig()
|
|||
|
|
const route = useRoute()
|
|||
|
|
|
|||
|
|
const connectionState = ref('connecting')
|
|||
|
|
const connectionError = ref('')
|
|||
|
|
const messageList = ref([])
|
|||
|
|
const hasOlderMessages = ref(false)
|
|||
|
|
const loadingInitial = ref(false)
|
|||
|
|
const loadingOlder = ref(false)
|
|||
|
|
const sending = ref(false)
|
|||
|
|
const errorMsg = ref('')
|
|||
|
|
const text = ref('')
|
|||
|
|
const newMessageCount = ref(0)
|
|||
|
|
const isNearBottom = ref(true)
|
|||
|
|
const didInitialScroll = ref(false)
|
|||
|
|
|
|||
|
|
const textareaEl = ref(null)
|
|||
|
|
const scrollContainer = ref(null)
|
|||
|
|
const imageInputEl = ref(null)
|
|||
|
|
|
|||
|
|
const targetType = computed(() =>
|
|||
|
|
String(config.public.trtcChatTargetType || 'C2C').toUpperCase() === 'GROUP' ? 'GROUP' : 'C2C'
|
|||
|
|
)
|
|||
|
|
const targetID = computed(() => config.public.trtcChatCustomerServiceID || 'administrator')
|
|||
|
|
const conversationID = computed(() => `${targetType.value}${targetID.value}`)
|
|||
|
|
|
|||
|
|
let messageListStore = null
|
|||
|
|
let messageInputStore = null
|
|||
|
|
let loginStore = null
|
|||
|
|
let unsubscribeLoginEvent = null
|
|||
|
|
let unsubscribeMessageEvent = null
|
|||
|
|
let unsubscribeMessageState = null
|
|||
|
|
|
|||
|
|
const filteredMessages = computed(() =>
|
|||
|
|
(messageList.value || []).filter((message) => message.status !== MessageStatus.Deleted)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
watch(text, (value) => {
|
|||
|
|
if (value && errorMsg.value) errorMsg.value = ''
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
function createLoginStore() {
|
|||
|
|
if (typeof LoginStore.create === 'function') return LoginStore.create()
|
|||
|
|
return LoginStore()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function syncMessageState() {
|
|||
|
|
messageList.value = messageListStore?.messageList?.value || []
|
|||
|
|
hasOlderMessages.value = Boolean(messageListStore?.hasOlderMessages?.value)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function resolveCredentials(userID) {
|
|||
|
|
const endpoint = config.public.trtcChatTokenEndpoint
|
|||
|
|
if (endpoint) {
|
|||
|
|
const response = await $fetch(endpoint, {
|
|||
|
|
method: 'GET',
|
|||
|
|
query: { userID, sourcePage: route.fullPath }
|
|||
|
|
})
|
|||
|
|
const sdkAppID = Number(response?.SDKAppID || response?.sdkAppID)
|
|||
|
|
if (!sdkAppID || !response?.userSig) {
|
|||
|
|
throw new Error('UserSig 接口需要返回 SDKAppID 和 userSig。')
|
|||
|
|
}
|
|||
|
|
return { sdkAppID, userSig: response.userSig }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
throw new Error('请先配置 NUXT_PUBLIC_TRTC_CHAT_TOKEN_ENDPOINT,用后端接口签发 UserSig。')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getVisitorID() {
|
|||
|
|
const key = 'official-consultation-user-id'
|
|||
|
|
const existing = localStorage.getItem(key)
|
|||
|
|
if (existing) return existing
|
|||
|
|
|
|||
|
|
const prefix = config.public.trtcChatVisitorPrefix || 'official_visitor'
|
|||
|
|
const id = `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
|||
|
|
localStorage.setItem(key, id)
|
|||
|
|
return id
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loginAndPrepareConversation() {
|
|||
|
|
connectionState.value = 'connecting'
|
|||
|
|
connectionError.value = ''
|
|||
|
|
loadingInitial.value = true
|
|||
|
|
|
|||
|
|
cleanupStores()
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const userID = getVisitorID()
|
|||
|
|
const { sdkAppID, userSig } = await resolveCredentials(userID)
|
|||
|
|
loginStore = createLoginStore()
|
|||
|
|
await loginStore.login({
|
|||
|
|
sdkAppID,
|
|||
|
|
userID,
|
|||
|
|
userSig,
|
|||
|
|
scene: config.public.trtcChatScene || '5000'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
messageListStore = MessageListStore.create(conversationID.value)
|
|||
|
|
messageInputStore = MessageInputStore.create(conversationID.value)
|
|||
|
|
|
|||
|
|
syncMessageState()
|
|||
|
|
unsubscribeMessageState = watch(
|
|||
|
|
[messageListStore.messageList, messageListStore.hasOlderMessages],
|
|||
|
|
syncMessageState
|
|||
|
|
)
|
|||
|
|
unsubscribeMessageEvent = messageListStore.onEvent((event) => {
|
|||
|
|
switch (event.type) {
|
|||
|
|
case 'onReceiveNewMessage':
|
|||
|
|
handleNewMessage(event.message)
|
|||
|
|
break
|
|||
|
|
default:
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
unsubscribeLoginEvent = loginStore.onEvent((event) => {
|
|||
|
|
if (event.type === 'kickedOffline') {
|
|||
|
|
connectionState.value = 'error'
|
|||
|
|
connectionError.value = '您的账号在其他设备登录,当前咨询已断开。'
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
await messageListStore.loadMessages()
|
|||
|
|
syncMessageState()
|
|||
|
|
connectionState.value = 'connected'
|
|||
|
|
await scrollToBottom('auto')
|
|||
|
|
didInitialScroll.value = true
|
|||
|
|
} catch (error) {
|
|||
|
|
connectionState.value = 'error'
|
|||
|
|
connectionError.value = formatTrtcChatError(error)
|
|||
|
|
} finally {
|
|||
|
|
loadingInitial.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function cleanupStores() {
|
|||
|
|
unsubscribeLoginEvent?.()
|
|||
|
|
unsubscribeMessageEvent?.()
|
|||
|
|
unsubscribeMessageState?.()
|
|||
|
|
unsubscribeLoginEvent = null
|
|||
|
|
unsubscribeMessageEvent = null
|
|||
|
|
unsubscribeMessageState = null
|
|||
|
|
messageInputStore?.destroy?.()
|
|||
|
|
messageListStore?.destroy?.()
|
|||
|
|
loginStore?.logout?.().catch(() => {})
|
|||
|
|
messageInputStore = null
|
|||
|
|
messageListStore = null
|
|||
|
|
loginStore = null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function scrollToBottom(behavior = 'auto') {
|
|||
|
|
await nextTick()
|
|||
|
|
const element = scrollContainer.value
|
|||
|
|
if (!element) return
|
|||
|
|
if (behavior === 'smooth') {
|
|||
|
|
element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' })
|
|||
|
|
} else {
|
|||
|
|
element.scrollTop = element.scrollHeight
|
|||
|
|
}
|
|||
|
|
checkNearBottom()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function checkNearBottom() {
|
|||
|
|
const element = scrollContainer.value
|
|||
|
|
if (!element) return
|
|||
|
|
isNearBottom.value = element.scrollHeight - element.scrollTop - element.clientHeight < 150
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleScroll() {
|
|||
|
|
checkNearBottom()
|
|||
|
|
if (isNearBottom.value) newMessageCount.value = 0
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleNewMessage(message) {
|
|||
|
|
if (message?.isSentBySelf || isNearBottom.value) {
|
|||
|
|
scrollToBottom('smooth')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
newMessageCount.value += 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
watch(messageList, (next, previous) => {
|
|||
|
|
if (!didInitialScroll.value || loadingOlder.value) return
|
|||
|
|
if ((next?.length || 0) <= (previous?.length || 0)) return
|
|||
|
|
const last = next[next.length - 1]
|
|||
|
|
if (last?.isSentBySelf) scrollToBottom('smooth')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
async function loadOlderMessages() {
|
|||
|
|
if (!messageListStore || loadingOlder.value || !hasOlderMessages.value) return
|
|||
|
|
const element = scrollContainer.value
|
|||
|
|
const oldScrollHeight = element?.scrollHeight || 0
|
|||
|
|
loadingOlder.value = true
|
|||
|
|
try {
|
|||
|
|
await messageListStore.loadOlderMessages()
|
|||
|
|
syncMessageState()
|
|||
|
|
await nextTick()
|
|||
|
|
if (element) element.scrollTop = element.scrollHeight - oldScrollHeight
|
|||
|
|
} catch (error) {
|
|||
|
|
errorMsg.value = formatTrtcChatError(error)
|
|||
|
|
} finally {
|
|||
|
|
loadingOlder.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function sendTextMessage() {
|
|||
|
|
const content = text.value.trim()
|
|||
|
|
if (!content || sending.value || !messageInputStore) return
|
|||
|
|
|
|||
|
|
sending.value = true
|
|||
|
|
errorMsg.value = ''
|
|||
|
|
try {
|
|||
|
|
await messageInputStore.sendMessage({ type: 'textMessage', text: content })
|
|||
|
|
text.value = ''
|
|||
|
|
await scrollToBottom('smooth')
|
|||
|
|
} catch (error) {
|
|||
|
|
errorMsg.value = formatTrtcChatError(error)
|
|||
|
|
} finally {
|
|||
|
|
sending.value = false
|
|||
|
|
await nextTick()
|
|||
|
|
textareaEl.value?.focus()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onKeydown(event) {
|
|||
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|||
|
|
event.preventDefault()
|
|||
|
|
sendTextMessage()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function chooseImage() {
|
|||
|
|
if (sending.value) return
|
|||
|
|
imageInputEl.value?.click()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function onImageSelected(event) {
|
|||
|
|
const input = event.target
|
|||
|
|
const file = input.files?.[0]
|
|||
|
|
input.value = ''
|
|||
|
|
if (!file || !messageInputStore) return
|
|||
|
|
if (!file.type.startsWith('image/')) {
|
|||
|
|
errorMsg.value = '请选择图片文件。'
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if (file.size > 20 * 1024 * 1024) {
|
|||
|
|
errorMsg.value = '图片不能超过 20MB。'
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
sending.value = true
|
|||
|
|
errorMsg.value = ''
|
|||
|
|
try {
|
|||
|
|
await messageInputStore.sendMessage({ type: 'imageMessage', file })
|
|||
|
|
await scrollToBottom('smooth')
|
|||
|
|
} catch (error) {
|
|||
|
|
errorMsg.value = formatTrtcChatError(error)
|
|||
|
|
} finally {
|
|||
|
|
sending.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function displayName(message) {
|
|||
|
|
const from = message.from || {}
|
|||
|
|
return from.friendRemark || from.nameCard || from.nickname || from.userID || '访客'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function avatarLetter(message) {
|
|||
|
|
return [...displayName(message)][0]?.toUpperCase() || '客'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function avatarStyle(message) {
|
|||
|
|
const palette = ['#1478fb', '#2f8f9d', '#5964d8', '#9465cf']
|
|||
|
|
const id = message.from?.userID || displayName(message)
|
|||
|
|
const index = [...id].reduce((total, char) => total + char.charCodeAt(0), 0) % palette.length
|
|||
|
|
return { background: palette[index] }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function shouldShowTimeLabel(current, previous) {
|
|||
|
|
if (!current?.timestamp) return false
|
|||
|
|
if (!previous?.timestamp) return true
|
|||
|
|
return Math.abs(current.timestamp.getTime() - previous.timestamp.getTime()) > 5 * 60 * 1000
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatTimeLabel(date) {
|
|||
|
|
const now = new Date()
|
|||
|
|
const sameDay = now.toDateString() === date.toDateString()
|
|||
|
|
const time = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
|||
|
|
return sameDay ? time : `${date.getMonth() + 1}/${date.getDate()} ${time}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function imageSource(payload = {}) {
|
|||
|
|
return payload.thumbImageURL || payload.largeImageURL || payload.originalImageURL || ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatFileSize(size = 0) {
|
|||
|
|
if (size < 1024) return `${size}B`
|
|||
|
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)}KB`
|
|||
|
|
return `${(size / 1024 / 1024).toFixed(1)}MB`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function customText(payload = {}) {
|
|||
|
|
if (!payload.customData) return payload.description || '[自定义消息]'
|
|||
|
|
if (typeof payload.customData !== 'string') return payload.description || '[自定义消息]'
|
|||
|
|
try {
|
|||
|
|
const parsed = JSON.parse(payload.customData)
|
|||
|
|
return parsed.title || parsed.text || payload.description || '[自定义消息]'
|
|||
|
|
} catch {
|
|||
|
|
return payload.description || payload.customData
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(() => {
|
|||
|
|
loginAndPrepareConversation()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
onBeforeUnmount(() => {
|
|||
|
|
cleanupStores()
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<section class="official-chat" role="dialog" aria-modal="true" aria-labelledby="official-chat-title">
|
|||
|
|
<header class="official-chat-header">
|
|||
|
|
<div>
|
|||
|
|
<p>{{ props.context.source || '官网在线咨询' }}</p>
|
|||
|
|
<h2 id="official-chat-title">在线客服</h2>
|
|||
|
|
</div>
|
|||
|
|
<button class="chat-icon-button" type="button" aria-label="关闭聊天窗口" @click="emit('close')">
|
|||
|
|
<AppIcon name="close" />
|
|||
|
|
</button>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<div v-if="connectionState === 'connecting'" class="chat-state-panel">
|
|||
|
|
<span class="chat-spinner" aria-hidden="true"></span>
|
|||
|
|
<strong>正在连接客服...</strong>
|
|||
|
|
<p>我们会为你打开一个专属咨询会话。</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-else-if="connectionState === 'error'" class="chat-state-panel is-error">
|
|||
|
|
<strong>暂时无法连接</strong>
|
|||
|
|
<p>{{ connectionError }}</p>
|
|||
|
|
<div class="chat-state-actions">
|
|||
|
|
<button type="button" @click="loginAndPrepareConversation">重试</button>
|
|||
|
|
<button type="button" class="is-ghost" @click="emit('close')">返回</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<template v-else>
|
|||
|
|
<div ref="scrollContainer" class="chat-message-list" @scroll.passive="handleScroll">
|
|||
|
|
<div v-if="loadingInitial" class="chat-inline-state">正在加载消息...</div>
|
|||
|
|
<button
|
|||
|
|
v-else-if="hasOlderMessages"
|
|||
|
|
class="load-older-button"
|
|||
|
|
type="button"
|
|||
|
|
:disabled="loadingOlder"
|
|||
|
|
@click="loadOlderMessages"
|
|||
|
|
>
|
|||
|
|
{{ loadingOlder ? '加载中...' : '加载更早消息' }}
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<p v-if="!loadingInitial && filteredMessages.length === 0" class="chat-empty">
|
|||
|
|
你好,这里是大马棒在线客服。请直接输入你的问题,我们会尽快回复。
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
<template v-for="(message, index) in filteredMessages" :key="message.msgID || message.id">
|
|||
|
|
<div v-if="shouldShowTimeLabel(message, filteredMessages[index - 1])" class="time-label">
|
|||
|
|
{{ formatTimeLabel(message.timestamp) }}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="message.status === MessageStatus.Recalled" class="system-tip">
|
|||
|
|
{{ message.isSentBySelf ? '你撤回了一条消息' : `${displayName(message)} 撤回了一条消息` }}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-else-if="message.messageType === MessageType.Tips" class="system-tip">
|
|||
|
|
系统通知
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div
|
|||
|
|
v-else-if="message.messageType === MessageType.Custom"
|
|||
|
|
class="message-row custom-row"
|
|||
|
|
:class="{ 'is-self': message.isSentBySelf }"
|
|||
|
|
>
|
|||
|
|
<article class="custom-card">{{ customText(message.messagePayload) }}</article>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-else class="message-row" :class="{ 'is-self': message.isSentBySelf }">
|
|||
|
|
<div v-if="!message.isSentBySelf" class="message-avatar" :style="avatarStyle(message)">
|
|||
|
|
{{ avatarLetter(message) }}
|
|||
|
|
</div>
|
|||
|
|
<div class="message-main">
|
|||
|
|
<span v-if="!message.isSentBySelf" class="message-name">{{ displayName(message) }}</span>
|
|||
|
|
<div class="message-bubble">
|
|||
|
|
<p v-if="message.messageType === MessageType.Text" class="message-text">
|
|||
|
|
{{ message.messagePayload.text }}
|
|||
|
|
</p>
|
|||
|
|
<a
|
|||
|
|
v-else-if="message.messageType === MessageType.Image"
|
|||
|
|
class="message-image-link"
|
|||
|
|
:href="message.messagePayload.originalImageURL || imageSource(message.messagePayload)"
|
|||
|
|
target="_blank"
|
|||
|
|
rel="noreferrer"
|
|||
|
|
>
|
|||
|
|
<img :src="imageSource(message.messagePayload)" alt="图片消息" />
|
|||
|
|
</a>
|
|||
|
|
<video
|
|||
|
|
v-else-if="message.messageType === MessageType.Video"
|
|||
|
|
class="message-video"
|
|||
|
|
:src="message.messagePayload.videoURL"
|
|||
|
|
:poster="message.messagePayload.videoSnapshotURL"
|
|||
|
|
controls
|
|||
|
|
></video>
|
|||
|
|
<audio
|
|||
|
|
v-else-if="message.messageType === MessageType.Audio"
|
|||
|
|
class="message-audio"
|
|||
|
|
:src="message.messagePayload.audioURL"
|
|||
|
|
controls
|
|||
|
|
></audio>
|
|||
|
|
<a
|
|||
|
|
v-else-if="message.messageType === MessageType.File"
|
|||
|
|
class="message-file"
|
|||
|
|
:href="message.messagePayload.fileURL"
|
|||
|
|
:download="message.messagePayload.fileName"
|
|||
|
|
>
|
|||
|
|
<strong>{{ message.messagePayload.fileName || '文件消息' }}</strong>
|
|||
|
|
<span>{{ formatFileSize(message.messagePayload.fileSize) }}</span>
|
|||
|
|
</a>
|
|||
|
|
<span v-else-if="message.messageType === MessageType.Face">
|
|||
|
|
[表情:{{ message.messagePayload.faceIndex }}]
|
|||
|
|
</span>
|
|||
|
|
<div v-else-if="message.messageType === MessageType.Merged">
|
|||
|
|
{{ message.messagePayload.title }}
|
|||
|
|
</div>
|
|||
|
|
<div v-else-if="message.messageType === MessageType.Stream">
|
|||
|
|
{{ message.messagePayload.markdown }}
|
|||
|
|
</div>
|
|||
|
|
<span v-else>[Unsupported: {{ message.messageType }}]</span>
|
|||
|
|
</div>
|
|||
|
|
<small v-if="message.isSentBySelf && message.status === MessageStatus.Sending">发送中</small>
|
|||
|
|
<small v-else-if="message.isSentBySelf && message.status === MessageStatus.SendFail">发送失败</small>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<button
|
|||
|
|
v-if="newMessageCount > 0 && !isNearBottom"
|
|||
|
|
class="new-message-badge"
|
|||
|
|
type="button"
|
|||
|
|
@click="scrollToBottom('smooth')"
|
|||
|
|
>
|
|||
|
|
{{ newMessageCount }} 条新消息
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<form class="chat-input-area" @submit.prevent="sendTextMessage">
|
|||
|
|
<p v-if="errorMsg" class="input-error">{{ errorMsg }}</p>
|
|||
|
|
<textarea
|
|||
|
|
ref="textareaEl"
|
|||
|
|
v-model="text"
|
|||
|
|
:disabled="sending"
|
|||
|
|
rows="2"
|
|||
|
|
placeholder="输入咨询内容,Enter 发送,Shift + Enter 换行"
|
|||
|
|
@keydown="onKeydown"
|
|||
|
|
></textarea>
|
|||
|
|
<div class="chat-toolbar">
|
|||
|
|
<button
|
|||
|
|
class="toolbar-button"
|
|||
|
|
type="button"
|
|||
|
|
:disabled="sending"
|
|||
|
|
title="发送图片"
|
|||
|
|
aria-label="发送图片"
|
|||
|
|
@click="chooseImage"
|
|||
|
|
>
|
|||
|
|
<AppIcon name="picture" />
|
|||
|
|
</button>
|
|||
|
|
<span class="toolbar-hint">支持文本和图片消息</span>
|
|||
|
|
<button class="send-button" type="submit" :disabled="sending || !text.trim()">
|
|||
|
|
{{ sending ? '发送中' : '发送' }}
|
|||
|
|
<AppIcon name="promotion" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<input ref="imageInputEl" type="file" accept="image/*" hidden @change="onImageSelected" />
|
|||
|
|
</form>
|
|||
|
|
</template>
|
|||
|
|
</section>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.official-chat {
|
|||
|
|
width: min(420px, calc(100vw - 32px));
|
|||
|
|
height: min(640px, calc(100dvh - 32px));
|
|||
|
|
display: flex;
|
|||
|
|
overflow: hidden;
|
|||
|
|
flex-direction: column;
|
|||
|
|
border: 1px solid rgba(215, 233, 255, 0.9);
|
|||
|
|
border-radius: 12px;
|
|||
|
|
background: #fff;
|
|||
|
|
box-shadow: 0 24px 64px rgba(37, 94, 160, 0.18);
|
|||
|
|
}
|
|||
|
|
.official-chat-header {
|
|||
|
|
min-height: 72px;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: 16px;
|
|||
|
|
padding: 16px;
|
|||
|
|
color: #fff;
|
|||
|
|
background: var(--gradient-action);
|
|||
|
|
}
|
|||
|
|
.official-chat-header p,
|
|||
|
|
.official-chat-header h2 {
|
|||
|
|
margin: 0;
|
|||
|
|
}
|
|||
|
|
.official-chat-header p {
|
|||
|
|
font-size: 12px;
|
|||
|
|
opacity: 0.86;
|
|||
|
|
}
|
|||
|
|
.official-chat-header h2 {
|
|||
|
|
margin-top: 4px;
|
|||
|
|
font-size: 18px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
.chat-icon-button,
|
|||
|
|
.toolbar-button,
|
|||
|
|
.send-button,
|
|||
|
|
.load-older-button,
|
|||
|
|
.chat-state-actions button,
|
|||
|
|
.new-message-badge {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
border: 0;
|
|||
|
|
transition:
|
|||
|
|
transform 0.2s,
|
|||
|
|
opacity 0.2s,
|
|||
|
|
background 0.2s,
|
|||
|
|
box-shadow 0.2s;
|
|||
|
|
}
|
|||
|
|
.chat-icon-button {
|
|||
|
|
width: 36px;
|
|||
|
|
height: 36px;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
color: #fff;
|
|||
|
|
background: rgba(255, 255, 255, 0.16);
|
|||
|
|
}
|
|||
|
|
.chat-icon-button:hover,
|
|||
|
|
.toolbar-button:hover,
|
|||
|
|
.send-button:hover,
|
|||
|
|
.chat-state-actions button:hover,
|
|||
|
|
.new-message-badge:hover {
|
|||
|
|
transform: translateY(-1px);
|
|||
|
|
}
|
|||
|
|
.chat-state-panel,
|
|||
|
|
.chat-inline-state,
|
|||
|
|
.chat-empty {
|
|||
|
|
display: grid;
|
|||
|
|
place-items: center;
|
|||
|
|
align-content: center;
|
|||
|
|
gap: 12px;
|
|||
|
|
min-height: 0;
|
|||
|
|
flex: 1;
|
|||
|
|
padding: 32px;
|
|||
|
|
color: #52657d;
|
|||
|
|
text-align: center;
|
|||
|
|
}
|
|||
|
|
.chat-state-panel strong {
|
|||
|
|
color: #2f4056;
|
|||
|
|
font-size: 18px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
.chat-state-panel p,
|
|||
|
|
.chat-empty {
|
|||
|
|
margin: 0;
|
|||
|
|
font-size: 14px;
|
|||
|
|
line-height: 1.6;
|
|||
|
|
}
|
|||
|
|
.chat-spinner {
|
|||
|
|
width: 32px;
|
|||
|
|
height: 32px;
|
|||
|
|
border: 3px solid #d7e9ff;
|
|||
|
|
border-top-color: var(--color-brand-primary);
|
|||
|
|
border-radius: 999px;
|
|||
|
|
animation: chat-spin 0.8s linear infinite;
|
|||
|
|
}
|
|||
|
|
.chat-state-actions {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 12px;
|
|||
|
|
}
|
|||
|
|
.chat-state-actions button {
|
|||
|
|
height: 40px;
|
|||
|
|
padding: 0 16px;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
color: #fff;
|
|||
|
|
background: var(--color-brand-primary);
|
|||
|
|
}
|
|||
|
|
.chat-state-actions .is-ghost {
|
|||
|
|
color: #41536a;
|
|||
|
|
background: #edf8ff;
|
|||
|
|
}
|
|||
|
|
.chat-message-list {
|
|||
|
|
position: relative;
|
|||
|
|
min-height: 0;
|
|||
|
|
flex: 1;
|
|||
|
|
overflow-x: hidden;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
padding: 16px;
|
|||
|
|
background: linear-gradient(180deg, #f9fbff, #edf8ff);
|
|||
|
|
}
|
|||
|
|
.chat-message-list::-webkit-scrollbar {
|
|||
|
|
width: 6px;
|
|||
|
|
}
|
|||
|
|
.chat-message-list::-webkit-scrollbar-track {
|
|||
|
|
background: transparent;
|
|||
|
|
}
|
|||
|
|
.chat-message-list::-webkit-scrollbar-thumb {
|
|||
|
|
border-radius: 999px;
|
|||
|
|
background: #cbd5e1;
|
|||
|
|
}
|
|||
|
|
.load-older-button {
|
|||
|
|
height: 32px;
|
|||
|
|
margin: 0 auto 16px;
|
|||
|
|
padding: 0 16px;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
color: #41536a;
|
|||
|
|
background: #fff;
|
|||
|
|
box-shadow: var(--shadow-card);
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
.time-label,
|
|||
|
|
.system-tip {
|
|||
|
|
width: fit-content;
|
|||
|
|
max-width: 80%;
|
|||
|
|
margin: 16px auto;
|
|||
|
|
padding: 4px 8px;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
color: #667a91;
|
|||
|
|
background: rgba(255, 255, 255, 0.76);
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
.message-row {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: flex-start;
|
|||
|
|
gap: 8px;
|
|||
|
|
margin-top: 12px;
|
|||
|
|
}
|
|||
|
|
.message-row.is-self {
|
|||
|
|
justify-content: flex-end;
|
|||
|
|
}
|
|||
|
|
.message-avatar {
|
|||
|
|
width: 32px;
|
|||
|
|
height: 32px;
|
|||
|
|
flex: 0 0 auto;
|
|||
|
|
display: grid;
|
|||
|
|
place-items: center;
|
|||
|
|
border: 2px solid #fff;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
color: #fff;
|
|||
|
|
box-shadow: 0 2px 8px rgba(37, 94, 160, 0.12);
|
|||
|
|
font-size: 12px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
.message-main {
|
|||
|
|
max-width: 72%;
|
|||
|
|
display: grid;
|
|||
|
|
gap: 4px;
|
|||
|
|
}
|
|||
|
|
.message-row.is-self .message-main {
|
|||
|
|
justify-items: end;
|
|||
|
|
}
|
|||
|
|
.message-name {
|
|||
|
|
color: #667a91;
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
.message-bubble {
|
|||
|
|
max-width: 100%;
|
|||
|
|
padding: 10px 12px;
|
|||
|
|
overflow-wrap: break-word;
|
|||
|
|
border: 1px solid rgba(215, 233, 255, 0.9);
|
|||
|
|
border-radius: 4px 12px 12px 12px;
|
|||
|
|
color: #2f4056;
|
|||
|
|
background: #fff;
|
|||
|
|
box-shadow: 0 1px 6px rgba(37, 94, 160, 0.06);
|
|||
|
|
font-size: 14px;
|
|||
|
|
line-height: 1.55;
|
|||
|
|
}
|
|||
|
|
.message-row.is-self .message-bubble {
|
|||
|
|
border-color: transparent;
|
|||
|
|
border-radius: 12px 4px 12px 12px;
|
|||
|
|
color: #fff;
|
|||
|
|
background: var(--color-brand-primary);
|
|||
|
|
}
|
|||
|
|
.message-text {
|
|||
|
|
margin: 0;
|
|||
|
|
white-space: pre-wrap;
|
|||
|
|
}
|
|||
|
|
.message-image-link,
|
|||
|
|
.message-video,
|
|||
|
|
.message-audio {
|
|||
|
|
display: block;
|
|||
|
|
max-width: 220px;
|
|||
|
|
}
|
|||
|
|
.message-image-link img,
|
|||
|
|
.message-video {
|
|||
|
|
max-width: 100%;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
}
|
|||
|
|
.message-file {
|
|||
|
|
display: grid;
|
|||
|
|
gap: 4px;
|
|||
|
|
color: inherit;
|
|||
|
|
text-decoration: none;
|
|||
|
|
}
|
|||
|
|
.message-file span,
|
|||
|
|
.message-main small {
|
|||
|
|
font-size: 12px;
|
|||
|
|
opacity: 0.76;
|
|||
|
|
}
|
|||
|
|
.custom-row.is-self {
|
|||
|
|
justify-content: flex-end;
|
|||
|
|
}
|
|||
|
|
.custom-card {
|
|||
|
|
max-width: 72%;
|
|||
|
|
padding: 12px;
|
|||
|
|
border: 1px solid rgba(215, 233, 255, 0.9);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
color: #2f4056;
|
|||
|
|
background: #fff;
|
|||
|
|
box-shadow: var(--shadow-card);
|
|||
|
|
font-size: 14px;
|
|||
|
|
}
|
|||
|
|
.new-message-badge {
|
|||
|
|
position: absolute;
|
|||
|
|
right: 50%;
|
|||
|
|
bottom: 104px;
|
|||
|
|
z-index: 2;
|
|||
|
|
height: 32px;
|
|||
|
|
padding: 0 12px;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
color: #fff;
|
|||
|
|
background: var(--color-brand-primary);
|
|||
|
|
box-shadow: var(--shadow-button);
|
|||
|
|
font-size: 12px;
|
|||
|
|
transform: translateX(50%);
|
|||
|
|
}
|
|||
|
|
.chat-input-area {
|
|||
|
|
display: grid;
|
|||
|
|
gap: 8px;
|
|||
|
|
padding: 12px;
|
|||
|
|
border-top: 1px solid rgba(215, 233, 255, 0.9);
|
|||
|
|
background: #fff;
|
|||
|
|
}
|
|||
|
|
.input-error {
|
|||
|
|
margin: 0;
|
|||
|
|
color: #d54b55;
|
|||
|
|
font-size: 12px;
|
|||
|
|
line-height: 1.45;
|
|||
|
|
}
|
|||
|
|
.chat-input-area textarea {
|
|||
|
|
width: 100%;
|
|||
|
|
min-height: 72px;
|
|||
|
|
max-height: 120px;
|
|||
|
|
resize: vertical;
|
|||
|
|
border: 1px solid #dbe7f5;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
outline: 0;
|
|||
|
|
padding: 10px 12px;
|
|||
|
|
color: #2f4056;
|
|||
|
|
background: #f9fbff;
|
|||
|
|
font: inherit;
|
|||
|
|
font-size: 14px;
|
|||
|
|
line-height: 1.55;
|
|||
|
|
transition:
|
|||
|
|
border-color 0.2s,
|
|||
|
|
box-shadow 0.2s,
|
|||
|
|
background 0.2s;
|
|||
|
|
}
|
|||
|
|
.chat-input-area textarea:focus {
|
|||
|
|
border-color: #66a8fa;
|
|||
|
|
background: #fff;
|
|||
|
|
box-shadow: 0 0 0 4px rgba(43, 128, 239, 0.1);
|
|||
|
|
}
|
|||
|
|
.chat-toolbar {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
}
|
|||
|
|
.toolbar-button {
|
|||
|
|
width: 36px;
|
|||
|
|
height: 36px;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
color: #2379e8;
|
|||
|
|
background: #edf8ff;
|
|||
|
|
}
|
|||
|
|
.toolbar-button:disabled,
|
|||
|
|
.send-button:disabled,
|
|||
|
|
.load-older-button:disabled {
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
opacity: 0.58;
|
|||
|
|
transform: none;
|
|||
|
|
}
|
|||
|
|
.toolbar-hint {
|
|||
|
|
flex: 1;
|
|||
|
|
color: #667a91;
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
.send-button {
|
|||
|
|
height: 36px;
|
|||
|
|
gap: 6px;
|
|||
|
|
padding: 0 14px;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
color: #fff;
|
|||
|
|
background: var(--color-brand-primary);
|
|||
|
|
box-shadow: var(--shadow-button);
|
|||
|
|
font-size: 14px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
@keyframes chat-spin {
|
|||
|
|
to {
|
|||
|
|
transform: rotate(360deg);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
@media (max-width: 640px) {
|
|||
|
|
.official-chat {
|
|||
|
|
width: 100vw;
|
|||
|
|
height: min(720px, calc(100dvh - 24px));
|
|||
|
|
border-radius: 12px 12px 0 0;
|
|||
|
|
}
|
|||
|
|
.message-main,
|
|||
|
|
.custom-card {
|
|||
|
|
max-width: 78%;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
|