为博客构建“轻量级知识索引”(八):基于系列数据的全站内容导航
文章摘要
针对博客内容中系列文章导航在全局认知层缺失的问题,本文设计了首页右侧系列菜单作为站点入口层补充,通过双JSON(series-meta.json与series-map.json)实现系列配置与内容结构的解耦,构建统一数据源。系统采用离线生成系列结构、PHP动态加载合并数据、前端轻量交互的三层架构,在WordPress PJAX环境下实现首页系列入口的稳定展示,使用户无需进入具体文章即可感知博客知识体系,提升系列内容的发现效率与阅读路径完整性。
Qwen3-14B · 2026-07-31

1 从文章内关联到站点入口:系列阅读结构的第一次升级

在之前的文章中(参见:为博客构建“轻量级知识索引”(四):系列文章导航与阅读路径设计),为了解决博客内容中同一系列文章之间横向阅读路径不连续的问题,我在文章正文底部引入了“系列文章卡片”功能。

这个组件的主要作用,是在单篇文章的上下文中,显式呈现该文章在所属系列中的结构位置,包括当前序号、系列总数以及前后文章的跳转关系,从而帮助读者在阅读过程中建立“局部连续性”。实际效果如下:

image.png

从使用体验来看,这一方案在“单篇阅读场景”下是有效的,它解决的是一个非常明确的问题:如何让读者在阅读某一篇文章时,不丢失系列上下文结构

但随着内容规模的增长,我逐渐发现一个更本质的问题:对于一部分访客而言,他们的访问路径并不是“文章 → 系列”,而是:

  • 从搜索引擎进入单篇文章

  • 从外部链接进入某一篇具体内容

  • 或者只是偶然浏览首页

在这些场景下,系列卡片实际上是“后置能力”——它依赖用户已经进入某一篇文章之后才能被感知。这就导致一个结构性缺口:系列文章系统在“局部阅读层”是可见的,但在“全局认知层”是缺失的。

换句话说,对于尚未形成系列认知的用户而言,他们并不知道:博客存在“系列”这一组织方式,也无法主动选择进入某个知识路径。因此,仅有“文章内系列卡片”是不够的。

基于这个问题,我认为系统还缺少一个关键入口:一个面向“站点入口层”的系列导航层,用于在用户进入网站初始页面(主要是首页)时,提供系列结构的整体可见性与快速可达能力。

在具体实现上,这一入口更适合放置于首页右侧中部的空白区域。该区域在当前布局中属于低信息密度区,具备良好的“承载辅助导航组件”的空间条件,同时不会干扰主内容流的阅读路径:

image.png

这个入口的目标并不是替代文章内的系列卡片,而是作为一个更高层级的结构补充:

  • 在首页提供所有系列的聚合入口
  • 让用户在未进入具体文章之前,就能够感知整个知识结构
  • 并支持快速进入任意系列的阅读路径

从系统设计角度来看,这实际上是在补齐一个“知识结构的上层索引层”。

2 设计

2.1 首页系列文章右侧菜单的设计思路

在确定需要在首页增加一个首页系列入口之后,真正需要思考的是:应该以什么样的形式呈现这个入口。

一种最简单的方式,是直接在右侧放置一个”系列文章”按钮,点击之后跳转到专门的系列页面。这种方案实现成本最低,也能够满足功能需求。但它更像是一个普通导航链接,除了告诉用户”这里有一个系列页面”之外,并不能让用户在首页直接了解到博客目前有哪些系列,也无法帮助用户快速判断哪些内容值得进一步阅读。

另一种方案,则是在首页直接展示所有系列的完整文章列表。这种方式的信息最完整,但随着系列数量不断增加,每个系列内部又包含多篇文章,整个组件很容易变得过于庞大,占据大量首页空间,也会打乱原本以最新文章为主的浏览节奏。

因此,我最终选择了一种介于两者之间的折中方案:首页只展示系列本身,而不直接展开系列内容。 对于每一个系列,仅保留几个最核心的信息:系列名称;系列包含的文章数量;进入该系列的入口。

这样做有几个好处。首先,它能够让访客在几秒钟内快速浏览整个博客目前已经建立的知识体系,而不需要阅读大量文章标题。其次,由于首页承担的是”发现内容”而不是”阅读内容”的职责,因此首页菜单更适合作为一个轻量级索引,而真正的文章组织与阅读路径,则交由后续的系列页面负责。

换句话说,首页菜单解决的是”发现什么”,而系列页面解决的是”如何阅读”。从系统设计角度来看,两者分别对应着不同的职责:前者属于知识结构的入口层,后者属于知识结构的展示层。

因此,首页系列菜单最终采用了一种”概览优先、按需深入”的设计思路——先帮助用户发现感兴趣的系列,再进入对应页面完成后续阅读,而不是试图在首页承载整个系列内容。

2.2 数据结构与系统输入:双 JSON 构成的系列组织基础

确定首页系列菜单的整体设计之后,接下来需要解决的问题就是:如何组织这些系列数据。

一种比较直接的实现方式,是让 WordPress 在页面加载时动态查询所有系列及其包含的文章,再由 PHP 根据查询结果生成菜单内容。不过,这种方案意味着每次访问首页都需要重新组织系列结构,并且随着系列数量不断增加,运行时计算也会越来越复杂。

由于整个博客”轻量级知识索引”系列一直遵循”构建阶段完成计算,运行阶段只负责展示”这一设计思想,因此这里依然采用了相同的策略:将系列结构提前构建为静态 JSON,由前端与 PHP 在运行时直接消费。

