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,100 @@
<template>
<footer class="bg-[var(--t9-footer-bg)] text-[var(--t9-footer-text)]">
<div class="t9-container py-10">
<!-- 上排四列信息 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 md:gap-8">
<div>
<div class="text-sm text-white/85 mb-2">版权合作</div>
<p class="text-sm leading-relaxed break-all">{{ copyrightContact }}</p>
</div>
<div>
<div class="text-sm text-white/85 mb-2">联系电话</div>
<p class="text-sm leading-relaxed">{{ phone || '—' }}</p>
</div>
<div>
<div class="text-sm text-white/85 mb-2">电子邮箱</div>
<p class="text-sm leading-relaxed break-all">{{ email || '—' }}</p>
</div>
<div>
<div class="text-sm text-white/85 mb-2">客服微信</div>
<div v-if="wxQrcode" class="mt-1">
<img :src="wxQrcode" alt="微信二维码" class="w-24 h-24 object-contain bg-white p-1">
<p class="text-xs mt-1">{{ siteConfig?.wxQrcodeText || '扫码关注' }}</p>
</div>
<p v-else class="text-sm leading-relaxed">{{ address || '—' }}</p>
</div>
</div>
<!-- 页脚导航链接后台 top!==1 的底部菜单 -->
<nav v-if="quickLinks.length" class="mt-8 pt-6 border-t border-white/10 flex flex-wrap gap-x-6 gap-y-2">
<NuxtLink
v-for="link in quickLinks"
:key="link.navigationId || link.path"
:to="getNavLink(link)"
:target="link.target === '_blank' ? '_blank' : undefined"
class="text-sm hover:text-white transition-colors"
>
{{ link.title }}
</NuxtLink>
</nav>
<!-- 下排版权 + 备案 -->
<div
class="mt-8 pt-6 border-t border-white/10 flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-xs text-center"
>
<span>{{ copyright || `Copyright © ${new Date().getFullYear()} ${siteName || ''} 版权所有` }}</span>
<a
v-if="icpNo"
href="https://beian.miit.gov.cn"
target="_blank"
rel="nofollow"
class="hover:text-white transition-colors"
>{{ icpNo }}</a>
<a
v-if="siteInfo?.policeNo"
:href="policeUrl"
target="_blank"
rel="nofollow"
class="hover:text-white transition-colors"
>{{ siteInfo.policeNo }}</a>
<a
rel="nofollow"
href="https://site.websoft.top"
target="_blank"
class="hover:text-white transition-colors"
>Powered by ·企业官网</a>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
/**
* 模板 9 - Footer 组件(墨绿文化出版风,对齐漓江出版社官网)
* 深色底 + 四列信息(版权合作 / 联系电话 / 电子邮箱 / 客服微信)+ 备案信息居中
*/
import type { CmsNavigation } from '~/types'
const {
siteInfo, siteName, siteConfig, bottomNavigations,
phone, email, address, copyright, wxQrcode, icpNo
} = useSite()
/** 版权合作联系方式:优先邮箱,其次电话 */
const copyrightContact = computed(() => email.value || phone.value || '—')
/** 公安备案查询地址(从备案号中提取数字部分) */
const policeUrl = computed(() => {
const no = String(siteInfo.value?.policeNo || '')
const code = no.replace(/\D/g, '')
return code
? `https://beian.mps.gov.cn/#/query/webSearch?code=${code}`
: 'https://beian.mps.gov.cn/'
})
/** 页脚链接:取底部导航(top!==1) */
const quickLinks = computed<CmsNavigation[]>(() => {
const navs = bottomNavigations.value || []
return navs.filter((nav) => nav.model !== 'index').slice(0, 8)
})
</script>
@@ -0,0 +1,141 @@
<template>
<header class="sticky top-0 z-50 bg-white border-b border-[var(--t9-border)]">
<div class="t9-container flex items-center h-[80px]">
<!-- Logo -->
<SiteBrand
link-class="flex-shrink-0 mr-6 lg:mr-10"
:logo="siteLogo"
:icon="siteIcon"
:name="siteName"
logo-class="h-[52px] w-auto max-w-[240px] object-contain"
icon-class="h-8 w-auto object-contain"
name-class="text-xl font-bold text-[var(--t9-primary)] truncate max-w-[220px]"
/>
<!-- 桌面端导航水平排列居左紧贴 Logo -->
<nav class="hidden lg:flex items-center h-full flex-1 min-w-0">
<template v-for="item in navItems" :key="item.navigationId || item.path">
<!-- 有子菜单 -->
<div v-if="item.children && item.children.length > 0" class="relative group h-full flex items-center">
<button
class="h-full px-4 text-base text-[var(--t9-text)] hover:text-[var(--t9-primary)] transition-colors flex items-center gap-1 whitespace-nowrap"
>
{{ item.title }}
<svg class="w-3.5 h-3.5" 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-0 top-full opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all">
<div class="bg-white shadow-lg border border-[var(--t9-border)] py-2 min-w-[164px]">
<NuxtLink
v-for="child in item.children"
:key="child.navigationId"
:to="getChildPath(child)"
class="block px-4 py-2 text-[15px] text-[var(--t9-text)] hover:text-[var(--t9-primary)] hover:bg-[var(--t9-primary-soft)] transition-colors whitespace-nowrap"
>
{{ child.title }}
</NuxtLink>
</div>
</div>
</div>
<!-- 无子菜单 -->
<NuxtLink
v-else
:to="getNavLink(item)"
:target="item.target === '_blank' ? '_blank' : undefined"
class="h-full px-4 flex items-center text-base text-[var(--t9-text)] hover:text-[var(--t9-primary)] transition-colors whitespace-nowrap relative"
active-class="!text-[var(--t9-primary)] after:absolute after:left-4 after:right-4 after:bottom-0 after:h-[2px] after:bg-[var(--t9-primary)]"
>
{{ item.title }}
</NuxtLink>
</template>
</nav>
<!-- 搜索框后台 setting.searchBtn 控制是否显示 -->
<SiteSearchBox
v-if="showHeaderSearch"
variant="light"
accent="var(--t9-primary)"
placeholder="搜索"
class="hidden lg:flex items-center flex-shrink-0 ml-4"
/>
<!-- 移动端菜单按钮 -->
<button
class="lg:hidden ml-auto p-2 text-[var(--t9-text)]"
aria-label="菜单"
@click="mobileMenuOpen = !mobileMenuOpen"
>
<svg v-if="!mobileMenuOpen" 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="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg v-else 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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- 移动端菜单 -->
<div v-if="mobileMenuOpen" class="lg:hidden bg-white border-t border-[var(--t9-border)]">
<div class="t9-container py-4 space-y-1">
<SiteSearchBox
v-if="showHeaderSearch"
variant="light"
accent="var(--t9-primary)"
placeholder="搜索"
class="mb-3"
/>
<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-[var(--t9-primary)]">
{{ 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-[var(--t9-text-secondary)] hover:text-[var(--t9-primary)] hover:bg-[var(--t9-primary-soft)] 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 text-[var(--t9-text)] hover:text-[var(--t9-primary)] hover:bg-[var(--t9-primary-soft)] transition-colors"
active-class="text-[var(--t9-primary)] bg-[var(--t9-primary-soft)]"
@click="mobileMenuOpen = false"
>
{{ item.title }}
</NuxtLink>
</template>
</div>
</div>
</header>
</template>
<script setup lang="ts">
/**
* 模板 9 - Header 组件(墨绿文化出版风,对齐漓江出版社官网)
* 单层结构:白底 80px,左 Logo + 水平菜单 + 右侧圆角搜索框
*/
import type { CmsNavigation } from '~/types'
const { siteName, siteLogo, siteIcon, navigations, showHeaderSearch, fetchSiteInfo } = useSite()
const mobileMenuOpen = ref(false)
/** 子导航链接统一走 app/utils 的 getNavLink */
function getChildPath(child: CmsNavigation): string {
return getNavLink(child)
}
// 获取站点信息(含导航数据)
await fetchSiteInfo()
/** 导航项(从 useSite 的 navigations 获取,已合并 topNavs + bottomNavs 并过滤 top===1 */
const navItems = computed<CmsNavigation[]>(() => {
return navigations.value || []
})
</script>
@@ -0,0 +1,153 @@
<template>
<section class="bg-white pt-4 pb-2">
<div class="t9-container">
<div class="relative overflow-hidden group w-full h-[220px] sm:h-[300px] lg:h-[380px] bg-[var(--t9-primary-soft)]">
<!-- 轮播图模式 -->
<template v-if="bannerMode">
<div
v-for="(b, i) in banners"
:key="b.id || i"
class="absolute inset-0 transition-opacity duration-700 ease-in-out"
:class="i === currentBannerIndex ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
<a v-if="bannerLink(b)" :href="bannerLink(b)" target="_blank" rel="noopener" class="block w-full h-full">
<img :src="bannerImg(b)" :alt="b.title || siteName" class="w-full h-full object-cover">
</a>
<img v-else :src="bannerImg(b)" :alt="b.title || siteName" class="w-full h-full object-cover">
</div>
</template>
<!-- 无图兜底墨绿渐变 + 站点标语 -->
<template v-else>
<div
class="absolute inset-0"
style="background-image: linear-gradient(120deg, #008960 0%, #00a878 100%)"
></div>
<div class="absolute -top-16 -left-10 w-64 h-64 rounded-full bg-white/10 blur-3xl"></div>
<div class="absolute -bottom-20 -right-6 w-72 h-72 rounded-full bg-black/10 blur-3xl"></div>
<div class="relative z-10 h-full flex flex-col items-center justify-center text-center text-white px-6">
<h1 class="text-2xl sm:text-3xl lg:text-4xl font-bold tracking-wide mb-4">{{ heroTitle }}</h1>
<p class="text-sm sm:text-base max-w-2xl text-white/90 t9-clamp-2">{{ heroSubtitle }}</p>
</div>
</template>
<!-- 左右箭头 -->
<button
v-if="bannerMode && banners.length > 1"
type="button" aria-label="上一张"
class="absolute left-0 top-1/2 -translate-y-1/2 w-10 h-16 bg-black/40 hover:bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition z-20"
@click="prev"
>
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
</button>
<button
v-if="bannerMode && banners.length > 1"
type="button" aria-label="下一张"
class="absolute right-0 top-1/2 -translate-y-1/2 w-10 h-16 bg-black/40 hover:bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition z-20"
@click="next"
>
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</button>
<!-- 指示点右下角对齐原站 .dot 样式 -->
<div
v-if="bannerMode && banners.length > 1"
class="absolute bottom-0 left-0 right-0 h-[30px] bg-[#6c7475]/80 flex items-center justify-end pr-4 gap-1.5 z-20"
>
<button
v-for="(b, i) in banners"
:key="'dot-' + (b.id || i)"
type="button"
:aria-label="'切换到第 ' + (i + 1) + ' '"
class="w-2.5 h-2.5 rounded-full border border-white transition"
:class="i === currentBannerIndex ? 'bg-white' : 'bg-white/40 hover:bg-white/70'"
@click="goTo(i)"
/>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
/**
* 模板 9 - Hero 主视觉区(墨绿文化出版风)
*
* 对齐原站:1200px 定宽、380px 高的 Banner,右下角灰条 + 白色小圆点指示器。
* - 有轮播图:图片走马灯 + 左右箭头 + 底部指示条;
* - 无轮播图:墨绿渐变 + 站点标语兜底。
*/
import type { CmsBanner, ApiEnvelope, PageResult } from '~/types'
import { ensureFullUrl } from '~/utils/image'
import { getBannerLink } from '~/utils'
const { siteInfo, siteName, fetchSiteInfo } = useSite()
await fetchSiteInfo()
/** 轮播图数据(SSR 预取,首屏即有) */
const { data: bannerRes } = await useFetch<ApiEnvelope<PageResult<CmsBanner>> | PageResult<CmsBanner>>('/api/banner', {
query: { position: 'home_slider' }
})
const banners = computed<CmsBanner[]>(() => {
const d = bannerRes.value as (ApiEnvelope<PageResult<CmsBanner>> | PageResult<CmsBanner>) | null
if (!d) return []
const list = ((d as ApiEnvelope<PageResult<CmsBanner>>)?.data?.list
?? (d as PageResult<CmsBanner>)?.list
?? []) as CmsBanner[]
return list
.filter((b) => hasImage(b) && b.deleted !== 1 && b.status !== 2)
.sort((a, b) => (a.sortNum ?? 0) - (b.sortNum ?? 0))
})
/** 是否有轮播图 */
const bannerMode = computed(() => banners.value.length > 0)
/** 当前帧索引 */
const currentBannerIndex = ref(0)
let bannerTimer: ReturnType<typeof setInterval> | null = null
function startAuto() {
if (banners.value.length > 1) {
bannerTimer = setInterval(() => {
currentBannerIndex.value = (currentBannerIndex.value + 1) % banners.value.length
}, 5000)
}
}
function stopAuto() {
if (bannerTimer) { clearInterval(bannerTimer); bannerTimer = null }
}
onMounted(() => { startAuto() })
onBeforeUnmount(() => { stopAuto() })
function prev() {
const n = banners.value.length
currentBannerIndex.value = (currentBannerIndex.value - 1 + n) % n
}
function next() {
const n = banners.value.length
currentBannerIndex.value = (currentBannerIndex.value + 1) % n
}
function goTo(i: number) {
currentBannerIndex.value = i
}
/** 取轮播图地址(兼容 image/imageUrl/pic/url,并提升 OSS 压缩宽度) */
function bannerImg(b: CmsBanner): string {
const raw = ensureFullUrl(b.image || b.imageUrl || b.pic || b.url || '')
return raw.replace(/resize,w_\d+/, 'resize,w_1600')
}
/** 取轮播图跳转链接(统一走 utils.getBannerLink,兼容 link/linkUrl/link_url 多字段名) */
function bannerLink(b: CmsBanner): string {
return getBannerLink(b)
}
/** 是否有可用图片 */
function hasImage(b: CmsBanner): boolean {
return !!(b.image || b.imageUrl || b.pic || b.url)
}
const heroTitle = computed(() => siteInfo.value?.slogan || siteName.value || '欢迎来到我们的官网')
const heroSubtitle = computed(() => siteInfo.value?.comments || '专注内容出版与文化传播,用好书连接读者与世界。')
</script>