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
+79 -24
View File
@@ -1,29 +1,30 @@
/**
* # 路由 产品列表 /collection/
* 列出全部产品(无分类限定)+ 顶部分类卡片;带分页。
* # 路由 产品列表 /collection/(原站 /shop/ 的等价页)
* 原站 /shop/ 使用 WooCommerce 归档模板(elementor_library post 1398):
* - a2ab15c:面包屑 hero(bg #F4F5F8)
* - 65176b9:左侧 WPC 筛选(20% 列)+ 右侧 “IoT Products” 标题、排序、24 商品 4 列网格、分页
* 本路由以 ElementorRenderer 直出该模板外壳,按 widget id 注入:
* - 98d2008 → ShopFilters(属性复选筛选,URL query 驱动)
* - d761846 → ShopProducts(WooCommerce 归档网格 + 排序 + 分页)
* 模板缺失时降级为内置分类卡片 + 简单网格。
*/
import { createFileRoute, Link } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getLayoutData, Layout } from "~/components/website/Layout";
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
import { ProductCard } from "~/components/website/ProductCard";
import { Pagination } from "~/components/website/Pagination";
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
import { parseCollectionSearch, searchToFilters, type CollectionSearch } from "~/components/website/collection-shared";
import { getProductCategoryTree, listProducts, type ProductListResult } from "~/server/data";
import { ShopFilters } from "~/components/website/ShopFilters";
import { ShopProducts, orderByToSort } from "~/components/website/ShopProducts";
import { getFilterConfig, getProductCategoryTree, getShopTemplate, listProducts, type ProductListResult } from "~/server/data";
import { useT } from "~/i18n";
export const Route = createFileRoute("/collection/")({
validateSearch: parseCollectionSearch as (search: Record<string, unknown>) => CollectionSearch,
loaderDeps: ({ search }) => ({ search }),
loader: async ({ deps }) => {
const [layout, cats, list] = await Promise.all([
getLayoutData(),
getProductCategoryTree().catch(() => []),
listProducts({ page: deps.search.page, filters: searchToFilters(deps.search), pageSize: 24 }).catch(
() => ({ items: [], total: 0, page: 1, pageSize: 24 }) as ProductListResult,
),
]);
return { layout, cats, list };
},
loader: async ({ deps }) => loadCollection({ data: deps.search }),
head: () => ({
meta: [
{ title: "All Products | IoT Gateways & Devices | Dusun IoT" },
@@ -33,12 +34,72 @@ export const Route = createFileRoute("/collection/")({
component: CollectionIndexPage,
});
/** # 函数 产品归档页数据加载(Server Function:客户端导航时由服务端执行,
* 避免把 db/查询层打进浏览器包;返回纯 JSON) */
const loadCollection = createServerFn({ method: "GET" })
.validator((d: CollectionSearch) => d)
.handler(async ({ data: search }) => {
const filters = searchToFilters(search);
const [layout, cats, list, shop, filterConfig] = await Promise.all([
getLayoutData(),
getProductCategoryTree().catch(() => []),
listProducts({ page: search.page, filters, pageSize: 24, sort: orderByToSort(search.orderby) }).catch(
() => ({ items: [], total: 0, page: 1, pageSize: 24 }) as ProductListResult,
),
getShopTemplate().catch(() => null),
getFilterConfig().catch(() => []),
]);
// Elementor 文档是递归 JSON,TanStack 序列化校验无法展开;运行时为纯 JSON
return { layout, cats, list, shop, filters, filterConfig, search } as any;
});
/** # 类型 归档页 loader 数据 */
type CollectionData = {
layout: any;
cats: { id: number; name: string; slug: string; parentId: number | null; count: number }[];
list: ProductListResult;
shop: { meta?: unknown } | null;
filters: Record<string, string[]>;
filterConfig: { name: string; slug: string; values: { value: string; slug: string; count: number }[] }[];
search: CollectionSearch;
};
/** # 组件 全部产品列表页 */
function CollectionIndexPage() {
const t = useT();
const data = Route.useLoaderData();
const shopDoc = extractElementor(data.shop?.meta);
if (shopDoc) {
return (
<Layout layout={data.layout}>
<ElementorRenderer
doc={shopDoc}
vars={{ breadcrumb: [{ label: "Home", url: "/" }, { label: "Shop" }] }}
shortcodes={{
"98d2008": (
<ShopFilters config={data.filterConfig} selected={data.filters} total={data.list.total} search={data.search} />
),
"d761846": (
<ShopProducts
items={data.list.items}
total={data.list.total}
page={data.list.page}
pageSize={data.list.pageSize}
search={data.search}
/>
),
}}
/>
</Layout>
);
}
return <FallbackCollectionPage data={data} />;
}
/** # 组件 降级列表页(归档模板缺失时) */
function FallbackCollectionPage(props: { data: CollectionData }) {
const t = useT();
const data = props.data;
const topCats = data.cats.filter((c) => !c.parentId && c.slug !== "uncategorized");
const page = data.list.page;
return (
<Layout layout={data.layout}>
<div className="site-container">
@@ -55,14 +116,13 @@ function CollectionIndexPage() {
<section className="mt-5 mb-8">
<div className="grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
{topCats.map((c) => (
<Link key={c.id} to="/collection/$slug/" params={{ slug: c.slug }} className="block overflow-hidden rounded-lg border border-line bg-white text-brand transition-[box-shadow,transform] duration-200 hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(16,42,76,0.1)]">
<Link key={c.id} to="/collection/$slug/" params={{ slug: c.slug }} className="block overflow-hidden rounded-lg border border-line bg-white text-brand transition-[box-shadow,transform] duration-200 hover:-translate-y-0.5">
<div className="px-4 pt-3.5 pb-[18px]">
<div className="mb-1.5 text-[15px] leading-[1.45] font-semibold">{c.name}</div>
<div className="text-[13px] text-muted-foreground">{t("collection_products_count", { total: Number(c.count) })}</div>
</div>
</Link>
))}
{topCats.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">Product categories are being updated.</p> : null}
</div>
</section>
@@ -70,14 +130,9 @@ function CollectionIndexPage() {
{data.list.items.map((p) => (
<ProductCard key={p.id} p={p} />
))}
{data.list.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">No products found. They may still be syncing — please check back soon.</p> : null}
{data.list.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">No products found.</p> : null}
</div>
<Pagination
page={page}
total={data.list.total}
pageSize={data.list.pageSize}
makeHref={(p) => `/collection/?page=${p}`}
/>
<Pagination page={data.list.page} total={data.list.total} pageSize={data.list.pageSize} makeHref={(p) => `/collection/?page=${p}`} />
</div>
</Layout>
);