From ddeeca32932303ec3fd6573099011dc86b5b551a Mon Sep 17 00:00:00 2001 From: bigcode Date: Mon, 2 Nov 2026 12:32:18 +0800 Subject: [PATCH] =?UTF-8?q?v0.10.21:=20=E4=BA=A7=E5=93=81=E5=BD=92?= =?UTF-8?q?=E6=A1=A3=E9=A1=B5=20C1-C4=EF=BC=88=E5=8E=9F=E7=AB=99=20/shop/?= =?UTF-8?q?=20=E6=A8=A1=E6=9D=BF=201398=20+=20WPC=20=E7=AD=9B=E9=80=89=20+?= =?UTF-8?q?=20WooCommerce=20=E7=BD=91=E6=A0=BC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 导入 archive 模板 post 1398(slug=shop-archive),/collection/ 用 ElementorRenderer 直出 hero/两列外壳,按 widget id 注入 ShopFilters/ShopProducts;新增 server function 解决客户端导航时 loader 打不开 db 的问题 - C1 hero:面包屑 Home / Shop(BreadcrumbWidget + vars.breadcrumb 上下文) - C2 筛选:12 组属性(term taxonomy 顺序,scripts/import-attribute-term-order.ts); 长期列表 5 项 + See more,Battery/Operating/Install 默认折叠;URL query 驱动, SoM 筛选实测 22 条;顶部 Filters 标题/已选 chips/Clear all - C3 网格:WooCommerce 归档 DOM(结果计数、orderby、24 卡 4 列、卡片 1:1 图/标题/ Read more);默认排序导入 products.meta.menu_order(scripts/import-product-menu-order.ts), 前三个商品与原站一致(DSOM-040R/110R/130N) - C4 分页:页码/箭头、当前页样式、page2 25–48 条验证 - 修复 Tailwind .columns-4 与 WooCommerce 网格类冲突;app.css 新增 shop 归档 scoped 样式 - 1436 实测:hero 52.8、shop 节 2906.7、筛选区 2798.7、网格 2523.3、卡 257.7×409、 分页 y2855,与原站逐项一致(±6px 内);bun test 11 pass;版本 0.10.21 --- docs/sections-inventory.md | 25 ++- package.json | 2 +- scripts/import-attribute-term-order.ts | 112 +++++++++++ scripts/import-product-menu-order.ts | 77 ++++++++ scripts/import-shop-template.ts | 42 ++++ src/components/elementor/Renderer.tsx | 58 +++++- src/components/website/ShopFilters.tsx | 201 ++++++++++++++++++++ src/components/website/ShopProducts.tsx | 172 +++++++++++++++++ src/components/website/collection-shared.ts | 16 +- src/routes/collection.index.tsx | 103 +++++++--- src/server/data.ts | 18 +- src/styles/app.css | 86 +++++++++ 12 files changed, 866 insertions(+), 46 deletions(-) create mode 100644 scripts/import-attribute-term-order.ts create mode 100644 scripts/import-product-menu-order.ts create mode 100644 scripts/import-shop-template.ts create mode 100644 src/components/website/ShopFilters.tsx create mode 100644 src/components/website/ShopProducts.tsx diff --git a/docs/sections-inventory.md b/docs/sections-inventory.md index 9029f0812..34bf6a1e4 100644 --- a/docs/sections-inventory.md +++ b/docs/sections-inventory.md @@ -57,11 +57,15 @@ ## 四、其他核心页型版块(自上而下,概览) -### 1. 产品分类列表/落地页(4 版块) -- C1 落地主题区(同 slug 文章 Elementor 直出,每类内容不同) -- C2 侧栏:分类树 + 属性复选筛选(URL search 驱动) -- C3 产品网格(ProductCard ×24) -- C4 分页 +### 1. 产品归档/列表页(原站 /shop/,4 版块) +- C1 归档 hero:Elementor 模板 post 1398 的 a2ab15c(面包屑 Home / Shop,bg #F4F5F8)✅ v0.10.21 +- C2 侧栏筛选:WPC Filter Everything(12 组属性复选、计数、See more、末三组折叠)✅ v0.10.21 +- C3 商品网格:WooCommerce 归档 24 卡 4 列(图/标题/Read more,Elementor skin)✅ v0.10.21 +- C4 排序 + 分页:orderby 下拉(默认 menu_order)、结果计数、页码/箭头分页 ✅ v0.10.21 +- 说明:分类 URL(/product-category/*)在原站多数 301 到 landing-pages/*(Elementor 落地页, + 归方案/落地页页型);/shop/ 才是 WooCommerce 归档版块,已按模板 1398 直出 + + ShopFilters/ShopProducts 注入。筛选 URL 用查询参数(原站 WPC 为 /shop/type-som/ 美化链接), + 术语顺序/计数来自导入数据(term taxonomy 顺序 + product_attribute_map 计数)。 ### 2. 方案页 - S1 `/solution/` 列表入口页 @@ -141,6 +145,17 @@ cookie-policy:19 段纯文本。 > (form_key=contact-form-1),成功显示 CF7 原文提示;弹窗内社交图标 5 个(20px/间距 14/品牌色)。 > 实测 1436:白卡 700×737 居中、两列 289.9/三列 189、消息框 604×153;390:宽 390、p5/p6 两列、 > p7 全宽,与 popup 自定义 CSS 一致;提交入库验证后已清理测试数据。 +> +> **C1-C4 产品归档取证(v0.10.21)**:原站 /shop/ 使用 archive 模板 post 1398 +> (scripts/import-shop-template.ts 导入 contents slug=shop-archive),/collection/ 路由直出 +> 模板外壳并注入 ShopFilters(widget 98d2008)/ShopProducts(d761846)。1436 实测: +> hero 52.8、shop 节 2906.7(左列 275.2/右列 1100.8、列高一致)、筛选区 2798.7(12 组, +> 标题 44/灰底、行高 20.8、组距 32;CPU/OS/Interface/RAM/Flash 默认 5 项 + See more, +> Battery/Operating/Install 默认折叠)、H1 24/600、结果计数“Showing 1–24 of 138 results”、 +> 商品网格 2523.3(24 卡 4 列 gap 10,卡 257.7×409)、分页 y2855 margin-top 40; +> 筛选 SoM → 22 条、排序 date、See more 5→17、分页 page2 均通过。默认排序改为 +> products.meta.menu_order(scripts/import-product-menu-order.ts),术语顺序改为 +> taxonomy 顺序(scripts/import-attribute-term-order.ts)。 ## 七、已知遗留(非当前版块范围,后续版块收口) diff --git a/package.json b/package.json index e0f93ff67..89a8b0ef0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dusuniot-rebuild", - "version": "0.10.20", + "version": "0.10.21", "private": true, "type": "module", "scripts": { diff --git a/scripts/import-attribute-term-order.ts b/scripts/import-attribute-term-order.ts new file mode 100644 index 000000000..1687a294d --- /dev/null +++ b/scripts/import-attribute-term-order.ts @@ -0,0 +1,112 @@ +/** + * # 脚本 导入产品属性术语顺序(对齐原站 /shop/ 筛选列表顺序) + * 原站 WPC 筛选按 wp_term_taxonomy 中的术语顺序展示(取证:pa_product-type 顺序 + * SoM → IoT Gateway → Wi-Fi Repeater → Router → Beacon,对应 term_id 756/764/845/846/862; + * pa_cpu 顺序 PX30 → RK3328 → RK3588 → PX30K …)。本项目导入时按字母序写入,需要修复。 + * 做法:解析 dump 的 wp_terms(term_id→name)与 wp_term_taxonomy(插入顺序), + * 对每个 pa_<属性 slug>,按顺序把 product_attribute_values.sort 设为序号。 + * 运行:bun scripts/import-attribute-term-order.ts + */ +import { SQL } from "bun"; + +const DUMP = "/home/bigcode/data/dev/sites/dusuniot/legacy/dusuniot-db-20261029.sql"; +const db = new SQL("postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot"); + +/** # 函数 提取某张表的 INSERT 段文本(支持多段 INSERT) */ +function tableInserts(lines: string[], table: string): string[] { + const out: string[] = []; + let buf = ""; + let on = false; + for (const l of lines) { + if (l.startsWith(`INSERT INTO \`${table}\` VALUES`)) { + on = true; + buf = l; + } else if (on) { + buf += l; + } + if (on && l.trimEnd().endsWith(";")) { + out.push(buf); + buf = ""; + on = false; + } + } + return out; +} + +/** # 函数 解析 SQL 值元组 */ +function parseTuples(s: string): string[][] { + const out: string[][] = []; + let p = s.indexOf("("); + while (p >= 0 && p < s.length) { + const values: string[] = []; + let cur = ""; + let inQ = false; + let esc = false; + let depth = 0; + p++; + for (; p < s.length; p++) { + const ch = s[p]!; + if (esc) { cur += ch; esc = false; continue; } + if (inQ) { + if (ch === "\\") { esc = true; continue; } + if (ch === "'") { inQ = false; continue; } + cur += ch; + continue; + } + if (ch === "'") { inQ = true; continue; } + if (ch === "(") { depth++; cur += ch; continue; } + if (ch === ")") { + if (depth === 0) { values.push(cur); cur = ""; p++; break; } + depth--; + cur += ch; + continue; + } + if (ch === "," && depth === 0) { values.push(cur); cur = ""; continue; } + cur += ch; + } + out.push(values); + p = s.indexOf("(", p); + } + return out; +} + +const lines = (await Bun.file(DUMP).text()).split("\n"); + +// term_id → name +const nameById = new Map(); +for (const seg of tableInserts(lines, "wp_terms")) { + for (const v of parseTuples(seg)) { + if (v[0]) nameById.set(Number(v[0]), v[1] ?? ""); + } +} +console.log("terms:", nameById.size); + +// taxonomy → [term names](按 dump 插入顺序) +const byTax = new Map(); +for (const seg of tableInserts(lines, "wp_term_taxonomy")) { + for (const v of parseTuples(seg)) { + const taxonomy = v[2] ?? ""; + const termId = Number(v[1]); + if (!taxonomy.startsWith("pa_")) continue; + const name = nameById.get(termId); + if (!name) continue; + const list = byTax.get(taxonomy) ?? []; + list.push(name); + byTax.set(taxonomy, list); + } +} + +let updated = 0; +for (const [taxonomy, names] of byTax) { + const attrSlug = taxonomy.slice(3); + for (let i = 0; i < names.length; i++) { + const res = await db` + UPDATE product_attribute_values v SET sort = ${i} + FROM product_attributes a + WHERE v.attribute_id = a.id AND a.slug = ${attrSlug} AND lower(trim(v.value)) = lower(trim(${names[i]!}))`; + updated += res.count; + } + console.log(taxonomy, "→", names.length, "terms"); +} +console.log("updated rows:", updated); +await db.end(); diff --git a/scripts/import-product-menu-order.ts b/scripts/import-product-menu-order.ts new file mode 100644 index 000000000..10e244850 --- /dev/null +++ b/scripts/import-product-menu-order.ts @@ -0,0 +1,77 @@ +/** + * # 脚本 导入产品 menu_order(WooCommerce 默认排序依据) + * 原站 /shop/ 默认排序 = wp_posts.menu_order ASC(取证:DSOM-040R=1、DSOM-110R=3、 + * DSOM-130N=4,与线上商品顺序一致)。本项目 products 表无 menu_order 列, + * 故写入 products.meta.menu_order,listProducts 默认排序读取该字段。 + * 数据源:legacy dump 的 wp_posts 全部 INSERT 段(每段 23 字段,menu_order 为第 20 列)。 + * 运行:bun scripts/import-product-menu-order.ts + */ +import { SQL } from "bun"; + +const DUMP = "/home/bigcode/data/dev/sites/dusuniot/legacy/dusuniot-db-20261029.sql"; +const db = new SQL("postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot"); + +/** # 函数 解析 SQL 值元组(仅支持字符串/数字,处理反斜杠转义与括号深度) */ +function parseTuples(s: string, from: number): string[][] { + const out: string[][] = []; + let p = from; + while (p < s.length) { + if (s[p] === ";") break; + if (s[p] !== "(") { p++; continue; } + const values: string[] = []; + let cur = ""; + let inQ = false; + let esc = false; + let depth = 0; + p++; + for (; p < s.length; p++) { + const ch = s[p]!; + if (esc) { cur += ch; esc = false; continue; } + if (inQ) { + if (ch === "\\") { esc = true; continue; } + if (ch === "'") { inQ = false; continue; } + cur += ch; + continue; + } + if (ch === "'") { inQ = true; continue; } + if (ch === "(") { depth++; cur += ch; continue; } + if (ch === ")") { + if (depth === 0) { values.push(cur); cur = ""; p++; break; } + depth--; + cur += ch; + continue; + } + if (ch === "," && depth === 0) { values.push(cur); cur = ""; continue; } + cur += ch; + } + out.push(values); + } + return out; +} + +const text = await Bun.file(DUMP).text(); +const rows: string[][] = []; +let idx = 0; +while (true) { + const i = text.indexOf("INSERT INTO `wp_posts`", idx); + if (i < 0) break; + const start = text.indexOf("(", i); + rows.push(...parseTuples(text, start)); + idx = text.indexOf(";", start); + if (idx < 0) break; +} +const products = rows.filter((v) => v[20] === "product"); +console.log("dump product rows:", products.length); + +let updated = 0; +for (const v of products) { + const legacyId = Number(v[0]); + const order = Number(v[19] || 0); + if (!legacyId) continue; + const res = await db` + UPDATE products SET meta = COALESCE(meta, '{}'::jsonb) || jsonb_build_object('menu_order', ${order}) + WHERE legacy_id = ${legacyId}`; + if (res.count) updated += res.count; +} +console.log("updated products:", updated); +await db.end(); diff --git a/scripts/import-shop-template.ts b/scripts/import-shop-template.ts new file mode 100644 index 000000000..9d0a6010e --- /dev/null +++ b/scripts/import-shop-template.ts @@ -0,0 +1,42 @@ +/** + * # 脚本 导入产品归档页 Elementor 模板(legacy elementor_library post 1398) + * 原站 /shop/ 与产品分类归档共用 archive 模板 post 1398(elementor_location=archive): + * - a2ab15c:面包屑 hero(bg #F4F5F8 / padding 0 30 / content 1400) + * - 65176b9:两列(20% WPC 筛选 + 80% 标题 + WooCommerce 商品归档) + * - 61b1990:移动端归档描述(hide_desktop) + * 写入 contents(type=template, slug=shop-archive, meta.elementor),供 + * collection 路由以 ElementorRenderer 直出外壳,筛选/商品网格按 widget id 注入。 + * 运行:bun scripts/import-shop-template.ts + */ +import { SQL } from "bun"; + +const DUMP = "/home/bigcode/data/dev/sites/dusuniot/legacy/dusuniot-db-20261029.sql"; +const db = new SQL("postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot"); + +/** # 函数 从 dump 提取指定 post 的 _elementor_data */ +async function extractTemplate(postId: number): Promise[]> { + const text = await Bun.file(DUMP).text(); + const re = new RegExp("\\(\\d+," + postId + ",'_elementor_data','((?:[^'\\\\]|\\\\.)*)'\\)"); + const m = text.match(re); + if (!m) throw new Error(`dump 中未找到 post ${postId} 的 _elementor_data`); + const json = m[1]!.replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + const data = JSON.parse(json); + if (!Array.isArray(data)) throw new Error("_elementor_data 不是数组"); + return data as Record[]; +} + +const elements = await extractTemplate(1398); +const meta = JSON.stringify({ elementor: elements }); +const existing = await db`SELECT id FROM contents WHERE slug='shop-archive' AND type='template'`; +if (existing.length) { + await db`UPDATE contents SET meta=${meta}::jsonb, status='published', updated_at=now() WHERE id=${existing[0]!.id}`; + console.log("已更新 shop-archive 模板,contents.id =", existing[0]!.id); +} else { + const inserted = await db` + INSERT INTO contents (type, slug, title, status, meta, legacy_id, published_at) + VALUES ('template', 'shop-archive', 'Shop Archive', 'published', ${meta}::jsonb, 1398, now()) + RETURNING id`; + console.log("已插入 shop-archive 模板,contents.id =", inserted[0]!.id); +} +console.log("顶层节:", elements.map((el) => el.id).join(", ")); +await db.end(); diff --git a/src/components/elementor/Renderer.tsx b/src/components/elementor/Renderer.tsx index 160c26e27..ba419c7b9 100644 --- a/src/components/elementor/Renderer.tsx +++ b/src/components/elementor/Renderer.tsx @@ -273,24 +273,46 @@ const PostsDataContext = createContext>({}); /** # 成员变量 WordPress 短代码渲染上下文(widget id → 预渲染节点;页脚 menu-2/gtranslate) **/ const ShortcodesContext = createContext>({}); +/** # 类型 渲染器动态数据(面包屑等 WP 动态 widget 的 SSR 上下文) */ +export type RendererVars = { + /** # 成员变量 面包屑路径(末项无 url 表示当前页) **/ + breadcrumb?: { label: string; url?: string }[]; +}; + +/** # 成员变量 动态数据上下文(woocommerce-breadcrumb 等 widget 读取) **/ +const VarsContext = createContext({}); + /** # 组件 Elementor 渲染器入口 * 参数 shortcodes 短代码 widget 的预渲染内容(键为 widget id;缺省渲染为空) + * 参数 vars 动态数据(面包屑路径等) */ -export function ElementorRenderer({ doc, postsData, shortcodes }: { doc: ElDocument; postsData?: Record; shortcodes?: Record }) { +export function ElementorRenderer({ + doc, + postsData, + shortcodes, + vars, +}: { + doc: ElDocument; + postsData?: Record; + shortcodes?: Record; + vars?: RendererVars; +}) { const elements = normalizeDoc(doc); if (!elements.length) return null; const css = collectCustomCss(elements) + collectResponsiveCss(elements); return ( - - -
- {css ?