feat: add customer service chat, optimize case and homepage, fix api proxy

这一提交完成了多项功能升级与优化:
1.  新增全局浮动客服组件,集成腾讯云TRTC聊天能力并添加错误格式化工具
2.  新增图片资源并调整部分静态文件结构
3.  优化案例页面:重构筛选逻辑、新增空状态处理、添加加载更多功能
4.  优化首页布局:修复统计数字动画、调整移动端适配样式、优化轮播逻辑
5.  重构官方内容获取工具:添加缓存机制、优化媒体资源处理
6.  优化API代理配置,移除默认本地代理并新增环境变量配置
7.  新增页面数据恢复刷新逻辑,优化页面交互体验
8.  修复按钮组件冗余代码,调整头部导航移动端样式
This commit is contained in:
2026-07-24 18:55:51 +08:00
parent 314cd6ccd7
commit 2ff1064de7
25 changed files with 4596 additions and 327 deletions
+9 -3
View File
@@ -1,4 +1,4 @@
<script setup>
<script setup>
import {
ArrowLeft,
ArrowRight,
@@ -9,8 +9,11 @@ import {
Menu,
Message,
Phone,
Picture,
Plus,
Search
Promotion,
Search,
Service
} from '@element-plus/icons-vue'
const props = defineProps({
@@ -30,8 +33,11 @@ const icons = {
menu: Menu,
message: Message,
phone: Phone,
picture: Picture,
plus: Plus,
search: Search
promotion: Promotion,
search: Search,
service: Service
}
const icon = computed(() => icons[props.name] || ArrowRight)
+151
View File
@@ -0,0 +1,151 @@
<script setup>
const route = useRoute()
const isOpen = ref(false)
const context = ref({ title: '在线客服', source: '' })
const isInteriorPage = computed(() => route.path !== '/')
function openFloatingChat() {
context.value = {
title: '在线客服',
source: isInteriorPage.value ? '' : ''
}
isOpen.value = true
}
function closeChat() {
isOpen.value = false
}
function onKeydown(event) {
if (event.key === 'Escape' && isOpen.value) closeChat()
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
<Teleport to="body">
<Transition name="consult-float">
<button
v-if="!isOpen"
class="consult-float-button"
:class="{ 'is-interior': isInteriorPage }"
type="button"
aria-label="打开在线客服"
title="在线客服"
@click="openFloatingChat"
>
<AppIcon name="service" />
<span>客服</span>
</button>
</Transition>
<Transition name="consult-panel">
<div v-if="isOpen" class="consult-chat-shell" :class="{ 'is-interior': isInteriorPage }">
<ClientOnly>
<ChatOfficialChatWindow :context="context" @close="closeChat" />
<template #fallback>
<section class="consult-client-loading">
<strong>正在打开在线客服...</strong>
</section>
</template>
</ClientOnly>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.consult-float-button {
position: fixed;
z-index: 46;
right: clamp(18px, 2.7vw, 12px);
bottom: clamp(18px, 25vw, 520px);
min-width: 88px;
height: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 16px;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 999px;
color: #fff;
background: var(--gradient-action);
box-shadow: 0 16px 34px rgba(35, 111, 231, 0.26);
font-size: 14px;
font-weight: 600;
transition:
transform 0.2s,
box-shadow 0.2s,
opacity 0.2s;
}
.consult-float-button :deep(.app-icon) {
width: 20px;
height: 20px;
}
.consult-float-button:hover {
transform: translateY(-3px);
box-shadow: 0 20px 40px rgba(35, 111, 231, 0.34);
}
.consult-float-button:active {
transform: translateY(1px) scale(0.98);
}
.consult-chat-shell {
position: fixed;
z-index: 90;
right: clamp(16px, 2.4vw, 36px);
bottom: clamp(16px, 2.4vw, 36px);
}
.consult-chat-shell.is-interior {
bottom: calc(clamp(16px, 2.4vw, 36px) + 58px);
}
.consult-client-loading {
width: min(420px, calc(100vw - 32px));
height: 180px;
display: grid;
place-items: center;
border-radius: 12px;
color: #2f4056;
background: #fff;
box-shadow: 0 24px 64px rgba(37, 94, 160, 0.18);
}
.consult-float-enter-active,
.consult-float-leave-active,
.consult-panel-enter-active,
.consult-panel-leave-active {
transition:
opacity 0.2s,
transform 0.24s cubic-bezier(0.22, 1, 0.36, 1);
}
.consult-float-enter-from,
.consult-float-leave-to {
opacity: 0;
transform: translateY(12px) scale(0.96);
}
.consult-panel-enter-from,
.consult-panel-leave-to {
opacity: 0;
transform: translateY(16px) scale(0.98);
}
@media (max-width: 640px) {
.consult-float-button {
right: 16px;
bottom: 16px;
min-width: 80px;
height: 44px;
}
.consult-float-button.is-interior {
bottom: 70px;
}
.consult-chat-shell,
.consult-chat-shell.is-interior {
right: 0;
bottom: 0;
left: 0;
}
}
</style>
@@ -0,0 +1,852 @@
<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>
+84 -10
View File
@@ -1,6 +1,73 @@
<script setup>
const route = useRoute()
const { openConsultation } = useConsultation()
const { fetchPublicMediaAsset, fetchPublicSiteConfig } = useOfficialContent()
const { data: siteConfigRows } = await useAsyncData('public-site-config-footer', () =>
fetchPublicSiteConfig()
)
function parseConfigValue(value, fallback) {
if (value == null || value === '') return fallback
if (typeof value !== 'string') return value
try {
return JSON.parse(value)
} catch {
return fallback
}
}
const siteConfig = computed(() => siteConfigRows.value || {})
const brandConfig = computed(() => parseConfigValue(siteConfig.value.BRAND_PROFILE, {}))
const contactConfig = computed(() => parseConfigValue(siteConfig.value.CONTACT, {}))
const footerConfig = computed(() => parseConfigValue(siteConfig.value.FOOTER, {}))
const companyName = computed(
() => brandConfig.value.companyName || brandConfig.value.name || '成都大马棒传媒有限公司'
)
const companyIntro = computed(
() =>
brandConfig.value.description ||
brandConfig.value.slogan ||
'聚焦 AI 搜索增长,用可信内容、全域信源与转化承接帮助品牌建立长期数字资产。'
)
const contactAddress = computed(
() => contactConfig.value.address || '成都市武侯区芯通科技大厦B座9楼'
)
const contactPhone = computed(() => contactConfig.value.phone || '待补充')
const contactEmail = computed(() => contactConfig.value.email || '待补充')
const copyrightText = computed(
() => footerConfig.value.copyright || `© 2026 ${companyName.value}`
)
const legalText = computed(() => {
const items = [footerConfig.value.icp || '备案信息待补充', footerConfig.value.privacyText || '隐私说明']
return items.filter(Boolean).join(' · ')
})
const footerQrAssetId = computed(() => footerConfig.value.qrAssetId || '')
const isDirectAssetUrl = (value) =>
/^(https?:)?\/\//.test(value) || value.startsWith('/') || value.startsWith('data:image/')
const directQrSrc = computed(() => {
const value = String(footerQrAssetId.value || '').trim()
return value && isDirectAssetUrl(value) ? value : ''
})
const { data: footerQrAsset } = await useAsyncData(
'public-site-config-footer-qr',
() => {
const id = String(footerQrAssetId.value || '').trim()
if (!id || isDirectAssetUrl(id)) return null
return fetchPublicMediaAsset(id).catch(() => null)
},
{ watch: [footerQrAssetId] }
)
const footerQrSrc = computed(
() =>
directQrSrc.value ||
footerQrAsset.value?.url ||
footerQrAsset.value?.fileUrl ||
footerQrAsset.value?.filePath ||
''
)
const footerQrAlt = computed(() => footerQrAsset.value?.alt || footerQrAsset.value?.name || '社媒二维码')
const sourceLabel = computed(() => {
const labels = {
@@ -22,8 +89,8 @@ const sourceLabel = computed(() => {
<footer class="subsite-footer" :class="{ 'is-interior': route.path !== '/' }">
<div class="page-width footer-content">
<div class="footer-company">
<img src="/images/lanhu/slices/home-group114-image33@2x.png" alt="成都大马棒传媒有限公司" />
<p>聚焦 AI 搜索增长用可信内容全域信源与转化承接帮助品牌建立长期数字资产</p>
<img src="/images/lanhu/slices/home-group114-image33@2x.png" :alt="companyName" />
<p>{{ companyIntro }}</p>
<button type="button" @click="openConsultation({ source: sourceLabel })">
<span>预约免费诊断</span>
<AppIcon name="arrow-right" />
@@ -49,23 +116,22 @@ const sourceLabel = computed(() => {
</div>
<div>
<strong>联系我们</strong>
<span class="contact-line"
><AppIcon name="location" />成都市武侯区芯通科技大厦B座9楼</span
>
<span class="contact-line"><AppIcon name="phone" />联系电话待补充</span>
<span class="contact-line"><AppIcon name="message" />商务邮箱待补充</span>
<span class="contact-line"><AppIcon name="location" />{{ contactAddress }}</span>
<span class="contact-line"><AppIcon name="phone" />联系电话{{ contactPhone }}</span>
<span class="contact-line"><AppIcon name="message" />商务邮箱{{ contactEmail }}</span>
</div>
</nav>
<div class="footer-qr" aria-label="专属顾问二维码占位">
<span class="qr-pattern" aria-hidden="true"></span>
<div class="footer-qr" aria-label="社媒二维码">
<img v-if="footerQrSrc" class="qr-image" :src="footerQrSrc" :alt="footerQrAlt" loading="lazy" />
<span v-else class="qr-pattern" aria-hidden="true"></span>
<strong>扫码添加专属顾问</strong>
<small>获取 1 1 GEO 增长建议</small>
</div>
</div>
<div class="footer-legal">
<div class="page-width">
<span>© 2026 成都大马棒传媒有限公司</span><span>备案信息待补充 · 隐私说明</span>
<span>{{ copyrightText }}</span><span>{{ legalText }}</span>
</div>
</div>
</footer>
@@ -177,6 +243,14 @@ const sourceLabel = computed(() => {
justify-items: center;
text-align: center;
}
.qr-image {
width: 86px;
height: 86px;
display: block;
border: 7px solid #fff;
background: #fff;
object-fit: contain;
}
.qr-pattern {
width: 86px;
height: 86px;
+7 -4
View File
@@ -241,6 +241,7 @@ watch(
@media (max-width: 820px) {
.subsite-header {
height: 58px;
overflow: visible;
}
.subsite-brand {
width: 158px;
@@ -253,7 +254,6 @@ watch(
position: relative;
width: 42px;
height: 42px;
display: block;
margin-left: auto;
display: grid;
place-items: center;
@@ -270,20 +270,23 @@ watch(
height: 20px;
}
.subsite-mobile-nav {
position: fixed;
position: absolute;
z-index: 29;
top: 58px;
top: 100%;
right: 0;
bottom: 0;
left: 0;
display: block;
height: calc(100dvh - 58px);
overflow-y: auto;
border-top: 1px solid rgba(48, 112, 192, 0.08);
background: rgba(247, 251, 255, 0.98);
box-shadow: 0 18px 34px rgba(61, 116, 185, 0.12);
backdrop-filter: blur(20px);
}
.subsite-mobile-nav .page-width {
display: grid;
padding-top: 20px;
padding-bottom: 28px;
}
.subsite-mobile-nav a,
.subsite-mobile-nav button {
+2
View File
@@ -15,6 +15,8 @@ defineProps({
: 'bg-primary text-white shadow-sm hover:bg-blue-600'
"
>
<slot />
</button>
</template>