diff --git a/docs/sections-inventory.md b/docs/sections-inventory.md index 6b3518d53..06e85e9bc 100644 --- a/docs/sections-inventory.md +++ b/docs/sections-inventory.md @@ -126,6 +126,13 @@ - page_css 导入扩展至全部文章:254 篇有 post-.css 已导入,355 篇原站 404(无自定义 CSS)。 - 已知数据差异:/blog/ 本地 category-blog 302 篇(26 页)vs 原站当前 7 页(dump 后原站 调整了分类成员);文章内文逐节高度仍有数据快照/lazy 假象差。 +- P1 产品详情 `/product/$slug` v0.10.27 ✅ 产品单模板一比一(elementor_library 2741,从 + legacy dump 补录入库 slug=product-template;内嵌模板 29972「IoT Gateways for + Recommendation」同步补录):hero 图廊 484×617/标题/meta(Model/Category)/短描述精确、 + 数据标签页(Description 面板嵌产品自身 elementor 文档 + Additional information 属性表)、 + 相关产品 4 列、CF7 表单、posts 卡片(postsData/shortcodes 上下文);产品 page_css 导入 + 130/138(8 篇原站 404);ACF 规格书/popup 动态按钮落地(spec_url 非 URL 回退 /contact-us/)。 + 剩余差异:内嵌 CF7/推荐卡片区高 +350~364、产品文档内少数版块为原站 lazy 占位差。 ### 3. 商城/产品/博客(WordPress 模板页) - SH1 商城:`/shop/` 分类+商品网格 diff --git a/package.json b/package.json index b835d3736..f75efa33c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dusuniot-rebuild", - "version": "0.10.26", + "version": "0.10.27", "private": true, "type": "module", "scripts": { diff --git a/scripts/import-page-css.ts b/scripts/import-page-css.ts index 7d82674fc..1e4d9f3aa 100644 --- a/scripts/import-page-css.ts +++ b/scripts/import-page-css.ts @@ -15,6 +15,7 @@ * 用法:bun scripts/import-page-css.ts [ ...] (缺省 = 全部方案落地页) */ import { SQL } from "bun"; +import { collectDocIds, filterCssForIds } from "../src/utils/css-translate"; /** # 常量 方案落地页 slug 全集(缺省导入目标) **/ const SOLUTION_SLUGS = [ @@ -37,87 +38,24 @@ const SOLUTION_SLUGS = [ /** # 常量 原站页面 CSS 地址模板 **/ const CSS_URL = (pid: number) => `https://www.dusuniot.com/wp-content/uploads/elementor/css/post-${pid}.css`; -/** # 函数 提取文档全部元素 id 集合(含嵌套;顶层为数组或 {elements} 包装均可) **/ -function collectIds(el: unknown, out: Set = new Set()): Set { - if (Array.isArray(el)) { - for (const x of el) collectIds(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 : []) collectIds(c, out); - return out; -} -/** # 函数 把选择器中的 elementor-element- 改写为 data-el 属性选择器 - * 规则保留条件:含文档 id,或引用 .ele-<自定类>(页面自定义类,渲染器把 _css_classes - * 落到节点 class 上,如 .ele-sol-review-box / .ele-secondDep-dif);其余丢弃 - */ -function translateSelector(sel: string, ids: Set, 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- 空格相连)与多余空白 - out = out.replace(new RegExp(`\\.elementor-${pid}\\s*`, "g"), ""); - return out.replace(/\s+/g, " ").trim(); -} -/** # 函数 解析 CSS 顶层规则(含 @media 嵌套块),返回过滤改写后的规则串 - * 用花括号配对扫描,避免依赖完整 CSS 解析器 - */ -function filterCss(css: string, ids: Set, 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 = filterCss(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; -} -const slugs = process.argv.slice(2).length ? process.argv.slice(2) : SOLUTION_SLUGS; +const sql0 = new SQL("postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot", { max: 1 }); +// CONTENT_TYPE 环境变量支持产品(product)等其它内容类型;"-all" = 该类型全部已发布内容 +const contentType = process.env.CONTENT_TYPE || "post"; +let slugs = process.argv.slice(2).length ? process.argv.slice(2) : SOLUTION_SLUGS; +if (slugs.length === 1 && slugs[0] === "-all") { + const rows0 = await sql0`select slug from contents where type = ${contentType} and status = ${"published"}`; + slugs = rows0.map((r: any) => r.slug); +} +await sql0.end(); const sql = new SQL("postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot", { max: 1 }); /** 已导入过的跳过(断点续传;meta.page_css 键存在即视为完成) */ let done = 0; let skipped = 0; for (const slug of slugs) { - const rows = await sql`select id, legacy_id, meta::text as meta from contents where slug = ${slug} and type = ${"post"} limit 1`; + const rows = await sql`select id, legacy_id, meta::text as meta from contents where slug = ${slug} and type = ${contentType} limit 1`; const row = rows[0]; if (!row) { skipped++; @@ -128,7 +66,7 @@ for (const slug of slugs) { skipped++; continue; } - const ids = collectIds(meta.elementor); + const ids = collectDocIds(meta.elementor); const pid = row.legacy_id; if (!pid) { skipped++; @@ -150,7 +88,7 @@ for (const slug of slugs) { skipped++; continue; } - const pageCss = filterCss(raw, ids, pid); + const pageCss = filterCssForIds(raw, ids, pid); meta.page_css = pageCss; // ::text::jsonb 双重转换:Bun SQL 绑定字符串直接 ::jsonb 会把整串存成 JSON 字符串值 await sql`update contents set meta = ${JSON.stringify(meta)}::text::jsonb where id = ${row.id}`; diff --git a/scripts/import-product-css.ts b/scripts/import-product-css.ts new file mode 100644 index 000000000..8f99183da --- /dev/null +++ b/scripts/import-product-css.ts @@ -0,0 +1,33 @@ +/** + * # 脚本 为全部已发布产品抓 post-.css → products.meta.page_css + * 与 import-page-css 相同的翻译(css-translate);断点续传(已有 page_css 键跳过)。 + * 用法:bun scripts/import-product-css.ts + */ +import { SQL } from "bun"; +import { collectDocIds, filterCssForIds } from "../src/utils/css-translate"; + +const sql = new SQL("postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot", { max: 1 }); +const rows = await sql`select id, legacy_id, meta::text as meta from products where status = ${"published"} and legacy_id is not null`; +let done = 0, skipped = 0, fail = 0; +for (const row of rows) { + const meta = JSON.parse(row.meta); + if (meta.page_css !== undefined) { skipped++; continue; } + const ids = collectDocIds(meta.elementor); + let raw: string | null = null; + for (let a = 0; a < 3 && raw === null; a++) { + try { + const res = await fetch(`https://www.dusuniot.com/wp-content/uploads/elementor/css/post-${row.legacy_id}.css`, { headers: { "User-Agent": "Mozilla/5.0" } }); + if (res.ok) raw = await res.text(); + else if (res.status === 404) break; + } catch { + await new Promise((r) => setTimeout(r, 300)); + } + } + if (raw === null) { fail++; continue; } + meta.page_css = filterCssForIds(raw, ids, row.legacy_id); + await sql`update products set meta = ${JSON.stringify(meta)}::text::jsonb where id = ${row.id}`; + done++; + if (done % 25 === 0) console.log(`... ${done}`); +} +console.log("imported:", done, "skipped:", skipped, "fail:", fail); +await sql.end(); diff --git a/src/components/elementor/Renderer.tsx b/src/components/elementor/Renderer.tsx index 8a1f0ac15..505357c1e 100644 --- a/src/components/elementor/Renderer.tsx +++ b/src/components/elementor/Renderer.tsx @@ -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>({}); +/** # 类型 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(null); + /** # 成员变量 WordPress 短代码渲染上下文(widget id → 预渲染节点;页脚 menu-2/gtranslate) **/ const ShortcodesContext = createContext>({}); @@ -292,11 +314,14 @@ export function ElementorRenderer({ shortcodes, vars, pageCss, + woo, }: { doc: ElDocument; postsData?: Record; shortcodes?: Record; vars?: RendererVars; + /** # 成员变量 Woo 产品单页上下文(产品模板 2741 渲染时传入) **/ + woo?: WooProductContext | null; /** # 成员变量 页面级生成 CSS(原站 post-.css 翻译成 data-el 选择器,scripts/import-page-css.ts 导入) **/ pageCss?: string; }) { @@ -307,12 +332,14 @@ export function ElementorRenderer({ -
- {css ? +
+
+ {woo.mainImage.alt +
+
+
    + {woo.thumbs.map((t, i) => ( +
  1. + {t.alt +
  2. + ))} +
+
+ ); +} + +/** # 组件 Woo 产品标题(24px/600 #333,product_title) */ +function WooProductTitle() { + const woo = useContext(WooContext); + if (!woo) return null; + return

{woo.title}

; +} + +/** # 组件 Woo 产品 meta(Model: SKU / Category: 链接,inline 两项、span pad 0 10px) */ +function WooProductMeta() { + const woo = useContext(WooContext); + if (!woo) return null; + return ( +
+ {woo.sku ? ( + + Model: + {woo.sku} + + ) : null} + {woo.categories.length > 0 ? ( + + Category: + + {woo.categories.map((c, i) => ( + + {i > 0 ? ", " : ""} + {c.name} + + ))} + + + ) : null} +
+ ); +} + +/** # 组件 Woo 产品短描述(16px/32 #333) */ +function WooProductShortDescription() { + const woo = useContext(WooContext); + if (!woo?.shortDescription) return null; + return
; +} + +/** # 组件 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 ( +
+ + +
+ {woo.productDoc ? ( + + ) : null} +
+
+

Additional information

+ + + {groups.map((g) => ( + + + + + ))} + +
{g.attr}{g.values.join(", ")}
+
+
+ ); +} + +/** # 组件 Woo 相关产品(woocommerce-products,related;4 列卡片) */ +function WooProductsWidget() { + const woo = useContext(WooContext); + if (!woo || !woo.related.length) return null; + return ( +
+
+ {woo.related.map((p) => ( + + ))} +
+
+ ); +} + +/** # 常量 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; diff --git a/src/routes/product.$slug.tsx b/src/routes/product.$slug.tsx index 8788e172c..455c5db67 100644 --- a/src/routes/product.$slug.tsx +++ b/src/routes/product.$slug.tsx @@ -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; postsData: Record } }; + 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 | 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; 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 = {}; + if (doc) { + const scIds = new Map(); + const walkSc = (els: ElElement[] | undefined) => { + for (const el of els || []) { + const st = (el.settings || {}) as Record; + 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 | 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 ; + if (!data.detail || !data.tpl) { + return ( + +
+

404

+

{t("product_not_found")}

+ +
+
+ ); + } return ( -
-
- -
-

{d.title}

- {d.categories.length > 0 ? ( -

- {d.categories.length > 1 ? t("product_categories") : t("product_category")}{" "} - {d.categories.map((cat, i) => ( - - {i > 0 ? ", " : ""} - {cat.name} - - ))} -

- ) : null} - {d.shortDescription ? ( -
- ) : null} - {/* 分隔线 + 操作按钮(对齐原站 Elementor 模板:Specification / Get A Quote) */} -
-
- - -
-
-
- - - - {d.attributes.length > 0 ? ( -
-

{t("product_additional_info")}

- - - {d.attributes.map((a, i) => ( - - - - - ))} - -
{a.attr}{a.value}
-
- ) : null} - -
-

