feat(app): 添加多模板关于我们页面及相关路由和404页面

- 新增404页面,优化未找到页面体验,避免被搜索引擎索引
- 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理
- 实现/article、/case、/product及/page动态路由兼容列表与详情展示
- 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置
- 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持
- 模板增强支持CMS单页内容加载及SEO信息动态设置
- 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
This commit is contained in:
2026-09-08 12:13:44 +08:00
commit 2b69686795
381 changed files with 59891 additions and 0 deletions
@@ -0,0 +1,159 @@
<template>
<footer class="bg-[var(--t10-footer-bg)] text-[var(--t10-footer-text)]">
<div class="t10-container py-12 lg:py-16">
<!-- 上排Logo 联系信息 + 链接列 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-10 lg:gap-8">
<!-- 公司信息 -->
<div class="lg:col-span-2">
<SiteBrand
link-class="inline-flex items-center gap-2 mb-5"
:logo="siteLogo"
:icon="siteIcon"
:name="siteName"
logo-class="h-[44px] w-auto max-w-[220px] object-contain brightness-0 invert"
icon-class="h-8 w-auto object-contain brightness-0 invert"
name-class="text-xl font-bold text-white truncate max-w-[220px]"
>
<template #fallback-icon>
<span class="text-xl font-bold text-white">C-MVP</span>
</template>
</SiteBrand>
<ul class="space-y-3 text-sm">
<li v-if="phone" class="flex items-start gap-3">
<svg class="w-4 h-4 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
<a :href="`tel:${phone}`" class="hover:text-white transition-colors">{{ phone }}</a>
</li>
<li v-if="email" class="flex items-start gap-3">
<svg class="w-4 h-4 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<a :href="`mailto:${email}`" class="hover:text-white transition-colors break-all">{{ email }}</a>
</li>
<li v-if="siteUrl" class="flex items-start gap-3">
<svg class="w-4 h-4 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
<a :href="siteUrl" target="_blank" rel="noopener" class="hover:text-white transition-colors break-all">{{ siteUrl }}</a>
</li>
<li v-if="address" class="flex items-start gap-3">
<svg class="w-4 h-4 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>{{ address }}</span>
</li>
</ul>
</div>
<!-- 链接列 -->
<div v-for="col in linkColumns" :key="col.title">
<h3 class="text-white font-semibold mb-5">{{ col.title }}</h3>
<ul class="space-y-2.5 text-sm">
<li v-for="link in col.links" :key="link.navigationId || link.path">
<NuxtLink
:to="getNavLink(link)"
:target="link.target === '_blank' ? '_blank' : undefined"
class="hover:text-white transition-colors"
>
{{ link.title }}
</NuxtLink>
</li>
</ul>
</div>
</div>
<!-- 分隔线 -->
<div class="mt-12 pt-8 border-t border-white/15">
<div class="flex flex-col md:flex-row items-center justify-between gap-4 text-xs text-center md:text-left">
<span>{{ copyright || `Copyright © ${new Date().getFullYear()} ${siteName || ''} 版权所有` }}</span>
<div class="flex flex-wrap items-center justify-center gap-x-4 gap-y-2">
<a
v-if="icpNo"
href="https://beian.miit.gov.cn"
target="_blank"
rel="nofollow"
class="hover:text-white transition-colors"
>{{ icpNo }}</a>
<a
v-if="siteInfo?.policeNo"
:href="policeUrl"
target="_blank"
rel="nofollow"
class="hover:text-white transition-colors"
>{{ siteInfo.policeNo }}</a>
<a
rel="nofollow"
href="https://site.websoft.top"
target="_blank"
class="hover:text-white transition-colors"
>Powered by ·企业官网</a>
</div>
</div>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
/**
* 模板 10 - Footer 组件(创芯存储科技蓝风格)
* 科技蓝底 + 公司信息 + 四列链接 + 备案版权
*/
import type { CmsNavigation } from '~/types'
const {
siteInfo, siteName, siteLogo, siteIcon,
phone, email, address, copyright, icpNo,
navigations, allNavigations, fetchSiteInfo
} = useSite()
await fetchSiteInfo()
const siteUrl = computed(() => {
const url = siteInfo.value?.domain || ''
if (!url) return ''
return url.startsWith('http') ? url : `https://${url}`
})
const policeUrl = computed(() => {
const no = String(siteInfo.value?.policeNo || '')
const code = no.replace(/\D/g, '')
return code
? `https://beian.mps.gov.cn/#/query/webSearch?code=${code}`
: 'https://beian.mps.gov.cn/'
})
/** 按模型或标题智能分组链接 */
const linkColumns = computed(() => {
const navs = navigations.value || []
const all = allNavigations.value || []
const cols: { title: string, links: CmsNavigation[] }[] = []
const findByModel = (model: string) => all.filter((n) => n.model === model && (n.parentId || 0) === 0).slice(0, 6)
const findByKeyword = (...keywords: string[]) => {
return navs.filter((n) => keywords.some((k) => n.title.includes(k))).slice(0, 6)
}
const products = findByModel('product')
if (products.length) cols.push({ title: products[0]?.title || '产品中心', links: products[0]?.children?.slice(0, 6) || [products[0]].filter(Boolean) })
const cases = findByModel('case')
if (cases.length) cols.push({ title: cases[0]?.title || '行业解决方案', links: cases[0]?.children?.slice(0, 6) || [cases[0]].filter(Boolean) })
const aboutLinks = findByKeyword('关于', '资讯', '联系', '加入')
if (aboutLinks.length) cols.push({ title: '关于我们', links: aboutLinks })
const partnerLinks = findByKeyword('合作', '伙伴', '服务')
if (partnerLinks.length) cols.push({ title: '合作伙伴', links: partnerLinks })
// 兜底:用底部导航补足列数
if (cols.length < 3) {
const bottom = (navs || []).filter((n) => n.model !== 'index').slice(0, 6)
if (bottom.length) cols.push({ title: '快速链接', links: bottom })
}
return cols
})
</script>
@@ -0,0 +1,147 @@
<template>
<header class="sticky top-0 z-50 bg-white shadow-sm">
<div class="t10-container flex items-center h-[72px]">
<!-- Logo -->
<SiteBrand
link-class="flex-shrink-0 mr-8 lg:mr-12"
:logo="siteLogo"
:icon="siteIcon"
:name="siteName"
logo-class="h-[44px] w-auto max-w-[220px] object-contain"
icon-class="h-8 w-auto object-contain"
name-class="text-xl font-bold text-[var(--t10-primary)] truncate max-w-[220px]"
>
<template #fallback-icon>
<span class="text-xl font-bold text-[var(--t10-primary)]">C-MVP</span>
</template>
</SiteBrand>
<!-- 桌面端导航 -->
<nav class="hidden lg:flex items-center h-full flex-1 min-w-0">
<template v-for="item in navItems" :key="item.navigationId || item.path">
<!-- 有子菜单 -->
<div v-if="item.children && item.children.length > 0" class="relative group h-full flex items-center">
<button
class="h-full px-4 text-[15px] text-[var(--t10-text)] hover:text-[var(--t10-primary)] transition-colors flex items-center gap-1 whitespace-nowrap"
>
{{ item.title }}
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div class="absolute left-0 top-full opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all">
<div class="bg-white shadow-lg border border-[var(--t10-border)] py-2 min-w-[164px]">
<NuxtLink
v-for="child in item.children"
:key="child.navigationId"
:to="getChildPath(child)"
class="block px-4 py-2 text-[15px] text-[var(--t10-text-secondary)] hover:text-[var(--t10-primary)] hover:bg-[var(--t10-primary-soft)] transition-colors whitespace-nowrap"
>
{{ child.title }}
</NuxtLink>
</div>
</div>
</div>
<!-- 无子菜单 -->
<NuxtLink
v-else
:to="getNavLink(item)"
:target="item.target === '_blank' ? '_blank' : undefined"
class="h-full px-4 flex items-center text-[15px] text-[var(--t10-text)] hover:text-[var(--t10-primary)] transition-colors whitespace-nowrap relative"
active-class="!text-[var(--t10-primary)] after:absolute after:left-4 after:right-4 after:bottom-0 after:h-[2px] after:bg-[var(--t10-primary)]"
>
{{ item.title }}
</NuxtLink>
</template>
</nav>
<!-- 右侧语言 + 搜索 -->
<div class="hidden lg:flex items-center gap-4 flex-shrink-0 ml-4">
<button class="text-sm text-[var(--t10-text-secondary)] hover:text-[var(--t10-primary)] transition-colors border border-[var(--t10-border)] rounded-full px-3 py-1">
CN
</button>
<SiteSearchBox
v-if="showHeaderSearch"
variant="light"
accent="var(--t10-primary)"
placeholder="搜索"
class="flex items-center"
/>
</div>
<!-- 移动端菜单按钮 -->
<button
class="lg:hidden ml-auto p-2 text-[var(--t10-text)]"
aria-label="菜单"
@click="mobileMenuOpen = !mobileMenuOpen"
>
<svg v-if="!mobileMenuOpen" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</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="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- 移动端菜单 -->
<div v-if="mobileMenuOpen" class="lg:hidden bg-white border-t border-[var(--t10-border)]">
<div class="t10-container py-4 space-y-1">
<SiteSearchBox
v-if="showHeaderSearch"
variant="light"
accent="var(--t10-primary)"
placeholder="搜索"
class="mb-3"
/>
<template v-for="item in navItems" :key="item.navigationId || item.path">
<div v-if="item.children && item.children.length > 0">
<div class="px-4 py-3 text-base font-semibold text-[var(--t10-primary)]">
{{ item.title }}
</div>
<NuxtLink
v-for="child in item.children"
:key="child.navigationId"
:to="getChildPath(child)"
class="block px-8 py-2 text-sm text-[var(--t10-text-secondary)] hover:text-[var(--t10-primary)] hover:bg-[var(--t10-primary-soft)] transition-colors"
@click="mobileMenuOpen = false"
>
{{ child.title }}
</NuxtLink>
</div>
<NuxtLink
v-else
:to="getNavLink(item)"
:target="item.target === '_blank' ? '_blank' : undefined"
class="block px-4 py-3 text-base text-[var(--t10-text)] hover:text-[var(--t10-primary)] hover:bg-[var(--t10-primary-soft)] transition-colors"
active-class="text-[var(--t10-primary)] bg-[var(--t10-primary-soft)]"
@click="mobileMenuOpen = false"
>
{{ item.title }}
</NuxtLink>
</template>
</div>
</div>
</header>
</template>
<script setup lang="ts">
/**
* 模板 10 - Header 组件(创芯存储科技蓝风格)
* 白底 72px,左 Logo + 水平菜单 + 右侧语言切换与搜索
*/
import type { CmsNavigation } from '~/types'
const { siteName, siteLogo, siteIcon, navigations, showHeaderSearch, fetchSiteInfo } = useSite()
const mobileMenuOpen = ref(false)
function getChildPath(child: CmsNavigation): string {
return getNavLink(child)
}
await fetchSiteInfo()
const navItems = computed<CmsNavigation[]>(() => {
return navigations.value || []
})
</script>
@@ -0,0 +1,115 @@
<template>
<section class="relative overflow-hidden bg-[#050B1F]">
<!-- 背景图 -->
<div class="absolute inset-0">
<img
v-if="bannerMode && banners.length"
:src="bannerImg(banners[currentBannerIndex])"
:alt="banners[currentBannerIndex]?.title || siteName"
class="w-full h-full object-cover"
>
<img
v-else
src="/images/template-10/hero-bg.jpg"
alt=""
class="w-full h-full object-cover"
>
<div class="absolute inset-0 bg-gradient-to-r from-[#050B1F]/85 via-[#050B1F]/50 to-transparent" />
</div>
<!-- 内容 -->
<div class="t10-container relative z-10 py-20 lg:py-28">
<div class="max-w-2xl">
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold text-white leading-tight mb-5">
{{ heroTitle }}
</h1>
<p class="text-base lg:text-lg text-white/85 leading-relaxed mb-8 max-w-xl">
{{ heroSubtitle }}
</p>
<div class="flex flex-wrap gap-4">
<NuxtLink v-if="productNav" :to="getNavLink(productNav)" class="t10-btn-primary">
了解产品
</NuxtLink>
<NuxtLink v-if="contactNav" :to="getNavLink(contactNav)" class="t10-btn-outline border-white text-white hover:bg-white hover:text-[var(--t10-primary)]">
联系我们
</NuxtLink>
</div>
</div>
</div>
<!-- 指示点 -->
<div v-if="bannerMode && banners.length > 1" class="absolute bottom-5 left-0 right-0 flex items-center justify-center gap-2 z-20">
<button
v-for="(b, i) in banners"
:key="'dot-' + (b.id || i)"
type="button"
:aria-label="'切换到第 ' + (i + 1) + ' '"
class="w-2.5 h-2.5 rounded-full transition"
:class="i === currentBannerIndex ? 'bg-white' : 'bg-white/40 hover:bg-white/70'"
@click="goTo(i)"
/>
</div>
</section>
</template>
<script setup lang="ts">
/**
* 模板 10 - Hero 主视觉区(创芯存储科技蓝风格)
* 全宽科技背景 + 左对齐大标题 + CTA 按钮
*/
import type { CmsBanner, CmsNavigation, ApiEnvelope, PageResult } from '~/types'
import { ensureFullUrl } from '~/utils/image'
const { siteInfo, siteName, navigations, fetchSiteInfo } = useSite()
await fetchSiteInfo()
const { data: bannerRes } = await useFetch<ApiEnvelope<PageResult<CmsBanner>> | PageResult<CmsBanner>>('/api/banner', {
query: { position: 'home_slider' }
})
const banners = computed<CmsBanner[]>(() => {
const d = bannerRes.value as (ApiEnvelope<PageResult<CmsBanner>> | PageResult<CmsBanner>) | null
if (!d) return []
const list = ((d as ApiEnvelope<PageResult<CmsBanner>>)?.data?.list
?? (d as PageResult<CmsBanner>)?.list
?? []) as CmsBanner[]
return list
.filter((b) => hasImage(b) && b.deleted !== 1 && b.status !== 2)
.sort((a, b) => (a.sortNum ?? 0) - (b.sortNum ?? 0))
})
const bannerMode = computed(() => banners.value.length > 0)
const currentBannerIndex = ref(0)
let bannerTimer: ReturnType<typeof setInterval> | null = null
function startAuto() {
if (banners.value.length > 1) {
bannerTimer = setInterval(() => {
currentBannerIndex.value = (currentBannerIndex.value + 1) % banners.value.length
}, 5000)
}
}
function stopAuto() {
if (bannerTimer) { clearInterval(bannerTimer); bannerTimer = null }
}
onMounted(() => { startAuto() })
onBeforeUnmount(() => { stopAuto() })
function goTo(i: number) {
currentBannerIndex.value = i
}
function bannerImg(b: CmsBanner): string {
const raw = ensureFullUrl(b.image || b.imageUrl || b.pic || b.url || '')
return raw.replace(/resize,w_\d+/, 'resize,w_1600')
}
function hasImage(b: CmsBanner): boolean {
return !!(b.image || b.imageUrl || b.pic || b.url)
}
const heroTitle = computed(() => siteInfo.value?.slogan || '专注嵌入式存储解决方案')
const heroSubtitle = computed(() => siteInfo.value?.comments || '为工控、物联网、消费电子、安防设备提供稳定可靠的存储配套,助力智能终端高效运行。')
const productNav = computed<CmsNavigation | undefined>(() => (navigations.value || []).find((n) => n.model === 'product'))
const contactNav = computed<CmsNavigation | undefined>(() => (navigations.value || []).find((n) => n.model === 'contact' || n.title.includes('联系')))
</script>
+19
View File
@@ -0,0 +1,19 @@
import type { TemplateConfig } from '~/composables/useTemplate'
/**
* 模板 10 配置
* 创芯存储科技蓝风格,面向半导体、电子元器件、存储芯片、工业控制类企业官网
* 特征:全宽科技 Banner、产品中心 Tab、核心优势数字卡片、行业解决方案交替布局、深蓝页脚
*/
export default {
id: 'template-10',
name: '创芯存储科技蓝模板',
description: '科技蓝专业风格,适合半导体、电子元器件、存储芯片、工业控制类企业官网,含产品中心、解决方案、品牌优势、新闻资讯等模块',
preview: '/templates/cloud-website/template-10.png',
supportedModules: ['home', 'products', 'cases', 'news', 'contact', 'page'],
themeConfig: {
primaryColor: '#0052D9',
secondaryColor: '#3370FF',
fontFamily: '"Noto Sans SC", "Source Han Sans SC", "思源黑体", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif'
}
} satisfies TemplateConfig
+227
View File
@@ -0,0 +1,227 @@
<template>
<div class="bg-white">
<!-- BannerCMS 内页横幅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>
+139
View File
@@ -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>
+396
View File
@@ -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>
+131
View File
@@ -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>
+219
View File
@@ -0,0 +1,219 @@
/* 模板 10 主题变量(创芯存储科技蓝风格)
通过 :root 全局定义,确保组件内 var(--t10-*) 始终有值,
不依赖 data-template-id 作用域(兼容 SSR 布局注入时机) */
:root {
--t10-primary: #0052D9;
--t10-primary-dark: #003BB5;
--t10-primary-light: #3370FF;
--t10-primary-soft: #EAF2FF;
--t10-primary-faint: #F5F9FF;
--t10-bg: #ffffff;
--t10-bg-soft: #F7F8FA;
--t10-bg-gray: #F2F4F7;
--t10-border: #E5E7EB;
--t10-border-2: #D1D5DB;
--t10-text: #1F2937;
--t10-text-strong: #111827;
--t10-text-secondary: #4B5563;
--t10-muted: #9CA3AF;
--t10-muted-light: #D1D5DB;
--t10-muted-2: #6B7280;
--t10-success: #10B981;
--t10-danger: #EF4444;
/* 页脚科技蓝背景 */
--t10-footer-bg: #0047BA;
--t10-footer-text: rgba(255, 255, 255, 0.75);
/* 固定内容区宽度 */
--t10-container: 1200px;
}
[data-template-id="template-10"] {
--t10-primary: #0052D9;
--t10-primary-dark: #003BB5;
--t10-primary-light: #3370FF;
--t10-primary-soft: #EAF2FF;
--t10-primary-faint: #F5F9FF;
--t10-bg: #ffffff;
--t10-bg-soft: #F7F8FA;
--t10-bg-gray: #F2F4F7;
--t10-border: #E5E7EB;
--t10-border-2: #D1D5DB;
--t10-text: #1F2937;
--t10-text-strong: #111827;
--t10-text-secondary: #4B5563;
--t10-muted: #9CA3AF;
--t10-muted-light: #D1D5DB;
--t10-muted-2: #6B7280;
--t10-success: #10B981;
--t10-danger: #EF4444;
--t10-footer-bg: #0047BA;
--t10-footer-text: rgba(255, 255, 255, 0.75);
--t10-container: 1200px;
}
/* 模板 10 固定宽度容器 */
.t10-container {
width: 100%;
max-width: var(--t10-container);
margin-left: auto;
margin-right: auto;
padding-left: 16px;
padding-right: 16px;
}
/* 栏目大标题:居左或居中 + 下方短横线 */
.t10-section-title {
position: relative;
font-size: 28px;
line-height: 1.4;
font-weight: 700;
color: var(--t10-text-strong);
padding-bottom: 16px;
margin-bottom: 32px;
}
.t10-section-title::after {
content: '';
display: block;
width: 48px;
height: 4px;
margin-top: 14px;
background-color: var(--t10-primary);
border-radius: 2px;
}
.t10-section-title.t10-section-title-center {
text-align: center;
}
.t10-section-title.t10-section-title-center::after {
margin-left: auto;
margin-right: auto;
}
.t10-section-subtitle {
font-size: 14px;
color: var(--t10-muted-2);
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 8px;
}
/* 单行/多行省略 */
.t10-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.t10-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.t10-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 蓝色药丸按钮 */
.t10-btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 28px;
background-color: var(--t10-primary);
color: #ffffff;
font-size: 14px;
font-weight: 500;
border-radius: 9999px;
transition: background-color 0.2s ease;
}
.t10-btn-primary:hover {
background-color: var(--t10-primary-dark);
}
.t10-btn-outline {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 9px 26px;
border: 1px solid var(--t10-primary);
color: var(--t10-primary);
font-size: 14px;
font-weight: 500;
border-radius: 9999px;
transition: all 0.2s ease;
}
.t10-btn-outline:hover {
background-color: var(--t10-primary);
color: #ffffff;
}
/* 通用页面顶部 Banner(非首页) */
.t10-page-banner {
position: relative;
overflow: hidden;
min-height: 280px;
display: flex;
align-items: center;
background-size: cover;
background-position: center;
}
.t10-page-banner::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(0, 71, 186, 0.85) 0%, rgba(0, 71, 186, 0.55) 60%, rgba(0, 82, 217, 0.25) 100%);
}
.t10-page-banner .t10-banner-inner {
position: relative;
z-index: 10;
}
/* 标签页导航 */
.t10-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
border-bottom: 1px solid var(--t10-border);
}
.t10-tab {
padding: 12px 20px;
font-size: 15px;
color: var(--t10-text-secondary);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: all 0.2s ease;
white-space: nowrap;
}
.t10-tab:hover {
color: var(--t10-primary);
}
.t10-tab.t10-tab-active {
color: var(--t10-primary);
border-bottom-color: var(--t10-primary);
font-weight: 500;
}
/* 面包屑 */
.t10-breadcrumb {
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
}
.t10-breadcrumb a:hover {
color: #ffffff;
}
/* 滚动条 */
.t10-scroll-x {
scrollbar-width: thin;
scrollbar-color: var(--t10-primary-light) transparent;
}
.t10-scroll-x::-webkit-scrollbar {
height: 6px;
}
.t10-scroll-x::-webkit-scrollbar-track {
background: transparent;
}
.t10-scroll-x::-webkit-scrollbar-thumb {
background-color: var(--t10-primary-light);
border-radius: 3px;
}