v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)

This commit is contained in:
BigCode
2026-11-01 00:15:29 +08:00
commit 5b83e71ad9
128 changed files with 20008 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
/**
* # 路由 /shop/ 全部产品列表页
* 原站 /shop/ 形态:左栏属性筛选(复选框,URL search params 驱动)+ 主区
* 「IoT Products / Showing x–y of z results」+ 产品网格 + 分页。
*/
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { getLayoutData, Layout } from "~/components/website/Layout";
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, filtersToSearch, type CollectionSearch } from "~/components/website/collection-shared";
import { getFilterConfig, listProducts, type FilterState, type ProductListResult } from "~/server/data";
import { useT } from "~/i18n";
export const Route = createFileRoute("/shop")({
validateSearch: parseCollectionSearch as (search: Record<string, unknown>) => CollectionSearch,
loaderDeps: ({ search }) => ({ search }),
loader: async ({ deps }) => {
const [layout, filterConfig] = await Promise.all([
getLayoutData(),
getFilterConfig().catch(() => []),
]);
const list = await listProducts({
filters: searchToFilters(deps.search),
page: deps.search.page,
pageSize: 24,
}).catch(() => ({ items: [], total: 0, page: 1, pageSize: 24 }) as ProductListResult);
return { layout, filterConfig, list };
},
head: () => ({
meta: [
{ title: "All Products - DusunIoT" },
{ name: "description", content: "Browse all Dusun IoT gateways and modules." },
],
}),
component: ShopPage,
});
/** # 函数 组件 /shop/ 页面 */
function ShopPage() {
const t = useT();
const data = Route.useLoaderData();
const search = Route.useSearch();
const router = useRouter();
const page = data.list.page;
const from = (page - 1) * data.list.pageSize + (data.list.items.length ? 1 : 0);
const to = (page - 1) * data.list.pageSize + data.list.items.length;
/** # 函数 更新筛选:切换某属性值后 navigate(重置到第 1 页) */
function toggleFilter(attrSlug: string, valueSlug: string) {
const filters: FilterState = searchToFilters(search);
const cur = filters[attrSlug] || [];
const next = cur.includes(valueSlug) ? cur.filter((v) => v !== valueSlug) : [...cur, valueSlug];
if (next.length) filters[attrSlug] = next;
else delete filters[attrSlug];
router.navigate({ to: "/shop/", search: filtersToSearch(filters, 1) });
}
return (
<Layout layout={data.layout}>
<div className="site-container">
<Breadcrumb aria-label="Breadcrumb">
<BreadcrumbList>
<BreadcrumbItem><BreadcrumbLink to="/">{t("nav_home")}</BreadcrumbLink></BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem><BreadcrumbPage>{t("nav_shop")}</BreadcrumbPage></BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="grid grid-cols-1 items-start gap-7 min-[900px]:grid-cols-[250px_1fr]">
{/* 左栏属性筛选(与原站 /shop/ 一致:仅属性过滤器,无分类树) */}
<aside>
{data.filterConfig.map((f) => (
<div className="mb-[18px] rounded-lg border border-line bg-soft p-4" key={f.slug}>
<h3 className="m-0 mb-2.5 text-[15px]">{f.name}</h3>
<ul className="m-0 list-none p-0">
{f.values.map((v) => {
const checked = (searchToFilters(search)[f.slug] || []).includes(v.slug);
return (
<li key={v.slug} className="mb-1.5">
<label className="flex cursor-pointer items-center gap-2">
<input type="checkbox" checked={checked} onChange={() => toggleFilter(f.slug, v.slug)} />
<span className="flex-1">{v.value}</span>
<span className="text-xs text-muted-foreground">({Number(v.count)})</span>
</label>
</li>
);
})}
{f.values.length === 0 ? <li className="text-muted-foreground">{t("collection_no_options")}</li> : null}
</ul>
</div>
))}
</aside>
<div>
<h1 className="mt-0 text-[26px]">{t("shop_iot_products")}</h1>
<p className="text-muted-foreground">
{data.list.total > 0 ? t("shop_showing", { from, to, total: data.list.total }) : t("shop_no_results")}
</p>
<div className="grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
{data.list.items.map((p) => (
<ProductCard key={p.id} p={p} />
))}
</div>
{data.list.items.length === 0 ? (
<p className="py-[60px] text-center text-muted-foreground">No products found with the selected filters.</p>
) : null}
<Pagination
page={page}
total={data.list.total}
pageSize={data.list.pageSize}
makeHref={(n) => (n > 1 ? `/shop/?page=${n}` : "/shop/")}
/>
</div>
</div>
</div>
</Layout>
);
}