feat(app): 添加多模板关于我们页面及相关路由和404页面
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引 - 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理 - 实现/article、/case、/product及/page动态路由兼容列表与详情展示 - 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置 - 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持 - 模板增强支持CMS单页内容加载及SEO信息动态设置 - 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="bg-white">
|
||||
<!-- Banner(CMS 内页横幅:title=about & position=inner_page,兜底静态默认图) -->
|
||||
<section
|
||||
class="t10-page-banner"
|
||||
:style="`background-image: url('${aboutBannerImage}'); min-height: 320px;`"
|
||||
>
|
||||
<div class="t10-container t10-banner-inner py-16 lg:py-20">
|
||||
<nav class="t10-breadcrumb mb-4" aria-label="面包屑">
|
||||
<NuxtLink to="/" class="hover:text-white transition-colors">首页</NuxtLink>
|
||||
<span class="mx-2">/</span>
|
||||
<span class="text-white">{{ pageTitle }}</span>
|
||||
</nav>
|
||||
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold text-white mb-3">{{ pageTitle }}</h1>
|
||||
<p class="text-lg text-white/80 tracking-[0.2em] uppercase">About Us</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 子导航 -->
|
||||
<div class="border-b border-[var(--t10-border)] bg-white">
|
||||
<div class="t10-container">
|
||||
<div class="flex flex-wrap items-center justify-between py-4 gap-4">
|
||||
<nav class="flex flex-wrap gap-6 text-sm lg:pl-[460px]">
|
||||
<NuxtLink
|
||||
v-for="link in aboutSubNavs"
|
||||
:key="link.path"
|
||||
:to="link.path"
|
||||
class="text-[var(--t10-text-secondary)] hover:text-[var(--t10-primary)] transition-colors"
|
||||
:class="{ '!text-[var(--t10-primary)] font-medium': route.path === link.path }"
|
||||
>
|
||||
{{ link.title }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
<nav class="t10-breadcrumb text-[var(--t10-muted-2)]">
|
||||
<NuxtLink to="/" class="hover:text-[var(--t10-primary)]">首页</NuxtLink>
|
||||
<span class="mx-2">/</span>
|
||||
<span class="text-[var(--t10-text)]">{{ pageTitle }}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 企业定位 / 企业目标 -->
|
||||
<section class="py-14 lg:py-20 bg-white">
|
||||
<div class="t10-container">
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="bg-[var(--t10-bg-soft)] rounded-xl p-8 lg:p-10 border border-[var(--t10-border)] hover:border-[var(--t10-primary)] transition-colors">
|
||||
<h2 class="text-xl font-bold text-[var(--t10-text-strong)] mb-4">企业定位</h2>
|
||||
<p class="text-[var(--t10-text-secondary)] leading-relaxed">
|
||||
{{ companyPosition || '深圳市创芯存储技术有限公司成立于2020年,主营DRAM与Flash存储芯片,是一家专注于为网络通讯、消费电子、AI、汽车电子、工业控制等高增长领域提供高性能和高可靠性存储芯片解决方案的创新型企业。' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-[var(--t10-bg-soft)] rounded-xl p-8 lg:p-10 border border-[var(--t10-border)] hover:border-[var(--t10-primary)] transition-colors">
|
||||
<h2 class="text-xl font-bold text-[var(--t10-text-strong)] mb-4">企业目标</h2>
|
||||
<p class="text-[var(--t10-text-secondary)] leading-relaxed">
|
||||
{{ companyGoal || '致力于通过稳定的产品、有竞争力的价格、持续的供应和超越客户期望的服务,为客户创造满意度、忠诚度和口碑的价值。' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 使命 愿景 价值观 -->
|
||||
<section
|
||||
class="relative py-16 lg:py-24 bg-cover bg-center bg-fixed"
|
||||
style="background-image: url('/images/template-10/about-mission-bg.jpg');"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-white/95 via-white/90 to-white/70" />
|
||||
<div class="t10-container relative z-10">
|
||||
<div class="max-w-2xl">
|
||||
<h2 class="text-2xl lg:text-3xl font-bold text-[var(--t10-text-strong)] mb-8">使命 愿景 价值观</h2>
|
||||
<div class="space-y-5 text-[var(--t10-text-secondary)]">
|
||||
<div class="flex gap-4">
|
||||
<span class="font-semibold text-[var(--t10-text-strong)] w-12 flex-shrink-0">愿景</span>
|
||||
<p>紧跟人工智能存储需求成长,成为行业值得信赖的存储解决方案提供商。</p>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<span class="font-semibold text-[var(--t10-text-strong)] w-12 flex-shrink-0">使命</span>
|
||||
<p>成为客户最具价值合作伙伴,持续为客户创造满意度、忠诚度和口碑的价值。</p>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<span class="font-semibold text-[var(--t10-text-strong)] w-12 flex-shrink-0">价值观</span>
|
||||
<p>成长、诚信、积累更重要。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 致力于为客户创造 -->
|
||||
<section class="py-14 lg:py-20 bg-[var(--t10-bg-soft)]">
|
||||
<div class="t10-container text-center">
|
||||
<h2 class="text-2xl lg:text-3xl font-bold text-[var(--t10-text-strong)] mb-6">
|
||||
{{ siteName || 'C-MVP' }} 致力于为客户创造
|
||||
</h2>
|
||||
<NuxtLink to="/contact" class="t10-btn-primary mb-12">
|
||||
满意度、忠诚度、口碑的价值
|
||||
</NuxtLink>
|
||||
|
||||
<!-- 价值循环图 -->
|
||||
<div class="relative max-w-3xl mx-auto mt-12">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div
|
||||
v-for="(item, i) in valueCycle"
|
||||
:key="i"
|
||||
class="bg-white rounded-xl p-6 border border-[var(--t10-border)] shadow-sm hover:border-[var(--t10-primary)] transition-colors"
|
||||
>
|
||||
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--t10-primary-soft)] flex items-center justify-center text-[var(--t10-primary)]">
|
||||
<svg v-if="i === 0" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
<svg v-else-if="i === 1" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
<svg v-else-if="i === 2" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</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="1.8" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="font-semibold text-[var(--t10-text-strong)]">{{ item.title }}</h3>
|
||||
<p class="text-sm text-[var(--t10-muted-2)] mt-1">{{ item.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden md:flex absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-24 h-24 rounded-full bg-[var(--t10-primary)] text-white items-center justify-center text-lg font-bold shadow-lg">
|
||||
价值
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 正文内容(后台维护) -->
|
||||
<section v-if="hasContent" class="py-14 lg:py-20 bg-white">
|
||||
<div class="t10-container max-w-4xl">
|
||||
<div class="bg-white rounded-xl border border-[var(--t10-border)] p-6 lg:p-10">
|
||||
<RichText :content="pageData?.content" />
|
||||
<PageAttachments
|
||||
v-if="pageData?.attachments?.length"
|
||||
:attachments="pageData.attachments"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 模板 10 - 关于我们页(创芯存储科技蓝风格)
|
||||
*/
|
||||
import type { PageDetail, CmsBanner, ApiEnvelope, PageResult } from '~/types'
|
||||
import { ensureFullUrl } from '~/utils/image'
|
||||
|
||||
const { siteInfo, siteName, fetchSiteInfo } = useSite()
|
||||
const route = useRoute()
|
||||
|
||||
await fetchSiteInfo()
|
||||
|
||||
// CMS 内页横幅:position=inner_page,匹配 title=about 的分组取背景图
|
||||
const DEFAULT_ABOUT_BANNER = '/images/template-10/about-banner.jpg'
|
||||
const { data: innerBannerRes } = await useFetch<ApiEnvelope<PageResult<CmsBanner>> | PageResult<CmsBanner>>('/api/banner', {
|
||||
query: { position: 'inner_page' }
|
||||
})
|
||||
|
||||
const aboutBannerImage = computed<string>(() => {
|
||||
const res = innerBannerRes.value as (ApiEnvelope<PageResult<CmsBanner>> | PageResult<CmsBanner>) | null
|
||||
if (!res) return DEFAULT_ABOUT_BANNER
|
||||
const list = ((res as ApiEnvelope<PageResult<CmsBanner>>)?.data?.list
|
||||
?? (res as PageResult<CmsBanner>)?.list
|
||||
?? []) as CmsBanner[]
|
||||
// 匹配 title=about(分组标题或明细标题)的首条有效图片
|
||||
const aboutItem = list.find((b) => {
|
||||
if (!b.image && !b.imageUrl && !b.pic) return false
|
||||
return b.title?.toLowerCase() === 'about'
|
||||
})
|
||||
if (aboutItem) {
|
||||
return ensureFullUrl(aboutItem.image || aboutItem.imageUrl || aboutItem.pic || '')
|
||||
}
|
||||
// 兜底:inner_page 下任意首条有图项(无 title 精确匹配时取第一张)
|
||||
const anyItem = list.find((b) => !!(b.image || b.imageUrl || b.pic))
|
||||
if (anyItem) {
|
||||
return ensureFullUrl(anyItem.image || anyItem.imageUrl || anyItem.pic || '')
|
||||
}
|
||||
return DEFAULT_ABOUT_BANNER
|
||||
})
|
||||
|
||||
const { data: pageData } = await useFetch<PageDetail>('/api/page/detail', {
|
||||
key: 'page-about',
|
||||
query: { path: 'about' }
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => pageData.value?.title?.trim() || '关于我们')
|
||||
const hasContent = computed(() => Boolean(pageData.value?.hasContent && pageData.value?.content))
|
||||
|
||||
const companyPosition = computed(() => {
|
||||
const cfg = siteInfo.value?.config as Record<string, unknown> | undefined
|
||||
return cfg?.position as string | undefined
|
||||
})
|
||||
const companyGoal = computed(() => {
|
||||
const cfg = siteInfo.value?.config as Record<string, unknown> | undefined
|
||||
return cfg?.goal as string | undefined
|
||||
})
|
||||
|
||||
const aboutSubNavs = [
|
||||
{ title: '关于创芯', path: '/about' },
|
||||
{ title: '新闻资讯', path: '/article' },
|
||||
{ title: '联系我们', path: '/contact' }
|
||||
]
|
||||
|
||||
const valueCycle = [
|
||||
{ title: '稳定的产品', desc: '严苛测试验证' },
|
||||
{ title: '超越客户期望的服务', desc: '快速响应支持' },
|
||||
{ title: '有竞争力的价格', desc: '优化成本结构' },
|
||||
{ title: '持续的供应', desc: '保障交付周期' }
|
||||
]
|
||||
|
||||
usePageSeo(
|
||||
{
|
||||
title: pageTitle.value,
|
||||
path: '/about',
|
||||
keywords: pageData.value?.keywords || undefined,
|
||||
description: pageData.value?.description || undefined
|
||||
},
|
||||
siteInfo.value
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-white pb-16">
|
||||
<!-- Banner -->
|
||||
<section
|
||||
class="t10-page-banner"
|
||||
style="background-image: url('/images/template-10/solution-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="caseNav?.path || '/case'">{{ caseNav?.title || '行业解决方案' }}</NuxtLink>
|
||||
<span class="mx-2">/</span>
|
||||
<span class="text-white">{{ caseItem?.title || '方案详情' }}</span>
|
||||
</nav>
|
||||
<h1 class="text-3xl sm:text-4xl font-bold text-white">{{ caseNav?.title || '行业解决方案' }}</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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="caseItem">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="rounded-2xl overflow-hidden mb-10">
|
||||
<img
|
||||
:src="coverImage"
|
||||
:alt="caseItem.title"
|
||||
class="w-full h-[260px] lg:h-[400px] object-cover"
|
||||
@error="onImgError"
|
||||
>
|
||||
</div>
|
||||
<h1 class="text-2xl lg:text-3xl font-bold text-[var(--t10-text-strong)] mb-6">
|
||||
{{ caseItem.title }}
|
||||
</h1>
|
||||
<div v-if="caseItem.summary" class="text-[var(--t10-text-secondary)] leading-relaxed mb-8 p-6 bg-[var(--t10-bg-soft)] rounded-xl border-l-4 border-[var(--t10-primary)]">
|
||||
{{ caseItem.summary }}
|
||||
</div>
|
||||
<div class="prose max-w-none">
|
||||
<RichText :content="caseItem.content" />
|
||||
</div>
|
||||
<PageAttachments
|
||||
v-if="caseItem.attachments?.length"
|
||||
:attachments="caseItem.attachments"
|
||||
class="mt-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 相关方案 -->
|
||||
<div v-if="relatedCases.length" class="mt-16">
|
||||
<h2 class="t10-section-title t10-section-title-center">相关方案</h2>
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<NuxtLink
|
||||
v-for="item in relatedCases"
|
||||
:key="item.id ?? item.articleId"
|
||||
:to="`/case/${item.id ?? item.articleId}`"
|
||||
class="group rounded-xl overflow-hidden border border-[var(--t10-border)] hover:border-[var(--t10-primary)] hover:shadow-md transition-all"
|
||||
>
|
||||
<div class="aspect-[16/10] overflow-hidden bg-[var(--t10-bg-gray)]">
|
||||
<img
|
||||
:src="itemImage(item)"
|
||||
:alt="item.title"
|
||||
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
@error="onImgError"
|
||||
>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<h3 class="font-medium text-[var(--t10-text)] t10-ellipsis group-hover:text-[var(--t10-primary)] transition-colors">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</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 { CaseItem, PageResult, ApiEnvelope } from '~/types'
|
||||
import { getCompressedImageUrl } from '~/utils/image'
|
||||
|
||||
const { allNavigations, fetchSiteInfo } = useSite()
|
||||
const route = useRoute()
|
||||
|
||||
await fetchSiteInfo()
|
||||
|
||||
const caseId = computed(() => route.params.id as string)
|
||||
|
||||
const { data: caseRes, pending, error } = await useFetch<{ data?: CaseItem }>('/api/case/detail', {
|
||||
key: `case-detail-${caseId.value}`,
|
||||
query: { id: caseId.value }
|
||||
})
|
||||
|
||||
const caseItem = computed<CaseItem | undefined>(() => caseRes.value?.data)
|
||||
|
||||
const caseNav = computed(() => {
|
||||
const navs = allNavigations.value || []
|
||||
return navs.find((n) => n.model === 'case')
|
||||
})
|
||||
|
||||
const FALLBACK_IMAGE = '/images/template-10/solution-industrial.jpg'
|
||||
const coverImage = computed(() => {
|
||||
const c = caseItem.value as Record<string, unknown> | undefined
|
||||
const raw = (c?.cover || c?.image || c?.photo || (Array.isArray(c?.images) ? (c.images as string[])[0] : '')) as string
|
||||
return raw ? getCompressedImageUrl(raw, { width: 1200 }) : FALLBACK_IMAGE
|
||||
})
|
||||
function onImgError(e: Event) {
|
||||
const el = e.target as HTMLImageElement
|
||||
if (el && !el.src.endsWith(FALLBACK_IMAGE)) el.src = FALLBACK_IMAGE
|
||||
}
|
||||
function itemImage(item: CaseItem): string {
|
||||
const anyItem = item as Record<string, unknown>
|
||||
const raw = (anyItem.cover || anyItem.image || anyItem.photo
|
||||
|| (Array.isArray(anyItem.images) ? (anyItem.images as string[])[0] : '')) as string
|
||||
return raw ? getCompressedImageUrl(raw, { width: 600 }) : FALLBACK_IMAGE
|
||||
}
|
||||
|
||||
const { data: relatedRes } = await useFetch<ApiEnvelope<PageResult<CaseItem>> | PageResult<CaseItem>>('/api/case/list', {
|
||||
key: `case-related-${caseId.value}`,
|
||||
query: { page: 1, limit: 3 },
|
||||
server: false
|
||||
})
|
||||
const relatedCases = computed<CaseItem[]>(() => {
|
||||
const d = relatedRes.value as (ApiEnvelope<PageResult<CaseItem>> | PageResult<CaseItem>) | null
|
||||
if (!d) return []
|
||||
const list = ((d as ApiEnvelope<PageResult<CaseItem>>)?.data?.list
|
||||
?? (d as PageResult<CaseItem>)?.list
|
||||
?? []) as CaseItem[]
|
||||
return list.filter((c) => String((c as Record<string, unknown>).id ?? (c as Record<string, unknown>).articleId) !== caseId.value).slice(0, 3)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-white pb-16">
|
||||
<!-- Banner -->
|
||||
<section
|
||||
class="t10-page-banner"
|
||||
style="background-image: url('/images/template-10/solution-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-[0.2em] uppercase">Solution</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 caseTabs"
|
||||
: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-if="displayCases.length" class="space-y-12">
|
||||
<NuxtLink
|
||||
v-for="(item, i) in displayCases"
|
||||
:key="item.id ?? item.articleId"
|
||||
:to="`/case/${item.id ?? item.articleId}`"
|
||||
class="group grid md:grid-cols-2 gap-0 rounded-2xl overflow-hidden border border-[var(--t10-border)] hover:shadow-lg transition-all"
|
||||
:class="i % 2 === 1 ? 'md:flex-row-reverse' : ''"
|
||||
>
|
||||
<div :class="['h-[240px] lg:h-[320px] overflow-hidden bg-[var(--t10-bg-gray)]', i % 2 === 1 ? 'md:order-2' : '']">
|
||||
<img
|
||||
:src="itemImage(item)"
|
||||
:alt="item.title"
|
||||
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
@error="onImgError"
|
||||
>
|
||||
</div>
|
||||
<div :class="['p-8 lg:p-12 flex flex-col justify-center bg-white', i % 2 === 1 ? 'md:order-1' : '']">
|
||||
<h3 class="text-xl lg:text-2xl font-bold text-[var(--t10-text-strong)] mb-4 group-hover:text-[var(--t10-primary)] transition-colors">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
<p class="text-[var(--t10-text-secondary)] leading-relaxed mb-6">
|
||||
{{ summaryOf(item) }}
|
||||
</p>
|
||||
<span class="inline-flex items-center text-[var(--t10-primary)] font-medium text-sm">
|
||||
了解详情
|
||||
<svg class="w-4 h-4 ml-1 transition-transform group-hover:translate-x-1" 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>
|
||||
</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- 兜底展示 -->
|
||||
<div v-else class="space-y-12">
|
||||
<div
|
||||
v-for="(sol, i) in fallbackSolutions"
|
||||
:key="i"
|
||||
class="group grid md:grid-cols-2 gap-0 rounded-2xl overflow-hidden border border-[var(--t10-border)] hover:shadow-lg transition-all"
|
||||
>
|
||||
<div :class="['h-[240px] lg:h-[320px] overflow-hidden bg-[var(--t10-bg-gray)]', i % 2 === 1 ? 'md:order-2' : '']">
|
||||
<img :src="sol.image" :alt="sol.title" class="w-full h-full object-cover">
|
||||
</div>
|
||||
<div :class="['p-8 lg:p-12 flex flex-col justify-center bg-white', i % 2 === 1 ? 'md:order-1' : '']">
|
||||
<h3 class="text-xl lg:text-2xl font-bold text-[var(--t10-text-strong)] mb-4">{{ sol.title }}</h3>
|
||||
<p class="text-[var(--t10-text-secondary)] leading-relaxed mb-6">{{ sol.desc }}</p>
|
||||
<NuxtLink to="/contact" class="inline-flex items-center text-[var(--t10-primary)] font-medium text-sm">
|
||||
了解详情
|
||||
<svg class="w-4 h-4 ml-1" 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>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</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 { CaseItem, PageResult, ApiEnvelope, CmsNavigation } from '~/types'
|
||||
import { collectDescendantNavIds } from '~/utils/nav-tree'
|
||||
import { getCompressedImageUrl } from '~/utils/image'
|
||||
import { stripHtml } from '~/utils'
|
||||
|
||||
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 = 10
|
||||
|
||||
const { data, pending, error } = await useFetch<
|
||||
ApiEnvelope<PageResult<CaseItem>> | PageResult<CaseItem>
|
||||
>('/api/case/list', {
|
||||
key: `case-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 cases = computed<CaseItem[]>(() => {
|
||||
const envelope = data.value as ApiEnvelope<PageResult<CaseItem>>
|
||||
const direct = data.value as PageResult<CaseItem>
|
||||
return envelope?.data?.list || direct?.list || []
|
||||
})
|
||||
|
||||
const totalCount = computed(() => {
|
||||
const envelope = data.value as ApiEnvelope<PageResult<CaseItem>>
|
||||
const direct = data.value as PageResult<CaseItem>
|
||||
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 caseNav = computed<CmsNavigation | undefined>(() => {
|
||||
const navs = allNavigations.value || []
|
||||
return navs.find((n) => n.navigationId === navigationId.value) || navs.find((n) => n.model === 'case')
|
||||
})
|
||||
|
||||
const caseTabs = computed<CmsNavigation[]>(() => {
|
||||
const parent = caseNav.value
|
||||
if (parent?.children?.length) return parent.children.slice(0, 8)
|
||||
return parent ? [parent] : []
|
||||
})
|
||||
|
||||
const activeTab = ref<number | undefined>(caseTabs.value[0]?.navigationId)
|
||||
watch(caseTabs, () => {
|
||||
activeTab.value = caseTabs.value[0]?.navigationId
|
||||
}, { immediate: true })
|
||||
|
||||
const displayCases = computed(() => {
|
||||
if (!activeTab.value) return cases.value.slice(0, limit)
|
||||
const ids = collectDescendantNavIds(activeTab.value, allNavigations.value || [])
|
||||
const filterIds = ids.length ? ids : [activeTab.value]
|
||||
return cases.value.filter((c) => {
|
||||
const item = c as Record<string, unknown>
|
||||
return filterIds.includes((item.navigationId || item.categoryId) as number)
|
||||
}).slice(0, limit)
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => caseNav.value?.title || '行业解决方案')
|
||||
|
||||
const FALLBACK_IMAGE = '/images/template-10/solution-industrial.jpg'
|
||||
function itemImage(item: CaseItem): string {
|
||||
const anyItem = item as Record<string, unknown>
|
||||
const raw = (anyItem.cover || anyItem.image || anyItem.photo
|
||||
|| (Array.isArray(anyItem.images) ? (anyItem.images as string[])[0] : '')) as string
|
||||
return raw ? getCompressedImageUrl(raw, { width: 1000 }) : FALLBACK_IMAGE
|
||||
}
|
||||
function onImgError(e: Event) {
|
||||
const el = e.target as HTMLImageElement
|
||||
if (el && !el.src.endsWith(FALLBACK_IMAGE)) el.src = FALLBACK_IMAGE
|
||||
}
|
||||
function summaryOf(item: CaseItem): string {
|
||||
const anyItem = item as Record<string, unknown>
|
||||
const s = (anyItem.summary || anyItem.description || anyItem.content || '') as string
|
||||
return stripHtml(s).slice(0, 160)
|
||||
}
|
||||
|
||||
const fallbackSolutions = [
|
||||
{ title: '工业控制设备', desc: '工控主板、工业网关、PLC 设备,主打宽温稳定、7×24 小时连续运行、抗干扰,适配 DDR4/LPDDR/eMMC 稳定方案。', image: '/images/template-10/solution-industrial.jpg' },
|
||||
{ title: '智能物联网终端', desc: '智能音箱、网关、安防摄像头、小体积、低功耗,提供轻量化物联网设备稳定可靠的存储配套。', image: '/images/template-10/solution-iot.jpg' },
|
||||
{ title: '安防监控设备', desc: '支持高清录像、大容量存储、长时间录像回放,以 DDR4+eMMC 组合满足严苛场景。', image: '/images/template-10/solution-security.jpg' },
|
||||
{ title: '消费&车载智能设备', desc: '车载中控、行车记录仪、智能电视、机顶盒,提供车规级和消费级稳定存储方案。', image: '/images/template-10/solution-vehicle.jpg' }
|
||||
]
|
||||
</script>
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[var(--t10-bg-soft)] pb-16">
|
||||
<!-- Banner -->
|
||||
<section
|
||||
class="t10-page-banner"
|
||||
style="background-image: url('/images/template-10/contact-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">联系我们</span>
|
||||
</nav>
|
||||
<h1 class="text-3xl sm:text-4xl font-bold text-white mb-2">联系我们</h1>
|
||||
<p class="text-lg text-white/80 tracking-[0.2em] uppercase">Contact Us</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 子导航 -->
|
||||
<div class="border-b border-[var(--t10-border)] bg-white">
|
||||
<div class="t10-container">
|
||||
<div class="flex flex-wrap items-center justify-between py-4 gap-4">
|
||||
<nav class="flex flex-wrap gap-6 text-sm lg:pl-[460px]">
|
||||
<NuxtLink
|
||||
v-for="link in subNavs"
|
||||
:key="link.path"
|
||||
:to="link.path"
|
||||
class="text-[var(--t10-text-secondary)] hover:text-[var(--t10-primary)] transition-colors"
|
||||
:class="{ '!text-[var(--t10-primary)] font-medium': route.path === link.path }"
|
||||
>
|
||||
{{ link.title }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
<nav class="text-xs text-[var(--t10-muted-2)]">
|
||||
<NuxtLink to="/" class="hover:text-[var(--t10-primary)]">首页</NuxtLink>
|
||||
<span class="mx-2">/</span>
|
||||
<span class="text-[var(--t10-text)]">联系我们</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="t10-container py-12 lg:py-16">
|
||||
<div class="grid lg:grid-cols-5 gap-8">
|
||||
<!-- 联系信息卡片 -->
|
||||
<div class="lg:col-span-3 bg-white rounded-2xl border border-[var(--t10-border)] overflow-hidden shadow-sm">
|
||||
<div class="grid md:grid-cols-2">
|
||||
<div class="h-[280px] md:h-full min-h-[280px]">
|
||||
<img
|
||||
src="/images/template-10/contact-chip.jpg"
|
||||
alt="公司产品"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
</div>
|
||||
<div class="p-8 lg:p-10 flex flex-col justify-center">
|
||||
<h2 class="text-xl font-bold text-[var(--t10-text-strong)] mb-1">
|
||||
{{ siteName || '深圳市创芯存储技术有限公司' }}
|
||||
</h2>
|
||||
<p class="text-sm text-[var(--t10-muted-2)] mb-8">{{ englishName || 'C-MVP Electronics Co.,Ltd.' }}</p>
|
||||
|
||||
<ul class="space-y-5 text-sm">
|
||||
<li class="flex items-start gap-3">
|
||||
<span class="text-[var(--t10-muted-2)] w-10 flex-shrink-0">电话</span>
|
||||
<a v-if="phone" :href="`tel:${phone}`" class="text-[var(--t10-text)] hover:text-[var(--t10-primary)] font-medium">{{ phone }}</a>
|
||||
<span v-else class="text-[var(--t10-muted)]">—</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<span class="text-[var(--t10-muted-2)] w-10 flex-shrink-0">邮箱</span>
|
||||
<a v-if="email" :href="`mailto:${email}`" class="text-[var(--t10-text)] hover:text-[var(--t10-primary)] font-medium break-all">{{ email }}</a>
|
||||
<span v-else class="text-[var(--t10-muted)]">—</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<span class="text-[var(--t10-muted-2)] w-10 flex-shrink-0">网址</span>
|
||||
<a v-if="siteUrl" :href="siteUrl" target="_blank" rel="noopener" class="text-[var(--t10-text)] hover:text-[var(--t10-primary)] font-medium break-all">{{ siteUrl }}</a>
|
||||
<span v-else class="text-[var(--t10-muted)]">—</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<span class="text-[var(--t10-muted-2)] w-10 flex-shrink-0">地址</span>
|
||||
<span class="text-[var(--t10-text)] font-medium">{{ address || '—' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 在线留言 -->
|
||||
<div class="lg:col-span-2 bg-white rounded-2xl border border-[var(--t10-border)] p-8 shadow-sm">
|
||||
<h2 class="text-xl font-bold text-[var(--t10-text-strong)] mb-6">在线留言</h2>
|
||||
<ContactForm
|
||||
input-class="w-full px-4 py-2.5 border border-[var(--t10-border)] rounded-lg focus:outline-none focus:border-[var(--t10-primary)] bg-[var(--t10-bg-soft)] placeholder-[var(--t10-muted)]"
|
||||
submit-class="w-full px-6 py-3 bg-[var(--t10-primary)] text-white font-semibold rounded-lg hover:bg-[var(--t10-primary-dark)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
label-class="block text-sm font-medium text-[var(--t10-text)] mb-1"
|
||||
:accent="config.themeConfig.primaryColor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 附件 -->
|
||||
<PageAttachments
|
||||
v-if="contactAttachments.length"
|
||||
:attachments="contactAttachments"
|
||||
class="mt-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 模板 10 - 联系我们页(创芯存储科技蓝风格)
|
||||
*/
|
||||
import type { PageDetail, PageAttachment } from '~/types'
|
||||
import config from '../config'
|
||||
|
||||
const { siteInfo, siteName, phone, email, address, fetchSiteInfo } = useSite()
|
||||
const route = useRoute()
|
||||
|
||||
await fetchSiteInfo()
|
||||
|
||||
const { data: contactPage } = await useFetch<PageDetail>('/api/page/detail', { query: { path: 'contact' } })
|
||||
const contactAttachments = computed<PageAttachment[]>(() => contactPage.value?.attachments || [])
|
||||
|
||||
const englishName = computed(() => {
|
||||
const cfg = siteInfo.value?.config as Record<string, unknown> | undefined
|
||||
return cfg?.englishName as string | undefined
|
||||
})
|
||||
|
||||
const siteUrl = computed(() => {
|
||||
const url = siteInfo.value?.domain || ''
|
||||
if (!url) return ''
|
||||
return url.startsWith('http') ? url : `https://${url}`
|
||||
})
|
||||
|
||||
const subNavs = [
|
||||
{ title: '关于创芯', path: '/about' },
|
||||
{ title: '新闻资讯', path: '/article' },
|
||||
{ title: '联系我们', path: '/contact' }
|
||||
]
|
||||
</script>
|
||||
@@ -0,0 +1,396 @@
|
||||
<template>
|
||||
<div class="bg-white">
|
||||
<HeroSection />
|
||||
|
||||
<!-- 公司简介 -->
|
||||
<section class="py-14 lg:py-20 bg-white">
|
||||
<div class="t10-container">
|
||||
<h2 class="t10-section-title">{{ siteName || '深圳市创芯存储技术有限公司' }}</h2>
|
||||
<div class="grid lg:grid-cols-2 gap-10 items-center">
|
||||
<div class="space-y-6">
|
||||
<div class="bg-[var(--t10-primary-faint)] border border-[var(--t10-primary-soft)] rounded-lg p-6">
|
||||
<div class="inline-flex items-center px-3 py-1 rounded-full bg-[var(--t10-primary)] text-white text-sm font-medium mb-3">
|
||||
企业定位
|
||||
</div>
|
||||
<p class="text-[var(--t10-text-secondary)] leading-relaxed">
|
||||
{{ companyPosition || '专注DRAM与Flash存储芯片,为网络通讯、消费电子、AI、汽车电子、工业控制等高速增长领域提供高性能和高可靠性存储解决方案。' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-[var(--t10-primary-faint)] border border-[var(--t10-primary-soft)] rounded-lg p-6">
|
||||
<div class="inline-flex items-center px-3 py-1 rounded-full bg-[var(--t10-primary)] text-white text-sm font-medium mb-3">
|
||||
企业目标
|
||||
</div>
|
||||
<p class="text-[var(--t10-text-secondary)] leading-relaxed">
|
||||
{{ companyGoal || '致力于通过稳定的产品、有竞争力的价格、持续的供应和超越客户期望的服务,为客户创造满意度、忠诚度和口碑的价值。' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative rounded-xl overflow-hidden shadow-lg">
|
||||
<img
|
||||
:src="aboutImage"
|
||||
alt="公司环境"
|
||||
class="w-full h-[280px] lg:h-[360px] object-cover"
|
||||
@error="onAboutImgError"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 产品中心 -->
|
||||
<section class="py-14 lg:py-20 bg-[var(--t10-bg-soft)]">
|
||||
<div class="t10-container">
|
||||
<h2 class="t10-section-title t10-section-title-center">{{ productNav?.title || '产品中心' }}</h2>
|
||||
|
||||
<!-- 分类 Tab -->
|
||||
<div v-if="productTabs.length" class="t10-tabs justify-center mb-10">
|
||||
<button
|
||||
v-for="tab in productTabs"
|
||||
:key="tab.navigationId"
|
||||
class="t10-tab"
|
||||
:class="{ 't10-tab-active': activeProductTab === tab.navigationId }"
|
||||
@click="activeProductTab = tab.navigationId"
|
||||
>
|
||||
{{ tab.title }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 产品列表 -->
|
||||
<div v-if="displayProducts.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
|
||||
<NuxtLink
|
||||
v-for="item in displayProducts"
|
||||
:key="item.id ?? item.productId"
|
||||
:to="`/product/${item.id ?? item.productId}`"
|
||||
class="group bg-white rounded-lg border border-[var(--t10-border)] hover:border-[var(--t10-primary)] hover:shadow-md transition-all overflow-hidden"
|
||||
>
|
||||
<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-medium 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-else class="text-center py-16 text-[var(--t10-muted-2)]">
|
||||
暂无产品内容
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-10">
|
||||
<NuxtLink v-if="productNav" :to="getNavLink(productNav)" class="t10-btn-outline">
|
||||
查看更多产品
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 核心优势 -->
|
||||
<section class="py-14 lg:py-20 bg-white">
|
||||
<div class="t10-container">
|
||||
<h2 class="t10-section-title t10-section-title-center">核心优势</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-5">
|
||||
<div
|
||||
v-for="(adv, i) in advantages"
|
||||
:key="i"
|
||||
class="relative bg-[var(--t10-bg-soft)] hover:bg-[var(--t10-primary)] rounded-lg p-6 transition-all group"
|
||||
>
|
||||
<div class="text-4xl font-bold text-[var(--t10-primary)] group-hover:text-white/90 mb-3">
|
||||
{{ adv.num }}
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-[var(--t10-text-strong)] group-hover:text-white mb-2">
|
||||
{{ adv.title }}
|
||||
</h3>
|
||||
<p class="text-sm text-[var(--t10-text-secondary)] group-hover:text-white/85 leading-relaxed">
|
||||
{{ adv.desc }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 行业解决方案 -->
|
||||
<section class="py-14 lg:py-20 bg-[var(--t10-bg-soft)]">
|
||||
<div class="t10-container">
|
||||
<h2 class="t10-section-title t10-section-title-center">{{ caseNav?.title || '行业解决方案' }}</h2>
|
||||
<div v-if="caseList.length" class="grid md:grid-cols-2 gap-6">
|
||||
<NuxtLink
|
||||
v-for="item in caseList"
|
||||
:key="item.id ?? item.articleId"
|
||||
:to="detailLink(caseNav?.model, item)"
|
||||
class="group relative overflow-hidden rounded-xl h-[220px] lg:h-[260px]"
|
||||
>
|
||||
<img
|
||||
:src="itemImage(item)"
|
||||
:alt="item.title"
|
||||
class="absolute inset-0 w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
@error="onImgError"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/75 via-black/30 to-transparent" />
|
||||
<div class="absolute bottom-0 left-0 right-0 p-6 text-white">
|
||||
<h3 class="text-lg font-semibold mb-1 group-hover:text-[var(--t10-primary-light)] transition-colors">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
<p class="text-sm text-white/80 t10-clamp-2">
|
||||
{{ summaryOf(item) }}
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div v-else class="grid md:grid-cols-2 gap-6">
|
||||
<div
|
||||
v-for="(sol, i) in fallbackSolutions"
|
||||
:key="i"
|
||||
class="group relative overflow-hidden rounded-xl h-[220px] lg:h-[260px]"
|
||||
>
|
||||
<img :src="sol.image" :alt="sol.title" class="absolute inset-0 w-full h-full object-cover transition-transform duration-500 group-hover:scale-105">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/75 via-black/30 to-transparent" />
|
||||
<div class="absolute bottom-0 left-0 right-0 p-6 text-white">
|
||||
<h3 class="text-lg font-semibold mb-1">{{ sol.title }}</h3>
|
||||
<p class="text-sm text-white/80 t10-clamp-2">{{ sol.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 为什么选择创芯 -->
|
||||
<section
|
||||
class="relative py-16 lg:py-24 bg-cover bg-center"
|
||||
style="background-image: url('/images/template-10/why-choose-bg.jpg');"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-[#0047BA]/92 to-[#0052D9]/75" />
|
||||
<div class="t10-container relative z-10">
|
||||
<div class="max-w-3xl mx-auto text-center text-white">
|
||||
<h2 class="text-2xl lg:text-3xl font-bold mb-5">为什么选择创芯</h2>
|
||||
<p class="text-white/90 leading-relaxed mb-8 bg-white/10 rounded-xl p-6 backdrop-blur-sm">
|
||||
{{ whyChooseText || '多年存储行业积淀,掌握嵌入式存储核心器件与方案设计能力,从选型推荐、样品测试到批量交付,提供全生命周期技术支持,助力客户缩短开发周期、降低供应链风险。' }}
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center justify-center gap-4">
|
||||
<NuxtLink v-if="contactNav" :to="getNavLink(contactNav)" class="t10-btn-primary bg-white text-[var(--t10-primary)] hover:bg-white/90">
|
||||
在线咨询
|
||||
</NuxtLink>
|
||||
<button type="button" class="t10-btn-outline border-white text-white hover:bg-white hover:text-[var(--t10-primary)]" @click="openConsult">
|
||||
样品申请
|
||||
</button>
|
||||
<NuxtLink v-if="productNav" :to="getNavLink(productNav)" class="t10-btn-outline border-white text-white hover:bg-white hover:text-[var(--t10-primary)]">
|
||||
资料下载
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 新闻中心 -->
|
||||
<section v-if="newsList.length" class="py-14 lg:py-20 bg-white">
|
||||
<div class="t10-container">
|
||||
<h2 class="t10-section-title t10-section-title-center">{{ newsNav?.title || '企业资讯' }}</h2>
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<NuxtLink
|
||||
v-for="item in newsList.slice(0, 6)"
|
||||
:key="item.id ?? item.articleId"
|
||||
:to="detailLink(newsNav?.model, item)"
|
||||
class="group bg-white rounded-xl border border-[var(--t10-border)] overflow-hidden hover:shadow-md transition-all"
|
||||
>
|
||||
<div class="aspect-[16/10] overflow-hidden bg-[var(--t10-bg-gray)]">
|
||||
<img
|
||||
:src="itemImage(item)"
|
||||
:alt="item.title"
|
||||
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
@error="onImgError"
|
||||
>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<h3 class="font-medium text-[var(--t10-text)] t10-clamp-2 group-hover:text-[var(--t10-primary)] transition-colors mb-3">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
<p class="text-sm text-[var(--t10-muted-2)] t10-clamp-2 mb-4">
|
||||
{{ summaryOf(item) }}
|
||||
</p>
|
||||
<span class="text-xs text-[var(--t10-muted)]">{{ formatDate(item.publishTime || item.createTime) }}</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div v-if="newsNav" class="text-center mt-10">
|
||||
<NuxtLink :to="getNavLink(newsNav)" class="t10-btn-outline">
|
||||
查看更多资讯
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 模板 10 - 首页(创芯存储科技蓝风格)
|
||||
* 板块:Hero → 公司简介 → 产品中心 → 核心优势 → 行业解决方案 → 为什么选择创芯 → 新闻中心
|
||||
*/
|
||||
import dayjs from 'dayjs'
|
||||
import HeroSection from '../components/HeroSection.vue'
|
||||
import type { CmsNavigation, Article, Product, CaseItem, PageResult, ApiEnvelope } from '~/types'
|
||||
import { collectDescendantNavIds } from '~/utils/nav-tree'
|
||||
import { getNavLink, stripHtml } from '~/utils'
|
||||
import { getCompressedImageUrl } from '~/utils/image'
|
||||
|
||||
type ContentItem = Article | Product | CaseItem
|
||||
|
||||
const { siteInfo, siteName, navigations, allNavigations, fetchSiteInfo } = useSite()
|
||||
const { openConsult } = useConsult()
|
||||
|
||||
await fetchSiteInfo()
|
||||
|
||||
const topNavs = computed<CmsNavigation[]>(() => {
|
||||
const primary = (navigations.value || []).filter((n) => (n.parentId || 0) === 0)
|
||||
if (primary.length) return primary
|
||||
return (allNavigations.value || []).filter((n) => (n.parentId || 0) === 0)
|
||||
})
|
||||
|
||||
const productNav = computed<CmsNavigation | undefined>(() => topNavs.value.find((n) => n.model === 'product'))
|
||||
const caseNav = computed<CmsNavigation | undefined>(() => topNavs.value.find((n) => n.model === 'case'))
|
||||
const newsNav = computed<CmsNavigation | undefined>(() => topNavs.value.find((n) => n.model === 'article'))
|
||||
const contactNav = computed<CmsNavigation | undefined>(() => topNavs.value.find((n) => n.model === 'contact' || n.title.includes('联系')))
|
||||
|
||||
async function fetchList(nav: CmsNavigation | undefined, limit: number): Promise<ContentItem[]> {
|
||||
if (!nav) return []
|
||||
const api = nav.model === 'product'
|
||||
? '/api/product/list'
|
||||
: nav.model === 'case'
|
||||
? '/api/case/list'
|
||||
: '/api/article/list'
|
||||
const ids = collectDescendantNavIds(nav.navigationId, allNavigations.value || [])
|
||||
const load = async (categoryIds?: string): Promise<ContentItem[]> => {
|
||||
try {
|
||||
const res = await $fetch<ApiEnvelope<PageResult<ContentItem>> | PageResult<ContentItem>>(api, {
|
||||
query: { categoryIds, page: 1, limit }
|
||||
})
|
||||
return ((res as ApiEnvelope<PageResult<ContentItem>>)?.data?.list
|
||||
?? (res as PageResult<ContentItem>)?.list
|
||||
?? []) as ContentItem[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
const scoped = ids.length ? ids.join(',') : undefined
|
||||
const list = await load(scoped)
|
||||
if (list.length || !scoped) return list
|
||||
return load(undefined)
|
||||
}
|
||||
|
||||
const [products, cases, articles] = await Promise.all([
|
||||
fetchList(productNav.value, 12),
|
||||
fetchList(caseNav.value, 8),
|
||||
fetchList(newsNav.value, 6)
|
||||
])
|
||||
|
||||
// 产品分类 Tab
|
||||
const productTabs = computed<CmsNavigation[]>(() => {
|
||||
const children = productNav.value?.children || []
|
||||
if (children.length) return children.slice(0, 6)
|
||||
return productNav.value ? [productNav.value] : []
|
||||
})
|
||||
|
||||
const activeProductTab = ref<number | undefined>(productTabs.value[0]?.navigationId)
|
||||
|
||||
const displayProducts = computed<Product[]>(() => {
|
||||
if (!activeProductTab.value) return products.slice(0, 8) as Product[]
|
||||
const ids = collectDescendantNavIds(activeProductTab.value, allNavigations.value || [])
|
||||
const filterIds = ids.length ? ids : [activeProductTab.value]
|
||||
return (products as Product[]).filter((p: Product) => {
|
||||
const item = p as Record<string, unknown>
|
||||
const navId = (item.navigationId || item.categoryId) as number | undefined
|
||||
return navId ? filterIds.includes(navId) : false
|
||||
}).slice(0, 8)
|
||||
})
|
||||
|
||||
watch(productTabs, () => {
|
||||
activeProductTab.value = productTabs.value[0]?.navigationId
|
||||
}, { immediate: true })
|
||||
|
||||
const caseList = computed<CaseItem[]>(() => (cases as CaseItem[]).slice(0, 4))
|
||||
const newsList = computed<Article[]>(() => (articles as Article[]).slice(0, 6))
|
||||
|
||||
const companyPosition = computed(() => {
|
||||
const cfg = siteInfo.value?.config as Record<string, unknown> | undefined
|
||||
return cfg?.position as string | undefined
|
||||
})
|
||||
const companyGoal = computed(() => {
|
||||
const cfg = siteInfo.value?.config as Record<string, unknown> | undefined
|
||||
return cfg?.goal as string | undefined
|
||||
})
|
||||
const whyChooseText = computed(() => {
|
||||
const cfg = siteInfo.value?.config as Record<string, unknown> | undefined
|
||||
return cfg?.whyChoose as string | undefined
|
||||
})
|
||||
const aboutImage = computed(() => {
|
||||
const cfg = siteInfo.value?.config as Record<string, unknown> | undefined
|
||||
const raw = cfg?.aboutImage as string | undefined
|
||||
return raw || '/images/template-10/about-building.jpg'
|
||||
})
|
||||
|
||||
const advantages = [
|
||||
{ num: '04', title: '灵活供货,交期可控', desc: '多仓备货与柔性产能,快速响应交付需求。' },
|
||||
{ num: '05', title: '全程技术贴身服务', desc: '从选型到量产全链路技术支持,快速响应。' },
|
||||
{ num: '01', title: '全系主流品类覆盖', desc: 'DDR3/DDR4/LPDDR/eMMC 全系列存储产品。' },
|
||||
{ num: '02', title: '平台高兼容,落地零门槛', desc: '主流平台完整验证,缩短客户开发周期。' },
|
||||
{ num: '03', title: '严选品质,可稳定量产', desc: '严格测试与制程管控,保障批量一致性。' }
|
||||
]
|
||||
|
||||
const fallbackSolutions = [
|
||||
{ title: '工业控制设备', desc: '工控主板、工业网关、PLC 设备,主打宽温稳定、7×24 小时连续运行。', image: '/images/template-10/solution-industrial.jpg' },
|
||||
{ title: '智能物联网终端', desc: '智能家电、网关、安防摄像头、可穿戴等轻量化物联网设备方案。', image: '/images/template-10/solution-iot.jpg' },
|
||||
{ title: '安防监控设备', desc: '支持高清录像、大容量存储、长时间录像回放,保障数据安全可靠。', image: '/images/template-10/solution-security.jpg' },
|
||||
{ title: '消费&车载智能设备', desc: '车载中控、行车记录仪、智能电视、机顶盒等稳定存储方案。', image: '/images/template-10/solution-vehicle.jpg' }
|
||||
]
|
||||
|
||||
const FALLBACK_IMAGE = '/images/template-10/news-default.jpg'
|
||||
|
||||
function itemImage(item: ContentItem): string {
|
||||
const anyItem = item as Record<string, unknown>
|
||||
const raw = (anyItem.image || anyItem.cover || anyItem.photo
|
||||
|| (Array.isArray(anyItem.images) ? (anyItem.images as string[])[0] : '')) as string
|
||||
if (!raw) return FALLBACK_IMAGE
|
||||
return getCompressedImageUrl(raw, { width: 800 })
|
||||
}
|
||||
|
||||
function onImgError(e: Event) {
|
||||
const el = e.target as HTMLImageElement
|
||||
if (el && !el.src.endsWith(FALLBACK_IMAGE)) el.src = FALLBACK_IMAGE
|
||||
}
|
||||
|
||||
function onAboutImgError(e: Event) {
|
||||
const el = e.target as HTMLImageElement
|
||||
el.src = '/images/template-10/about-building.jpg'
|
||||
}
|
||||
|
||||
function summaryOf(item: ContentItem): string {
|
||||
const anyItem = item as Record<string, unknown>
|
||||
const s = (anyItem.summary || anyItem.description || anyItem.content || '') as string
|
||||
return stripHtml(s).slice(0, 120)
|
||||
}
|
||||
|
||||
function detailLink(model: string | undefined, item: ContentItem): string {
|
||||
const anyItem = item as Record<string, unknown>
|
||||
const id = anyItem.id || anyItem.articleId || anyItem.productId
|
||||
if (model === 'product') return `/product/${id}`
|
||||
if (model === 'case') return `/case/${id}`
|
||||
return `/article/${id}`
|
||||
}
|
||||
|
||||
function formatDate(date?: unknown) {
|
||||
if (!date) return ''
|
||||
return dayjs(String(date)).format('YYYY-MM-DD')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[var(--t10-bg-soft)] pb-16">
|
||||
<!-- Banner -->
|
||||
<section
|
||||
class="t10-page-banner"
|
||||
style="background-image: url('/images/template-10/news-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="newsNav?.path || '/article'">{{ newsNav?.title || '企业资讯' }}</NuxtLink>
|
||||
<span class="mx-2">/</span>
|
||||
<span class="text-white">{{ article?.title || '资讯详情' }}</span>
|
||||
</nav>
|
||||
<h1 class="text-3xl sm:text-4xl font-bold text-white">{{ newsNav?.title || '企业资讯' }}</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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="article">
|
||||
<div class="max-w-4xl mx-auto bg-white rounded-xl border border-[var(--t10-border)] overflow-hidden">
|
||||
<img
|
||||
v-if="coverImage"
|
||||
:src="coverImage"
|
||||
:alt="article.title"
|
||||
class="w-full h-[240px] lg:h-[360px] object-cover"
|
||||
@error="onImgError"
|
||||
>
|
||||
<div class="p-6 lg:p-10">
|
||||
<h1 class="text-2xl lg:text-3xl font-bold text-[var(--t10-text-strong)] mb-4">
|
||||
{{ article.title }}
|
||||
</h1>
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm text-[var(--t10-muted-2)] mb-8 pb-6 border-b border-[var(--t10-border)]">
|
||||
<span v-if="article.publishTime || article.createTime">
|
||||
发布时间:{{ formatDate(article.publishTime || article.createTime) }}
|
||||
</span>
|
||||
<span v-if="article.author">作者:{{ article.author }}</span>
|
||||
<span v-if="article.source">来源:{{ article.source }}</span>
|
||||
</div>
|
||||
<div class="prose max-w-none">
|
||||
<RichText :content="article.content" />
|
||||
</div>
|
||||
<PageAttachments
|
||||
v-if="article.attachments?.length"
|
||||
:attachments="article.attachments"
|
||||
class="mt-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 相关资讯 -->
|
||||
<div v-if="relatedNews.length" class="mt-12">
|
||||
<h2 class="t10-section-title t10-section-title-center">相关资讯</h2>
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<NuxtLink
|
||||
v-for="item in relatedNews"
|
||||
:key="item.id ?? item.articleId"
|
||||
:to="`/article/${item.id ?? item.articleId}`"
|
||||
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-[16/10] overflow-hidden bg-[var(--t10-bg-gray)]">
|
||||
<img
|
||||
:src="itemImage(item)"
|
||||
:alt="item.title"
|
||||
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
@error="onImgError"
|
||||
>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<h3 class="font-medium text-[var(--t10-text)] t10-clamp-2 group-hover:text-[var(--t10-primary)] transition-colors">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</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 dayjs from 'dayjs'
|
||||
import type { Article, PageResult, ApiEnvelope } from '~/types'
|
||||
import { getCompressedImageUrl } from '~/utils/image'
|
||||
|
||||
const { allNavigations, fetchSiteInfo } = useSite()
|
||||
const route = useRoute()
|
||||
|
||||
await fetchSiteInfo()
|
||||
|
||||
const articleId = computed(() => route.params.id as string)
|
||||
|
||||
const { data: articleRes, pending, error } = await useFetch<{ data?: Article }>('/api/article/detail', {
|
||||
key: `article-detail-${articleId.value}`,
|
||||
query: { id: articleId.value }
|
||||
})
|
||||
|
||||
const article = computed<Article | undefined>(() => articleRes.value?.data)
|
||||
|
||||
const newsNav = computed(() => {
|
||||
const navs = allNavigations.value || []
|
||||
return navs.find((n) => n.model === 'article')
|
||||
})
|
||||
|
||||
const FALLBACK_IMAGE = '/images/template-10/news-default.jpg'
|
||||
const coverImage = computed(() => {
|
||||
const a = article.value as Record<string, unknown> | undefined
|
||||
const raw = (a?.cover || a?.image || a?.photo || (Array.isArray(a?.images) ? (a.images as string[])[0] : '')) as string
|
||||
return raw ? getCompressedImageUrl(raw, { width: 1200 }) : FALLBACK_IMAGE
|
||||
})
|
||||
function onImgError(e: Event) {
|
||||
const el = e.target as HTMLImageElement
|
||||
if (el && !el.src.endsWith(FALLBACK_IMAGE)) el.src = FALLBACK_IMAGE
|
||||
}
|
||||
function itemImage(item: Article): string {
|
||||
const anyItem = item as Record<string, unknown>
|
||||
const raw = (anyItem.image || anyItem.cover || anyItem.photo
|
||||
|| (Array.isArray(anyItem.images) ? (anyItem.images as string[])[0] : '')) as string
|
||||
return raw ? getCompressedImageUrl(raw, { width: 600 }) : FALLBACK_IMAGE
|
||||
}
|
||||
|
||||
const { data: relatedRes } = await useFetch<ApiEnvelope<PageResult<Article>> | PageResult<Article>>('/api/article/list', {
|
||||
key: `article-related-${articleId.value}`,
|
||||
query: { page: 1, limit: 3 },
|
||||
server: false
|
||||
})
|
||||
const relatedNews = computed<Article[]>(() => {
|
||||
const d = relatedRes.value as (ApiEnvelope<PageResult<Article>> | PageResult<Article>) | null
|
||||
if (!d) return []
|
||||
const list = ((d as ApiEnvelope<PageResult<Article>>)?.data?.list
|
||||
?? (d as PageResult<Article>)?.list
|
||||
?? []) as Article[]
|
||||
return list.filter((a) => String((a as Record<string, unknown>).id ?? (a as Record<string, unknown>).articleId) !== articleId.value).slice(0, 3)
|
||||
})
|
||||
|
||||
function formatDate(date?: unknown) {
|
||||
if (!date) return ''
|
||||
return dayjs(String(date)).format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[var(--t10-bg-soft)] pb-16">
|
||||
<!-- Banner -->
|
||||
<section
|
||||
class="t10-page-banner"
|
||||
style="background-image: url('/images/template-10/about-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">{{ newsNav?.title || '企业资讯' }}</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-[0.2em] uppercase">News</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 子导航 -->
|
||||
<div class="border-b border-[var(--t10-border)] bg-white">
|
||||
<div class="t10-container">
|
||||
<div class="flex flex-wrap items-center justify-between py-4 gap-4">
|
||||
<nav class="flex flex-wrap gap-6 text-sm lg:pl-[460px]">
|
||||
<NuxtLink
|
||||
v-for="link in subNavs"
|
||||
:key="link.path"
|
||||
:to="link.path"
|
||||
class="text-[var(--t10-text-secondary)] hover:text-[var(--t10-primary)] transition-colors"
|
||||
:class="{ '!text-[var(--t10-primary)] font-medium': route.path === link.path }"
|
||||
>
|
||||
{{ link.title }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
<nav class="text-xs text-[var(--t10-muted-2)]">
|
||||
<NuxtLink to="/" class="hover:text-[var(--t10-primary)]">首页</NuxtLink>
|
||||
<span class="mx-2">/</span>
|
||||
<span class="text-[var(--t10-text)]">{{ pageTitle }}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="t10-container py-10">
|
||||
<h2 class="t10-section-title t10-section-title-center">{{ pageTitle }}</h2>
|
||||
|
||||
<div v-if="pending" class="flex justify-center py-12">
|
||||
<SiteLoading />
|
||||
</div>
|
||||
<SiteError v-else-if="error" message="获取资讯列表失败" />
|
||||
|
||||
<div v-else class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<NuxtLink
|
||||
v-for="item in newsList"
|
||||
:key="item.id ?? item.articleId"
|
||||
:to="`/article/${item.id ?? item.articleId}`"
|
||||
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-[16/10] overflow-hidden bg-[var(--t10-bg-gray)]">
|
||||
<img
|
||||
:src="itemImage(item)"
|
||||
:alt="item.title"
|
||||
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
@error="onImgError"
|
||||
>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<h3 class="font-medium text-[var(--t10-text)] t10-clamp-2 group-hover:text-[var(--t10-primary)] transition-colors mb-3 min-h-[48px]">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
<p class="text-sm text-[var(--t10-muted-2)] t10-clamp-2 mb-4 min-h-[40px]">
|
||||
{{ summaryOf(item) }}
|
||||
</p>
|
||||
<span class="text-xs text-[var(--t10-muted)]">{{ formatDate(item.publishTime || item.createTime) }}</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!pending && !error && newsList.length === 0" class="text-center py-20 text-[var(--t10-muted-2)]">
|
||||
暂无资讯内容
|
||||
</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 dayjs from 'dayjs'
|
||||
import type { Article, PageResult, ApiEnvelope, CmsNavigation } from '~/types'
|
||||
import { getCompressedImageUrl } from '~/utils/image'
|
||||
import { stripHtml } from '~/utils'
|
||||
|
||||
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 currentPage = ref(1)
|
||||
const limit = 9
|
||||
|
||||
const { data, pending, error } = await useFetch<
|
||||
ApiEnvelope<PageResult<Article>> | PageResult<Article>
|
||||
>('/api/article/list', {
|
||||
key: `article-list-${navigationId.value ?? 'all'}-p${currentPage.value}`,
|
||||
query: {
|
||||
page: currentPage,
|
||||
limit,
|
||||
...(navigationId.value ? { navigationId: navigationId.value } : {})
|
||||
},
|
||||
watch: [currentPage, navigationId]
|
||||
})
|
||||
|
||||
const newsList = computed<Article[]>(() => {
|
||||
const envelope = data.value as ApiEnvelope<PageResult<Article>>
|
||||
const direct = data.value as PageResult<Article>
|
||||
return envelope?.data?.list || direct?.list || []
|
||||
})
|
||||
|
||||
const totalCount = computed(() => {
|
||||
const envelope = data.value as ApiEnvelope<PageResult<Article>>
|
||||
const direct = data.value as PageResult<Article>
|
||||
return envelope?.data?.count || envelope?.data?.total || direct?.count || direct?.total || 0
|
||||
})
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / limit)))
|
||||
|
||||
const newsNav = computed<CmsNavigation | undefined>(() => {
|
||||
const navs = allNavigations.value || []
|
||||
return navs.find((n) => n.navigationId === navigationId.value) || navs.find((n) => n.model === 'article')
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => newsNav.value?.title || '企业资讯')
|
||||
|
||||
const subNavs = [
|
||||
{ title: '关于创芯', path: '/about' },
|
||||
{ title: '新闻资讯', path: '/article' },
|
||||
{ title: '联系我们', path: '/contact' }
|
||||
]
|
||||
|
||||
const FALLBACK_IMAGE = '/images/template-10/news-default.jpg'
|
||||
function itemImage(item: Article): string {
|
||||
const anyItem = item as Record<string, unknown>
|
||||
const raw = (anyItem.image || anyItem.cover || anyItem.photo
|
||||
|| (Array.isArray(anyItem.images) ? (anyItem.images as string[])[0] : '')) as string
|
||||
return raw ? getCompressedImageUrl(raw, { width: 600 }) : FALLBACK_IMAGE
|
||||
}
|
||||
function onImgError(e: Event) {
|
||||
const el = e.target as HTMLImageElement
|
||||
if (el && !el.src.endsWith(FALLBACK_IMAGE)) el.src = FALLBACK_IMAGE
|
||||
}
|
||||
function summaryOf(item: Article): string {
|
||||
const anyItem = item as Record<string, unknown>
|
||||
const s = (anyItem.summary || anyItem.description || anyItem.content || '') as string
|
||||
return stripHtml(s).slice(0, 100)
|
||||
}
|
||||
function formatDate(date?: unknown) {
|
||||
if (!date) return ''
|
||||
return dayjs(String(date)).format('YYYY-MM-DD')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-white pb-16">
|
||||
<!-- Banner(背景优先取父级栏目 background,回退栏目自身,再回退内置图) -->
|
||||
<section
|
||||
class="t10-page-banner"
|
||||
:style="{ backgroundImage: `url('${bannerImage}')`, minHeight: '280px' }"
|
||||
>
|
||||
<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">{{ pageTitle }}</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="t10-container py-12 lg:py-16">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<!-- 单页配图 -->
|
||||
<img
|
||||
v-if="coverImage"
|
||||
:src="coverImage"
|
||||
:alt="pageTitle"
|
||||
class="w-full h-[240px] lg:h-[360px] object-cover rounded-xl mb-10"
|
||||
>
|
||||
|
||||
<div class="bg-white rounded-xl border border-[var(--t10-border)] p-6 lg:p-10 shadow-sm">
|
||||
<template v-if="hasContent">
|
||||
<div class="flex flex-wrap items-center gap-3 mb-8 pb-6 border-b border-[var(--t10-border)]">
|
||||
<span class="w-1.5 h-7 rounded-full bg-[var(--t10-primary)]" />
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-[var(--t10-text-strong)]">{{ pageTitle }}</h2>
|
||||
<span v-if="updateTimeText" class="sm:ml-auto text-sm text-[var(--t10-muted-2)]">
|
||||
更新于 {{ updateTimeText }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="prose max-w-none">
|
||||
<RichText :content="pageData?.content" />
|
||||
</div>
|
||||
<PageAttachments
|
||||
v-if="pageData?.attachments?.length"
|
||||
:attachments="pageData.attachments"
|
||||
class="mt-10"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-else class="text-center py-16">
|
||||
<svg class="w-16 h-16 mx-auto text-[var(--t10-muted-light)] mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
<p class="text-[var(--t10-text)] font-medium mb-2">{{ emptyState.title }}</p>
|
||||
<p class="text-[var(--t10-muted-2)] text-sm leading-relaxed mb-6">{{ emptyState.desc }}</p>
|
||||
<NuxtLink to="/" class="text-[var(--t10-primary)] hover:underline font-medium">返回首页</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 模板 10 - 通用单页(创芯存储科技蓝风格)
|
||||
* 用于品牌优势、服务条款等后台「单页管理」维护的页面
|
||||
*/
|
||||
import dayjs from 'dayjs'
|
||||
import type { PageDetail } from '~/types'
|
||||
|
||||
const { siteInfo, allNavigations, fetchSiteInfo } = useSite()
|
||||
const { fileUrl } = useFileUrl()
|
||||
const route = useRoute()
|
||||
|
||||
await fetchSiteInfo()
|
||||
|
||||
const slug = computed(() => (route.params.slug as string) || (route.params.id as string) || '')
|
||||
const pagePath = computed(() => slug.value || String(route.query.path || ''))
|
||||
|
||||
const { data: pageData } = await useFetch<PageDetail>('/api/page/detail', {
|
||||
key: `page-${pagePath.value || route.path}`,
|
||||
query: { path: pagePath.value || 'about' }
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => pageData.value?.title?.trim() || '页面')
|
||||
const hasContent = computed(() => Boolean(pageData.value?.hasContent && pageData.value?.content))
|
||||
const coverImage = computed(() => fileUrl(pageData.value?.photo || ''))
|
||||
|
||||
/**
|
||||
* 栏目页 Banner 背景图:优先取「父级栏目」的 background(后台上传),
|
||||
* 回退到当前栏目自身的 background,再回退到模板内置静态图。
|
||||
* 父级栏目定位:按当前单页 slug 在导航树里找到对应栏目,再取其 parentId 指向的栏目。
|
||||
*/
|
||||
const currentNav = computed(() => {
|
||||
const s = slug.value
|
||||
if (!s) return undefined
|
||||
return allNavigations.value.find((n) => n.pagePath === s)
|
||||
})
|
||||
const bannerImage = computed<string>(() => {
|
||||
const cur = currentNav.value
|
||||
const parent = cur?.parentId
|
||||
? allNavigations.value.find((n) => n.navigationId === cur.parentId)
|
||||
: undefined
|
||||
const bg = parent?.background || cur?.background || ''
|
||||
return bg ? fileUrl(bg) : '/images/template-10/advantage-banner.jpg'
|
||||
})
|
||||
|
||||
const updateTimeText = computed(() => {
|
||||
const t = pageData.value?.updateTime
|
||||
return t ? dayjs(t).format('YYYY-MM-DD') : ''
|
||||
})
|
||||
|
||||
const emptyState = computed(() => {
|
||||
if (pageData.value?.status === 'error') {
|
||||
return { title: '内容暂时无法加载', desc: '内容接口暂时不可用,请稍后再试。' }
|
||||
}
|
||||
return {
|
||||
title: '内容尚未录入',
|
||||
desc: '请前往管理后台「单页管理」补充该页面的正文内容。'
|
||||
}
|
||||
})
|
||||
|
||||
usePageSeo(
|
||||
{
|
||||
title: pageTitle.value,
|
||||
path: route.path,
|
||||
keywords: pageData.value?.keywords || undefined,
|
||||
description: pageData.value?.description || undefined,
|
||||
image: coverImage.value || undefined
|
||||
},
|
||||
siteInfo.value
|
||||
)
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user