hulai-website/components/common/FloatingContact.vue

247 行
6.3 KiB
Vue

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<template>
<div v-if="visible" class="floating-contact-wrap">
<!-- 折叠态小圆点点击展开 -->
<button
v-if="collapsed"
type="button"
class="floating-contact floating-contact--mini"
aria-label="打开咨询客服"
@click="expand"
>
<span class="fc-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
</span>
</button>
<!-- 展开态胶囊按钮 + 关闭小钮 -->
<NuxtLink
v-else
:to="target"
class="floating-contact"
:aria-label="ariaLabel"
@click="onClick"
>
<span class="fc-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
</span>
<span class="fc-label">{{ label }}</span>
<span class="fc-pulse" aria-hidden="true"></span>
</NuxtLink>
<button
v-if="!collapsed"
type="button"
class="floating-contact-close"
aria-label="收起咨询客服"
@click.stop="collapse"
>
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
</template>
<script setup>
const route = useRoute()
const { data: brand } = await usePublicApi('brand')
const STORAGE_KEY = 'hulai:floatingContact:collapsed'
const collapsed = ref(false)
onMounted(() => {
try {
collapsed.value = sessionStorage.getItem(STORAGE_KEY) === '1'
} catch {}
})
function collapse() {
collapsed.value = true
try { sessionStorage.setItem(STORAGE_KEY, '1') } catch {}
track('collapse')
}
function expand() {
collapsed.value = false
try { sessionStorage.removeItem(STORAGE_KEY) } catch {}
track('expand')
}
function track(action) {
try {
if (typeof window !== 'undefined' && typeof window.gtag === 'function') {
window.gtag('event', 'floating_contact_' + action, { page_path: route.path })
}
if (typeof window !== 'undefined' && window._hmt && Array.isArray(window._hmt)) {
window._hmt.push(['_trackEvent', 'FloatingContact', action, route.path])
}
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('floating-contact', { detail: { action, path: route.path } }))
}
} catch {}
}
function onClick() {
track('click')
}
// 后台可配brand.floatingCTA = { enabled, text, to, hideOnPaths }
const config = computed(() => brand.value?.floatingCTA || {})
const label = computed(() => config.value.text || '咨询客服')
const target = computed(() => config.value.to || '/contact')
const ariaLabel = computed(() => label.value)
const hideOnPaths = computed(() => {
const p = config.value.hideOnPaths
if (Array.isArray(p) && p.length) return p
return ['/contact']
})
const visible = computed(() => {
if (config.value.enabled === false) return false
const cur = route.path
return !hideOnPaths.value.some(p => cur === p || (p.endsWith('/') ? cur.startsWith(p) : cur.startsWith(p + '/')) || cur === p)
})
</script>
<style lang="less" scoped>
.floating-contact-wrap {
position: fixed;
right: 20px;
bottom: 24px;
z-index: 90;
@media (max-width: 767px) {
right: 16px;
bottom: calc(20px + env(safe-area-inset-bottom, 0px));
}
.respond-md({
bottom: calc(28px + env(safe-area-inset-bottom, 0px));
right: 28px;
});
}
.floating-contact {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 18px 0 14px;
height: 52px;
border-radius: 999px;
background: linear-gradient(135deg, @color-primary 0%, @color-primary-light 100%);
color: #fff;
text-decoration: none;
font-size: @font-size-sm;
font-weight: @font-weight-medium;
letter-spacing: 0.5px;
border: none;
cursor: pointer;
box-shadow:
0 10px 24px -6px rgba(45, 106, 79, 0.45),
0 4px 10px -2px rgba(0, 0, 0, 0.15);
transition: transform 0.25s ease, box-shadow 0.25s ease, background 0.25s ease;
will-change: transform;
&:hover {
transform: translateY(-2px);
box-shadow:
0 14px 30px -6px rgba(45, 106, 79, 0.55),
0 6px 14px -2px rgba(0, 0, 0, 0.18);
}
&:active {
transform: translateY(0);
}
&--mini {
width: 44px;
height: 44px;
padding: 0;
justify-content: center;
opacity: 0.85;
&:hover { opacity: 1; }
}
@media (max-width: 767px) {
width: 52px;
height: 52px;
padding: 0;
justify-content: center;
.fc-label { display: none; }
&--mini {
width: 44px;
height: 44px;
}
}
}
.floating-contact-close {
position: absolute;
top: -6px;
right: -6px;
width: 22px;
height: 22px;
border-radius: 50%;
background: #fff;
color: #374151;
border: 1px solid rgba(0,0,0,0.08);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
box-shadow: 0 2px 6px rgba(0,0,0,0.12);
transition: background 0.2s ease, transform 0.2s ease;
&:hover {
background: #f3f4f6;
transform: scale(1.08);
}
svg { display: block; }
}
.fc-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
}
.fc-label {
white-space: nowrap;
line-height: 1;
}
.fc-pulse {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
box-shadow: 0 0 0 0 rgba(64, 145, 108, 0.55);
animation: fc-pulse 2.4s ease-out infinite;
}
@keyframes fc-pulse {
0% { box-shadow: 0 0 0 0 rgba(64, 145, 108, 0.5); }
70% { box-shadow: 0 0 0 14px rgba(64, 145, 108, 0); }
100% { box-shadow: 0 0 0 0 rgba(64, 145, 108, 0); }
}
@media (prefers-reduced-motion: reduce) {
.fc-pulse { animation: none; }
.floating-contact { transition: none; }
}
</style>