3.2.54 主题接管「文章分类」挂件:父子折叠树替代核心下拉框(#24)

原来侧栏那个分类挂件是 WP 核心的 WP_Widget_Categories,实例配置
dropdown=1,输出的是 <select> 下拉框。主题 CSS 里那套
`.widget_categories ul li` 只匹配 ul li,下拉框里没有 ul
⇒ 写了但一条都没生效。不是"没写样式",是样式和渲染形态对不上。

做法:沿用核心的 id_base `categories` 直接顶替(不是新建独立挂件)。
- option 名不变,_register_widgets() 照旧认领存量实例 categories-3
- 用户升级后什么都不用做:就地变新样式、位置不变、标题沿用
- 代价:update() 需容忍核心的 dropdown/hierarchical 老字段
  (不读也不显式删,用户重存表单时自然收敛掉)

呈现:父级常显 + 子级可折叠,用 <details>/<summary> 原生实现,零 JS。
父级链接放在 <summary> 里不会误触折叠 —— HTML 规范规定 <summary>
的"激活即折叠"只在点击目标非 interactive content 时触发。

显式 pad_counts:核心是在 show_count && hierarchical 时隐式打开的
(category-template.php:392)。不显式给的话父级数字 220→188,
用户会以为丢了 32 篇文章。

orderby=count 不交给 SQL:WP_Term_Query 翻成 ORDER BY tt.count
(原始值),而 pad_counts 是查询后由 PHP 补的 ⇒ 数字大的排在小的
后面。改成本地取回后按补过的 count 排,并做 strnatcasecmp tie-break。

分类名不 decode:实测 esc_html() 对 & 的输出恒为 &amp;,所以库里
存 & 还是存 &amp;,最终 HTML 字节一致、浏览器显示也一致。decode
是冗余且有多一层绕过风险。(建单时判断错了,此处自我纠正。)

删掉失效的 .widget_categories ul li 整套规则及 @media 里的宽度规则。

样式三个缺陷(线上真实页面量坐标才发现,不是看代码能看出来的):
1. 主题全局 ul>li{margin-bottom:6px;padding-left:2px;list-style:circle}
   (kratos.css:74) 未被重置 ⇒ 每行多 6px,8 行累积 42px 行距发虚
2. 同上 padding-left:2px 只覆盖了 :not(.has-children) ⇒ 带箭头行错位 2px
3. 有子级行的 li 里只有 <details> 一个 flex 子项,space-between 推不动;
   <details> 默认按内容收缩 ⇒ 数字右边界差 7.1px(实测
   [220.5,220.5,227.6,227.6,220.5])

验证:
- 端到端(真实 WP 加载待测副本)77 通过 / 0 失败
- 反证 12 组全部命中预期判据,无一组空跑
- CSS 视觉反向验证 3 组:三处缺陷逐个注回,全部被量出来
- 视觉几何 18 项全绿(行距/胶囊/对齐/留白/悬停/箭头)
- 用 3.2.53 老代码跑同一套判据:11 通过 / 57 失败

过程中修掉 5 条假判据(全在我自己的测试脚本里)。用老代码跑时它
居然"通过 16 条",逐条查全是假绿,根因同一个:否定式断言作用在
空靶上(与 pitfalls #424 同一族)。最典型的一条:
  /@media[^{]*\{[^}]*widget_categories/ 的 [^}]* 遇到块内第一个 }
  就截断 ⇒ 永远匹配不到 ⇒ 恒真。老代码 2354 行明明有那条规则。
改成按花括号配对切块后,老代码从「16 通过/50 失败」变「11/57」。

包 kratos_yang-3.2.54.zip:4783259 字节 / 237 成员 / 0 个 1980 时间戳
readme 45 个版本标题 · update.json → 3.2.54
服务器主题目录仍 3.2.53,未被覆盖
This commit is contained in:
2026-10-27 12:18:53 +08:00
parent 75848f2ba2
commit 4c1d67b86b
4 changed files with 828 additions and 10 deletions
+327 -8
View File
@@ -2071,6 +2071,317 @@ button:focus {
} }
} }
/* ══════════════════════════════════════════════════════════════════════
* 「文章分类」挂件(.w-cats)(3.2.54 / 工单 #24)
*
* ── 它为什么要重做 ────────────────────────────────────────────────────
* 原来侧栏跑的是 WP 核心 `WP_Widget_Categories`,实例配置 `dropdown = 1`,
* 输出的是 `wp_dropdown_categories()` 的一个 `<select>` —— 一个光秃秃的
* 系统下拉框,跟侧栏其余挂件(圆角卡片、14/15px 字号、悬停反馈)完全不搭。
* 更糟的是主题里那套 `.widget_categories ul li` 规则只匹配 `ul`,
* 下拉框里没有 `ul` ⇒ **写了也一条不生效**。
*
* 现在改成主题自建的父子折叠树,和 `.w-comments` / `.w-tags` 同一套视觉。
*
* ── 一条硬约束(3.2.51 的教训,别再踩)────────────────────────────────
* 🔴 选择器**只依赖 `.w-cats` 自身**,绝不写 `.k-main .sidebar .w-cats`。
* 3.2.51「个人简介撑爆」就是三段祖先选择器造成的:挂件在后台
* 「外观→小工具」的预览里没有 `.k-main .sidebar` 那层,规则全部失效。
* 前台看着正常,恰恰把问题藏死。
* 🔴 所以这里**不写内边距/白底**:那由外层 `.widget` 提供
* (前台 `.k-main .sidebar .widget`,后台由 WP 自己的挂件容器提供)。
*
* ── 视觉层级 ──────────────────────────────────────────────────────────
* 父分类 15px #333 ← 主体,带折叠箭头
* 子分类 14px #666 ← 比父级轻一档,靠左侧缩进 + 细线表示从属
* 计数 12px #bbb ← 最轻,右对齐;父级数字含子级(pad_counts)
* 三层字号必须不同 —— 一样大的话 29 个分类会糊成一片,找不着重点。
* ══════════════════════════════════════════════════════════════════════ */
.w-cats .yang-cat-list {
margin: 0;
padding: 0;
list-style: none;
}
/* 首层每一行:让「名字 · 数字」两端对齐。
*
* 🔴🔴 这里踩过一个真坑,记清楚 —— 两种行的 DOM 结构**不一样**:
*
* 无子级: <li class="yang-cat-item">
* <a class="yang-cat-link">名字</a>
* <span class="yang-cat-count">12</span> ← a 的**兄弟**
* </li>
* 有子级: <li class="yang-cat-item has-children">
* <details><summary>
* <span class="yang-cat-caret"></span>
* <a class="yang-cat-link">名字</a> ← 都是 summary 的**子项**
* <span class="yang-cat-count">120</span>
* </summary>...</details>
* </li>
*
* 我第一版把 flex 写在 `.yang-cat-item > .yang-cat-link` 上(也就是 `<a>` 自身),
* 结果无子级那一行里**只有 `<a>` 变成 flex 容器,而它内部只有一个文本节点**,
* 于是 `space-between` 没有第二个子项可推 —— 数字留在原地紧贴文字,
* 实测右侧间隙 299px、行高被撑到 36.5px(数字挤到下一行)。
*
* ⇒ 正确做法:flex 必须加在**同时包含名字和数字的那一层**,
* 也就是 `<li>`(无子级)与 `<summary>`(有子级)。
*
* 🔴🔴 还要**重置主题自己的全局 `ul > li`**(kratos.css:74)——
* 这条是 3.2.54 实测抓出来的,光看代码很难想到:
*
* ul > li { margin-bottom: 6px; padding-left: 2px;
* list-style: circle; line-height: 22px; }
*
* 它的特异性是 (0,0,2),而我们 `.w-cats .yang-cat-item` 是 (0,2,0),
* 本来能压过 —— 但 `padding-left` / `margin-bottom` 那几条
* **只写在了 `:not(.has-children)` 上**,带子级的 `<li>` 完全没被覆盖:
*
* 实测(线上真实页面注入新 DOM 后 getComputedStyle):
* 无子级 li display:flex pl:0px mb:6px
* 有子级 li display:list-item pl:2px mb:6px ← 没被覆盖
*
* 后果有两个,都是线上能看出来的:
* ① 每行之间多出 6px 空隙,8 行累积 42px —— 行距看着松散、不齐;
* ② 有子级那行名字被推开 2px,与无子级行**对不齐**(实测 30 vs 45,
* 其中 13px 是箭头按设计占的宽度,剩下 2px 就是这个 padding)。
*
* ⇒ 改成在 `.yang-cat-item` 上统一重置,不靠 `:not()` 分叉。
* 这样两种行基线一致,将来加第三层结构也不会漏。 */
.w-cats .yang-cat-item {
margin-bottom: 0;
padding-left: 0;
list-style: none;
/* `<li>` 的 line-height 也被全局规则钉成 22px,这里跟上组件字号 */
line-height: 1.5;
}
/* 两种行都要成为 flex 容器,名字与数字分居两端。
* 🔴 用 `display:flex` 覆盖 `list-item` 会**同时**干掉圆点标记
* (list-item 是唯一能显示 marker 的 display 值),
* 所以这里不再需要额外写 list-style-type:none 去压 circle。
* 🔴 上下 padding 不在这里给:带子级的行由 <summary> 自己撑(它也写了
* 7px),两处都写会变成 14px,行高直接翻倍。分开写在下面。 */
.w-cats .yang-cat-item {
display: flex;
align-items: center;
justify-content: space-between;
}
/* 只有「无子级」和「子级」这两种叶子行需要自己撑上下间距 */
.w-cats .yang-cat-item:not(.has-children) {
padding: 7px 0;
}
/* 行内文字(无子级 li 里的 a、有子级 summary 里的 a):
* 只负责字号/颜色/可换行,**不再**做 flex 容器。 */
.w-cats .yang-cat-item > .yang-cat-link {
min-width: 0;
color: #333;
font-size: 15px;
line-height: 1.5;
text-decoration: none;
/* 中文长分类名没有空格,必须允许任意位置换行,否则撑破挂件 */
overflow-wrap: break-word;
word-wrap: break-word;
}
.w-cats .yang-cat-item > .yang-cat-link:hover {
color: #00a2ff;
}
/* ── 折叠容器 ────────────────────────────────────────────────────────── */
/* 🔴 <summary> 默认带 display:list-item 与三角形 marker。
* 我们要自己的箭头(用 CSS 画,不依赖字体图标),
* 所以先关掉原生 marker —— 两个都显示会变成"双箭头"。 */
.w-cats .yang-cat-summary {
display: flex;
align-items: center;
padding: 7px 0;
color: #333;
font-size: 15px;
line-height: 1.5;
cursor: pointer;
list-style: none; /* Firefox */
/* 关掉长按选中,避免手机端点开时误选文字 */
-webkit-user-select: none;
user-select: none;
}
.w-cats .yang-cat-summary::-webkit-details-marker {
display: none;
}
.w-cats .yang-cat-summary:hover {
color: #00a2ff;
}
/* 🔴🔴 `<details>` 必须显式撑满整行宽度,否则**两种行的数字对不齐**。
*
* 这是 3.2.54 视觉几何实测抓出来的真缺陷(不是量法问题):
*
* 无子级行(<a> 是 li 的直接子项) 数字右边界 220.5
* 有子级行(<details> 包着) 数字右边界 227.6 ← 差 7.1px
*
* 原因:无子级行的 `<a>` 与数字是 li 的**两个** flex 子项,
* `space-between` 把它们推到两端,数字贴右沿。
* 有子级行的 li 里只有 **一个** flex 子项 `<details>`,
* `space-between` 没有第二个子项可推 ⇒ 不起作用;
* 而 `<details>` 默认宽度是 auto(按内容收缩),
* 于是整组靠左,数字停在内文字结尾处。
*
* ⇒ 给 `<details>` 加 `flex: 1 1 auto; min-width: 0`,
* 让它吃满 li 的可用宽度,内部的 summary 才能把数字推到右沿。
* `min-width: 0` 不能省:它是 flex 项允许收缩到内容宽度以下的前提,
* 长分类名才能按 overflow-wrap 换行而不是撑破容器(3.2.51 的教训)。
*
* 这条同时解释了为什么 `<summary>` 自己写了 width 也没用 ——
* 父级 `<details>` 收缩了,子级撑满的只是那个收缩后的宽度。 */
.w-cats .yang-cat-group {
flex: 1 1 auto;
min-width: 0;
}
/* summary 里的名字链接:占满剩余宽度,让数字被推到最右 */
.w-cats .yang-cat-summary .yang-cat-link {
flex: 1 1 auto;
min-width: 0;
color: inherit;
text-decoration: none;
overflow-wrap: break-word;
word-wrap: break-word;
}
.w-cats .yang-cat-summary:hover .yang-cat-link {
color: #00a2ff;
}
/* 箭头:CSS 画的实心三角,展开时旋转 90°。
*
* 🔴 为什么不用字体图标:主题的 kicon 字体只包含固定的几个字形,
* 加一个要动字体文件;而这是个极简三角形,CSS 边框三角形足够,
* 且不会因字体没加载而"箭头消失"。 */
.w-cats .yang-cat-caret {
flex: 0 0 auto;
width: 0;
height: 0;
margin-right: 8px;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 5px solid #bbb;
-webkit-transition: -webkit-transform 0.2s;
transition: transform 0.2s;
}
.w-cats .yang-cat-summary:hover .yang-cat-caret {
border-left-color: #00a2ff;
}
/* 展开时箭头朝下 */
.w-cats details[open] > .yang-cat-summary .yang-cat-caret {
-webkit-transform: rotate(90deg);
transform: rotate(90deg);
}
/* 子级列表:左侧缩进 + 一条竖线表示从属关系。
* 🔴 竖线用 border-left 而不是额外元素:分类层级只有 2 层,
* 加伪元素会让"缩进"和"线"两套定位各自出错。 */
.w-cats .yang-cat-children {
margin: 0;
padding: 0 0 2px 13px;
border-left: 1px solid #f0f0f0;
list-style: none;
}
.w-cats .yang-cat-children > .yang-cat-item {
list-style: none;
/* 子级行比父级紧一档(5px vs 7px):层级越深、行距越小,
* 视觉上才"收"得进去,否则子列表会显得和父级一样重。 */
padding: 5px 0;
}
/* 🔴 子级 li 的 flex 已由上面通用的 `.w-cats .yang-cat-item` 提供,
* 这里**只需**给行高与间距,绝不能再在 `<a>` 上写 flex ——
* 那正是本组件第一版的原始 bug(见上方 DOM 结构说明):
* 子级的 `<a>` 里只有一个文本节点,数字是它的**兄弟**,
* 在 `<a>` 上写 flex + space-between 推不动任何东西。 */
/* 子级每一行:与父级同样两端对齐,但字号轻一档、颜色浅一档 */
.w-cats .yang-cat-children > .yang-cat-item > .yang-cat-link {
min-width: 0;
/* 左侧不用 padding:竖线已经提供了缩进,再加就成双倍缩进 */
color: #666;
font-size: 14px;
line-height: 1.5;
text-decoration: none;
overflow-wrap: break-word;
word-wrap: break-word;
}
.w-cats .yang-cat-children > .yang-cat-item > .yang-cat-link:hover {
color: #00a2ff;
}
/* ── 文章数 ──────────────────────────────────────────────────────────── */
.w-cats .yang-cat-count {
flex: 0 0 auto;
margin-left: 8px;
padding: 1px 7px;
border-radius: 10px;
background-color: #f5f5f5;
color: #999;
font-size: 12px;
line-height: 18px;
-webkit-transition: 0.2s;
transition: 0.2s;
}
/* 悬停联动:鼠标在名字(或整行)上时,数字胶囊也变成主题蓝。
*
* 🔴 第一版写错了:`.yang-cat-item > .yang-cat-link:hover .yang-cat-count`
* 里的**空格**表示"数字是链接的后代",可实际上数字是链接的**兄弟**
* (见上面两种行的 DOM 结构)。于是无子级那一行悬停时胶囊颜色不变,
* 实测 rgb(245,245,245) → rgb(245,245,245),联动完全没生效。
*
* 正确写法有两条:
* ① 无子级行:`.yang-cat-item:hover > .yang-cat-count`
* —— 悬停在整行(li)上,把**直接子级**的数字染色。
* 用 `>` 而不是空格,避免连带命中子级的计数。
* ② 有子级行:`.yang-cat-summary:hover .yang-cat-count`
* —— summary 里数字本来就是后代,这条原来是对的,保留。 */
.w-cats .yang-cat-item:not(.has-children):hover > .yang-cat-count,
.w-cats .yang-cat-summary:hover .yang-cat-count {
background-color: #00a2ff;
color: #fff;
}
/* ── 当前分类高亮 ────────────────────────────────────────────────────── */
/* 只染色不加粗:加粗会让这一行宽度变化,29 行里突兀地抖一下。
*
* 🔴 三种情况都要覆盖(同上,注意无子级的 a 是 li 的直接子项、
* 有子级的 a 在 summary 里,不能用同一个后代选择器一把抓):
* · 无子级的行高亮 → li.current-cat > .yang-cat-link
* · 有子级的行高亮 → li.current-cat > .yang-cat-summary
* 以及 summary 里的链接(summary 自身不染色时链接会覆盖掉)
* · 子级行高亮 → 子级 li.current-cat > .yang-cat-link
* (这条原来漏了:子级 li 也是 .yang-cat-item,但它的 a 是
* 直接子项,第一条 `> .yang-cat-link` 其实已经覆盖,
* 这里显式写出以免将来改结构时断掉) */
.w-cats .yang-cat-item.current-cat > .yang-cat-link,
.w-cats .yang-cat-item.current-cat > .yang-cat-summary,
.w-cats .yang-cat-item.current-cat > .yang-cat-summary .yang-cat-link,
.w-cats .yang-cat-children > .yang-cat-item.current-cat > .yang-cat-link {
color: #00a2ff;
}
/* 看子分类时,父级也给个提示(不与真正的高亮抢戏) */
.w-cats .yang-cat-item.current-cat-parent > .yang-cat-summary .yang-cat-link {
color: #00a2ff;
}
.k-main .sidebar .w-recommended .bookmark-item { .k-main .sidebar .w-recommended .bookmark-item {
display: block; display: block;
overflow: hidden; overflow: hidden;
@@ -2208,7 +2519,6 @@ button:focus {
.k-main .sidebar .w-tags .item a:hover, .k-main .sidebar .w-tags .item a:hover,
.k-main .sidebar .widget_links ul li:hover, .k-main .sidebar .widget_links ul li:hover,
.k-main .sidebar .widget_categories ul li:hover,
.k-main .sidebar .widget_archive ul li:hover { .k-main .sidebar .widget_archive ul li:hover {
background-color: #eeeeee; background-color: #eeeeee;
} }
@@ -2290,13 +2600,23 @@ button:focus {
box-shadow: none; box-shadow: none;
} }
.k-main .sidebar .widget_categories ul, /* ── 归档挂件(核心 widget_archive)的格子布局
*
* 🔴 3.2.54 起这里**只剩归档**,分类那半已经删掉(工单 #24)。
* 原因:原来那套 `.widget_categories ul li` 一条都没生效过 ——
* 线上分类挂件的实例配置是 `dropdown = 1`,核心输出的是
* `<select>` 下拉框(`wp_dropdown_categories`),里面根本没有 `ul`。
* 选择器和渲染形态对不上,规则等于白写。
* 分类改由主题自建的 `w-cats` 接管(样式见本文件后面的分类区块),
* 外侧 classname 也不再是 `widget_categories`,留在这里只会误导人。
*
* 归档挂件仍然用这套(`widget_archive` 的 before_widget 带这个类),
* 所以原样保留,只是把 selector 里分类那一半摘掉。 */
.k-main .sidebar .widget_archive ul { .k-main .sidebar .widget_archive ul {
margin: 0; margin: 0;
padding-left: 0; padding-left: 0;
} }
.k-main .sidebar .widget_categories ul li,
.k-main .sidebar .widget_archive ul li { .k-main .sidebar .widget_archive ul li {
display: inline-block; display: inline-block;
overflow: hidden; overflow: hidden;
@@ -2315,7 +2635,6 @@ button:focus {
-webkit-line-clamp: 1; -webkit-line-clamp: 1;
} }
.k-main .sidebar .widget_categories ul li a,
.k-main .sidebar .widget_archive ul li a { .k-main .sidebar .widget_archive ul li a {
color: #333; color: #333;
} }
@@ -2348,10 +2667,10 @@ button:focus {
* 窗口越窄列数越多。3.2.50 起标签与友情链接改成 `calc(50% - 2px)`, * 窗口越窄列数越多。3.2.50 起标签与友情链接改成 `calc(50% - 2px)`,
* 由容器宽度自己决定列数;这里只剩 tab 的内边距需要收一收。 * 由容器宽度自己决定列数;这里只剩 tab 的内边距需要收一收。
* *
* 分类/归档那两条宽度**保留** —— 它们的父元素是 li 列表, * 归档那条宽度**保留** —— 它的父元素是 li 列表,不是按行排的网格,
* 不是按行排的网格,压窄只影响观感不影响列数,且原有排版依赖它。 * 压窄只影响观感不影响列数,且原有排版依赖它。见下方规则。
* 见下方规则。 */ * 分类那条已在 3.2.54 删除(工单 #24):分类改由 .w-cats 接管,
.k-main .sidebar .widget_categories ul li, * 不再走 .widget_categories ul li 那套。 */
.k-main .sidebar .widget_archive ul li { .k-main .sidebar .widget_archive ul li {
width: 110px; width: 110px;
} }
+1 -1
View File
File diff suppressed because one or more lines are too long
+499
View File
@@ -27,6 +27,19 @@ function widgets_init()
'WP_Widget_Search', 'WP_Widget_Search',
'WP_Widget_Tag_Cloud', 'WP_Widget_Tag_Cloud',
'WP_Nav_Menu_Widget', 'WP_Nav_Menu_Widget',
/* 3.2.54 / 工单 #24:分类挂件由主题接管(widget_categories,
* id_base 沿用 'categories',理由见该类的注释)。
* 🔴 为什么必须在这里注销核心的那个:
* `WP_Widget_Factory::register()` 是按**类名**存的,不是按 id_base。
* 只 register 自己的类,核心 `WP_Widget_Categories` 仍在工厂里,
* 后台「可用挂件」列表会同时出现「分类」和「文章分类」两项 ——
* 用户根本分不清该拖哪个。注销掉核心的,列表里只剩一个。
* 注销不影响存量实例:`_register_widgets()` 是按 id_base 读
* option `widget_categories`,本类 id_base 相同,照样认领全部实例。
* 执行顺序没问题:本函数挂在 `widgets_init` 优先级 10,而工厂的
* `_register_widgets` 挂在优先级 100(wp-includes/widgets.php),
* 所以「先注销 + 再注册」一定在真正注册实例之前完成。 */
'WP_Widget_Categories',
); );
foreach ($wp_widget as $wp_widget) { foreach ($wp_widget as $wp_widget) {
@@ -1061,6 +1074,491 @@ class widget_posts extends WP_Widget
} }
} }
/* ==========================================================================
* 「文章分类」挂件(3.2.54 · 工单 #24)
*
* ── 为什么这次是「接管」而不是「另起一个」 ──────────────────────────────
* WP 核心自带的 `WP_Widget_Categories` 本来就在跑,主题从没管过它。
* 线上侧栏那个实例是 `categories-3`,配置里 **`dropdown` = 1**:
*
* array( 'title' => '文章分类', 'count' => 1, 'hierarchical' => 1, 'dropdown' => 1 )
*
* 也就是说访客实际看到的是一个 `<select>` 下拉框(`wp_dropdown_categories`),
* 不是列表。主题 CSS 里那套 `.widget_categories ul li`(kratos.css 2293 行起)
* 因此**一条都没生效** —— 它只匹配 `ul li`,下拉框里根本没有 `ul`。
* 「丑」的直接原因就在这里:不是样式没写,是**样式和渲染形态对不上**。
*
* 所以本挂件用核心的同一个 id_base `categories` 顶替它(主题的
* `widgets_init` 在核心 `init` 之后跑,`register_widget` 覆盖同 id_base 的项)。
* 好处是**用户升级后什么都不同做**:
* · 侧栏里那个 `categories-3` 实例直接变成新样式,位置不变;
* · 已存的 `title` = 「文章分类」原样沿用,不用重新填;
* · 不存在"旧的还在旁边、要手动删掉"的迁移步骤。
*
* 🔴 为什么 id_base 必须**沿用** `categories` 而不是换新名字:
* 换新 id_base 会得到新的 option 名(`widget_新名字`),旧实例 `categories-3`
* 的配置留在 `widget_categories` 里没人读 —— 用户看到的是"挂件凭空消失"。
* 沿用 `categories` 则 option 名不变,存量实例继续有效。
* 这也意味着 update() **必须容忍核心那套老字段**(dropdown 等),见下方说明。
*
* ── 「父级常显 + 子级可折叠」的呈现(用户 2026-10-27 选定)──────────────
* 本站实测 29 个分类(9 父 + 20 子)。全展开侧栏要拉长约 1100px,
* 所以默认只列父级,有子级的父级带一个折叠箭头,点开才展开。
* 🔴 折叠用 `<details>/<summary>` 原生实现,**不写一行 JS**:
* - 不依赖 jQuery/Bootstrap 版本,不会跟主题其它脚本抢事件;
* - 键盘可操作、读屏软件能正确朗读,自带 `aria-expanded` 语义;
* - 关掉 JS 也能用(分类导航属于基础功能,不该依赖脚本)。
* `<summary>` 里同时放"父级链接 + 箭头按钮"会有嵌套点击问题,
* 所以箭头用 `::marker` 之外的独立 span,父级链接本身仍是普通 `<a>`。
*
* ── 父级数字为什么要 pad_counts(这条最容易做错)────────────────────────
* 核心 `wp_list_categories()` 在 `show_count && hierarchical` 时**自动**打开
* `pad_counts`(wp-includes/category-template.php:392-393)。实测本站:
*
* 生活记录 原始 count = 188
* 生活记录 带 pad_counts = 220 (188 + 博士6 + 美国22 + 游泳4)
*
* 线上那个下拉框显示的也是 220。如果这里取原始 count,同一个分类会在
* 升级前后显示成两个不同的数字(220 → 188),用户会以为文章丢了。
* 所以这里显式 `'pad_counts' => true`,与核心行为保持一致。
*
* ── 分类名里的 HTML 实体:实测结论是「不用管」(记一次自我纠正)──────
* 线上有一个分类名在库里存的就是 `Ubuntu&amp;Debian`(id=122),
* 不是 `Ubuntu&Debian`。我一开始认定必须 `html_entity_decode()` 一层,
* 理由是"下拉框里会显示成 `Ubuntu&amp;Debian`"。**实测证明这个判断是错的**:
*
* raw = [Ubuntu&amp;Debian]
* decode = [Ubuntu&Debian]
* esc(raw) = [Ubuntu&amp;Debian]
* esc(decode) = [Ubuntu&amp;Debian] ← 两者完全相同
*
* 关键点:`esc_html()` 对 `&` 的输出**恒为** `&amp;`。所以
* · 库里存 `&` → esc_html 变 `&amp;` → 浏览器显示 `&`
* · 库里存 `&amp;` → esc_html 不动它 → 浏览器显示 `&`
* 两种数据经 esc_html 后**字节完全一致**,浏览器里都是 `Ubuntu&Debian`,
* 本来就是正常的。加 decode 不会改变任何输出,只是个看着"更讲究"的冗余。
*
* ⇒ 所以这里**不 decode**,直接用 `esc_html()`。少一层处理 = 少一处
* 可能出错的地方(decode 反而有风险:若将来分类名里有人写了
* `&lt;script&gt;` 这种真·字面量,decode 会把它变成真标签)。
* 这个教训和工单 #23 那次一样:**先实测,再写注释**。
* ====================================================================== */
class widget_categories extends WP_Widget
{
public function __construct()
{
/* id_base 沿用核心的 'categories'(接管,理由见上方),
* 名字走第 2 参(工单 #23,理由见 widget_ad 的注释)。 */
$widget_ops = array(
'classname' => 'widget_categories',
'description' => __('按父子层级展示文章分类,父级常显、子级可折叠', 'kratos'),
);
parent::__construct('categories', __('文章分类', 'kratos'), $widget_ops);
}
/**
* 取实例字段;没填就用默认值。
*
* 🔴 判空一律「trim 后是不是空串」,不用 empty():
* `!empty('0')` 是 false。本挂件里的 `count` 是 0/1 开关、
* 标题也可能就叫「0」,用 empty() 会被当"没填"。
* 与 widget_about::pick() / widget_recent_comments::pick() 同一套判据。
*
* @param array $instance
* @param string $key
* @param mixed $fallback
* @return mixed
*/
private function pick($instance, $key, $fallback)
{
if (!isset($instance[$key])) {
return $fallback;
}
$v = $instance[$key];
// 布尔开关:checkbox 没勾时不提交,所以「缺键 = 关」由上面的 isset 处理,
// 键在则按真值判定(核心老实例存的是 1/0 整数,也能走这里)
if (is_bool($fallback)) {
return (bool) $v;
}
if (is_int($fallback)) {
$s = is_scalar($v) ? trim((string) $v) : '';
if ($s === '' || !preg_match('/^-?\d+$/', $s)) {
return $fallback;
}
return (int) $s;
}
if (!is_string($v)) {
return $fallback;
}
$v = trim($v);
return ($v === '') ? $fallback : $v;
}
/**
* 分类名转成可安全输出的文本。
*
* 只做 `esc_html()`,**不做** html_entity_decode —— 原因见类注释里的
* 实测数据:esc_html 对 `&` 的输出恒为 `&amp;`,所以库里存 `&` 还是
* 存 `&amp;`,最终 HTML 字节一致、浏览器显示也一致。decode 是冗余。
* 反过来 decode 有真实风险:分类名里若有人写了字面量 `&lt;script&gt;`,
* decode 会把它还原成真标签再 esc 一遍 —— 白白多一层可被绕过的路径。
*
* @param string $name
* @return string
*/
private function name_html($name)
{
return esc_html((string) $name);
}
/**
* 取分类树。
*
* @param bool $show_count 是否带上文章数(pad_counts 规则见类注释)
* @param bool $hide_empty 是否隐藏空分类
* @param string $orderby 'name' | 'count'
* @param string $order 'ASC' | 'DESC'
* @return array
*/
private function get_tree($show_count, $hide_empty, $orderby, $order)
{
$args = array(
'hide_empty' => (bool) $hide_empty,
'orderby' => $orderby,
'order' => $order,
/* 🔴 pad_counts 必须显式给:核心 wp_list_categories 是靠
* 「show_count && hierarchical」**隐式**打开的
* (category-template.php:392)。这里若不给,父级数字
* 会比升级前小(220 → 188),看着像丢了文章。 */
'pad_counts' => (bool) $show_count,
);
/* 🔴🔴 「按文章数排序」不能交给 SQL —— 这条实测踩过。
*
* `orderby=count` 在 WP_Term_Query::parse_orderby() 里被翻成
* `ORDER BY tt.count`(class-wp-term-query.php:926-927),
* 排的是数据库里的**原始** count 列;而 pad_counts 是在查询
* 之后由 PHP 补的(父级 = 自己 + 所有子孙)。
*
* 两者不一致的直接后果(实测本站,orderby=count&order=DESC):
*
* 生活记录 220 ← 排第 1(原始 188)
* 评论杂谈 168
* 信息技术 233 ← 排第 3(原始 233,最大!)
* 情感随笔 113
* 人生感悟 120
*
* 界面上看就是"数字大的排在小的后面"—— 用户会认为排序坏了。
* 所以 count 排序改成「先按 name 取回、再在 PHP 里按补过的
* count 排」。取回本来就要全量(要建树),不会多一次查询。 */
if ($orderby === 'count') {
$args['orderby'] = 'name';
$args['order'] = 'ASC';
}
$cats = get_categories($args);
if (!is_array($cats)) {
return array();
}
/* get_categories 返回的是平铺数组(按 orderby 排好),
* 这里按 parent 归拢成树。不用 Walker,因为要输出 <details>,
* 而 Walker_Category 只认 <ul>/<li>。 */
$by_parent = array();
foreach ($cats as $c) {
$pid = (int) $c->parent;
if (!isset($by_parent[$pid])) {
$by_parent[$pid] = array();
}
$by_parent[$pid][] = $c;
}
// 只保留「根(parent=0)→ 直接子级」两层。实测本站最深就是 2 层;
// 若将来有第三层,递归会把 <details> 套娃,侧栏观感反而更差。
$tree = array();
if (!isset($by_parent[0])) {
return $tree;
}
foreach ($by_parent[0] as $root) {
$rid = (int) $root->term_id;
$children = isset($by_parent[$rid]) ? $by_parent[$rid] : array();
$tree[] = array(
'cat' => $root,
'children' => $children,
);
}
/* 按文章数排序:在 PHP 里按「补过的 count」排,两边都排。
* 用 usort + 显式 tie-break(count 相同时按名称),
* 否则相等项的先后顺序取决于 usort 的实现,每次请求可能不同。 */
if ($orderby === 'count') {
$desc = ($order === 'DESC');
$cmp = function ($a, $b) use ($desc) {
$ca = (int) $a->count;
$cb = (int) $b->count;
if ($ca !== $cb) {
return $desc ? ($cb <=> $ca) : ($ca <=> $cb);
}
// 数量相同:按名称稳定排序(strnatcasecmp 对 "C#笔记" 之类更自然)
return strnatcasecmp($a->name, $b->name);
};
usort($tree, function ($x, $y) use ($cmp) {
return $cmp($x['cat'], $y['cat']);
});
foreach ($tree as $i => $node) {
if (count($node['children']) > 1) {
usort($tree[$i]['children'], $cmp);
}
}
}
return $tree;
}
public function widget($args, $instance)
{
$show_count = $this->pick($instance, 'count', true);
$hide_empty = $this->pick($instance, 'hide_empty', true);
$show_children = $this->pick($instance, 'children', true);
$orderby = $this->pick($instance, 'orderby', 'name');
$order = $this->pick($instance, 'order', 'ASC');
// 夹紧非法值(老实例里可能存着任意字符串)
if ($orderby !== 'count') {
$orderby = 'name';
}
if ($order !== 'DESC') {
$order = 'ASC';
}
$tree = $this->get_tree($show_count, $hide_empty, $orderby, $order);
// 🔴 一个分类都没有就整块不输出 —— 不留「标题 + 空 ul」的空壳
if (count($tree) === 0) {
return;
}
/* 当前分类页高亮用。is_category() 为真时查当前 term_id。
*
* 🔴 为什么不看 get_queried_object() 是不是 term 就完事:
* 单篇文章页上它是 WP_Post,用 in_category() 反查会把一篇
* 属于多个分类的文章算成多处高亮,高亮就不唯一了。
* 所以只在 is_category() 为真时取,其余页面一律不高亮。 */
$current_id = 0;
if (function_exists('is_category') && is_category()) {
$qo = get_queried_object();
if (is_object($qo) && isset($qo->term_id)) {
$current_id = (int) $qo->term_id;
}
}
/* 当前分类的祖先链:访问子分类时父级要一起标记,
* 否则用户看不出"我在哪个大类下面"。get_ancestors() 返回的是
* 从近到远的父级 id 数组。 */
$current_ancestors = array();
if ($current_id > 0 && function_exists('get_ancestors')) {
$anc = get_ancestors($current_id, 'category');
if (is_array($anc)) {
$current_ancestors = array_map('intval', $anc);
}
}
echo '<div class="' . esc_attr(yang_widget_class('w-cats')) . '">';
yang_widget_title($args, $instance, __('文章分类', 'kratos'), $this->id_base);
echo '<ul class="yang-cat-list">';
foreach ($tree as $node) {
$root = $node['cat'];
$rid = (int) $root->term_id;
$kids = $node['children'];
$has_kids = ($show_children && count($kids) > 0);
$root_cls = 'yang-cat-item';
if ($has_kids) {
$root_cls .= ' has-children';
}
if ($current_id === $rid) {
$root_cls .= ' current-cat';
} elseif (in_array($rid, $current_ancestors, true)) {
/* 当前在看这个父级底下的子分类 —— 父级挂 current-cat-parent,
* 与核心 Walker_Category 的类名保持一致,便于写样式。 */
$root_cls .= ' current-cat-parent';
}
echo '<li class="' . esc_attr($root_cls) . '">';
if ($has_kids) {
/* 有子级:<details> 原生折叠。
*
* 🔴 为什么父级链接可以放在 <summary> 里(这不是偷懒):
* HTML 规范规定,`<summary>` 的"激活即折叠"行为只在该
* 点击目标**不是** interactive content 时才触发,而 `<a>`
* 本身属于 interactive content。所以点名字是**纯导航**、
* 不会顺带展开;点箭头/数字/行内空白才折叠。
* 两个动作各归各的,不需要一行 JS。
* (若哪天有人把 `<a>` 换成 `<span onclick>`,这条就失效了,
* 所以这里写清楚原因,避免后人改错。)
*
* 🔴 为什么用 <details> 而不是 JS 手风琴:
* · 不依赖 jQuery/Bootstrap 版本,也不跟主题其它脚本抢事件;
* · 键盘 Tab + 回车天然可用,读屏软件正确朗读展开状态;
* · 关掉 JS 仍能展开(分类导航是基础功能,不该依赖脚本)。 */
echo '<details class="yang-cat-group">';
echo '<summary class="yang-cat-summary">';
echo '<span class="yang-cat-caret" aria-hidden="true"></span>';
echo '<a class="yang-cat-link" href="' . esc_url(get_category_link($rid)) . '">'
. $this->name_html($root->name) . '</a>';
if ($show_count) {
echo '<span class="yang-cat-count">' . (int) $root->count . '</span>';
}
echo '</summary>';
echo '<ul class="yang-cat-children">';
foreach ($kids as $kid) {
$kid_id = (int) $kid->term_id;
$kid_cls = 'yang-cat-item';
if ($current_id === $kid_id) {
$kid_cls .= ' current-cat';
}
echo '<li class="' . esc_attr($kid_cls) . '">';
echo '<a class="yang-cat-link" href="' . esc_url(get_category_link($kid_id)) . '">'
. $this->name_html($kid->name) . '</a>';
if ($show_count) {
echo '<span class="yang-cat-count">' . (int) $kid->count . '</span>';
}
echo '</li>';
}
echo '</ul>';
echo '</details>';
} else {
// 无子级:普通一行
echo '<a class="yang-cat-link" href="' . esc_url(get_category_link($rid)) . '">'
. $this->name_html($root->name) . '</a>';
if ($show_count) {
echo '<span class="yang-cat-count">' . (int) $root->count . '</span>';
}
}
echo '</li>';
}
echo '</ul>';
echo '</div>';
}
public function update($new_instance, $old_instance)
{
$instance = array();
/* 🔴 标题用「键存在就存原值」的写法,不是 `!empty`:
* 用户把标题清空是合法操作(表示不显示标题),
* `!empty` 会把它存成 ''、看着"存住了",但读的时候
* 又要区分「没设过」和「设成空」—— 这里统一成存空串即可,
* yang_widget_title() 对空串会回落到默认名「文章分类」。
* 这是本挂件与核心行为的一处有意差异:核心是空则回落
* __('Categories'),我们回落到 __('文章分类')。 */
$instance['title'] = isset($new_instance['title'])
? sanitize_text_field($new_instance['title'])
: '';
$instance['count'] = !empty($new_instance['count']) ? 1 : 0;
$instance['hide_empty'] = !empty($new_instance['hide_empty']) ? 1 : 0;
$instance['children'] = !empty($new_instance['children']) ? 1 : 0;
$orderby = isset($new_instance['orderby']) ? $new_instance['orderby'] : 'name';
$instance['orderby'] = ($orderby === 'count') ? 'count' : 'name';
$order = isset($new_instance['order']) ? $new_instance['order'] : 'ASC';
$instance['order'] = ($order === 'DESC') ? 'DESC' : 'ASC';
/* 🔴 核心的 dropdown / hierarchical 字段不在这里保存。
* 接管后它们没有意义,但**不能显式删掉**:本类只在
* 「用户再次保存这个挂件」时才跑 update(),而 update() 的返回值
* 会整体替换旧实例。如果用户从没打开过新表单,旧实例里那三个
* 字段还在(无害:widget() 不读它们)。
* 一旦用户保存了,字段就自然消失 —— 这正是我们想要的收敛方向。 */
return $instance;
}
public function form($instance)
{
$title = isset($instance['title']) ? (string) $instance['title'] : '';
$show_count = $this->pick($instance, 'count', true);
$hide_empty = $this->pick($instance, 'hide_empty', true);
$show_children = $this->pick($instance, 'children', true);
$orderby = $this->pick($instance, 'orderby', 'name');
$order = $this->pick($instance, 'order', 'ASC');
if ($orderby !== 'count') {
$orderby = 'name';
}
if ($order !== 'DESC') {
$order = 'ASC';
}
?>
<div class="media-widget-control">
<p>
<label for="<?php echo esc_attr($this->get_field_id('title')); ?>"><?php _e('标题:', 'kratos'); ?></label>
<input class="widefat" id="<?php echo esc_attr($this->get_field_id('title')); ?>" name="<?php echo esc_attr($this->get_field_name('title')); ?>" type="text" value="<?php echo esc_attr($title); ?>" placeholder="<?php esc_attr_e('文章分类', 'kratos'); ?>">
<span class="description"><?php _e('留空则显示「文章分类」。', 'kratos'); ?></span>
</p>
<p>
<input class="checkbox" type="checkbox" id="<?php echo esc_attr($this->get_field_id('children')); ?>" name="<?php echo esc_attr($this->get_field_name('children')); ?>" <?php checked($show_children); ?>>
<label for="<?php echo esc_attr($this->get_field_id('children')); ?>"><?php _e('显示子分类(父级可展开)', 'kratos'); ?></label>
<span class="description"><?php _e('关掉则只列父分类。', 'kratos'); ?></span>
</p>
<p>
<input class="checkbox" type="checkbox" id="<?php echo esc_attr($this->get_field_id('count')); ?>" name="<?php echo esc_attr($this->get_field_name('count')); ?>" <?php checked($show_count); ?>>
<label for="<?php echo esc_attr($this->get_field_id('count')); ?>"><?php _e('显示文章数', 'kratos'); ?></label>
<span class="description"><?php _e('父分类的数字包含其子分类的文章。', 'kratos'); ?></span>
</p>
<p>
<input class="checkbox" type="checkbox" id="<?php echo esc_attr($this->get_field_id('hide_empty')); ?>" name="<?php echo esc_attr($this->get_field_name('hide_empty')); ?>" <?php checked($hide_empty); ?>>
<label for="<?php echo esc_attr($this->get_field_id('hide_empty')); ?>"><?php _e('隐藏没有文章的分类', 'kratos'); ?></label>
</p>
<p>
<label for="<?php echo esc_attr($this->get_field_id('orderby')); ?>"><?php _e('排序依据:', 'kratos'); ?></label>
<select class="widefat" id="<?php echo esc_attr($this->get_field_id('orderby')); ?>" name="<?php echo esc_attr($this->get_field_name('orderby')); ?>">
<option value="name" <?php selected($orderby, 'name'); ?>><?php _e('分类名称', 'kratos'); ?></option>
<option value="count" <?php selected($orderby, 'count'); ?>><?php _e('文章数量', 'kratos'); ?></option>
</select>
</p>
<p>
<label for="<?php echo esc_attr($this->get_field_id('order')); ?>"><?php _e('排序方向:', 'kratos'); ?></label>
<select class="widefat" id="<?php echo esc_attr($this->get_field_id('order')); ?>" name="<?php echo esc_attr($this->get_field_name('order')); ?>">
<option value="ASC" <?php selected($order, 'ASC'); ?>><?php _e('升序', 'kratos'); ?></option>
<option value="DESC" <?php selected($order, 'DESC'); ?>><?php _e('降序', 'kratos'); ?></option>
</select>
</p>
<p class="description">
<?php _e('本挂件已接管 WordPress 自带的「分类」挂件:已有的分类挂件会直接在原位置换成新样式,标题也会保留。', 'kratos'); ?>
</p>
</div>
<?php
}
}
/* ========================================================================== /* ==========================================================================
* 「最近评论」挂件(3.2.52 · 工单 #22) * 「最近评论」挂件(3.2.52 · 工单 #22)
* *
@@ -1505,6 +2003,7 @@ function register_widgets()
register_widget('widget_tags'); register_widget('widget_tags');
register_widget('widget_search'); register_widget('widget_search');
register_widget('widget_posts'); register_widget('widget_posts');
register_widget('widget_categories');
register_widget('widget_recent_comments'); register_widget('widget_recent_comments');
} }
add_action('widgets_init', 'register_widgets'); add_action('widgets_init', 'register_widgets');
+1 -1
View File
@@ -1,7 +1,7 @@
/* /*
Theme Name: Kratos_Yang Theme Name: Kratos_Yang
Text Domain: kratos_Yang Text Domain: kratos_Yang
Version: 3.2.53 Version: 3.2.54
Requires PHP: 7.0 Requires PHP: 7.0
Description: 这是一个Kratos主题的分支版本,用来增加一些定制化功能。 Description: 这是一个Kratos主题的分支版本,用来增加一些定制化功能。
Tags: 博客, 双栏, 响应式, 自定义背景, 自定义颜色, 自定义图标, 自定义菜单, 特色图像, 收录优化, 邮件中心, 主题选项, 小工具, 文章置顶, 无障碍友好 Tags: 博客, 双栏, 响应式, 自定义背景, 自定义颜色, 自定义图标, 自定义菜单, 特色图像, 收录优化, 邮件中心, 主题选项, 小工具, 文章置顶, 无障碍友好