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,306 @@
<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: 280px;"
>
<div class="t10-container t10-banner-inner">
<nav class="t10-breadcrumb mb-3">
<NuxtLink to="/">首页</NuxtLink>
<span class="mx-2">/</span>
<NuxtLink :to="productNav?.path || '/product'">{{ productNav?.title || '产品中心' }}</NuxtLink>
<span class="mx-2">/</span>
<span class="text-white">{{ product?.productName || '产品详情' }}</span>
</nav>
<h1 class="text-3xl sm:text-4xl font-bold text-white">{{ productNav?.title || '产品中心' }}</h1>
</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': activeCategory === tab.navigationId }"
@click="goToCategory(tab)"
>
{{ 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="获取产品详情失败" />
<template v-else-if="product">
<!-- 产品主信息 -->
<div class="grid lg:grid-cols-2 gap-10 items-start mb-12">
<div class="bg-[var(--t10-bg-soft)] rounded-xl p-6 lg:p-10 flex items-center justify-center">
<img
:src="productImage"
:alt="product.productName"
class="max-w-full max-h-[420px] object-contain"
@error="onImgError"
>
</div>
<div>
<h1 class="text-2xl lg:text-3xl font-bold text-[var(--t10-text-strong)] mb-4">
{{ product.productName }}
</h1>
<p class="text-[var(--t10-text-secondary)] leading-relaxed mb-6">
{{ product.summary || product.description || '该产品暂无详细描述。' }}
</p>
<div v-if="productAdvantages.length" class="space-y-3 mb-8">
<div class="text-sm font-semibold text-[var(--t10-text-strong)]">产品亮点</div>
<ul class="space-y-2">
<li v-for="(adv, i) in productAdvantages" :key="i" class="flex items-start gap-2 text-sm text-[var(--t10-text-secondary)]">
<span class="w-1.5 h-1.5 rounded-full bg-[var(--t10-primary)] mt-1.5 flex-shrink-0" />
{{ adv }}
</li>
</ul>
</div>
<div class="flex flex-wrap gap-4">
<button type="button" class="t10-btn-primary" @click="openConsult">立即咨询</button>
<NuxtLink to="/contact" class="t10-btn-outline">联系我们</NuxtLink>
</div>
</div>
</div>
<!-- 详情 Tab -->
<div class="border border-[var(--t10-border)] rounded-t-xl overflow-hidden">
<div class="flex flex-wrap border-b border-[var(--t10-border)] bg-[var(--t10-bg-soft)]">
<button
v-for="tab in detailTabs"
:key="tab.key"
class="px-6 py-3 text-sm font-medium transition-colors"
:class="activeTab === tab.key
? 'bg-white text-[var(--t10-primary)] border-t-2 border-t-[var(--t10-primary)]'
: 'text-[var(--t10-text-secondary)] hover:text-[var(--t10-primary)]'"
@click="activeTab = tab.key"
>
{{ tab.title }}
</button>
</div>
<div class="p-6 lg:p-10 bg-white min-h-[300px]">
<!-- 产品参数 -->
<div v-if="activeTab === 'params'">
<h3 class="text-lg font-bold text-[var(--t10-text-strong)] mb-5">产品参数</h3>
<div class="overflow-x-auto">
<table v-if="paramsTable.length" class="w-full text-sm border border-[var(--t10-border)]">
<thead>
<tr class="bg-[var(--t10-bg-soft)]">
<th
v-for="h in paramsTable[0]"
:key="h"
class="px-4 py-3 text-left font-semibold text-[var(--t10-text-strong)] border-b border-[var(--t10-border)]"
>
{{ h }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, idx) in paramsTable.slice(1)"
:key="idx"
class="border-b border-[var(--t10-border)] last:border-0"
>
<td
v-for="(cell, cidx) in row"
:key="cidx"
class="px-4 py-3 text-[var(--t10-text-secondary)]"
>
{{ cell }}
</td>
</tr>
</tbody>
</table>
<div v-else class="text-[var(--t10-muted-2)]">暂无参数数据</div>
</div>
</div>
<!-- 产品特性 -->
<div v-else-if="activeTab === 'features'">
<h3 class="text-lg font-bold text-[var(--t10-text-strong)] mb-5">产品特征</h3>
<div v-if="featureList.length" class="grid sm:grid-cols-2 gap-4">
<div
v-for="(f, i) in featureList"
:key="i"
class="flex gap-4 p-5 bg-[var(--t10-bg-soft)] rounded-lg"
>
<div class="w-10 h-10 rounded-lg bg-[var(--t10-primary)] text-white flex items-center justify-center flex-shrink-0 font-bold">
{{ String(i + 1).padStart(2, '0') }}
</div>
<p class="text-[var(--t10-text-secondary)] text-sm leading-relaxed">{{ f }}</p>
</div>
</div>
<div v-else class="text-[var(--t10-muted-2)]">暂无特征数据</div>
</div>
<!-- 应用领域 -->
<div v-else-if="activeTab === 'applications'">
<h3 class="text-lg font-bold text-[var(--t10-text-strong)] mb-5">应用领域</h3>
<div class="grid grid-cols-3 sm:grid-cols-5 gap-6">
<div
v-for="(app, i) in applicationList"
:key="i"
class="text-center"
>
<div class="w-14 h-14 mx-auto mb-3 rounded-full bg-[var(--t10-primary-soft)] flex items-center justify-center text-[var(--t10-primary)]">
<svg class="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<span class="text-sm text-[var(--t10-text-secondary)]">{{ app }}</span>
</div>
</div>
</div>
<!-- 资料下载 -->
<div v-else-if="activeTab === 'downloads'">
<h3 class="text-lg font-bold text-[var(--t10-text-strong)] mb-5">资料下载</h3>
<div v-if="attachments.length" class="space-y-3">
<a
v-for="(att, i) in attachments"
:key="i"
:href="att.url || att.fileUrl || '#'"
target="_blank"
class="flex items-center gap-3 p-4 border border-[var(--t10-border)] rounded-lg hover:border-[var(--t10-primary)] hover:text-[var(--t10-primary)] transition-colors"
>
<svg class="w-6 h-6 text-[var(--t10-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="text-sm text-[var(--t10-text-secondary)]">{{ att.name || att.title || '下载文件' }}</span>
</a>
</div>
<div v-else class="text-[var(--t10-muted-2)]">暂无下载资料</div>
</div>
</div>
</div>
<!-- 富文本内容 -->
<div v-if="product.content" class="mt-12">
<RichText :content="product.content" />
</div>
</template>
<div v-else class="text-center py-20 text-[var(--t10-muted-2)]">
产品不存在或已下架
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 模板 10 - 产品详情页(创芯存储科技蓝风格)
*/
import type { Product, PageAttachment } from '~/types'
const { allNavigations, fetchSiteInfo } = useSite()
const { openConsult } = useConsult()
const route = useRoute()
const router = useRouter()
await fetchSiteInfo()
const productId = computed(() => route.params.id as string)
const { data: productRes, pending, error } = await useFetch<{ data?: Product }>('/api/product/detail', {
key: `product-detail-${productId.value}`,
query: { id: productId.value }
})
const product = computed<Product | undefined>(() => productRes.value?.data)
const productNav = computed(() => {
const navs = allNavigations.value || []
return navs.find((n) => n.model === 'product')
})
const productTabs = computed(() => {
const parent = productNav.value
if (parent?.children?.length) return parent.children.slice(0, 8)
return parent ? [parent] : []
})
const activeCategory = computed(() => {
const p = product.value as Record<string, unknown> | undefined
return p?.navigationId || p?.categoryId
})
function goToCategory(tab: Record<string, unknown>) {
const path = tab.path as string | undefined
const navId = tab.navigationId as number | undefined
if (path) router.push(path)
else if (navId) router.push(`/product?navId=${navId}`)
}
const activeTab = ref('params')
const detailTabs = [
{ key: 'params', title: '产品参数' },
{ key: 'features', title: '产品特征' },
{ key: 'applications', title: '应用领域' },
{ key: 'downloads', title: '资料下载' }
]
const FALLBACK_IMAGE = '/images/template-10/product-fallback.jpg'
const productImage = computed(() => {
const p = product.value as Record<string, unknown> | undefined
const raw = (p?.cover || p?.photo || p?.image || (Array.isArray(p?.images) ? (p.images as string[])[0] : '')) as string
return raw ? raw : FALLBACK_IMAGE
})
function onImgError(e: Event) {
const el = e.target as HTMLImageElement
if (el && !el.src.endsWith(FALLBACK_IMAGE)) el.src = FALLBACK_IMAGE
}
const productAdvantages = computed(() => {
const p = product.value as Record<string, unknown> | undefined
const raw = p?.advantages || p?.highlights
if (Array.isArray(raw)) return (raw as unknown[]).filter((s): s is string => typeof s === 'string' && !!s)
if (typeof raw === 'string') return raw.split(/\n|;/).filter((s) => s.trim())
return []
})
const paramsTable = computed<string[][]>(() => {
const p = product.value as Record<string, unknown> | undefined
const params = p?.params || p?.parameters
if (Array.isArray(params) && params.length) {
return params.map((row) => Array.isArray(row) ? row.map(String) : Object.values(row).map(String))
}
return [
['产品型号', '容量', '接口', '温度', '电压'],
['示例型号', '2GB', 'x16', '-40~85°C', '1.5V']
]
})
const featureList = computed<string[]>(() => {
const p = product.value as Record<string, unknown> | undefined
const raw = p?.features
if (Array.isArray(raw)) return (raw as unknown[]).filter((s): s is string => typeof s === 'string' && !!s)
if (typeof raw === 'string') return raw.split(/\n/).filter((s) => s.trim())
return ['高速读写,低功耗设计', '宽温宽压,工业级可靠性', '完整的平台兼容与验证支持', '灵活的封装与容量定制']
})
const applicationList = computed<string[]>(() => {
const p = product.value as Record<string, unknown> | undefined
const raw = p?.applications
if (Array.isArray(raw)) return (raw as unknown[]).filter((s): s is string => typeof s === 'string' && !!s)
if (typeof raw === 'string') return raw.split(/\n||,/).filter((s) => s.trim())
return ['数据中心', '无人机', '智能音箱', '智能电视', '机顶盒', 'POS机', 'GPON', '工业IPC', '电视盒子']
})
const attachments = computed<PageAttachment[]>(() => {
const p = product.value as Record<string, unknown> | undefined
return (p?.attachments || []) as PageAttachment[]
})
</script>