Files
Zhanglj 2ff1064de7 feat: add customer service chat, optimize case and homepage, fix api proxy
这一提交完成了多项功能升级与优化:
1.  新增全局浮动客服组件,集成腾讯云TRTC聊天能力并添加错误格式化工具
2.  新增图片资源并调整部分静态文件结构
3.  优化案例页面:重构筛选逻辑、新增空状态处理、添加加载更多功能
4.  优化首页布局:修复统计数字动画、调整移动端适配样式、优化轮播逻辑
5.  重构官方内容获取工具:添加缓存机制、优化媒体资源处理
6.  优化API代理配置,移除默认本地代理并新增环境变量配置
7.  新增页面数据恢复刷新逻辑,优化页面交互体验
8.  修复按钮组件冗余代码,调整头部导航移动端样式
2026-07-24 18:55:51 +08:00

853 lines
23 KiB
Vue
Raw Permalink 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>
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>