从整体来看,这套系统主要依赖两份核心 JSON 文件,它们分别承担不同职责,并共同组成整个系列组织体系。

1、series-meta.json

首先是系列元数据层,对应 series-meta.json。这一层负责描述”系列本身是什么”,例如:

{
  "cloudflare": {
    "title": "Cloudflare 教程",
    "order": 1,
    "description": "Cloudflare 系列文章",
    "hide": false
  }
}

可以看到,这一层并不关心系列中有哪些文章,而只描述系列自身的属性,例如:系列名称;展示顺序;系列简介;是否在前端显示。

因此,从系统角度来看,series-meta.json 更像是整个系列系统的”配置层”。它定义的是:一个系列应该如何呈现。而不是:一个系列包含哪些内容。

这样做的好处在于,系列的展示配置与文章组织完全解耦,即使以后调整排序、修改标题或临时隐藏某个系列,也不需要重新组织文章结构。

2、series-map.json

第二层是系列内容映射层,对应 series-map.json。这一层负责描述:每个系列包含哪些文章,典型结构如下:

{
  "cloudflare": {
    "count": 10,
    "posts": [
        {
            "index": 1,
            "title": "Cloudflare Tunnel 基础介绍",
            "url": "/technology/xxxx/"
        }
    ]
}

比于 series-meta.json,这一层开始真正进入内容组织。每一个系列都会保存:系列文章总数;
每篇文章在系列中的顺序;标题;URL。

可以看到,这里保存的全部都是已经整理好的静态结构,而不是运行时再去查询 WordPress 数据库。

因此,无论是首页系列菜单、系列页面(甚至之前的”系列文章卡片”),都可以直接消费这一份结构数据,而不需要各自维护一套独立逻辑。

换句话说,series-map.json 实际上成为了整个系列系统唯一的内容来源(Single Source of Truth)。


注:为什么拆成两个 JSON,而不是一个?

从数据规模来看,把所有信息放进一个 JSON 当然也是可以实现的。例如,每个系列下面同时保存 title、description、posts 等全部字段。但从工程设计角度来看,这种方式会让”配置”与”内容”耦合在一起。

当前采用双 JSON 的原因,本质上是职责分离。其中:series-meta.json 负责描述系列,series-map.json 负责描述文章。

两者虽然最终都会被 PHP 合并使用,但在生成阶段却可以分别维护。例如:修改系列标题,调整排序,临时隐藏系列,这些操作都只会影响 series-meta.json。而新增文章、调整文章顺序,则只会影响 series-map.json

这种拆分不仅降低了数据维护成本,也让整个系列系统更容易扩展。


2.3 系统整体架构与工程价值

从整体结构来看,这两份 JSON 并不是彼此独立存在,而是建立在整个”轻量级知识索引”体系已有的数据基础之上,共同组成系列文章系统的数据组织结构:

WordPress 文章
        │
        ▼
article-index.json
        │
        ├───────────────┐
        ▼               ▼
series-meta.json   series-map.json
        │               │
        └──────┬────────┘
               ▼
        系列文章系统
               │
      ┌────────┴────────┐
      ▼                 ▼
  首页系列菜单       /series 页面
                           │
                           ▼
                     系列文章卡片

从数据流来看,整个系列文章系统并不是直接建立在 WordPress 原始内容之上,而是延续了整个”轻量级知识索引”系列一贯采用的分层组织方式。

首先,由 article-index.json 提供整个博客统一的文章基础数据。在此基础上,再进一步整理出系列相关的数据结构:其中,series-meta.json 负责描述系列本身的元信息,例如系列名称、展示顺序、简介以及是否显示等配置;而 series-map.json 则负责描述每个系列包含哪些文章,以及这些文章在系列中的排列关系。

这样一来,系列本身的配置与系列内容之间便形成了明确的职责分离:前者定义”系列是什么”,后者定义”系列包含什么”。虽然最终都会共同服务于系列文章系统,但两者各自承担的职责完全不同,也能够分别维护和扩展。

进一步来看,这两份 JSON 并不仅仅服务于某一个具体功能,而是共同构成了整个系列文章系统的统一数据来源。无论是首页系列菜单、系列页面,还是上一篇实现的系列文章卡片,都建立在这套统一的数据结构之上。因此,当系列结构发生变化时,只需要更新这两份 JSON,所有相关功能都能够自动获得一致的数据,而无需分别维护各自的数据逻辑。

从工程角度来看,这种设计延续了整个”轻量级知识索引”系列始终坚持的一个核心思想:将内容组织与页面展示彻底分离。

在这套架构中,每一层数据结构都只承担单一职责:

  • article-index.json:提供统一的文章基础数据;
  • series-meta.json:描述系列的元信息;
  • series-map.json:组织系列的内容结构。

最终,不同的页面只需要根据自身需求消费这些已经组织好的数据,而无需重新计算系列关系或重新组织文章结构。

因此,这套架构的价值并不仅仅在于新增了一个首页系列菜单,而是在整个博客中建立起了一套可复用的系列组织基础。随着系列相关功能不断增加,这套数据结构仍然可以继续作为统一的数据来源,为不同页面提供一致且稳定的系列信息。

3 实现

3.1 工程实现的整体流程

根据第2章的设计思路,对应到实际工程实现上,这一套首页系列文章系统同样可以拆分为三个明确的步骤,每一步都有对应的产物和执行位置。

第一步,是生成系列组织数据文件 series-meta.jsonseries-map.json。这一部分由脚本 build_series_map.py 负责完成,核心作用是在已有 article-index.json 的基础上,整理博客中所有系列的组织关系,并分别生成系列元数据与系列内容映射。前者负责描述系列本身的属性,后者负责描述系列包含的文章及其顺序,这两份 JSON 共同构成整个系列系统的数据基础。

第二步,是在 WordPress 后端建立统一的数据读取入口。系统运行时首先读取 series-meta.jsonseries-map.json,并完成数据合并、排序以及过滤等基础处理,最终生成一份统一的系列数据结构。后续无论是首页系列菜单、系列页面,都直接使用这一统一的数据来源,而不再分别维护各自的数据逻辑。

第三步,则是在不同页面完成对应的展示逻辑。系统运行时,首先通过统一的数据读取入口 series_load_data() 加载 series-meta.jsonseries-map.json,完成数据合并、排序以及过滤等基础处理,并生成统一的系列数据结构。随后,不同页面再根据各自的需求消费这些数据:首页负责展示所有系列的聚合入口,引导访客发现整个博客的系列知识结构;系列页面负责展示某一个系列下完整的文章列表;上一篇实现的系列文章卡片,则负责在文章阅读过程中提供系列内部的阅读导航。虽然这三个功能的展示形式不同,但都建立在同一套系列数据结构之上,因此始终能够保持一致的组织关系。

series-meta.json      series-map.json
        │                   │
        └────────┬──────────┘
                 ▼
         series_load_data()
                 │
     ┌───────────┼────────────┐
     ▼           ▼            ▼
  首页系列菜单   /series 页面   系列文章卡片

这种拆分方式最大的好处在于,将数据组织、数据消费以及页面展示三个阶段彻底解耦。系列关系只需要在构建阶段整理一次,运行阶段则始终围绕统一的数据结构展开,不同页面只负责以各自的方式展示数据,而无需重复组织系列内容,也无需重新计算文章之间的关系。

3.2 系列结构生成:build_series_map.py

build_series_map.py脚本的代码如下:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import json
import os
import re
import sys

# ======================================================
# Config
# ======================================================

CACHE_DIR = "/docker/wordpress/html/wp-content/themes/argon-theme-master/cache"
ARTICLE_INDEX = os.path.join(CACHE_DIR, "article-index.json")
SERIES_MAP = os.path.join(CACHE_DIR, "series-map.json")


# ======================================================
# Load article index
# ======================================================

def load_article_index():
    if not os.path.exists(ARTICLE_INDEX):
        raise FileNotFoundError(f"Input file not found:\n{ARTICLE_INDEX}")
    with open(ARTICLE_INDEX, "r", encoding="utf-8") as f:
        return json.load(f)


# ======================================================
# Extract series info(强约束规则)
# ======================================================

def extract_series_info(title):
    title = title.replace(" ", " ").strip()

    m = re.search(r"[((]([一二三四五六七八九十])[))]", title)
    if not m:
        return None

    cn_map = {
        "一": 1, "二": 2, "三": 3, "四": 4, "五": 5,
        "六": 6, "七": 7, "八": 8, "九": 9, "十": 10
    }

    index = cn_map.get(m.group(1), 0)
    if index <= 0:
        return None

    series_key = re.split(r"[((]", title)[0].strip()

    return {
        "series_key": series_key,
        "series_index": index
    }


# ======================================================
# Build series map
# ======================================================

def build_series_map(article_index):
    series = {}

    for post_id, article in article_index.items():
        title = article.get("title")
        url = article.get("url")
        if not title or not url:
            continue

        info = extract_series_info(title)
        if not info:
            continue

        key = info["series_key"]

        if key not in series:
            series[key] = {
                "series_key": key,
                "count": 0,
                "posts": []
            }

        series[key]["posts"].append({
            "id": int(post_id),
            "index": info["series_index"],
            "title": title,
            "url": url
        })

    for item in series.values():
        item["posts"].sort(key=lambda x: x["index"])
        item["count"] = len(item["posts"])

    return dict(sorted(series.items(), key=lambda x: x[0]))


# ======================================================
# Save
# ======================================================

def save_series_map(series_map):
    with open(SERIES_MAP, "w", encoding="utf-8") as f:
        json.dump(series_map, f, ensure_ascii=False, indent=2)


# ======================================================
# Main
# ======================================================

def main():
    print("=" * 50)
    print("Build Series Map")
    print("=" * 50)

    try:
        articles = load_article_index()
        series_map = build_series_map(articles)
        save_series_map(series_map)

        total_series = len(series_map)
        total_articles = sum(item["count"] for item in series_map.values())

        print()
        print(f"Input  : {ARTICLE_INDEX}")
        print(f"Output : {SERIES_MAP}")
        print()
        print(f"Series count    : {total_series}")
        print(f"Series articles : {total_articles}")
        print()
        print("Status : Success")

    except Exception as e:
        print()
        print("Status : Failed")
        print(e)
        sys.exit(1)


if __name__ == "__main__":
    main()

从功能上来说,这个脚本主要完成了三件事:读取文章索引数据、识别文章所属系列、生成系列文章结构并输出 JSON 文件

其中最关键的部分并不是解析逻辑本身,而是整个“系列结构的抽取过程”。在这个阶段,系统基于前一阶段生成的 article-index.json,通过解析文章标题中的序号信息,将原本分散的文章组织成结构化的系列数据。

最终输出的 series-map.json,每个系列都会包含以下几个核心字段:

  • series_key:系列唯一标识
  • count:系列文章数量
  • posts:系列文章列表(包含 index / title / url)

从系统角度来看,这一步的意义在于:把原本分散在文章层的隐式“系列关系”,转换成显式的结构化数据模型。后续首页系列菜单、系列页面以及系列文章卡片,都是直接基于这个结构进行展示的。

3.3 WordPress 首页系列菜单实现:前端渲染与结构消费层

3.3.1 前端架构拆分:PHP / JS / CSS 的职责划分

这一部分是“系列文章系统”在 WordPress 中的最终呈现层,也就是首页右侧中部的系列入口菜单。

与前面章节的“数据生成层(build 阶段)”不同,这一部分的重点已经从“如何构建系列结构”,转向“如何在 WordPress 中消费 series 数据并完成展示”。

从实现结构上看,这一模块同样被拆分为三层:PHP(数据加载与结构组织层)、JavaScript(交互控制层)、CSS(视觉呈现层)。

这种拆分的本质并不是前端架构设计,而是 WordPress 在现有渲染模型下的自然约束结果: PHP 负责确定性数据输出,JS 负责轻量交互状态,CSS 负责视觉表达。


1、PHP部分

在这一结构中,PHP 是整个系统的核心:PHP 负责调用 series_load_data() 方法,从缓存中读取预先生成的series-meta.jsonseries-map.json,并将两者合并为统一的数据结构。在此基础上,PHP 会完成三件关键工作:过滤不可见系列(hide 字段);按 order 字段排序;生成首页菜单所需的轻量结构数据。

最终输出到前端的,是一个结构化数组,而不是查询结果或动态计算逻辑。可以理解为:
PHP 在这里承担的是“系列结构的消费与整理层”,而不是计算层。

2、JavaScript部分

本文中的JavaScript只负责最轻量的交互控制,其核心职责是:hover / click 展开菜单;控制子菜单显示状态;处理简单的 DOM 切换。

所有系列数据已经由 PHP 注入到页面中,因此 JS 不再参与任何数据构建或排序逻辑,仅负责 UI 状态变化。

3、CSS部分

CSS 部分则完全负责视觉表达,包括:首页右侧中部浮动区域布局;hover 展开动画;分类层级缩进;字号与间距控制。

这一层的设计目标是保持“低存在感”,即在不干扰首页主体内容的前提下提供快速入口。


通过这样的三层拆分,整个首页系列菜单系统形成了一个非常清晰的结构:

  • PHP:结构数据输出(series_load_data)

  • JS:交互状态控制

  • CSS:视觉表达

从系统角度来看,这一模块的特点是: 完全依赖离线构建数据,不引入任何运行时复杂计算。

3.3.2 WordPress 侧数据加载与菜单生成(PHP)

这一部分是系列文章系统在 WordPress 中的展示实现层。与传统 WordPress 页面直接保存内容不同,该功能采用“空白页面作为入口,PHP 动态生成内容”的方式实现。

首先需要在 WordPress 中创建一个空白页面,并设置固定 URL(/series/)作为系列文章展示入口。该页面本身不包含实际内容,PHP 会通过 WordPress 提供的页面生命周期,在请求匹配到该 URL 时接管内容生成逻辑。

具体来说,系统通过 the_content filter 判断当前页面是否为 /series/,如果匹配,则调用系列数据加载函数 series_load_data(),从缓存目录读取前面 build 阶段生成的 series-map.jsonseries-meta.json

这一层的职责并不是生成系列关系,而是对已有的系列数据进行组织与转换:系统会合并两个 JSON 文件中的信息,过滤隐藏系列,并按照配置中的排序规则生成统一的系列列表结构,随后根据当前 URL 参数决定展示全部系列入口,还是某个具体系列下的文章列表。

换句话说,这一层承担的是“结构化数据到 WordPress 页面展示”的转换过程。series-map.json 提供文章与系列之间的实际关联关系,series-meta.json 则提供系列标题、描述、排序等展示信息,两者结合后形成 /series/ 页面以及首页系列菜单所需要的数据基础。

从整体来看,这段 PHP 代码主要完成三件事情:数据加载与合并、series 结构规范化、以及首页与系列页面的输出适配。

在数据准备阶段,系统从缓存目录中读取 series-map.jsonseries-meta.json。前者提供系列文章列表与数量信息,后者提供系列的标题、排序权重以及隐藏配置。随后以 series_key 为索引进行合并,并过滤掉 hide 标记的系列。

在此基础上,每个 series 会被统一整理为标准结构,包括标题、描述、排序权重以及文章列表等字段,并通过 order 字段进行整体排序,从而保证前端展示顺序与配置一致。

/series 页面中,系统基于 series_load_data() 输出的结构进行渲染,并根据 URL 参数决定当前是“全系列模式”还是“单系列模式”。全系列模式下展示所有 series 列表及其文章数量与描述信息;单系列模式下仅展示当前 series 的文章列表,并保持严格的顺序结构。

在首页部分,系统通过 render_series_menu() 生成轻量级系列入口菜单,仅展示 series 标题与文章数量,并提供跳转到 /series/?series=xxx 的入口链接,用于快速进入对应系列内容。

在 WordPress 集成层面,系统通过 the_content filter 注入 /series 页面内容,并通过 wp_footer 在首页底部挂载系列菜单,从而实现页面级与入口级的双重展示结构。

从实现定位来看,这一层 PHP 并不参与任何 series 的生成逻辑,而只是一个标准的结构消费层:负责将 build 阶段生成的 series 数据,转换为 WordPress 页面与首页入口可直接使用的展示结构。

完整PHP代码如下:

// ======================================================
// ① Load & merge data
// ======================================================
function series_load_data(){
    cache_dir=get_template_directory().'/cache';series_map_file=cache_dir.'/series-map.json';series_meta_file=cache_dir.'/series-meta.json';
    if(!file_exists(series_map_file)||!file_exists(series_meta_file)){return[];}series_map=json_decode(file_get_contents(series_map_file),true);series_meta=json_decode(file_get_contents(series_meta_file),true);
    if(!series_map||!series_meta){return[];}series_list=[];
    foreach(series_map askey=>data){
        if(!isset(series_meta[key]))continue;meta=series_meta[key];
        if(!empty(meta['hide']))continue;series_list[]=[
            'key'=>key,
            'title'=>meta['title']??key,
            'order'=>meta['order']??999,
            'description'=>meta['description']??'',
            'posts'=>data['posts']??[],
            'count'=>data['count']??0
        ];
    }
    usort(series_list,function(a,b){return a['order']<=>b['order'];});
    return series_list;
}

// ======================================================
// ② Render page (/series)
// ======================================================
function render_series_page(){series_list=series_load_data();
    if(empty(series_list)){echo'<p>Series data not found.</p>';return;}active_series=isset(_GET['series'])?sanitize_text_field(_GET['series']):null;
    ob_start();
    echo'<div class="series-page">';

    // =========================
    // HEADER
    // =========================
    if(active_series){current=null;
        foreach(series_list asseries){
            if(series['key']===active_series){
                current=series;
                break;
            }
        }

        if(current){
            echo'<div class="series-header">';
            echo'<h1>📚 '.esc_html(current['title']).'</h1>';

            if(!empty(current['description'])){
                echo'<div class="series-desc">';
                echo esc_html(current['description']);
                echo'</div>';
            }

            echo'<p>共 '.intval(current['count']).' 篇文章</p>';
            echo'<p><a href="/series/">← 返回所有系列</a></p>';
            echo'</div>';
        }

    }else{
        echo'<div class="series-header">';
        echo'<h1>📚 系列文章</h1>';
        echo'<p>共 '.count(series_list).' 个系列</p>';
        echo'</div>';
    }

    // =========================
    // LIST
    // =========================
    echo'<div class="series-list">';

    foreach(series_list asseries){

        if(active_series &&series['key']!==active_series){
            continue;
        }

        // ❗关键修改:单系列模式不再重复展示标题/描述
        if(!active_series){
            echo'<div class="series-item">';

            echo'<div class="series-title">';
            echo'<strong>'.esc_html(series['title']).'</strong>';
            echo'<span class="series-count">('.intval(series['count']).')</span>';
            echo'</div>';

            if(!empty(series['description'])){
                echo'<div class="series-desc">'.esc_html(series['description']).'</div>';
            }

        }else{
            // 单系列模式只保留容器,不重复标题信息
            echo'<div class="series-item single-series">';
        }

        if(!empty(series['posts'])){

            echo'<div class="series-posts">';

            foreach(series['posts'] as post){
                echo'<div class="series-post-item">';
                echo'<a href="'.esc_url(post['url']).'">';
                echo'第 '.intval(post['index']).' 篇:'.esc_html(post['title']);
                echo'</a>';
                echo'</div>';
            }

            echo'</div>';
        }

        echo'</div>';
    }

    echo'</div>';
    echo'</div>';

    echo ob_get_clean();
}

// ======================================================
// ③ Render menu
// ======================================================
function render_series_menu(){
    series_list=series_load_data();
    if(empty(series_list))return;
    echo'<div id="series-menu"><div class="series-menu-title">📚 浏览文章系列</div><div class="series-menu-list">';
    foreach(series_list asseries){
        echo'<div class="series-menu-item"><div class="series-menu-header"><a class="series-menu-link" href="/series/?series='.esc_attr(series['key']).'"><span class="series-name">'.esc_html(series['title']).'</span></a><span class="series-count">('.intval(series['count']).')</span></div></div>';
    }
    echo'</div><div class="series-menu-footer"><a href="/series/">浏览所有系列 →</a></div></div>';
}

// ======================================================
// ④ /series page hook
// ======================================================
add_filter('the_content',function(content){
    if(!is_page('series'))return content;
    ob_start();
    render_series_page();
    returncontent.ob_get_clean();
},20);

// ======================================================
// ⑤ HOME ONLY menu + PJAX safe guard
// ======================================================
add_action('wp_footer', function () {
    if (!is_front_page()) return;
    echo '<div class="series-menu-wrapper">';
    render_series_menu();
    echo '</div>';
});

3.3.3 首页系列菜单交互逻辑实现(sidebar.js 扩展逻辑)

这一部分是首页“系列文章菜单”在前端运行时的控制逻辑,它并不是一个独立模块,而是集成在 sidebar.js 中的扩展能力,用于在统一的前端初始化体系下,对首页入口组件进行状态控制。

从整体实现来看,这部分逻辑的职责非常单一:根据当前页面路由状态,控制系列菜单在首页的显示与隐藏,并确保在 PJAX 与浏览器历史切换过程中状态一致。

与右侧语义菜单不同,这一部分不涉及任何数据渲染或结构生成,它本质上是一个“页面级可见性控制器”。

1)首页可见性控制逻辑

