diff --git a/docs/sections-inventory.md b/docs/sections-inventory.md index 055fb12ca..b8bb73aa2 100644 --- a/docs/sections-inventory.md +++ b/docs/sections-inventory.md @@ -253,5 +253,5 @@ cookie-policy:19 段纯文本。 | 3 | ~~全局 `a:hover` 为 --brand-dark(#c85600),kit 全局 secondary 是 #FFA11B~~ ✅ v0.10.31 修复(a:hover=secondary #FFA11B,e-global 全套变量补入 :root) | G1/全局样式 | 已修 | | 4 | PG 连接曾被 stale vite dev 进程(3311 端口)泄漏占满(290 连接),已清理;重复发生时检查残留 dev 进程 | 环境 | 记录 | | 5 | ~~内嵌模板文章列表取 dump 快照,原站新增文章(161855/161748)尚未导入~~ ✅ v0.10.35:`scripts/import-live-posts.ts` REST 增量导入 19 篇(post+page,幂等可重跑)+ `scripts/download-missing-media.ts` 补拉 2026/11 特色图 16 张;博客 HTML 文章详情统一原站 single 模板布局(hero 白摘要/Post Views 行) | 内容数据 | 已修 | -| 6 | posts 卡片移动端个别卡高 ±5px(原站 srcset 选 768/medium 变体,本地用原图;宽高比略有差异) | posts widget | 低 | +| 6 | ~~posts 卡片移动端个别卡高 ±5px(原站 srcset 选 768/medium 变体,本地用原图;宽高比略有差异)~~ ✅ v0.10.37:真因是移动端列未堆叠(Elementor frontend.css 对所有列 ≤767 回退 100%,本地仅对设 `_inline_size` 的列生成规则)+ posts 缩略图缺 has-item-ratio 模式(Elementor cards_item_ratio 控件默认 0.66 恒生效:容器 pad 比率框 + img 绝对居中 scale(1.01);fit-height per-image 判定=图片高宽比 a.startsWith("limit="))?.slice(6) || 0); +const UPLOADS = process.cwd() + "/public/uploads"; + +/** # 函数 解析图片文件头取宽高(PNG/JPEG/GIF/WebP 四格式) + * 参数 buf 文件头部字节(≥ 64 字节足够) + * 返回值 [宽, 高];无法解析返回 null + */ +function imageSize(buf: Uint8Array): [number, number] | null { + const u8 = buf, dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + // PNG: 8 字节签名 + IHDR(宽高在 16/20 偏移) + if (u8[0] === 0x89 && u8[1] === 0x50 && u8[2] === 0x4e && u8[3] === 0x47) + return [dv.getUint32(16), dv.getUint32(20)]; + // GIF: "GIF8" 宽高在 6/8(小端) + if (u8[0] === 0x47 && u8[1] === 0x49 && u8[2] === 0x46) + return [dv.getUint16(6, true), dv.getUint16(8, true)]; + // WebP: "RIFF....WEBP",VP8/VP8L/VP8X 三子格式 + if (u8[0] === 0x52 && u8[1] === 0x49 && u8[2] === 0x46 && u8[3] === 0x46 && u8[8] === 0x57 && u8[9] === 0x45) { + const fourcc = String.fromCharCode(u8[12], u8[13], u8[14], u8[15]); + if (fourcc === "VP8 ") return [dv.getUint16(26, true) & 0x3fff, dv.getUint16(28, true) & 0x3fff]; + if (fourcc === "VP8L") { + const b = dv.getUint32(21, true); + return [(b & 0x3fff) + 1, ((b >> 14) & 0x3fff) + 1]; + } + if (fourcc === "VP8X") { + // canvas 宽/高各 3 字节小端(24-26 宽、27-29 高),getUint32 会读到相邻字段越界 + const w = (u8[24] | (u8[25] << 8) | (u8[26] << 16)) + 1; + const h = (u8[27] | (u8[28] << 8) | (u8[29] << 16)) + 1; + return [w, h]; + } + return null; + } + // JPEG: 扫 SOFn 段(FFC0-FFCF 除 FFD8/FF01/FFD0-FFD7) + if (u8[0] === 0xff && u8[1] === 0xd8) { + let i = 2; + while (i + 9 < u8.length) { + if (u8[i] !== 0xff) { i++; continue; } + const marker = u8[i + 1]; + if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; } + const len = dv.getUint16(i + 2); + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) + return [dv.getUint16(i + 7), dv.getUint16(i + 9)]; + i += 2 + len; + } + return null; + } + // SVG 是文本( 100000 || size[1] > 100000) { fail++; continue; } + if (!dry) await sql`update media set width = ${size[0]}, height = ${size[1]} where id = ${r.id}`; + ok++; +} +console.log(`回填 ${ok}${dry ? "(dry)" : ""},文件缺失 ${miss},解析失败 ${fail}`); +await sql.end(); diff --git a/scripts/forensic-posts-cards.ts b/scripts/forensic-posts-cards.ts new file mode 100644 index 000000000..058cdf418 --- /dev/null +++ b/scripts/forensic-posts-cards.ts @@ -0,0 +1,64 @@ +/** + * # 脚本 取证 posts 卡片缩略图(本地 vs 原站,390px 移动视口) + * 对比每张 .elementor-post__thumbnail img 的渲染盒与当前选中资源(srcset 候选), + * 定位遗留表 #6 移动端个别卡 ±5px 差异。 + * 用法:bun scripts/forensic-posts-cards.ts + */ +import { chromium } from "playwright-core"; + +const CHROME = process.env.HOME + "/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome"; +const LOCAL = "http://127.0.0.1:3312"; +const LIVE = "https://www.dusuniot.com"; +const path = process.argv[2] || "/"; + +/** # 函数 抓取页面上全部 posts 卡片缩略图盒 */ +async function measure(base: string) { + const browser = await chromium.launch({ executablePath: CHROME, headless: true }); + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); + await page.goto(base + path, { waitUntil: "networkidle", timeout: 60000 }); + // 逐步滚到底触发懒加载图片,再滚回首个 posts 区块停稳(避免离开视口后浏览器取消加载) + await page.evaluate(async () => { + for (let y = 0; y < document.body.scrollHeight; y += 600) { + window.scrollTo(0, y); + await new Promise((r) => setTimeout(r, 120)); + } + const t = document.querySelector(".elementor-posts-container"); + if (t) t.scrollIntoView({ block: "start" }); + }); + await page.waitForTimeout(3000); + const data = await page.evaluate(() => { + return Array.from(document.querySelectorAll(".elementor-post__thumbnail img")).map((img) => { + const r = img.getBoundingClientRect(); + const card = img.closest(".elementor-post__card")!.getBoundingClientRect(); + const title = (img.closest("article")?.querySelector(".elementor-post__title")?.textContent || "").trim().slice(0, 40); + return { + title, + img: { x: +r.x.toFixed(1), y: +r.y.toFixed(1), w: +r.width.toFixed(1), h: +r.height.toFixed(1) }, + cardH: +card.height.toFixed(1), + cur: (img as HTMLImageElement).currentSrc.split("/uploads/")[1] || (img as HTMLImageElement).currentSrc, + nw: (img as HTMLImageElement).naturalWidth, + nh: (img as HTMLImageElement).naturalHeight, + }; + }); + }); + await browser.close(); + return data; +} + +const [local, live] = await Promise.all([measure(LOCAL), measure(LIVE)]); +console.log("=== 本地 ==="); +for (const d of local) console.log(JSON.stringify(d)); +console.log("=== 原站 ==="); +for (const d of live) console.log(JSON.stringify(d)); +console.log("=== 差异 ==="); +const n = Math.max(local.length, live.length); +for (let i = 0; i < n; i++) { + const a = local[i], b = live[i]; + if (!a || !b) { console.log(i, "缺侧", !!a, !!b); continue; } + const dh = +(a.img.h - b.img.h).toFixed(1); + const dch = +(a.cardH - b.cardH).toFixed(1); + const same = a.cur === b.cur; + if (Math.abs(dh) > 0.5 || Math.abs(dch) > 0.5 || !same) + console.log(`#${i} [${a.title}] imgH ${a.img.h} vs ${b.img.h} (${dh > 0 ? "+" : ""}${dh}) cardH ${a.cardH} vs ${b.cardH} (${dch > 0 ? "+" : ""}${dch}) 资源${same ? "同" : "异: " + a.cur + " <-> " + b.cur} nat ${a.nw}x${a.nh} vs ${b.nw}x${b.nh}`); +} +console.log("完成"); diff --git a/scripts/forensic-posts-struct.ts b/scripts/forensic-posts-struct.ts new file mode 100644 index 000000000..bed757e79 --- /dev/null +++ b/scripts/forensic-posts-struct.ts @@ -0,0 +1,52 @@ +/** + * # 脚本 取证 posts widget 容器结构(本地 vs 原站,390px) + * 抓每个 posts 区块的 widget data-id、grid/容器样式、article 数、swiper 迹象。 + * 用法:bun scripts/forensic-posts-struct.ts + */ +import { chromium } from "playwright-core"; + +const CHROME = process.env.HOME + "/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome"; +const LOCAL = "http://127.0.0.1:3312"; +const LIVE = "https://www.dusuniot.com"; +const path = process.argv[2] || "/"; + +async function measure(base: string) { + const browser = await chromium.launch({ executablePath: CHROME, headless: true }); + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); + await page.goto(base + path, { waitUntil: "networkidle", timeout: 60000 }); + await page.evaluate(async () => { + for (let y = 0; y < document.body.scrollHeight; y += 600) { + window.scrollTo(0, y); + await new Promise((r) => setTimeout(r, 100)); + } + }); + await page.waitForTimeout(1500); + const data = await page.evaluate(() => { + const out: unknown[] = []; + for (const grid of Array.from(document.querySelectorAll(".elementor-posts-container"))) { + const widget = grid.closest("[data-id]") as HTMLElement | null; + const art = grid.querySelectorAll("article"); + const cs = getComputedStyle(grid); + const wrap = widget?.parentElement; + out.push({ + widgetId: widget?.getAttribute("data-id"), + widgetClass: (widget?.className || "").slice(0, 120), + wrapClass: (wrap?.className || "").slice(0, 140), + articles: art.length, + gridDisplay: cs.display, + gridCols: (grid.getAttribute("style") || "").slice(0, 160), + swiper: !!widget?.querySelector(".swiper"), + articleW: art.length ? +art[0].getBoundingClientRect().width.toFixed(1) : 0, + }); + } + return out; + }); + await browser.close(); + return data; +} + +const [local, live] = await Promise.all([measure(LOCAL), measure(LIVE)]); +console.log("=== 本地 ==="); +for (const d of local) console.log(JSON.stringify(d)); +console.log("=== 原站 ==="); +for (const d of live) console.log(JSON.stringify(d)); diff --git a/src/components/elementor/Renderer.tsx b/src/components/elementor/Renderer.tsx index 9ef00061d..8a85a2669 100644 --- a/src/components/elementor/Renderer.tsx +++ b/src/components/elementor/Renderer.tsx @@ -423,13 +423,16 @@ function collectResponsiveCss(elements: ElElement[]): string { const num = (v: unknown) => (typeof v === "number" ? v : v != null && v !== "" ? Number(v) : NaN); const tw = num(st._inline_size_tablet); const mw = num(st._inline_size_mobile); - const dw = num(st._inline_size); if (Number.isFinite(tw) && tw > 0) { css += `@media (max-width:1024px){${sel}{flex:0 0 ${tw}% !important;max-width:${tw}% !important;}}\n`; } if (Number.isFinite(mw) && mw > 0) { css += `@media (max-width:767px){${sel}{flex:0 0 ${mw}% !important;max-width:${mw}% !important;}}\n`; - } else if (Number.isFinite(dw) && dw > 0) { + } else { + // Elementor frontend.css 对所有列在 ≤767 回退 100%(@media(max-width:767px){ + // .elementor-column{width:100%}})——无论该列桌面是否设 _inline_size。 + // (首页 8bc6b68 col-50 默认均分列未设 inline size,v0.10.36 前移动端不堆叠: + // posts 卡片区保持 195px 并排,原站为 374px 全宽堆叠) css += `@media (max-width:767px){${sel}{flex:0 0 100% !important;max-width:100% !important;}}\n`; } // icon-list 响应式项间距(space_between_tablet/mobile:页脚社交 10 → 7px) @@ -1078,7 +1081,10 @@ function PostsWidget({ el, settings }: { el: ElElement; settings: Record {hasThumb ? ( -
+ {/* fit-height:Elementor 前端 JS per-image 判定(图片高宽比 < 桌面 ratio + * 时按高适配、宽溢出裁剪;首页 3025217 卡2 1280x720=0.5625<0.66 实测 fit) */} +
{p.title}
diff --git a/src/server/data.ts b/src/server/data.ts index c0b597e2b..90d187cd0 100644 --- a/src/server/data.ts +++ b/src/server/data.ts @@ -657,6 +657,10 @@ export type PostCardItem = { excerpt: string | null; publishedAt: Date | null; imageUrl: string | null; + /** 特色图原始宽(fit-height 判定:高宽比 < cards_item_ratio 时按高适配) */ + imageWidth: number | null; + /** 特色图原始高(fit-height 判定用) */ + imageHeight: number | null; content: string | null; categoryName: string | null; }; @@ -691,6 +695,8 @@ export async function listPostsByTermsLegacy(legacyTermIds: number[], limit: num excerpt: contents.excerpt, publishedAt: contents.publishedAt, imageUrl: media.url, + imageWidth: media.width, + imageHeight: media.height, content: contents.html, categoryName: sql`(SELECT cat.name FROM content_terms ct JOIN categories cat ON cat.id = ct.category_id WHERE ct.content_id = ${contents.id} AND cat.taxonomy = 'category' LIMIT 1)`, }) diff --git a/src/styles/app.css b/src/styles/app.css index e28624a3c..59d81cfdd 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -472,11 +472,14 @@ * padding 48/48/16 + 白底 + 圆角 6 + 边框 #E0E5F9 均设置驱动 */ [data-el="65282de"] > div, [data-el="cc3e8ca"] > div { width: auto !important; } - /* ===== Elementor posts widget(skin cards 基础;H18 + 模板 27143 实测 2026-11-02) + /* ===== Elementor posts widget(skin cards 基础;H18 + 模板 27143 实测 2026-11-02, + * has-item-ratio 模式 3025217 实测 2026-11-04) * 移植 widget-posts.min.css cards 皮肤:article 纵向 flex;__card 白底/圆角 3/ - * overflow hidden/纵向 flex;缩略图容器用 padding-bottom 比率撑高、img 静态 - * 宽度 calc(100%+1px)(高度按原图比例);hover-gradient 覆盖渐变;badge 绝对 - * 右上(默认 #69727d 胶囊,H18 由 custom_css 覆盖为白底圆角 4); + * overflow hidden/纵向 flex;未设 cards_item_ratio 的 widget 缩略图为静态流 + * (img 宽 calc(100%+1px)、高按原图比例);设了 ratio 的 widget 走 Elementor + * has-item-ratio 模式(3025217 桌面实测 thumb pad 219.766=0.66×333、img absolute + * 居中 scale(1.01);移动端 fit-height img height:100%/width:auto);hover-gradient + * 覆盖渐变;badge 绝对右上(默认 #69727d 胶囊,H18 由 custom_css 覆盖为白底圆角 4); * text flex-grow;meta 在 text 之后(border-top + margin-top:auto)。 * 各尺寸/颜色由 PostsWidget 按设置内联(padding/字号/边框),此处只留结构。 */ .elementor-posts-container:not(.elementor-posts-masonry) { align-items: stretch; } @@ -500,11 +503,37 @@ width: 100%; min-height: 100%; border-radius: 3px; background-color: #fff; } .elementor-post__thumbnail__link { display: block; position: relative; width: 100%; margin: 0 0 20px; } - .elementor-post__thumbnail { - position: relative; overflow: hidden; + .elementor-post__thumbnail { position: relative; overflow: hidden; } + .elementor-post__thumbnail img { display: block; width: calc(100% + 1px); max-width: none; height: auto; } + /* has-item-ratio 模式(posts widget 恒走,Renderer 加容器 class): + * 缩略图容器 pad 撑比率框,img 绝对居中(left/top calc(50%+1px))+ scale(1.01) + * translate 回中(原站 3025217 桌面实测矩阵 matrix(1.01,0,0,1.01,-168.67,-111.3))。 + * 桌面默认 img 宽 calc(100%+1px)、高按图片比率(widget-posts.min.css card 规则); + * fit-height(Renderer per-image:图片高宽比 < ratio)img height:100%/width:auto; + * 移动端原站两卡均 fit(Elementor JS 按 ratio_mobile 重算,3025217 卡1 0.6604<0.67 + * 亦 fit),媒体查询内全体 fit 覆盖桌面默认 */ + .elementor-posts-container.elementor-has-item-ratio .elementor-post__thumbnail { padding-bottom: calc(var(--ep-ratio, 0.66) * 100%); } - .elementor-post__thumbnail img { display: block; width: calc(100% + 1px); max-width: none; height: auto; } + .elementor-posts-container.elementor-has-item-ratio .elementor-post__thumbnail img { + position: absolute; left: calc(50% + 1px); top: calc(50% + 1px); + height: auto; width: calc(100% + 1px); + transform: scale(1.01) translate(-50%, -50%); + } + /* fit-height:图片高宽比 < 桌面 ratio(Renderer per-image class)→ img 按高适配 */ + .elementor-posts-container.elementor-has-item-ratio .elementor-post__thumbnail.elementor-fit-height img { + height: 100%; width: auto; + } + /* 移动端原站两卡均 fit(Elementor JS 按 ratio_mobile 0.67 重算,卡1 0.6604<0.67 亦 fit) + * → 媒体查询内全体 fit,覆盖桌面默认(同特异性源码靠后生效) */ + @media (max-width: 767px) { + .elementor-posts-container.elementor-has-item-ratio .elementor-post__thumbnail img { + height: 100%; width: auto; + } + .elementor-posts-container.elementor-has-item-ratio .elementor-post__thumbnail { + padding-bottom: calc(var(--ep-ratio-m, var(--ep-ratio, 0.66)) * 100%); + } + } .elementor-post__badge { position: absolute; top: 0; right: 0; z-index: 1; margin: 20px; background: #69727d; color: #fff; border-radius: 999px; @@ -526,9 +555,6 @@ background-repeat: no-repeat; opacity: 1; transition: all 0.3s ease-out; } .elementor-posts__hover-gradient .elementor-post__card:hover .elementor-post__thumbnail__link:after { opacity: 0.5; } - @media (max-width: 767px) { - .elementor-post__thumbnail { padding-bottom: calc(var(--ep-ratio-m, var(--ep-ratio, 0.66)) * 100%); } - } /* 右列卡(a22c185 .ele-post-dif-2):纯文本卡 bg #F4F5F8、标题链接右侧箭头图标 * 与 44px 两行钳制均由 custom_css 注入;此处理品牌底色/箭头 hover 位移 */ [data-el="a22c185"] .elementor-post__card { background-color: #F4F5F8; }