Files
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

196 lines
8.2 KiB
Vue
Raw Permalink 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 group h-[280px] sm:h-[400px] lg:h-[600px]">
<!-- 底色金棕渐变兜底 -->
<div class="absolute inset-0" :style="{ backgroundImage: 'var(--t8-navbar-bg)' }"></div>
<!-- 轮播图模式 -->
<template v-if="bannerMode">
<div class="absolute inset-0">
<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 || siteName" class="w-full h-full object-cover">
</a>
<img v-else :src="bannerImg(b)" :alt="b.title || siteName" class="w-full h-full object-cover">
</div>
</div>
<div class="absolute inset-0 bg-black/35 pointer-events-none"></div>
</template>
<!-- 无图渐变兜底装饰 -->
<template v-else>
<div class="absolute -top-24 -left-16 w-80 h-80 rounded-full bg-white/15 blur-3xl"></div>
<div class="absolute -bottom-28 -right-10 w-96 h-96 rounded-full bg-black/10 blur-3xl"></div>
</template>
<!-- 文案叠加始终显示bannerMode 下取当前帧 title/subtitle否则用站点 slogan/comments -->
<div class="relative z-10 h-full container mx-auto px-4 flex flex-col items-center justify-center text-center text-white pointer-events-none">
<h1 :key="'h-' + currentBannerIndex" class="hero-fade text-3xl sm:text-4xl lg:text-6xl font-bold mb-4 sm:mb-5 tracking-wide drop-shadow-sm">{{ currentTitle }}</h1>
<p v-if="currentSubtitle" :key="'p-' + currentBannerIndex" class="hero-fade text-sm sm:text-base lg:text-xl max-w-2xl mb-6 sm:mb-8 text-white/90">{{ currentSubtitle }}</p>
<NuxtLink
:to="ctaLink"
class="inline-flex items-center justify-center px-8 py-3 bg-white text-[var(--t8-primary-dark)] text-sm sm:text-base font-semibold rounded-full hover:bg-[var(--t8-primary-soft)] transition-colors shadow-lg pointer-events-auto"
>
了解更多
<svg class="w-4 h-4 ml-2" 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>
</NuxtLink>
</div>
<!-- 左右箭头 -->
<button
v-if="bannerMode && banners.length > 1"
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-20"
@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
v-if="bannerMode && banners.length > 1"
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-20"
@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 v-if="bannerMode && banners.length > 1" class="absolute bottom-5 left-1/2 -translate-x-1/2 flex gap-2 z-20">
<button
v-for="(b, i) in banners"
:key="'dot-' + (b.id || i)"
type="button"
:aria-label="'切换到第 ' + (i + 1) + ' '"
class="w-2.5 h-2.5 rounded-full transition"
:class="i === currentBannerIndex ? 'bg-white scale-110' : 'bg-white/50 hover:bg-white/80'"
@click="goTo(i)"
/>
</div>
</section>
</template>
<script setup lang="ts">
/**
* 模板 8 - Hero 主视觉区(支持 CMS 轮播图)
*
* 双模式二选一:
* - 有轮播图(/api/banner 返回 ≥1 张):全宽铺满走马灯 + 遮罩 + 左右箭头/指示点 + 了解更多按钮;
* - 无轮播图:金棕渐变背景 + 居中大标题文案 + 了解更多按钮。
*/
import type { CmsNavigation, CmsBanner, ApiEnvelope, PageResult } from '~/types'
import { ensureFullUrl } from '~/utils/image'
import { getNavLink, getBannerLink } from '~/utils'
const { siteInfo, siteName, navigations, fetchSiteInfo } = useSite()
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))
})
/** 是否有轮播图:决定走全屏走马灯还是金棕渐变背景 */
const bannerMode = computed(() => banners.value.length > 0)
/** 当前帧索引 */
const currentBannerIndex = ref(0)
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() })
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 heroTitle = computed(() => siteInfo.value?.slogan || siteName.value || '欢迎来到我们的官网')
const heroSubtitle = computed(() => siteInfo.value?.comments || '我们致力于为企业提供专业的数字化官网解决方案,从品牌展示到客户转化,全流程助力企业互联网营销。')
/** 当前帧(用于文案切换) */
const currentBanner = computed<CmsBanner | undefined>(() => banners.value[currentBannerIndex.value])
/** 当前帧文案:优先用 banner 自带的 title/subtitle,回退到站点 slogan/comments */
const currentTitle = computed(() => {
const bTitle = currentBanner.value?.title?.trim()
return bTitle || heroTitle.value
})
const currentSubtitle = computed(() => {
const bSubtitle = currentBanner.value?.subtitle?.trim()
return bSubtitle || heroSubtitle.value
})
const aboutNav = computed<CmsNavigation | undefined>(() => {
const navs = navigations.value || []
return navs.find((n) => n.model === 'page' && (n.title || '').includes('关于'))
|| navs.find((n) => n.model === 'page')
})
const ctaLink = computed(() => aboutNav.value ? (aboutNav.value.path || getNavLink(aboutNav.value)) : '/about')
</script>
<style scoped>
/* 轮播切换时文案淡入(:key 变化触发 remount,节点入场跑一次) */
.hero-fade {
animation: hero-fade-in 0.7s ease-out both;
}
@keyframes hero-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>