{t("product_related")}

-
- {data.related.items.map((p) => ( - - ))} - {data.related.items.length === 0 ?

{t("product_no_related")}

: null} -
-
-
- - {/* Get A Quote 弹窗(对齐原站 Elementor popup 询盘表单) */} - {quoteOpen ? ( -
setQuoteOpen(false)}> -
e.stopPropagation()}> - - -
-
- ) : null} + [id, ]), + )} + /> ); } - -/** # 函数 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 ( - - ); -} - -/** # 组件 图廊:主图 + 缩略图切换(纯 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
; - } - const img = props.images[Math.min(active, props.images.length - 1)]; - return ( -
-
- {img.alt - {/* 全屏查看触发图标(对齐原站 gallery trigger) */} - -
- {props.images.length > 1 ? ( -
- {props.images.map((t, i) => ( - {t.alt setActive(i)} - loading="lazy" - /> - ))} -
- ) : null} - {zoomOpen ? ( -
setZoomOpen(false)}> - {img.alt e.stopPropagation()} /> - -
- ) : null} -
- ); -} - -/** # 组件 产品描述:有 Elementor 用渲染器,否则输出 description HTML */ -function Description(props: { detail: { meta: Record | null; description: string | null } }) { - const elementor = extractElementor(props.detail.meta); - if (elementor) return ; - if (props.detail.description) { - return
; - } - return null; -} - -/** # 组件 产品未找到 */ -function NotFound() { - const t = useT(); - return ( -
-

404

-

{t("product_not_found")}

- -
- ); -} diff --git a/src/server/data.ts b/src/server/data.ts index a00024a72..e3dcd3482 100644 --- a/src/server/data.ts +++ b/src/server/data.ts @@ -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; + 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({ diff --git a/src/utils/css-translate.ts b/src/utils/css-translate.ts new file mode 100644 index 000000000..8bcd9a67d --- /dev/null +++ b/src/utils/css-translate.ts @@ -0,0 +1,76 @@ +/** + * # 模块 原站页面生成 CSS 翻译(post-.css → data-el 选择器) + * 供 import-page-css / import-product-css 等导入脚本共用。 + */ + +/** # 函数 把选择器中的 elementor-element- 改写为 data-el 属性选择器 + * 规则保留条件:含文档 id,或引用 .ele-<自定类>(页面自定义类);其余丢弃 + * 参数 sel 原选择器;ids 文档元素 id 集合;pid 页面(模板)post id + */ +export function translateSelector(sel: string, ids: Set, 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- 空格相连)与多余空白 + 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, 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 = new Set()): Set { + 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; +} diff --git a/src/utils/image-variant.ts b/src/utils/image-variant.ts index d64443895..03d139c5d 100644 --- a/src/utils/image-variant.ts +++ b/src/utils/image-variant.ts @@ -21,6 +21,14 @@ const dirCache = new Map(); /** # 成员变量 变体解析结果缓存(url+size → 最终 url) **/ const resultCache = new Map(); +/** # 函数 对外:媒体 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 档位目标宽 */