Files
admin/www/app/pages/products/[slug].vue
T
2026-08-03 19:09:03 +08:00

149 lines
5.4 KiB
Vue
Raw Blame History

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