From 7fd9e75887e55147f8cbe4b34e995f4850a846d8 Mon Sep 17 00:00:00 2001 From: bigcode Date: Wed, 4 Nov 2026 10:30:16 +0800 Subject: [PATCH] =?UTF-8?q?v0.13.0:=20=E5=89=8D=E5=8F=B0=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=88loader=20=E6=9C=8D=E5=8A=A1=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E5=8C=96=E9=98=B2=E5=AE=A2=E6=88=B7=E7=AB=AF=E9=80=80?= =?UTF-8?q?=E5=8C=96=EF=BC=89=E3=80=81=E5=85=A8=E7=AB=99=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E5=9B=BE+=E6=87=92=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E3=80=81favicon=E3=80=81=E5=88=86=E9=A1=B5=E5=9C=86=E7=82=B9?= =?UTF-8?q?=E3=80=81=E5=AF=BC=E8=88=AA=201400px=20=E5=B1=85=E4=B8=AD?= =?UTF-8?q?=E3=80=81=E7=BC=BA=E5=A4=B1=E5=9B=BE=E7=89=87=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/admin-crm.md | 20 +++++ package.json | 2 +- public/apple-touch-icon.png | Bin 0 -> 5644 bytes public/favicon.ico | Bin 0 -> 4286 bytes scripts/front-browser-test.ts | 98 +++++++++++++++++++++ scripts/prod-server.ts | 5 +- scripts/sync-missing-uploads.ts | 79 +++++++++++++++++ src/components/ui/pagination.tsx | 6 +- src/components/website/BlogArchive.tsx | 21 +++-- src/components/website/Header.tsx | 10 +-- src/components/website/Layout.tsx | 28 +++++- src/components/website/SolutionArchive.tsx | 22 +++-- src/routes/$.tsx | 91 +++++++++++-------- src/routes/__root.tsx | 13 ++- src/routes/blog.$slug.tsx | 32 ++++--- src/routes/blog.index.tsx | 2 +- src/routes/blog.page.$page.tsx | 2 +- src/routes/collection.$slug.tsx | 75 ++++++++++------ src/routes/contact-us.tsx | 21 +++-- src/routes/index.tsx | 90 ++++++++++--------- src/routes/product.$slug.tsx | 28 +++--- src/routes/search.tsx | 30 ++++--- src/routes/shop.tsx | 40 ++++++--- src/routes/solution.$slug.tsx | 29 +++--- src/routes/solution.index.tsx | 2 +- src/routes/solution.page.$page.tsx | 2 +- src/server/data.ts | 49 ++++++++++- src/styles/app.css | 10 +++ 28 files changed, 599 insertions(+), 208 deletions(-) create mode 100644 public/apple-touch-icon.png create mode 100644 public/favicon.ico create mode 100644 scripts/front-browser-test.ts create mode 100644 scripts/sync-missing-uploads.ts diff --git a/docs/admin-crm.md b/docs/admin-crm.md index e9553359b..61d0b4403 100644 --- a/docs/admin-crm.md +++ b/docs/admin-crm.md @@ -1,8 +1,28 @@ # 后台(Admin / CRM)与 MCP 说明 +> v0.13.0 起:前台导航修复(loader 服务函数化)、全站图片占位图 + 懒加载、favicon。 > v0.12.0 起:PG 16 → 18.6(增量备份能力)、备份系统(全量/增量/S3)、用户权限分层。 > v0.11.0 起:后台按「功能接近原版 WordPress + 现代化 CRM 界面」重做一轮,并新增站点管理 MCP 服务器。 +## 前台约定(v0.13.0 起生效) + +- **TanStack Start loader 是同构运行的**:客户端导航(点击 LOGO / `` / 分页)会在浏览器重跑一遍 + 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 生成); + 全部前台 `` 由 Layout 内联脚本补 `loading="lazy" decoding="async"`(MutationObserver 覆盖新增节点), + 加载完成前显示占位背景(`app.css` 的 `.ds-site img` 规则;作用域为 Layout 的 `
`)。 + 注意透明 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) 两类用户:**普通用户**(customer,仅商城账号)与**后台用户**(admin > manager > editor)。能力矩阵(`src/server/auth.ts` requireCapability): diff --git a/package.json b/package.json index e6f76d516..caf9dd0de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dusuniot-rebuild", - "version": "0.12.0", + "version": "0.13.0", "private": true, "type": "module", "scripts": { diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4e153784b992af78453ba9a0cd2af477c8ec16d6 GIT binary patch literal 5644 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 { + 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); diff --git a/scripts/prod-server.ts b/scripts/prod-server.ts index be5d75925..2812392f2 100644 --- a/scripts/prod-server.ts +++ b/scripts/prod-server.ts @@ -31,7 +31,10 @@ export default { /** # 函数 请求入口:静态文件优先,其余交给 Start handler */ async fetch(req: Request): Promise { const url = new URL(req.url); - const f = staticFiles.get(url.pathname); + // 尾斜杠归一化:偶发的 ".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) { return new Response(Bun.file(f), { headers: { "cache-control": "public, max-age=31536000, immutable" }, diff --git a/scripts/sync-missing-uploads.ts b/scripts/sync-missing-uploads.ts new file mode 100644 index 000000000..4e2d4a816 --- /dev/null +++ b/scripts/sync-missing-uploads.ts @@ -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/ 下载补齐; + * 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(); +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)) 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); diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx index d521d49ba..ecc61449d 100644 --- a/src/components/ui/pagination.tsx +++ b/src/components/ui/pagination.tsx @@ -11,14 +11,14 @@ function Pagination({ className, ...props }: React.ComponentProps<"nav">) { return