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:
@@ -7,4 +7,5 @@ const isInteriorPage = computed(() => route.path !== '/')
|
||||
<NuxtPage />
|
||||
<InteriorPageTools v-if="isInteriorPage" />
|
||||
<ConsultationModal />
|
||||
<CustomerChatFloating />
|
||||
</template>
|
||||
|
||||
+154
-68
@@ -275,8 +275,8 @@ a:focus-visible {
|
||||
background 0.32s ease;
|
||||
}
|
||||
.stat-item img {
|
||||
width: 86px;
|
||||
height: 86px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
object-fit: contain;
|
||||
margin-bottom: 19px;
|
||||
}
|
||||
@@ -465,10 +465,10 @@ a:focus-visible {
|
||||
}
|
||||
.lead-advantage {
|
||||
display: grid;
|
||||
grid-template-columns: 47% 1fr;
|
||||
align-items: center;
|
||||
grid-template-columns: minmax(0, 47%) minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
gap: 24px;
|
||||
height: 182px;
|
||||
min-height: 182px;
|
||||
overflow: hidden;
|
||||
margin-top: 56px;
|
||||
border-radius: 13px;
|
||||
@@ -478,12 +478,16 @@ a:focus-visible {
|
||||
box-shadow 0.32s ease;
|
||||
}
|
||||
.lead-advantage > img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: 182px;
|
||||
max-height: 182px;
|
||||
object-fit: cover;
|
||||
transition: transform 0.55s cubic-bezier(0.2, 0.75, 0.2, 1);
|
||||
}
|
||||
.lead-advantage > div {
|
||||
align-self: center;
|
||||
min-width: 0;
|
||||
padding: 16px 30px;
|
||||
}
|
||||
.lead-advantage h3,
|
||||
@@ -593,15 +597,31 @@ a:focus-visible {
|
||||
.industries-section {
|
||||
padding: 80px 0 111px;
|
||||
}
|
||||
.industries-content {
|
||||
width: 100%;
|
||||
}
|
||||
.industry-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 30px;
|
||||
margin-top: 76px;
|
||||
overflow-x: auto;
|
||||
margin: 76px auto 0;
|
||||
padding: 0 24px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.industry-grid::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.industry-grid article {
|
||||
position: relative;
|
||||
min-height: 311px;
|
||||
flex: 0 0 min(560px, calc(100vw - 48px));
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 220px) minmax(0, 1fr);
|
||||
grid-template-rows: auto 25px auto minmax(0, 1fr) auto;
|
||||
column-gap: 18px;
|
||||
min-height: 324px;
|
||||
overflow: hidden;
|
||||
padding: 26px;
|
||||
border-radius: 12px;
|
||||
@@ -611,31 +631,41 @@ a:focus-visible {
|
||||
box-shadow 0.32s ease;
|
||||
}
|
||||
.industry-grid h3 {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
color: #414141;
|
||||
font-size: 26px;
|
||||
}
|
||||
.industry-grid img {
|
||||
width: 45%;
|
||||
height: 150px;
|
||||
object-fit: contain;
|
||||
float: left;
|
||||
margin: 25px 15px 0 -8px;
|
||||
grid-row: 3 / 6;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
float: none;
|
||||
margin: 0;
|
||||
border-radius: 9px;
|
||||
}
|
||||
.quote {
|
||||
margin-top: 33px;
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
margin-top: 8px;
|
||||
color: #787878;
|
||||
font-weight: 700;
|
||||
font-size: 43px;
|
||||
line-height: 0.55;
|
||||
}
|
||||
.industry-grid p {
|
||||
margin: 19px 0 14px;
|
||||
grid-column: 2;
|
||||
grid-row: 4;
|
||||
margin: 12px 0 14px;
|
||||
color: #777;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.tags {
|
||||
grid-column: 2;
|
||||
grid-row: 5;
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
}
|
||||
@@ -696,24 +726,34 @@ a:focus-visible {
|
||||
padding: 88px 0 78px;
|
||||
background: #fff;
|
||||
}
|
||||
.testimonial-section .page-width {
|
||||
overflow: hidden;
|
||||
}
|
||||
.testimonial-track {
|
||||
--testimonial-gap: 24px;
|
||||
--testimonial-loop-offset: 12px;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
width: max-content;
|
||||
gap: var(--testimonial-gap);
|
||||
width: 100%;
|
||||
margin-top: 62px;
|
||||
transform: translateX(-520px);
|
||||
}
|
||||
.testimonial-track.is-centered {
|
||||
justify-content: center;
|
||||
}
|
||||
.testimonial-track.is-carousel {
|
||||
width: max-content;
|
||||
animation: testimonial-carousel 34s linear infinite;
|
||||
}
|
||||
.testimonial-track.is-carousel:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
.testimonial-track article {
|
||||
width: 460px;
|
||||
flex: 0 0 400px;
|
||||
min-height: 216px;
|
||||
padding: 25px 27px;
|
||||
border-radius: 10px;
|
||||
background: #f9fbff;
|
||||
color: #767676;
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
box-shadow 0.3s ease,
|
||||
background 0.3s ease;
|
||||
}
|
||||
.testimonial-track header {
|
||||
display: flex;
|
||||
@@ -726,6 +766,7 @@ a:focus-visible {
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #516b71, #f3bc94);
|
||||
object-fit: cover;
|
||||
}
|
||||
.testimonial-track time {
|
||||
margin-left: auto;
|
||||
@@ -736,6 +777,11 @@ a:focus-visible {
|
||||
font-size: 15px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
@keyframes testimonial-carousel {
|
||||
to {
|
||||
transform: translate3d(calc(-50% - var(--testimonial-loop-offset)), 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.cta-section {
|
||||
position: relative;
|
||||
@@ -803,11 +849,6 @@ a:focus-visible {
|
||||
box-shadow: 0 15px 30px rgba(63, 135, 239, 0.16);
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
.testimonial-track article:hover {
|
||||
background: #fff;
|
||||
box-shadow: 0 16px 28px rgba(72, 135, 226, 0.13);
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 761px) {
|
||||
@@ -815,19 +856,7 @@ a:focus-visible {
|
||||
will-change: transform;
|
||||
}
|
||||
.section-title h2::after {
|
||||
position: absolute;
|
||||
right: 42%;
|
||||
bottom: -13px;
|
||||
left: 42%;
|
||||
height: 2px;
|
||||
content: '';
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, transparent, rgba(20, 120, 251, 0.78), transparent);
|
||||
opacity: 0;
|
||||
transform: scaleX(0);
|
||||
transition:
|
||||
transform 0.75s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 0.45s ease;
|
||||
display: none;
|
||||
}
|
||||
.section-title.is-visible h2::after {
|
||||
opacity: 1;
|
||||
@@ -855,12 +884,14 @@ a:focus-visible {
|
||||
box-shadow: inset 0 0 0 1px rgba(113, 169, 255, 0.24);
|
||||
opacity: var(--spot-opacity);
|
||||
}
|
||||
.case-content .motion-card::after {
|
||||
box-shadow: none;
|
||||
}
|
||||
.motion-card img {
|
||||
will-change: transform;
|
||||
}
|
||||
.case-background,
|
||||
.delivery-banner,
|
||||
.testimonial-track {
|
||||
.delivery-banner {
|
||||
will-change: transform;
|
||||
}
|
||||
}
|
||||
@@ -874,6 +905,11 @@ a:focus-visible {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
.testimonial-track.is-carousel {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
@@ -895,35 +931,72 @@ a:focus-visible {
|
||||
width: min(100% - 32px, 560px);
|
||||
}
|
||||
.hero-section {
|
||||
height: min(720px, calc(100svh - 58px));
|
||||
min-height: 570px;
|
||||
height: auto;
|
||||
min-height: min(700px, calc(100svh - 58px));
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 82% 78%, rgba(68, 165, 255, 0.24), transparent 36%),
|
||||
linear-gradient(180deg, #f7fbff 0%, #eaf3ff 56%, #dfeeff 100%);
|
||||
}
|
||||
.hero-section::after {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 0;
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(247, 251, 255, 0.98) 0%,
|
||||
rgba(247, 251, 255, 0.92) 38%,
|
||||
rgba(247, 251, 255, 0.46) 62%,
|
||||
rgba(247, 251, 255, 0.08) 100%
|
||||
);
|
||||
}
|
||||
.hero-art {
|
||||
background-position: right bottom;
|
||||
background-size: auto 74%;
|
||||
opacity: 0.72;
|
||||
z-index: 0;
|
||||
inset: auto -54% -28px -50%;
|
||||
height: 58%;
|
||||
background-position: center bottom;
|
||||
background-size: min(760px, 170vw) auto;
|
||||
opacity: 0.9;
|
||||
filter: saturate(1.06) contrast(1.02);
|
||||
-webkit-mask-image: linear-gradient(to top, #000 76%, transparent 100%);
|
||||
mask-image: linear-gradient(to top, #000 76%, transparent 100%);
|
||||
will-change: transform;
|
||||
}
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: inherit;
|
||||
align-items: flex-start;
|
||||
padding-top: clamp(72px, 13vh, 112px);
|
||||
padding-top: clamp(62px, 10svh, 92px);
|
||||
padding-bottom: min(43vh, 310px);
|
||||
}
|
||||
.hero-copy {
|
||||
margin: 0;
|
||||
max-width: 340px;
|
||||
max-width: 360px;
|
||||
}
|
||||
.hero-copy h1 {
|
||||
font-size: clamp(28px, 8.2vw, 36px);
|
||||
line-height: 1.28;
|
||||
letter-spacing: -0.5px;
|
||||
letter-spacing: 0;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.hero-points {
|
||||
display: grid;
|
||||
margin: 22px 0 27px;
|
||||
gap: 10px;
|
||||
gap: 9px;
|
||||
font-size: 13px;
|
||||
max-width: 300px;
|
||||
max-width: 320px;
|
||||
}
|
||||
.hero-points span {
|
||||
width: fit-content;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgba(70, 139, 230, 0.12);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
box-shadow: 0 8px 20px rgba(57, 124, 216, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.button-row {
|
||||
width: min(100%, 310px);
|
||||
@@ -962,7 +1035,7 @@ a:focus-visible {
|
||||
}
|
||||
.section-title p {
|
||||
max-width: 34em;
|
||||
margin: 11px auto 0;
|
||||
margin: 15px auto 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -1048,7 +1121,6 @@ a:focus-visible {
|
||||
padding: 19px;
|
||||
}
|
||||
.advantage-grid,
|
||||
.industry-grid,
|
||||
.delivery-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
@@ -1089,16 +1161,21 @@ a:focus-visible {
|
||||
}
|
||||
.industry-grid {
|
||||
margin-top: 40px;
|
||||
gap: 16px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.industry-grid article {
|
||||
min-height: 280px;
|
||||
flex-basis: min(92vw, 430px);
|
||||
grid-template-columns: minmax(0, 43%) minmax(0, 1fr);
|
||||
min-height: 290px;
|
||||
padding: 23px;
|
||||
}
|
||||
.industry-grid h3 {
|
||||
font-size: 23px;
|
||||
}
|
||||
.industry-grid img {
|
||||
height: 130px;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.delivery-banner {
|
||||
height: 118px;
|
||||
@@ -1114,19 +1191,19 @@ a:focus-visible {
|
||||
font-size: 18px;
|
||||
}
|
||||
.testimonial-track {
|
||||
transform: none;
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
--testimonial-gap: 16px;
|
||||
--testimonial-loop-offset: 8px;
|
||||
gap: var(--testimonial-gap);
|
||||
margin-top: 35px;
|
||||
padding: 0 16px 16px;
|
||||
margin-right: -16px;
|
||||
margin-left: -16px;
|
||||
}
|
||||
.testimonial-track.is-centered {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 16px;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.testimonial-track article {
|
||||
min-width: min(82vw, 330px);
|
||||
width: min(82vw, 330px);
|
||||
flex-basis: min(82vw, 330px);
|
||||
scroll-snap-align: center;
|
||||
}
|
||||
.cta-section {
|
||||
@@ -1153,11 +1230,20 @@ a:focus-visible {
|
||||
width: calc(100% - 24px);
|
||||
}
|
||||
.hero-content {
|
||||
padding-top: 62px;
|
||||
padding-top: 54px;
|
||||
padding-bottom: 260px;
|
||||
}
|
||||
.hero-copy h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
.hero-art {
|
||||
right: -72%;
|
||||
left: -70%;
|
||||
height: 54%;
|
||||
}
|
||||
.button-row {
|
||||
width: min(100%, 300px);
|
||||
}
|
||||
.stats-panel {
|
||||
gap: 9px;
|
||||
padding: 10px;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,6 +15,8 @@ defineProps({
|
||||
: 'bg-primary text-white shadow-sm hover:bg-blue-600'
|
||||
"
|
||||
>
|
||||
|
||||
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,8 @@ export function useConsultation() {
|
||||
const context = useState('consultation-context', () => ({
|
||||
title: '预约免费 GEO 诊断',
|
||||
source: '全站咨询入口',
|
||||
industry: ''
|
||||
industry: '',
|
||||
need: ''
|
||||
}))
|
||||
const form = useState('consultation-form', () => ({
|
||||
name: '',
|
||||
@@ -19,10 +20,12 @@ export function useConsultation() {
|
||||
context.value = {
|
||||
title: payload.title || '预约免费 GEO 诊断',
|
||||
source: payload.source || '全站咨询入口',
|
||||
industry: payload.industry || ''
|
||||
industry: payload.industry || '',
|
||||
need: payload.need || ''
|
||||
}
|
||||
|
||||
if (payload.industry) form.value.industry = payload.industry
|
||||
form.value.need = payload.need || ''
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,32 @@ const ALL_LABEL = '全部'
|
||||
const LATEST_LABEL = '最新文章'
|
||||
const FALLBACK_IMAGE = '/images/lanhu/slices/home-group95-image21@2x.png'
|
||||
|
||||
function isUsableUrl(value) {
|
||||
const url = String(value || '').trim()
|
||||
if (!url || url === '#') return false
|
||||
return !/^javascript:/i.test(url)
|
||||
}
|
||||
|
||||
function pickUrl(asset) {
|
||||
return asset?.url || asset?.fileUrl || asset?.filePath || ''
|
||||
return [asset?.url, asset?.fileUrl, asset?.filePath].find(isUsableUrl) || ''
|
||||
}
|
||||
|
||||
function buildMediaMap(media = []) {
|
||||
return new Map(media.map((asset) => [asset.id, asset]))
|
||||
}
|
||||
|
||||
function noCacheOptions(query = {}) {
|
||||
return {
|
||||
query: {
|
||||
...query,
|
||||
_t: Date.now()
|
||||
},
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toTags(value) {
|
||||
if (Array.isArray(value)) return value.filter(Boolean)
|
||||
return String(value || '')
|
||||
@@ -29,23 +47,57 @@ function bySortAndTime(left, right) {
|
||||
|
||||
export function useOfficialContent() {
|
||||
const config = useRuntimeConfig()
|
||||
const serverApiBase = `${String(config.officialApiProxyTarget || 'http://localhost:8080').replace(/\/$/, '')}/api`
|
||||
const apiBase = import.meta.server ? serverApiBase : (config.public.officialApiBase || '/api')
|
||||
const nuxtApp = useNuxtApp()
|
||||
const apiBase = config.public.officialApiBase || '/api'
|
||||
const siteConfigCache = useState('public-site-config-cache', () => null)
|
||||
const siteConfigLoaded = useState('public-site-config-loaded', () => false)
|
||||
|
||||
function fetchPublicContent(type) {
|
||||
console.log(apiBase)
|
||||
return $fetch('/public/content', {
|
||||
baseURL: apiBase,
|
||||
query: { type }
|
||||
...noCacheOptions({ type })
|
||||
})
|
||||
}
|
||||
|
||||
function fetchPublicCategories() {
|
||||
return $fetch('/public/categories', {
|
||||
baseURL: apiBase,
|
||||
...noCacheOptions()
|
||||
})
|
||||
}
|
||||
|
||||
function fetchHomepageSections() {
|
||||
return $fetch('/public/homepage/sections', { baseURL: apiBase })
|
||||
return $fetch('/public/homepage/sections', {
|
||||
baseURL: apiBase,
|
||||
...noCacheOptions()
|
||||
})
|
||||
}
|
||||
|
||||
function fetchPublicSiteConfig() {
|
||||
return $fetch('/public/site-config', { baseURL: apiBase })
|
||||
function fetchPublicSiteConfig(options = {}) {
|
||||
if (!options.fresh && siteConfigLoaded.value) return siteConfigCache.value || {}
|
||||
|
||||
nuxtApp._publicSiteConfigPromise ||= $fetch('/public/site-config', {
|
||||
baseURL: apiBase,
|
||||
...noCacheOptions()
|
||||
})
|
||||
.then((rows) => {
|
||||
siteConfigCache.value = rows || {}
|
||||
siteConfigLoaded.value = true
|
||||
return siteConfigCache.value
|
||||
})
|
||||
.finally(() => {
|
||||
nuxtApp._publicSiteConfigPromise = null
|
||||
})
|
||||
|
||||
return nuxtApp._publicSiteConfigPromise
|
||||
}
|
||||
|
||||
function fetchPublicMediaAsset(id) {
|
||||
if (!id) return null
|
||||
return $fetch(`/public/media/${encodeURIComponent(id)}`, {
|
||||
baseURL: apiBase,
|
||||
...noCacheOptions()
|
||||
})
|
||||
}
|
||||
|
||||
function assetUrl(assetId, mediaMap, fallback = FALLBACK_IMAGE) {
|
||||
@@ -61,7 +113,8 @@ export function useOfficialContent() {
|
||||
function normalizeCase(item, mediaMap) {
|
||||
const metric = item.metricValue || ''
|
||||
const metricLabel = item.metricLabel || ''
|
||||
const clientName = item.isAnonymous && item.clientName ? `匿名客户:${item.clientName}` : item.clientName
|
||||
const clientName =
|
||||
item.isAnonymous && item.clientName ? `匿名客户:${item.clientName}` : item.clientName
|
||||
|
||||
return {
|
||||
...item,
|
||||
@@ -73,7 +126,10 @@ export function useOfficialContent() {
|
||||
description: item.description || item.summary || '',
|
||||
metric,
|
||||
metricLabel,
|
||||
metrics: [[metric, metricLabel], [item.secondary, '辅助成果']].filter((row) => row[0]),
|
||||
metrics: [
|
||||
[metric, metricLabel],
|
||||
[item.secondary, '辅助成果']
|
||||
].filter((row) => row[0]),
|
||||
secondary: item.secondary || '',
|
||||
contentHtml: item.contentHtml || ''
|
||||
}
|
||||
@@ -104,7 +160,7 @@ export function useOfficialContent() {
|
||||
excerpt: item.summary || item.description || '',
|
||||
cta: item.cta || '获取白皮书',
|
||||
cover: item.coverUrl || assetUrl(item.coverAssetId, mediaMap),
|
||||
documentUrl: item.documentUrl || pickUrl(documentAsset),
|
||||
documentUrl: isUsableUrl(item.documentUrl) ? item.documentUrl : pickUrl(documentAsset),
|
||||
documentName: item.documentName || documentAsset?.name || item.title,
|
||||
contentHtml: item.contentHtml || ''
|
||||
}
|
||||
@@ -120,8 +176,10 @@ export function useOfficialContent() {
|
||||
LATEST_LABEL,
|
||||
bySortAndTime,
|
||||
buildMediaMap,
|
||||
fetchPublicCategories,
|
||||
fetchPublicContent,
|
||||
fetchHomepageSections,
|
||||
fetchPublicMediaAsset,
|
||||
fetchPublicSiteConfig,
|
||||
normalizeCase,
|
||||
normalizeArticle,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export function useRefreshNuxtDataOnResume(keys) {
|
||||
const refreshKeys = Array.isArray(keys) ? keys : [keys]
|
||||
let isRefreshing = false
|
||||
|
||||
async function refreshVisibleData() {
|
||||
if (isRefreshing || document.visibilityState !== 'visible') return
|
||||
|
||||
isRefreshing = true
|
||||
try {
|
||||
await refreshNuxtData(refreshKeys)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('focus', refreshVisibleData)
|
||||
document.addEventListener('visibilitychange', refreshVisibleData)
|
||||
refreshVisibleData()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('focus', refreshVisibleData)
|
||||
document.removeEventListener('visibilitychange', refreshVisibleData)
|
||||
})
|
||||
}
|
||||
+13
-10
@@ -1,10 +1,21 @@
|
||||
const officialApiProxyTarget = (process.env.NUXT_OFFICIAL_API_PROXY_TARGET || 'http://localhost:8080').replace(/\/$/, '')
|
||||
const officialApiProxyTarget =
|
||||
process.env.NUXT_OFFICIAL_API_PROXY_TARGET || 'http://localhost:8080'.replace(/\/$/, '')
|
||||
// (process.env.NUXT_OFFICIAL_API_PROXY_TARGET || 'http://43.137.4.161:18080').replace(/\/$/, '')
|
||||
|
||||
export default defineNuxtConfig({
|
||||
runtimeConfig: {
|
||||
officialApiProxyTarget,
|
||||
public: {
|
||||
officialApiBase: process.env.NUXT_PUBLIC_OFFICIAL_API_BASE || '/api'
|
||||
officialApiBase: process.env.NUXT_PUBLIC_OFFICIAL_API_BASE || '/api',
|
||||
trtcChatTokenEndpoint:
|
||||
process.env.NUXT_PUBLIC_TRTC_CHAT_TOKEN_ENDPOINT ||
|
||||
'http://localhost:8080/api/trtc-chat/usersig',
|
||||
// 'http://43.137.4.161.:18080/api/trtc-chat/usersig',
|
||||
trtcChatCustomerServiceID:
|
||||
process.env.NUXT_PUBLIC_TRTC_CHAT_CUSTOMER_SERVICE_ID || 'administrator',
|
||||
trtcChatTargetType: process.env.NUXT_PUBLIC_TRTC_CHAT_TARGET_TYPE || 'C2C',
|
||||
trtcChatVisitorPrefix: process.env.NUXT_PUBLIC_TRTC_CHAT_VISITOR_PREFIX || 'official_visitor',
|
||||
trtcChatScene: process.env.NUXT_PUBLIC_TRTC_CHAT_SCENE || '5000'
|
||||
}
|
||||
},
|
||||
future: {
|
||||
@@ -21,14 +32,6 @@ export default defineNuxtConfig({
|
||||
}
|
||||
}
|
||||
},
|
||||
nitro: {
|
||||
devProxy: {
|
||||
'/api': {
|
||||
target: `${officialApiProxyTarget}/api`,
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
app: {
|
||||
head: {
|
||||
title: '大马棒 · 云途全域 GEO',
|
||||
|
||||
Generated
+1704
-3
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -5,6 +5,7 @@
|
||||
"scripts": {
|
||||
"dev": "nuxt dev --host 0.0.0.0",
|
||||
"build": "nuxt build",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
@@ -13,7 +14,8 @@
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"gsap": "^3.15.0",
|
||||
"nuxt": "^3.17.0"
|
||||
"nuxt": "^3.17.0",
|
||||
"tuikit-atomicx-vue3": "^6.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.21",
|
||||
|
||||
+76
-6
@@ -5,11 +5,11 @@ import SiteFooter from '~/components/site/SiteFooter.vue'
|
||||
|
||||
const pageRoot = ref(null)
|
||||
const { openConsultation } = useConsultation()
|
||||
const { fetchPublicSiteConfig } = useOfficialContent()
|
||||
const { fetchPublicMediaAsset, fetchPublicSiteConfig } = useOfficialContent()
|
||||
let animationContext
|
||||
|
||||
const { data: siteConfigRows } = await useAsyncData('public-site-config-about', () =>
|
||||
fetchPublicSiteConfig()
|
||||
fetchPublicSiteConfig({ fresh: true })
|
||||
)
|
||||
|
||||
function parseConfigValue(value, fallback) {
|
||||
@@ -31,6 +31,35 @@ const contactConfig = computed(() =>
|
||||
address: '待补充'
|
||||
})
|
||||
)
|
||||
const footerConfig = computed(() => parseConfigValue(siteConfig.value.FOOTER, {}))
|
||||
const contactQrAssetId = computed(() => footerConfig.value.qrAssetId || '')
|
||||
const isDirectAssetUrl = (value) =>
|
||||
/^(https?:)?\/\//.test(value) || value.startsWith('/') || value.startsWith('data:image/')
|
||||
const directContactQrSrc = computed(() => {
|
||||
const value = String(contactQrAssetId.value || '').trim()
|
||||
return value && isDirectAssetUrl(value) ? value : ''
|
||||
})
|
||||
const { data: contactQrAsset } = await useAsyncData(
|
||||
'public-site-config-about-contact-qr',
|
||||
() => {
|
||||
const id = String(contactQrAssetId.value || '').trim()
|
||||
if (!id || isDirectAssetUrl(id)) return null
|
||||
return fetchPublicMediaAsset(id).catch(() => null)
|
||||
},
|
||||
{ watch: [contactQrAssetId] }
|
||||
)
|
||||
useRefreshNuxtDataOnResume(['public-site-config-about', 'public-site-config-about-contact-qr'])
|
||||
const contactQrSrc = computed(
|
||||
() =>
|
||||
directContactQrSrc.value ||
|
||||
contactQrAsset.value?.url ||
|
||||
contactQrAsset.value?.fileUrl ||
|
||||
contactQrAsset.value?.filePath ||
|
||||
''
|
||||
)
|
||||
const contactQrAlt = computed(
|
||||
() => contactQrAsset.value?.alt || contactQrAsset.value?.name || '社媒二维码'
|
||||
)
|
||||
|
||||
useHead(() => ({
|
||||
title: '关于我们|大马棒·云途全域 GEO',
|
||||
@@ -44,14 +73,28 @@ useHead(() => ({
|
||||
]
|
||||
}))
|
||||
|
||||
const stats = [
|
||||
const fallbackStats = [
|
||||
['1000+', '累计服务客户'],
|
||||
['12+', '覆盖主流行业'],
|
||||
['5 万', '真人内容生产网络'],
|
||||
['5000+', '权威媒体资源']
|
||||
]
|
||||
|
||||
const serviceFlow = [
|
||||
const stats = computed(() => {
|
||||
const rows = parseConfigValue(siteConfig.value.ABOUT_STATS, [])
|
||||
if (!Array.isArray(rows) || rows.length === 0) return fallbackStats
|
||||
|
||||
return rows
|
||||
.filter((item) => item.status !== 'OFFLINE')
|
||||
.sort((left, right) => (Number(left.sortOrder) || 0) - (Number(right.sortOrder) || 0))
|
||||
.map((item) => [
|
||||
item.value || item.metricValue || '',
|
||||
item.label || item.metricLabel || item.title || ''
|
||||
])
|
||||
.filter((item) => item[0] || item[1])
|
||||
})
|
||||
|
||||
const fallbackServiceFlow = [
|
||||
['01', '可见性诊断', '测试品牌在主流 AI 平台中的认知、提及与推荐表现。'],
|
||||
['02', '策略规划', '围绕业务目标、用户问题与行业特征确定 GEO 路径。'],
|
||||
['03', '内容建设', '把真实业务信息转化为用户愿意读、AI 能识别的内容。'],
|
||||
@@ -59,6 +102,21 @@ const serviceFlow = [
|
||||
['05', '效果复盘', '持续监测覆盖率、推荐率、流量与咨询线索质量。']
|
||||
]
|
||||
|
||||
const serviceFlow = computed(() => {
|
||||
const rows = parseConfigValue(siteConfig.value.ABOUT_SERVICE_FLOW, [])
|
||||
if (!Array.isArray(rows) || rows.length === 0) return fallbackServiceFlow
|
||||
|
||||
return rows
|
||||
.filter((item) => item.status !== 'OFFLINE')
|
||||
.sort((left, right) => (Number(left.sortOrder) || 0) - (Number(right.sortOrder) || 0))
|
||||
.map((item, index) => [
|
||||
item.step || item.number || String(index + 1).padStart(2, '0'),
|
||||
item.title || item.heading || '',
|
||||
item.description || item.body || ''
|
||||
])
|
||||
.filter((item) => item[1] || item[2])
|
||||
})
|
||||
|
||||
const fallbackTimeline = [
|
||||
{
|
||||
year: '2023',
|
||||
@@ -101,13 +159,24 @@ const timeline = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
const teamCapabilities = [
|
||||
const fallbackTeamCapabilities = [
|
||||
['搜索理解', '经历 PC 搜索、移动搜索到 AI 搜索的持续变化。'],
|
||||
['内容运营', '理解平台规则,也理解真实用户愿意阅读和相信什么。'],
|
||||
['媒体资源', '长期积累权威媒体、垂直信源与真实创作者网络。'],
|
||||
['业务转化', '不止关注曝光,更关注咨询、线索质量与长期资产。']
|
||||
]
|
||||
|
||||
const teamCapabilities = computed(() => {
|
||||
const rows = parseConfigValue(siteConfig.value.ABOUT_TEAM_CAPABILITIES, [])
|
||||
if (!Array.isArray(rows) || rows.length === 0) return fallbackTeamCapabilities
|
||||
|
||||
return rows
|
||||
.filter((item) => item.status !== 'OFFLINE')
|
||||
.sort((left, right) => (Number(left.sortOrder) || 0) - (Number(right.sortOrder) || 0))
|
||||
.map((item) => [item.title || item.heading || '', item.description || item.body || ''])
|
||||
.filter((item) => item[0] || item[1])
|
||||
})
|
||||
|
||||
function requestDiagnosis(source) {
|
||||
openConsultation({ title: '预约免费 GEO 增长诊断', source })
|
||||
}
|
||||
@@ -344,7 +413,8 @@ onBeforeUnmount(() => animationContext?.revert())
|
||||
</div>
|
||||
</dl>
|
||||
<div class="contact-qr">
|
||||
<span aria-hidden="true"></span><strong>扫码添加专属顾问</strong
|
||||
<img v-if="contactQrSrc" :src="contactQrSrc" :alt="contactQrAlt" loading="lazy" />
|
||||
<span v-else aria-hidden="true"></span><strong>扫码添加专属顾问</strong
|
||||
><small>获取 1 对 1 GEO 增长诊断</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+28
-7
@@ -30,15 +30,32 @@ const caseTags = computed(() =>
|
||||
)
|
||||
|
||||
const relatedCases = computed(() =>
|
||||
(pageData.value?.cases || []).filter((item) => item.slug !== slug).slice(0, 4)
|
||||
(pageData.value?.cases || [])
|
||||
.filter((item) => item.slug !== slug && item.isRecommended === true)
|
||||
.slice(0, 4)
|
||||
)
|
||||
const recommendedCases = computed(() =>
|
||||
(pageData.value?.cases || [])
|
||||
.filter(
|
||||
(item) => item.slug !== slug && item.isRecommended === true && item.isFullCase && item.slug
|
||||
)
|
||||
.slice(0, 5)
|
||||
)
|
||||
|
||||
function caseListLink(item) {
|
||||
return item.isFullCase && item.slug ? `/cases/${item.slug}` : '/cases'
|
||||
}
|
||||
|
||||
useHead(() => ({
|
||||
title: `${articleTitle.value}|客户案例`,
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: caseStudy.value.seoDescription || caseStudy.value.description || caseStudy.value.summary || articleTitle.value
|
||||
content:
|
||||
caseStudy.value.seoDescription ||
|
||||
caseStudy.value.description ||
|
||||
caseStudy.value.summary ||
|
||||
articleTitle.value
|
||||
},
|
||||
{ property: 'og:title', content: articleTitle.value },
|
||||
{ property: 'og:description', content: caseStudy.value.description || caseStudy.value.summary },
|
||||
@@ -229,11 +246,11 @@ function buildCaseArticleHtml(caseData) {
|
||||
</article>
|
||||
|
||||
<aside class="news-sidebar" aria-label="相关推荐">
|
||||
<section class="sidebar-panel">
|
||||
<section v-if="relatedCases.length" class="sidebar-panel">
|
||||
<h2>相关案例</h2>
|
||||
<ol class="hot-list">
|
||||
<li v-for="(item, index) in relatedCases" :key="item.industry">
|
||||
<NuxtLink to="/cases">
|
||||
<li v-for="(item, index) in relatedCases" :key="item.id || item.slug">
|
||||
<NuxtLink :to="caseListLink(item)">
|
||||
<span>{{ index + 1 }}</span>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<em>{{ item.metric }}</em>
|
||||
@@ -242,10 +259,14 @@ function buildCaseArticleHtml(caseData) {
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="sidebar-panel">
|
||||
<section v-if="recommendedCases.length" class="sidebar-panel">
|
||||
<h2>推荐案例</h2>
|
||||
<div class="recommend-list">
|
||||
<NuxtLink v-for="item in relatedCases.slice(0, 3)" :key="item.title" to="/cases">
|
||||
<NuxtLink
|
||||
v-for="item in recommendedCases"
|
||||
:key="item.id || item.slug"
|
||||
:to="`/cases/${item.slug}`"
|
||||
>
|
||||
<img :src="item.cover" :alt="item.title" />
|
||||
<span>
|
||||
<strong>{{ item.title }}</strong>
|
||||
|
||||
+138
-80
@@ -1,29 +1,47 @@
|
||||
<script setup>
|
||||
import SiteHeader from '~/components/site/SiteHeader.vue'
|
||||
import SiteFooter from '~/components/site/SiteFooter.vue'
|
||||
const { ALL_LABEL, bySortAndTime, categoryList, fetchPublicContent, normalizeCase } =
|
||||
const { ALL_LABEL, bySortAndTime, fetchPublicCategories, fetchPublicContent, normalizeCase } =
|
||||
useOfficialContent()
|
||||
|
||||
const activeCategory = ref(ALL_LABEL)
|
||||
const snapshotVisibleCount = ref(3)
|
||||
const { openConsultation } = useConsultation()
|
||||
|
||||
const { data: caseRows } = await useAsyncData('public-cases', async () => {
|
||||
const rows = await fetchPublicContent('CASE')
|
||||
return rows.sort(bySortAndTime).map((item) => normalizeCase(item, new Map()))
|
||||
})
|
||||
const { data: pageData } = await useAsyncData('public-cases', async () => {
|
||||
const [rows, categories] = await Promise.all([
|
||||
fetchPublicContent('CASE'),
|
||||
fetchPublicCategories().catch(() => ({}))
|
||||
])
|
||||
|
||||
const cases = computed(() => caseRows.value || [])
|
||||
const caseCategories = computed(() => categoryList(cases.value, 'industry'))
|
||||
const featuredCase = computed(
|
||||
() =>
|
||||
cases.value.find((item) => item.isFeatured) ||
|
||||
cases.value.find((item) => item.isFullCase) ||
|
||||
cases.value[0] ||
|
||||
null
|
||||
)
|
||||
return {
|
||||
cases: rows.sort(bySortAndTime).map((item) => normalizeCase(item, new Map())),
|
||||
categories: categories || {}
|
||||
}
|
||||
})
|
||||
useRefreshNuxtDataOnResume('public-cases')
|
||||
|
||||
const cases = computed(() => pageData.value?.cases || [])
|
||||
const caseCategories = computed(() => [
|
||||
ALL_LABEL,
|
||||
...Array.from(
|
||||
new Set(
|
||||
(pageData.value?.categories?.caseIndustries || []).map((item) => item.name).filter(Boolean)
|
||||
)
|
||||
)
|
||||
])
|
||||
const filteredCases = computed(() => {
|
||||
if (activeCategory.value === ALL_LABEL) return cases.value
|
||||
return cases.value.filter((item) => item.industry === activeCategory.value)
|
||||
})
|
||||
const primaryCases = computed(() => {
|
||||
if (activeCategory.value === ALL_LABEL) return cases.value.filter((item) => item.isFeatured)
|
||||
return filteredCases.value.filter((item) => item.isFullCase)
|
||||
})
|
||||
const resultSnapshots = computed(() =>
|
||||
cases.value.filter((item) => item.id !== featuredCase.value?.id)
|
||||
filteredCases.value.filter((item) => item.isRecommended === false)
|
||||
)
|
||||
const heroPromotion = computed(() => filteredCases.value.filter((item) => item.isRecommended ).slice(0, 3))
|
||||
|
||||
useHead({
|
||||
title: '客户案例|大马棒·云途全域 GEO',
|
||||
@@ -36,15 +54,26 @@ useHead({
|
||||
]
|
||||
})
|
||||
|
||||
const showFeatured = computed(() => {
|
||||
if (!featuredCase.value) return false
|
||||
return activeCategory.value === ALL_LABEL || activeCategory.value === featuredCase.value.industry
|
||||
const visibleSnapshots = computed(() => resultSnapshots.value.slice(0, snapshotVisibleCount.value))
|
||||
const canLoadMoreSnapshots = computed(
|
||||
() => snapshotVisibleCount.value < resultSnapshots.value.length
|
||||
)
|
||||
const hasPrimaryCases = computed(() => primaryCases.value.length > 0)
|
||||
const primaryEmptyKicker = computed(() =>
|
||||
activeCategory.value === ALL_LABEL ? '精选案例正在整理' : '完整案例正在整理'
|
||||
)
|
||||
const primaryEmptyTitle = computed(() =>
|
||||
activeCategory.value === ALL_LABEL
|
||||
? '精选案例尚未上线'
|
||||
: `${activeCategory.value}行业的完整案例尚未上线`
|
||||
)
|
||||
const primaryEmptySource = computed(() =>
|
||||
activeCategory.value === ALL_LABEL ? '案例全部精选空状态' : '行业完整案例空状态'
|
||||
)
|
||||
|
||||
watch(activeCategory, () => {
|
||||
snapshotVisibleCount.value = 3
|
||||
})
|
||||
const visibleSnapshots = computed(() => {
|
||||
if (activeCategory.value === ALL_LABEL) return resultSnapshots.value
|
||||
return resultSnapshots.value.filter((item) => item.industry === activeCategory.value)
|
||||
})
|
||||
const hasResults = computed(() => showFeatured.value || visibleSnapshots.value.length > 0)
|
||||
|
||||
function requestPlan(industry, source) {
|
||||
openConsultation({ title: `获取${industry}行业增长方案`, industry, source })
|
||||
@@ -86,7 +115,7 @@ function requestCustomPlan(source) {
|
||||
|
||||
<div class="case-collage" aria-label="不同行业案例结果预览">
|
||||
<article
|
||||
v-for="(snapshot, index) in resultSnapshots"
|
||||
v-for="(snapshot, index) in heroPromotion"
|
||||
:key="snapshot.industry"
|
||||
:class="`collage-card collage-card-${index + 1}`"
|
||||
>
|
||||
@@ -98,7 +127,7 @@ function requestCustomPlan(source) {
|
||||
</div>
|
||||
</article>
|
||||
<div class="collage-center">
|
||||
<small>CASE LIBRARY</small><strong>真实增长路径</strong><span>持续更新</span>
|
||||
<img src="/images/cases/real-growth-path.png" alt="真实增长路径" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,7 +140,6 @@ function requestCustomPlan(source) {
|
||||
<p class="section-kicker">按行业查看</p>
|
||||
<h2>结果相似,背后的路径并不相同</h2>
|
||||
</div>
|
||||
<p>筛选行业,查看已经确认的完整案例和结果快照。未经客户授权,案例默认使用匿名名称。</p>
|
||||
</header>
|
||||
|
||||
<div class="case-filters" role="group" aria-label="案例行业筛选">
|
||||
@@ -128,27 +156,57 @@ function requestCustomPlan(source) {
|
||||
|
||||
<Transition name="case-change" mode="out-in">
|
||||
<div :key="activeCategory" class="case-results">
|
||||
<article v-if="showFeatured" class="featured-case">
|
||||
<article
|
||||
v-for="caseItem in primaryCases"
|
||||
:key="caseItem.id || caseItem.slug"
|
||||
class="featured-case"
|
||||
>
|
||||
<div class="featured-case-image">
|
||||
<img :src="featuredCase.cover" alt="教育培训行业 GEO 案例封面" />
|
||||
<span>{{ featuredCase.industry }} · 完整案例</span>
|
||||
<img :src="caseItem.cover" :alt="`${caseItem.industry}行业 GEO 案例封面`" />
|
||||
<span
|
||||
>{{ caseItem.industry }} ·
|
||||
{{ caseItem.isFullCase ? '完整案例' : '精选案例' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="featured-case-copy">
|
||||
<p>{{ featuredCase.client }}</p>
|
||||
<h2>{{ featuredCase.title }}</h2>
|
||||
<span class="featured-summary">{{ featuredCase.summary }}</span>
|
||||
<p>{{ caseItem.client }}</p>
|
||||
<h2>{{ caseItem.title }}</h2>
|
||||
<span class="featured-summary">{{ caseItem.summary }}</span>
|
||||
<div class="featured-metrics">
|
||||
<div v-for="metric in featuredCase.metrics" :key="metric[1]">
|
||||
<div v-for="metric in caseItem.metrics" :key="metric[1]">
|
||||
<strong>{{ metric[0] }}</strong
|
||||
><span>{{ metric[1] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<NuxtLink :to="`/cases/${featuredCase.slug}`"
|
||||
<NuxtLink
|
||||
v-if="caseItem.isFullCase && caseItem.slug"
|
||||
:to="`/cases/${caseItem.slug}`"
|
||||
>查看完整复盘 <AppIcon name="arrow-right"
|
||||
/></NuxtLink>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
@click="requestPlan(caseItem.industry, `${caseItem.industry}精选案例`)"
|
||||
>
|
||||
获取同类方案 <AppIcon name="arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="!hasPrimaryCases" class="case-empty primary-empty">
|
||||
<div class="empty-visual" aria-hidden="true"><span></span><span></span><i></i></div>
|
||||
<p class="section-kicker">{{ primaryEmptyKicker }}</p>
|
||||
<h3>{{ primaryEmptyTitle }}</h3>
|
||||
<p>我们可以先结合您的业务情况,分享对应行业的策略思路与可参考指标。</p>
|
||||
<button
|
||||
class="primary-button small"
|
||||
type="button"
|
||||
@click="requestPlan(activeCategory, primaryEmptySource)"
|
||||
>
|
||||
获取{{ activeCategory }}行业建议 <AppIcon name="arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="visibleSnapshots.length" class="snapshot-heading">
|
||||
<div>
|
||||
<p class="section-kicker">结果快照</p>
|
||||
@@ -185,20 +243,14 @@ function requestCustomPlan(source) {
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasResults" class="case-empty">
|
||||
<div class="empty-visual" aria-hidden="true"><span></span><span></span><i></i></div>
|
||||
<p class="section-kicker">案例正在整理</p>
|
||||
<h3>该行业的公开案例尚未上线</h3>
|
||||
<p>我们可以先结合您的业务情况,分享对应行业的策略思路与可参考指标。</p>
|
||||
<button
|
||||
class="primary-button small"
|
||||
type="button"
|
||||
@click="requestPlan(activeCategory, '案例筛选空状态')"
|
||||
>
|
||||
获取{{ activeCategory }}行业建议 <AppIcon name="arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="canLoadMoreSnapshots"
|
||||
class="case-load-more"
|
||||
type="button"
|
||||
@click="snapshotVisibleCount += 2"
|
||||
>
|
||||
加载更多 <AppIcon name="plus" />
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
@@ -442,35 +494,20 @@ function requestCustomPlan(source) {
|
||||
}
|
||||
.collage-center {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
z-index: 04;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 170px;
|
||||
height: 170px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 12px solid rgba(255, 255, 255, 0.66);
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, #2c82ef, #4bb8e9);
|
||||
box-shadow: 0 24px 48px rgba(40, 123, 224, 0.28);
|
||||
width: min(230px, 68%);
|
||||
aspect-ratio: 1838 / 1300;
|
||||
overflow: hidden;
|
||||
border-radius: 18px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.collage-center small {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.14em;
|
||||
opacity: 0.76;
|
||||
}
|
||||
.collage-center strong {
|
||||
margin-top: 8px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.collage-center span {
|
||||
margin-top: 4px;
|
||||
font-size: 10px;
|
||||
opacity: 0.76;
|
||||
.collage-center img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
.case-library-section {
|
||||
padding: 112px 0 130px;
|
||||
@@ -533,6 +570,9 @@ function requestCustomPlan(source) {
|
||||
background: #eaf4ff;
|
||||
box-shadow: 0 28px 60px rgba(60, 123, 201, 0.13);
|
||||
}
|
||||
.featured-case + .featured-case {
|
||||
margin-top: 28px;
|
||||
}
|
||||
.featured-case-image {
|
||||
position: relative;
|
||||
min-height: 480px;
|
||||
@@ -612,12 +652,15 @@ function requestCustomPlan(source) {
|
||||
color: #8392a4;
|
||||
font-size: 9px;
|
||||
}
|
||||
.featured-case-copy > a {
|
||||
.featured-case-copy > a,
|
||||
.featured-case-copy > button {
|
||||
align-self: flex-start;
|
||||
margin-top: 29px;
|
||||
padding-bottom: 5px;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid #82b3ea;
|
||||
color: #247be8;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
@@ -739,6 +782,26 @@ function requestCustomPlan(source) {
|
||||
background: transparent;
|
||||
font-size: 11px;
|
||||
}
|
||||
.case-load-more {
|
||||
min-width: 150px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin: 42px auto 0;
|
||||
border: 1px solid #bad2ea;
|
||||
border-radius: 5px;
|
||||
color: #527497;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
.case-load-more:hover {
|
||||
color: #247be8;
|
||||
border-color: #73aae4;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.case-empty {
|
||||
min-height: 440px;
|
||||
display: flex;
|
||||
@@ -975,12 +1038,7 @@ function requestCustomPlan(source) {
|
||||
right: 0;
|
||||
}
|
||||
.collage-center {
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
border-width: 9px;
|
||||
}
|
||||
.collage-center strong {
|
||||
font-size: 15px;
|
||||
width: 76%;
|
||||
}
|
||||
.case-library-section {
|
||||
padding-top: 76px;
|
||||
|
||||
+164
-112
@@ -7,12 +7,17 @@ import SiteFooter from '~/components/site/SiteFooter.vue'
|
||||
const pageRoot = ref(null)
|
||||
const { openConsultation: openGlobalConsultation } = useConsultation()
|
||||
const { fetchHomepageSections } = useOfficialContent()
|
||||
const SIMPLE_STAT_VALUE = /^(\d+(?:\.\d+)?)([+%]*)$/
|
||||
let animationContext
|
||||
let animationMedia
|
||||
let counterContext
|
||||
|
||||
const { data: homepageSectionRows } = await useAsyncData('public-homepage-sections', () =>
|
||||
fetchHomepageSections()
|
||||
const { data: homepageSectionRows } = await useAsyncData(
|
||||
'public-homepage-sections',
|
||||
() => fetchHomepageSections(),
|
||||
{ server: false }
|
||||
)
|
||||
useRefreshNuxtDataOnResume('public-homepage-sections')
|
||||
|
||||
const homepageSections = computed(() => homepageSectionRows.value || [])
|
||||
const homepageSectionMap = computed(() =>
|
||||
@@ -23,29 +28,28 @@ function homepageSection(key) {
|
||||
return homepageSectionMap.value[key] || {}
|
||||
}
|
||||
|
||||
function backgroundStyle(url) {
|
||||
return url ? { backgroundImage: `url("${url}")` } : null
|
||||
function normalizeBackgroundUrl(value) {
|
||||
const url = String(value || '')
|
||||
.trim()
|
||||
.replace(/"|"|"/gi, '"')
|
||||
.replace(/&/gi, '&')
|
||||
const declarationMatch = url.match(/^background-image\s*:\s*(url\(.*\))\s*;?$/i)
|
||||
const source = declarationMatch ? declarationMatch[1] : url
|
||||
const urlMatch = source.match(/^url\((.*)\)$/i)
|
||||
return (urlMatch ? urlMatch[1] : source).trim().replace(/^['"]|['"]$/g, '')
|
||||
}
|
||||
|
||||
const fallbackLogos = [
|
||||
'Bond',
|
||||
'WaSun',
|
||||
'西西能源',
|
||||
'CIIC',
|
||||
'SUNAC',
|
||||
'B+ WORKS',
|
||||
'Bond',
|
||||
'WaSun',
|
||||
'西西能源',
|
||||
'CIIC',
|
||||
'SUNAC',
|
||||
'B+ WORKS'
|
||||
]
|
||||
function backgroundStyle(url) {
|
||||
const normalizedUrl = normalizeBackgroundUrl(url)
|
||||
return normalizedUrl ? { backgroundImage: `url(${normalizedUrl})` } : null
|
||||
}
|
||||
|
||||
const fallbackLogos = []
|
||||
const fallbackStats = [
|
||||
['300+', '累计服务客户', '/images/lanhu/slices/home-group100-image26@2x.png'],
|
||||
['12+', '覆盖行业', '/images/lanhu/slices/home-group101-image27@2x.png'],
|
||||
['126%', '客户AI搜索曝光平均提升', '/images/lanhu/slices/home-group102-image28@2x.png'],
|
||||
['65%', '客户转化链路效率平均提升', '/images/lanhu/slices/home-group103-image29@2x.png']
|
||||
['300+', '累计服务客户', '/images/lanhu/slices/home-group37-group70-image5@2x.png'],
|
||||
['12+', '覆盖行业', '/images/lanhu/slices/home-group36-group71-image5@2x.png'],
|
||||
['126%', '客户AI搜索曝光平均提升', '/images/lanhu/slices/home-group35-group72-image5@2x.png'],
|
||||
['65%', '客户转化链路效率平均提升', '/images/lanhu/slices/home-group34-group74-image5@2x.png']
|
||||
]
|
||||
const heroBackgroundImage = '/images/lanhu/slices/hero-geo-visual@2x.png'
|
||||
const fallbackCorePoints = [
|
||||
@@ -67,9 +71,21 @@ const fallbackRingModels = [
|
||||
}
|
||||
]
|
||||
const fallbackAdvantageCards = [
|
||||
['全域媒体资源优势', '5000+权威官媒+10万+优质自媒体矩阵'],
|
||||
['全链路闭环优势', '行业唯一打通「纯曝光+内容资产沉淀+转化闭环」'],
|
||||
['全客群定制优势', '覆盖全类型经营主体,一对一专属定制专属方案']
|
||||
{
|
||||
heading: '全域媒体资源优势',
|
||||
bodyOne: '5000+权威官媒+10万+优质自媒体矩阵',
|
||||
bodyTwo: '提升AI搜索权重 多渠道权威背书,全方位强化品牌 曝光公信力'
|
||||
},
|
||||
{
|
||||
heading: '全链路闭环优势',
|
||||
bodyOne: '行业唯一打通「纯曝光-内容资产沉淀·转化闭环',
|
||||
bodyTwo: '从曝光到获客全链路承接,避免流量断层实现 品效合'
|
||||
},
|
||||
{
|
||||
heading: '全客群定制优势',
|
||||
bodyOne: '覆盖全类型经营主体,一对一专属定制方案',
|
||||
bodyTwo: '拒绝标准化模板,根据企业阶段、行业、需求匹 記最优 GEO策略'
|
||||
}
|
||||
]
|
||||
const fallbackCaseImages = [
|
||||
'/images/lanhu/slices/home-group90-image15@2x.png',
|
||||
@@ -132,9 +148,9 @@ const processSection = computed(() => homepageSection('PROCESS'))
|
||||
const testimonialSection = computed(() => homepageSection('TESTIMONIAL'))
|
||||
const bottomCtaSection = computed(() => homepageSection('BOTTOM_CTA'))
|
||||
|
||||
const heroTitle = computed(
|
||||
() => heroSection.value.title || '大马棒 · 云途全域GEO\n让品牌在AI时代拥有持续增长力'
|
||||
)
|
||||
const heroTitle = computed(() => heroSection.value.title || '大马棒 · 云途全域GEO')
|
||||
const heroDescription = computed(() => heroSection.value.description || 'AI时代拥有持续增长力')
|
||||
console.log(heroSection.value.description)
|
||||
const heroTags = computed(() =>
|
||||
heroSection.value.tags?.length
|
||||
? heroSection.value.tags
|
||||
@@ -150,14 +166,57 @@ const stats = computed(() => {
|
||||
fallbackStats[index % fallbackStats.length][2]
|
||||
])
|
||||
})
|
||||
|
||||
watch(stats, async () => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
await nextTick()
|
||||
|
||||
if (window.innerWidth <= 760 || !pageRoot.value) return
|
||||
|
||||
counterContext?.revert()
|
||||
|
||||
counterContext = gsap.context(() => {
|
||||
document.querySelectorAll('.stat-item strong').forEach((element) => {
|
||||
const finalText = element.textContent.trim()
|
||||
const statMatch = finalText.match(SIMPLE_STAT_VALUE)
|
||||
if (!statMatch) return
|
||||
|
||||
const finalValue = Number.parseFloat(statMatch[1])
|
||||
const suffix = statMatch[2]
|
||||
const counter = { value: 0 }
|
||||
ScrollTrigger.create({
|
||||
trigger: element,
|
||||
start: 'top 88%',
|
||||
once: true,
|
||||
onEnter: () => {
|
||||
gsap.to(counter, {
|
||||
value: finalValue,
|
||||
duration: 1.45,
|
||||
ease: 'power2.out',
|
||||
onUpdate: () => {
|
||||
element.textContent = `${Math.round(counter.value)}${suffix}`
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}, pageRoot.value)
|
||||
}, { immediate: true, flush: 'post' })
|
||||
|
||||
const corePoints = computed(() => {
|
||||
const cards = capabilitiesSection.value.cards
|
||||
if (!cards?.length) return fallbackCorePoints
|
||||
|
||||
return cards.slice(0, 2).map((card, index) => [
|
||||
card.title || fallbackCorePoints[index]?.[0] || '',
|
||||
card.details?.[0]?.description || card.details?.[0]?.heading || fallbackCorePoints[index]?.[1] || ''
|
||||
])
|
||||
return cards
|
||||
.slice(0, 2)
|
||||
.map((card, index) => [
|
||||
card.title || fallbackCorePoints[index]?.[0] || '',
|
||||
card.details?.[0]?.description ||
|
||||
card.details?.[0]?.heading ||
|
||||
fallbackCorePoints[index]?.[1] ||
|
||||
''
|
||||
])
|
||||
})
|
||||
const ringModels = computed(() => fallbackRingModels)
|
||||
const leadAdvantage = computed(() => {
|
||||
@@ -172,11 +231,10 @@ const leadAdvantage = computed(() => {
|
||||
})
|
||||
const advantageCards = computed(() => {
|
||||
const groups = advantagesSection.value.groups?.slice(1)
|
||||
console.log(groups);
|
||||
|
||||
if (!groups?.length) return fallbackAdvantageCards
|
||||
return groups.map((group) => [
|
||||
group.heading,
|
||||
[group.bodyOne, group.bodyTwo].filter(Boolean).join(';')
|
||||
])
|
||||
return groups
|
||||
})
|
||||
const caseCards = computed(() => {
|
||||
const contents = caseBlockSection.value.contents
|
||||
@@ -215,6 +273,12 @@ const deliverySteps = computed(() => {
|
||||
fallbackDeliverySteps[index % fallbackDeliverySteps.length][2]
|
||||
])
|
||||
})
|
||||
const testimonialCarouselEnabled = computed(() => testimonials.value.length > 3)
|
||||
const testimonialLoopItems = computed(() =>
|
||||
testimonialCarouselEnabled.value
|
||||
? [...testimonials.value, ...testimonials.value]
|
||||
: testimonials.value
|
||||
)
|
||||
const testimonials = computed(() => {
|
||||
const groups = testimonialSection.value.groups
|
||||
if (!groups?.length) {
|
||||
@@ -259,8 +323,7 @@ onMounted(async () => {
|
||||
|
||||
const heroTimeline = gsap.timeline({ defaults: { ease: 'power3.out' } })
|
||||
heroTimeline
|
||||
.from('.subsite-header', { autoAlpha: 0, y: -16, duration: 0.5, clearProps: 'all' })
|
||||
.from('.hero-copy h1', { autoAlpha: 0, y: 32, duration: 0.7, clearProps: 'all' }, '-=0.12')
|
||||
.from('.hero-copy h1', { autoAlpha: 0, y: 32, duration: 0.7, clearProps: 'all' })
|
||||
.from(
|
||||
'.hero-points span',
|
||||
{ autoAlpha: 0, y: 16, duration: 0.42, stagger: 0.09, clearProps: 'all' },
|
||||
@@ -294,7 +357,6 @@ onMounted(async () => {
|
||||
reveal('.delivery-banner', '.delivery-banner', { y: 22 })
|
||||
reveal('.delivery-grid article', '.delivery-grid', { y: 22, stagger: 0.1 })
|
||||
reveal('.testimonial-section .section-title', '.testimonial-section')
|
||||
reveal('.testimonial-track article', '.testimonial-track', { y: 22, stagger: 0.1 })
|
||||
reveal('.cta-content', '.cta-section', { y: 26 })
|
||||
|
||||
animationMedia = gsap.matchMedia()
|
||||
@@ -366,28 +428,6 @@ onMounted(async () => {
|
||||
})
|
||||
}
|
||||
|
||||
document.querySelectorAll('.stat-item strong').forEach((element) => {
|
||||
const finalText = element.textContent.trim()
|
||||
const finalValue = Number.parseFloat(finalText)
|
||||
const suffix = finalText.replace(/[\d.]/g, '')
|
||||
const counter = { value: 0 }
|
||||
ScrollTrigger.create({
|
||||
trigger: element,
|
||||
start: 'top 88%',
|
||||
once: true,
|
||||
onEnter: () => {
|
||||
gsap.to(counter, {
|
||||
value: finalValue,
|
||||
duration: 1.45,
|
||||
ease: 'power2.out',
|
||||
onUpdate: () => {
|
||||
element.textContent = `${Math.round(counter.value)}${suffix}`
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
document.querySelectorAll('.section-title').forEach((title) => {
|
||||
ScrollTrigger.create({
|
||||
trigger: title,
|
||||
@@ -470,17 +510,6 @@ onMounted(async () => {
|
||||
scrub: 1
|
||||
}
|
||||
})
|
||||
gsap.to('.testimonial-track', {
|
||||
x: -720,
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: '.testimonial-section',
|
||||
start: 'top bottom',
|
||||
end: 'bottom top',
|
||||
scrub: 1
|
||||
}
|
||||
})
|
||||
|
||||
return () => cleanup.forEach((dispose) => dispose())
|
||||
})
|
||||
}, pageRoot.value)
|
||||
@@ -489,6 +518,7 @@ onMounted(async () => {
|
||||
onBeforeUnmount(() => {
|
||||
animationMedia?.revert()
|
||||
animationContext?.revert()
|
||||
counterContext?.revert()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -497,19 +527,16 @@ onBeforeUnmount(() => {
|
||||
<SiteHeader />
|
||||
|
||||
<main>
|
||||
<section class="hero-section">
|
||||
<div
|
||||
class="hero-art"
|
||||
:style="backgroundStyle(heroBackgroundImage)"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<section class="hero-section">
|
||||
<div
|
||||
class="hero-art"
|
||||
:style="backgroundStyle(heroBackgroundImage)"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="page-width hero-content">
|
||||
<div class="hero-copy">
|
||||
<h1>
|
||||
<template v-for="(line, index) in heroTitle.split('\n')" :key="`${line}-${index}`">
|
||||
<br v-if="index" />{{ line }}
|
||||
</template>
|
||||
</h1>
|
||||
<h1>{{ heroTitle }}</h1>
|
||||
<h1>{{ heroDescription }}</h1>
|
||||
<p class="hero-points">
|
||||
<span v-for="tag in heroTags" :key="tag">✓ {{ tag }}</span>
|
||||
</p>
|
||||
@@ -620,15 +647,15 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="advantage-grid">
|
||||
<article v-for="([title, text], index) in advantageCards" :key="title">
|
||||
<article v-for="(item, index) in advantageCards" :key="index">
|
||||
<img
|
||||
:src="`/images/lanhu/slices/home-group${37 - index}-group${70 + index}-image5@2x.png`"
|
||||
alt=""
|
||||
/>
|
||||
<div>
|
||||
<h3>{{ title }}</h3>
|
||||
<p><b>核心点:</b>{{ text }}</p>
|
||||
<p><b>价值:</b>提升AI搜索权重,构建持续增长的品牌信任力</p>
|
||||
<h3>{{ item.heading }}</h3>
|
||||
<p><b>核心点:</b>{{ item.bodyOne }}</p>
|
||||
<p><b>价值:</b>{{ item.bodyTwo }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -656,26 +683,28 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="industries" class="industries-section page-width">
|
||||
<SectionTitle
|
||||
>{{ industryBlockSection.title || '深耕12+行业,定制专属GEO增长方案'
|
||||
}}<template #subtitle
|
||||
>With In-depth Experience Across 12+ Industries, We Craft Exclusive Geo Growth
|
||||
Solutions<br />Tailored To Clients’ Needs</template
|
||||
></SectionTitle
|
||||
>
|
||||
<div class="industry-grid">
|
||||
<article v-for="card in industryCards" :key="card[0]">
|
||||
<h3>{{ card[0] }}</h3>
|
||||
<img :src="card[2]" alt="" />
|
||||
<div class="quote">“</div>
|
||||
<p>某企业服务客户:<br />{{ card[1] }}</p>
|
||||
<div class="tags">
|
||||
<span v-for="tag in card[3] || ['生活服务', '全域曝光']" :key="`${card[0]}-${tag}`"
|
||||
>#{{ tag }}</span
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
<section id="industries" class="industries-section">
|
||||
<div class="industries-content">
|
||||
<SectionTitle
|
||||
>{{ industryBlockSection.title || '深耕12+行业,定制专属GEO增长方案'
|
||||
}}<template #subtitle
|
||||
>With In-depth Experience Across 12+ Industries, We Craft Exclusive Geo Growth
|
||||
Solutions<br />Tailored To Clients’ Needs</template
|
||||
></SectionTitle
|
||||
>
|
||||
<div class="industry-grid">
|
||||
<article v-for="card in industryCards" :key="card[0]">
|
||||
<h3>{{ card[0] }}</h3>
|
||||
<img :src="card[2]" alt="" />
|
||||
<div class="quote">“</div>
|
||||
<p>某企业服务客户:<br />{{ card[1] }}</p>
|
||||
<div class="tags">
|
||||
<span v-for="tag in card[3] || ['生活服务', '全域曝光']" :key="`${card[0]}-${tag}`"
|
||||
>#{{ tag }}</span
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -709,11 +738,26 @@ onBeforeUnmount(() => {
|
||||
>Genuine Client Testimonials Attest To Our Service Quality</template
|
||||
></SectionTitle
|
||||
>
|
||||
<div class="testimonial-track">
|
||||
<article v-for="(item, index) in testimonials" :key="`${item.name}-${index}`">
|
||||
<div
|
||||
:class="[
|
||||
'testimonial-track',
|
||||
testimonialCarouselEnabled ? 'is-carousel' : 'is-centered'
|
||||
]"
|
||||
>
|
||||
<article
|
||||
v-for="(item, index) in testimonialLoopItems"
|
||||
:key="`${item.name}-${index}`"
|
||||
:aria-hidden="
|
||||
testimonialCarouselEnabled && index >= testimonials.length ? 'true' : null
|
||||
"
|
||||
>
|
||||
<header>
|
||||
<span class="avatar" :style="backgroundStyle(item.avatarUrl)"></span
|
||||
><b>{{ item.name }}</b
|
||||
<img
|
||||
v-if="normalizeBackgroundUrl(item.avatarUrl)"
|
||||
class="avatar"
|
||||
:src="normalizeBackgroundUrl(item.avatarUrl)"
|
||||
:alt="item.name ? `${item.name}头像` : ''"
|
||||
/><span v-else class="avatar"></span><b>{{ item.name }}</b
|
||||
><time>{{ item.date }}</time>
|
||||
</header>
|
||||
<p>{{ item.description }}</p>
|
||||
@@ -721,7 +765,15 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div>
|
||||
<span
|
||||
style="
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background-image: url('https://cdn.xznawq.com/geo/___16_1784796340147.png');
|
||||
"
|
||||
></span>
|
||||
</div>
|
||||
<section class="cta-section">
|
||||
<div class="cta-art" :style="backgroundStyle(bottomCtaSection.assetUrl)"></div>
|
||||
<div class="cta-content">
|
||||
|
||||
@@ -5,9 +5,11 @@ import SiteFooter from '~/components/site/SiteFooter.vue'
|
||||
const {
|
||||
ALL_LABEL,
|
||||
LATEST_LABEL,
|
||||
buildMediaMap,
|
||||
bySortAndTime,
|
||||
categoryList,
|
||||
fetchPublicCategories,
|
||||
fetchPublicContent,
|
||||
fetchPublicMediaAsset,
|
||||
normalizeArticle,
|
||||
normalizeWhitepaper
|
||||
} = useOfficialContent()
|
||||
@@ -17,17 +19,30 @@ const visibleCount = ref(4)
|
||||
const { openConsultation } = useConsultation()
|
||||
|
||||
const { data: pageData } = await useAsyncData('public-insights', async () => {
|
||||
const [articleRows, whitepaperRows] = await Promise.all([
|
||||
const [articleRows, whitepaperRows, categories] = await Promise.all([
|
||||
fetchPublicContent('ARTICLE'),
|
||||
fetchPublicContent('WHITEPAPER')
|
||||
fetchPublicContent('WHITEPAPER'),
|
||||
fetchPublicCategories().catch(() => ({}))
|
||||
])
|
||||
const whitepaperAssetIds = Array.from(
|
||||
new Set(
|
||||
whitepaperRows.flatMap((item) => [item.coverAssetId, item.documentAssetId]).filter(Boolean)
|
||||
)
|
||||
)
|
||||
const whitepaperMediaRows = await Promise.all(
|
||||
whitepaperAssetIds.map((id) => fetchPublicMediaAsset(id).catch(() => null))
|
||||
)
|
||||
const whitepaperMediaMap = buildMediaMap(whitepaperMediaRows.filter(Boolean))
|
||||
|
||||
return {
|
||||
articles: articleRows.sort(bySortAndTime).map((item) => normalizeArticle(item, new Map())),
|
||||
whitepapers: whitepaperRows.sort(bySortAndTime).map((item) => normalizeWhitepaper(item, new Map()))
|
||||
whitepapers: whitepaperRows
|
||||
.sort(bySortAndTime)
|
||||
.map((item) => normalizeWhitepaper(item, whitepaperMediaMap)),
|
||||
categories: categories || {}
|
||||
}
|
||||
})
|
||||
|
||||
useRefreshNuxtDataOnResume('public-insights')
|
||||
const insights = computed(() => pageData.value?.articles || [])
|
||||
const whitepapers = computed(() => pageData.value?.whitepapers || [])
|
||||
const featuredInsight = computed(
|
||||
@@ -36,7 +51,15 @@ const featuredInsight = computed(
|
||||
const featuredWhitepaper = computed(
|
||||
() => whitepapers.value.find((item) => item.isFeatured) || whitepapers.value[0] || null
|
||||
)
|
||||
const insightCategories = computed(() => categoryList(insights.value, 'category', [LATEST_LABEL]))
|
||||
const insightCategories = computed(() => [
|
||||
ALL_LABEL,
|
||||
LATEST_LABEL,
|
||||
...Array.from(
|
||||
new Set(
|
||||
(pageData.value?.categories?.insightCategories || []).map((item) => item.name).filter(Boolean)
|
||||
)
|
||||
)
|
||||
])
|
||||
|
||||
useHead({
|
||||
title: '增长洞察|大马棒·云途全域 GEO',
|
||||
@@ -74,7 +97,7 @@ function requestWhitepaper(source) {
|
||||
|
||||
openConsultation({
|
||||
title: featuredWhitepaper.value?.title || '获取行业白皮书',
|
||||
need: featuredWhitepaper.value?.title || '行业白皮书',
|
||||
need: '行业白皮书',
|
||||
source
|
||||
})
|
||||
}
|
||||
@@ -227,9 +250,14 @@ function articleTags(article) {
|
||||
<div class="page-width whitepaper-grid">
|
||||
<div class="whitepaper-number"><span>REPORT</span><strong>2026</strong></div>
|
||||
<div class="whitepaper-copy">
|
||||
<p>{{ featuredWhitepaper?.category || '行业白皮书' }}</p>
|
||||
<!-- <p>{{ featuredWhitepaper?.category || '行业白皮书' }}</p>-->
|
||||
<p>行业白皮书</p>
|
||||
<h2>{{ featuredWhitepaper?.title || 'AI 搜索与品牌增长观察' }}</h2>
|
||||
<span>{{ featuredWhitepaper?.excerpt || '用户行为 · 内容信源 · 衡量框架 · 行业实践' }}</span>
|
||||
|
||||
<!-- <h2>AI 搜索与品牌增长观察</h2>-->
|
||||
<span>{{
|
||||
featuredWhitepaper?.excerpt || '用户行为 · 内容信源 · 衡量框架 · 行业实践'
|
||||
}}</span>
|
||||
</div>
|
||||
<button type="button" @click="requestWhitepaper('增长洞察页白皮书')">
|
||||
{{ featuredWhitepaper?.cta || '获取白皮书' }} <AppIcon name="arrow-right" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.6 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.2 MiB |
@@ -1,6 +1,6 @@
|
||||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const targetBase = String(config.officialApiProxyTarget || 'http://localhost:8080').replace(/\/$/, '')
|
||||
const targetBase = String(config.officialApiProxyTarget).replace(/\/$/, '')
|
||||
const rawPath = event.context.params?.path || ''
|
||||
const path = Array.isArray(rawPath) ? rawPath.join('/') : rawPath
|
||||
const requestUrl = getRequestURL(event)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
const ERROR_MAP = {
|
||||
20003: '对方账号不存在,请确认客服账号已在腾讯云 IM 控制台创建或已登录过。',
|
||||
2501: '对方账号不存在,请确认客服账号 ID 是否正确。',
|
||||
2801: '网络请求超时,请检查网络连接后重试。',
|
||||
60005: '当前账号尚未完成登录,请稍后重试。',
|
||||
60016: '登录态已失效,请重新连接客服。',
|
||||
70001: '登录凭证已过期,请刷新页面后重试。',
|
||||
70003: '登录凭证解析失败,请检查 UserSig 签发接口。',
|
||||
70009: 'UserSig 校验失败,请检查 SDKAppID 与 SecretKey 是否匹配。',
|
||||
70013: 'UserSig 中的 UserID 与当前登录用户不一致。',
|
||||
70014: 'UserSig 中的 SDKAppID 与当前应用配置不一致。'
|
||||
}
|
||||
|
||||
export function formatTrtcChatError(error) {
|
||||
const code = error?.code || error?.errorCode
|
||||
return ERROR_MAP[code] || error?.errorMessage || error?.message || '操作失败,请稍后重试。'
|
||||
}
|
||||
Reference in New Issue
Block a user