系统通过 syncSeriesMenu() 函数实现首页判断,其核心依据是当前 URL path:

  • 当路径为站点根路径(首页)时,显示 .series-menu-wrapper
  • 当路径为其他页面时,隐藏该组件

这一逻辑保证了系列菜单只作为“首页入口层”存在,而不会进入文章阅读场景,从而避免信息层级污染。

2)与 sidebar.js 生命周期的统一

由于该逻辑被集成在 sidebar.js 中,因此它必须与现有初始化体系保持一致,而不是独立运行。

系统将 syncSeriesMenu() 挂载到与右侧菜单相同的生命周期链路中:

  • 首次加载:DOM ready 后初始化执行
  • PJAX 切换:通过 window.pjaxLoaded 重新同步状态
  • 浏览器前进/后退:通过 pageshowpopstate 修正状态

这种设计的本质是将“页面级 UI 状态”纳入统一生命周期管理,避免 PJAX 局部替换导致的 DOM 状态不一致问题。

3)与右侧菜单体系的关系

虽然这一部分与右侧语义菜单共享同一个 JS 文件,但两者在职责上是完全隔离的:

  • 右侧菜单:基于语义数据的内容推荐系统
  • 首页系列菜单:基于路由状态的入口层控制系统

它们唯一的交集在于: 都依赖 sidebar.js 的统一初始化生命周期(pjaxLoaded + init

因此可以理解为:sidebar.js 同时承担了“语义推荐 UI”和“站点结构入口 UI”两套不同层级的控制逻辑,但二者在数据与职责上完全解耦。

series-menu 控制代码(节选)

function syncSeriesMenu() {
    const menu = document.querySelector(".series-menu-wrapper");
    if (!menu) return;

    const path = location.pathname.replace(/\/+$/, "");
    const isHome = (path === "");

    menu.style.display = isHome ? "" : "none";
}

生命周期挂载(与现有体系一致)

window.pjaxLoaded = function () {
    initialized = false;
    init(); // sidebar 主系统初始化
    syncSeriesMenu(); // 新增:首页系列菜单状态同步
};

window.addEventListener("pageshow", syncSeriesMenu);
window.addEventListener("popstate", syncSeriesMenu);

这一部分的核心价值不在逻辑复杂度,而在于它补齐了整个系列结构中的一个关键入口层:让“系列文章体系”不再只存在于文章内部或分类结构中,而是在首页层级形成一个稳定的入口节点。

同时,它通过复用 sidebar.js 的生命周期体系,实现了与语义菜单一致的状态同步机制,从而保证整个站点在 PJAX 环境下仍然保持统一的交互行为模型。


关于 PJAX(Argon 主题)环境下的执行约束说明

需要额外说明的是,在 Argon 主题中,由于启用了 PJAX(PushState + AJAX)无刷新加载机制,页面切换时并不会触发完整的浏览器重载流程,因此传统的 DOMContentLoaded 仅在首次进入站点时生效。

在这种机制下,官方提供了 window.pjaxLoaded 作为统一的页面切换完成钩子,用于在每次文章切换完成后重新执行前端初始化逻辑。

因此,如果需要在 Argon 主题中自行添加 JavaScript 功能,并且希望其在“不同文章之间切换时仍然持续生效”,必须将初始化逻辑挂载到 window.pjaxLoaded 中,而不能仅依赖首次加载事件。

否则会出现典型问题:

  • 首次进入页面功能正常
  • 通过 PJAX 切换文章后,JS 逻辑不再执行
  • DOM 更新后事件绑定失效或状态丢失

本篇中的 syncSeriesMenu() 同样遵循这一机制,通过挂载到 window.pjaxLoaded,确保在每次页面切换完成后都能够重新执行状态同步,从而保证首页系列菜单在不同访问路径下始终保持一致行为。

从实践角度看,在 Argon 这类 PJAX 主题中,pjaxLoaded 实际上等价于“前端应用的页面生命周期入口”,所有依赖 DOM 状态的自定义脚本,都应默认以该钩子作为重建触发点。


3.3.4 首页系列菜单样式与视觉呈现(CSS)

这一部分是首页“系列文章菜单”在 WordPress 页面中的视觉表现层,对应的实现为 CSS 样式文件。它的职责并不涉及任何交互逻辑或数据处理,而是将 PHP 输出的结构化系列数据转化为具有层级关系的可视化列表,从而完成首页入口层的最终呈现。

从整体结构来看,这一部分样式主要围绕两个层级展开:首页系列入口容器(Series Menu Wrapper)与系列列表卡片(Series Item)。前者负责控制组件在页面中的位置与悬浮行为,后者负责承载具体的系列信息与文章列表。


1)整体布局与悬浮结构

