官网开发中 0803

This commit is contained in:
eafonyang
2026-08-03 19:09:03 +08:00
parent eeccdb079c
commit f37b6451ef
36 changed files with 1906 additions and 78 deletions
+148
View File
@@ -0,0 +1,148 @@
<script setup lang="ts">
import type { SectionBlock, Product, ProductSeries } from '~/types/site';
const { t, locale } = useI18n();
const localePath = useLocalePath();
const route = useRoute();
const siteStore = useSiteStore();
const { getSections, getProducts } = useSiteApi();
const slug = String(route.params.slug || '');
// ===== 查找产品(优先用 store 数据,直接访问时主动加载) =====
const product = ref<Product | null>(null);
const series = ref<ProductSeries | null>(null);
const sections = ref<SectionBlock[]>([]);
const loading = ref(true);
const notFound = ref(false);
async function loadProduct() {
const { productSeries } = storeToRefs(siteStore);
// store 未初始化或未命中时,主动拉取一次
if (!siteStore.loaded && !siteStore.loading) {
await siteStore.init();
}
for (const s of productSeries.value || []) {
const p = (s.products || []).find((item) => item.slug === slug);
if (p) {
product.value = p;
series.value = s;
break;
}
}
// 兜底:直接调接口
if (!product.value) {
try {
const data = await getProducts();
for (const s of data?.series || []) {
const p = (s.products || []).find((item) => item.slug === slug);
if (p) {
product.value = p;
series.value = s;
break;
}
}
} catch {
// ignore
}
}
}
onMounted(async () => {
try {
await loadProduct();
if (!product.value) {
notFound.value = true;
return;
}
// SEO:产品详情页
await usePageSeo('product_detail', product.value.id, product.value.name_zh);
// 产品详情区块(page_type=product_detail + product_id
const res = await getSections('product_detail', product.value.id);
sections.value = res?.items ?? [];
} catch {
// ignore
} finally {
loading.value = false;
}
});
// ===== 同系列其他产品 =====
const siblings = computed(() =>
(series.value?.products || []).filter((p) => p.slug !== slug)
);
function productName(p: Product): string {
return locale.value === 'en' ? p.name_en : p.name_zh;
}
</script>
<template>
<div v-if="loading" class="min-h-60vh flex-col-center text-sm text-gray-400">
{{ t('common.loading') }}
</div>
<!-- 产品不存在 -->
<div v-else-if="notFound || !product" class="min-h-60vh flex-col-center px-16px text-center">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">404</h1>
<p class="mt-12px text-gray-500 dark:text-gray-400">{{ t('common.notFoundDesc') }}</p>
<NuxtLink
:to="localePath('/products')"
class="mt-24px px-24px py-10px rounded-6px bg-primary text-white text-sm hover:opacity-90 transition-opacity"
>
{{ t('nav.products') }}
</NuxtLink>
</div>
<div v-else>
<!-- 产品头部面包屑 + 名称 + 简介 -->
<section class="section-container py-48px md:py-64px flex flex-col gap-12px">
<nav class="flex items-center gap-8px text-sm text-gray-400">
<NuxtLink :to="localePath('/')" class="hover:text-primary transition-colors">{{ t('nav.home') }}</NuxtLink>
<span>/</span>
<NuxtLink :to="localePath('/products')" class="hover:text-primary transition-colors">{{ t('nav.products') }}</NuxtLink>
<span>/</span>
<span class="text-gray-600 dark:text-gray-300">{{ productName(product) }}</span>
</nav>
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mt-8px">{{ productName(product) }}</h1>
<p class="text-base md:text-lg text-gray-500 dark:text-gray-400 max-w-760px leading-relaxed">
{{ locale === 'en' ? product.intro_en : product.intro_zh }}
</p>
</section>
<!-- 详情区块 CMS Section 组合渲染 -->
<SectionRenderer v-for="section in sections" :key="section.id" :section="section" />
<!-- 空区块提示 -->
<div v-if="!sections.length" class="section-container py-64px text-center">
<p class="text-gray-400">{{ t('common.empty') }}</p>
</div>
<!-- 同系列其他产品 -->
<section v-if="siblings.length" class="border-t border-gray-100 dark:border-dark-700 bg-gray-50 dark:bg-dark-900">
<div class="section-container py-48px md:py-64px">
<h2 class="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-24px">
{{ t('common.moreInSeries') }}
</h2>
<div class="grid gap-16px sm:grid-cols-2 lg:grid-cols-3">
<NuxtLink
v-for="p in siblings"
:key="p.id"
:to="localePath(`/products/${p.slug}`)"
class="group flex items-center gap-16px p-16px rounded-12px border border-gray-100 dark:border-dark-700 bg-white dark:bg-dark-800 hover:shadow-md hover:-translate-y-2px transition-all duration-300"
>
<div class="w-64px h-48px rounded-8px bg-gray-50 dark:bg-dark-700 overflow-hidden shrink-0">
<img v-if="p.cover_url" :src="p.cover_url" :alt="productName(p)" class="w-full h-full object-cover" />
</div>
<div class="flex flex-col gap-4px min-w-0">
<span class="font-medium text-gray-900 dark:text-white group-hover:text-primary transition-colors truncate">
{{ productName(p) }}
</span>
<span class="text-xs text-gray-400 truncate">{{ locale === 'en' ? p.intro_en : p.intro_zh }}</span>
</div>
</NuxtLink>
</div>
</div>
</section>
</div>
</template>
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import type { ProductListConfig, ProductSeries } from '~/types/site';
const { t, locale } = useI18n();
const localePath = useLocalePath();
const siteStore = useSiteStore();
const { getPageConfig } = useSiteApi();
// SEO:产品列表页
await usePageSeo('products', undefined, `${t('app.name')} - ${t('nav.products')}`);
// 页面配置(hero + 列数),未配置时用默认值
const config = ref<ProductListConfig | null>(null);
const loading = ref(true);
onMounted(async () => {
try {
const [cfg] = await Promise.all([getPageConfig<ProductListConfig>('product-list')]);
config.value = cfg;
} catch {
config.value = null;
} finally {
loading.value = false;
}
});
// 产品系列(store 已在 app 启动时加载)
const { productSeries } = storeToRefs(siteStore);
const maxColumns = computed(() => Math.min(Math.max(config.value?.max_columns || 3, 1), 4));
// 有产品的系列才展示
const visibleSeries = computed(() =>
(productSeries.value || []).filter((s) => (s.products || []).length > 0)
);
const heroTitle = computed(() => (locale.value === 'en' ? config.value?.hero_title_en : config.value?.hero_title_zh) || (locale.value === 'en' ? 'Products' : '产品中心'));
const heroSubtitle = computed(() => (locale.value === 'en' ? config.value?.hero_subtitle_en : config.value?.hero_subtitle_zh) || '');
const heroOverline = computed(() => (locale.value === 'en' ? config.value?.hero_overline_en : config.value?.hero_overline_zh) || '');
function seriesName(s: ProductSeries): string {
return locale.value === 'en' ? s.name_en : s.name_zh;
}
function seriesSubtitle(s: ProductSeries): string {
return locale.value === 'en' ? s.subtitle_en : s.subtitle_zh;
}
</script>
<template>
<div>
<PageHero
:title="heroTitle"
:subtitle="heroSubtitle || undefined"
:overline="heroOverline || undefined"
:bg-url="config?.hero_bg_url || undefined"
/>
<div v-if="loading" class="py-80px text-center text-sm text-gray-400">
{{ t('common.loading') }}
</div>
<!-- 按系列分组的产品卡片 -->
<div v-else-if="visibleSeries.length" class="section-container py-64px md:py-80px flex flex-col gap-80px">
<section v-for="series in visibleSeries" :key="series.id">
<div class="mb-32px flex flex-col gap-8px">
<h2 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white">{{ seriesName(series) }}</h2>
<p v-if="seriesSubtitle(series)" class="text-gray-500 dark:text-gray-400">{{ seriesSubtitle(series) }}</p>
</div>
<div
class="grid gap-24px"
:class="{
'grid-cols-1': maxColumns === 1,
'sm:grid-cols-2': maxColumns === 2,
'sm:grid-cols-2 lg:grid-cols-3': maxColumns === 3,
'sm:grid-cols-2 lg:grid-cols-4': maxColumns === 4
}"
>
<NuxtLink
v-for="product in series.products"
:key="product.id"
:to="localePath(`/products/${product.slug}`)"
class="group flex flex-col rounded-16px border border-gray-100 dark:border-dark-700 overflow-hidden bg-white dark:bg-dark-800 hover:shadow-xl hover:-translate-y-4px transition-all duration-300"
>
<div class="aspect-[16/10] bg-gray-50 dark:bg-dark-700 overflow-hidden">
<img
v-if="product.cover_url"
:src="product.cover_url"
:alt="locale === 'en' ? product.name_en : product.name_zh"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
<div v-else class="w-full h-full flex-center text-gray-300 dark:text-gray-600 text-4xl">🎧</div>
</div>
<div class="p-20px flex flex-col gap-8px flex-1">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white group-hover:text-primary transition-colors">
{{ locale === 'en' ? product.name_en : product.name_zh }}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{{ locale === 'en' ? product.intro_en : product.intro_zh }}
</p>
</div>
</NuxtLink>
</div>
</section>
</div>
<!-- 空状态 -->
<div v-else class="section-container py-80px text-center">
<p class="text-gray-400">{{ t('common.empty') }}</p>
</div>
</div>
</template>