v0.10.21: 产品归档页 C1-C4(原站 /shop/ 模板 1398 + WPC 筛选 + WooCommerce 网格)

- 导入 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
This commit is contained in:
bigcode
2026-11-02 12:32:18 +08:00
parent 5ff5f9d477
commit ddeeca3293
12 changed files with 866 additions and 46 deletions
+51 -7
View File
@@ -273,24 +273,46 @@ const PostsDataContext = createContext<Record<string, PostCardItem[]>>({});
/** # 成员变量 WordPress 短代码渲染上下文(widget id → 预渲染节点;页脚 menu-2/gtranslate) **/
const ShortcodesContext = createContext<Record<string, ReactNode>>({});
/** # 类型 渲染器动态数据(面包屑等 WP 动态 widget 的 SSR 上下文) */
export type RendererVars = {
/** # 成员变量 面包屑路径(末项无 url 表示当前页) **/
breadcrumb?: { label: string; url?: string }[];
};
/** # 成员变量 动态数据上下文(woocommerce-breadcrumb 等 widget 读取) **/
const VarsContext = createContext<RendererVars>({});
/** # 组件 Elementor 渲染器入口
* 参数 shortcodes 短代码 widget 的预渲染内容(键为 widget id;缺省渲染为空)
* 参数 vars 动态数据(面包屑路径等)
*/
export function ElementorRenderer({ doc, postsData, shortcodes }: { doc: ElDocument; postsData?: Record<string, PostCardItem[]>; shortcodes?: Record<string, ReactNode> }) {
export function ElementorRenderer({
doc,
postsData,
shortcodes,
vars,
}: {
doc: ElDocument;
postsData?: Record<string, PostCardItem[]>;
shortcodes?: Record<string, ReactNode>;
vars?: RendererVars;
}) {
const elements = normalizeDoc(doc);
if (!elements.length) return null;
const css = collectCustomCss(elements) + collectResponsiveCss(elements);
return (
<PostsDataContext.Provider value={postsData || {}}>
<ShortcodesContext.Provider value={shortcodes || {}}>
<div className="content-html">
{css ? <style dangerouslySetInnerHTML={{ __html: css }} /> : null}
<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>
</ShortcodesContext.Provider>
</PostsDataContext.Provider>
</ShortcodesContext.Provider>
</PostsDataContext.Provider>
</VarsContext.Provider>
);
}
@@ -644,6 +666,9 @@ function WidgetNodeInner({ el }: { el: ElElement }) {
const w = s(el.widgetType);
const settings = resolveGlobals(el.settings || {});
const shortcodes = useContext(ShortcodesContext);
// 任意 widget 可由调用方按 id 注入实现(WooCommerce 归档/WPC 筛选等动态插件 widget)
const injected = el.id ? shortcodes[el.id] : undefined;
if (injected !== undefined) return <>{injected}</>;
const unknownFallback = () => {
const title = s(settings.heading_title) || s(settings.title);
const text = s(settings.editor) || s(settings.html) || s(settings.text);
@@ -658,6 +683,8 @@ function WidgetNodeInner({ el }: { el: ElElement }) {
switch (w) {
case "heading":
return <HeadingWidget settings={settings} />;
case "woocommerce-breadcrumb":
return <BreadcrumbWidget />;
case "text-editor":
return <TextEditorWidget settings={settings} />;
case "image":
@@ -746,6 +773,23 @@ function WidgetNodeInner({ el }: { el: ElElement }) {
}
}
/** # 组件 woocommerce-breadcrumb widget:面包屑(路径由 vars.breadcrumb 注入;
* 原站归档页模板 1398 的 hero 即此 widget,末项为当前页、前面项带链接) */
function BreadcrumbWidget() {
const vars = useContext(VarsContext);
const trail = vars.breadcrumb?.length ? vars.breadcrumb : [{ label: "Home", url: "/" }, { label: "Shop" }];
return (
<nav className="woocommerce-breadcrumb" aria-label="Breadcrumb">
{trail.map((n, i) => (
<Fragment key={`${n.label}-${i}`}>
{i > 0 ? <span className="ds-crumb-sep" dangerouslySetInnerHTML={{ __html: "&nbsp;/&nbsp;" }} /> : null}
{n.url && i < trail.length - 1 ? <a href={n.url}>{n.label}</a> : <span>{n.label}</span>}
</Fragment>
))}
</nav>
);
}
/** # 组件 heading widget:标题(可带 link,is_external → target=_blank;title 支持 HTML) */
function HeadingWidget({ settings }: { settings: Record<string, unknown> }) {
// 标签合法域同原站 Utils::validate_html_tag(h1..h6 / div / span / p);默认 h2
+201
View File
@@ -0,0 +1,201 @@
/**
* # 组件 商店筛选侧栏 ShopFilters(对齐原站 /shop/ 的 WPC Filter Everything 组件)
* 原站结构(filter-everything 插件,filter set 25674):
* .wpc-filters-main-wrap > .wpc-filters-widget-content > .wpc-filters-widget-wrapper
* > .wpc-filters-section ×12(.wpc-filter-title 灰底标题 + .wpc-filters-ul-list 复选框列表)
* - 顺序固定:Product Type / Chip Platform / CPU Name / System support / Downlink / Uplink /
* Interface / RAM / Flash / Back-up Battery / Operating Temperature / Install
* - 后三组默认折叠(标题为按钮,带展开箭头);
* - 术语行 = 复选框 + 名称 + (计数);长列表(>8)显示 “Show more/less”;
* - 勾选切换写入 URL query(属性 slug=值1,值2),页面按筛选重查(本站等价实现)。
*/
import { useMemo, useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import type { CollectionSearch } from "~/components/website/collection-shared";
/** # 类型 单个筛选器定义(来自 getFilterConfig) */
export type ShopFilterConfig = {
name: string;
slug: string;
values: { value: string; slug: string; count: number }[];
};
/** # 常量 筛选器展示顺序(原站 WPC filter set 25674 的 section 顺序) **/
const FILTER_ORDER = [
"product-type",
"chip-platform",
"cpu",
"os",
"downlink",
"uplink",
"interface",
"ram",
"flash",
"battery",
"operating-temperature",
"install",
];
/** # 常量 默认折叠的筛选组(原站实测末三组标题为按钮且内容 display:none) **/
const COLLAPSED_BY_DEFAULT = new Set(["battery", "operating-temperature", "install"]);
/** # 函数 从 search 组装 URL query(保留 orderby 等键) */
function searchToQuery(search: CollectionSearch): string {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(search)) {
if (k !== "page" && v) p.set(k, String(v));
}
// 任何筛选变化都回到第 1 页(原站 WPC 行为)
const qs = p.toString();
return qs ? `?${qs}` : "";
}
/** # 常量 长列表筛选组(WPC wpc-show-more:默认只显示 5 项 + See more) **/
const SHOW_MORE_SLUGS = new Set(["cpu", "os", "interface", "ram", "flash"]);
/** # 组件 商店筛选侧栏 */
export function ShopFilters(props: {
config: ShopFilterConfig[];
selected: Record<string, string[]>;
total: number;
search: CollectionSearch;
}) {
const navigate = useNavigate();
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [showAll, setShowAll] = useState<Record<string, boolean>>({});
// 按固定顺序排列,并剔除原站未展示的 network-device
const sections = useMemo(() => {
const map = new Map(props.config.map((c) => [c.slug, c]));
return FILTER_ORDER.map((slug) => map.get(slug)).filter((x): x is ShopFilterConfig => !!x);
}, [props.config]);
/** # 函数 勾选/取消某个术语(写 URL query 并回到第 1 页) */
function toggle(attrSlug: string, valueSlug: string) {
const current = new Set(props.selected[attrSlug] || []);
if (current.has(valueSlug)) current.delete(valueSlug);
else current.add(valueSlug);
const next: CollectionSearch = { ...props.search };
delete next.page;
if (current.size) next[attrSlug] = [...current].join(",");
else delete next[attrSlug];
navigate({ to: "/collection/", search: next });
}
/** # 函数 清空全部筛选 */
function clearAll() {
const next: CollectionSearch = {};
if (props.search.orderby) next.orderby = props.search.orderby;
navigate({ to: "/collection/", search: next });
}
const selectedChips = sections.flatMap((s) =>
(props.selected[s.slug] || []).map((v) => ({ attr: s.slug, value: v, label: s.values.find((x) => x.slug === v)?.value || v })),
);
return (
<div className="wpc-filters-main-wrap wpc-filter-set-25674" data-wpc-hide-empty="yes" data-set="25674">
<div className="wpc-filters-widget-content wpc-show-counts-yes">
<div className="wpc-filters-widget-containers-wrapper">
{/* 已选条件 chips(原站顶部区域内联展示) */}
<div className="wpc-filters-widget-top-container">
<div className="wpc-widget-top-inside">
<div className="wpc-inner-widget-chips-wrapper">
<ul className={"wpc-filter-chips-list" + (selectedChips.length ? "" : " wpc-empty-chips-container")}>
{selectedChips.map((c) => (
<li key={`${c.attr}-${c.value}`} className="wpc-filter-chip">
<span>{c.label}</span>
<button type="button" aria-label={`Remove ${c.label}`} onClick={() => toggle(c.attr, c.value)}>
×
</button>
</li>
))}
</ul>
{selectedChips.length ? (
<button type="button" className="wpc-clear-all" onClick={clearAll}>
Clear all
</button>
) : null}
</div>
</div>
</div>
<div className="wpc-filter-set-widget-title">Filters</div>
<div className="wpc-filters-scroll-container">
<div className="wpc-filters-widget-wrapper">
{/* 原站 .wpc-posts-found 因 hide-empty 配置无文字,仅保留 24px 占位 */}
<div className="wpc-posts-found" data-found={props.total} aria-hidden="true" />
{sections.map((sec, idx) => {
const collapsed = COLLAPSED_BY_DEFAULT.has(sec.slug) && !expanded[sec.slug];
const all = showAll[sec.slug];
const limited = SHOW_MORE_SLUGS.has(sec.slug);
const values = all || !limited ? sec.values : sec.values.slice(0, 5);
return (
<div
key={sec.slug}
className={"wpc-filters-section wpc-filter-" + sec.slug + " wpc-filter-taxonomy wpc-filter-layout-checkboxes" + (collapsed ? " wpc-filter-collapsed" : "")}
data-fid={idx}
>
<div className="wpc-filter-header">
{COLLAPSED_BY_DEFAULT.has(sec.slug) ? (
<button
type="button"
className="widget-title wpc-filter-title"
aria-expanded={!collapsed}
onClick={() => setExpanded((e) => ({ ...e, [sec.slug]: !e[sec.slug] }))}
>
<span className="wpc-wrap-icons">{sec.name}</span>
<span className="wpc-open-icon" aria-hidden="true" />
</button>
) : (
<div className="widget-title wpc-filter-title">{sec.name}</div>
)}
</div>
{collapsed ? null : (
<div className="wpc-filter-content">
<ul className="wpc-filters-ul-list wpc-filters-checkboxes">
{values.map((v) => {
const checked = (props.selected[sec.slug] || []).includes(v.slug);
return (
<li key={v.slug} className={"wpc-checkbox-item wpc-term-item" + (checked ? " wpc-term-selected" : "")}>
<div className="wpc-term-item-content-wrapper">
<input
type="checkbox"
checked={checked}
readOnly
aria-label={v.value}
id={`wpc-checkbox-${sec.slug}-${v.slug}`}
onClick={() => toggle(sec.slug, v.slug)}
/>
<label htmlFor={`wpc-checkbox-${sec.slug}-${v.slug}`}>
<span className="wpc-filter-link">{v.value}</span>
<span className="wpc-term-count">
<span>(</span>
<span className="wpc-term-count-value">{v.count}</span>
<span>)</span>
</span>
</label>
</div>
</li>
);
})}
</ul>
{limited && sec.values.length > 5 ? (
<div className="wpc-filter-more-less">
<button type="button" className="wpc-toggle-a" onClick={() => setShowAll((s) => ({ ...s, [sec.slug]: !s[sec.slug] }))}>
{all ? "See less" : "See more"}
</button>
</div>
) : null}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
/**
* # 组件 商店商品区 ShopProducts(对齐原站 /shop/ WooCommerce 归档 + Elementor skin)
* 原站结构(elementor 模板 1398 的 wc-archive-products widget d761846):
* .woocommerce > .woocommerce-result-count + form.woocommerce-ordering + ul.products(4 列)
* > li.product ×24(图/标题/Read more)+ nav.woocommerce-pagination。
* 卡片样式由 Elementor skin 生成(白底 1px #C9CEE8 边框、图 1:1、标题 16/600/#444、
* 按钮白底橙字描边圆角 8),见 app.css「Shop archive」块。
*/
import { Link, useNavigate } from "@tanstack/react-router";
import type { CollectionSearch } from "~/components/website/collection-shared";
/** # 类型 商品卡数据(listProducts.items 子集) */
export type ShopProduct = {
id: number;
slug: string;
title: string;
sku: string | null;
imageUrl: string | null;
imageAlt?: string | null;
};
/** # 常量 WooCommerce 排序项(原站 orderby 下拉) **/
const ORDER_OPTIONS = [
{ value: "menu_order", label: "Default sorting" },
{ value: "popularity", label: "Sort by popularity" },
{ value: "date", label: "Sort by latest" },
{ value: "price", label: "Sort by price: low to high" },
{ value: "price-desc", label: "Sort by price: high to low" },
];
/** # 函数 原站 orderby → listProducts sort */
export function orderByToSort(orderby: string | undefined): "default" | "views" | "latest" | "price" | "price-desc" {
switch (orderby) {
case "popularity": return "views";
case "date": return "latest";
case "price": return "price";
case "price-desc": return "price-desc";
default: return "default";
}
}
/** # 函数 组装带筛选与排序的 URL(保留全部 search 键) */
function hrefFor(search: CollectionSearch, page: number): string {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(search)) {
if (k !== "page" && v) p.set(k, String(v));
}
if (page > 1) p.set("page", String(page));
const qs = p.toString();
return `/collection/${qs ? `?${qs}` : ""}`;
}
/** # 组件 商店商品区 */
export function ShopProducts(props: {
items: ShopProduct[];
total: number;
page: number;
pageSize: number;
search: CollectionSearch;
}) {
const navigate = useNavigate();
const totalPages = Math.max(1, Math.ceil(props.total / props.pageSize));
const start = (props.page - 1) * props.pageSize + 1;
const end = Math.min(props.total, props.page * props.pageSize);
const orderby = props.search.orderby || "menu_order";
// 页码窗口:原站 6 页全展示;页数多时用省略号(WooCommerce 默认行为)
const pages: (number | "…")[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
const from = Math.max(2, props.page - 1);
const to = Math.min(totalPages - 1, props.page + 1);
if (from > 2) pages.push("…");
for (let i = from; i <= to; i++) pages.push(i);
if (to < totalPages - 1) pages.push("…");
pages.push(totalPages);
}
return (
<div className="woocommerce ds-cols-4">
<div className="woocommerce-notices-wrapper" />
{props.total > 0 ? (
<p className="woocommerce-result-count" role="status">
Showing {start}–{end} of {props.total} results
</p>
) : (
<p className="woocommerce-result-count">It seems we can&apos;t find what you&apos;re looking for.</p>
)}
<form className="woocommerce-ordering" method="get" onSubmit={(e) => e.preventDefault()}>
<select
name="orderby"
className="orderby"
aria-label="Shop order"
value={orderby}
onChange={(e) => {
const next: CollectionSearch = { ...props.search };
delete next.page;
if (e.target.value === "menu_order") delete next.orderby;
else next.orderby = e.target.value;
navigate({ to: "/collection/", search: next });
}}
>
{ORDER_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<input type="hidden" name="paged" value={String(props.page)} />
</form>
<ul className="products elementor-grid ds-cols-4">
{props.items.map((p, i) => (
<li key={p.id} className={"product type-product status-publish has-post-thumbnail product-type-simple" + (i === 0 ? " first" : "")}>
<Link to="/product/$slug/" params={{ slug: p.slug }} className="woocommerce-LoopProduct-link woocommerce-loop-product__link">
{p.imageUrl ? (
<img
loading="lazy"
width={261}
height={261}
src={p.imageUrl}
className="attachment-woocommerce_thumbnail size-woocommerce_thumbnail"
alt={p.imageAlt || p.title}
/>
) : null}
<h2 className="woocommerce-loop-product__title">{p.title}</h2>
</Link>
<div className="woocommerce-loop-product__buttons">
<Link to="/product/$slug/" params={{ slug: p.slug }} className="button product_type_simple">
Read more
</Link>
</div>
</li>
))}
</ul>
{totalPages > 1 ? (
<nav className="woocommerce-pagination" aria-label="Product Pagination">
<ul className="page-numbers">
{props.page > 1 ? (
<li>
<a className="prev page-numbers" aria-label={`Page ${props.page - 1}`} href={hrefFor(props.search, props.page - 1)}>
←
</a>
</li>
) : null}
{pages.map((pg, i) =>
pg === "…" ? (
<li key={`e${i}`}><span className="page-numbers dots">…</span></li>
) : (
<li key={pg}>
{pg === props.page ? (
<span aria-label={`Page ${pg}`} aria-current="page" className="page-numbers current">{pg}</span>
) : (
<a aria-label={`Page ${pg}`} className="page-numbers" href={hrefFor(props.search, pg)}>{pg}</a>
)}
</li>
),
)}
{props.page < totalPages ? (
<li>
<a className="next page-numbers" aria-label={`Page ${props.page + 1}`} href={hrefFor(props.search, props.page + 1)}>
→
</a>
</li>
) : null}
</ul>
</nav>
) : null}
</div>
);
}
+9 -7
View File
@@ -10,13 +10,13 @@
import type { FilterState } from "~/server/data";
/** # 类型 /collection 的扁平 search 参数:page 数字 + 其余属性筛选键(逗号分隔值) */
export type CollectionSearch = { page?: number } & Record<string, string | undefined>;
export type CollectionSearch = { page?: number; orderby?: string } & Record<string, string | number | undefined>;
/** # 函数 解析 collection search 参数(保持扁平原样,仅规范化 page) */
export function parseCollectionSearch(search: Record<string, unknown>): CollectionSearch {
const out: Record<string, string | undefined> = {};
const out: Record<string, string | number | undefined> = {};
const page = Number(search.page);
if (Number.isFinite(page) && page > 1) out.page = String(Math.floor(page));
if (Number.isFinite(page) && page > 1) out.page = Math.floor(page);
for (const [k, v] of Object.entries(search)) {
if (k === "page" || v == null) continue;
if (Array.isArray(v)) {
@@ -29,11 +29,13 @@ export function parseCollectionSearch(search: Record<string, unknown>): Collecti
return out as CollectionSearch;
}
/** # 函数 扁平 search → 结构化筛选状态(每个属性键按逗号拆分为值数组) */
/** # 函数 扁平 search → 结构化筛选状态(每个属性键按逗号拆分为值数组)
* orderby(WooCommerce 排序)不是筛选字段,跳过
*/
export function searchToFilters(search: CollectionSearch): FilterState {
const filters: FilterState = {};
for (const [k, v] of Object.entries(search)) {
if (k === "page" || v == null) continue;
if (k === "page" || k === "orderby" || v == null) continue;
const values = String(v)
.split(",")
.map((x) => x.trim())
@@ -45,13 +47,13 @@ export function searchToFilters(search: CollectionSearch): FilterState {
/** # 函数 结构化筛选状态 → 扁平 search(navigate 用,page > 1 时附带) */
export function filtersToSearch(filters: FilterState | undefined, page: number): CollectionSearch {
const out: Record<string, string | undefined> = {};
const out: Record<string, string | number | undefined> = {};
if (filters) {
for (const [k, vals] of Object.entries(filters)) {
if (vals.length) out[k] = vals.join(",");
}
}
if (page > 1) out.page = String(page);
if (page > 1) out.page = page;
return out as CollectionSearch;
}