.series-menu-wrapper 作为整个组件的外层容器,被固定在页面右侧中部,通过 position: fixedtransform: translateY(-50%) 实现垂直居中定位。这种布局方式保证了无论页面滚动到任何位置,该入口始终保持可见,但又不会干扰正文阅读区域。

容器本身设置了较窄的宽度与较高的 z-index,使其在视觉上保持“边缘入口”的定位属性,而不是主内容的一部分,从而强化其作为“导航入口层”的角色。

2)主容器与展开机制

#series-menu 承担的是实际内容承载层,其默认状态为收起结构,仅展示标题区域。当用户 hover 时,通过 CSS 选择器触发 .series-menu-list.series-menu-footer 的显示,从而实现轻量级展开效果。

这种实现方式的核心特点是:默认状态极度收敛(仅标题);hover 才暴露完整列表;无需 JS 参与展开控制。从交互模型上看,这是一个典型的“纯 CSS 驱动的轻交互菜单”,用于降低首页入口层的实现复杂度。

3)系列卡片结构

每个系列通过 .series-item 进行承载,其内部采用“标题 + 描述 + 文章列表”的三段式结构。

其中:

  • .series-title 用于展示系列名称与文章数量
  • .series-desc 用于提供系列的语义说明(弱信息层)
  • .series-posts 用于展示该系列下的文章列表

