在第一篇里,菜单的每一行都是手写的。本文把它改成自动生成:用 WordPress 自带的 REST API,通过 JavaScript 拉取最新的几篇文章,自动填入标题、摘要、特色图和链接。以后发布新文章,菜单会自动更新,不需要再手动修改。
前置条件:需要先按第一篇放好 CSS(包括 SVG 箭头的样式)。本文只替换 HTML 和 JavaScript,CSS 不变。
实现思路
- 页面里只放一个空的容器
#hm-list。 - JavaScript 请求
/wp-json/wp/v2/posts,拿到最新的 5 篇文章(加上_embed可以同时拿到特色图和分类名)。 - 把每篇文章拼成一个
.hm-item,插入容器。 - 再给新生成的菜单项绑定第一篇里的鼠标跟随预览效果。
第一步:HTML
不再手写标题,只保留一个容器:
<section class="hover-menu" aria-label="Latest posts hover menu">
<div id="hm-list" class="hover-menu__wrap"></div>
<div id="hm-cursor" class="hm-cursor" aria-hidden="true"></div>
</section>第二步:JavaScript
抓取最新 5 篇文章,自动填入标题、说明、图片和链接:
<script>
(async () => {
const listEl = document.getElementById("hm-list");
const cursor = document.getElementById("hm-cursor");
if (!listEl || !cursor) return;
// 显示几篇(目前为 5 篇)
const PER_PAGE = 5;
// 抓取最新文章(_embed 用来拿特色图和分类名)
const api = `/wp-json/wp/v2/posts?per_page=${PER_PAGE}&orderby=date&order=desc&_embed=1`;
let posts = [];
try {
const res = await fetch(api, { credentials: "same-origin" });
if (!res.ok) throw new Error("REST API request failed");
posts = await res.json();
} catch (e) {
console.warn(e);
listEl.innerHTML = `<div style="padding:16px 6px;">无法加载最新文章(请确认未禁用 REST API)。</div>`;
return;
}
// 生成菜单 HTML(标题 + 说明 + SVG 箭头 + data-image)
listEl.innerHTML = posts.map(p => {
const title = stripHTML(p.title?.rendered || "");
const link = p.link || "#";
// 说明文字:优先用摘要(excerpt),没有就用日期
const excerpt = stripHTML(p.excerpt?.rendered || "");
const desc = excerpt ? clampText(excerpt, 70) : formatDate(p.date);
// 分类名:取第一个分类(需要 _embed)
const catName = getFirstCategoryName(p);
// 特色图:需要 _embed,拿 source_url
const imgUrl = getFeaturedImageUrl(p) || "";
// 说明行:分类 · 摘要(或日期)
const descLine = catName ? `${catName} · ${desc}` : desc;
return `
<a class="hm-item" href="${escapeAttr(link)}" data-image="${escapeAttr(imgUrl)}">
<div class="hm-curtain" aria-hidden="true"></div>
<div class="hm-content">
<div class="hm-title">${escapeHTML(title)}</div>
<div class="hm-desc">${escapeHTML(descLine)}</div>
</div>
<div class="hm-arrow" aria-hidden="true">
<svg class="hm-arrow__svg" viewBox="0 0 24 24" fill="none">
<path d="M7 17L17 7" />
<path d="M9 7h8v8" />
</svg>
</div>
</a>
`;
}).join("");
// ========== 鼠标跟随预览图(自动绑定新生成的 .hm-item) ==========
const items = listEl.querySelectorAll(".hm-item");
// 预加载图片
items.forEach(a => {
const url = a.getAttribute("data-image");
if (url) { const img = new Image(); img.src = url; }
});
const CURSOR_W = 320, CURSOR_H = 220, PAD = 24, SHIFT_X = 360, LERP = 0.12;
let mouseX = 0, mouseY = 0, x = 0, y = 0, hovering = false, activeSrc = null, rafId = null;
document.addEventListener("mousemove", (e) => { mouseX = e.clientX; mouseY = e.clientY; }, { passive: true });
items.forEach(item => {
item.addEventListener("mouseenter", () => {
hovering = true;
cursor.style.opacity = "1";
const url = item.getAttribute("data-image");
if (url) swapImage(url);
start();
});
item.addEventListener("mouseleave", () => {
hovering = false;
cursor.style.opacity = "0";
// 鼠标移开后停止动画循环,避免持续占用 CPU
setTimeout(() => {
if (!hovering && rafId) { cancelAnimationFrame(rafId); rafId = null; }
}, 400);
});
});
function start(){ if (!rafId) rafId = requestAnimationFrame(tick); }
function tick(){
let tx = mouseX + SHIFT_X;
let ty = mouseY - CURSOR_H / 2;
const maxLeft = window.innerWidth - CURSOR_W - PAD;
const maxTop = window.innerHeight - CURSOR_H - PAD;
tx = Math.max(PAD, Math.min(tx, maxLeft));
ty = Math.max(PAD, Math.min(ty, maxTop));
x += (tx - x) * LERP;
y += (ty - y) * LERP;
const scale = hovering ? 1 : 0.92;
cursor.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
rafId = requestAnimationFrame(tick);
}
function swapImage(url){
if (activeSrc === url) return;
activeSrc = url;
const img = document.createElement("img");
img.src = url;
img.alt = "";
img.className = "hm-img-enter";
[...cursor.children].forEach(old => {
old.classList.remove("hm-img-enter");
old.classList.add("hm-img-exit");
setTimeout(() => old.remove(), 700);
});
cursor.appendChild(img);
}
// ======= helpers =======
function getFeaturedImageUrl(post){
const fm = post?._embedded?.["wp:featuredmedia"]?.[0];
return fm?.source_url || fm?.media_details?.sizes?.medium?.source_url || "";
}
function getFirstCategoryName(post){
// WP 会在 _embedded 里给 term;分类通常在 wp:term 的某个数组里
const termGroups = post?._embedded?.["wp:term"];
if (!Array.isArray(termGroups)) return "";
for (const group of termGroups) {
if (!Array.isArray(group)) continue;
const cat = group.find(t => t?.taxonomy === "category");
if (cat?.name) return cat.name;
}
return "";
}
// 去掉 HTML 标签,并把 & – 等实体还原成普通文字
function stripHTML(html){
const doc = new DOMParser().parseFromString(String(html), "text/html");
return (doc.body.textContent || "").trim();
}
function clampText(s, n){ return s.length > n ? s.slice(0, n - 1) + "…" : s; }
function formatDate(iso){
try{
const d = new Date(iso);
return d.toLocaleDateString("ja-JP", { year:"numeric", month:"2-digit", day:"2-digit" });
}catch{ return ""; }
}
// 输出到 innerHTML 前转义,防止标题里的特殊字符破坏页面
function escapeHTML(s){
return String(s)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function escapeAttr(s){ return escapeHTML(s); }
})();
</script>把 HTML 和 JavaScript 放进「自定义 HTML」块的方法,与第一篇相同。
只显示某个分类
默认抓取全站最新文章。如果只想要某个分类,把脚本开头的 api 那一行改成下面两行:
const CATEGORY_ID = 123; // ← 换成你的分类 ID
const api = `/wp-json/wp/v2/posts?per_page=${PER_PAGE}&orderby=date&order=desc&categories=${CATEGORY_ID}&_embed=1`;categories= 是 WordPress REST API 的标准过滤参数。
怎么查分类 ID:在后台打开该分类的编辑页面,浏览器地址栏里通常有 tag_ID=123 这样的参数,这个数字就是分类 ID。
自定义
- 显示几篇:改
PER_PAGE。 - 说明文字长度:改
clampText(excerpt, 70)里的 70。 - 日期格式:
formatDate里用的是日文格式(如2026/01/17),想要中文格式把"ja-JP"改成"zh-CN"。 - 没有摘要时显示什么:目前是显示日期,可以在
desc那一行改成别的。 - 自定义文章类型:如果你的内容不是普通文章,而是自定义文章类型,把接口里的
posts换成该类型的 REST 路径(该类型需要开启show_in_rest)。
常见问题
菜单显示”无法加载最新文章”?
先在浏览器里直接打开 https://你的域名/wp-json/wp/v2/posts。打不开的话,通常是安全插件或主机禁用了 REST API,或者固定链接设置有问题。如果你的站点使用”朴素”固定链接,接口地址可以改成 /?rest_route=/wp/v2/posts,后面的参数用 & 连接。WordPress 装在子目录时,路径前面要加上子目录名。
有的文章 hover 时预览图不对?
没有设置特色图的文章,data-image 是空字符串,脚本不会切换图片,预览框会继续显示上一篇的图。建议给没有特色图的文章设置一张默认图。
预览图完全不显示?
见 hover 菜单预览图不显示的排查。
键盘能操作吗?
菜单本身是普通链接,可以用 Tab 切换。但本篇的脚本没有包含第一篇里”键盘聚焦时显示预览图”的部分,需要的话可以参照第一篇补上。