feat(app): 添加多模板关于我们页面及相关路由和404页面

- 新增404页面,优化未找到页面体验,避免被搜索引擎索引
- 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理
- 实现/article、/case、/product及/page动态路由兼容列表与详情展示
- 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置
- 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持
- 模板增强支持CMS单页内容加载及SEO信息动态设置
- 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
This commit is contained in:
2026-09-08 12:13:44 +08:00
commit 2b69686795
381 changed files with 59891 additions and 0 deletions
@@ -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 p-3 text-blue-500 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">
/**
* 模板 1 - 特色功能模块(后台可配置)
* 数据来自 useFeatures(),未配置时回退到下方默认值。
*/
import FeatureIcon from '~/components/FeatureIcon.vue'
import { useFeatures } from '~/composables/useFeatures'
import type { FeatureItem } from '~/types'
const defaultFeatures: FeatureItem[] = [
{ title: '企业优势', desc: '快速建立品牌信任感,展示企业实力与核心竞争力。', icon: 'building' },
{ title: '核心产品', desc: '清晰的产品展示与分类,帮助客户快速了解产品价值。', icon: 'box' },
{ title: '客户案例', desc: '真实案例呈现,增强说服力,促进客户决策。', icon: 'case' },
{ title: '在线留言', desc: '便捷的留言咨询通道,不错过任何潜在客户。', icon: 'message' }
]
const { enabled, title, subtitle, items } = useFeatures(defaultFeatures)
</script>
@@ -0,0 +1,128 @@
<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 sm:grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12">
<!-- 公司信息 -->
<div class="sm:col-span-2 lg:col-span-1">
<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>
<h4 class="text-white font-semibold mb-4">快速链接</h4>
<ul class="space-y-2">
<li v-for="item in quickLinks" :key="item.navigationId">
<NuxtLink
:to="getNavLink(item)"
:target="item.target === '_blank' ? '_blank' : undefined"
class="text-sm text-gray-400 hover:text-white transition-colors"
>
{{ item.title }}
</NuxtLink>
</li>
</ul>
</div>
<!-- 联系方式 -->
<div>
<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="address" class="leading-relaxed">
<span class="text-gray-500">地址</span>{{ address }}
</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>
</ul>
</div>
<!-- 微信二维码 + 社交链接 -->
<div v-if="wxQrcode || socialLinks.length">
<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-32 h-32 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">
/**
* 模板 1 - Footer 组件
* 使用 config 与 useSite 的 bottomNavigationstop!==1 的页脚链接)渲染
*/
import type { CmsNavigation } from '~/types'
const { siteInfo, siteName, siteLogo, siteIcon, siteConfig, bottomNavigations, phone, email, address, icpNo, copyright, wxQrcode, slogan, officialWebsite, officialWebsiteUrl, socialLinks } = useSite()
/** 快速链接:取顶级导航中非首页且无子菜单的项 */
const quickLinks = computed<CmsNavigation[]>(() => {
const navs = bottomNavigations.value || []
return navs.filter((nav) => nav.model !== 'index').slice(0, 6)
})
</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-8">
<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-12 w-auto max-w-[200px] 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 mx-10">
<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,302 @@
<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 from-[#0d4fb8]/45 via-[#1a6dff]/30 to-[#1a6dff]/18 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-5 left-1/2 -translate-x-1/2 flex gap-2 z-30">
<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>
</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-2 gap-12 items-center w-full">
<div class="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"
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 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">
/**
* 模板 1 - 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>
+18
View File
@@ -0,0 +1,18 @@
import type { TemplateConfig } from '~/composables/useTemplate'
/**
* 模板 1 配置
* 蓝色科技风企业官网模板
*/
export default {
id: 'template-01',
name: '科技蓝企业模板',
description: '蓝色科技风,适合互联网、科技、SaaS 类企业官网',
preview: '/templates/cloud-website/template-01.png',
supportedModules: ['home', 'about', 'products', 'cases', 'news', 'contact'],
themeConfig: {
primaryColor: '#1a6dff',
secondaryColor: '#0d4fb8',
fontFamily: '"Noto Sans SC", "Source Han Sans SC", "思源黑体", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif'
}
} satisfies TemplateConfig
+270
View File
@@ -0,0 +1,270 @@
<template>
<div>
<!-- Hero 横幅 -->
<section class="relative overflow-hidden bg-gradient-to-r from-blue-700 to-blue-500">
<div class="absolute -top-20 -right-16 w-72 h-72 rounded-full bg-white/10 blur-3xl" />
<div class="absolute -bottom-24 left-10 w-64 h-64 rounded-full bg-black/10 blur-3xl" />
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-12 lg:py-16 relative z-10">
<nav class="text-xs text-white/70 mb-3" aria-label="面包屑">
<NuxtLink to="/" class="hover:text-white transition-colors">首页</NuxtLink>
<span class="mx-2">/</span>
<span class="text-white">关于我们</span>
</nav>
<h1 class="text-3xl sm:text-4xl font-bold text-white">关于我们</h1>
<p class="text-white/75 mt-3 max-w-2xl leading-relaxed">
{{ slogan || '专业务实,做企业值得信赖的数字化伙伴' }}
</p>
</div>
</section>
<!-- 公司简介 -->
<section class="py-16 lg:py-20 bg-white">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="max-w-5xl mx-auto">
<div class="grid lg:grid-cols-2 gap-12 items-center">
<div class="order-1 lg:order-2">
<span class="inline-block px-3 py-1 rounded-full bg-blue-50 text-blue-600 text-sm font-semibold mb-4">公司简介</span>
<h2 class="text-2xl sm:text-3xl font-bold text-gray-900 mb-6">专业务实做企业值得信赖的数字化伙伴</h2>
<div class="text-gray-600 leading-relaxed mb-8 space-y-4">
<p v-if="introText">{{ introText }}</p>
<template v-else>
<p>我们深耕企业软件与行业信息化领域聚焦客户需求提供从咨询规划产品设计到研发交付运维支持的一站式服务</p>
<p>以稳定可靠的架构与持续优化的服务帮助客户降低数字化门槛提升运营效率实现可持续的业务增长</p>
</template>
</div>
<div class="flex flex-wrap gap-3 mb-8">
<span v-for="tag in tags" :key="tag"
class="px-3 py-1.5 rounded-full bg-blue-50 border border-blue-100 text-sm text-gray-700">{{ tag }}</span>
</div>
<NuxtLink to="/contact" class="inline-flex items-center px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors shadow-sm">
联系我们
<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>
<!-- 右侧装饰图区域 coverImage 时显示实际图片否则显示 CSS 渐变装饰块 -->
<div class="order-2 lg:order-1 rounded-2xl overflow-hidden shadow-lg"
:class="coverImage ? '' : 'bg-gradient-to-br from-blue-100 to-blue-50 flex items-center justify-center min-h-[280px] lg:min-h-[384px]'">
<img v-if="coverImage" :src="coverImage" alt="关于我们" class="w-full h-72 lg:h-96 object-cover">
<div v-else class="p-8 text-center">
<svg class="w-20 h-20 mx-auto text-blue-300 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" 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>
<p class="text-blue-400 font-medium">{{ pageTitle }}</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- 核心优势 -->
<section class="py-16 lg:py-20 bg-gray-50">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="max-w-5xl mx-auto">
<div class="text-center mb-12">
<span class="inline-block px-3 py-1 rounded-full bg-white text-blue-600 text-sm font-semibold mb-4">核心优势</span>
<h2 class="text-2xl sm:text-3xl font-bold text-gray-900 mb-4">为什么选择我们</h2>
<p class="text-gray-500 max-w-2xl mx-auto">从技术到服务全链路保障客户的数字化投资回报</p>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
<div v-for="(item, index) in advantages" :key="item.title"
class="bg-white rounded-2xl p-7 shadow-sm hover:shadow-md transition-shadow border border-gray-100 group">
<div class="w-14 h-14 rounded-xl flex items-center justify-center mb-5 bg-blue-50">
<svg class="w-7 h-7 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="item.icon" /></svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">{{ item.title }}</h3>
<p class="text-sm text-gray-500 leading-relaxed">{{ item.desc }}</p>
</div>
</div>
</div>
</div>
</section>
<!-- 发展历程 -->
<section class="py-16 lg:py-20 bg-white">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="max-w-5xl mx-auto">
<div class="grid lg:grid-cols-2 gap-12 items-center">
<div class="order-2 lg:order-1">
<span class="inline-block px-3 py-1 rounded-full bg-blue-50 text-blue-600 text-sm font-semibold mb-4">发展历程</span>
<h2 class="text-2xl sm:text-3xl font-bold text-gray-900 mb-8">一路同行步履不停</h2>
<div class="relative border-l-2 border-blue-100 pl-8 space-y-8">
<div v-for="(m, index) in milestones" :key="m.year" class="relative">
<span class="absolute -left-[41px] top-1 w-5 h-5 rounded-full bg-blue-600 border-4 border-white shadow"></span>
<div class="text-blue-600 font-bold text-lg">{{ m.year }}</div>
<div class="text-gray-900 font-semibold mt-1">{{ m.title }}</div>
<p class="text-gray-500 text-sm leading-relaxed mt-1">{{ m.desc }}</p>
</div>
</div>
</div>
<div class="order-1 lg:order-2 rounded-2xl overflow-hidden shadow-lg bg-gradient-to-br from-blue-50 to-indigo-50 flex items-center justify-center min-h-[320px] lg:min-h-[480px]">
<div class="p-8 text-center">
<svg class="w-24 h-24 mx-auto text-blue-200 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="0.8" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p class="text-blue-400 font-medium">持续成长</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- 数据实力 -->
<section class="py-16 lg:py-20 bg-gray-50">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="max-w-5xl mx-auto">
<div class="text-center mb-12">
<span class="inline-block px-3 py-1 rounded-full bg-white text-blue-600 text-sm font-semibold mb-4">数据实力</span>
<h2 class="text-2xl sm:text-3xl font-bold text-gray-900 mb-4">用结果说话</h2>
<p class="text-gray-500 max-w-2xl mx-auto">沉淀多年的交付能力与行业口碑是我们最扎实的底气</p>
</div>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-6">
<div v-for="(s, index) in stats" :key="s.label"
class="bg-white rounded-2xl p-8 text-center shadow-sm border border-gray-100">
<div class="text-gray-900 font-bold text-3xl sm:text-4xl">
{{ s.value }}<span class="text-blue-600">{{ s.suffix }}</span>
</div>
<div class="text-sm text-gray-500 mt-2">{{ s.label }}</div>
</div>
</div>
</div>
</div>
</section>
<!-- CMS 富文本扩展区仅当后台有录入内容时显示 -->
<section v-if="hasContent" class="py-16 lg:py-20 bg-white">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="max-w-5xl mx-auto">
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div class="p-6 sm:p-10">
<div class="flex items-center gap-3 mb-6 pb-6 border-b border-gray-100">
<span class="w-1.5 h-6 rounded-full bg-blue-600" />
<h2 class="text-xl sm:text-2xl font-bold text-gray-900">{{ pageData?.title || '详细介绍' }}</h2>
<span v-if="updateTimeText" class="sm:ml-auto text-sm text-gray-400">
更新于 {{ updateTimeText }}
</span>
</div>
<RichText :content="pageData?.content" />
<PageAttachments
v-if="pageData?.attachments?.length"
:attachments="pageData.attachments"
/>
</div>
</div>
</div>
</div>
</section>
<!-- 联系 CTA -->
<section class="py-16 lg:py-20 bg-gradient-to-r from-blue-700 to-blue-500 relative overflow-hidden">
<div class="absolute -top-20 -right-20 w-80 h-80 rounded-full bg-white/10 blur-3xl" />
<div class="container mx-auto px-4 sm:px-6 lg:px-8 text-center relative z-10">
<h2 class="text-2xl sm:text-3xl font-bold text-white mb-4">有项目需求或合作意向</h2>
<p class="text-white/80 max-w-2xl mx-auto mb-8">立即联系我们获取专属方案与产品演示</p>
<div class="flex flex-wrap items-center justify-center gap-4">
<NuxtLink
to="/contact"
class="inline-flex items-center px-8 py-3 bg-white text-blue-700 font-semibold rounded-lg hover:bg-gray-100 transition-colors shadow-lg"
>
立即咨询
</NuxtLink>
<a
v-if="siteInfo?.phone"
:href="`tel:${siteInfo.phone}`"
class="inline-flex items-center px-6 py-3 border-2 border-white/40 text-white font-semibold rounded-lg hover:bg-white/10 transition-colors"
>
{{ siteInfo.phone }}
</a>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
/**
* 模板 1 - 关于我们(科技蓝)
*
* 重构为「模板内置内容 + 可选 CMS 增强」模式:
* - 默认展示公司简介 / 核心优势 / 发展历程 / 数据实力 / CTA 五大板块
* - 数据优先取 siteInfoslogan/comments),回退模板内置文案
* - 若后台「单页管理」录入了 about 正文(cms_page),底部追加富文本扩展区
* - 彻底解决 API 410/空数据导致页面白屏的问题
*/
import dayjs from 'dayjs'
import type { PageDetail, CaseItem, Product, PageResult, ApiEnvelope } from '~/types'
const { siteInfo, slogan, fetchSiteInfo } = useSite()
const { fileUrl } = useFileUrl()
await fetchSiteInfo()
// ========== CMS 单页数据(可选增强) ==========
const { data: pageData } = await useFetch<PageDetail>('/api/page/detail', {
key: 'page-about',
query: { path: 'about' }
})
const pageTitle = computed(() => pageData.value?.title?.trim() || '关于我们')
const coverImage = computed(() => fileUrl(pageData.value?.photo || ''))
const hasContent = computed(() => Boolean(pageData.value?.hasContent && pageData.value?.content))
const updateTimeText = computed(() => {
const t = pageData.value?.updateTime
return t ? dayjs(t).format('YYYY-MM-DD') : ''
})
// ========== 公司简介 ==========
const introText = computed(() => siteInfo.value?.comments || siteInfo.value?.content || '')
const tags = ['自主研发', '行业深耕', '安全稳定', '贴身服务']
// ========== 核心优势 ==========
const advantages = [
{ title: '技术实力', desc: '核心产品自主可控,技术栈先进,按需灵活扩展与定制。', icon: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z' },
{ title: '行业经验', desc: '多行业落地实践,深刻理解业务场景与真实痛点。', icon: 'M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6l8-4z' },
{ title: '服务体系', desc: '从咨询规划到运维支持全流程陪伴,7×24 响应。', icon: 'M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z' },
{ title: '客户口碑', desc: '以交付质量与长期服务,赢得客户的持续信赖。', icon: 'M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z' }
]
// ========== 发展历程 ==========
const milestones = [
{ year: '2015', title: '扬帆起航', desc: '公司正式成立,组建核心研发团队,确立企业数字化服务方向。' },
{ year: '2018', title: '技术沉淀', desc: '自研核心产品体系成型,服务客户覆盖多个重点行业。' },
{ year: '2021', title: '规模跃升', desc: '交付能力持续增强,建立标准化实施与运维服务体系。' },
{ year: '2024', title: '智领未来', desc: '拥抱云原生与智能化,为客户提供更敏捷的数字化方案。' }
]
// ========== 数据实力(实时接口 + 占位兜底) ==========
const cases = ref<CaseItem[]>([])
try {
const res = await $fetch<PageResult<CaseItem>>('/api/case/list', { query: { page: 1, limit: 100 } })
cases.value = res?.list || []
} catch { cases.value = [] }
const products = ref<Product[]>([])
try {
const res = await $fetch<ApiEnvelope<PageResult<Product>> | PageResult<Product>>('/api/product/list', { query: { page: 1, limit: 50 } })
const envelope = res as ApiEnvelope<PageResult<Product>>
products.value = envelope?.data?.list || (res as PageResult<Product>)?.list || []
} catch { products.value = [] }
const stats = computed(() => [
{ value: String(cases.value.length || 200), suffix: '+', label: '成功案例' },
{ value: String(products.value.length || 50), suffix: '+', label: '产品与方案' },
{ value: '500', suffix: '+', label: '服务客户' },
{ value: '10', suffix: '年+', label: '行业经验' }
])
// ========== SEO ==========
usePageSeo(
{
title: pageTitle.value,
path: '/about',
keywords: pageData.value?.keywords || undefined,
description: pageData.value?.description || undefined,
image: coverImage.value || undefined
},
siteInfo.value
)
</script>
@@ -0,0 +1,56 @@
<template>
<div class="min-h-screen py-16 bg-white">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div v-if="caseItem" class="max-w-4xl mx-auto">
<div class="mb-8">
<NuxtLink :to="`/case${caseItem?.navigationId ? '?navId=' + caseItem.navigationId : ''}`" class="text-sm text-gray-500 hover:text-blue-600"> 返回案例列表</NuxtLink>
</div>
<h1 class="text-3xl sm:text-4xl font-bold text-gray-900 mb-4">{{ caseItem.title }}</h1>
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-8 pb-8 border-b border-gray-100">
<span v-if="caseItem.clientName" class="px-3 py-1 bg-blue-50 text-blue-600 rounded-full">
{{ caseItem.clientName }}
</span>
<span v-if="caseItem.projectTime">项目时间{{ caseItem.projectTime }}</span>
<span v-if="caseItem.categoryName">行业{{ caseItem.categoryName }}</span>
</div>
<div class="aspect-video bg-gray-100 rounded-xl overflow-hidden mb-10">
<img
v-if="caseItem.cover"
:src="fileUrl(caseItem.cover)"
:alt="caseItem.title"
class="w-full h-full object-cover"
>
</div>
<p v-if="caseItem.summary" class="text-lg text-gray-600 mb-8 leading-relaxed">
{{ caseItem.summary }}
</p>
<RichText :content="caseItem.content" />
</div>
<div v-else class="text-center py-20">
<h1 class="text-2xl font-bold text-gray-900 mb-4">案例不存在或已下架</h1>
<NuxtLink :to="`/case${caseItem?.navigationId ? '?navId=' + caseItem.navigationId : ''}`" class="text-blue-600 hover:underline">返回案例列表</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { CaseItem } from '~/types'
/**
* 模板 1 - 案例详情页
*/
const route = useRoute()
const id = route.params.id as string
const { fileUrl } = useFileUrl()
const { data: caseItem } = await useFetch<CaseItem | null>(`/api/case/detail?id=${id}`, {
key: `case-${id}`
})
</script>
@@ -0,0 +1,130 @@
<template>
<div class="min-h-screen py-16 bg-gray-50">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h1 class="text-3xl font-bold text-gray-900 mb-4">{{ pageTitle }}</h1>
<p class="text-gray-600 max-w-2xl mx-auto">展示我们的成功案例与行业经验</p>
</div>
<div v-if="pending" class="flex justify-center py-12">
<SiteLoading />
</div>
<SiteError v-else-if="error" message="获取案例列表失败" />
<div v-else class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
<article
v-for="item in cases"
:key="item.id"
class="group bg-white rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow"
>
<NuxtLink :to="`/case/${item.id}`" class="block">
<div class="aspect-[4/3] bg-gray-100 overflow-hidden">
<img
v-if="item.cover"
:src="fileUrl(item.cover)"
:alt="item.title"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
>
<span v-else class="flex items-center justify-center h-full text-gray-400">暂无图片</span>
</div>
<div class="p-5">
<h2 class="text-lg font-semibold text-gray-900 mb-2 group-hover:text-blue-600 transition-colors">
{{ item.title }}
</h2>
<p class="text-sm text-gray-600 line-clamp-2">{{ stripHtml(item.summary) }}</p>
<div v-if="item.clientName" class="mt-3 text-xs text-gray-500">
客户{{ item.clientName }}
</div>
</div>
</NuxtLink>
</article>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="flex justify-center mt-12">
<nav class="flex items-center gap-2">
<button
v-for="p in totalPages"
:key="p"
class="px-4 py-2 text-sm rounded-lg transition-colors"
:class="p === currentPage ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-blue-50'"
@click="currentPage = p"
>
{{ p }}
</button>
</nav>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { CaseItem, PageResult } from '~/types'
import { collectDescendantNavIds } from '~/utils/nav-tree'
/**
* 模板 1 - 案例列表页
*/
const { fileUrl } = useFileUrl()
const route = useRoute()
const { allNavigations, fetchSiteInfo } = useSite()
// 确保导航数据已加载(用于栏目标题与分类判定)
await fetchSiteInfo()
// 列表(栏目)入口 /case/{navigationId} 时按分类过滤;/case 根目录展示全部案例
const navigationId = computed<number | undefined>(() => {
const id = route.params.id
return id ? Number(id) : undefined
})
// 聚合:父栏目访问时递归收集自身 + 所有后代栏目 navigationId,交给后端 IN 查询
const categoryIds = computed<string | undefined>(() => {
const ids = collectDescendantNavIds(navigationId.value, allNavigations.value || [])
return ids.length ? ids.join(',') : undefined
})
const currentPage = ref(1)
const limit = 12
const { data, pending, error } = await useFetch<PageResult<CaseItem>>('/api/case/list', {
key: `case-list-${categoryIds.value ?? navigationId.value ?? 'all'}-p${currentPage.value}`,
query: {
page: currentPage,
limit,
...(categoryIds.value
? { categoryIds: categoryIds.value }
: (navigationId.value ? { navigationId: navigationId.value } : {}))
},
watch: [currentPage, categoryIds, navigationId]
})
const cases = computed(() => data.value?.list || [])
const totalCount = computed(() => data.value?.count ?? 0)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / limit)))
// 切换栏目时回到第 1 页
watch([categoryIds, navigationId], () => {
currentPage.value = 1
})
// 栏目标题:命中导航取导航标题,否则用模块默认名
const pageTitle = computed(() => {
const id = navigationId.value
if (id == null) return '案例展示'
const navs = (allNavigations.value || []) as any[]
const find = (items: any[]): any => {
for (const it of items) {
if (it.navigationId === id) return it
if (it.children?.length) {
const f = find(it.children)
if (f) return f
}
}
return undefined
}
return find(navs)?.title || '案例展示'
})
</script>
@@ -0,0 +1,91 @@
<template>
<div class="min-h-screen py-16 bg-gray-50">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="max-w-6xl mx-auto">
<div class="text-center mb-12">
<h1 class="text-3xl font-bold text-gray-900 mb-4">联系我们</h1>
<p class="text-gray-600 max-w-2xl mx-auto">
有任何问题或合作意向欢迎随时与我们联系
</p>
</div>
<div class="grid lg:grid-cols-2 gap-10">
<!-- 联系信息 -->
<div class="bg-white rounded-xl p-8 shadow-sm">
<h2 class="text-xl font-bold text-gray-900 mb-6">联系方式</h2>
<div class="space-y-6">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
</div>
<div>
<h3 class="font-medium text-gray-900">电话咨询</h3>
<p class="text-gray-600 mt-1">{{ siteInfo?.phone || '400-000-0000' }}</p>
</div>
</div>
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<h3 class="font-medium text-gray-900">电子邮箱</h3>
<p class="text-gray-600 mt-1">{{ siteInfo?.email || 'contact@example.com' }}</p>
</div>
</div>
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<h3 class="font-medium text-gray-900">公司地址</h3>
<p class="text-gray-600 mt-1">{{ siteInfo?.address || '请填写公司地址' }}</p>
</div>
</div>
</div>
</div>
<!-- 在线表单 -->
<div class="bg-white rounded-xl p-8 shadow-sm">
<h2 class="text-xl font-bold text-gray-900 mb-6">在线留言</h2>
<ContactForm
input-class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500"
submit-class="w-full px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
label-class="block text-sm font-medium text-gray-700 mb-1"
:accent="config.themeConfig.primaryColor"
/>
</div>
</div>
</div>
</div>
<PageAttachments
v-if="contactAttachments.length"
:attachments="contactAttachments"
class="mt-10"
/>
</div>
</template>
<script setup lang="ts">
import type { PageDetail, PageAttachment } from '~/types'
import config from '../config'
/**
* 模板 1 - 联系我们页
*/
const { siteInfo, fetchSiteInfo } = useSite()
await fetchSiteInfo()
// 联系页附件(CMS「单页管理」按 path=contact 维护)
const { data: contactPage } = await useFetch<PageDetail>('/api/page/detail', { query: { path: 'contact' } })
const contactAttachments = computed<PageAttachment[]>(() => contactPage.value?.attachments || [])
</script>
+124
View File
@@ -0,0 +1,124 @@
<template>
<div>
<!-- 首屏 hero/banner 开关控制 -->
<HeroSection :show-banner="homeBlocks.banner" :show-hero-features="homeBlocks.hero" />
<!-- 我们的优势FeatureSection 内部已根据 advantages.enabled 自显隐 -->
<FeatureSection />
<!-- 关于我们 -->
<AboutSection
title-color="text-gray-900"
accent-color="#3b82f6"
summary-color="text-gray-600"
link-color="text-blue-600"
container-class="container mx-auto px-4 sm:px-6 lg:px-8 grid lg:grid-cols-2 gap-12 items-center"
image-bg-color="bg-gray-100"
image-object-fit="object-cover"
/>
<!-- 推荐/置顶产品全站跨栏目精选置顶产品 top>0受后台产品展示区块开关控制 -->
<RecommendProductsSection
v-if="showProducts"
title="产品展示"
subtitle="了解我们的核心产品与解决方案"
accent-color="#3b82f6"
title-color="#111827"
subtitle-color="#4b5563"
:limit="3"
:more-link="productNav?.path || '/products'"
more-text="查看更多"
/>
<!-- 案例展示上游无推荐字段默认最新案例受后台案例展示区块开关控制 -->
<RecommendCasesSection
v-if="showCases"
title="案例展示"
subtitle="真实案例呈现,见证客户成功"
accent-color="#3b82f6"
title-color="#111827"
subtitle-color="#4b5563"
:limit="3"
:more-link="caseNav?.path || '/case'"
more-text="查看更多案例"
/>
<!-- 推荐资讯全站跨栏目精选推荐文章 recommend=1受后台最新动态区块开关控制 -->
<RecommendArticlesSection
v-if="showNews"
title="最新动态"
subtitle="了解企业最新资讯与行业动态"
accent-color="#3b82f6"
title-color="#111827"
subtitle-color="#4b5563"
:limit="3"
:more-link="newsNav?.path || '/news'"
more-text="查看更多"
/>
<!-- 联系我们 -->
<section v-if="showCta" class="py-16 lg:py-20 bg-blue-50">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-2xl sm:text-3xl font-bold text-gray-900 mb-4" data-reveal>
准备好开始了吗
</h2>
<p class="text-gray-600 max-w-2xl mx-auto mb-8" data-reveal data-reveal-delay="100">
立即联系我们获取专属企业官网解决方案
</p>
<button
type="button"
class="inline-flex items-center justify-center px-8 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors anim-shimmer"
@click="openConsult()"
>
立即咨询
</button>
</div>
</section>
</div>
</template>
<script setup lang="ts">
/**
* 模板 1 - 首页
* 产品 / 案例 / 资讯三个区块改为读取「推荐 / 置顶」精选内容(全站跨栏目),
* 由共享组件 RecommendProductsSection / RecommendCasesSection / RecommendArticlesSection 承载,
* 逻辑见 app/composables/useRecommend.ts。
* 各区块显示仍受后台「首页区块总览」开关(siteInfo.setting.features)控制。
*/
import HeroSection from '../components/HeroSection.vue'
import FeatureSection from '../components/FeatureSection.vue'
import { useFeatures } from '~/composables/useFeatures'
import type { CmsNavigation } from '~/types'
const { siteInfo, navigations, fetchSiteInfo } = useSite()
const { openConsult } = useConsult()
const { homeBlocks } = useFeatures([
{ title: '企业优势', desc: '快速建立品牌信任感,展示企业实力与核心竞争力。', icon: 'building' },
{ title: '核心产品', desc: '清晰的产品展示与分类,帮助客户快速了解产品价值。', icon: 'box' },
{ title: '客户案例', desc: '真实案例呈现,增强说服力,促进客户决策。', icon: 'case' },
{ title: '在线留言', desc: '便捷的留言咨询通道,不错过任何潜在客户。', icon: 'message' }
])
// 获取站点信息(含导航)
await fetchSiteInfo()
/** 各区块显隐开关(后台首页区块总览) */
const showProducts = computed(() => homeBlocks.value.products)
const showCases = computed(() => homeBlocks.value.cases)
const showNews = computed(() => homeBlocks.value.news)
const showCta = computed(() => homeBlocks.value.cta)
/** 从导航中定位各模块,用于「查看更多」跳转 */
const newsNav = computed<CmsNavigation | undefined>(() => {
const navs = navigations.value || []
return navs.find((n) => n.model === 'article')
})
const productNav = computed<CmsNavigation | undefined>(() => {
const navs = navigations.value || []
return navs.find((n) => n.model === 'product')
})
const caseNav = computed<CmsNavigation | undefined>(() => {
const navs = navigations.value || []
return navs.find((n) => n.model === 'case')
})
</script>
@@ -0,0 +1,55 @@
<template>
<div class="min-h-screen py-16 bg-white">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div v-if="article" class="max-w-4xl mx-auto">
<div class="mb-8">
<NuxtLink :to="`/article${article?.navigationId ? '?navId=' + article.navigationId : ''}`" class="text-sm text-gray-500 hover:text-blue-600"> 返回新闻列表</NuxtLink>
</div>
<h1 class="text-3xl sm:text-4xl font-bold text-gray-900 mb-4">
{{ article.title }}
</h1>
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-8 pb-8 border-b border-gray-100">
<span v-if="article.categoryName" class="px-3 py-1 bg-blue-50 text-blue-600 rounded-full">
{{ article.categoryName }}
</span>
<span>发布时间{{ formatDate(article.publishTime || article.createTime) }}</span>
<span v-if="article.author">作者{{ article.author }}</span>
</div>
<RichText :content="article.content" />
<PageAttachments
v-if="article?.files?.length"
:attachments="article.files"
/>
</div>
<div v-else class="text-center py-20">
<h1 class="text-2xl font-bold text-gray-900 mb-4">文章不存在或已下架</h1>
<NuxtLink :to="`/article${article?.navigationId ? '?navId=' + article.navigationId : ''}`" class="text-blue-600 hover:underline">返回新闻列表</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import type { Article } from '~/types'
/**
* 模板 1 - 新闻详情页
*/
const route = useRoute()
const id = route.params.id as string
const { fileUrl } = useFileUrl()
const { data: article } = await useFetch<Article | null>(`/api/article/detail?id=${id}`, {
key: `article-${id}`
})
function formatDate(date?: string) {
if (!date) return ''
return dayjs(date).format('YYYY-MM-DD')
}
</script>
@@ -0,0 +1,191 @@
<template>
<div class="min-h-screen py-16 bg-gray-50">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h1 class="text-3xl font-bold text-gray-900 mb-4">{{ pageTitle }}</h1>
<p class="text-gray-600 max-w-2xl mx-auto">了解企业最新动态与行业资讯</p>
</div>
<div v-if="pending" class="flex justify-center py-12">
<SiteLoading />
</div>
<div v-else-if="error" class="text-center py-12">
<SiteError message="获取新闻列表失败" />
</div>
<template v-else>
<div v-if="articles.length > 0" class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<article
v-for="item in articles"
:key="item.id || item.articleId"
class="bg-white rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow"
>
<NuxtLink :to="`/article/${item.id || item.articleId}`">
<div class="aspect-video bg-gray-100 flex items-center justify-center">
<img
v-if="item.image || item.cover || item.photo"
:src="fileUrl(item.image || item.cover || item.photo || '')"
:alt="item.title"
class="w-full h-full object-cover"
>
<span v-else class="text-gray-400">暂无图片</span>
</div>
</NuxtLink>
<div class="p-5">
<div class="text-xs text-gray-500 mb-2">
{{ formatDate(item.publishTime || item.createTime) }}
<span v-if="item.categoryName" class="ml-2 text-blue-600">{{ item.categoryName }}</span>
</div>
<h2 class="text-lg font-semibold text-gray-900 mb-2 line-clamp-2 hover:text-blue-600 transition-colors">
<NuxtLink :to="`/article/${item.id || item.articleId}`">{{ item.title }}</NuxtLink>
</h2>
<p class="text-sm text-gray-600 line-clamp-2">{{ stripHtml(item.summary) }}</p>
</div>
</article>
</div>
<!-- 空状态 -->
<div v-else class="text-center py-20">
<svg class="w-16 h-16 mx-auto text-gray-300 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
</svg>
<p class="text-gray-500">暂无新闻内容</p>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="flex justify-center mt-12">
<nav class="flex items-center gap-2">
<button
v-for="p in totalPages"
:key="p"
class="px-4 py-2 text-sm rounded-lg transition-colors"
:class="p === currentPage
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-blue-50'"
@click="currentPage = p"
>
{{ p }}
</button>
</nav>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 模板 1 - 新闻列表页
* 从路由参数获取 navigationId,调用 /api/article/list 获取文章列表
* 支持两种入口:
* 1. /article/:navigationId → CMS 导航路径
* 2. /news → 传统路径(自动从 topNavs 中查找 model=article 的栏目)
*/
import dayjs from 'dayjs'
import type { Article, PageResult, ApiEnvelope, CmsNavigation } from '~/types'
import { collectDescendantNavIds } from '~/utils/nav-tree'
const { allNavigations, fetchSiteInfo } = useSite()
const { fileUrl } = useFileUrl()
const route = useRoute()
await fetchSiteInfo()
/** 从路由参数、查询参数或导航中获取栏目 navigationId
*
* 优先级(2026-07-21 修正):
* 1. route.query.navId(查询参数 ?navId=xxx,优先——导航子分类下拉切换时通过此方式传入,
* 必须高于 route.params.id,否则 /article/4277?navId=4278 会因 params.id=4277 先命中而忽略 navId
* 2. route.params.id(路由参数 /article/:navigationId
* 3. navigations 中 model=article 的第一个栏目(兜底)
*/
const navigationId = computed<number | undefined>(() => {
// 1. 查询参数优先(导航子分类下拉切换时传入)
const queryNavId = route.query.navId as string
if (queryNavId) {
const num = Number(queryNavId)
if (!Number.isNaN(num)) return num
}
// 2. 路由参数
const routeId = route.params.id as string
if (routeId) {
const num = Number(routeId)
if (!Number.isNaN(num)) return num
}
// 3. 兜底:从 navigations 中查找 model=article 的导航
const navs = allNavigations.value || []
const nav = navs.find((n) => n.model === 'article' || (n.path || '').split('/').filter(Boolean)[0] === 'article')
return nav?.navigationId
})
/** 聚合:父栏目访问时递归收集自身 + 所有后代栏目 navigationId,交给后端 IN 查询 */
const categoryIds = computed<string | undefined>(() => {
const ids = collectDescendantNavIds(navigationId.value, allNavigations.value || [])
return ids.length ? ids.join(',') : undefined
})
/** 页面标题 */
const pageTitle = computed(() => {
const navs = allNavigations.value || []
const findNav = (items: CmsNavigation[]): CmsNavigation | undefined => {
for (const item of items) {
if (item.navigationId === navigationId.value) return item
if (item.children?.length) {
const found = findNav(item.children)
if (found) return found
}
}
return undefined
}
if (keywords.value) return `搜索:"${keywords.value}"`
return findNav(navs)?.title || '新闻资讯'
})
/** 搜索关键词(来自 Header 搜索框提交的 ?keywords= */
const keywords = computed(() => ((route.query.keywords as string) || '').trim())
const currentPage = ref(1)
const limit = 12
// 响应式 query + watch 选项触发翻页/换栏目的重新请求;
// key 带页码,保证每一页独立缓存、翻页必然重新拉取(避免共用 key 被缓存返回第 1 页)。
// keywords 变化时同样触发重新请求,关键词搜索忽略栏目、全局检索。
const { data, pending, error } = await useFetch<
ApiEnvelope<PageResult<Article>> | PageResult<Article>
>('/api/article/list', {
key: `news-list-${keywords.value || 'all'}-${categoryIds.value ?? navigationId.value ?? 'default'}-p${currentPage.value}`,
query: {
categoryIds: keywords.value ? undefined : categoryIds.value,
keywords: keywords.value || undefined,
page: currentPage,
limit
},
watch: [currentPage, navigationId, categoryIds, keywords]
})
const articles = computed<Article[]>(() => {
const envelope = data.value as ApiEnvelope<PageResult<Article>>
const direct = data.value as PageResult<Article>
return envelope?.data?.list || direct?.list || []
})
const totalCount = computed(() => {
const envelope = data.value as ApiEnvelope<PageResult<Article>>
const direct = data.value as PageResult<Article>
return envelope?.data?.count || envelope?.data?.total || direct?.count || direct?.total || 0
})
const totalPages = computed(() => {
return Math.ceil(totalCount.value / limit)
})
// 栏目切换 / 关键词变化时回到第 1 页(watch 选项已负责重新请求)
watch([navigationId, keywords], () => {
currentPage.value = 1
})
function formatDate(date?: string) {
if (!date) return ''
return dayjs(date).format('YYYY-MM-DD')
}
</script>
+163
View File
@@ -0,0 +1,163 @@
<template>
<div class="min-h-[60vh] flex items-center py-20 bg-white">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<!-- 顶部栏目标题 + 自身内容 -->
<div v-if="pageTitle" class="max-w-4xl mx-auto">
<h1 class="text-3xl sm:text-4xl font-bold text-gray-900 mb-6 text-center">
{{ pageTitle }}
</h1>
<div v-if="pageData && pageData.hasContent">
<div v-if="pageData.updateTime" class="text-gray-500 text-sm text-center mb-10">
更新时间{{ formatDate(pageData.updateTime) }}
</div>
<RichText :content="pageData.content" />
</div>
<PageAttachments
v-if="pageData?.attachments?.length"
:attachments="pageData.attachments"
/>
</div>
<!-- 子栏目聚合列表 -->
<div v-if="childCards.length" class="max-w-5xl mx-auto mt-16">
<h2 class="text-2xl font-bold text-gray-900 mb-8 text-center">子栏目</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<NuxtLink
v-for="p in childCards"
:key="p.navigationId"
:to="`/page/${p.navigationId}`"
class="block p-6 rounded-xl border border-gray-200 hover:shadow-lg hover:border-blue-400 transition"
>
<h3 class="text-lg font-semibold text-gray-900 mb-2">{{ p.title }}</h3>
<p class="text-sm text-gray-500 leading-relaxed line-clamp-3">{{ excerpt(p.content) }}</p>
</NuxtLink>
</div>
</div>
<!-- 空状态 -->
<div v-if="!pageData?.hasContent && !childCards.length" class="text-center py-20">
<h1 class="text-2xl font-bold text-gray-900 mb-4">{{ pageTitle }}</h1>
<p class="text-gray-500 mb-2">{{ emptyState.title }}</p>
<p class="text-gray-400 text-sm mb-6 leading-relaxed">
{{ emptyState.desc }}<br />
当前导航{{ currentNav?.title || pageTitle }}ID{{ navigationId }}
</p>
<NuxtLink to="/" class="text-blue-600 hover:underline">返回首页</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 模板 1 - 通用 CMS 页面
* 支持两种入口:
* 1. /page/:navigationId → CMS 导航路径(优先)
* 2. /:slug → 传统路径
*
* 父栏目聚合:当当前 page 栏目拥有子栏目时,递归收集其下所有子栏目的单页,
* 以卡片列表形式聚合展示(每个子页卡片链接到 /page/{childNavId} 详情页)。
*/
import dayjs from 'dayjs'
import type { CmsNavigation, PageDetail } from '~/types'
import { collectDescendantNavIds, isParentNavigation } from '~/utils/nav-tree'
interface ChildPage {
pageId?: number
title?: string
path?: string
content?: string
navigationId?: number
image?: string | null
}
const route = useRoute()
const { allNavigations, fetchSiteInfo } = useSite()
await fetchSiteInfo()
/** 从路由获取 navigationId */
const navigationId = computed(() => {
const id = route.params.id as string
if (id) {
const num = Number(id)
if (!Number.isNaN(num)) return num
}
return undefined
})
/** 当前栏目是否为父栏目(拥有子栏目) */
const hasChildren = computed(() => isParentNavigation(navigationId.value, allNavigations.value || []))
/** 递归收集当前栏目自身 + 所有后代 navigationId */
const descendantNavIds = computed(() =>
collectDescendantNavIds(navigationId.value, allNavigations.value || [])
)
/** 从导航中查找当前页面信息(使用 allNavigations,已做标题映射) */
const currentNav = computed<CmsNavigation | undefined>(() => {
if (!navigationId.value) return undefined
const findNav = (items: CmsNavigation[]): CmsNavigation | undefined => {
for (const item of items) {
if (item.navigationId === navigationId.value) return item
if (item.children?.length) {
const found = findNav(item.children)
if (found) return found
}
}
return undefined
}
return findNav(allNavigations.value)
})
/** 获取页面内容 */
const navId = navigationId.value
const pagePath = (() => {
const num = Number(route.params.id)
return Number.isNaN(num) ? (route.params.id as string) : undefined
})()
const { data: pageData } = await useFetch<PageDetail>('/api/page/detail', {
key: `page-${navId ?? pagePath ?? 'default'}`,
query: { navigationId: navId, path: pagePath }
})
/** 父栏目聚合:取其下所有子栏目的单页(不含父栏目自身,避免与顶部内容重复) */
const childPages = ref<ChildPage[]>([])
if (hasChildren.value && descendantNavIds.value.length) {
const { data: childrenData } = await useFetch<{ list: ChildPage[]; count: number }>('/api/page/children', {
key: `page-children-${navId}`,
query: { navigationIds: descendantNavIds.value.join(',') }
})
childPages.value = childrenData.value?.list || []
}
const childCards = computed(() => childPages.value.filter(p => p.navigationId !== navId))
const pageTitle = computed(() => {
return pageData.value?.title || currentNav.value?.title || '页面'
})
/** 空内容兜底文案,按状态区分「未录入」与「接口异常」 */
const emptyState = computed(() => {
const status = pageData.value?.status
if (status === 'error') {
return { title: '内容暂时无法加载', desc: '内容接口暂时不可用,请稍后再试。' }
}
return { title: '内容尚未录入', desc: '该页面正文尚未在 CMS 管理后台配置,请前往 CMS 后台为该导航补充内容。' }
})
/** 富文本正文截取纯文本摘要 */
function excerpt(html?: string, len = 80) {
if (!html) return ''
const text = (html || '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim()
return text.length > len ? text.slice(0, len) + '…' : text
}
function formatDate(date?: string) {
if (!date) return ''
return dayjs(date).format('YYYY-MM-DD')
}
</script>
@@ -0,0 +1,64 @@
<template>
<div class="min-h-screen py-16 bg-white">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div v-if="product" class="max-w-5xl mx-auto">
<div class="mb-8">
<NuxtLink :to="`/product${product?.navigationId ? '?navId=' + product.navigationId : ''}`" class="text-sm text-gray-500 hover:text-blue-600"> 返回产品列表</NuxtLink>
</div>
<div class="grid lg:grid-cols-2 gap-10 mb-12">
<div class="aspect-video bg-gray-100 rounded-xl overflow-hidden">
<img
v-if="product.cover"
:src="fileUrl(product.cover)"
:alt="product.productName"
class="w-full h-full object-cover"
>
</div>
<div>
<h1 class="text-3xl font-bold text-gray-900 mb-4">{{ product.productName }}</h1>
<p v-if="product.subtitle" class="text-lg text-gray-600 mb-6">{{ product.subtitle }}</p>
<p class="text-gray-600 leading-relaxed mb-6">{{ stripHtml(product.description) }}</p>
<div v-if="product.price" class="text-2xl font-bold text-blue-600 mb-6">
{{ product.price }}
</div>
<button
type="button"
class="inline-flex items-center justify-center px-8 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors"
@click="openConsult({ need: `咨询产品:${product.productName}`, source: 'product-detail' })"
>
立即咨询
</button>
</div>
</div>
<div class="border-t border-gray-100 pt-10">
<h2 class="text-2xl font-bold text-gray-900 mb-6">产品详情</h2>
<RichText :content="product.content" />
</div>
</div>
<div v-else class="text-center py-20">
<h1 class="text-2xl font-bold text-gray-900 mb-4">产品不存在或已下架</h1>
<NuxtLink :to="`/product${product?.navigationId ? '?navId=' + product.navigationId : ''}`" class="text-blue-600 hover:underline">返回产品列表</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Product } from '~/types'
/**
* 模板 1 - 产品详情页
*/
const route = useRoute()
const id = route.params.id as string
const { fileUrl } = useFileUrl()
const { openConsult } = useConsult()
const { data: product } = await useFetch<Product | null>(`/api/product/detail?id=${id}`, {
key: `product-${id}`
})
</script>
@@ -0,0 +1,139 @@
<template>
<div class="min-h-screen py-16 bg-gray-50">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h1 class="text-3xl font-bold text-gray-900 mb-4">产品中心</h1>
<p class="text-gray-600 max-w-2xl mx-auto">为企业提供全方位的数字化解决方案</p>
</div>
<div v-if="pending" class="flex justify-center py-12">
<SiteLoading />
</div>
<SiteError v-else-if="error" message="获取产品列表失败" />
<div v-else class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
<article
v-for="item in products"
:key="item.id ?? item.productId"
class="bg-white rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow"
>
<div class="aspect-video bg-gray-100 flex items-center justify-center">
<img
v-if="item.cover"
:src="fileUrl(item.cover)"
:alt="item.productName"
class="w-full h-full object-cover"
>
<span v-else class="text-gray-400">暂无图片</span>
</div>
<div class="p-5">
<h2 class="text-lg font-semibold text-gray-900 mb-2 hover:text-blue-600 transition-colors">
<NuxtLink :to="`/product/${item.id ?? item.productId}`">{{ item.productName }}</NuxtLink>
</h2>
<p class="text-sm text-gray-600 line-clamp-2 mb-4">{{ stripHtml(item.description || item.subtitle) }}</p>
<div class="flex items-center justify-between">
<span v-if="item.price" class="text-lg font-bold text-blue-600">{{ item.price }}</span>
<NuxtLink
:to="`/product/${item.id ?? item.productId}`"
class="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
了解详情
</NuxtLink>
</div>
</div>
</article>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="flex justify-center mt-12">
<nav class="flex items-center gap-2">
<button
v-for="p in totalPages"
:key="p"
class="px-4 py-2 text-sm rounded-lg transition-colors"
:class="p === currentPage ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-blue-50'"
@click="currentPage = p"
>
{{ p }}
</button>
</nav>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Product, PageResult, ApiEnvelope } from '~/types'
import { collectDescendantNavIds } from '~/utils/nav-tree'
/**
* 产品列表页
*/
const { fileUrl } = useFileUrl()
const { allNavigations, fetchSiteInfo } = useSite()
const route = useRoute()
await fetchSiteInfo()
/**
* 栏目 navigationId:从查询参数或路由参数读取。
* 优先级(2026-07-21 修正):route.query.navId > route.params.id
* (子分类下拉切换通过 ?navId= 传入,必须优先于路径参数,否则切换不生效)
*/
const navigationId = computed<number | undefined>(() => {
// 1. 查询参数优先(导航子分类下拉切换时传入)
const qid = route.query.navId as string
if (qid) {
const n = Number(qid)
if (!Number.isNaN(n)) return n
}
// 2. 路由参数(/product/:navigationId
const pid = route.params.id as string
if (pid) {
const n = Number(pid)
if (!Number.isNaN(n)) return n
}
return undefined
})
// 聚合:父栏目访问时递归收集自身 + 所有后代栏目 navigationId,交给后端 IN 查询
const categoryIds = computed<string | undefined>(() => {
const ids = collectDescendantNavIds(navigationId.value, allNavigations.value || [])
return ids.length ? ids.join(',') : undefined
})
const currentPage = ref(1)
const limit = 12
const { data, pending, error } = await useFetch<
ApiEnvelope<PageResult<Product>> | PageResult<Product>
>('/api/product/list', {
key: `product-list-${categoryIds.value ?? navigationId.value ?? 'all'}-p${currentPage.value}`,
query: {
page: currentPage,
limit,
...(categoryIds.value
? { categoryIds: categoryIds.value }
: (navigationId.value ? { navigationId: navigationId.value } : {}))
},
watch: [currentPage, categoryIds, navigationId]
})
const products = computed<Product[]>(() => {
const envelope = data.value as ApiEnvelope<PageResult<Product>>
const direct = data.value as PageResult<Product>
return envelope?.data?.list || direct?.list || []
})
const totalCount = computed(() => {
const envelope = data.value as ApiEnvelope<PageResult<Product>>
const direct = data.value as PageResult<Product>
return envelope?.data?.count || envelope?.data?.total || direct?.count || direct?.total || 0
})
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / limit)))
// 切换栏目时回到第 1 页
watch([categoryIds, navigationId], () => {
currentPage.value = 1
})
</script>
@@ -0,0 +1,80 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#1a6dff] to-[#0d4fb8] text-white">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="max-w-2xl mx-auto text-center">
<div class="w-20 h-20 mx-auto mb-8 rounded-full bg-white/20 flex items-center justify-center">
<svg class="w-10 h-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h1 class="text-3xl sm:text-4xl font-bold mb-4">
网站服务已到期
</h1>
<p class="text-lg text-blue-100 mb-8">
您访问的企业官网服务已过期请联系管理员续费以恢复正常访问
</p>
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 sm:p-8 mb-8 text-left">
<h2 class="text-xl font-semibold mb-4">续费后可继续使用</h2>
<ul class="space-y-3 text-blue-100">
<li class="flex items-center gap-2">
<svg class="w-5 h-5 flex-shrink-0" 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>
企业官网正常展示
</li>
<li class="flex items-center gap-2">
<svg class="w-5 h-5 flex-shrink-0" 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>
产品/案例/新闻内容展示
</li>
<li class="flex items-center gap-2">
<svg class="w-5 h-5 flex-shrink-0" 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>
在线留言与客户咨询
</li>
<li class="flex items-center gap-2">
<svg class="w-5 h-5 flex-shrink-0" 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>
独立域名访问
</li>
</ul>
</div>
<button
class="inline-flex items-center justify-center px-8 py-3 bg-white text-blue-600 font-semibold rounded-lg hover:bg-blue-50 transition-colors"
@click="goToRenew"
>
立即续费
</button>
<p v-if="siteInfo?.phone" class="mt-6 text-sm text-blue-100">
如需帮助请拨打{{ siteInfo.phone }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 模板 1 - 续费引导页
*/
const { siteInfo, fetchSiteInfo } = useSite()
await fetchSiteInfo()
definePageMeta({
layout: 'blank'
})
function goToRenew() {
// 实际项目中应跳转 SaaS 管理后台续费页面
const appId = useRuntimeConfig().public.appId
window.open(`/api/subscription/renew?appId=${appId}`, '_blank')
}
</script>
+58
View File
@@ -0,0 +1,58 @@
<template>
<Component :is="component" v-if="component" />
<SiteLoading v-else />
</template>
<script setup lang="ts">
/**
* 模板 1 - 通用模板页面选择器
* 根据路由动态选择对应页面组件
*/
const route = useRoute()
const component = shallowRef<Component | null>(null)
const routeMap: Record<string, () => Promise<{ default: Component }>> = {
news: () => import('./NewsList.vue'),
'news-id': () => import('./NewsDetail.vue'),
products: () => import('./ProductList.vue'),
'products-id': () => import('./ProductDetail.vue'),
cases: () => import('./CaseList.vue'),
'cases-id': () => import('./CaseDetail.vue'),
contact: () => import('./Contact.vue'),
renewal: () => import('./Renewal.vue')
}
async function resolveComponent() {
const name = route.name as string | undefined
if (name === 'index') {
const mod = await import('./Home.vue')
component.value = mod.default
return
}
if (name === 'slug') {
const mod = await import('./Page.vue')
component.value = mod.default
return
}
if (name) {
const loader = routeMap[name]
if (loader) {
const mod = await loader()
component.value = mod.default
return
}
}
component.value = null
}
if (import.meta.server) {
await resolveComponent()
}
onMounted(resolveComponent)
</script>
+11
View File
@@ -0,0 +1,11 @@
/* 模板 1 主题变量 */
[data-template-id="template-01"] {
--t1-primary: #1a6dff;
--t1-primary-dark: #0d4fb8;
--t1-primary-light: #e8f0ff;
--t1-text: #1f2937;
--t1-text-secondary: #6b7280;
--t1-bg: #ffffff;
--t1-bg-gray: #f9fafb;
--t1-footer-bg: #111827;
}