文章列表采用左侧边线进行视觉分组,使其在空间结构上与标题区形成明显层级区分,从而增强“系列 → 子文章”的归属关系。

4)文章列表与信息密度控制

.series-post-item 负责具体文章条目展示,其链接采用简洁的单行文本结构,并通过 hover 状态提供基础反馈。整体设计目标是控制信息密度,使首页入口层在有限空间内能够承载多个系列而不显拥挤。

在长标题处理上,通过 word-breakoverflow-wrap 保证文本在窄布局下的可读性,避免横向溢出影响整体布局稳定性。

5)视觉一致性与暗色模式

样式系统中额外提供了 html.darkmode 下的适配规则,用于保证在夜间模式下仍然保持层级清晰度。

主要调整包括:背景色由白色转为深色体系;边框与分割线降低对比度;链接颜色提升可读性;描述区域采用半透明背景维持层级感。

这一层的设计目标不是“重绘 UI”,而是确保在不同主题模式下,信息结构的可读性保持一致。


从整体来看,这一部分 CSS 的核心不是复杂视觉效果,而是一个明确的结构约束:在有限空间内,将“系列集合 → 系列卡片 → 文章列表”这一层级关系稳定表达出来,同时保持首页入口的轻量性与非干扰性。

与之前文章右侧语义菜单不同,这一组件更偏向“静态信息入口”,因此整体采用了更轻的交互模型(hover 展开 + 无状态切换),以降低系统复杂度。

