feat(app): 添加多模板关于我们页面及相关路由和404页面
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引 - 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理 - 实现/article、/case、/product及/page动态路由兼容列表与详情展示 - 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置 - 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持 - 模板增强支持CMS单页内容加载及SEO信息动态设置 - 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<section v-if="enabled" class="py-16 lg:py-20 bg-gray-50">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-2xl sm:text-3xl font-bold text-gray-900 mb-4">{{ title }}</h2>
|
||||
<p v-if="subtitle" class="text-gray-600 max-w-2xl mx-auto">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="item.title || index"
|
||||
class="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow anim-card group"
|
||||
data-reveal
|
||||
:data-reveal-delay="index * 100"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 rounded-lg bg-blue-50 flex items-center justify-center mb-4 transition-transform duration-300 group-hover:scale-110"
|
||||
>
|
||||
<FeatureIcon :name="item.icon" class="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">{{ item.title }}</h3>
|
||||
<p class="text-sm text-gray-600 leading-relaxed">{{ item.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 模板 7 - 四大核心业务模块(科技蓝,后台可配置)
|
||||
* 数据来自 useFeatures(),未配置时回退到下方默认值。
|
||||
*/
|
||||
import FeatureIcon from '~/components/FeatureIcon.vue'
|
||||
import { useFeatures } from '~/composables/useFeatures'
|
||||
import type { FeatureItem } from '~/types'
|
||||
|
||||
const defaultFeatures: FeatureItem[] = [
|
||||
{ title: '政府采购代理', desc: '提供招标投标、政府采购全流程代理服务,专业合规、高效透明。', icon: 'document' },
|
||||
{ title: '工程咨询服务', desc: '工程造价、工程监理、项目管理等一站式工程咨询服务,助力项目顺利落地。', icon: 'building' },
|
||||
{ title: '投融资服务', desc: '项目投融资策划与对接,为政府及企业提供资金解决方案与财务顾问支持。', icon: 'money' },
|
||||
{ title: 'AI 数智化', desc: '运用人工智能与大数据技术,推动政务与企业数字化转型,提升管理效能。', icon: 'cpu' }
|
||||
]
|
||||
|
||||
const { enabled, title, subtitle, items } = useFeatures(defaultFeatures)
|
||||
</script>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<footer class="bg-gray-900 text-gray-300">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-12 lg:py-16">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-8 lg:gap-12">
|
||||
<!-- 公司信息 -->
|
||||
<div class="lg:col-span-3">
|
||||
<SiteBrand
|
||||
link-class="mb-4"
|
||||
:logo="siteLogo"
|
||||
:icon="siteIcon"
|
||||
:name="siteName"
|
||||
logo-class="h-8 w-auto max-w-[200px] object-contain"
|
||||
icon-class="h-6 w-auto object-contain"
|
||||
name-class="text-lg font-bold text-white"
|
||||
>
|
||||
<template #fallback-icon>
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-600 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="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
</SiteBrand>
|
||||
<p v-if="slogan" class="text-sm text-gray-400 mb-3">{{ slogan }}</p>
|
||||
<p class="text-sm text-gray-400 leading-relaxed">
|
||||
{{ siteInfo?.comments || siteInfo?.content || '专注企业数字化官网建设,助力品牌增长。' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 导航分组(支持二级:父级标题 + 子级链接) -->
|
||||
<div class="lg:col-span-4">
|
||||
<div class="grid grid-cols-2 gap-x-6 gap-y-8">
|
||||
<div v-for="group in footerGroups" :key="group.key">
|
||||
<h4 class="text-white font-semibold mb-4">
|
||||
<span
|
||||
v-if="group.parentLink"
|
||||
:to="group.parentLink"
|
||||
:target="group.target === '_blank' ? '_blank' : undefined"
|
||||
class="text-white"
|
||||
>
|
||||
{{ group.title }}
|
||||
</span>
|
||||
<template v-else>{{ group.title }}</template>
|
||||
</h4>
|
||||
<ul v-if="group.hasChildren" class="space-y-2">
|
||||
<li v-for="link in group.links" :key="link.key">
|
||||
<NuxtLink
|
||||
:to="getNavLink(link)"
|
||||
:target="link.target === '_blank' ? '_blank' : undefined"
|
||||
class="text-sm text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
{{ link.title }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 联系方式 -->
|
||||
<div class="lg:col-span-3">
|
||||
<h4 class="text-white font-semibold mb-4">联系我们</h4>
|
||||
<ul class="space-y-2 text-sm text-gray-400">
|
||||
<li v-if="phone">
|
||||
<span class="text-gray-500">电话:</span>{{ phone }}
|
||||
</li>
|
||||
<li v-if="email">
|
||||
<span class="text-gray-500">邮箱:</span>{{ email }}
|
||||
</li>
|
||||
<li v-if="officialWebsite">
|
||||
<span class="text-gray-500">官网:</span>
|
||||
<a :href="officialWebsiteUrl" target="_blank" rel="nofollow" class="hover:text-white transition-colors">{{ officialWebsite }}</a>
|
||||
</li>
|
||||
<li v-if="address" class="leading-relaxed">
|
||||
<span class="text-gray-500">地址:</span>{{ address }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 微信二维码 + 社交链接 -->
|
||||
<div v-if="wxQrcode || socialLinks.length" class="lg:col-span-2">
|
||||
<h4 class="text-white font-semibold mb-4">关注我们</h4>
|
||||
<div v-if="wxQrcode" class="bg-white rounded-lg p-3 inline-block">
|
||||
<img :src="wxQrcode" alt="微信二维码" class="w-28 h-28 object-contain">
|
||||
</div>
|
||||
<p v-if="wxQrcode" class="text-xs text-gray-500 mt-2">{{ siteConfig?.wxQrcodeText || '扫码关注' }}</p>
|
||||
<div v-if="socialLinks.length" class="mt-3 flex flex-col gap-1">
|
||||
<a
|
||||
v-for="link in socialLinks"
|
||||
:key="link.url"
|
||||
:href="link.url"
|
||||
target="_blank"
|
||||
rel="nofollow"
|
||||
class="text-sm text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
{{ link.name || link.type }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-800 mt-12 pt-8 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ copyright || `© ${new Date().getFullYear()} ${siteName || '企业官网'} 版权所有` }}
|
||||
</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<p v-if="icpNo" class="text-sm text-gray-500">
|
||||
{{ icpNo }}
|
||||
</p>
|
||||
<p v-if="siteInfo?.policeNo" class="text-sm text-gray-500">
|
||||
{{ siteInfo.policeNo }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="text-gray-500">Powered by</span>
|
||||
<a
|
||||
rel="nofollow"
|
||||
href="https://site.websoft.top"
|
||||
target="_blank"
|
||||
class="text-gray-500 hover:text-gray-200 transition-colors"
|
||||
>云·企业官网</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 模板 7 - Footer 组件
|
||||
* 使用 useSite 的 bottomNavigations(top!==1 的页脚链接)渲染导航。
|
||||
* 支持二级导航:每个顶级栏目作为分组标题,其 children 作为子链接渲染在标题下方;
|
||||
* 若栏目无子级,则该栏目自身作为单独链接展示(无二级时不退化、不报错)。
|
||||
*/
|
||||
import type { CmsNavigation } from '~/types'
|
||||
|
||||
const {
|
||||
siteInfo, siteName, siteLogo, siteIcon, siteConfig,
|
||||
bottomNavigations, phone, email, address, icpNo, copyright,
|
||||
wxQrcode, slogan, officialWebsite, officialWebsiteUrl, socialLinks
|
||||
} = useSite()
|
||||
|
||||
interface FooterGroup {
|
||||
key: string | number
|
||||
title: string
|
||||
/** 父级可点击链接(父级自身有 path/url 时存在;无子级时即为该栏目链接) */
|
||||
parentLink: string | null
|
||||
/** 是否渲染子级列表 */
|
||||
hasChildren: boolean
|
||||
/** 子级链接(无子级时为 [父级自身]) */
|
||||
links: CmsNavigation[]
|
||||
/** 父级打开方式 */
|
||||
target?: string
|
||||
}
|
||||
|
||||
function navKey(nav: CmsNavigation): string | number {
|
||||
return nav.navigationId ?? nav.path ?? nav.pagePath ?? nav.title ?? Math.random()
|
||||
}
|
||||
|
||||
/** 页脚导航分组:将 bottomNavigations 整理为「父级标题 + 子级链接」结构 */
|
||||
const footerGroups = computed<FooterGroup[]>(() => {
|
||||
const navs = bottomNavigations.value || []
|
||||
return navs
|
||||
.filter((nav) => nav.model !== 'index')
|
||||
.map((nav) => {
|
||||
const children = nav.children && nav.children.length ? nav.children : null
|
||||
const hasOwnLink = !!(nav.path || nav.url || nav.pagePath)
|
||||
return {
|
||||
key: navKey(nav),
|
||||
title: nav.title || '',
|
||||
parentLink: hasOwnLink ? getNavLink(nav) : null,
|
||||
hasChildren: !!children,
|
||||
links: children || [nav],
|
||||
target: nav.target
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<header class="sticky top-0 z-50 bg-white/95 backdrop-blur-sm border-b border-gray-100">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-2">
|
||||
<div class="flex items-center justify-between h-16 lg:h-20">
|
||||
<!-- Logo -->
|
||||
<SiteBrand
|
||||
link-class="flex-shrink-0"
|
||||
:logo="siteLogo"
|
||||
:icon="siteIcon"
|
||||
:name="siteName"
|
||||
logo-class="h-16 w-auto object-contain"
|
||||
icon-class="h-8 w-auto object-contain"
|
||||
name-class="text-lg font-bold text-gray-900 truncate max-w-[160px] sm:max-w-xs"
|
||||
>
|
||||
<template #fallback-icon>
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-600 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="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
</SiteBrand>
|
||||
|
||||
<!-- 搜索框(后台 setting.searchBtn 控制是否显示) -->
|
||||
<SiteSearchBox v-if="showHeaderSearch" variant="light" accent="#2563eb" class="hidden lg:flex items-center ml-8 mr-auto" />
|
||||
|
||||
<!-- 桌面端导航 -->
|
||||
<nav class="hidden lg:flex items-center gap-8">
|
||||
<template v-for="item in navItems" :key="item.navigationId || item.path">
|
||||
<!-- 有子菜单 -->
|
||||
<div v-if="item.children && item.children.length > 0" class="relative group">
|
||||
<button class="text-base font-medium text-gray-700 hover:text-blue-600 transition-colors flex items-center gap-1">
|
||||
{{ item.title }}
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- 下拉菜单 -->
|
||||
<div class="absolute left-1/2 -translate-x-1/2 top-full pt-2 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all">
|
||||
<div class="bg-white rounded-lg shadow-lg border border-gray-100 py-2 min-w-[160px]">
|
||||
<NuxtLink
|
||||
v-for="child in item.children"
|
||||
:key="child.navigationId"
|
||||
:to="getChildPath(child)"
|
||||
class="block px-4 py-2 text-[15px] text-gray-700 hover:text-blue-600 hover:bg-blue-50 transition-colors whitespace-nowrap"
|
||||
>
|
||||
{{ child.title }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 无子菜单 -->
|
||||
<NuxtLink
|
||||
v-else
|
||||
:to="getNavLink(item)"
|
||||
:target="item.target === '_blank' ? '_blank' : undefined"
|
||||
class="text-base font-medium text-gray-700 hover:text-blue-600 transition-colors"
|
||||
active-class="text-blue-600"
|
||||
>
|
||||
{{ item.title }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="hidden lg:block" style="position: fixed; right: 20px; top: 50%; transform: translateY(-50%);">
|
||||
<NuxtLink
|
||||
to="/contact"
|
||||
class="inline-flex items-center justify-center px-4 py-2 bg-blue-600 text-white text-base font-semibold rounded-full hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
在线咨询
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- 移动端菜单按钮 -->
|
||||
<button
|
||||
class="lg:hidden p-2 rounded-lg hover:bg-gray-100"
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
>
|
||||
<svg v-if="!mobileMenuOpen" class="w-6 h-6 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<svg v-else class="w-6 h-6 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端菜单 -->
|
||||
<div
|
||||
v-if="mobileMenuOpen"
|
||||
class="lg:hidden bg-white border-t border-gray-100"
|
||||
>
|
||||
<div class="container mx-auto px-4 py-4 space-y-1">
|
||||
<template v-for="item in navItems" :key="item.navigationId || item.path">
|
||||
<!-- 有子菜单 -->
|
||||
<div v-if="item.children && item.children.length > 0">
|
||||
<div class="px-4 py-3 text-base font-semibold text-gray-900">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<NuxtLink
|
||||
v-for="child in item.children"
|
||||
:key="child.navigationId"
|
||||
:to="getChildPath(child)"
|
||||
class="block px-8 py-2 text-sm text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
@click="mobileMenuOpen = false"
|
||||
>
|
||||
{{ child.title }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<!-- 无子菜单 -->
|
||||
<NuxtLink
|
||||
v-else
|
||||
:to="getNavLink(item)"
|
||||
:target="item.target === '_blank' ? '_blank' : undefined"
|
||||
class="block px-4 py-3 text-base font-medium text-gray-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
active-class="text-blue-600 bg-blue-50"
|
||||
@click="mobileMenuOpen = false"
|
||||
>
|
||||
{{ item.title }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<NuxtLink
|
||||
to="/contact"
|
||||
class="block px-4 py-3 mt-2 text-center bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors"
|
||||
@click="mobileMenuOpen = false"
|
||||
>
|
||||
在线咨询
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 模板 1 - Header 组件
|
||||
* 使用 useSite 的 navigations(合并 topNavs + bottomNavs,过滤 top===1)渲染导航菜单
|
||||
*/
|
||||
import type { CmsNavigation } from '~/types'
|
||||
|
||||
const { siteInfo, siteName, siteLogo, siteIcon, navigations, showHeaderSearch, fetchSiteInfo } = useSite()
|
||||
const mobileMenuOpen = ref(false)
|
||||
|
||||
/** 子导航链接统一走 app/utils 的 getNavLink(自动避免 /page/4598?navId=4598 冗余拼接) */
|
||||
function getChildPath(child: CmsNavigation): string {
|
||||
return getNavLink(child)
|
||||
}
|
||||
|
||||
// 获取站点信息(含导航数据)
|
||||
await fetchSiteInfo()
|
||||
|
||||
/** 导航项(使用 useSite 合并 topNavs + bottomNavs 并过滤 top===1 的导航) */
|
||||
const navItems = computed<CmsNavigation[]>(() => {
|
||||
return navigations.value || []
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,310 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user