init
This commit is contained in:
145
app/[locale]/line/[slug]/page.tsx
Normal file
145
app/[locale]/line/[slug]/page.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { LOCALES } from "@/i18n/routing";
|
||||
import { getLine, getLines } from "@/lib/lines";
|
||||
import { constructMetadata } from "@/lib/metadata";
|
||||
import { Line } from "@/types/line";
|
||||
import { Metadata } from "next";
|
||||
import { Locale } from "next-intl";
|
||||
|
||||
// 强制静态渲染
|
||||
export const dynamic = "force-static";
|
||||
|
||||
// 固定 Params 类型为普通对象(Next.js 原生传参无异步)
|
||||
type Params = {
|
||||
locale: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type MetadataProps = {
|
||||
params: Params;
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: MetadataProps): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
const line = await getLine(locale, slug);
|
||||
|
||||
if (!line) {
|
||||
return constructMetadata({
|
||||
title: "404 - 产线不存在",
|
||||
description: "请求的产线页面未找到",
|
||||
noIndex: true,
|
||||
locale: locale as Locale,
|
||||
path: `/line/${slug}`,
|
||||
canonicalUrl: `/line/${slug}`,
|
||||
});
|
||||
}
|
||||
|
||||
return constructMetadata({
|
||||
title: line.title,
|
||||
description: line.desc,
|
||||
locale: locale as Locale,
|
||||
path: `/line/${slug}`,
|
||||
canonicalUrl: `/line/${slug}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 页面主组件 - 仅保留字段展示+修复 Hydration 错误
|
||||
export default async function ProductDetailPage({ params }: { params: Params }) {
|
||||
// 🔴 修复:params 是同步对象,移除不必要的 await
|
||||
const { locale, slug } = await params;
|
||||
const line = await getLine(locale, slug);
|
||||
|
||||
if (!line) return <div className="p-6">404 - 产线不存在</div>;
|
||||
|
||||
return (
|
||||
<div className="product-detail max-w-5xl mx-auto p-6 md:p-8">
|
||||
{/* 标题 */}
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-gray-800 mb-4">
|
||||
{line.title}
|
||||
</h1>
|
||||
|
||||
{/* 产品描述 */}
|
||||
<p className="text-gray-700 text-lg mb-8 leading-relaxed">
|
||||
{line.desc}
|
||||
</p>
|
||||
|
||||
{/* 封面图 */}
|
||||
{line.cover && (
|
||||
<div className="mb-8 rounded-lg overflow-hidden shadow-md">
|
||||
<img
|
||||
src={line.cover}
|
||||
alt={`${line.title} - 封面`}
|
||||
className="w-full h-auto object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 产品图片列表 */}
|
||||
{line.images && line.images.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl md:text-3xl font-semibold text-gray-800 mb-4">
|
||||
产品图片
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6">
|
||||
{line.images.map((imgUrl, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg overflow-hidden shadow-sm"
|
||||
>
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt={`${line.title} - 图片${index + 1}`}
|
||||
className="w-full h-auto object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 产品特性/参数 */}
|
||||
{line.properties && line.properties.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h2 className="text-2xl md:text-3xl font-semibold text-gray-800 mb-4">
|
||||
产品参数
|
||||
</h2>
|
||||
<ul className="space-y-3">
|
||||
{line.properties.map((prop, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="p-4 bg-gray-50 rounded-lg text-gray-700"
|
||||
>
|
||||
{/* 处理参数中的换行符 \n */}
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: prop.replace(/\n/g, "<br />"),
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const defaultLocale = LOCALES[0];
|
||||
const workShops: Line[] = await getLines(defaultLocale);
|
||||
|
||||
return LOCALES.flatMap((locale) =>
|
||||
workShops.map((workShop) => ({
|
||||
locale,
|
||||
slug: workShop.slug as string,
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("生成产品静态参数失败:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
118
app/[locale]/line/page.tsx
Normal file
118
app/[locale]/line/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Link as I18nLink, LOCALES } from "@/i18n/routing";
|
||||
import { getLines } from "@/lib/lines";
|
||||
import { constructMetadata } from "@/lib/metadata";
|
||||
import { Line } from "@/types/line";
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
// 强制静态生成
|
||||
export const dynamic = "force-static";
|
||||
|
||||
// 明确 Params 类型(静态生成的参数)
|
||||
type Params = { locale: string };
|
||||
|
||||
type MetadataProps = {
|
||||
params: Params;
|
||||
};
|
||||
|
||||
// 生成页面元数据(确保服务端/客户端翻译一致)
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: MetadataProps): Promise<Metadata> {
|
||||
const { locale } = params;
|
||||
const t = await getTranslations({ locale, namespace: "Workshop" });
|
||||
|
||||
return constructMetadata({
|
||||
page: "Workshop",
|
||||
title: t("pageTitle", { defaultValue: "车间展示" }), // 使用 next-intl 官方的 defaultValue
|
||||
description: t("pageDesc", { defaultValue: "公司产品线展示" }),
|
||||
locale: locale,
|
||||
path: `/line`,
|
||||
canonicalUrl: `/line`,
|
||||
});
|
||||
}
|
||||
|
||||
// 生成静态路由参数(多语言)
|
||||
export async function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({
|
||||
locale,
|
||||
}));
|
||||
}
|
||||
|
||||
// 空数据组件(纯静态,无动态属性)
|
||||
function EmptyState() {
|
||||
return <div className="py-10 text-center text-gray-500">暂无产品线数据</div>;
|
||||
}
|
||||
|
||||
// 单个车间卡片组件(Client Component 标记,避免 Hydration 冲突)
|
||||
// 'use client'; // 仅当需要添加交互时启用,当前纯展示可不用
|
||||
|
||||
function ProductCard({ product }: { product: Line }) {
|
||||
// 为 Image 组件添加默认值,避免属性缺失导致不匹配
|
||||
const coverSrc = product.cover || "/default-workshop-cover.png"; // 兜底封面图
|
||||
const coverAlt = product.title || "产品线封面";
|
||||
|
||||
return (
|
||||
<I18nLink
|
||||
href={`/line/${product.slug}`}
|
||||
locale={product.locale}
|
||||
>
|
||||
<div className="border rounded-lg overflow-hidden shadow-sm hover:shadow-md transition-shadow">
|
||||
{/* 封面图:优化属性,确保服务端/客户端渲染一致 */}
|
||||
<div className="relative h-64 w-full">
|
||||
<img
|
||||
src={coverSrc}
|
||||
alt={coverAlt}
|
||||
sizes="100vw" // 简化 sizes,避免解析差异
|
||||
className="object-cover"
|
||||
// 显式设置属性,避免隐式差异
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 车间信息 */}
|
||||
<div className="p-4">
|
||||
<h2 className="text-xl font-bold mb-2">{product.title || "未命名产品线"}</h2>
|
||||
<p className="text-gray-600 mb-4">{product.desc || "暂无描述"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</I18nLink>
|
||||
);
|
||||
}
|
||||
|
||||
// 页面主组件(Server Component)
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Params;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
// 获取翻译(确保服务端/客户端一致)
|
||||
const t = await getTranslations({ locale, namespace: "Workshop" });
|
||||
|
||||
// 获取产品线数据(顶层 await,Server Component 原生支持)
|
||||
const products: Line[] = await getLines(locale);
|
||||
|
||||
// 页面标题翻译兜底
|
||||
const pageTitle = t("pageTitle", { defaultValue: "产品线展示" });
|
||||
|
||||
return (
|
||||
<main className="container mx-auto py-8 px-4">
|
||||
{/* 页面标题(纯静态渲染,无动态属性) */}
|
||||
<h1 className="text-3xl font-bold mb-8 text-center">{pageTitle}</h1>
|
||||
|
||||
{/* 移除不必要的 Suspense:Server Component 顶层 await 无需 Suspense */}
|
||||
{products.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{products.map((product) => {
|
||||
// 用稳定的 key(优先用唯一标识,如 id;无 id 则用 title+locale 避免 index 导致的问题)
|
||||
const stableKey = `${product.title}-${product.locale}`;
|
||||
return <ProductCard key={stableKey} product={product} />;
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user