v0.13.0: 前台导航修复(loader 服务函数化防客户端退化)、全站图片占位图+懒加载、favicon、分页圆点、导航 1400px 居中、缺失图片补齐脚本
This commit is contained in:
@@ -1,8 +1,28 @@
|
|||||||
# 后台(Admin / CRM)与 MCP 说明
|
# 后台(Admin / CRM)与 MCP 说明
|
||||||
|
|
||||||
|
> v0.13.0 起:前台导航修复(loader 服务函数化)、全站图片占位图 + 懒加载、favicon。
|
||||||
> v0.12.0 起:PG 16 → 18.6(增量备份能力)、备份系统(全量/增量/S3)、用户权限分层。
|
> v0.12.0 起:PG 16 → 18.6(增量备份能力)、备份系统(全量/增量/S3)、用户权限分层。
|
||||||
> v0.11.0 起:后台按「功能接近原版 WordPress + 现代化 CRM 界面」重做一轮,并新增站点管理 MCP 服务器。
|
> v0.11.0 起:后台按「功能接近原版 WordPress + 现代化 CRM 界面」重做一轮,并新增站点管理 MCP 服务器。
|
||||||
|
|
||||||
|
## 前台约定(v0.13.0 起生效)
|
||||||
|
|
||||||
|
- **TanStack Start loader 是同构运行的**:客户端导航(点击 LOGO / `<Link>` / 分页)会在浏览器重跑一遍
|
||||||
|
loader。凡直接调用 `db`/查询层的写法(依赖 `bun:sql`,客户端是 stub)全部失败,页面退化为空数据。
|
||||||
|
**loader 的数据获取必须走 `createServerFn`**(服务端执行返回纯 JSON);`redirect()/notFound()` 不能在
|
||||||
|
serverFn 内抛给 Router,用协议标记(如 `$.tsx` 的 `__redirect/__notFound`)回传后由 loader 本地抛出。
|
||||||
|
已改造:`loadHomePage`(/)、`loadCatchAll`(/$)、`loadProduct`、`loadCollectionCategory`、`loadShopData`、
|
||||||
|
`loadSearchData`、`loadContactData`、`loadBlogPost`、`loadSolutionPage`、`loadBlogArchive`、`loadSolutionArchive`、
|
||||||
|
`loadCollection`(/collection/)等;购物车/结账/账户本来就用 serverFn(getCart/getCurrentUser/getMyOrders)。
|
||||||
|
- **图片占位图与懒加载**:`public/uploads/placeholder.jpg`(灰底 + 低透明度 LOGO,300×260,ImageMagick 生成);
|
||||||
|
全部前台 `<img>` 由 Layout 内联脚本补 `loading="lazy" decoding="async"`(MutationObserver 覆盖新增节点),
|
||||||
|
加载完成前显示占位背景(`app.css` 的 `.ds-site img` 规则;作用域为 Layout 的 `<main class="ds-site">`)。
|
||||||
|
注意透明 PNG 加载完成后会透出占位图——导出透明图时慎用。
|
||||||
|
- **缺失图片补齐**:`bun run scripts/sync-missing-uploads.ts` 幂等——扫库(contents/products/media)引用路径,
|
||||||
|
与 public/uploads 比对,缺失文件从原站下载(v0.13.0 补回 90 张;原站已失效 38 张不可恢复,走占位图)。
|
||||||
|
- **favicon**:`public/favicon.ico`(原站 32×32 PNG 转 ICO,ImageMagick)+ `apple-touch-icon.png`(LOGO 180×180),
|
||||||
|
根路由 `head().links` 注入。
|
||||||
|
- **导航宽度**:Header 顶栏与主导航行统一 `max-w-[1400px] mx-auto` 居中(2026-11-04 用户指定,原站基准)。
|
||||||
|
|
||||||
## 用户权限分层(RBAC)
|
## 用户权限分层(RBAC)
|
||||||
|
|
||||||
两类用户:**普通用户**(customer,仅商城账号)与**后台用户**(admin > manager > editor)。能力矩阵(`src/server/auth.ts` requireCapability):
|
两类用户:**普通用户**(customer,仅商城账号)与**后台用户**(admin > manager > editor)。能力矩阵(`src/server/auth.ts` requireCapability):
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dusuniot-rebuild",
|
"name": "dusuniot-rebuild",
|
||||||
"version": "0.12.0",
|
"version": "0.13.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* # 脚本 前台浏览器回归测试(playwright + headless chromium)
|
||||||
|
* 覆盖本轮修复:LOGO 返回首页(Elementor 正常渲染)、/landing-pages/ 卡片图显示、
|
||||||
|
* 后退历史、分页圆点去除、导航 1400px 居中、favicon、图片占位背景。
|
||||||
|
* 输出 PASS/FAIL 清单。
|
||||||
|
*/
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const CHROME = `${process.env.HOME}/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome`;
|
||||||
|
const BASE = "http://localhost:3312";
|
||||||
|
|
||||||
|
const results: { name: string; ok: boolean; detail?: string }[] = [];
|
||||||
|
const check = (name: string, ok: boolean, detail?: string) => {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ executablePath: CHROME, headless: true, args: ["--no-sandbox"] });
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1500, height: 900 } });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
const http404: string[] = [];
|
||||||
|
page.on("response", (r) => { if (r.status() === 404) http404.push(r.url()); });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. logging 首页 SSR
|
||||||
|
await page.goto(BASE + "/", { waitUntil: "networkidle" });
|
||||||
|
check("首页 SSR(Elementor hero)", await page.getByText("System on Module").count() > 0 || (await page.locator("main .content-html").count()) > 0);
|
||||||
|
|
||||||
|
// 2. favicon link 注入
|
||||||
|
const icon = await page.locator('link[rel="icon"]').first().getAttribute("href").catch(() => null);
|
||||||
|
check("favicon link 注入", icon === "/favicon.ico", String(icon));
|
||||||
|
const iconStatus = await ctx.request.get(BASE + icon || "/favicon.ico");
|
||||||
|
check("favicon.ico 可访问", iconStatus.status() === 200);
|
||||||
|
|
||||||
|
// 3. 导航 1400px 居中
|
||||||
|
const navBox = await page.locator("header div.flex.h-12, header div.flex.min-h\\[68px\\]").first().boundingBox().catch(() => null);
|
||||||
|
const navWidth = navBox?.width ?? 0;
|
||||||
|
check("主导航容器 1400px 居中", Math.abs(navWidth - 1400) < 6, `实测 ${navWidth.toFixed(0)}px`);
|
||||||
|
|
||||||
|
// 4. /landing-pages/ 卡片图(无 404 且出现 banner 图)
|
||||||
|
await page.goto(BASE + "/landing-pages/", { waitUntil: "networkidle" });
|
||||||
|
const cardImgs = await page.locator("main .ant-table-row img, main img[src*='uploads']").count();
|
||||||
|
const badImgs = await page.evaluate(() => Array.from(document.querySelectorAll(".ds-thumb img, main img")).filter((i) => (i as HTMLImageElement).naturalWidth === 0 && (i as HTMLImageElement).complete).length);
|
||||||
|
check("landing-pages 卡片图加载", cardImgs > 4, `图片 ${cardImgs} 张,加载失败 ${badImgs}`);
|
||||||
|
|
||||||
|
// 5. 页面无明显 404 请求
|
||||||
|
check("无 404 静态资源", http404.length === 0, http404.slice(0, 3).join(" | "));
|
||||||
|
|
||||||
|
// 6. 点击 LOGO 回首页(客户端导航)→ Elementor 渲染 + 后退正常
|
||||||
|
await page.click("header a[href='/']");
|
||||||
|
await page.waitForTimeout(2800);
|
||||||
|
const homeKids = await page.locator("main > *").count();
|
||||||
|
check("LOGO 客户端导航回首页", page.url().endsWith("/") && homeKids > 0, `URL=${page.url()} main子节点=${homeKids}`);
|
||||||
|
writeFileSync("/tmp/rt-home.png", await page.screenshot());
|
||||||
|
await page.goBack({ waitUntil: "domcontentloaded" });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
const backOk = await page.getByText("LANDING PAGES").count() > 0 || await page.getByRole("heading").filter({ hasText: /Landing/i }).count() > 0;
|
||||||
|
check("后退回 landing 正常", backOk && page.url().includes("landing-pages"), page.url());
|
||||||
|
|
||||||
|
// 7. 分页圆点去除(/blog/page/2/ 有分页)
|
||||||
|
await page.goto(BASE + "/collection/", { waitUntil: "networkidle" });
|
||||||
|
await page.evaluate(() => { window.scrollTo(0, document.body.scrollHeight); });
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const pagNav = await page.locator('nav[aria-label="Pagination"]').first().count();
|
||||||
|
if (pagNav) {
|
||||||
|
const text = await page.locator('nav[aria-label="Pagination"]').first().innerText();
|
||||||
|
check("分页无多余圆点", !/[•●]/.test(text), text.replace(/\n/g, " ").slice(0, 60));
|
||||||
|
// 分页客户端翻页(Link)
|
||||||
|
await page.locator('nav[aria-label="Pagination"] a').filter({ hasText: "2" }).first().click().catch(() => {});
|
||||||
|
await page.waitForTimeout(2200);
|
||||||
|
check("分页客户端翻页", page.url().includes("page=2"), page.url());
|
||||||
|
} else {
|
||||||
|
check("分页存在性", false, "当前页无分页导航(测试面缩窄)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 图片懒加载属性(全站 img 至少一个 loading=lazy 且占位图 CSS 在)
|
||||||
|
const lazyStats = await page.evaluate(() => {
|
||||||
|
const imgs = Array.from(document.querySelectorAll(".ds-site img")) as HTMLImageElement[];
|
||||||
|
return { total: imgs.length, lazy: imgs.filter((i) => i.getAttribute("loading") === "lazy").length };
|
||||||
|
});
|
||||||
|
check("全站图片 loading=lazy 注入", lazyStats.total > 0 && lazyStats.lazy >= 1, `lazy ${lazyStats.lazy}/${lazyStats.total}`);
|
||||||
|
const bg = await page.evaluate(() => getComputedStyle(document.querySelector(".ds-site img")!).backgroundImage);
|
||||||
|
check("占位图 CSS 生效", /placeholder\.jpg/.test(bg), bg.slice(0, 80));
|
||||||
|
|
||||||
|
// 9. 产品页客户端导航(Link 卡片点开)
|
||||||
|
await page.goto(BASE + "/shop/", { waitUntil: "networkidle" });
|
||||||
|
await page.locator("main a[href^='/product/']").first().click();
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
check("产品页客户端导航", /\/product\/.+/.test(page.url()) && (await page.locator("main .content-html, main img").count()) > 0, page.url());
|
||||||
|
} catch (e) {
|
||||||
|
check("脚本异常", false, e instanceof Error ? e.message.slice(0, 300) : String(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
const fail = results.filter((r) => !r.ok);
|
||||||
|
console.log(`\n==== ${results.length - fail.length}/${results.length} PASS ====`);
|
||||||
|
process.exit(0);
|
||||||
@@ -31,7 +31,10 @@ export default {
|
|||||||
/** # 函数 请求入口:静态文件优先,其余交给 Start handler */
|
/** # 函数 请求入口:静态文件优先,其余交给 Start handler */
|
||||||
async fetch(req: Request): Promise<Response> {
|
async fetch(req: Request): Promise<Response> {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const f = staticFiles.get(url.pathname);
|
// 尾斜杠归一化:偶发的 "<file>.jpg/" 形态请求(SPA 预取取回/上游转发)直接按静态
|
||||||
|
// 文件应答,避免交给 Start handler 后落入路由 catch-all 返回 404
|
||||||
|
let f = staticFiles.get(url.pathname);
|
||||||
|
if (!f && url.pathname.endsWith("/")) f = staticFiles.get(url.pathname.replace(/\/+$/, ""));
|
||||||
if (f) {
|
if (f) {
|
||||||
return new Response(Bun.file(f), {
|
return new Response(Bun.file(f), {
|
||||||
headers: { "cache-control": "public, max-age=31536000, immutable" },
|
headers: { "cache-control": "public, max-age=31536000, immutable" },
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* # 脚本 补齐缺失的上传图片(从原站 dusuniot.com 拉取)
|
||||||
|
* 背景:导入另抓的 html/meta 引用了 uploads 里的图片,但部分文件未同步到
|
||||||
|
* public/uploads(如 landing-pages 分类卡片 2026/11 目录)。本脚本:
|
||||||
|
* 1. 扫库(contents.html/meta/excerpt、products 文本列、media.url)收集全部 uploads 引用路径;
|
||||||
|
* 2. 本地缺失的文件从 https://www.dusuniot.com/wp-content/uploads/<path> 下载补齐;
|
||||||
|
* 3. 输出成功/失败清单。可重复执行(幂等,跳过已存在文件)。
|
||||||
|
*/
|
||||||
|
import { mkdirSync, existsSync, writeFileSync } from "node:fs";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { initDb } from "~/db";
|
||||||
|
import { db } from "~/db";
|
||||||
|
import { contents, products, media } from "~/db/schema";
|
||||||
|
|
||||||
|
const PUBLIC_ROOT = join(process.cwd(), "public", "uploads");
|
||||||
|
const ORIGIN = "https://www.dusuniot.com/wp-content/uploads/";
|
||||||
|
|
||||||
|
await initDb();
|
||||||
|
|
||||||
|
/** # 成员变量 引用路径去重集合(含 html/meta/excerpt/products/media 全部来源) **/
|
||||||
|
const paths = new Set<string>();
|
||||||
|
const addFromText = (s: unknown) => {
|
||||||
|
if (s == null || typeof s !== "string") return;
|
||||||
|
for (const m of s.matchAll(/(?:https?:\/\/(?:www\.)?dusuniot\.com)?\/wp-content\/uploads\/([\w\-./%~()+!@^[\]!'*,]+?\.(?:png|jpe?g|webp|gif|svg|ico|mp4|pdf))/gi)) {
|
||||||
|
paths.add(m[1]);
|
||||||
|
}
|
||||||
|
for (const m of s.matchAll(/\/uploads\/((?:\d{4}\/\d{2}\/)?[\w\-./%~()+!@^[\]!'*,]+?\.(?:png|jpe?g|webp|gif|svg|ico|mp4|pdf))/gi)) {
|
||||||
|
paths.add(m[1]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// contents:html/meta/excerpt
|
||||||
|
for (const row of await db.select({ html: contents.html, meta: contents.meta, excerpt: contents.excerpt, seoTitle: contents.seoTitle }).from(contents)) {
|
||||||
|
addFromText(row.html);
|
||||||
|
addFromText(row.excerpt);
|
||||||
|
addFromText(row.seoTitle);
|
||||||
|
addFromText(row.meta);
|
||||||
|
}
|
||||||
|
// products:常见文本/jsonb 列
|
||||||
|
for (const row of await db.select().from(products)) {
|
||||||
|
for (const v of Object.values(row as Record<string, unknown>)) addFromText(v);
|
||||||
|
}
|
||||||
|
// media 表登记的 URL
|
||||||
|
for (const row of await db.select({ url: media.url }).from(media)) addFromText(row.url);
|
||||||
|
|
||||||
|
console.log("引用路径总数:", paths.size);
|
||||||
|
|
||||||
|
let ok = 0;
|
||||||
|
let okExisting = 0;
|
||||||
|
const failed: string[] = [];
|
||||||
|
for (const p of [...paths].sort()) {
|
||||||
|
// 解码 WP URL 编码与 query
|
||||||
|
const clean = p.split("?")[0];
|
||||||
|
const local = join(PUBLIC_ROOT, decodeURIComponent(clean));
|
||||||
|
if (existsSync(local)) {
|
||||||
|
okExisting++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const url = ORIGIN + clean;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
failed.push(`${res.status} ${clean}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const buf = new Uint8Array(await res.arrayBuffer());
|
||||||
|
if (buf.byteLength < 100) {
|
||||||
|
failed.push(`DOCSIZE ${clean}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
mkdirSync(dirname(local), { recursive: true });
|
||||||
|
writeFileSync(local, buf);
|
||||||
|
ok++;
|
||||||
|
} catch (e) {
|
||||||
|
failed.push(`ERR ${clean}: ${(e as Error).message.slice(0, 80)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`已存在 ${okExisting},新下载 ${ok},失败 ${failed.length}`);
|
||||||
|
for (const f of failed.slice(0, 40)) console.log(" fail:", f);
|
||||||
@@ -11,14 +11,14 @@ function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
|||||||
return <nav aria-label="Pagination" className={cn("mx-0 my-8 flex flex-wrap justify-center gap-1.5", className)} {...props} />;
|
return <nav aria-label="Pagination" className={cn("mx-0 my-8 flex flex-wrap justify-center gap-1.5", className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** # 组件 分页内容列表 */
|
/** # 组件 分页内容列表(list-none:全局未重置列表符号,需显式去圆点) */
|
||||||
function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
|
function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
return <ul className={cn("flex flex-wrap items-center gap-1.5", className)} {...props} />;
|
return <ul className={cn("flex list-none flex-wrap items-center gap-1.5 p-0 m-0", className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** # 组件 分页项 */
|
/** # 组件 分页项 */
|
||||||
function PaginationItem({ className, ...props }: React.ComponentProps<"li">) {
|
function PaginationItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
return <li className={cn("", className)} {...props} />;
|
return <li className={cn("list-none", className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** # 类型 分页链接属性(href 由调用方生成,isActive 标记当前页) **/
|
/** # 类型 分页链接属性(href 由调用方生成,isActive 标记当前页) **/
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
* - 卡片阴影/hover 与 solution 一致;分页同款(当前页 #FFA11B)
|
* - 卡片阴影/hover 与 solution 一致;分页同款(当前页 #FFA11B)
|
||||||
*/
|
*/
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
import { listPosts } from "~/server/data";
|
import { listPosts } from "~/server/data";
|
||||||
import { useT } from "~/i18n";
|
import { useT } from "~/i18n";
|
||||||
@@ -30,14 +31,18 @@ export type BlogArchiveData = {
|
|||||||
page: number;
|
page: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** # 函数 加载博客归档数据(layout + 分页列表;原站 /blog/ 为 category-blog 归档) **/
|
/** # 函数 加载博客归档数据(Server Function:loader 是同构运行,客户端导航在浏览器重跑
|
||||||
export async function loadBlogArchive(page: number) {
|
* loader 时 db 依赖必须由服务端执行;调用处用 { data: page }。原站 /blog/ 为 category-blog 归档) */
|
||||||
|
export const loadBlogArchive = createServerFn({ method: "GET" })
|
||||||
|
.validator((d: number) => d)
|
||||||
|
.handler(async ({ data: page }: { data: number }): Promise<any> => {
|
||||||
|
const n = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
|
||||||
const [layout, list] = await Promise.all([
|
const [layout, list] = await Promise.all([
|
||||||
getLayoutData(),
|
getLayoutData(),
|
||||||
listPosts(page, 12, "blog").catch(() => ({ items: [], total: 0, page: 1, pageSize: 12 })),
|
listPosts(n, 12, "blog").catch(() => ({ items: [], total: 0, page: 1, pageSize: 12 })),
|
||||||
]);
|
]);
|
||||||
return { layout, list, page };
|
return { layout, list, page: n };
|
||||||
}
|
});
|
||||||
|
|
||||||
/** # 函数 日期长格式(原站 November 2, 2026) **/
|
/** # 函数 日期长格式(原站 November 2, 2026) **/
|
||||||
function fmtDate(d: Date | string | null): string {
|
function fmtDate(d: Date | string | null): string {
|
||||||
|
|||||||
@@ -119,10 +119,10 @@ export function Header(props: { menu: MenuNode[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-[100] bg-white shadow-[0_1px_6px_rgba(16,42,76,0.06)]">
|
<header className="sticky top-0 z-[100] bg-white shadow-[0_1px_6px_rgba(16,42,76,0.06)]">
|
||||||
{/* topbar:口号 + 社交图标(原站实测 2026-11-03:高 36px、容器 1350、文字 16px/16、
|
{/* topbar:口号 + 社交图标(原站实测 2026-11-04 用户指定:容器 1400 居中、
|
||||||
图标 36×36 盒 gap 10、顺序 Linkedin/Youtube/Facebook、无登录项;<768 隐藏) */}
|
高 36px、图标 36×36 盒 gap 10、顺序 Linkedin/Youtube/Facebook、无登录项;<768 隐藏) */}
|
||||||
<div className="hidden bg-footer text-white min-[768px]:block">
|
<div className="hidden bg-footer text-white min-[768px]:block">
|
||||||
<div className="mx-auto flex h-9 w-full max-w-[1350px] items-center justify-between">
|
<div className="mx-auto flex h-9 w-full max-w-[1400px] items-center justify-between px-4 min-[960px]:px-0">
|
||||||
<a href="/landing-pages/oem-odm/" className="text-[16px] leading-4 text-white hover:text-[#f5b278]">
|
<a href="/landing-pages/oem-odm/" className="text-[16px] leading-4 text-white hover:text-[#f5b278]">
|
||||||
{t("topbar_oem")}
|
{t("topbar_oem")}
|
||||||
</a>
|
</a>
|
||||||
@@ -140,10 +140,10 @@ export function Header(props: { menu: MenuNode[] }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 主导航(原站实测 2026-11-03:桌面 68px 容器 40/25、logo 后 80px 起菜单、项 pad 8/6 项距 6;
|
{/* 主导航(原站 2026-11-04 用户指定:导航容器 1400px 居中;桌面 68px、logo 后 80px 起菜单;
|
||||||
平板 768-959 为汉堡导航(顶栏 36 + 导航 48,logo 缩放,右侧 CONTACT US+邮件);
|
平板 768-959 为汉堡导航(顶栏 36 + 导航 48,logo 缩放,右侧 CONTACT US+邮件);
|
||||||
手机 <768 无顶栏、导航 48、汉堡在 logo 右 96px、右侧橙色气泡+邮件) */}
|
手机 <768 无顶栏、导航 48、汉堡在 logo 右 96px、右侧橙色气泡+邮件) */}
|
||||||
<div className="flex h-12 items-center px-2.5 pr-[13px] min-[768px]:pr-[68px] min-[960px]:h-[68px] min-[960px]:pl-10 min-[960px]:pr-[25px]">
|
<div className="mx-auto flex h-12 items-center px-2.5 pr-[13px] min-[768px]:pr-[68px] min-[960px]:h-[68px] min-[960px]:max-w-[1400px] min-[960px]:px-10">
|
||||||
{/* 直接复用原站 header logo(Elementor site-logo 同一图片,实测渲染 171×42.75px) */}
|
{/* 直接复用原站 header logo(Elementor site-logo 同一图片,实测渲染 171×42.75px) */}
|
||||||
<Link to="/" className="shrink-0">
|
<Link to="/" className="shrink-0">
|
||||||
<img src="/uploads/2022/06/Dusuniot-logo-R-400-e1669108867399.png" width={300} height={75} alt="Dusun IoT" className="block h-auto w-[109px] min-[768px]:w-[103px] min-[880px]:w-[124px] min-[960px]:w-[171px]" />
|
<img src="/uploads/2022/06/Dusuniot-logo-R-400-e1669108867399.png" width={300} height={75} alt="Dusun IoT" className="block h-auto w-[109px] min-[768px]:w-[103px] min-[880px]:w-[124px] min-[960px]:w-[171px]" />
|
||||||
|
|||||||
@@ -39,15 +39,41 @@ export const getLayoutData = createServerFn({ method: "GET" }).handler(async ():
|
|||||||
return { menu, footerMenu, categories: cats, footerDoc } satisfies LayoutData;
|
return { menu, footerMenu, categories: cats, footerDoc } satisfies LayoutData;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** # 常量 全站图片懒加载脚本(SSR 直出的 HTML 里 WP 图片多数已带 loading=lazy;
|
||||||
|
* 专管手工组件/用户编辑图片不带 lazy 的情况):
|
||||||
|
* - 未带 loading 属性的 <img> 全部补 loading="lazy" + decoding="async"
|
||||||
|
* - MutationObserver 覆盖后续新增节点
|
||||||
|
* - 加载失败兜底占位图(CSS 灰底占位背景在 img 未加载时可见)
|
||||||
|
*/
|
||||||
|
const LAZY_IMG_SCRIPT = `(function(){
|
||||||
|
var fix = function(root){
|
||||||
|
root.querySelectorAll && root.querySelectorAll('img').forEach(function(img){
|
||||||
|
if (img.hasAttribute('loading') || img.closest('header')) return;
|
||||||
|
img.setAttribute('loading','lazy');
|
||||||
|
if (!img.hasAttribute('decoding')) img.setAttribute('decoding','async');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
var boot = function(){
|
||||||
|
fix(document);
|
||||||
|
new MutationObserver(function(muts){
|
||||||
|
for (var m of muts) for (var n of m.addedNodes) if (n.nodeType===1) fix(n);
|
||||||
|
}).observe(document.body,{childList:true,subtree:true});
|
||||||
|
};
|
||||||
|
document.readyState==='loading' ? document.addEventListener('DOMContentLoaded',boot) : boot();
|
||||||
|
})();`;
|
||||||
|
|
||||||
/** # 类型 Layout 属性 */
|
/** # 类型 Layout 属性 */
|
||||||
export function Layout(props: { layout: LayoutData; children: React.ReactNode }) {
|
export function Layout(props: { layout: LayoutData; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header menu={props.layout.menu} />
|
<Header menu={props.layout.menu} />
|
||||||
<main>{props.children}</main>
|
{/* ds-site 前台标识:全局 img 占位图/懒加载样式按此作用域(不影响后台) */}
|
||||||
|
<main className="ds-site">{props.children}</main>
|
||||||
<Footer categories={props.layout.categories} menu={props.layout.footerMenu} doc={props.layout.footerDoc} />
|
<Footer categories={props.layout.categories} menu={props.layout.footerMenu} doc={props.layout.footerDoc} />
|
||||||
{/* 全局询价弹窗:接管页面内所有 Elementor popup 1129 链接(Make an Inquiry / 按钮) */}
|
{/* 全局询价弹窗:接管页面内所有 Elementor popup 1129 链接(Make an Inquiry / 按钮) */}
|
||||||
<InquiryPopup />
|
<InquiryPopup />
|
||||||
|
{/* 全站图片懒加载兜底脚本(绕过 React 水合,直接 setAttribute) */}
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: LAZY_IMG_SCRIPT }} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
* - 分页:居中,页码 margin 0 10px,当前页 #FFA11B,当前页/端点为 span 其余为 a
|
* - 分页:居中,页码 margin 0 10px,当前页 #FFA11B,当前页/端点为 span 其余为 a
|
||||||
*/
|
*/
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
import { listSolutionLandings } from "~/server/data";
|
import { listSolutionLandings } from "~/server/data";
|
||||||
import { pageWindow } from "~/components/website/BlogArchive";
|
import { pageWindow } from "~/components/website/BlogArchive";
|
||||||
@@ -20,14 +21,19 @@ import { useT } from "~/i18n";
|
|||||||
/** # 类型 方案归档页 props **/
|
/** # 类型 方案归档页 props **/
|
||||||
export type SolutionArchiveProps = { page: number };
|
export type SolutionArchiveProps = { page: number };
|
||||||
|
|
||||||
/** # 函数 加载方案归档数据(layout + 分页列表) **/
|
/** # 函数 加载方案归档数据(Server Function:loader 是同构运行,客户端导航在浏览器重跑
|
||||||
export async function loadSolutionArchive(page: number) {
|
* loader 时 db 依赖必须由服务端执行;这里以 serverFn 承载,调用处用 { data: page })
|
||||||
|
* 返回值 layout + 分页列表
|
||||||
|
**/
|
||||||
|
export const loadSolutionArchive = createServerFn({ method: "GET" })
|
||||||
|
.validator((d: number) => d)
|
||||||
|
.handler(async ({ data: page }: { data: number }): Promise<any> => {
|
||||||
const [layout, list] = await Promise.all([
|
const [layout, list] = await Promise.all([
|
||||||
getLayoutData(),
|
getLayoutData(),
|
||||||
listSolutionLandings(page, 12).catch(() => ({ items: [], total: 0, page: 1, pageSize: 12 })),
|
listSolutionLandings(Number.isFinite(page) && page > 0 ? Math.floor(page) : 1, 12).catch(() => ({ items: [], total: 0, page: 1, pageSize: 12 })),
|
||||||
]);
|
]);
|
||||||
return { layout, list, page };
|
return { layout, list, page: Number.isFinite(page) && page > 0 ? Math.floor(page) : 1 };
|
||||||
}
|
});
|
||||||
|
|
||||||
/** # 常量 摘录显示词数上限(原站 archive-posts excerpt_length,取证:12 张卡片全部 ≤24 词、DB 全文更长) **/
|
/** # 常量 摘录显示词数上限(原站 archive-posts excerpt_length,取证:12 张卡片全部 ≤24 词、DB 全文更长) **/
|
||||||
const EXCERPT_WORDS = 24;
|
const EXCERPT_WORDS = 24;
|
||||||
|
|||||||
+55
-36
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createFileRoute, Link, notFound, redirect } from "@tanstack/react-router";
|
import { createFileRoute, Link, notFound, redirect } from "@tanstack/react-router";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { getLayoutData, Layout, type LayoutData } from "~/components/website/Layout";
|
import { getLayoutData, Layout, type LayoutData } from "~/components/website/Layout";
|
||||||
import { Pagination } from "~/components/website/Pagination";
|
import { Pagination } from "~/components/website/Pagination";
|
||||||
import { ElementorRenderer, extractElementor, type ElElement } from "~/components/elementor/Renderer";
|
import { ElementorRenderer, extractElementor, type ElElement } from "~/components/elementor/Renderer";
|
||||||
@@ -89,9 +90,49 @@ export const Route = createFileRoute("/$")({
|
|||||||
return { page: Number.isFinite(page) && page > 0 ? Math.floor(page) : undefined };
|
return { page: Number.isFinite(page) && page > 0 ? Math.floor(page) : undefined };
|
||||||
},
|
},
|
||||||
loaderDeps: ({ search }) => ({ page: search.page }),
|
loaderDeps: ({ search }) => ({ page: search.page }),
|
||||||
|
// loader 本体抽为 Server Function:客户端导航时浏览器里重跑 loader,若直接调
|
||||||
|
// db/查询层(bun:sql 在客户端是 stub)会全部失败并误判 404。redirect / notFound
|
||||||
|
// 用协议标记回传,由 loader 本地抛出(保证 Router 自身语义处理)。
|
||||||
loader: async ({ location, deps }) => {
|
loader: async ({ location, deps }) => {
|
||||||
|
// 协议标记由 loader 本地抛出(redirect/notFound 不能在 createServerFn 里抛给 Router)
|
||||||
|
const r: any = await loadCatchAll({ data: { pathname: location.pathname, page: deps.page } });
|
||||||
|
if (r?.__redirect) throw redirect({ to: String(r.__redirect), href: String(r.__redirect), statusCode: (Number(r.__code) || 301) as 301 });
|
||||||
|
if (r?.__notFound) throw notFound();
|
||||||
|
return r as CatchAllData;
|
||||||
|
},
|
||||||
|
head: ({ loaderData }) => {
|
||||||
|
const langLabel = loaderData?.lang ? ` (${LANG_NAMES[loaderData.lang] || loaderData.lang})` : "";
|
||||||
|
if (loaderData?.archive) {
|
||||||
|
return {
|
||||||
|
meta: [
|
||||||
|
{ title: `${loaderData.archive.name} | Dusun IoT` },
|
||||||
|
{ name: "description", content: `Browse ${loaderData.archive.name} articles from Dusun IoT.` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
meta: [
|
||||||
|
{ title: loaderData?.content ? (loaderData.content.seoTitle || loaderData.content.title) + langLabel : "Page Not Found | Dusun IoT" },
|
||||||
|
{ name: "description", content: loaderData?.content?.seoDescription || loaderData?.content?.excerpt || "Dusun IoT - IoT gateway manufacturer and solution provider." },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: CatchAllPage,
|
||||||
|
notFoundComponent: NotFoundShell,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 类型 兜底加载协议:redirect / notFound 标记或正常数据(CatchAllData 形状) **/
|
||||||
|
type CatchAllLoadResult = { __redirect?: string; __code?: number; __notFound?: boolean | true } & CatchAllData;
|
||||||
|
|
||||||
|
/** # 函数 兜底页数据加载(Server Function:执行于服务端,返回纯 JSON)
|
||||||
|
* 逻辑与原先 loader 相同;redirect / notFound 以协议标记返回。
|
||||||
|
*/
|
||||||
|
const loadCatchAll = createServerFn({ method: "GET" })
|
||||||
|
.validator((d: { pathname: string; page?: number }) => d)
|
||||||
|
.handler(async ({ data }): Promise<any> => {
|
||||||
|
const { pathname, page: depsPage } = data;
|
||||||
// 解析路径段:去首尾斜杠、过滤空段
|
// 解析路径段:去首尾斜杠、过滤空段
|
||||||
const segments = location.pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
const segments = pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
||||||
let lang = "";
|
let lang = "";
|
||||||
if (segments.length >= 1 && isLangCode(segments[0])) {
|
if (segments.length >= 1 && isLangCode(segments[0])) {
|
||||||
lang = segments[0];
|
lang = segments[0];
|
||||||
@@ -133,74 +174,51 @@ export const Route = createFileRoute("/$")({
|
|||||||
const last = segments[segments.length - 1]!;
|
const last = segments[segments.length - 1]!;
|
||||||
// 1) /product/product/<slug>/:导入落地页里的重复前缀 → /product/<slug>/(仅当产品存在)
|
// 1) /product/product/<slug>/:导入落地页里的重复前缀 → /product/<slug>/(仅当产品存在)
|
||||||
if (segments.length >= 3 && segments[0] === "product" && segments[1] === "product" && (await hasProductSlug(last).catch(() => false))) {
|
if (segments.length >= 3 && segments[0] === "product" && segments[1] === "product" && (await hasProductSlug(last).catch(() => false))) {
|
||||||
const to = `/product/${last}/`;
|
return { __redirect: `/product/${last}/`, __code: 301 } as CatchAllLoadResult;
|
||||||
throw redirect({ to, href: to, statusCode: 301 as const });
|
|
||||||
}
|
}
|
||||||
// 2) /product-specification/...:原站产品规格页。本地等价物是产品页;单段落产品列表
|
// 2) /product-specification/...:原站产品规格页。本地等价物是产品页;单段落产品列表
|
||||||
if (segments[0] === "product-specification") {
|
if (segments[0] === "product-specification") {
|
||||||
if (segments.length === 1) {
|
if (segments.length === 1) {
|
||||||
throw redirect({ to: "/collection/", href: "/collection/", statusCode: 301 as const });
|
return { __redirect: "/collection/", __code: 301 } as CatchAllLoadResult;
|
||||||
}
|
}
|
||||||
// 已导入的旧链规则优先(可能链向其它规格页 slug),无规则再按 slug 找产品,最后落产品列表
|
// 已导入的旧链规则优先(可能链向其它规格页 slug),无规则再按 slug 找产品,最后落产品列表
|
||||||
const rule = await resolveRedirect("/" + segments.join("/"))
|
const rule = await resolveRedirect("/" + segments.join("/"))
|
||||||
.then((r) => r?.target)
|
.then((r) => r?.target)
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
if (rule) throw redirect({ to: rule, href: rule, statusCode: 301 as const });
|
if (rule) return { __redirect: rule, __code: 301 } as CatchAllLoadResult;
|
||||||
const to = (await hasProductSlug(last).catch(() => false)) ? `/product/${last}/` : "/collection/";
|
return { __redirect: (await hasProductSlug(last).catch(() => false)) ? `/product/${last}/` : "/collection/", __code: 301 } as CatchAllLoadResult;
|
||||||
throw redirect({ to, href: to, statusCode: 301 as const });
|
|
||||||
}
|
}
|
||||||
// 3) /collection/<parent>/<child>/:嵌套产品分类路径 → 子分类页(无该子分类则父分类兜底)
|
// 3) /collection/<parent>/<child>/:嵌套产品分类路径 → 子分类页(无该子分类则父分类兜底)
|
||||||
if (segments[0] === "collection" && segments.length === 3) {
|
if (segments[0] === "collection" && segments.length === 3) {
|
||||||
const child = await getProductChildCategory(segments[1]!, segments[2]!).catch(() => null);
|
const child = await getProductChildCategory(segments[1]!, segments[2]!).catch(() => null);
|
||||||
const to = `/collection/${child ? child.slug : segments[1]!}/`;
|
return { __redirect: `/collection/${child ? child.slug : segments[1]!}/`, __code: 301 } as CatchAllLoadResult;
|
||||||
throw redirect({ to, href: to, statusCode: 301 as const });
|
|
||||||
}
|
}
|
||||||
// 4) 数据库 redirects 表规则(链式解析并跳过自指规则防死循环)
|
// 4) 数据库 redirects 表规则(链式解析并跳过自指规则防死循环)
|
||||||
const target = await resolveRedirect("/" + segments.join("/"))
|
const target = await resolveRedirect("/" + segments.join("/"))
|
||||||
.then((r) => r?.target)
|
.then((r) => r?.target)
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
if (target) throw redirect({ to: target, href: target, statusCode: 301 as const });
|
if (target) return { __redirect: target, __code: 301 } as CatchAllLoadResult;
|
||||||
// 5) WP /%category%/ 结构:单段/双段路径末段命中文章分类 → 分类归档页
|
// 5) WP /%category%/ 结构:单段/双段路径末段命中文章分类 → 分类归档页
|
||||||
if (segments.length <= 2) {
|
if (segments.length <= 2) {
|
||||||
const cat = await getPostCategoryBySlug(last).catch(() => null);
|
const cat = await getPostCategoryBySlug(last).catch(() => null);
|
||||||
if (cat) {
|
if (cat) {
|
||||||
const posts = await listPosts(deps.page || 1, 12, cat.slug).catch(() => ({ items: [], total: 0, page: deps.page || 1, pageSize: 12 }));
|
const posts = await listPosts(depsPage || 1, 12, cat.slug).catch(() => ({ items: [], total: 0, page: depsPage || 1, pageSize: 12 }));
|
||||||
return { layout, content: null, lang, hotProducts, archive: { name: cat.name, slug: cat.slug, posts }, templates: {}, postsData: {} } satisfies CatchAllData;
|
return { layout, content: null, lang, hotProducts, archive: { name: cat.name, slug: cat.slug, posts }, templates: {}, postsData: {} } satisfies CatchAllData;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 未命中任何内容 → 404(SSR 返回 404 状态码,由 notFoundComponent 渲染)
|
// 未命中任何内容 → 404(SSR 返回 404 状态码,由 notFoundComponent 渲染)
|
||||||
throw notFound();
|
return { __notFound: true } as CatchAllLoadResult;
|
||||||
}
|
}
|
||||||
// 语言前缀首页等内容为空时的兜底
|
// 语言前缀首页等内容为空时的兜底
|
||||||
if (!content) throw notFound();
|
if (!content) return { __notFound: true } as CatchAllLoadResult;
|
||||||
|
|
||||||
// 内嵌 Elementor 模板([elementor-template id="N"])+ posts widget 数据预取
|
// 内嵌 Elementor 模板([elementor-template id="N"])+ posts widget 数据预取
|
||||||
const mainEls = asElements(extractElementor(content.meta));
|
const mainEls = asElements(extractElementor(content.meta));
|
||||||
const embedded = await loadEmbeddedTemplates(mainEls);
|
const embedded = await loadEmbeddedTemplates(mainEls);
|
||||||
const postsData = await loadPostsWidgetsData([...mainEls, ...embedded.elements]);
|
const postsData = await loadPostsWidgetsData([...mainEls, ...embedded.elements]);
|
||||||
|
|
||||||
return { layout, content, lang, hotProducts, archive: null, templates: embedded.templates, postsData } satisfies CatchAllData;
|
return { layout, content, lang, hotProducts, archive: null, templates: embedded.templates, postsData } satisfies CatchAllLoadResult;
|
||||||
},
|
});
|
||||||
head: ({ loaderData }) => {
|
|
||||||
const langLabel = loaderData?.lang ? ` (${LANG_NAMES[loaderData.lang] || loaderData.lang})` : "";
|
|
||||||
if (loaderData?.archive) {
|
|
||||||
return {
|
|
||||||
meta: [
|
|
||||||
{ title: `${loaderData.archive.name} | Dusun IoT` },
|
|
||||||
{ name: "description", content: `Browse ${loaderData.archive.name} articles from Dusun IoT.` },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
meta: [
|
|
||||||
{ title: loaderData?.content ? (loaderData.content.seoTitle || loaderData.content.title) + langLabel : "Page Not Found | Dusun IoT" },
|
|
||||||
{ name: "description", content: loaderData?.content?.seoDescription || loaderData?.content?.excerpt || "Dusun IoT - IoT gateway manufacturer and solution provider." },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
component: CatchAllPage,
|
|
||||||
notFoundComponent: NotFoundShell,
|
|
||||||
});
|
|
||||||
|
|
||||||
/** # 组件 分类归档页(WP /%category%/ 结构):面包屑 + 文章卡片分页列表,样式与 /blog/ 一致 */
|
/** # 组件 分类归档页(WP /%category%/ 结构):面包屑 + 文章卡片分页列表,样式与 /blog/ 一致 */
|
||||||
function ArchivePage() {
|
function ArchivePage() {
|
||||||
@@ -294,7 +312,8 @@ function CatchAllPage() {
|
|||||||
const data = Route.useLoaderData();
|
const data = Route.useLoaderData();
|
||||||
// 分类归档命中:走归档模板
|
// 分类归档命中:走归档模板
|
||||||
if (data.archive) return <ArchivePage />;
|
if (data.archive) return <ArchivePage />;
|
||||||
const c = data.content;
|
// 加载协议保证 redirect/notFound 之后 content 必非空
|
||||||
|
const c = data.content!;
|
||||||
const elementor = extractElementor(c.meta);
|
const elementor = extractElementor(c.meta);
|
||||||
const langLabel = data.lang ? ` (${LANG_NAMES[data.lang] || data.lang})` : "";
|
const langLabel = data.lang ? ` (${LANG_NAMES[data.lang] || data.lang})` : "";
|
||||||
// Elementor 页面(原站 full-canvas 模板):通栏直出,不加面包屑/h1
|
// Elementor 页面(原站 full-canvas 模板):通栏直出,不加面包屑/h1
|
||||||
|
|||||||
+12
-1
@@ -9,6 +9,7 @@ async function loader() {
|
|||||||
|
|
||||||
/** # 函数 根路由文档组件
|
/** # 函数 根路由文档组件
|
||||||
* 应用全局 HTML 骨架,前台与后台共用;各路由通过 head() 注入 meta/link。
|
* 应用全局 HTML 骨架,前台与后台共用;各路由通过 head() 注入 meta/link。
|
||||||
|
* 根级注入 favicon / apple-touch-icon(与原站一致)。
|
||||||
* LocaleProvider 将语言注入全站(后台不使用 t(),不受影响)。
|
* LocaleProvider 将语言注入全站(后台不使用 t(),不受影响)。
|
||||||
*/
|
*/
|
||||||
function RootDocument() {
|
function RootDocument() {
|
||||||
@@ -32,4 +33,14 @@ function RootDocument() {
|
|||||||
* (取证:原站 head 无 Poppins css link、无 woff2 请求,Poppins 仅作 CSS 声明,
|
* (取证:原站 head 无 Poppins css link、无 woff2 请求,Poppins 仅作 CSS 声明,
|
||||||
* 实际渲染走系统 fallback;本地同样不加载字体文件,保持两边一致回退。)
|
* 实际渲染走系统 fallback;本地同样不加载字体文件,保持两边一致回退。)
|
||||||
**/
|
**/
|
||||||
export const Route = createRootRoute({ component: RootDocument, loader });
|
export const Route = createRootRoute({
|
||||||
|
component: RootDocument,
|
||||||
|
loader,
|
||||||
|
// 全局 head 资源:favicon / apple-touch-icon(public/ 同名文件)
|
||||||
|
head: () => ({
|
||||||
|
links: [
|
||||||
|
{ rel: "icon", href: "/favicon.ico", sizes: "32x32" },
|
||||||
|
{ rel: "apple-touch-icon", href: "/apple-touch-icon.png" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|||||||
+20
-12
@@ -4,6 +4,7 @@
|
|||||||
* IoT Gateways 产品分组折叠菜单)+ 底部 Related Posts;Elementor 文章通栏直出。
|
* IoT Gateways 产品分组折叠菜单)+ 底部 Related Posts;Elementor 文章通栏直出。
|
||||||
*/
|
*/
|
||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
|
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
|
||||||
import { InquiryForm } from "~/components/website/InquiryForm";
|
import { InquiryForm } from "~/components/website/InquiryForm";
|
||||||
@@ -96,10 +97,25 @@ function BrandIcon(props: { label: string; bg: string; path: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute("/blog/$slug")({
|
export const Route = createFileRoute("/blog/$slug")({
|
||||||
loader: async ({ params }) => {
|
// loader 本体抽为 Server Function(客户端导航在浏览器重跑 loader,db 依赖必须在服务端执行)
|
||||||
|
loader: (ctx) => loadBlogPost({ data: { slug: ctx.params.slug } }),
|
||||||
|
head: ({ loaderData }) => ({
|
||||||
|
meta: [
|
||||||
|
{ title: loaderData?.post ? loaderData.post.seoTitle || `${loaderData.post.title} | Dusun IoT Blog` : "Post Not Found | Dusun IoT" },
|
||||||
|
{ name: "description", content: loaderData?.post?.seoDescription || loaderData?.post?.excerpt || "Blog post from Dusun IoT." },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
component: BlogPostPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 函数 文章详情数据加载(Server Function:客户端导航时由服务端执行;返回纯 JSON) */
|
||||||
|
const loadBlogPost = createServerFn({ method: "GET" })
|
||||||
|
.validator((d: { slug: string }) => d)
|
||||||
|
.handler(async ({ data }): Promise<any> => {
|
||||||
|
const slug = data.slug;
|
||||||
const [layout, post, latest, productMenu] = await Promise.all([
|
const [layout, post, latest, productMenu] = await Promise.all([
|
||||||
getLayoutData(),
|
getLayoutData(),
|
||||||
getPublishedContent(params.slug, "post").catch(() => null),
|
getPublishedContent(slug, "post").catch(() => null),
|
||||||
listPosts(1, 6).catch(() => ({ items: [] as PostListItem[], total: 0, page: 1, pageSize: 6 })),
|
listPosts(1, 6).catch(() => ({ items: [] as PostListItem[], total: 0, page: 1, pageSize: 6 })),
|
||||||
getMenuTree("menu-1").catch(() => [] as MenuNode[]),
|
getMenuTree("menu-1").catch(() => [] as MenuNode[]),
|
||||||
]);
|
]);
|
||||||
@@ -110,17 +126,9 @@ export const Route = createFileRoute("/blog/$slug")({
|
|||||||
const meta = (post.meta ?? null) as Record<string, unknown> | null;
|
const meta = (post.meta ?? null) as Record<string, unknown> | null;
|
||||||
resolveImageVariants(meta?.elementor);
|
resolveImageVariants(meta?.elementor);
|
||||||
const terms = post.terms.map((t) => ({ name: t.name, slug: t.slug, taxonomy: t.taxonomy }));
|
const terms = post.terms.map((t) => ({ name: t.name, slug: t.slug, taxonomy: t.taxonomy }));
|
||||||
const related = latest.items.filter((p) => p.slug !== params.slug).slice(0, 4);
|
const related = latest.items.filter((p) => p.slug !== slug).slice(0, 4);
|
||||||
return { layout, post: { ...post, meta, terms }, related, gwGroups };
|
return { layout, post: { ...post, meta, terms }, related, gwGroups };
|
||||||
},
|
});
|
||||||
head: ({ loaderData }) => ({
|
|
||||||
meta: [
|
|
||||||
{ title: loaderData?.post ? loaderData.post.seoTitle || `${loaderData.post.title} | Dusun IoT Blog` : "Post Not Found | Dusun IoT" },
|
|
||||||
{ name: "description", content: loaderData?.post?.seoDescription || loaderData?.post?.excerpt || "Blog post from Dusun IoT." },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
component: BlogPostPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
/** # 组件 文章详情页 */
|
/** # 组件 文章详情页 */
|
||||||
function BlogPostPage() {
|
function BlogPostPage() {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
|||||||
import { BlogArchive, loadBlogArchive } from "~/components/website/BlogArchive";
|
import { BlogArchive, loadBlogArchive } from "~/components/website/BlogArchive";
|
||||||
|
|
||||||
export const Route = createFileRoute("/blog/")({
|
export const Route = createFileRoute("/blog/")({
|
||||||
loader: () => loadBlogArchive(1),
|
loader: () => loadBlogArchive({ data: 1 }),
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ title: "Blog | IoT Knowledge & News | Dusun IoT" },
|
{ title: "Blog | IoT Knowledge & News | Dusun IoT" },
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { BlogArchive, loadBlogArchive } from "~/components/website/BlogArchive";
|
|||||||
export const Route = createFileRoute("/blog/page/$page")({
|
export const Route = createFileRoute("/blog/page/$page")({
|
||||||
loader: async ({ params }) => {
|
loader: async ({ params }) => {
|
||||||
const n = Number(params.page);
|
const n = Number(params.page);
|
||||||
const data = await loadBlogArchive(Number.isFinite(n) && n > 1 ? Math.floor(n) : 1);
|
const data = await loadBlogArchive({ data: Number.isFinite(n) && n > 1 ? Math.floor(n) : 1 });
|
||||||
// 越界页真 404(原站 /blog/page/28/ 404;软 404 影响 SEO)
|
// 越界页真 404(原站 /blog/page/28/ 404;软 404 影响 SEO)
|
||||||
if (!Number.isFinite(n) || n < 1 || (data.page > 1 && data.page > Math.ceil(data.list.total / data.list.pageSize)))
|
if (!Number.isFinite(n) || n < 1 || (data.page > 1 && data.page > Math.ceil(data.list.total / data.list.pageSize)))
|
||||||
throw notFound();
|
throw notFound();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* 主区:分类标题 + 产品网格 + 分页。
|
* 主区:分类标题 + 产品网格 + 分页。
|
||||||
*/
|
*/
|
||||||
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
import { ProductCard } from "~/components/website/ProductCard";
|
import { ProductCard } from "~/components/website/ProductCard";
|
||||||
import { Pagination } from "~/components/website/Pagination";
|
import { Pagination } from "~/components/website/Pagination";
|
||||||
@@ -17,32 +18,8 @@ export const Route = createFileRoute("/collection/$slug")({
|
|||||||
validateSearch: parseCollectionSearch as (search: Record<string, unknown>) => CollectionSearch,
|
validateSearch: parseCollectionSearch as (search: Record<string, unknown>) => CollectionSearch,
|
||||||
// 该版本 loader 拿不到 search,需要 loaderDeps 显式声明依赖
|
// 该版本 loader 拿不到 search,需要 loaderDeps 显式声明依赖
|
||||||
loaderDeps: ({ search }) => ({ search }),
|
loaderDeps: ({ search }) => ({ search }),
|
||||||
loader: async ({ params, deps }) => {
|
// loader 本体抽为 Server Function(客户端导航在浏览器重跑 loader,db 依赖必须在服务端执行)
|
||||||
const [layout, cats, filterConfig] = await Promise.all([
|
loader: (ctx) => loadCollectionCategory({ data: { slug: ctx.params.slug, search: ctx.deps.search } }),
|
||||||
getLayoutData(),
|
|
||||||
getProductCategoryTree().catch(() => []),
|
|
||||||
getFilterConfig().catch(() => []),
|
|
||||||
]);
|
|
||||||
const list = await listProducts({
|
|
||||||
category: params.slug,
|
|
||||||
filters: searchToFilters(deps.search),
|
|
||||||
page: deps.search.page,
|
|
||||||
pageSize: 24,
|
|
||||||
}).catch(() => ({ items: [], total: 0, page: 1, pageSize: 24 }) as ProductListResult);
|
|
||||||
// 分类名从分类树里找;找不到(无效 slug)也给出列表页(空态)
|
|
||||||
const cat = cats.find((c) => c.slug === params.slug) ?? null;
|
|
||||||
// 原站 /product-category/<slug>/ 直接渲染该分类的 Elementor 营销落地页(内容以
|
|
||||||
// 同 slug 文章形式导入;smart-gateway 这类单复数变体也试)。无落地页才走筛选列表。
|
|
||||||
let landing: Awaited<ReturnType<typeof getPublishedContent>> = null;
|
|
||||||
for (const s of [params.slug, `${params.slug}s`, params.slug.replace(/s$/, "")]) {
|
|
||||||
const row = await getPublishedContent(s, "post").catch(() => null);
|
|
||||||
if (row && extractElementor(row.meta)) {
|
|
||||||
landing = row;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { layout, cats, filterConfig, list, cat, slug: params.slug, landing };
|
|
||||||
},
|
|
||||||
head: ({ loaderData }) => ({
|
head: ({ loaderData }) => ({
|
||||||
meta: [
|
meta: [
|
||||||
{
|
{
|
||||||
@@ -57,17 +34,59 @@ export const Route = createFileRoute("/collection/$slug")({
|
|||||||
component: CollectionCategoryPage,
|
component: CollectionCategoryPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** # 函数 分类列表数据加载(Server Function:客户端 navigation 由服务端执行,返回纯 JSON) */
|
||||||
|
const loadCollectionCategory = createServerFn({ method: "GET" })
|
||||||
|
.validator((d: { slug: string; search?: unknown }) => d)
|
||||||
|
.handler(async ({ data }): Promise<any> => {
|
||||||
|
const { search: depsSearch, slug } = data;
|
||||||
|
const [layout, cats, filterConfig] = await Promise.all([
|
||||||
|
getLayoutData(),
|
||||||
|
getProductCategoryTree().catch(() => []),
|
||||||
|
getFilterConfig().catch(() => []),
|
||||||
|
]);
|
||||||
|
const list = await listProducts({
|
||||||
|
category: slug,
|
||||||
|
filters: searchToFilters((depsSearch ?? {}) as CollectionSearch),
|
||||||
|
page: (depsSearch as { page?: number } | null)?.page,
|
||||||
|
pageSize: 24,
|
||||||
|
}).catch(() => ({ items: [], total: 0, page: 1, pageSize: 24 }) as ProductListResult);
|
||||||
|
// 分类名从分类树里找;找不到(无效 slug)也给出列表页(空态)
|
||||||
|
const cat = cats.find((c) => c.slug === slug) ?? null;
|
||||||
|
// 原站 /product-category/<slug>/ 直接渲染该分类的 Elementor 营销落地页(内容以
|
||||||
|
// 同 slug 文章形式导入;smart-gateway 这类单复数变体也试)。无落地页才走筛选列表。
|
||||||
|
let landing: unknown = null;
|
||||||
|
for (const s of [slug, `${slug}s`, slug.replace(/s$/, "")]) {
|
||||||
|
const row = await getPublishedContent(s, "post").catch(() => null);
|
||||||
|
if (row && extractElementor(row.meta)) {
|
||||||
|
landing = row;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { layout, cats, filterConfig, list, cat, slug, landing };
|
||||||
|
});
|
||||||
|
|
||||||
/** # 类型 分类树节点 */
|
/** # 类型 分类树节点 */
|
||||||
type Cat = { id: number; name: string; slug: string; parentId: number | null; count: number };
|
type Cat = { id: number; name: string; slug: string; parentId: number | null; count: number };
|
||||||
|
|
||||||
|
/** # 类型 分类列表页 loader 数据(serverFn 返回 any,展示层按此收窄) */
|
||||||
|
type CollectionCategoryData = {
|
||||||
|
layout: any;
|
||||||
|
cats: Cat[];
|
||||||
|
filterConfig: { name: string; slug: string; values: { value: string; slug: string; count: number }[] }[];
|
||||||
|
list: ProductListResult;
|
||||||
|
cat: Cat | null;
|
||||||
|
slug: string;
|
||||||
|
landing: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
/** # 组件 分类列表页 */
|
/** # 组件 分类列表页 */
|
||||||
function CollectionCategoryPage() {
|
function CollectionCategoryPage() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const data = Route.useLoaderData();
|
const data = Route.useLoaderData() as CollectionCategoryData;
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const page = data.list.page;
|
const page = data.list.page;
|
||||||
const landingElementor = extractElementor(data.landing?.meta ?? null);
|
const landingElementor = extractElementor((data.landing as Record<string, unknown> | null)?.meta ?? null);
|
||||||
|
|
||||||
/** # 函数 更新筛选:切换某属性值后 navigate(重置到第 1 页) */
|
/** # 函数 更新筛选:切换某属性值后 navigate(重置到第 1 页) */
|
||||||
function toggleFilter(attrSlug: string, valueSlug: string) {
|
function toggleFilter(attrSlug: string, valueSlug: string) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* 有 Elementor 内容则渲染;否则标准布局:联系方式卡片 + 询盘表单。
|
* 有 Elementor 内容则渲染;否则标准布局:联系方式卡片 + 询盘表单。
|
||||||
*/
|
*/
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
|
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
|
||||||
import { InquiryForm } from "~/components/website/InquiryForm";
|
import { InquiryForm } from "~/components/website/InquiryForm";
|
||||||
@@ -10,14 +11,8 @@ import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbP
|
|||||||
import { getPublishedContent } from "~/server/data";
|
import { getPublishedContent } from "~/server/data";
|
||||||
|
|
||||||
export const Route = createFileRoute("/contact-us")({
|
export const Route = createFileRoute("/contact-us")({
|
||||||
loader: async () => {
|
// loader 本体抽为 Server Function(客户端导航在浏览器重跑 loader,db 依赖必须在服务端执行)
|
||||||
const [layout, page] = await Promise.all([
|
loader: () => loadContactData(),
|
||||||
getLayoutData(),
|
|
||||||
getPublishedContent("contact-us", "page").catch(() => null),
|
|
||||||
]);
|
|
||||||
const meta = (page?.meta ?? null) as Record<string, unknown> | null;
|
|
||||||
return { layout, page, meta };
|
|
||||||
},
|
|
||||||
head: ({ loaderData }) => ({
|
head: ({ loaderData }) => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ title: loaderData?.page?.seoTitle || "Contact Us | Dusun IoT" },
|
{ title: loaderData?.page?.seoTitle || "Contact Us | Dusun IoT" },
|
||||||
@@ -27,6 +22,16 @@ export const Route = createFileRoute("/contact-us")({
|
|||||||
component: ContactPage,
|
component: ContactPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** # 函数 联系页数据加载(Server Function:客户端导航时由服务端执行) */
|
||||||
|
const loadContactData = createServerFn({ method: "GET" }).handler(async (): Promise<any> => {
|
||||||
|
const [layout, page] = await Promise.all([
|
||||||
|
getLayoutData(),
|
||||||
|
getPublishedContent("contact-us", "page").catch(() => null),
|
||||||
|
]);
|
||||||
|
const meta = (page?.meta ?? null) as Record<string, unknown> | null;
|
||||||
|
return { layout, page, meta };
|
||||||
|
});
|
||||||
|
|
||||||
/** # 组件 联系页(原站为 full-canvas Elementor 模板:hero 通栏 + 表单卡叠压,不加 site-container) */
|
/** # 组件 联系页(原站为 full-canvas Elementor 模板:hero 通栏 + 表单卡叠压,不加 site-container) */
|
||||||
function ContactPage() {
|
function ContactPage() {
|
||||||
const data = Route.useLoaderData();
|
const data = Route.useLoaderData();
|
||||||
|
|||||||
+47
-43
@@ -3,55 +3,18 @@
|
|||||||
* 有 Elementor 内容则渲染渲染器;否则渲染后备首页(Hero + 分类卡片 + 最新产品 + 最新文章 + CTA)。
|
* 有 Elementor 内容则渲染渲染器;否则渲染后备首页(Hero + 分类卡片 + 最新产品 + 最新文章 + CTA)。
|
||||||
*/
|
*/
|
||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
import { ElementorRenderer, extractElementor, type ElElement } from "~/components/elementor/Renderer";
|
import { ElementorRenderer, extractElementor, type ElElement } from "~/components/elementor/Renderer";
|
||||||
import { ProductCard } from "~/components/website/ProductCard";
|
import { ProductCard } from "~/components/website/ProductCard";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { getHomePage, getProductCategoryTree, listProducts, listPosts, listPostsByTermLegacy, type PostCardItem } from "~/server/data";
|
import { getHomePage, getProductCategoryTree, listProducts, listPosts, listPostsByTermLegacy } from "~/server/data";
|
||||||
|
|
||||||
/** # 函数 收集 Elementor 文档中 posts widget 的查询并预取文章(H18)
|
|
||||||
* posts widget(skin cards)按 settings.posts_include_term_ids(WP 术语 legacy_id)+
|
|
||||||
* cards_posts_per_page 查询最新文章,结果按 widget id 传给渲染器。
|
|
||||||
* 参数 meta 首页 contents.meta
|
|
||||||
* 返回值 widgetId → 文章卡数组的映射
|
|
||||||
*/
|
|
||||||
async function loadPostsWidgets(meta: Record<string, unknown> | null): Promise<Record<string, PostCardItem[]>> {
|
|
||||||
const out: Record<string, PostCardItem[]> = {};
|
|
||||||
if (!meta) return out;
|
|
||||||
const walk = async (els: ElElement[] | undefined) => {
|
|
||||||
for (const el of els || []) {
|
|
||||||
if (el.widgetType === "posts" && el.id) {
|
|
||||||
const st = (el.settings || {}) as Record<string, unknown>;
|
|
||||||
const termIds = Array.isArray(st.posts_include_term_ids) ? (st.posts_include_term_ids as number[]) : [];
|
|
||||||
const perPage = Number(st.cards_posts_per_page) || 5;
|
|
||||||
if (termIds.length) {
|
|
||||||
out[el.id] = await listPostsByTermLegacy(Number(termIds[0]), perPage).catch(() => []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await walk(el.elements);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const elementor = (meta.elementor ?? null) as { elements?: ElElement[] } | ElElement[] | null;
|
|
||||||
// 文档可能为裸数组或 {elements} 包装两种形态(extractElementor 同款兼容)
|
|
||||||
await walk(Array.isArray(elementor) ? elementor : elementor?.elements);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
loader: async () => {
|
// loader 本体抽为 Server Function(TanStack Start loader 是同构运行:客户端导航会在
|
||||||
// 并行取布局 + 首页内容 + 后备数据;DB 为空时各自返回空结构(优雅降级)
|
// 浏览器再跑一遍,若直接调 db/查询层(依赖 bun:sql stub)会全部失败——页面退化为
|
||||||
const [layout, home, cats, products, posts] = await Promise.all([
|
// 空数据兜底。数据获取必须走 createServerFn,由服务端执行返回纯 JSON)
|
||||||
getLayoutData(),
|
loader: async () => loadHomePage(),
|
||||||
getHomePage().catch(() => null),
|
|
||||||
getProductCategoryTree().catch(() => []),
|
|
||||||
listProducts({ page: 1, pageSize: 8 }).catch(() => ({ items: [], total: 0, page: 1, pageSize: 8 })),
|
|
||||||
listPosts(1, 3).catch(() => ({ items: [], total: 0, page: 1, pageSize: 3 })),
|
|
||||||
]);
|
|
||||||
const meta = (home?.meta ?? null) as Record<string, unknown> | null;
|
|
||||||
// Elementor posts widget 预取(H18:左右两个 posts widget)
|
|
||||||
const postsData = await loadPostsWidgets(meta);
|
|
||||||
return { layout, home, meta, cats, products, posts, postsData };
|
|
||||||
},
|
|
||||||
head: ({ loaderData }) => ({
|
head: ({ loaderData }) => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ title: loaderData?.home?.seoTitle || "Dusun IoT | IoT Gateway Manufacturer & Solution Provider" },
|
{ title: loaderData?.home?.seoTitle || "Dusun IoT | IoT Gateway Manufacturer & Solution Provider" },
|
||||||
@@ -66,6 +29,47 @@ export const Route = createFileRoute("/")({
|
|||||||
component: HomePage,
|
component: HomePage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** # 函数 首页数据加载(Server Function:客户端导航时由服务端执行,返回纯 JSON)
|
||||||
|
* 内容:布局 + Elementor 首页 + 分类树 + 最新产品 + 最新文章 + posts widget 预取。
|
||||||
|
*/
|
||||||
|
const loadHomePage = createServerFn({ method: "GET" }).handler(async (): Promise<any> => {
|
||||||
|
// 并行取布局 + 首页内容 + 后备数据;DB 为空时各自返回空结构(优雅降级)
|
||||||
|
const [layout, home, cats, products, posts] = await Promise.all([
|
||||||
|
getLayoutData(),
|
||||||
|
getHomePage().catch(() => null),
|
||||||
|
getProductCategoryTree().catch(() => []),
|
||||||
|
listProducts({ page: 1, pageSize: 8 }).catch(() => ({ items: [], total: 0, page: 1, pageSize: 8 })),
|
||||||
|
listPosts(1, 3).catch(() => ({ items: [], total: 0, page: 1, pageSize: 3 })),
|
||||||
|
]);
|
||||||
|
const meta = (home?.meta ?? null) as Record<string, unknown> | null;
|
||||||
|
// Elementor posts widget 预取(H18:左右两个 posts widget)
|
||||||
|
const postsData = await loadPostsWidgetsHome(meta);
|
||||||
|
return { layout, home, meta, cats, products, posts, postsData };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 函数 收集首页 Elementor 文档中 posts widget 的查询并预取文章(仅供 serverFn 内部使用) */
|
||||||
|
async function loadPostsWidgetsHome(meta: unknown): Promise<Record<string, unknown>> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
if (!meta || typeof meta !== "object") return out;
|
||||||
|
const walk = async (els: ElElement[] | undefined) => {
|
||||||
|
for (const el of els || []) {
|
||||||
|
if (el.widgetType === "posts" && el.id) {
|
||||||
|
const st = (el.settings || {}) as Record<string, unknown>;
|
||||||
|
const termIds = Array.isArray(st.posts_include_term_ids) ? (st.posts_include_term_ids as number[]) : [];
|
||||||
|
const perPage = Number(st.cards_posts_per_page) || 5;
|
||||||
|
if (termIds.length) {
|
||||||
|
out[el.id] = await listPostsByTermLegacy(Number(termIds[0]), perPage).catch(() => []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await walk(el.elements);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const elementor = ((meta as Record<string, unknown>).elementor ?? null) as { elements?: ElElement[] } | ElElement[] | null;
|
||||||
|
// 文档可能为裸数组或 {elements} 包装两种形态(extractElementor 同款兼容)
|
||||||
|
await walk(Array.isArray(elementor) ? elementor : elementor?.elements);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** # 组件 首页 */
|
/** # 组件 首页 */
|
||||||
function HomePage() {
|
function HomePage() {
|
||||||
const data = Route.useLoaderData();
|
const data = Route.useLoaderData();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* popup 按钮 → elementor-action 触发全局询盘弹窗。
|
* popup 按钮 → elementor-action 触发全局询盘弹窗。
|
||||||
*/
|
*/
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
import { ElementorRenderer, extractElementor, type ElDocument, type ElElement, type WooProductContext } from "~/components/elementor/Renderer";
|
import { ElementorRenderer, extractElementor, type ElDocument, type ElElement, type WooProductContext } from "~/components/elementor/Renderer";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -17,7 +18,22 @@ import { resolveMediaVariantUrl } from "~/utils/image-variant";
|
|||||||
import { useT } from "~/i18n";
|
import { useT } from "~/i18n";
|
||||||
|
|
||||||
export const Route = createFileRoute("/product/$slug")({
|
export const Route = createFileRoute("/product/$slug")({
|
||||||
loader: async ({ params }) => {
|
// loader 本体抽为 Server Function(客户端导航在浏览器重跑 loader,db 依赖必须在服务端执行)
|
||||||
|
loader: (ctx) => loadProduct({ data: { slug: ctx.params.slug } }),
|
||||||
|
head: ({ loaderData }) => ({
|
||||||
|
meta: [
|
||||||
|
{ title: loaderData?.detail ? loaderData.detail.seoTitle || `${loaderData.detail.title} | Dusun IoT` : "Product Not Found | Dusun IoT" },
|
||||||
|
{ name: "description", content: loaderData?.detail?.seoDescription || loaderData?.detail?.shortDescription || "IoT gateway and device product details from Dusun IoT." },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
component: ProductPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 函数 产品详情页数据加载(Server Function:返回纯 JSON;slug 经 data 传入) */
|
||||||
|
const loadProduct = createServerFn({ method: "GET" })
|
||||||
|
.validator((d: { slug: string }) => d)
|
||||||
|
.handler(async ({ data }): Promise<any> => {
|
||||||
|
const params = data;
|
||||||
const [layout, detail, template] = await Promise.all([
|
const [layout, detail, template] = await Promise.all([
|
||||||
getLayoutData(),
|
getLayoutData(),
|
||||||
getProductDetail(params.slug).catch(() => null),
|
getProductDetail(params.slug).catch(() => null),
|
||||||
@@ -110,15 +126,7 @@ export const Route = createFileRoute("/product/$slug")({
|
|||||||
? await loadPostsWidgetsData([...(doc as unknown as ElElement[]), ...((Array.isArray(productDoc) ? productDoc : []) as ElElement[])])
|
? 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 };
|
return { layout, detail: { ...detail, meta, gallery }, tpl: doc ? { doc, pageCss, woo, templates, postsData } : null };
|
||||||
},
|
});
|
||||||
head: ({ loaderData }) => ({
|
|
||||||
meta: [
|
|
||||||
{ title: loaderData?.detail ? loaderData.detail.seoTitle || `${loaderData.detail.title} | Dusun IoT` : "Product Not Found | Dusun IoT" },
|
|
||||||
{ name: "description", content: loaderData?.detail?.seoDescription || loaderData?.detail?.shortDescription || "IoT gateway and device product details from Dusun IoT." },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
component: ProductPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
/** # 组件 产品详情页(模板 2741 直出) */
|
/** # 组件 产品详情页(模板 2741 直出) */
|
||||||
function ProductPage() {
|
function ProductPage() {
|
||||||
|
|||||||
+19
-11
@@ -3,6 +3,7 @@
|
|||||||
* 服务端 listProducts({q}) 全文匹配产品标题/SKU。
|
* 服务端 listProducts({q}) 全文匹配产品标题/SKU。
|
||||||
*/
|
*/
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
import { ProductCard } from "~/components/website/ProductCard";
|
import { ProductCard } from "~/components/website/ProductCard";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -17,7 +18,22 @@ export const Route = createFileRoute("/search")({
|
|||||||
return q ? { q } : {};
|
return q ? { q } : {};
|
||||||
},
|
},
|
||||||
loaderDeps: ({ search }) => ({ q: search.q }),
|
loaderDeps: ({ search }) => ({ q: search.q }),
|
||||||
loader: async ({ deps }) => {
|
// loader 本体抽为 Server Function(客户端导航在浏览器重跑 loader,db 依赖必须在服务端执行)
|
||||||
|
loader: async ({ deps }) => loadSearchData({ data: { q: deps.q } }),
|
||||||
|
head: ({ loaderData }) => ({
|
||||||
|
meta: [
|
||||||
|
{ title: loaderData?.q ? `Search: ${loaderData.q} | Dusun IoT` : "Search | Dusun IoT" },
|
||||||
|
{ name: "description", content: "Search Dusun IoT products by keyword or SKU." },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
component: SearchPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 函数 搜索页数据加载(Server Function:客户端导航时由服务端执行) */
|
||||||
|
const loadSearchData = createServerFn({ method: "GET" })
|
||||||
|
.validator((d: { q?: string }) => d)
|
||||||
|
.handler(async ({ data }): Promise<any> => {
|
||||||
|
const deps = { q: data?.q };
|
||||||
const layout = await getLayoutData();
|
const layout = await getLayoutData();
|
||||||
const empty: ProductListResult = { items: [], total: 0, page: 1, pageSize: 48 };
|
const empty: ProductListResult = { items: [], total: 0, page: 1, pageSize: 48 };
|
||||||
if (!deps.q) return { layout, list: empty, q: deps.q };
|
if (!deps.q) return { layout, list: empty, q: deps.q };
|
||||||
@@ -36,15 +52,7 @@ export const Route = createFileRoute("/search")({
|
|||||||
list = await listProducts({ q: capWords, pageSize: 48 }).catch(() => empty);
|
list = await listProducts({ q: capWords, pageSize: 48 }).catch(() => empty);
|
||||||
}
|
}
|
||||||
return { layout, list, q: deps.q };
|
return { layout, list, q: deps.q };
|
||||||
},
|
});
|
||||||
head: ({ loaderData }) => ({
|
|
||||||
meta: [
|
|
||||||
{ title: loaderData?.q ? `Search: ${loaderData.q} | Dusun IoT` : "Search | Dusun IoT" },
|
|
||||||
{ name: "description", content: "Search Dusun IoT products by keyword or SKU." },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
component: SearchPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
/** # 组件 搜索结果页 */
|
/** # 组件 搜索结果页 */
|
||||||
function SearchPage() {
|
function SearchPage() {
|
||||||
@@ -67,7 +75,7 @@ function SearchPage() {
|
|||||||
<Button type="submit">{t("search_button")}</Button>
|
<Button type="submit">{t("search_button")}</Button>
|
||||||
</form>
|
</form>
|
||||||
<div className="grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
|
<div className="grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
|
||||||
{data.list.items.map((p) => (
|
{data.list.items.map((p: { id: number; slug: string; title: string; sku: string | null; imageUrl: string | null; imageAlt?: string | null }) => (
|
||||||
<ProductCard key={p.id} p={p} />
|
<ProductCard key={p.id} p={p} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+27
-13
@@ -4,6 +4,7 @@
|
|||||||
* 「IoT Products / Showing x–y of z results」+ 产品网格 + 分页。
|
* 「IoT Products / Showing x–y of z results」+ 产品网格 + 分页。
|
||||||
*/
|
*/
|
||||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
import { ProductCard } from "~/components/website/ProductCard";
|
import { ProductCard } from "~/components/website/ProductCard";
|
||||||
import { Pagination } from "~/components/website/Pagination";
|
import { Pagination } from "~/components/website/Pagination";
|
||||||
@@ -15,18 +16,8 @@ import { useT } from "~/i18n";
|
|||||||
export const Route = createFileRoute("/shop")({
|
export const Route = createFileRoute("/shop")({
|
||||||
validateSearch: parseCollectionSearch as (search: Record<string, unknown>) => CollectionSearch,
|
validateSearch: parseCollectionSearch as (search: Record<string, unknown>) => CollectionSearch,
|
||||||
loaderDeps: ({ search }) => ({ search }),
|
loaderDeps: ({ search }) => ({ search }),
|
||||||
loader: async ({ deps }) => {
|
// loader 本体抽为 Server Function(客户端导航在浏览器重跑 loader,db 依赖必须在服务端执行)
|
||||||
const [layout, filterConfig] = await Promise.all([
|
loader: async ({ deps }) => loadShopData({ data: deps.search }),
|
||||||
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: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ title: "All Products - DusunIoT" },
|
{ title: "All Products - DusunIoT" },
|
||||||
@@ -36,10 +27,33 @@ export const Route = createFileRoute("/shop")({
|
|||||||
component: ShopPage,
|
component: ShopPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** # 函数 /shop/ 产品列表数据加载(Server Function:客户端导航时由服务端执行) */
|
||||||
|
const loadShopData = createServerFn({ method: "GET" })
|
||||||
|
.validator((d: unknown) => d)
|
||||||
|
.handler(async ({ data: search }): Promise<any> => {
|
||||||
|
const [layout, filterConfig] = await Promise.all([
|
||||||
|
getLayoutData(),
|
||||||
|
getFilterConfig().catch(() => []),
|
||||||
|
]);
|
||||||
|
const list = await listProducts({
|
||||||
|
filters: searchToFilters((search ?? {}) as CollectionSearch),
|
||||||
|
page: (search as { page?: number } | null)?.page,
|
||||||
|
pageSize: 24,
|
||||||
|
}).catch(() => ({ items: [], total: 0, page: 1, pageSize: 24 }) as ProductListResult);
|
||||||
|
return { layout, filterConfig, list };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 类型 /shop/ loader 数据(serverFn 返回 any,展示层按此收窄) */
|
||||||
|
type ShopData = {
|
||||||
|
layout: any;
|
||||||
|
filterConfig: { name: string; slug: string; values: { value: string; slug: string; count: number }[] }[];
|
||||||
|
list: ProductListResult;
|
||||||
|
};
|
||||||
|
|
||||||
/** # 函数 组件 /shop/ 页面 */
|
/** # 函数 组件 /shop/ 页面 */
|
||||||
function ShopPage() {
|
function ShopPage() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const data = Route.useLoaderData();
|
const data = Route.useLoaderData() as ShopData;
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const page = data.list.page;
|
const page = data.list.page;
|
||||||
|
|||||||
@@ -3,23 +3,15 @@
|
|||||||
* 方案营销页(Elementor 通栏直出);无内容回退 404。
|
* 方案营销页(Elementor 通栏直出);无内容回退 404。
|
||||||
*/
|
*/
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
|
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
|
||||||
import { getPublishedContent } from "~/server/data";
|
import { getPublishedContent } from "~/server/data";
|
||||||
import { resolveImageVariants } from "~/utils/image-variant";
|
import { resolveImageVariants } from "~/utils/image-variant";
|
||||||
|
|
||||||
export const Route = createFileRoute("/solution/$slug")({
|
export const Route = createFileRoute("/solution/$slug")({
|
||||||
loader: async ({ params }) => {
|
// loader 本体抽为 Server Function(客户端导航在浏览器重跑 loader,db 依赖必须在服务端执行)
|
||||||
const [layout, page] = await Promise.all([
|
loader: (ctx) => loadSolutionPage({ data: { slug: ctx.params.slug } }),
|
||||||
getLayoutData(),
|
|
||||||
getPublishedContent(params.slug, "post").catch(() => null),
|
|
||||||
]);
|
|
||||||
if (!page || !extractElementor(page.meta)) return { layout, page: null };
|
|
||||||
const meta = (page.meta ?? null) as Record<string, unknown> | null;
|
|
||||||
// image widget 的 WP 尺寸变体(medium/thumbnail)解析为本地 -300x272 类文件
|
|
||||||
resolveImageVariants(meta?.elementor);
|
|
||||||
return { layout, page: { ...page, meta } };
|
|
||||||
},
|
|
||||||
head: ({ loaderData }) => ({
|
head: ({ loaderData }) => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ title: loaderData?.page ? loaderData.page.seoTitle || loaderData.page.title : "Solution Not Found | Dusun IoT" },
|
{ title: loaderData?.page ? loaderData.page.seoTitle || loaderData.page.title : "Solution Not Found | Dusun IoT" },
|
||||||
@@ -29,6 +21,21 @@ export const Route = createFileRoute("/solution/$slug")({
|
|||||||
component: SolutionPage,
|
component: SolutionPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** # 函数 方案页数据加载(Server Function:客户端导航时由服务端执行;返回纯 JSON) */
|
||||||
|
const loadSolutionPage = createServerFn({ method: "GET" })
|
||||||
|
.validator((d: { slug: string }) => d)
|
||||||
|
.handler(async ({ data }): Promise<any> => {
|
||||||
|
const [layout, page] = await Promise.all([
|
||||||
|
getLayoutData(),
|
||||||
|
getPublishedContent(data.slug, "post").catch(() => null),
|
||||||
|
]);
|
||||||
|
if (!page || !extractElementor(page.meta)) return { layout, page: null };
|
||||||
|
const meta = (page.meta ?? null) as Record<string, unknown> | null;
|
||||||
|
// image widget 的 WP 尺寸变体(medium/thumbnail)解析为本地 -300x272 类文件
|
||||||
|
resolveImageVariants(meta?.elementor);
|
||||||
|
return { layout, page: { ...page, meta } };
|
||||||
|
});
|
||||||
|
|
||||||
/** # 组件 方案落地页 */
|
/** # 组件 方案落地页 */
|
||||||
function SolutionPage() {
|
function SolutionPage() {
|
||||||
const data = Route.useLoaderData();
|
const data = Route.useLoaderData();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
|||||||
import { SolutionArchive, loadSolutionArchive } from "~/components/website/SolutionArchive";
|
import { SolutionArchive, loadSolutionArchive } from "~/components/website/SolutionArchive";
|
||||||
|
|
||||||
export const Route = createFileRoute("/solution/")({
|
export const Route = createFileRoute("/solution/")({
|
||||||
loader: () => loadSolutionArchive(1),
|
loader: () => loadSolutionArchive({ data: 1 }),
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ title: "IoT Gateways & Devices Solution - DusunIoT" },
|
{ title: "IoT Gateways & Devices Solution - DusunIoT" },
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { SolutionArchive, loadSolutionArchive } from "~/components/website/Solut
|
|||||||
export const Route = createFileRoute("/solution/page/$page")({
|
export const Route = createFileRoute("/solution/page/$page")({
|
||||||
loader: async ({ params }) => {
|
loader: async ({ params }) => {
|
||||||
const n = Number(params.page);
|
const n = Number(params.page);
|
||||||
return loadSolutionArchive(Number.isFinite(n) && n > 1 ? Math.floor(n) : 1);
|
return loadSolutionArchive({ data: Number.isFinite(n) && n > 1 ? Math.floor(n) : 1 });
|
||||||
},
|
},
|
||||||
head: ({ loaderData }) => ({
|
head: ({ loaderData }) => ({
|
||||||
meta: [
|
meta: [
|
||||||
|
|||||||
+45
-4
@@ -390,6 +390,17 @@ function firstUploadedImage(meta: unknown): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** # 函数 从 WP 导出 HTML 里提取第一张 <img> 的 src(部分文章 meta.elementor 为空、
|
||||||
|
* 只有 html 富文本,列表图回退必须扫正文)
|
||||||
|
* 参数 html 内容 HTML
|
||||||
|
* 返回值 图片 URL(含原站绝对地址,展示层统一改写)或 null
|
||||||
|
*/
|
||||||
|
function firstHtmlImage(html: string | null): string | null {
|
||||||
|
if (!html) return null;
|
||||||
|
const m = /<img[^>]*\ssrc=["']([^"']+)["']/i.exec(html);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** # 类型 博客列表项(列表卡片 + 文章页侧栏/相关文章复用) */
|
/** # 类型 博客列表项(列表卡片 + 文章页侧栏/相关文章复用) */
|
||||||
export type PostListItem = Awaited<ReturnType<typeof listPosts>>["items"][number];
|
export type PostListItem = Awaited<ReturnType<typeof listPosts>>["items"][number];
|
||||||
|
|
||||||
@@ -439,8 +450,23 @@ export async function listSolutionLandings(page = 1, pageSize = 12) {
|
|||||||
.orderBy(desc(contents.publishedAt))
|
.orderBy(desc(contents.publishedAt))
|
||||||
.limit(pageSize)
|
.limit(pageSize)
|
||||||
.offset((page - 1) * pageSize);
|
.offset((page - 1) * pageSize);
|
||||||
|
const items = rows.map((r) => ({ ...r, title: decodeHtmlEntities(r.title), excerpt: r.excerpt ?? "" }));
|
||||||
|
// 无特色图:meta(elementor 文档/HTML)→ 正文富文本提取第一张图作列表图回退
|
||||||
|
const needFallback = items.filter((it) => !it.imageUrl);
|
||||||
|
if (needFallback.length) {
|
||||||
|
const metas = await db
|
||||||
|
.select({ id: contents.id, meta: contents.meta, html: contents.html })
|
||||||
|
.from(contents)
|
||||||
|
.where(inArray(contents.id, needFallback.map((it) => it.id)));
|
||||||
|
const firstImg = new Map<number, string>();
|
||||||
|
for (const m of metas) {
|
||||||
|
const url = firstUploadedImage(m.meta) ?? firstHtmlImage(m.html);
|
||||||
|
if (url) firstImg.set(m.id, url);
|
||||||
|
}
|
||||||
|
for (const it of items) if (!it.imageUrl && firstImg.has(it.id)) it.imageUrl = firstImg.get(it.id)!;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
items: rows.map((r) => ({ ...r, title: decodeHtmlEntities(r.title), excerpt: r.excerpt ?? "" })),
|
items,
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -476,16 +502,16 @@ export async function listPosts(page = 1, pageSize = 12, categorySlug?: string)
|
|||||||
.orderBy(desc(contents.publishedAt))
|
.orderBy(desc(contents.publishedAt))
|
||||||
.limit(pageSize)
|
.limit(pageSize)
|
||||||
.offset((page - 1) * pageSize);
|
.offset((page - 1) * pageSize);
|
||||||
// 无特色图的文章:从 elementor 内容里提取第一张上传图片作列表图回退
|
// 无特色图的文章:meta(elementor 文档/HTML)→ 正文富文本逐层提取第一张图片作列表图回退
|
||||||
const needFallback = items.filter((it) => !it.imageUrl);
|
const needFallback = items.filter((it) => !it.imageUrl);
|
||||||
if (needFallback.length) {
|
if (needFallback.length) {
|
||||||
const metas = await db
|
const metas = await db
|
||||||
.select({ id: contents.id, meta: contents.meta })
|
.select({ id: contents.id, meta: contents.meta, html: contents.html })
|
||||||
.from(contents)
|
.from(contents)
|
||||||
.where(inArray(contents.id, needFallback.map((it) => it.id)));
|
.where(inArray(contents.id, needFallback.map((it) => it.id)));
|
||||||
const firstImg = new Map<number, string>();
|
const firstImg = new Map<number, string>();
|
||||||
for (const m of metas) {
|
for (const m of metas) {
|
||||||
const url = firstUploadedImage(m.meta);
|
const url = firstUploadedImage(m.meta) ?? firstHtmlImage(m.html);
|
||||||
if (url) firstImg.set(m.id, url);
|
if (url) firstImg.set(m.id, url);
|
||||||
}
|
}
|
||||||
for (const it of items) if (!it.imageUrl && firstImg.has(it.id)) it.imageUrl = firstImg.get(it.id)!;
|
for (const it of items) if (!it.imageUrl && firstImg.has(it.id)) it.imageUrl = firstImg.get(it.id)!;
|
||||||
@@ -690,6 +716,7 @@ export async function listPostsByTermsLegacy(legacyTermIds: number[], limit: num
|
|||||||
const catIds = catRows.map((c) => c.id);
|
const catIds = catRows.map((c) => c.id);
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
|
id: contents.id,
|
||||||
slug: contents.slug,
|
slug: contents.slug,
|
||||||
title: contents.title,
|
title: contents.title,
|
||||||
excerpt: contents.excerpt,
|
excerpt: contents.excerpt,
|
||||||
@@ -711,5 +738,19 @@ export async function listPostsByTermsLegacy(legacyTermIds: number[], limit: num
|
|||||||
)
|
)
|
||||||
.orderBy(desc(contents.publishedAt))
|
.orderBy(desc(contents.publishedAt))
|
||||||
.limit(Math.max(1, Math.min(20, limit)));
|
.limit(Math.max(1, Math.min(20, limit)));
|
||||||
|
// 无特色图:meta(elementor 文档/HTML)→ 正文富文本提取第一张图(列表卡片缩略图兜底)
|
||||||
|
const needFallback = rows.filter((r) => !r.imageUrl);
|
||||||
|
if (needFallback.length) {
|
||||||
|
const metas = await db
|
||||||
|
.select({ id: contents.id, meta: contents.meta, html: contents.html })
|
||||||
|
.from(contents)
|
||||||
|
.where(inArray(contents.id, needFallback.map((r) => r.id)));
|
||||||
|
const firstImg = new Map<number, string>();
|
||||||
|
for (const m of metas) {
|
||||||
|
const url = firstUploadedImage(m.meta) ?? firstHtmlImage(m.html);
|
||||||
|
if (url) firstImg.set(m.id, url);
|
||||||
|
}
|
||||||
|
for (const r of rows) if (!r.imageUrl) r.imageUrl = firstImg.get(r.id) ?? null;
|
||||||
|
}
|
||||||
return rows as PostCardItem[];
|
return rows as PostCardItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,16 @@
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
img { max-width: 100%; }
|
img { max-width: 100%; }
|
||||||
|
/* 全站 <img> 占位图(.ds-site 作用域,Layout.tsx 的 main):
|
||||||
|
* 未加载完成时先显示灰底 + 低透明度 Dusun logo(JPG,public/uploads/placeholder.jpg);
|
||||||
|
* 图片加载完成后被像素覆盖(透明 PNG 慎用,会透出占位图) */
|
||||||
|
.ds-site img {
|
||||||
|
background-color: #e8eaee;
|
||||||
|
background-image: url("/uploads/placeholder.jpg");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: 42% auto;
|
||||||
|
}
|
||||||
a { color: var(--brand); text-decoration: none; }
|
a { color: var(--brand); text-decoration: none; }
|
||||||
/* 原站 kit-15(post-15.css):a:hover = --e-global-color-secondary #FFA11B */
|
/* 原站 kit-15(post-15.css):a:hover = --e-global-color-secondary #FFA11B */
|
||||||
a:hover { color: var(--e-global-color-secondary, #ffa11b); }
|
a:hover { color: var(--e-global-color-secondary, #ffa11b); }
|
||||||
|
|||||||
Reference in New Issue
Block a user