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

这一提交完成了多项功能升级与优化:
1.  新增全局浮动客服组件,集成腾讯云TRTC聊天能力并添加错误格式化工具
2.  新增图片资源并调整部分静态文件结构
3.  优化案例页面:重构筛选逻辑、新增空状态处理、添加加载更多功能
4.  优化首页布局:修复统计数字动画、调整移动端适配样式、优化轮播逻辑
5.  重构官方内容获取工具:添加缓存机制、优化媒体资源处理
6.  优化API代理配置,移除默认本地代理并新增环境变量配置
7.  新增页面数据恢复刷新逻辑,优化页面交互体验
8.  修复按钮组件冗余代码,调整头部导航移动端样式
This commit is contained in:
2026-07-24 18:55:51 +08:00
parent 314cd6ccd7
commit 2ff1064de7
25 changed files with 4596 additions and 327 deletions
+76 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(/&quot;|&#34;|&#x22;/gi, '"')
.replace(/&amp;/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">
+37 -9
View File
@@ -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