v0.10.18: H18 最新博文(8bc6b68)完成 + 文章分类关系数据修复

- PostsWidget 忠实重写(skin cards):预取架构 loader 按 widget 收集术语→查文章→
  PostsDataContext 分发;卡 DOM/缩略图比率 0.66/NEWS 徽章(uppercase)/日期 F j,Y/
  摘要 18 词(空回退正文);右列纯文本卡(44px 两行钳制+箭头图标,custom_css 注入)
- posts cards 基础 CSS + slim 白名单 cards_*/_skin/posts_include* 全键
- scripts/repair-content-terms.ts:从 legacy dump wp_term_relationships 提取 67709 对,
  经 legacy_id 映射补插 1162 条 content_terms(Blog 302/News 22 篇,原仅 61 篇有关联)
- 补 icon-arrow-light-orange.png;双端节内几何/样式全等(sec 521),截图目视一致
This commit is contained in:
bigcode
2026-11-02 00:27:15 +08:00
parent f614d10c94
commit 27fa9e605f
8 changed files with 297 additions and 44 deletions
+100 -38
View File
@@ -5,8 +5,8 @@
* 样式以内联 style 复现视觉效果(背景、内边距、对齐、列宽百分比),
* 未识别的 widgetType 优雅降级:渲染 heading_title / editor 文本或忽略。
*/
import { Component, Fragment, useEffect, useState, type CSSProperties, type ReactNode } from "react";
import { getLatestPosts, getLatestProducts, type LatestItem } from "~/server/data";
import { Component, createContext, useContext, Fragment, useEffect, useState, type CSSProperties, type ReactNode } from "react";
import { getLatestPosts, getLatestProducts, type LatestItem, type PostCardItem } from "~/server/data";
import TickerBar from "~/components/website/TickerBar";
import { InquiryForm } from "~/components/website/InquiryForm";
import { localSiteUrl } from "~/utils/media";
@@ -232,18 +232,23 @@ function normalizeDoc(doc: ElDocument): ElElement[] {
return [];
}
/** # 成员变量 posts widget 文章数据上下文(loader 预取,经 widget id 分发) **/
const PostsDataContext = createContext<Record<string, PostCardItem[]>>({});
/** # 组件 Elementor 渲染器入口 */
export function ElementorRenderer({ doc }: { doc: ElDocument }) {
export function ElementorRenderer({ doc, postsData }: { doc: ElDocument; postsData?: Record<string, PostCardItem[]> }) {
const elements = normalizeDoc(doc);
if (!elements.length) return null;
const css = collectCustomCss(elements) + collectResponsiveCss(elements);
return (
<div className="content-html">
{css ? <style dangerouslySetInnerHTML={{ __html: css }} /> : null}
{elements.map((el, i) => (
<ElementNode key={el.id || i} el={el} />
))}
</div>
<PostsDataContext.Provider value={postsData || {}}>
<div className="content-html">
{css ? <style dangerouslySetInnerHTML={{ __html: css }} /> : null}
{elements.map((el, i) => (
<ElementNode key={el.id || i} el={el} />
))}
</div>
</PostsDataContext.Provider>
);
}
@@ -571,7 +576,7 @@ function WidgetNodeInner({ el }: { el: ElElement }) {
case "eael-content-ticker":
return <TickerWidget settings={settings} />;
case "posts":
return <PostsWidget settings={settings} />;
return <PostsWidget el={el} settings={settings} />;
case "social-icons":
return <SocialIconsWidget settings={settings} />;
case "eael-contact-form-7":
@@ -750,42 +755,99 @@ function TickerWidget({ settings }: { settings: Record<string, unknown> }) {
}
/** # 组件 posts widget(Elementor Pro 卡片皮肤:文章/产品卡片网格) */
function PostsWidget({ settings }: { settings: Record<string, unknown> }) {
const perPage = Number(s(settings.cards_posts_per_page)) || 3;
const [posts, setPosts] = useState<Array<{ title: string; slug: string; excerpt: string | null; publishedAt: Date | string | null; image: string | null }>>([]);
useEffect(() => {
let alive = true;
getLatestPosts()
.then((rows) => { if (alive) setPosts(rows.slice(0, perPage).map((r) => ({ title: r.title, slug: r.url, excerpt: null as string | null, publishedAt: null, image: null }))); })
.catch(() => {});
return () => { alive = false; };
}, [perPage]);
/** # 组件 posts widget(Elementor skin cards 忠实 DOM,H18 实测 2026-11-01)
* .elementor-posts-container.elementor-posts.elementor-posts--skin-cards.elementor-grid
* (grid 列数 cards_columns、gap 16/10=row/column)> article.elementor-post > .elementor-post__card
* (flex 纵向、边框/底色/圆角 3 由设置)> [缩略图链接(比率 0.66 内宽、badge 绝对右上)]
* + .elementor-post__text(padding 12) > .elementor-post__title(div,fs18/400,#000)
* + .elementor-post__meta-data(日期 F j, Y,#ADADAD) + .elementor-post__excerpt(18 词,#767676,底距 44);
* 缩略图 thumbnail=none 时省略(右列卡 74 高纯文本);文章数据经 PostsDataContext 预取。
*/
function PostsWidget({ el, settings }: { el: ElElement; settings: Record<string, unknown> }) {
const allPosts = useContext(PostsDataContext);
const posts = (el.id && allPosts[el.id]) || [];
if (!posts.length) return null;
const showThumb = s(settings.cards_thumbnail) !== "none";
const cols = Number(s(settings.cards_columns)) || 1;
const rowGap = sizeCss(settings.cards_row_gap) || "16px";
const colGap = sizeCss(settings.cards_column_gap) || "10px";
const textPad = sizeCss(settings.cards_card_padding) || "12px";
const titleSpacing = sizeCss(settings.cards_title_spacing) || "16px";
const excSpacing = sizeCss(settings.cards_excerpt_spacing) || "44px";
const excerptColor = s(settings.cards_excerpt_color) || undefined;
const titleTag = (["h1", "h2", "h3", "h4", "h5", "h6", "div", "span", "p"].includes(s(settings.cards_title_tag)) ? s(settings.cards_title_tag) : "h3") as "h3";
const TitleTag = titleTag;
// 徽章/日期/摘要开关:switcher 键缺省 = 开(左卡无键但有徽章/摘要的实测语义),
// 空串 = 显式关(右卡 cards_show_badge/cards_show_excerpt 均为空串)
const showBadge = settings.cards_show_badge === undefined ? true : s(settings.cards_show_badge) !== "";
const showMeta = Array.isArray(settings.cards_meta_data) && (settings.cards_meta_data as string[]).includes("date");
const showExcerpt = settings.cards_show_excerpt === undefined ? true : s(settings.cards_show_excerpt) !== "";
// 日期格式 F j, Y(October 21, 2026)
const fmtDate = (d: Date | string | null) => {
if (!d) return null;
const dt = typeof d === "string" ? new Date(d) : d;
if (isNaN(dt.getTime())) return null;
return dt.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
};
return (
<div className="mx-auto grid max-w-[1140px] grid-cols-1 gap-[22px] px-6 py-2.5 min-[900px]:grid-cols-3">
{posts.map((p) => (
<a
className="block overflow-hidden rounded-lg border border-line bg-white text-inherit transition-shadow hover:shadow-[0_10px_26px_rgba(16,42,76,0.12)]"
key={p.slug}
href={"/blog/" + p.slug + "/"}
>
{p.image ? (
<div className="aspect-video overflow-hidden bg-[#f2f5f9]">
<img src={p.image} alt="" loading="lazy" className="h-full w-full object-cover" />
<div
className="elementor-posts-container elementor-posts elementor-posts--skin-cards elementor-grid"
style={{ display: "grid", gridTemplateColumns: `repeat(${cols}, 1fr)`, gap: `${rowGap} ${colGap}` }}
>
{posts.map((p) => {
// 链接前缀按主分类:News → /news/,其余 → /blog/(原站 News 文章走 /news/<slug>/)
const href = (p.categoryName === "News" ? "/news/" : "/blog/") + p.slug + "/";
return (
<article key={p.slug} className="elementor-post elementor-grid-item post type-post status-publish format-standard has-post-thumbnail">
<div className="elementor-post__card">
{showThumb && p.imageUrl ? (
<a className="elementor-post__thumbnail__link" href={href} tabIndex={-1}>
<div className="elementor-post__thumbnail">
<img src={localSiteUrl(p.imageUrl)} alt={p.title} loading="lazy" />
</div>
{showBadge && p.categoryName ? (
<span className="elementor-post__badge">{p.categoryName}</span>
) : null}
</a>
) : null}
<div className="elementor-post__text">
<TitleTag className="elementor-post__title" style={{ marginBottom: titleSpacing }}>
<a href={href}>{p.title}</a>
</TitleTag>
{showMeta && p.publishedAt ? (
<div className="elementor-post__meta-data">
<span className="elementor-post-date">{fmtDate(p.publishedAt)}</span>
</div>
) : null}
{showExcerpt ? (
<p
className="elementor-post__excerpt"
style={{ color: excerptColor, marginBottom: excSpacing }}
dangerouslySetInnerHTML={{ __html: postExcerpt(p) }}
/>
) : null}
</div>
</div>
) : null}
<div className="px-4 pt-3.5 pb-[18px]">
<h3 className="mt-0 mb-1.5 text-[17px] leading-[1.4]">{p.title}</h3>
{p.publishedAt ? <time className="text-[13px] text-muted-foreground">{String(p.publishedAt).slice(0, 10)}</time> : null}
{p.excerpt ? <p className="mt-2 mb-0 line-clamp-2 text-sm text-muted-foreground">{p.excerpt}</p> : null}
</div>
</a>
))}
</article>
);
})}
</div>
);
}
/** # 组件 social-icons widget(图标行) */
/** # 函数 文章卡摘要:excerpt 优先,空则取正文纯文本前 N 词(WP wp_trim_words 语义) */
function postExcerpt(p: PostCardItem): string {
return p.excerpt && p.excerpt.trim() ? p.excerpt : postWords(p.content || "", 18);
}
/** # 函数 纯文本取前 N 词(去 HTML 标签后按空白分词,"…" 结尾) */
function postWords(html: string, n: number): string {
const text = html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
const words = text.split(" ");
return words.length <= n ? text : words.slice(0, n).join(" ") + " …";
}
/** # 组件 social-icons widget:忠实 Elementor DOM(widget-social-icons 语义,H16 实测)
* .elementor-social-icons-wrapper.elementor-grid(inline-grid + column-gap=icon_spacing,
* 由父级 text-align 居中)> a.elementor-icon.elementor-social-icon.elementor-social-icon-<name>