WordPress|hover 菜单自动显示最新文章(REST API)

在第一篇里,菜单的每一行都是手写的。本文把它改成自动生成:用 WordPress 自带的 REST API,通过 JavaScript 拉取最新的几篇文章,自动填入标题、摘要、特色图和链接。以后发布新文章,菜单会自动更新,不需要再手动修改。

前置条件:需要先按第一篇放好 CSS(包括 SVG 箭头的样式)。本文只替换 HTML 和 JavaScript,CSS 不变。

实现思路

  1. 页面里只放一个空的容器 #hm-list。
  2. JavaScript 请求 /wp-json/wp/v2/posts,拿到最新的 5 篇文章(加上 _embed 可以同时拿到特色图和分类名)。
  3. 把每篇文章拼成一个 .hm-item,插入容器。
  4. 再给新生成的菜单项绑定第一篇里的鼠标跟随预览效果。

第一步: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 切换。但本篇的脚本没有包含第一篇里”键盘聚焦时显示预览图”的部分,需要的话可以参照第一篇补上。