Files
hjc-web/app/templates/template-10/pages/ProductDetail.vue
T
gxwebsoft 2b69686795 feat(app): 添加多模板关于我们页面及相关路由和404页面
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引
- 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理
- 实现/article、/case、/product及/page动态路由兼容列表与详情展示
- 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置
- 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持
- 模板增强支持CMS单页内容加载及SEO信息动态设置
- 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
2026-09-08 12:13:44 +08:00

307 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>