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 000000000..4e153784b Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 000000000..da2cee4c0 Binary files /dev/null and b/public/favicon.ico differ diff --git a/scripts/front-browser-test.ts b/scripts/front-browser-test.ts new file mode 100644 index 000000000..360fb36a2 --- /dev/null +++ b/scripts/front-browser-test.ts @@ -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); 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