CSS部分的代码如下(用于WordPress当前主题的”额外CSS”部分):

/* =========================
   Series Page Layout
========================= */

.series-page {
    max-width: 1000px;
    margin: 40px auto;
    padding: 0 20px;
}

/* header */
.series-header {
    margin-bottom: 30px;
}

.series-header h1 {
    font-size: 26px;
    margin: 0;
}

.series-header p {
    color: #666;
    margin-top: 6px;
}

/* =========================
   Series Item Card
========================= */

.series-item {
    padding: 18px 20px;
    margin-bottom: 24px;
    border: 1px solid #eaeaea;
    border-radius: 10px;
    background: #fff;
}

/* series title row */
.series-title {
    font-size: 18px;
    font-weight: 600;
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    margin-bottom: 8px;
}

/* series count */
.series-count {
    font-size: 14px;
    color: #888;
    font-weight: normal;
}

/* =========================
   Description
========================= */

.series-desc {
    margin: 14px 0 18px;
    padding: 10px 14px;
    border-left: 4px solid #4f8ef7;
    background: #f8f9fa;
    border-radius: 0 8px 8px 0;
    font-size: 14px;
    color: #666;
    line-height: 1.75;
}

/* =========================
   Posts List
========================= */

.series-posts {
    padding-left: 8px;
    border-left: 2px solid #f0f0f0;
}

