2b69686795
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引 - 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理 - 实现/article、/case、/product及/page动态路由兼容列表与详情展示 - 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置 - 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持 - 模板增强支持CMS单页内容加载及SEO信息动态设置 - 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
307 lines
13 KiB
Vue
307 lines
13 KiB
Vue
<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>
|