v0.13.0: 前台导航修复(loader 服务函数化防客户端退化)、全站图片占位图+懒加载、favicon、分页圆点、导航 1400px 居中、缺失图片补齐脚本

This commit is contained in:
bigcode
2026-11-04 10:30:16 +08:00
parent 4fba80a216
commit 7fd9e75887
28 changed files with 599 additions and 208 deletions
+98
View File
@@ -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);
+4 -1
View File
@@ -31,7 +31,10 @@ export default {
/** # 函数 请求入口:静态文件优先,其余交给 Start handler */
async fetch(req: Request): Promise<Response> {
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) {
return new Response(Bun.file(f), {
headers: { "cache-control": "public, max-age=31536000, immutable" },
+79
View File
@@ -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);