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,194 @@
<template>
<div class="min-h-screen bg-white pb-16">
<!-- Banner -->
<section
class="t10-page-banner"
style="background-image: url('/images/template-10/product-banner.jpg'); min-height: 300px;"
>
<div class="t10-container t10-banner-inner">
<nav class="t10-breadcrumb mb-3">
<NuxtLink to="/">首页</NuxtLink>
<span class="mx-2">/</span>
<span class="text-white">{{ pageTitle }}</span>
</nav>
<h1 class="text-3xl sm:text-4xl font-bold text-white mb-2">{{ pageTitle }}</h1>
<p class="text-lg text-white/80 tracking-widest uppercase">Product Center</p>
</div>
</section>
<!-- 分类 Tab -->
<div class="border-b border-[var(--t10-border)] bg-white sticky top-[72px] z-30">
<div class="t10-container">
<div class="t10-tabs">
<button
v-for="tab in productTabs"
:key="tab.navigationId"
class="t10-tab"
:class="{ 't10-tab-active': activeTab === tab.navigationId }"
@click="activeTab = tab.navigationId"
>
{{ tab.title }}
</button>
</div>
</div>
</div>
<div class="t10-container py-10">
<div v-if="pending" class="flex justify-center py-12">
<SiteLoading />
</div>
<SiteError v-else-if="error" message="获取产品列表失败" />
<!-- 产品网格 -->
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
<NuxtLink
v-for="item in products"
:key="item.id ?? item.productId"
:to="`/product/${item.id ?? item.productId}`"
class="group bg-white rounded-xl border border-[var(--t10-border)] overflow-hidden hover:shadow-md hover:border-[var(--t10-primary)] transition-all"
>
<div class="aspect-square bg-[var(--t10-bg-gray)] overflow-hidden">
<img
:src="itemImage(item)"
:alt="item.productName"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
@error="onImgError"
>
</div>
<div class="p-4">
<h3 class="text-sm font-semibold text-[var(--t10-text)] t10-ellipsis group-hover:text-[var(--t10-primary)] transition-colors">
{{ item.productName }}
</h3>
<p class="mt-1 text-xs text-[var(--t10-muted-2)] t10-clamp-2">
{{ item.summary || item.description || '' }}
</p>
</div>
</NuxtLink>
</div>
<!-- 空状态 -->
<div v-if="!pending && !error && products.length === 0" class="text-center py-20 text-[var(--t10-muted-2)]">
<svg class="w-16 h-16 mx-auto mb-4" style="color: var(--t10-muted-light)" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p>暂无产品内容</p>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="flex justify-center mt-12">
<nav class="flex items-center gap-1.5 flex-wrap justify-center">
<button
v-for="p in totalPages"
:key="p"
class="min-w-[36px] h-9 px-2 text-sm border rounded transition-colors"
:class="p === currentPage
? 'bg-[var(--t10-primary)] border-[var(--t10-primary)] text-white'
: 'bg-white border-[var(--t10-border)] text-[var(--t10-text)] hover:border-[var(--t10-primary)] hover:text-[var(--t10-primary)]'"
@click="currentPage = p"
>
{{ p }}
</button>
</nav>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 模板 10 - 产品列表页(创芯存储科技蓝风格)
*/
import type { Product, PageResult, ApiEnvelope, CmsNavigation } from '~/types'
import { collectDescendantNavIds } from '~/utils/nav-tree'
import { getCompressedImageUrl } from '~/utils/image'
const { allNavigations, fetchSiteInfo } = useSite()
const route = useRoute()
await fetchSiteInfo()
const navigationId = computed<number | undefined>(() => {
const qid = route.query.navId as string
if (qid) {
const n = Number(qid)
if (!Number.isNaN(n)) return n
}
const pid = route.params.id as string
if (pid) {
const n = Number(pid)
if (!Number.isNaN(n)) return n
}
return undefined
})
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)))
watch([categoryIds, navigationId], () => {
currentPage.value = 1
})
const productNav = computed<CmsNavigation | undefined>(() => {
const navs = allNavigations.value || []
return navs.find((n) => n.navigationId === navigationId.value) || navs.find((n) => n.model === 'product')
})
const productTabs = computed<CmsNavigation[]>(() => {
const parent = productNav.value
if (parent?.children?.length) return parent.children.slice(0, 8)
return parent ? [parent] : []
})
const activeTab = ref<number | undefined>(productTabs.value[0]?.navigationId)
watch(productTabs, () => {
activeTab.value = productTabs.value[0]?.navigationId
}, { immediate: true })
const pageTitle = computed(() => productNav.value?.title || '产品中心')
const FALLBACK_IMAGE = '/images/template-10/product-fallback.jpg'
function itemImage(item: Product): string {
const anyItem = item as Record<string, unknown>
const raw = (anyItem.cover || anyItem.photo || anyItem.image
|| (Array.isArray(anyItem.images) ? (anyItem.images as string[])[0] : '')) as string
return raw ? getCompressedImageUrl(raw, { width: 800 }) : FALLBACK_IMAGE
}
function onImgError(e: Event) {
const el = e.target as HTMLImageElement
if (el && !el.src.endsWith(FALLBACK_IMAGE)) el.src = FALLBACK_IMAGE
}
</script>