v0.10.27: /product/$slug 产品单模板一比一(模板2741/29972 补录、Woo 图廊/meta/tabs/相关产品组件、page_css 130 篇、动态按钮落地)+ css-translate 共享模块

This commit is contained in:
bigcode
2026-11-03 05:18:10 +08:00
parent d016be03ae
commit 35d154870a
9 changed files with 472 additions and 277 deletions
+198 -6
View File
@@ -10,6 +10,7 @@ import { getLatestPosts, getLatestProducts, type LatestItem, type PostCardItem }
import TickerBar from "~/components/website/TickerBar";
import { Cf7Form604 } from "~/components/website/Cf7Form604";
import { localSiteUrl } from "~/utils/media";
import { ProductCard, type ProductCardData } from "~/components/website/ProductCard";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
@@ -270,6 +271,27 @@ function normalizeDoc(doc: ElDocument): ElElement[] {
/** # 成员变量 posts widget 文章数据上下文(loader 预取,经 widget id 分发) **/
const PostsDataContext = createContext<Record<string, PostCardItem[]>>({});
/** # 类型 Woo 产品单页上下文(模板 2741 的 Woo widget 数据) */
export type WooProductContext = {
/** # 成员变量 产品标题/SKU/短描述(HTML) **/
title: string;
sku: string;
shortDescription: string;
/** # 成员变量 分类(meta Category 链接) **/
categories: { name: string; slug: string }[];
/** # 成员变量 图廊:主图 + 缩略图(已解析 WP 尺寸变体) **/
mainImage: { url: string; alt?: string };
thumbs: { url: string; alt?: string }[];
/** # 成员变量 属性表(Additional information) **/
attributes: { attr: string; value: string }[];
/** # 成员变量 产品自身 elementor 文档(Description 标签页内容)与 pageCss **/
productDoc?: ElDocument;
productPageCss?: string;
/** # 成员变量 相关产品(woocommerce-products 卡片数据) **/
related: ProductCardData[];
};
const WooContext = createContext<WooProductContext | null>(null);
/** # 成员变量 WordPress 短代码渲染上下文(widget id → 预渲染节点;页脚 menu-2/gtranslate) **/
const ShortcodesContext = createContext<Record<string, ReactNode>>({});
@@ -292,11 +314,14 @@ export function ElementorRenderer({
shortcodes,
vars,
pageCss,
woo,
}: {
doc: ElDocument;
postsData?: Record<string, PostCardItem[]>;
shortcodes?: Record<string, ReactNode>;
vars?: RendererVars;
/** # 成员变量 Woo 产品单页上下文(产品模板 2741 渲染时传入) **/
woo?: WooProductContext | null;
/** # 成员变量 页面级生成 CSS(原站 post-<id>.css 翻译成 data-el 选择器,scripts/import-page-css.ts 导入) **/
pageCss?: string;
}) {
@@ -307,12 +332,14 @@ export function ElementorRenderer({
<VarsContext.Provider value={vars || {}}>
<PostsDataContext.Provider value={postsData || {}}>
<ShortcodesContext.Provider value={shortcodes || {}}>
<div className="content-html">
{css ? <style dangerouslySetInnerHTML={{ __html: css }} /> : null}
{elements.map((el, i) => (
<ElementNode key={el.id || i} el={el} />
))}
</div>
<WooContext.Provider value={woo || null}>
<div className="content-html">
{css ? <style dangerouslySetInnerHTML={{ __html: css }} /> : null}
{elements.map((el, i) => (
<ElementNode key={el.id || i} el={el} />
))}
</div>
</WooContext.Provider>
</ShortcodesContext.Provider>
</PostsDataContext.Provider>
</VarsContext.Provider>
@@ -779,6 +806,18 @@ function WidgetNodeInner({ el }: { el: ElElement }) {
return <SocialIconsWidget settings={settings} />;
case "eael-contact-form-7":
return <ContactFormWidget el={el} settings={settings} />;
case "woocommerce-product-images":
return <WooProductImages />;
case "woocommerce-product-title":
return <WooProductTitle />;
case "woocommerce-product-meta":
return <WooProductMeta />;
case "woocommerce-product-short-description":
return <WooProductShortDescription />;
case "woocommerce-product-data-tabs":
return <WooProductDataTabs />;
case "woocommerce-products":
return <WooProductsWidget />;
case "video":
return <VideoWidget settings={settings} />;
case "counter":
@@ -2029,6 +2068,159 @@ function VideoWidget({ settings }: { settings: Record<string, unknown> }) {
);
}
/** # 组件 Woo 产品图廊(woocommerce-product-images)
* 取证(dsgw-210-a-1,1440):图廊 484 宽、margin 0 0 32px;主图 482×482(600x600 变体);
* 缩略条 margin 0 -4px、每行 4 张(li 25%)。图片 URL 已在 loader 解析为变体。
*/
function WooProductImages() {
const woo = useContext(WooContext);
if (!woo) return null;
return (
<div className="woo-gallery">
<style>{WOO_CSS}</style>
<div className="woo-gallery-wrapper">
<div className="woo-gallery-image">
<img src={woo.mainImage.url} alt={woo.mainImage.alt || woo.title} width={600} height={600} />
</div>
</div>
<ol className="woo-thumbs">
{woo.thumbs.map((t, i) => (
<li key={i}>
<img src={t.url} alt={t.alt || woo.title} width={100} height={100} loading="lazy" />
</li>
))}
</ol>
</div>
);
}
/** # 组件 Woo 产品标题(24px/600 #333,product_title) */
function WooProductTitle() {
const woo = useContext(WooContext);
if (!woo) return null;
return <h1 className="woo-title">{woo.title}</h1>;
}
/** # 组件 Woo 产品 meta(Model: SKU / Category: 链接,inline 两项、span pad 0 10px) */
function WooProductMeta() {
const woo = useContext(WooContext);
if (!woo) return null;
return (
<div className="woo-meta">
{woo.sku ? (
<span className="woo-meta-item">
<span className="woo-meta-label">Model: </span>
<span className="woo-meta-value">{woo.sku}</span>
</span>
) : null}
{woo.categories.length > 0 ? (
<span className="woo-meta-item">
<span className="woo-meta-label">Category:</span>
<span className="woo-meta-value">
{woo.categories.map((c, i) => (
<a key={c.slug} href={`/collection/${c.slug}/`}>
{i > 0 ? ", " : ""}
{c.name}
</a>
))}
</span>
</span>
) : null}
</div>
);
}
/** # 组件 Woo 产品短描述(16px/32 #333) */
function WooProductShortDescription() {
const woo = useContext(WooContext);
if (!woo?.shortDescription) return null;
return <div className="woo-short-desc" dangerouslySetInnerHTML={{ __html: localSiteUrl(woo.shortDescription) }} />;
}
/** # 组件 Woo 产品数据标签页(woocommerce-product-data-tabs)
* 原站 tabs 栏 display:none(JS 未初始化态),Description 面板直接显示产品自身
* elementor 文档;Additional information 面板为属性表(隐藏)。
*/
function WooProductDataTabs() {
const woo = useContext(WooContext);
if (!woo) return null;
const groups: { attr: string; values: string[] }[] = [];
for (const a of woo.attributes) {
const g = groups.find((x) => x.attr === a.attr);
if (g) g.values.push(a.value);
else groups.push({ attr: a.attr, values: [a.value] });
}
return (
<div className="woo-tabs">
<style>{WOO_CSS}</style>
<ul className="woo-tabs-list">
<li className="woo-tab-active"><a href="#tab-description">Description</a></li>
<li><a href="#tab-additional_information">Additional information</a></li>
</ul>
<div className="woo-tab-panel woo-tab-description">
{woo.productDoc ? (
<ElementorRenderer doc={woo.productDoc} pageCss={woo.productPageCss} />
) : null}
</div>
<div className="woo-tab-panel woo-tab-addinfo" style={{ display: "none" }}>
<h2>Additional information</h2>
<table className="woo-attrs">
<tbody>
{groups.map((g) => (
<tr key={g.attr}>
<th>{g.attr}</th>
<td>{g.values.join(", ")}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
/** # 组件 Woo 相关产品(woocommerce-products,related;4 列卡片) */
function WooProductsWidget() {
const woo = useContext(WooContext);
if (!woo || !woo.related.length) return null;
return (
<div className="woo-related">
<div className="woo-related-grid">
{woo.related.map((p) => (
<ProductCard key={p.id} p={p} />
))}
</div>
</div>
);
}
/** # 常量 Woo 产品组件样式(取证 2026-11-03 dsgw-210-a-1 @1440) */
const WOO_CSS = `
.woo-gallery { margin: 0 0 32px; }
.woo-gallery .woo-gallery-image img { display: block; width: 100%; height: auto; }
.woo-gallery .woo-thumbs { list-style: none; display: flex; flex-wrap: wrap; margin: 6px -4px 0; padding: 0; }
.woo-gallery .woo-thumbs li { width: 25%; padding: 4px; box-sizing: border-box; list-style: none; }
.woo-gallery .woo-thumbs img { display: block; width: 100%; height: auto; border-radius: 3px; cursor: pointer; opacity: 0.85; }
.woo-title { margin: 0; font-size: 24px; font-weight: 600; line-height: 24px; color: #333; }
.woo-meta { display: flex; flex-wrap: wrap; margin: 16px -10px 0; font-size: 16px; line-height: 24px; color: #333; }
.woo-meta .woo-meta-item { padding: 0 10px; }
.woo-meta .woo-meta-label { font-weight: 700; }
.woo-meta .woo-meta-value a { color: #333; text-decoration: none; }
.woo-meta .woo-meta-value a:hover { color: #ED6C00; }
.woo-short-desc { margin-top: 20px; font-size: 16px; line-height: 32px; color: #333; }
.woo-short-desc p { margin: 0 0 10px; }
.woo-tabs-list { list-style: none; display: none; margin: 0; padding: 0; }
.woo-tabs-list li { display: inline-block; }
.woo-tabs-list li a { display: inline-block; padding: 12.8px 19.2px; font-size: 16px; font-weight: 700; line-height: 16px; color: #515151; }
.woo-tab-description { display: block; }
.woo-attrs { width: 100%; border-collapse: collapse; }
.woo-attrs th, .woo-attrs td { border-top: 1px solid rgba(0,0,0,0.06); padding: 10px 16px; text-align: left; font-size: 15px; }
.woo-related-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
@media (max-width: 767px) {
.woo-related-grid { grid-template-columns: repeat(2, 1fr); }
}
`;
/** # 函数 从任意 meta 对象中安全提取 elementor 文档(含类型收窄) */
export function extractElementor(meta: unknown): ElDocument | undefined {
if (!meta || typeof meta !== "object") return undefined;
+120 -195
View File
@@ -1,38 +1,115 @@
/**
* # 路由 产品详情 /product/$slug/
* 对齐原站产品页模板:无面包屑;左图廊 + 右标题/Category/短描述/分隔线/
* Specification 与 Get A Quote 按钮;描述(Elementor 或 HTML)+
* Additional information(属性表)+ 相关产品。询盘走 Get A Quote 弹窗。
* 一比一复刻原站 Elementor 产品单模板(elementor_library 2741,dump 补录):
* a7db516 hero(图廊 + 标题/meta/短描述/按钮)→ 43df588(数据标签页:Description 面板嵌
* 产品自身 elementor 文档 + Additional information 属性表)→ 6819437/b4f3f0c/e12a7a7/
* 5955180(相关产品)/a0a5183(CF7 表单)/bed3f8a/9e7188e。
* 模板内动态链接:ACF 规格书按钮 → products.spec_url(非 URL 时回退 /contact-us/)、
* popup 按钮 → elementor-action 触发全局询盘弹窗。
*/
import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { getLayoutData, Layout } from "~/components/website/Layout";
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
import { InquiryForm } from "~/components/website/InquiryForm";
import { ProductCard } from "~/components/website/ProductCard";
import { ElementorRenderer, extractElementor, type ElDocument, type ElElement, type WooProductContext } from "~/components/elementor/Renderer";
import { Button } from "~/components/ui/button";
import { getProductDetail, getFilterConfig, listProducts, type ProductListResult } from "~/server/data";
import { Link } from "@tanstack/react-router";
import { getProductDetail, getProductTemplate, getEmbeddedTemplate, listProducts, loadPostsWidgetsData, type ProductListResult } from "~/server/data";
import { resolveMediaVariantUrl } from "~/utils/image-variant";
import { useT } from "~/i18n";
export const Route = createFileRoute("/product/$slug")({
loader: async ({ params }) => {
const [layout, detail, filterConfig] = await Promise.all([
const [layout, detail, template] = await Promise.all([
getLayoutData(),
getProductDetail(params.slug).catch(() => null),
getFilterConfig().catch(() => []),
getProductTemplate().catch(() => null),
]);
// 未找到产品:返回 notFound 标记(组件里渲染 404)
if (!detail) return { layout, detail: null, filterConfig, related: { items: [], total: 0, page: 1, pageSize: 4 } as ProductListResult };
const related = await listProducts({ category: detail.categories[0]?.slug, page: 1, pageSize: 8 }).catch(() => ({ items: [], total: 0, page: 1, pageSize: 8 }) as ProductListResult);
// meta 转为具体 JSON 类型(jsonb 列推断为 unknown,需显式收窄)
if (!detail)
return { layout, detail: null, tpl: null as null | { doc: ElDocument; pageCss?: string; woo: WooProductContext; templates: Record<string, ElElement[]>; postsData: Record<string, never[]> } };
const related = await listProducts({ category: detail.categories[0]?.slug, page: 1, pageSize: 8 }).catch(
() => ({ items: [], total: 0, page: 1, pageSize: 8 }) as ProductListResult,
);
const meta = (detail.meta ?? null) as Record<string, unknown> | null;
const gallery = detail.gallery.map((g) => ({ url: g.url, alt: g.alt }));
return {
layout,
detail: { ...detail, meta, gallery },
filterConfig,
related: { ...related, items: related.items.filter((r) => r.slug !== detail.slug).slice(0, 4) },
// 图廊变体:主图 600x600、缩略 300 档(原站 data-thumb 100x100,取 300 保证清晰度)
const mainImage = {
url: detail.featuredUrl ? resolveMediaVariantUrl(detail.featuredUrl, 600) : "",
alt: detail.featuredAlt ?? detail.title,
};
const thumbs = [
...(detail.featuredUrl ? [{ url: resolveMediaVariantUrl(detail.featuredUrl, 300), alt: detail.featuredAlt ?? detail.title }] : []),
...gallery.map((g) => ({ url: resolveMediaVariantUrl(g.url, 300), alt: g.alt ?? detail.title })),
];
const productDoc = extractElementor(meta);
const woo: WooProductContext = {
title: detail.title,
sku: detail.sku ?? "",
shortDescription: detail.shortDescription ?? "",
categories: detail.categories,
mainImage,
thumbs,
attributes: detail.attributes.map((a) => ({ attr: a.attr, value: a.value })),
productDoc: productDoc ?? undefined,
productPageCss: typeof meta?.page_css === "string" ? meta.page_css : undefined,
related: related.items.filter((r) => r.slug !== detail.slug).slice(0, 4).map((r) => ({
id: r.id,
slug: r.slug,
title: r.title,
sku: r.sku,
imageUrl: r.imageUrl,
imageAlt: r.imageAlt,
})),
};
// 模板动态链接落地:ACF 规格书 → spec_url(非 URL 回退 /contact-us/);popup → 询盘弹窗触发
let doc: ElDocument | null = null;
let pageCss: string | undefined;
if (template?.doc) {
doc = JSON.parse(JSON.stringify(template.doc)) as ElDocument;
pageCss = template.pageCss;
const specHref = /^(\/|https?:)/.test(detail.specUrl ?? "") ? (detail.specUrl as string) : "/contact-us/";
const walk = (el: unknown) => {
if (Array.isArray(el)) {
el.forEach(walk);
return;
}
if (!el || typeof el !== "object") return;
const e = el as { widgetType?: unknown; settings?: Record<string, unknown>; elements?: unknown[] };
if (e.widgetType === "button" && e.settings) {
const dyn = JSON.stringify(e.settings.__dynamic__ ?? "");
if (dyn.includes("acf-url")) e.settings.url = specHref;
else if (dyn.includes("popup")) e.settings.url = "/?elementor-action=popup";
}
(Array.isArray(e.elements) ? e.elements : []).forEach(walk);
};
walk(doc);
}
// 内嵌模板短代码(如 bed3f8a → template-29972「IoT Gateways for Recommendation」)
const templates: Record<string, ElElement[]> = {};
if (doc) {
const scIds = new Map<number, string[]>();
const walkSc = (els: ElElement[] | undefined) => {
for (const el of els || []) {
const st = (el.settings || {}) as Record<string, unknown>;
const m = /\[elementor-template\s+id=["']?(\d+)/.exec(typeof st.shortcode === "string" ? st.shortcode : "");
if (m && el.id) {
const list = scIds.get(Number(m[1])) ?? [];
list.push(el.id);
scIds.set(Number(m[1]), list);
}
walkSc(el.elements as ElElement[] | undefined);
}
};
walkSc(doc as unknown as ElElement[]);
for (const [tid, widgetIds] of scIds) {
const row = await getEmbeddedTemplate(tid);
const elDoc = extractElementor((row?.meta ?? null) as Record<string, unknown> | null);
if (Array.isArray(elDoc) && elDoc.length) for (const wid of widgetIds) templates[wid] = elDoc;
}
}
// posts widget 预取(模板 + 产品文档里全部 posts widget)
const postsData = doc
? await loadPostsWidgetsData([...(doc as unknown as ElElement[]), ...((Array.isArray(productDoc) ? productDoc : []) as ElElement[])])
: {};
return { layout, detail: { ...detail, meta, gallery }, tpl: doc ? { doc, pageCss, woo, templates, postsData } : null };
},
head: ({ loaderData }) => ({
meta: [
@@ -43,186 +120,34 @@ export const Route = createFileRoute("/product/$slug")({
component: ProductPage,
});
/** # 组件 产品详情页 */
/** # 组件 产品详情页(模板 2741 直出) */
function ProductPage() {
const t = useT();
const data = Route.useLoaderData();
const d = data.detail;
const [quoteOpen, setQuoteOpen] = useState(false);
if (!d) return <Layout layout={data.layout}><NotFound /></Layout>;
if (!data.detail || !data.tpl) {
return (
<Layout layout={data.layout}>
<div className="pt-[70px] pb-10 text-center">
<h1 className="m-0 text-[64px] text-brand">404</h1>
<p>{t("product_not_found")}</p>
<Button asChild>
<Link to="/shop/">Back to Shop</Link>
</Button>
</div>
</Layout>
);
}
return (
<Layout layout={data.layout}>
<div className="site-container">
<div className="my-3 mb-8 grid grid-cols-1 gap-9 min-[900px]:grid-cols-[minmax(0,5fr)_minmax(0,6fr)]">
<Gallery images={[...(d.featuredUrl ? [{ url: d.featuredUrl, alt: d.featuredAlt }] : []), ...d.gallery]} title={d.title} />
<div>
<h1 className="mt-0 text-[26px] leading-[1.35]">{d.title}</h1>
{d.categories.length > 0 ? (
<p className="my-1.5 text-muted-foreground">
{d.categories.length > 1 ? t("product_categories") : t("product_category")}{" "}
{d.categories.map((cat, i) => (
<span key={cat.slug}>
{i > 0 ? ", " : ""}
<Link to="/collection/$slug/" params={{ slug: cat.slug }} className="text-brand">{cat.name}</Link>
</span>
))}
</p>
) : null}
{d.shortDescription ? (
<div className="content-html" dangerouslySetInnerHTML={{ __html: d.shortDescription }} />
) : null}
{/* 分隔线 + 操作按钮(对齐原站 Elementor 模板:Specification / Get A Quote) */}
<hr className="my-5 border-t border-line" />
<div className="flex flex-wrap gap-3">
<Button variant="outline" className="border-brand px-4 py-2 text-brand hover:bg-brand hover:text-white" onClick={() => scrollToSpec(d)}>
<SpecGlyph /> {t("product_specification")}
</Button>
<Button variant="outline" className="border-brand px-4 py-2 text-brand hover:bg-brand hover:text-white" onClick={() => setQuoteOpen(true)}>
{t("product_get_quote")}
</Button>
</div>
</div>
</div>
<Description detail={d} />
{d.attributes.length > 0 ? (
<section id="additional-information" className="my-10 scroll-mt-24">
<h2>{t("product_additional_info")}</h2>
<table className="my-3 w-full border-collapse text-sm">
<tbody>
{d.attributes.map((a, i) => (
<tr key={i}>
<th className="w-[32%] border border-line bg-soft px-3 py-2 text-left font-semibold">{a.attr}</th>
<td className="border border-line px-3 py-2 text-left">{a.value}</td>
</tr>
))}
</tbody>
</table>
</section>
) : null}
<section className="my-10">
<h2>{t("product_related")}</h2>
<div className="mt-[18px] grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
{data.related.items.map((p) => (
<ProductCard key={p.id} p={p} />
))}
{data.related.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">{t("product_no_related")}</p> : null}
</div>
</section>
</div>
{/* Get A Quote 弹窗(对齐原站 Elementor popup 询盘表单) */}
{quoteOpen ? (
<div className="fixed inset-0 z-[300] flex items-start justify-center overflow-auto bg-black/55 p-4 py-[60px]" onClick={() => setQuoteOpen(false)}>
<div className="relative w-full max-w-[560px] rounded-lg bg-white p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<button
type="button"
aria-label="Close"
onClick={() => setQuoteOpen(false)}
className="absolute top-2.5 right-3 cursor-pointer border-0 bg-transparent text-2xl leading-none text-muted-foreground hover:text-ink"
>
×
</button>
<InquiryForm formKey="product-inquiry-form" productInterest={d.title} title={t("product_quote_title", { name: d.title })} />
</div>
</div>
) : null}
<ElementorRenderer
doc={data.tpl.doc}
pageCss={data.tpl.pageCss}
woo={data.tpl.woo}
postsData={data.tpl.postsData}
shortcodes={Object.fromEntries(
Object.entries(data.tpl.templates).map(([id, doc]) => [id, <ElementorRenderer key={id} doc={doc} postsData={data.tpl!.postsData} />]),
)}
/>
</Layout>
);
}
/** # 函数 Specification 按钮:有属性表滚动到 Additional information,有规格书则下载 */
function scrollToSpec(d: { attributes: unknown[]; specUrl?: string | null }) {
if (d.attributes.length > 0) {
document.getElementById("additional-information")?.scrollIntoView({ behavior: "smooth" });
return;
}
if (d.specUrl) window.open(d.specUrl, "_blank", "noreferrer");
}
/** # 组件 Specification 按钮文件图标(对齐原站 far-file-alt) */
function SpecGlyph() {
return (
<svg width="15" height="15" viewBox="0 0 384 512" fill="currentColor" aria-hidden="true">
<path d="M288 248v28c0 6.6-5.4 12-12 12H108c-6.6 0-12-5.4-12-12v-28c0-6.6 5.4-12 12-12h168c6.6 0 12 5.4 12 12zm-12 72H108c-6.6 0-12 5.4-12 12v28c0 6.6 5.4 12 12 12h168c6.6 0 12-5.4 12-12v-28c0-6.6-5.4-12-12-12zm108-188.1V464c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V48C0 21.5 21.5 0 48 0h204.1C264.8 0 277 5.1 286 14.1L369.9 98c9 8.9 14.1 21.2 14.1 33.9zm-128-80V128h76.1L256 51.9zM336 464V176H232c-13.3 0-24-10.7-24-24V48H48v416h288z" />
</svg>
);
}
/** # 组件 图廊:主图 + 缩略图切换(纯 React state) */function Gallery(props: { images: { url: string; alt: string | null }[]; title: string }) {
const [active, setActive] = useState(0);
const [zoomOpen, setZoomOpen] = useState(false);
if (!props.images.length) {
return <div className="flex aspect-[4/3] items-center justify-center overflow-hidden rounded-lg border border-line bg-white" aria-label={props.title} />;
}
const img = props.images[Math.min(active, props.images.length - 1)];
return (
<div>
<div className="relative flex aspect-[4/3] items-center justify-center overflow-hidden rounded-lg border border-line bg-white">
<img src={img.url} alt={img.alt || props.title} className="max-h-full max-w-full object-contain" />
{/* 全屏查看触发图标(对齐原站 gallery trigger) */}
<button
type="button"
aria-label="View full-screen image gallery"
onClick={() => setZoomOpen(true)}
className="absolute top-2 right-2 cursor-pointer border-0 bg-transparent text-[15px] text-muted-foreground hover:text-brand"
>
🔍
</button>
</div>
{props.images.length > 1 ? (
<div className="mt-2.5 flex flex-wrap gap-2">
{props.images.map((t, i) => (
<img
key={i}
src={t.url}
alt={t.alt || `${props.title} thumbnail ${i + 1}`}
className={"size-[72px] cursor-pointer rounded-md border-2 bg-white object-contain p-1 " + (i === active ? "border-brand" : "border-line")}
onClick={() => setActive(i)}
loading="lazy"
/>
))}
</div>
) : null}
{zoomOpen ? (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/80 p-6" onClick={() => setZoomOpen(false)}>
<img src={img.url} alt={img.alt || props.title} className="max-h-full max-w-full rounded bg-white object-contain" onClick={(e) => e.stopPropagation()} />
<button
type="button"
aria-label="Close"
onClick={() => setZoomOpen(false)}
className="absolute top-4 right-5 cursor-pointer border-0 bg-transparent text-3xl leading-none text-white"
>
×
</button>
</div>
) : null}
</div>
);
}
/** # 组件 产品描述:有 Elementor 用渲染器,否则输出 description HTML */
function Description(props: { detail: { meta: Record<string, unknown> | null; description: string | null } }) {
const elementor = extractElementor(props.detail.meta);
if (elementor) return <ElementorRenderer doc={elementor} />;
if (props.detail.description) {
return <div className="content-html" dangerouslySetInnerHTML={{ __html: props.detail.description }} />;
}
return null;
}
/** # 组件 产品未找到 */
function NotFound() {
const t = useT();
return (
<div className="pt-[70px] pb-10 text-center">
<h1 className="m-0 text-[64px] text-brand">404</h1>
<p>{t("product_not_found")}</p>
<Button asChild>
<Link to="/collection/">{t("product_browse_all")}</Link>
</Button>
</div>
);
}
+16
View File
@@ -260,6 +260,22 @@ async function runProductQuery(conds: unknown[], page: number, pageSize: number,
}
/** # 函数 产品详情(含图廊、属性、分类) */
/** # 函数 产品单页模板(elementor_library 2741,dump 缺失已从 legacy SQL 补录)
* 返回值 { doc, pageCss };未入库返回 null
*/
export async function getProductTemplate() {
const [row] = await db
.select({ meta: contents.meta })
.from(contents)
.where(and(eq(contents.slug, "product-template"), eq(contents.type, "template")));
if (!row) return null;
const meta = (row.meta ?? {}) as Record<string, unknown>;
return {
doc: meta.elementor as never,
pageCss: typeof meta.page_css === "string" ? meta.page_css : undefined,
};
}
export async function getProductDetail(slug: string) {
const [row] = await db
.select({
+76
View File
@@ -0,0 +1,76 @@
/**
* # 模块 原站页面生成 CSS 翻译(post-<id>.css → data-el 选择器)
* 供 import-page-css / import-product-css 等导入脚本共用。
*/
/** # 函数 把选择器中的 elementor-element-<id> 改写为 data-el 属性选择器
* 规则保留条件:含文档 id,或引用 .ele-<自定类>(页面自定义类);其余丢弃
* 参数 sel 原选择器;ids 文档元素 id 集合;pid 页面(模板)post id
*/
export function translateSelector(sel: string, ids: Set<string>, pid: number): string | null {
const idIn = [...sel.matchAll(/elementor-element-(\w+)/g)].map((m) => m[1]);
const hasDocId = idIn.some((id) => ids.has(id));
const hasCustomCls = /\.ele-[a-z][\w-]*/i.test(sel) && !/elementor-element-/.test(sel);
if (!hasDocId && !hasCustomCls) return null;
let out = sel;
for (const id of new Set(idIn)) {
if (!ids.has(id)) continue;
out = out.split(`.elementor-element.elementor-element-${id}`).join(`[data-el="${id}"]`);
out = out.split(`.elementor-element-${id}`).join(`[data-el="${id}"]`);
}
// 剥页面作用域前缀(.elementor-<pid> 空格相连)与多余空白
out = out.replace(new RegExp(`\\.elementor-${pid}\\s*`, "g"), "");
return out.replace(/\s+/g, " ").trim();
}
/** # 函数 解析 CSS 顶层规则(含 @media 嵌套块),只保留命中文档 id/自定类的规则
* 参数 css 原始 CSS 文本;ids 文档元素 id 集合;pid 页面 post id
*/
export function filterCssForIds(css: string, ids: Set<string>, pid: number): string {
let out = "";
let i = 0;
const n = css.length;
while (i < n) {
// 跳过注释
if (css.startsWith("/*", i)) {
const end = css.indexOf("*/", i + 2);
i = end === -1 ? n : end + 2;
continue;
}
const brace = css.indexOf("{", i);
if (brace === -1) break;
const sel = css.slice(i, brace).trim();
let depth = 1;
let j = brace + 1;
while (j < n && depth > 0) {
if (css[j] === "{") depth++;
else if (css[j] === "}") depth--;
j++;
}
const body = css.slice(brace + 1, j - 1);
if (sel.startsWith("@media") || sel.startsWith("@supports")) {
const inner = filterCssForIds(body, ids, pid);
if (inner) out += `${sel}{${inner}}\n`;
} else if (sel.startsWith("@keyframes") || sel.startsWith("@font-face")) {
out += `${sel}{${body}}\n`;
} else {
const parts = sel.split(",").map((p) => translateSelector(p, ids, pid)).filter((p): p is string => !!p);
if (parts.length) out += `${parts.join(",")}{${body}}\n`;
}
i = j;
}
return out;
}
/** # 函数 从文档(数组或 {elements} 包装)提取全部元素 id 集合 */
export function collectDocIds(el: unknown, out: Set<string> = new Set()): Set<string> {
if (Array.isArray(el)) {
for (const x of el) collectDocIds(x, out);
return out;
}
if (!el || typeof el !== "object") return out;
const e = el as { id?: unknown; elements?: unknown[] };
if (typeof e.id === "string") out.add(e.id);
for (const c of Array.isArray(e.elements) ? e.elements : []) collectDocIds(c, out);
return out;
}
+8
View File
@@ -21,6 +21,14 @@ const dirCache = new Map<string, string[]>();
/** # 成员变量 变体解析结果缓存(url+size → 最终 url) **/
const resultCache = new Map<string, string>();
/** # 函数 对外:媒体 URL 的 WP 尺寸变体解析(服务端;无变体返回原 URL)
* 参数 url 原图 URL;targetWidth WP 档位目标宽(150/300/600/768/1024)
*/
export function resolveMediaVariantUrl(url: string, targetWidth: number): string {
if (typeof window !== "undefined" || !url?.includes("/uploads/")) return url;
return findVariant(url, targetWidth) ?? url;
}
/** # 函数 查找某原图在给定目标宽度下的最佳变体 URL(无则返回 null)
* 参数 url 原图 URL(本地 /uploads/... 或原站绝对地址);targetWidth WP 档位目标宽
*/