.series-post-item {
    margin: 6px 0;
    font-size: 14px;
}

.series-post-item a {
    text-decoration: none;
    color: #333;
}

.series-post-item a:hover {
    color: #0073aa;
}

/* =========================
   Visual separation boost
========================= */

.series-item + .series-item {
    margin-top: 18px;
}

/* =========================
   Series menu Layout
========================= */

/* ======================================================
0. 容器
====================================================== */
.series-menu-wrapper {
    position: fixed;
    right: 20px;
    top: 50%;
    transform: translateY(-50%);
    width: 200px;
    max-height: 60vh;
    overflow: hidden;
    z-index: 9999;
}

/* ======================================================
1. 主框体(默认状态 = 收起)
====================================================== */
#series-menu {
    font-size: 13px;
    background: #fff;
    border: 1px solid #eaeaea;
    border-radius: 8px;
    padding: 8px;
    box-shadow: 0 4px 16px rgba(0,0,0,0.08);
    position: relative;
}

/* ======================================================
2. 标题
====================================================== */
.series-menu-title {
    font-weight: 600;
    font-size: 14px;
    padding: 8px 6px;
    text-align: center;
    border-bottom: 1px solid #eee;
    background: #fafafa;
    border-radius: 6px;
    cursor: pointer;
}

/* ======================================================
3. 列表(默认:彻底隐藏)
====================================================== */
.series-menu-list {
    display: none;
    margin-top: 8px;
    max-height: 300px;
    overflow-y: auto;
}

/* hover 才显示 */
#series-menu:hover .series-menu-list {
    display: block;
}

/* ======================================================
4. 单个系列
====================================================== */
.series-menu-item {
    padding: 2px 4px;
    margin-bottom: 2px;
    border-radius: 6px;
    border: 1px solid #f0f0f0;
    background: #fafafa;
    line-height: 1.1;
}

.series-menu-item:hover {
    background: #f5f5f5;
}

/* ======================================================
5. 标题行
====================================================== */
.series-menu-header {
    display: flex;
    align-items: flex-start;
    gap: 6px;
}

/* ======================================================
6. 链接(控制两行核心)
====================================================== */
.series-menu-link {
    flex: 1;
    min-width: 0;
    display: block;

    line-height: 1.25;

    /* 两行限制 */
    max-height: 2.5em;
    overflow: hidden;

    white-space: normal;
    word-break: break-word;
    overflow-wrap: anywhere;

    color: #333;
    text-decoration: none;
}

