Files
hjc-web/app/templates/template-07/components/HeroSection.vue
T
gxwebsoft 2b69686795 feat(app): 添加多模板关于我们页面及相关路由和404页面
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引
- 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理
- 实现/article、/case、/product及/page动态路由兼容列表与详情展示
- 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置
- 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持
- 模板增强支持CMS单页内容加载及SEO信息动态设置
- 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
2026-09-08 12:13:44 +08:00

311 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section class="relative overflow-hidden text-white group">
<!-- 模式 B有轮播图且后台开启 Banner 轮播 轮播图作为全屏背景 + 暗色蒙版 -->
<template v-if="bannerMode">
<div class="absolute inset-0 z-0">
<!-- 轮播图绝对定位叠放 opacity 交叉淡入 -->
<div
v-for="(b, i) in banners"
:key="b.id || i"
class="absolute inset-0 transition-opacity duration-1000 ease-in-out"
:class="i === currentBannerIndex ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
<a
v-if="bannerLink(b)"
:href="bannerLink(b)"
target="_blank"
rel="noopener"
class="block w-full h-full"
>
<img
:src="bannerImg(b)"
:alt="b.title || ''"
class="w-full h-full object-cover"
>
</a>
<img
v-else
:src="bannerImg(b)"
:alt="b.title || ''"
class="w-full h-full object-cover"
>
</div>
<!-- 品牌蓝蒙版左重右轻保证左侧文案可读 -->
<div class="absolute inset-0 bg-gradient-to-r pointer-events-none" />
</div>
</template>
<!-- 模式 A无轮播图 蓝渐变 + 柔光球装饰原方案兜底 -->
<template v-else>
<div class="absolute inset-0 z-0 bg-gradient-to-br from-[#1a6dff] to-[#0d4fb8] anim-gradient" />
<div class="anim-blob w-72 h-72 bg-white/25 -top-16 -left-10" />
<div class="anim-blob anim-blob--2 w-80 h-80 bg-sky-300/30 -bottom-24 right-0" />
</template>
<!-- 左右箭头多图时hover 显示 -->
<template v-if="bannerMode && banners.length > 1">
<button
type="button"
aria-label="上一张"
class="absolute left-3 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-black/30 hover:bg-black/50 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition z-30"
@click="prev"
>
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
type="button"
aria-label="下一张"
class="absolute right-3 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-black/30 hover:bg-black/50 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition z-30"
@click="next"
>
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<!-- 文字切换指示器 -->
<div
class="absolute bottom-8 left-1/2 -translate-x-1/2 z-30 flex flex-wrap justify-center gap-3 max-w-[90%] px-4 py-2 rounded-full bg-black/20 backdrop-blur-sm"
@mouseenter="stopAuto"
@mouseleave="startAuto"
>
<button
v-for="(b, i) in banners"
:key="'nav-' + (b.id || i)"
type="button"
:aria-label="b.title || ('切换到第 ' + (i + 1) + ' ')"
class="px-4 py-1.5 text-sm rounded-full transition border whitespace-nowrap"
:class="i === currentBannerIndex
? 'bg-red-600 border-red-600 text-white font-semibold'
: 'bg-white/10 border-white/30 text-white hover:bg-white/20'"
@click="goTo(i)"
>
{{ b.title || ('图片 ' + (i + 1)) }}
</button>
</div>
</template>
<!-- 内容层不使用 :key remount watch+transition 切换文案 -->
<div class="container mx-auto px-4 sm:px-6 lg:px-8 relative z-20 py-20 lg:py-28 pointer-events-none">
<div class="grid lg:grid-cols-12 gap-12 items-center w-full">
<div class="lg:col-span-7 max-w-2xl transition-opacity duration-500 ease-in-out" :class="{ 'opacity-0': textFading }">
<h1
class="text-3xl sm:text-4xl lg:text-5xl font-bold leading-tight mb-6"
data-reveal
>
{{ heroTitle }}
</h1>
<p
class="text-base sm:text-lg text-blue-100 mb-8 leading-relaxed line-clamp-2"
data-reveal
data-reveal-delay="120"
>
{{ heroSubtitle }}
</p>
<div class="flex flex-wrap gap-4" data-reveal data-reveal-delay="240">
<button
type="button"
class="inline-flex items-center justify-center px-6 py-3 bg-white text-blue-600 font-semibold rounded-lg hover:bg-blue-50 transition-colors anim-shimmer pointer-events-auto"
@click="openConsult()"
>
免费咨询
</button>
<NuxtLink
v-if="newsNav"
:to="newsLink"
class="inline-flex items-center justify-center px-6 py-3 border-2 border-white text-white font-semibold rounded-lg hover:bg-white/10 transition-colors pointer-events-auto"
>
查看动态
</NuxtLink>
</div>
</div>
<div v-if="showHeroFeatures" class="hidden lg:flex lg:col-span-5 justify-center" data-reveal data-reveal-delay="360">
<div class="relative w-full max-w-lg">
<div class="absolute inset-0 bg-white/10 rounded-3xl transform rotate-3" />
<div class="relative bg-white/20 backdrop-blur-sm rounded-3xl p-8 border border-white/20">
<div class="space-y-4">
<div v-for="feature in features" :key="feature" class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<span class="text-blue-50">{{ feature }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
/**
* 模板 7 - Hero 主视觉区(支持 CMS 轮播图作为背景 + 文案跟随切换)
*
* 数据源:GET /api/banner?position=home_slider(代理 cms_banner_group 表)。
* 双模式二选一:
* - 有轮播图:图片作为全屏背景 + 品牌蓝蒙版 + 左侧文案(从 banner.title/subtitle 取)/按钮/右侧特性卡片 +
* 自动轮播(5s) + 左右箭头/指示点;轮播切换时文案淡入淡出过渡;
* - 无轮播图:回退到原蓝渐变 + 柔光球方案。
*
* 文案优先级:当前 banner 的 title/subtitle → siteInfo.slogan/comments → siteName 拼接 → 硬编码兜底。
* 即使某张 banner 未配文案也不会出现空白。
*/
import type { CmsNavigation, CmsBanner, ApiEnvelope, PageResult } from '~/types'
import { ensureFullUrl } from '~/utils/image'
import { getNavLink, getBannerLink } from '~/utils'
interface Props {
/** 是否启用 Banner 轮播背景;false 时即使有轮播图也回退到蓝渐变 */
showBanner?: boolean
/** 是否显示首屏右侧特性卡片 */
showHeroFeatures?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showBanner: true,
showHeroFeatures: true
})
const { siteInfo, siteName, navigations, fetchSiteInfo } = useSite()
const { openConsult } = useConsult()
await fetchSiteInfo()
/** 轮播图数据(SSR 预取,首屏即有) */
const { data: bannerRes } = await useFetch<ApiEnvelope<PageResult<CmsBanner>> | PageResult<CmsBanner>>('/api/banner', {
query: { position: 'home_slider' }
})
const banners = computed<CmsBanner[]>(() => {
const d = bannerRes.value as (ApiEnvelope<PageResult<CmsBanner>> | PageResult<CmsBanner>) | null
if (!d) return []
const list = ((d as ApiEnvelope<PageResult<CmsBanner>>)?.data?.list
?? (d as PageResult<CmsBanner>)?.list
?? []) as CmsBanner[]
return list
.filter((b) => hasImage(b) && b.deleted !== 1 && b.status !== 2)
.sort((a, b) => (a.sortNum ?? 0) - (b.sortNum ?? 0))
})
/** 是否进入轮播图模式:有可用轮播图且后台开启 Banner 轮播开关 */
const bannerMode = computed(() => props.showBanner && banners.value.length > 0)
/** 当前帧索引 */
const currentBannerIndex = ref(0)
/** 文案淡出/淡入控制(watch currentBannerIndex 触发,不用 :key remount */
const textFading = ref(false)
let fadeTimer: ReturnType<typeof setTimeout> | null = null
watch(currentBannerIndex, () => {
// 先淡出 → 等 300ms 文案已更新 → 再淡入
textFading.value = true
clearTimeout(fadeTimer!)
fadeTimer = setTimeout(() => {
textFading.value = false
}, 300)
})
let bannerTimer: ReturnType<typeof setInterval> | null = null
function startAuto() {
if (banners.value.length > 1) {
bannerTimer = setInterval(() => {
currentBannerIndex.value = (currentBannerIndex.value + 1) % banners.value.length
}, 5000)
}
}
function stopAuto() {
if (bannerTimer) { clearInterval(bannerTimer); bannerTimer = null }
}
onMounted(() => { startAuto() })
onBeforeUnmount(() => { stopAuto(); clearTimeout(fadeTimer!) })
function prev() {
const n = banners.value.length
currentBannerIndex.value = (currentBannerIndex.value - 1 + n) % n
}
function next() {
const n = banners.value.length
currentBannerIndex.value = (currentBannerIndex.value + 1) % n
}
function goTo(i: number) {
currentBannerIndex.value = i
}
/** 取轮播图地址(兼容 image/imageUrl/pic/url,并提升 OSS 压缩宽度防大屏发虚) */
function bannerImg(b: CmsBanner): string {
const raw = ensureFullUrl(b.image || b.imageUrl || b.pic || b.url || '')
return raw.replace(/resize,w_\d+/, 'resize,w_1920')
}
/** 取轮播图跳转链接(统一走 utils.getBannerLink,兼容 link/linkUrl/link_url 多字段名) */
function bannerLink(b: CmsBanner): string {
return getBannerLink(b)
}
/** 是否有可用图片 */
function hasImage(b: CmsBanner): boolean {
return !!(b.image || b.imageUrl || b.pic || b.url)
}
/** 当前活跃的轮播项 */
const currentBanner = computed<CmsBanner | undefined>(() => banners.value[currentBannerIndex.value])
/**
* 标题:优先取当前 banner 的 title
* 无值时回退 siteInfo.slogan → siteName 拼接 → 硬编码兜底。
* 保证任何情况下都不为空。
*/
const heroTitle = computed(() => {
const bt = currentBanner.value?.title
if (bannerMode.value && bt && bt.trim()) {
return bt.trim()
}
return (siteInfo.value?.slogan?.trim())
|| (siteName.value ? `欢迎来到 ${siteName.value}` : '专注企业数字化官网建设')
})
/**
* 副标题:优先取当前 banner 的 subtitle
* 无值时回退 siteInfo.comments → 硬编码兜底。
* 保证任何情况下都不为空。
*/
const heroSubtitle = computed(() => {
const bs = currentBanner.value?.subtitle
if (bannerMode.value && bs && bs.trim()) {
return bs.trim()
}
return (siteInfo.value?.comments?.trim())
|| '快速搭建品牌展示、产品发布与客户咨询一体化官网,助力企业数字化转型与业务增长。'
})
const newsNav = computed<CmsNavigation | undefined>(() => {
const navs = navigations.value || []
return navs.find((n) => n.model === 'article')
})
/** 「查看动态」按钮链接:走标准 getNavLink 优先级(url > path),不硬编码 */
const newsLink = computed(() => getNavLink(newsNav.value))
/** 右侧特性卡片文案:优先读后台 setting.features.heroFeatures,未配置回退模板默认值 */
const { heroFeatures } = useFeatures([])
const features = computed<string[]>(() => {
const hf = heroFeatures.value
if (Array.isArray(hf) && hf.length) return hf
return [
'多模板一键切换',
'响应式多端适配',
'SEO/GEO 友好',
'独立域名绑定'
]
})
</script>