.series-menu-link:hover {
    color: #000;
}

/* ======================================================
7. footer(默认隐藏)
====================================================== */
.series-menu-footer {
    display: none;
    margin-top: 8px;
    padding-top: 8px;
    border-top: 1px solid #eee;
    text-align: center;
}

#series-menu:hover .series-menu-footer {
    display: block;
}

/* ======================================================
8. 夜间模式(正确 selector:html.darkmode)
====================================================== */

html.darkmode #series-menu {
    background: #1e1e1e !important;
    border-color: #333 !important;
}

html.darkmode .series-menu-title {
    background: #2a2a2a !important;
    color: #eaeaea !important;
    border-color: #333 !important;
}

html.darkmode .series-menu-item {
    background: #2a2a2a !important;
    border-color: #333 !important;

}

html.darkmode .series-menu-link,
html.darkmode .series-name {
    color: #eee !important;
}

html.darkmode .series-menu-footer a {
    color: #aaa !important;
}

html.darkmode .series-menu-footer a:hover {
    color: #fff !important;
}

html.darkmode .series-desc {
    background: rgba(255,255,255,.04);
      border-left-color: #6aa9ff;
    color: #ddd;
}

3.3.5 首页文章系列菜单运行效果与交互表现

在系统完成加载之后,首页右侧会出现一个系列文章入口组件。该组件在初始状态下保持收起,仅保留一个简洁的标题区域与轻量的视觉提示,用于在不干扰首页阅读结构的前提下,让用户感知到“系列结构入口”的存在:

image.png

当用户将鼠标移动到该区域时,组件进入展开状态,此时系统会以列表形式展示所有可用系列。每一个系列以卡片形式呈现,包括系列名称与对应的文章数量,使用户能够在首页直接获得对整个博客内容结构的整体认知。

在这一状态下,首页不再只是内容入口,而变成一个“结构入口层”,用户可以从任意一个系列切入对应的知识路径,而不需要先进入具体文章再回溯结构关系:

image.png

当用户进一步点击某个系列时,系统会进入该系列的详情展示模式,该系列下的所有文章会按照既定顺序完整展开,并以列表形式呈现。这个顺序来源于文章在系列中的编号逻辑,因此阅读路径本身是线性且确定的。

在这一层级中,每一篇文章都提供直接跳转入口,使用户可以沿着系列结构完成连续阅读,而不需要依赖标签或搜索来重新组织路径:

image.png

在交互行为上,这一组件采用的是非常轻量的状态模型:鼠标进入时展开,鼠标离开时收起,内部点击仅负责页面跳转,不引入额外状态管理。这种设计避免了复杂交互逻辑带来的不确定性,使整个组件在行为上保持稳定且可预测。

同时由于该组件运行在首页层级,它的职责并不是承载内容消费,而是提供结构入口,因此在视觉设计上始终保持低信息密度,即使在展开状态下,也不会对首页主内容区域造成干扰。

从整体体验来看,这个系列菜单的意义并不在于“展示文章”,而在于建立一个明确的认知入口:

用户在进入首页的第一时间,就可以意识到整个博客的内容并不是孤立文章集合,而是由多个可连续阅读的“系列结构”构成,并且可以直接从结构层进入任意知识路径。

4 系列菜单与系列卡片的关系

如果把当前博客里的“series 系列结构”拆开来看,其实已经有两种不同的呈现方式:一套是之前做的系列文章卡片,另一套是本文实现的系列文章菜单。

它们看起来都在做同一件事——展示系列文章,但实际解决的问题是不一样的。

系列文章卡片,是在文章阅读过程中使用的。它出现在具体文章页面里,作用很直接,就是告诉你:这个系列还有哪些文章,以及顺序是什么。本质上,它解决的是“在一个系列内部怎么继续往下看”的问题。

而系列文章菜单则完全不依赖当前文章,它放在首页侧边位置,展示的是所有系列的整体入口。它解决的是“有哪些系列可以看、从哪里进入”的问题。

所以两者的区别其实很清楚:系列卡片——解决“一个系列里面怎么继续读”(横向);系列菜单——解决“有哪些系列可以选”(纵向)。

从使用场景上看,它们也刚好互补:用户进入一篇文章后,会用卡片在系列内部继续阅读;
而在还没选定内容的时候,会通过菜单去发现整个系列体系。

所以这一部分更像是一个补全关系:卡片负责“读的时候怎么顺着看”;菜单负责“开始之前怎么选着看”。

两者拼在一起,才算把“series 系列内容”这件事从发现到阅读完整闭环。

📚 系列文章:为博客构建“轻量级知识索引”(8 / 8)

📌 内容结构提示:
这篇内容属于「博客知识地图」的一部分,你可以从这里查看完整内容路径: 博客知识地图
查看相关分类·3个匹配
📎 相关文章
分享这篇文章
博客内容均系原创,转载请注明出处!博客的RSS地址为:https://blog.tangwudi.com/feed,欢迎订阅;如有需要,可以加入Telegram群一起讨论问题。
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
       

👋 欢迎来到“无敌的个人博客”

这里主要围绕以下方向展开长期探索:

🧱 个人数字基础设施与博客系统构建
☁️ Cloudflare 与网络架构实践
🧠 AI 与知识系统探索
🛡️ 网络安全与访问优化
🎵 音乐与声音认知
👁️ 认知视角与世界观