Files
wordpress-kratos-yang/inc/theme-widgets.php
T
laoyang b7dcdc1602 分类挂件下拉面板列出全部层级并缩进 #24
需求(2026-10-27):把多级目录也放进去,现在只显示第一级,把其他的
也放进去,注意缩进。线上实测 29 个分类(8 一级 + 21 二级,无第三层),
因此面板从「只列一级」改成「有几层列几层」。

inc/theme-widgets.php
- get_tree() 从写死两层改成任意深度递归。带 $seen 已访问集合 +
  $max_depth=10 双重保护:parent 成环时只跳过、不抛异常 —— 侧栏
  挂件白屏会把整站带下去,宁可少显示一条也不能崩。
- 排序递归到所有层(原来只排前两层,第三层往后顺序与「按文章数排」不一致)。
- 新增 find_name():触发条文案与面板渲染共用同一套遍历。原来两段各自
  硬编码两层,层数一变必漏一处。
- 新增 render_level():widget() 面板改成单次递归调用。关闭 children
  时把 children 清空交给同一函数,不另写一份循环 —— 两份循环必然漂移。
- 下一层 <ul> 放在本层 li 内部(HTML 里 ul 的直接子元素只能是 li)。
- children 兜底值 false → true,**两处**(widget 第 1501 行、form 第
  1701 行)。线上实例 categories-3 没有这个键 ⇒ 走兜底 ⇒ 用户零操作生效。

assets/css/kratos.css
- .yang-cats-item 加 flex-wrap:wrap;子级 ul 用 flex:0 0 100% 独占
  一整行,落在父级 li 的内容盒里 ⇒ 缩进逐层累加且所有层共用一个右边界。
- 缩进只由子级 li 的 padding-left 给,**子级 ul 的 padding-left 归零**。
  第一版两处都给了 12px,几何实测每层缩进 24px(stepPx=[24,24,18]),
  第 3 层名字只剩 148px,收敛规则也一起失效。
- 第 4 层起收缩到 6px([style*="--cat-d:3"] 起),避免深层吃光面板宽度。
- 每层 padding-right 一律 0,右侧 12px 只由顶层统一给 ⇒ 任意深度的
  数字右沿都在同一竖线(数字要竖着比大小,错开一列就没法比)。

assets/css/kratos.min.css 重新压缩;style.css 版本号 → 3.2.56。

验证
- 端到端(真实 WordPress):120 通过 / 0 失败;旧代码 100 / 17
- 反证 20 组(R1~R19 + R13b)全部命中
- 视觉几何(线上真实页面):56 通过 / 0 失败
- 打包内容自检 26 项全过;发布判据 79 条全过
- 发布:update.json → 3.2.56,md5 0aa90b30e76d754de41febcb9271ddef
- 红线:服务器主题目录仍 3.2.55(未覆盖),等用户在后台点升级
2026-10-27 13:33:53 +08:00

2216 lines
112 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 侧栏小工具
* @author Yongquan Yang <yangyq.net>
* @license MIT License
* @version 2020.08.03
*/
// 添加小工具
function widgets_init()
{
register_sidebar(array(
'name' => __('侧边栏工具', 'kratos'),
'id' => 'sidebar_tool',
'before_widget' => '<div class="widget %2$s">',
'after_widget' => '</div>',
'before_title' => '<div class="title">',
'after_title' => '</div>',
));
// 去掉默认小工具
$wp_widget = array(
'WP_Widget_Pages',
'WP_Widget_Meta',
'WP_Widget_Recent_Posts',
'WP_Widget_Recent_Comments',
'WP_Widget_RSS',
'WP_Widget_Search',
'WP_Widget_Tag_Cloud',
'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) {
unregister_widget($wp_widget);
}
}
add_action('widgets_init', 'widgets_init');
/* ==========================================================================
* 后台「外观 → 小工具」用传统拖放面板,不用区块编辑器(3.2.53 / 工单 #23)
*
* ── 为什么要关 ──────────────────────────────────────────────────────
* WP 5.8 起把后台小工具页默认换成了区块编辑器(`wp-admin/widgets-form-blocks.php`)。
* 它**没有"从左边拖到右边"这个动作**,只能点「+」再搜索挂件名,操作方式
* 与传统面板差异很大。用户的实际反馈就是「找不到可以拖动的地方」。
*
* 那挂件名字有没有问题?实测没有 —— 4 个挂件虽然写的是
* `parent::__construct(false, false, $widget_ops)`(`$widget->name` 确实是
* false),但名字放在 `$widget_ops['name']` 里,经
* `wp_register_sidebar_widget()` 的 `array_merge($widget, $options)`
* 覆盖后,`$wp_registered_widgets[...]['name']` 一直是正常中文名。
* 实测结论(老代码):`$widget->name` = false,
* `$wp_registered_widgets['ad-1']['name']` = '图片广告'。
* **所以"找不到挂件"与名字无关,只是区块编辑器没有拖放。**
* (这一度被我误判成名字为空,记一次教训:见 tests 里的说明。)
*
* ── 为什么这个钩子名是 `use_widgets_block_editor` ───────────────────
* WP 7.1.3 的 `wp_use_widgets_block_editor()` 只认这一个 filter:
*
* return apply_filters('use_widgets_block_editor',
* get_theme_support('widgets-block-editor'));
*
* 网上常见的 `gutenberg_use_widgets_block_editor` / `widgets_block_editor_disabled`
* 在 7.1.3 里**一处都不存在**(`grep -rn` 查过 wp-admin/ 与 wp-includes/ 全无命中),
* 写了也是白写。这里按实际源码来。
*
* ── 顺带把主题声明也去掉 ────────────────────────────────────────────
* 只挂 filter 也能生效,但 `get_theme_support('widgets-block-editor')` 仍返回 true,
* 语义上"主题声称支持区块小工具"却又不让用,容易误导后来人。
* 所以在 after_setup_theme 里 `remove_theme_support` —— 必须挂在这个钩子上:
* 主题的 `add_theme_support` 与 `functions.php` 的 require 同在 after_setup_theme
* 早期跑,直接写 `remove_theme_support` 会因为"还没 add"而静默失败。
*
* 🔴 如果哪天决定改用区块界面,把这两段一起删掉即可 —— 但**必须先确认
* 4 个挂件的名字都补上了**,否则会退回"找不到挂件"的老问题。
* ====================================================================== */
add_filter('use_widgets_block_editor', '__return_false');
function yang_drop_widgets_block_editor_support()
{
remove_theme_support('widgets-block-editor');
}
add_action('after_setup_theme', 'yang_drop_widgets_block_editor_support', 20);
/* ==========================================================================
* 后台挂件表单的公共样式与脚本(3.2.51)
*
* ── 为什么不再各个挂件自己挂一份 ────────────────────────────────────
* 原来 `widget_about` / `widget_ad` 各自在构造函数里 add_action 一个
* `admin_enqueue_scripts`,动作里 enqueue 媒体库 + widget.min.js。
* 这套写法有两个毛病:
*
* ① 两个挂件都启用时,同一份资源被 enqueue 两次(id 相同,WP 会去重,
* 但每次都会多跑一次 wp_enqueue_media,白白初始化一遍媒体框);
* ② 更要紧的:媒体框的 JS 只在**挂件实例展开**用得上,可 WP 的旧版
* 挂件页会把所有实例的 form() 一次性渲染进页面 —— 资源没到就等于
* 「选择图片」按钮点了没反应。
*
* 现在统一挂在一个函数里,只在挂件相关页面加载。
*
* 🔴 缩略图预览这部分样式用 wp_add_inline_style 而不是去改
* assets/css/widget.min.css:那个文件**没有源文件**(仓库里只有压缩产物),
* 为了加几条规则去手改一行压缩 CSS,下次别人再改就无从维护。
* ====================================================================== */
if (!function_exists('yang_widget_admin_assets')):
function yang_widget_admin_assets($hook)
{
// 只在挂件页 / 自定义器加载。别的后台页面不需要媒体框。
if (!in_array($hook, array('widgets.php', 'customize.php'), true)) {
return;
}
wp_enqueue_media();
wp_enqueue_script('widget_scripts', ASSET_PATH . '/assets/js/widget.min.js', array('jquery'), THEME_VERSION, true);
wp_enqueue_style('widget_css', ASSET_PATH . '/assets/css/widget.min.css', array(), THEME_VERSION);
$css = '
/* 挂件表单里的图片缩略图。
没有它的话,用户只能看到一串 URL,根本不知道那张图长什么样 ——
「背景图在哪改」这个问题有一半是"看不见"造成的。 */
.yang-media-preview{display:block;margin:8px 0 0;line-height:0}
.yang-media-preview img{display:block;width:64px;height:64px;object-fit:cover;border:1px solid #dcdcde;border-radius:4px;background:#f6f7f7}
.yang-media-preview-wide img{width:100%;max-width:260px;height:auto;aspect-ratio:16/9}
/* 「清除」按钮跟「选择图片」贴在一起,别让它单独换行成一个大按钮 */
.media-widget-control .yang-media-clear{margin-left:6px}
';
wp_register_style('yang-widget-admin', false, array('widget_css'), THEME_VERSION);
wp_enqueue_style('yang-widget-admin');
wp_add_inline_style('yang-widget-admin', $css);
/* 「清除」按钮:把输入框清空,并顺手把缩略图去掉。
*
* 🔴 为什么必须同时清缩略图:只清 input 的话,图片还挂在那儿,
* 用户会以为没清掉,再点一次又点回原来的图。
* 🔴 为什么用 change 事件触发:主题的 widget.min.js 靠
* `.change()` 让 WP 把表单标记为"已修改",否则直接
* 改 .val() 不会点亮「保存」按钮,用户点保存时改动丢失。 */
$js = <<<'JS'
(function(){
if (typeof jQuery === 'undefined') { return; }
jQuery(function($){
$(document).on('click', '.yang-media-clear', function(e){
e.preventDefault();
var id = $(this).attr('data-target');
if (!id) { return; }
var $input = $('#' + id);
$input.val('').trigger('change');
$input.closest('p').find('.yang-media-preview').remove();
$(this).remove();
});
});
})();
JS;
wp_add_inline_script('widget_scripts', $js);
}
endif;
add_action('admin_enqueue_scripts', 'yang_widget_admin_assets');
/* ==========================================================================
* 挂件标题与外框(3.2.50)
*
* ── 为什么要有这两个函数 ──────────────────────────────────────────────
* register_sidebar() 里已经声明了版式:
*
* 'before_title' => '<div class="title">',
* 'after_title' => '</div>',
*
* 但主题自己的挂件**全都绕开了它**:`widget_tags` 把「标签聚合」硬编码在
* 代码里,`widget_ad` / `widget_about` / `widget_posts` 干脆没有标题字段,
* `widget_search` 有字段却自己手写了一个 `<div class="title">`。
*
* 结果是:在后台改侧栏版式(换标题标签、加装饰)对这四个挂件无效,
* 而且用户根本没地方给它们起标题。这两个函数把「标题」收敛到一处,
* 保证所有挂件走同一条路径。
*
* 🔴 空标题必须**不输出任何节点**,而不是输出空的 `<div class="title"></div>` ——
* 后者会留下一条只有内边距的空白条,视觉上像"多了一块间距"。
* ====================================================================== */
if (!function_exists('yang_widget_title')):
/**
* 输出挂件标题(走 sidebar 注册的 before_title / after_title)。
*
* @param array $args widget() 收到的 $args
* @param string $instance 实例里的 title
* @param string $fallback 没设置标题时的默认值;传 '' 表示默认不显示标题
* @param string $id_base 挂件 id_base,仅用于 widget_title 过滤器
*/
function yang_widget_title($args, $instance, $fallback = '', $id_base = '')
{
$title = isset($instance['title']) ? trim((string) $instance['title']) : '';
if ($title === '') {
$title = $fallback;
}
/* 兼容 WP 惯例:主题/插件可以用 widget_title 过滤器改标题。
* 这里保留它,否则装过「改挂件标题」类插件的站点行为会变。 */
if (function_exists('apply_filters')) {
$title = apply_filters('widget_title', $title, (array) $instance, $id_base);
$title = trim((string) $title);
}
if ($title === '') {
return;
}
$before = isset($args['before_title']) ? $args['before_title'] : '<div class="title">';
$after = isset($args['after_title']) ? $args['after_title'] : '</div>';
echo $before . esc_html($title) . $after;
}
endif;
if (!function_exists('yang_widget_uid')):
/**
* 生成挂件内 HTML id 的唯一前缀。
*
* 🔴 为什么不能用写死的 id:挂件可以添加多个实例,同一页面上
* 两个「文章聚合」就会产生两套重复 id(HTML 非法,
* aria-labelledby 也只能指到第一个)。
* `$this->id` 由 WP_Widget 分配(形如 `widget_posts-2`),
* 同一请求内唯一;但在单元测试/旧版环境里可能不存在,所以做兜底。
*
* @param object $widget 挂件实例
* @return string 形如 `yang-widget_posts-2`
*/
function yang_widget_uid($widget)
{
$id = '';
if (is_object($widget) && isset($widget->id) && is_string($widget->id) && $widget->id !== '') {
$id = $widget->id;
} elseif (is_object($widget) && isset($widget->id_base) && is_string($widget->id_base) && $widget->id_base !== '') {
$id = $widget->id_base;
} else {
$id = 'widget';
}
return 'yang-' . preg_replace('/[^a-z0-9_-]+/i', '-', $id);
}
endif;
if (!function_exists('yang_widget_open')):
/**
* 挂件外框起始标签。
*
* 侧栏注册的 before_widget 是 `<div class="widget %2$s">`,其中 %2$s 是
* 挂件类名(classname)。这里故意**不用**它,而是自己拼类名:
* 主题的挂件样式全部写成 `.k-main .sidebar .w-about` 这种以 `w-` 开头的
* 短类名,用 classname 会变成 `widget_widget_about` 之类,样式一条都不生效。
* 保留 `widget` 这个基类(主题里 `.k-main .sidebar .widget` 提供白底与内边距)。
*
* @param string $modifier 形如 'w-about'
* @return string
*/
function yang_widget_class($modifier)
{
return 'widget ' . $modifier;
}
endif;
/* ==========================================================================
* 热度算法(3.2.49)
*
* ── 为什么废弃旧的 most_comm_posts() ──────────────────────────────────
* 旧实现的候选池是「最近 N 天(默认 30)**发布**的文章」:
*
* WHERE post_date BETWEEN '$daysago' AND '$today'
* ORDER BY comment_count DESC LIMIT 6
*
* 对日更站点没问题,对低频更新的站点直接失效。本站实测(2026-10-26):
* 7 天内发布 0 篇 / 30 天内 1 篇 / 90 天内 1 篇 / 180 天内 6 篇
* 30 天候选池只有 1 篇 ⇒ LIMIT 6 也只能返回 1 条。
* 这就是侧栏「热点」永远只显示一条的真正原因 —— **不是排序错,是池子空**。
*
* 而且「最近 30 天发布」本身就不是「热」的定义:热是累计互动,不是新鲜度。
*
* ── 现在的算法 ────────────────────────────────────────────────────────
* 候选池:全站所有已发布 post(不含 page —— 「留言本」这种页面 views 214 万,
* 它的热度对"最热文章"没有意义;`post_type='post'` 天然挡掉)。
* 打分:三个指标各取 log10 后加权求和
*
* score = 3*log10(views+1) + 5*log10(love+1) + 4*log10(comments+1)
*
* 为什么取 log10:本站 views 中位数 4981、最大 56075(差 11 倍),
* 但 love 最大只有 40、评论最大 93。不取对数的话 views 一个指标就吃掉
* 全部权重,点赞和评论等于没参与。取对数后三者量级被拉平到可比的区间。
*
* 为什么权重是 3 / 5 / 4(不是常见的 1 / 3 / 5):
* 用通用值实测,前 8 名有 6 篇是 2009 年的老随笔 —— 因为那批文章评论
* 80~93 条,仅评论一项(1.97×5=9.86)就压过 views 最大值的贡献(4.75×1=4.75),
* 结果变成"评论数排行榜"。把 views 权重提到 3 之后三个指标影响力均衡:
* 点赞最高(5)—— 点👍 是比评论更强的认可信号,且本站只有 197 篇有点赞,
* 稀缺性让它更有区分度;评论次之(4)—— 要付出打字成本;浏览最轻(3)。
*
* 实测这组权重的排名(前 8):Anaconda多用户安装(love=40) · kratos主题发布(love=9,评论37)
* · LaTex上划线(love=19) · 那一年2009 · LaTex字体(love=12) · Windows7自带WMP(评论93)
* → 技术文章与高互动随笔混合出现,2009 年那批也没有被完全埋掉。
*
* ── 有意不加时间衰减 ──────────────────────────────────────────────────
* 「全站历史最热」是累计榜,不是时效榜。Hacker News / Reddit 那套
* gravity 衰减是给「新内容榜」用的,加上去会把老文章压到看不见,
* 那就又回到"只有最近几条"的老问题。要时效榜是另一个功能,不塞在这里。
*
* ── 顺手修掉的两个坑 ──────────────────────────────────────────────────
* ① 旧代码里 `date_default_timezone_set("PRC")` 直接改 **PHP 全局时区**,
* 影响同一请求里之后所有时间函数。已删。
* ② 旧代码把 `$title` 原样插进 `title="..."` 属性,标题里有双引号就会
* 截断属性(甚至注入)。已改用 esc_attr()。
* ====================================================================== */
if (!function_exists('yang_hot_score')):
/**
* 单篇文章的热度分。
*
* @param int $views 浏览数
* @param int $love 点赞数
* @param int $comments 评论数
* @return float
*/
function yang_hot_score($views, $love, $comments)
{
return 3 * log10((int) $views + 1)
+ 5 * log10((int) $love + 1)
+ 4 * log10((int) $comments + 1);
}
endif;
if (!function_exists('yang_hot_posts')):
/**
* 取热度最高的文章。
*
* 为什么在 PHP 里算分、不在 SQL 里 ORDER BY:
* 分值是三项 log 加权和,SQL 里要写三段 LOG10 + COALESCE + 两个
* LEFT JOIN 到 postmeta,可读性极差,而且没法复用 yang_hot_score()
* (测试和产品必须是同一份实现,不能各写一遍)。
* 代价是要把候选行取回来 —— 本站 967 篇 post,一次取 4 个字段
* 内存占用可忽略;如果将来文章上万,再加 LIMIT / 缓存。
* 为防止极端情况,SQL 侧先用 views 粗排取前 N*10(对数排序里
* 真正的高分不会来自 views 极低的文章,这个粗排是安全的)。
*
* @param int $nums 返回条数
* @return array 文章对象数组,每项含 ID/post_title/views/love/comments/score
*/
function yang_hot_posts($nums = 6)
{
global $wpdb;
$nums = max(1, (int) $nums);
// 粗排窗口:给足余量。views 前 200 名以外不可能靠 love/评论翻盘
// (log10 域下,love 满分 1.61×5=8.05,补不回 views 差 1 个数量级的 3 分)。
$pool = max(200, $nums * 20);
$sql = $wpdb->prepare(
"SELECT p.ID, p.post_title, p.post_date, p.comment_count,
COALESCE(CAST(mv.meta_value AS UNSIGNED), 0) AS views,
COALESCE(CAST(ml.meta_value AS UNSIGNED), 0) AS love
FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->postmeta} mv ON mv.post_id = p.ID AND mv.meta_key = 'views'
LEFT JOIN {$wpdb->postmeta} ml ON ml.post_id = p.ID AND ml.meta_key = 'love'
WHERE p.post_status = 'publish'
AND p.post_type = 'post'
ORDER BY views DESC
LIMIT %d",
$pool
);
$rows = $wpdb->get_results($sql);
if (empty($rows)) {
return array();
}
$scored = array();
foreach ($rows as $r) {
$r->views = (int) $r->views;
$r->love = (int) $r->love;
$r->comments = (int) $r->comment_count;
$r->score = yang_hot_score($r->views, $r->love, $r->comments);
$scored[] = $r;
}
usort($scored, function ($a, $b) {
if ($a->score == $b->score) {
// 同分按浏览数兜底,保证顺序稳定(否则每次刷新顺序会跳)
return $b->views <=> $a->views;
}
return $b->score <=> $a->score;
});
return array_slice($scored, 0, $nums);
}
endif;
/**
* 小工具文章聚合 —— 热点文章
*
* ⚠️ 这个函数名和签名保留着,因为 `widget_posts::widget()` 里有一句
* `if (function_exists('most_comm_posts'))` 调用它。留着旧名不破兼容,
* 但内部已经完全换成 yang_hot_posts()。
*
* @param int $days 已废弃。旧实现用它当"最近 N 天发布"的时间窗,
* 那正是只显示一条的根因。保留参数只为不破坏调用签名。
* @param int $nums 返回条数
*/
function most_comm_posts($days = 30, $nums = 6)
{
$posts = yang_hot_posts($nums);
$output = '';
foreach ($posts as $p) {
$output .= '<a class="bookmark-item" title="' . esc_attr($p->post_title) . '"'
. ' href="' . esc_url(get_permalink($p->ID)) . '" rel="bookmark">'
. '<i class="kicon i-book"></i>'
. esc_html(wp_strip_all_tags($p->post_title))
. '</a>';
}
echo $output;
}
class widget_search extends WP_Widget {
public function __construct() {
$widget_ops = array(
'classname' => 'widget_search',
'description' => __( 'A search form for your site.' ),
'customize_selective_refresh' => true,
);
parent::__construct( 'search', _x( 'Search', 'Search widget' ), $widget_ops );
}
public function widget( $args, $instance ) {
/* 🔴 id 必须带实例后缀。
* 原来写死 id="searchform" / id="search" / id="searchsubmit",
* 挂两个搜索挂件时全撞;主题别处或核心再放一个搜索表单也会撞。 */
$uid = yang_widget_uid($this);
echo '<div class="' . esc_attr(yang_widget_class('w-search')) . '">';
yang_widget_title($args, $instance, '', $this->id_base);
?>
<div class="item">
<form role="search" method="get" id="<?php echo esc_attr($uid); ?>-form"
class="searchform" action="<?php echo esc_url(home_url('/')); ?>">
<div class="input-group mt-2 mb-2">
<label class="sr-only" for="<?php echo esc_attr($uid); ?>-field"><?php _e('搜索', 'kratos'); ?></label>
<input type="text" name="s" id="<?php echo esc_attr($uid); ?>-field" class="form-control"
placeholder="<?php esc_attr_e('搜点什么呢?', 'kratos'); ?>">
<div class="input-group-append">
<button class="btn btn-primary btn-search" type="submit" id="<?php echo esc_attr($uid); ?>-submit"><?php _e('搜索', 'kratos'); ?></button>
</div>
</div>
</form>
</div>
<?php
echo '</div>';
}
public function form( $instance ) {
$instance = wp_parse_args( (array) $instance, array( 'title' => '' ) );
$title = $instance['title'];
?>
<p><label for="<?php echo $this->get_field_id( 'title' ); ?>"><?php _e( 'Title:' ); ?> <input class="widefat" id="<?php echo $this->get_field_id( 'title' ); ?>" name="<?php echo $this->get_field_name( 'title' ); ?>" type="text" value="<?php echo esc_attr( $title ); ?>" /></label></p>
<?php
}
public function update( $new_instance, $old_instance ) {
$instance = $old_instance;
$new_instance = wp_parse_args( (array) $new_instance, array( 'title' => '' ) );
$instance['title'] = sanitize_text_field( $new_instance['title'] );
return $instance;
}
}
class widget_ad extends WP_Widget
{
public function __construct()
{
/* 名字与 id_base 都走显式实参(2026-10-27,工单 #23)。
*
* ── 原来写法为什么"看着像 bug 但实际没坏" ────────────────────
* 原来写的是 `parent::__construct(false, false, $widget_ops)`,
* 把名字放在 `$widget_ops['name']` 里。两条链路分开看:
*
* ① `$widget->name`:确实等于 false。
* `WP_Widget::__construct` 只有 `$this->name = $name;`,
* 而 `wp_parse_args` 的默认值只补 classname 与
* customize_selective_refresh,**不补 name**。
*
* ② 但后台列表/搜索用的**不是** `$widget->name`,而是
* `$wp_registered_widgets[<id>]['name']`。它是这么来的:
* `_register_one()` → `wp_register_sidebar_widget($id, $this->name, ..., $this->widget_options)`
* → 里面先 `$widget = array('name' => $name, ...)`
* → 紧接着 `$widget = array_merge($widget, $options)` ← 关键
* `$options` 就是 `$this->widget_options`,其中的 `'name'`
* **把前面的 false 覆盖掉了**。所以注册表里名字一直是好的。
*
* 实测(老代码):`$widget->name` = false,但
* `$wp_registered_widgets['ad-1']['name']` = '图片广告' —— 名字没丢。
* 也就是说「用户找不到挂件」的真实原因是后台走了区块编辑器、
* 没有拖放面板,与本构造函数的实参无关(开关见文件上方)。
*
* ── 那为什么还是改了 ────────────────────────────────────────
* ① 名字配在 `$widget_ops` 里是靠 array_merge **顺带**覆盖生效的,
* 属于隐式行为;显式走第 2 参是 WP 文档写法,读代码的人不用
* 再去追 array_merge 才敢确认名字有没有生效。
* ② `$this->name` 在整个 WP 里有多处直接读(例如区块编辑器的
* 挂件类型接口),值是 false 时行为取决于调用方是否兜底 ——
* 这次侥幸没事,不代表以后没事。
*
* 第 1 参显式写 'ad':$id_base 决定 option 名(widget_ad)。
* 库里已经有用户的实例,**id_base 一旦变了配置就全丢**。
* 原来传 false 时 WP 靠类名推导出 'ad'(不是 widget_ad,它会把
* wp_/widget_ 前缀剥掉),效果一样 —— 写出来是为了不依赖推导。 */
$widget_ops = array(
'description' => __('显示自定义图片广告的工具', 'kratos'),
);
parent::__construct('ad', __('图片广告', 'kratos'), $widget_ops);
}
public function widget($args, $instance)
{
$subtitle = !empty($instance['subtitle']) ? $instance['subtitle'] : __('广告', 'kratos');
$image = !empty($instance['image']) ? $instance['image'] : '';
$url = !empty($instance['url']) ? $instance['url'] : '';
/* 🔴 原来这三处都是裸拼:
* '<a href="' . $url . '"...><img src="' . $image . '"><div class="prompt">' . $subtitle . '</div></a>'
* ① $url 没 esc_url —— 后台填个带引号的地址就能截断 href 属性;
* ② img 的 src 值**没有引号** —— 图片 URL 里只要有一个空格,
* 后半截就会被解析成另一个属性(常见于文件名带空格的媒体库图片);
* ③ $subtitle 没转义 —— 后台填 < 或 & 会破坏结构。
* 下面全部补齐;alt 取副标题,避免读屏软件只念文件名。 */
echo '<div class="' . esc_attr(yang_widget_class('w-ad')) . '">';
yang_widget_title($args, $instance, '', $this->id_base);
$imgAlt = wp_strip_all_tags($subtitle);
echo '<a href="' . esc_url($url) . '" target="_blank" rel="noreferrer noopener">'
. '<img src="' . esc_url($image) . '" alt="' . esc_attr($imgAlt) . '">'
. '<div class="prompt">' . esc_html($subtitle) . '</div>'
. '</a>';
echo '</div>';
}
public function update($new_instance, $old_instance)
{
$instance = array();
$instance['title'] = (!empty($new_instance['title'])) ? sanitize_text_field($new_instance['title']) : '';
$instance['subtitle'] = (!empty($new_instance['subtitle'])) ? sanitize_text_field($new_instance['subtitle']) : '';
$instance['image'] = (!empty($new_instance['image'])) ? esc_url_raw($new_instance['image']) : '';
$instance['url'] = (!empty($new_instance['url'])) ? esc_url_raw($new_instance['url']) : '';
return $instance;
}
public function form($instance)
{
$title = !empty($instance['title']) ? $instance['title'] : '';
$subtitle = !empty($instance['subtitle']) ? $instance['subtitle'] : __('广告', 'kratos');
$image = !empty($instance['image']) ? $instance['image'] : '';
$url = !empty($instance['url']) ? $instance['url'] : '';
?>
<div class="media-widget-control">
<p>
<label for="<?php echo $this->get_field_id('title'); ?>"><?php _e('标题:', 'kratos');?></label>
<input class="widefat" id="<?php echo $this->get_field_id('title'); ?>" name="<?php echo $this->get_field_name('title'); ?>" type="text" value="<?php echo esc_attr($title); ?>">
<span class="description"><?php _e('留空则不显示标题。', 'kratos');?></span>
</p>
<p>
<label for="<?php echo $this->get_field_id('subtitle'); ?>"><?php _e('副标题:', 'kratos');?></label>
<input class="widefat" id="<?php echo $this->get_field_id('subtitle'); ?>" name="<?php echo $this->get_field_name('subtitle'); ?>" type="text" value="<?php echo esc_attr($subtitle); ?>">
</p>
<p>
<label for="<?php echo $this->get_field_id('url'); ?>"><?php _e('链接地址:', 'kratos');?></label>
<input class="widefat" id="<?php echo $this->get_field_id('url'); ?>" name="<?php echo $this->get_field_name('url'); ?>" type="text" value="<?php echo esc_attr($url); ?>">
</p>
<p>
<label for="<?php echo $this->get_field_id('image'); ?>"><?php _e('广告图片:', 'kratos');?></label>
<input class="widefat" id="<?php echo $this->get_field_id('image'); ?>" name="<?php echo $this->get_field_name('image'); ?>" type="text" value="<?php echo esc_url($image); ?>" />
<button type="button" class="button-update-media upload_ad"><?php _e('选择图片', 'kratos');?></button>
</p>
</div>
<?php
}
}
class widget_about extends WP_Widget
{
public function __construct()
{
/* 名字走第 2 参(工单 #23,完整理由与实测见 widget_ad 的注释):
* 原来把名字塞在 `$widget_ops['name']` 里,靠
* `wp_register_sidebar_widget()` 里 `array_merge($widget, $options)`
* 顺带覆盖才生效 —— 能用,但是隐式的。显式传更清楚。
* 第 1 参显式写 'about',保住 option 名 widget_about —— 线上已有实例。 */
$widget_ops = array(
'description' => __('可跳转后台的个人简介展示工具', 'kratos'),
);
parent::__construct('about', __('个人简介', 'kratos'), $widget_ops);
}
/**
* 取挂件实例里的某个值;空则返回回退值。
*
* 🔴 为什么不用 `!empty()` 判定:
* ① 用户在后台把字段清空后,update() 存进去的是空字符串 '',
* `!empty('')` 为 false —— 这一条两边一致,没问题;
* ② 但 `!empty('0')` 也是 **false**!昵称就叫「0」、简介只写「0」
* 的站点会莫名其妙被回落到主题设置。判空只能判「去掉空白后是空串」。
*
* 3.2.51 起 widget_about 的四个字段都走这里,取值顺序统一为:
* 实例自己的值 → $fallback(主题设置 / 主题自带默认图)
*
* @param array $instance 挂件实例
* @param string $key 字段名
* @param string $fallback 实例里没填时用的值
* @return string
*/
private function pick($instance, $key, $fallback)
{
$v = isset($instance[$key]) ? $instance[$key] : '';
if (!is_string($v)) {
return $fallback;
}
$v = trim($v);
return ($v === '') ? $fallback : $v;
}
public function widget($args, $instance)
{
/* ══════════════════════════════════════════════════════════════
* 3.2.51:这三项原来**只读主题设置**,挂件表单里压根没有对应字段。
*
* 老代码长这样(拿掉注释后的实质):
* $introduce = kratos_option('a_about', ...);
* $username = kratos_option('a_nickname', ...);
* $avatar = yang_option_image('a_gravatar');
*
* 于是用户点开侧栏「个人简介」,表单里只有「背景图片」一个字段,
* 头像 / 昵称 / 简介**无处可填**,只能跑去「主题设置 → 站长信息」
* 改那份全站共用的。同一侧栏想放两个人的简介就不可能。
*
* 取值顺序(实例优先,留空回落):
* ① 挂件实例自己填的
* ② 主题设置「站长信息」—— **必须保留**:线上站点正用着
* 「老杨 / 博士,非博学之士…」那一套,改成"空了就空着"
* 会让所有老站点升级后侧栏突然变空白。
* ③ 代码里的兜底文案
* ══════════════════════════════════════════════════════════════ */
$introduce = $this->pick(
$instance,
'introduce',
kratos_option('a_about', __('保持饥渴的专注,追求最佳的品质', 'kratos'))
);
$username = $this->pick(
$instance,
'nickname',
kratos_option('a_nickname', __('Kratos', 'kratos'))
);
$avatar = $this->pick(
$instance,
'avatar',
yang_option_image('a_gravatar')
);
$background = $this->pick(
$instance,
'background',
ASSET_PATH . '/assets/img/about-background.png'
);
/* 🔴 原来写的是 `background:url(' . $background . ') no-repeat ...`
* —— url() 里的地址**没有引号**。只要路径含空格、括号、单引号,
* CSS 解析器就会在那里截断,整条声明作废(背景变白)。
* 媒体库文件名带空格很常见。这里补引号。
*
* 🔴 这里**只拼一次转义**,而且拼完还要整体 esc_attr()。
* 第一版写的是 esc_url($background) 再套 esc_attr($bg) ——
* esc_url() 已经做过 HTML 实体化,esc_attr() 又来一次 ⇒
* `&` 被编成 `&amp;amp;`,CSS 里拿到的是字面量 "amp",
* 背景图直接 404。测试里那条「URL 完整」判据抓到的就是它。
*
* 正确做法:URL 只做**协议/字符清洗**(esc_url_raw,不入实体),
* url() 内部用双引号包住,最后整个 style 属性交给 esc_attr()
* 统一转义一次。这样 `&` 只会被编成 `&amp;`(正确),
* 浏览器解析回属性值后仍是原始的 `&`。
*
* 🔴 $introduce / $username / $avatar 现在都可能来自**后台表单**
* (用户自己填的),原来是裸 echo。个人简介里写个 < 就把后面的
* 结构吃掉了。简介保留换行(textarea 的自然语义),其余全部转义。 */
$bg = 'background-image:url("' . esc_url_raw($background) . '");'
. 'background-repeat:no-repeat;'
. 'background-position:center center;'
. 'background-size:cover;';
echo '<div class="' . esc_attr(yang_widget_class('w-about')) . '">';
yang_widget_title($args, $instance, '', $this->id_base);
echo '<div class="background" style="' . esc_attr($bg) . '"></div><div class="wrapper text-center">';
if (current_user_can('manage_options')) {
echo '<a href="' . esc_url(admin_url()) . '">';
} else {
echo '<a href="' . esc_url(wp_login_url()) . '">';
}
echo '<img src="' . esc_url($avatar) . '" alt="' . esc_attr(wp_strip_all_tags($username)) . '"></a>';
echo '</div><div class="textwidget text-center">'
. '<p class="username">' . esc_html($username) . '</p>'
. '<p class="about">' . nl2br(esc_html($introduce)) . '</p>'
. '</div>';
echo '</div>';
}
/**
* 保存实例。
*
* 🔴 不能用 `!empty($new_instance[...])` 判"用户填了没有":
* `!empty('0')` 是 **false** ⇒ 昵称就叫「0」、简介只写「0」的站点
* 保存后会变成空串、前台回落主题设置,而且**后台看起来像没保存成功**。
* 测试 `_about_test.php` 的「昵称『0』能存下来」抓到的就是这个。
* 统一用「转成字符串、trim 后是不是空串」判断,与 pick() 一致。
*/
public function update($new_instance, $old_instance)
{
$instance = array();
$instance['title'] = $this->clean_text($new_instance, 'title');
$instance['background'] = $this->clean_url($new_instance, 'background');
/* 3.2.51 新增三项。
*
* 🔴 简介是 textarea,**不能**用 sanitize_text_field:
* 那个函数会去掉所有换行,用户分段的简介会挤成一整段。
* 要用 sanitize_textarea_field(WP 4.7+ 才有)。
* 主题没声明最低 WP 版本,所以对更老的版本做个兜底 ——
* 兜底只做「去标签 + 规整换行」,不追求完全等价。
*
* 🔴 头像/背景是 URL,用 esc_url_raw(只清洗协议与字符),
* 不要在这里 esc_url —— 存进库的值必须是可以直接用的原地址。 */
$instance['avatar'] = $this->clean_url($new_instance, 'avatar');
$instance['nickname'] = $this->clean_text($new_instance, 'nickname');
$instance['introduce'] = $this->clean_textarea($new_instance, 'introduce');
return $instance;
}
/**
* 取一个新提交的字段;没提交或只有空白 → 空串。
*
* @param array $src 新提交的实例数组
* @param string $key 字段名
* @return string
*/
private function submitted($src, $key)
{
if (!isset($src[$key])) {
return '';
}
$v = $src[$key];
if (!is_string($v)) {
return '';
}
return trim($v);
}
private function clean_text($src, $key)
{
$v = $this->submitted($src, $key);
return $v === '' ? '' : sanitize_text_field($v);
}
private function clean_url($src, $key)
{
$v = $this->submitted($src, $key);
return $v === '' ? '' : esc_url_raw($v);
}
private function clean_textarea($src, $key)
{
$v = $this->submitted($src, $key);
if ($v === '') {
return '';
}
if (function_exists('sanitize_textarea_field')) {
return sanitize_textarea_field($v);
}
return trim(preg_replace('/\r\n|\r/', "\n", wp_strip_all_tags($v)));
}
public function form($instance)
{
$title = !empty($instance['title']) ? $instance['title'] : '';
/* 输入框里显示**用户自己填的原始值**(没填就是空),
* 预览图显示**前台实际会用的那张**(含回落)。
*
* 两者刻意分开:
* · 输入框若直接显示回落值,用户会以为"我已经填了",
* 保存后才发现其实存的是主题设置那份;
* · 预览图画的是最终效果,用户一眼能看出现在用的是哪张。 */
$avatarRaw = isset($instance['avatar']) ? trim((string) $instance['avatar']) : '';
$bgRaw = isset($instance['background']) ? trim((string) $instance['background']) : '';
$nicknameRaw = isset($instance['nickname']) ? trim((string) $instance['nickname']) : '';
$introduceRaw = isset($instance['introduce']) ? (string) $instance['introduce'] : '';
$avatarShown = $this->pick($instance, 'avatar', yang_option_image('a_gravatar'));
$bgShown = $this->pick($instance, 'background', ASSET_PATH . '/assets/img/about-background.png');
?>
<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); ?>">
<span class="description"><?php _e('留空则不显示标题。', 'kratos');?></span>
</p>
<p>
<label for="<?php echo esc_attr($this->get_field_id('avatar')); ?>"><?php _e('个人头像:', 'kratos');?></label>
<input class="widefat" id="<?php echo esc_attr($this->get_field_id('avatar')); ?>" name="<?php echo esc_attr($this->get_field_name('avatar')); ?>" type="text" value="<?php echo esc_attr($avatarRaw); ?>">
<button type="button" class="button-update-media upload_avatar"><?php _e('选择图片', 'kratos');?></button>
<span class="description"><?php _e('留空则使用「主题设置 → 站长信息 → 个人头像」。', 'kratos');?></span>
<?php if ($avatarShown !== '') : ?>
<span class="yang-media-preview"><img src="<?php echo esc_url($avatarShown); ?>" alt=""></span>
<?php endif; ?>
</p>
<p>
<label for="<?php echo esc_attr($this->get_field_id('nickname')); ?>"><?php _e('昵称:', 'kratos');?></label>
<input class="widefat" id="<?php echo esc_attr($this->get_field_id('nickname')); ?>" name="<?php echo esc_attr($this->get_field_name('nickname')); ?>" type="text" value="<?php echo esc_attr($nicknameRaw); ?>" placeholder="<?php echo esc_attr(kratos_option('a_nickname', __('Kratos', 'kratos'))); ?>">
<span class="description"><?php _e('留空则使用「主题设置 → 站长信息 → 个人昵称」。', 'kratos');?></span>
</p>
<p>
<label for="<?php echo esc_attr($this->get_field_id('introduce')); ?>"><?php _e('个人简介:', 'kratos');?></label>
<textarea class="widefat" rows="4" id="<?php echo esc_attr($this->get_field_id('introduce')); ?>" name="<?php echo esc_attr($this->get_field_name('introduce')); ?>" placeholder="<?php echo esc_attr(kratos_option('a_about', __('保持饥渴的专注,追求最佳的品质', 'kratos'))); ?>"><?php echo esc_textarea($introduceRaw); ?></textarea>
<span class="description"><?php _e('支持换行。留空则使用「主题设置 → 站长信息 → 个人简介」。', 'kratos');?></span>
</p>
<p>
<label for="<?php echo esc_attr($this->get_field_id('background')); ?>"><?php _e('背景图片:', 'kratos');?></label>
<input class="widefat" id="<?php echo esc_attr($this->get_field_id('background')); ?>" name="<?php echo esc_attr($this->get_field_name('background')); ?>" type="text" value="<?php echo esc_attr($bgRaw); ?>">
<button type="button" class="button-update-media upload_background"><?php _e('选择图片', 'kratos');?></button>
<?php if ($bgRaw !== '') : ?>
<button type="button" class="button yang-media-clear" data-target="<?php echo esc_attr($this->get_field_id('background')); ?>"><?php _e('清除', 'kratos');?></button>
<?php endif; ?>
<span class="description"><?php _e('留空则使用主题自带的默认背景图。', 'kratos');?></span>
<?php if ($bgShown !== '') : ?>
<span class="yang-media-preview yang-media-preview-wide"><img src="<?php echo esc_url($bgShown); ?>" alt=""></span>
<?php endif; ?>
</p>
</div>
<?php
}
}
class widget_tags extends WP_Widget
{
public function __construct()
{
/* 名字走第 2 参(工单 #23,完整理由与实测见 widget_ad 的注释)。
* 第 1 参写 'tags',保住 option 名 widget_tags。 */
$widget_ops = array(
'description' => __('文章标签的展示工具', 'kratos'),
);
parent::__construct('tags', __('标签聚合', 'kratos'), $widget_ops);
}
public function widget($args, $instance)
{
$number = !empty($instance['number']) ? $instance['number'] : '8';
$order = !empty($instance['order']) ? $instance['order'] : 'RAND';
$tags = wp_tag_cloud(array(
'unit' => 'px',
'smallest' => 14,
'largest' => 14,
'number' => $number,
'format' => 'flat',
'orderby' => 'count',
'order' => $order,
'echo' => false,
)
);
/* 🔴 标题原来是硬编码的 __('标签聚合'),后台没有输入框也改不了。
* 现在走实例的 title,没设才回落到「标签聚合」——
* 这样老站点的显示**一点不变**,但用户可以改了。 */
echo '<div class="' . esc_attr(yang_widget_class('w-tags')) . '">';
yang_widget_title($args, $instance, __('标签聚合', 'kratos'), $this->id_base);
echo '<div class="item">' . $tags . '</div>';
echo '</div>';
}
public function update($new_instance, $old_instance)
{
$instance = array();
$instance['title'] = (!empty($new_instance['title'])) ? sanitize_text_field($new_instance['title']) : '';
$instance['number'] = (!empty($new_instance['number'])) ? (int) $new_instance['number'] : 8;
$instance['order'] = (!empty($new_instance['order'])) ? $new_instance['order'] : 'RAND';
return $instance;
}
public function form($instance)
{
global $wpdb;
$title = !empty($instance['title']) ? $instance['title'] : '';
$number = !empty($instance['number']) ? $instance['number'] : '8';
$order = !empty($instance['order']) ? $instance['order'] : 'RAND';
?>
<div class="media-widget-control">
<p>
<label for="<?php echo $this->get_field_id('title'); ?>"><?php _e('标题:', 'kratos');?></label>
<input class="widefat" id="<?php echo $this->get_field_id('title'); ?>" name="<?php echo $this->get_field_name('title'); ?>" type="text" value="<?php echo esc_attr($title); ?>" placeholder="<?php echo esc_attr__('标签聚合', 'kratos'); ?>">
<span class="description"><?php _e('留空则仍显示「标签聚合」。', 'kratos');?></span>
</p>
<p>
<label for="<?php echo $this->get_field_id('number'); ?>"><?php _e('显示数量:', 'kratos');?></label>
<input class="widefat" id="<?php echo $this->get_field_id('number'); ?>" name="<?php echo $this->get_field_name('number'); ?>" type="text" value="<?php echo esc_attr($number); ?>" />
</p>
<p>
<label for="<?php echo $this->get_field_id('order'); ?>"><?php _e('显示排序:', 'kratos');?></label>
<select name="<?php echo $this->get_field_name("order"); ?>" id='<?php echo $this->get_field_id("order"); ?>'>
<option value="DESC" <?php echo ($order == 'DESC') ? 'selected' : ''; ?>><?php _e('降序', 'kratos');?></option>
<option value="ASC" <?php echo ($order == 'ASC') ? 'selected' : ''; ?>><?php _e('升序', 'kratos');?></option>
<option value="RAND" <?php echo ($order == 'RAND') ? 'selected' : ''; ?>><?php _e('随机', 'kratos');?></option>
</select>
</p>
</div>
<?php
}
}
class widget_posts extends WP_Widget
{
public function __construct()
{
/* 名字走第 2 参(工单 #23,完整理由与实测见 widget_ad 的注释)。
* 第 1 参写 'posts',保住 option 名 widget_posts —— 线上已有实例。 */
$widget_ops = array(
'description' => __('展示最热、随机、最新文章的工具', 'kratos'),
);
parent::__construct('posts', __('文章聚合', 'kratos'), $widget_ops);
}
public function widget($args, $instance)
{
$number = !empty($instance['number']) ? (int) $instance['number'] : 6;
if ($number < 1) {
$number = 6;
}
/* 🔴 id 唯一化(原来全是写死的)。
* 老代码把同一套标签页导航写了两份 —— 桌面一份带图标(d-none d-xl-flex)、
* 手机一份不带图标(d-xl-none)—— 于是 nav-tab / nav-new-tab /
* nav-hot-tab / nav-random-tab **四个 id 各出现两次**,HTML 非法;
* `aria-labelledby="nav-new-tab"` 也只能指到第一份。
* 挂两个「文章聚合」实例时撞得更彻底。
*
* 现在只渲染**一份**导航:图标用 CSS 在窄屏隐藏(见 kratos.css 的
* `.w-recommended .tab-icon`)。这样 id 天然唯一,DOM 也少一半。 */
$uid = yang_widget_uid($this);
echo '<div class="' . esc_attr(yang_widget_class('w-recommended')) . '">';
yang_widget_title($args, $instance, '', $this->id_base);
?>
<div class="nav nav-tabs" id="<?php echo esc_attr($uid); ?>-tab" role="tablist">
<a class="nav-item nav-link active" id="<?php echo esc_attr($uid); ?>-new-tab" data-toggle="tab" href="#<?php echo esc_attr($uid); ?>-new" role="tab" aria-controls="<?php echo esc_attr($uid); ?>-new" aria-selected="true"><i class="kicon i-tabnew tab-icon"></i><?php _e('最新', 'kratos');?></a>
<a class="nav-item nav-link" id="<?php echo esc_attr($uid); ?>-hot-tab" data-toggle="tab" href="#<?php echo esc_attr($uid); ?>-hot" role="tab" aria-controls="<?php echo esc_attr($uid); ?>-hot" aria-selected="false"><i class="kicon i-tabhot tab-icon"></i><?php _e('热点', 'kratos');?></a>
<a class="nav-item nav-link" id="<?php echo esc_attr($uid); ?>-random-tab" data-toggle="tab" href="#<?php echo esc_attr($uid); ?>-random" role="tab" aria-controls="<?php echo esc_attr($uid); ?>-random" aria-selected="false"><i class="kicon i-tabrandom tab-icon"></i><?php _e('随机', 'kratos');?></a>
</div>
<div class="tab-content" id="<?php echo esc_attr($uid); ?>-tabContent">
<div class="tab-pane fade show active" id="<?php echo esc_attr($uid); ?>-new" role="tabpanel" aria-labelledby="<?php echo esc_attr($uid); ?>-new-tab">
<?php
/* 🔴 原来写的是 `'numberposts=' . $number . ' & offset=0'` ——
* 字符串里带空格,WP_Query 解析时参数名变成 `' offset'`(前面
* 有空格),`offset=0` 实际没生效。offset=0 本身是默认值,
* 所以症状看不出来,但这是错的写法。改成数组传参。 */
$myposts = get_posts(array(
'numberposts' => $number,
'offset' => 0,
));
foreach ($myposts as $post): ?>
<a class="bookmark-item" title="<?php echo esc_attr($post->post_title); ?>" href="<?php echo esc_url(get_permalink($post->ID)); ?>" rel="bookmark"><i class="kicon i-book"></i><?php echo esc_html(wp_strip_all_tags($post->post_title)) ?></a>
<?php endforeach;?>
</div>
<div class="tab-pane fade" id="<?php echo esc_attr($uid); ?>-hot" role="tabpanel" aria-labelledby="<?php echo esc_attr($uid); ?>-hot-tab">
<?php
/* 热点:全站累计热度(views/love/评论 三项 log 加权),
* 不再受「最近 N 天发布」的时间窗限制。原因见 most_comm_posts()
* 上方的注释块。 */
if (function_exists('most_comm_posts')) {
most_comm_posts(0, $number);
} ?>
</div>
<div class="tab-pane fade" id="<?php echo esc_attr($uid); ?>-random" role="tabpanel" aria-labelledby="<?php echo esc_attr($uid); ?>-random-tab">
<?php
$myposts = get_posts(array(
'numberposts' => $number,
'offset' => 0,
'orderby' => 'rand',
));
foreach ($myposts as $post): ?>
<a class="bookmark-item" title="<?php echo esc_attr($post->post_title); ?>" href="<?php echo esc_url(get_permalink($post->ID)); ?>" rel="bookmark"><i class="kicon i-book"></i><?php echo esc_html(wp_strip_all_tags($post->post_title)) ?></a>
<?php endforeach;?>
</div>
</div>
<?php echo '</div>';
}
public function update($new_instance, $old_instance)
{
$instance = array();
$instance['title'] = (!empty($new_instance['title'])) ? sanitize_text_field($new_instance['title']) : '';
$instance['number'] = (!empty($new_instance['number'])) ? (int) $new_instance['number'] : 6;
/* `days` 已废弃:旧实现拿它当「最近 N 天发布」的时间窗,
* 那正是热点只显示一条的根因。这里不再保存,
* 老实例里残留的 days 值会在下次保存时被丢掉。 */
return $instance;
}
public function form($instance)
{
$title = !empty($instance['title']) ? $instance['title'] : '';
$number = !empty($instance['number']) ? $instance['number'] : '6';
?>
<div class="media-widget-control">
<p>
<label for="<?php echo $this->get_field_id('title'); ?>"><?php _e('标题:', 'kratos');?></label>
<input class="widefat" id="<?php echo $this->get_field_id('title'); ?>" name="<?php echo $this->get_field_name('title'); ?>" type="text" value="<?php echo esc_attr($title); ?>">
<span class="description"><?php _e('留空则不显示标题。', 'kratos');?></span>
</p>
<p>
<label for="<?php echo $this->get_field_id('number'); ?>"><?php _e('展示数量:', 'kratos');?></label>
<input class="widefat" id="<?php echo $this->get_field_id('number'); ?>" name="<?php echo $this->get_field_name('number'); ?>" type="text" value="<?php echo esc_attr($number); ?>" />
</p>
<p class="description">
<?php _e('「最新」「随机」「热点」三个标签页都用这个数量。', 'kratos'); ?><br>
<?php _e('「热点」按全站累计热度排序:浏览量、点赞数、评论数三项分别取对数后加权(3 : 5 : 4),不受发布时间限制。', 'kratos'); ?>
</p>
</div>
<?php
}
}
/* ==========================================================================
* 「文章分类」挂件(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 等),见下方说明。
*
* ── 呈现形态:收起一行 + 点击弹出面板(3.2.55 按用户反馈改判)────────────
* 3.2.54 先做成「父级常显 + 子级折叠树」,8 个父级铺开占约 300px 侧栏高度。
* 用户否决:「不点击的话就一行,这样占用的空间小,点击后把所有的一级分类
* 拉出来,类似于之前那样(指原生的 <select>),但样式要好看」。
*
* 所以现在是**真下拉面板**:
* 收起 → 一行触发条(当前分类名,不在分类页时显示「选择分类」+ 箭头)
* 展开 → 浮层面板,`position:absolute` 贴在触发条下方,**不推开下面的挂件**
* 内容 → 默认只列一级分类(本站 9 个);子分类是可选项(默认关)
*
* 🔴 仍然用 `<details>/<summary>` 做开合,**核心逻辑不写 JS**:
* - 不依赖 jQuery/Bootstrap 版本,不会跟主题其它脚本抢事件;
* - 键盘可操作、读屏软件能正确朗读,自带展开/收起语义;
* - 关掉 JS 也能展开、也能点分类跳转(分类导航是基础功能)。
* JS 只做两件 `<details>` 原生没有的事:**点面板外部关闭、按 Esc 关闭**。
* 这段是渐进增强:脚本没跑,功能不缺,只是要再点一次触发条才收起来。
*
* 🔴 为什么面板能做绝对定位浮层而不被裁掉(实测过,不是猜的):
* 链路 `.k-main .sidebar .widget` → `.w-cats` → 面板,`sidebar` 与 `widget`
* 都**没有** `overflow` 声明(`body` 只有 `overflow-x:hidden`,不影响纵向),
* 所以面板可以溢出挂件边界。若将来谁给 `.widget` 加了 `overflow:hidden`,
* 面板会被整块裁掉 —— 这条写在这里就是给那时候看的。
*
* ── 父级数字为什么要 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;
}
if (!isset($by_parent[0])) {
return array();
}
/* 🔴 3.2.56 起改成**任意深度**递归(3.2.55 是写死两层)。
*
* 用户的诉求是「把多级目录也放进去,注意缩进」。本站实测最深
* 就 2 层(8 个一级 + 21 个二级,没有第三级),但"写死两层"
* 的代码在将来有人加第三级时会**静默丢掉**那一层 —— 页面上
* 看不出错,只是分类凭空少了。这种"少显示了但没人报错"的
* 缺陷最难发现,所以这里一次做成递归。
*
* 🔴 递归必须带**已访问集合 + 深度保护**:
* · 数据库里 parent 指针理论上可以成环(手工改库、导入坏数据
* 都能造出来)。没有已访问集合的话,成环 ⇒ 无限递归 ⇒
* PHP 直接栈溢出白屏。侧栏挂件白屏会把整站都带下去。
* · 深度上限兜住其他意外(正常最多几级)。
* 两重保护都只用"跳过"处理,**不抛异常** —— 分类导航是
* 基础功能,宁可少显示一条也不能整站白屏。 */
$max_depth = 10;
$build = function ($parent_id, $depth, $seen) use (&$build, $by_parent, $max_depth) {
if ($depth > $max_depth || !isset($by_parent[$parent_id])) {
return array();
}
$out = array();
foreach ($by_parent[$parent_id] as $c) {
$cid = (int) $c->term_id;
// 成环或重复:跳过,不进入下一层
if (isset($seen[$cid])) {
continue;
}
$seen[$cid] = true;
$out[] = array(
'cat' => $c,
'children' => $build($cid, $depth + 1, $seen),
);
}
return $out;
};
$tree = $build(0, 0, array());
/* 按文章数排序:在 PHP 里按「补过的 count」排,**每一层都排**。
* 用 usort + 显式 tie-break(count 相同时按名称),
* 否则相等项的先后顺序取决于 usort 的实现,每次请求可能不同。
*
* 🔴 这里必须递归到所有层 —— 3.2.55 只排了前两层,第三层往后
* 永远保持 get_categories 给的顺序,与「按文章数排」不一致。 */
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);
};
$sort_level = function (&$nodes) use (&$sort_level, $cmp) {
usort($nodes, function ($x, $y) use ($cmp) {
return $cmp($x['cat'], $y['cat']);
});
foreach ($nodes as $i => $n) {
if (!empty($n['children'])) {
$sort_level($nodes[$i]['children']);
}
}
};
$sort_level($tree);
}
return $tree;
}
/**
* 在树里按 term_id 找分类名(任意深度)。
*
* 🔴 单独抽出来,是为了让「触发条文案」和「面板渲染」用**同一套**
* 遍历逻辑。3.2.55 那版是两段各自硬编码两层的循环 —— 一旦树的
* 层数变了,两处必须同时改,漏一处就会「面板里有、触发条上找不到」。
*
* 找不到返回空串(调用方据此显示占位文案「选择分类」)。
*
* @param array $tree get_tree() 的返回值
* @param int $cat_id
* @return string
*/
private function find_name(array $tree, $cat_id)
{
foreach ($tree as $node) {
if ((int) $node['cat']->term_id === (int) $cat_id) {
return (string) $node['cat']->name;
}
if (!empty($node['children'])) {
$got = $this->find_name($node['children'], $cat_id);
if ($got !== '') {
return $got;
}
}
}
return '';
}
/**
* 递归渲染一层分类(任意深度)。
*
* 每层的 DOM 完全一样:
*
* <li class="yang-cats-item" style="--cat-d: N">
* <a class="yang-cats-link">名字</a>
* <span class="yang-cats-count">N</span>
* <ul class="yang-cats-children"> …下一层… </ul>
* </li>
*
* 🔴 下一层的 `<ul>` 必须放在**本层 li 里面**,不能放在 li 外面当兄弟:
* HTML 里 `<ul>` 的直接子元素只能是 `<li>`,写成
* `<ul><li/><ul/><li/></ul>` 是非法结构,浏览器容错行为不可依赖。
*
* 🔴 缩进靠**逐层累加的左内边距**:
* li 是 `display:flex; flex-wrap:wrap`
* 下一层 ul 用 `flex: 0 0 100%` 独占一行(不进 flex 主轴)
* ⇒ 嵌套 ul 落在父级 li 的**内容盒**内,天然右边界一致
* ⇒ 所以每层 li 的 padding-left 相加就是缩进,且**数字右沿
* 始终对齐到面板右侧同一列**(数字要竖着比大小,不能错开)
*
* 3.2.55 只做两层,靠 `<ul padding-left>` 做缩进,导致子级数字比
* 父级多缩进 12px(那次修的是把子级 li 的 padding-right 归零)。
* 改成"逐层 li padding-left、padding-right 只在顶层给"之后,
* 这个对齐关系对**任意层数**都成立。
*
* @param array $nodes 本层节点
* @param int $current_id 当前分类页的 term_id(0 = 不在分类页)
* @param array $ancestors 当前分类的祖先 id 链
* @param bool $show_count 是否显示文章数
* @param int $depth 当前层深度(0 = 顶级)
*/
private function render_level(array $nodes, $current_id, array $ancestors, $show_count, $depth = 0)
{
if (count($nodes) === 0) {
return;
}
echo $depth === 0
? '<ul class="yang-cats-list">'
: '<ul class="yang-cats-children">';
foreach ($nodes as $node) {
$cat = $node['cat'];
$cid = (int) $cat->term_id;
$kids = isset($node['children']) ? $node['children'] : array();
$cls = 'yang-cats-item';
if ($current_id === $cid) {
$cls .= ' current-cat';
} elseif (in_array($cid, $ancestors, true)) {
/* 当前在看这个父级底下的子分类 —— 父级挂 current-cat-parent,
* 与核心 Walker_Category 的类名保持一致,便于写样式。
* 这条要**对每一层都生效**(3.2.55 只对第一层生效)。 */
$cls .= ' current-cat-parent';
}
if (count($kids) > 0) {
$cls .= ' has-children';
}
echo '<li class="' . esc_attr($cls) . '" style="--cat-d:' . $depth . '">';
echo '<a class="yang-cats-link" href="' . esc_url(get_category_link($cid)) . '">'
. $this->name_html($cat->name) . '</a>';
if ($show_count) {
echo '<span class="yang-cats-count">' . (int) $cat->count . '</span>';
}
/* 下一层直接接在 li 内部(合法结构),由 CSS 的 flex-wrap 让它独占一行 */
if (count($kids) > 0) {
$this->render_level($kids, $current_id, $ancestors, $show_count, $depth + 1);
}
echo '</li>';
}
echo '</ul>';
}
public function widget($args, $instance)
{
$show_count = $this->pick($instance, 'count', true);
$hide_empty = $this->pick($instance, 'hide_empty', true);
/* 🔴 children 默认值 3.2.56 改回 **true**(3.2.55 是 false)。
*
* 3.2.55 时用户的诉求是「点击后把所有**一级**分类拉出来」,
* 所以默认只列一级。2026-10-27 追加要求:「把多级目录也放进去,
* 现在只显示第一级,把其他的也放进去,注意缩进」⇒ 默认改成
* 列出**所有层级**(递归渲染,缩进表达层级)。
*
* 线上那个实例 `categories-3` 是核心老配置,压根没有 children
* 这个键 ⇒ 走 fallback ⇒ **改兜底值就等于改线上呈现**,用户
* 不用进后台勾一下,也不用动数据库。这是本挂件最省事的杠杆。
*
* 仍然保留开关:侧栏分类特别多时,可以在后台关掉只留一级。 */
$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。
*
* 只在 is_category() 时取,空串表示"不在分类页" ⇒ 触发条显示占位文案。
* 父级分类被访问时,$current_id 就是那个父级(不是它的子级),
* 所以不必再往祖先链上找。 */
$current_name = '';
if ($current_id > 0) {
$current_name = $this->find_name($tree, $current_id);
}
/* 面板 DOM id:一页上可能有多个实例(虽然实际很少),必须唯一,
* 否则 `<summary>` 里若要写 aria-controls 会指到第一个。
* 用 yang_widget_uid() 拿 `widget_categories-3` 这类稳定串。 */
$panel_id = yang_widget_uid($this) . '-panel';
echo '<details class="yang-cats-drop">';
/* 🔴 触发条用 <summary>,**里面不放 <a>**。
*
* 3.2.54 那版把父级链接塞进 <summary> 里靠"<a> 是 interactive
* content 所以不触发展开"来分工(实测确实可行)。但这一版
* <summary> 的职责变了:它整条就是"开合开关",不该再带导航。
* 把 <a> 放进来只会让"点空白=开合、点文字=跳转"这种
* 半吊子分工回来,反而更难用。
*
* 所以这里纯文本 + 箭头,箭头用 CSS 三角(不依赖字体图标)。 */
echo '<summary class="yang-cats-trigger">';
echo '<span class="yang-cats-label">'
. ($current_name !== ''
? $this->name_html($current_name)
: esc_html__('选择分类', 'kratos'))
. '</span>';
echo '<span class="yang-cats-caret" aria-hidden="true"></span>';
echo '</summary>';
echo '<div class="yang-cats-panel" id="' . esc_attr($panel_id) . '">';
/* 🔴 3.2.56:子分类从「两层平铺」改成**递归渲染所有层级**。
*
* 用户 2026-10-27 的要求:「把多级目录也放进去,现在只显示第一级,
* 把其他的也放进去,注意缩进」。
*
* ⇒ 面板里现在一次列全:一级 → 二级 → 三级(有几层列几层),
* 点开一次就能看到全部,不用逐层点。
*
* 🔴 缩进**不是**靠 `--cat-d` 变量算的(那样每层都要写一条
* 偏移量规则,层数一变就得改 CSS)。缩进靠**嵌套 + 逐层
* padding-left** 自然累加:`.yang-cats-children` 用
* `flex: 0 0 100%` 独占一行、嵌在父级 li 的内容盒里,
* 所以每层 li 的 padding-left 相加就是缩进,右边界还一致。
* `--cat-d` 只用来做两件事:① 标记真实深度供第 4 层起
* 缩进收敛(`[style*="--cat-d:3"]` 起);② 给测试读。
* **它不参与算缩进**,写错了也不会导致布局错位。
*
* 🔴 仍然保留 `children` 开关(默认已改回 **true**):
* 有人侧栏分类多,全列出来面板太长要滚很久,这时可以关掉
* 只留一级。关掉时不是"少一层",而是**只列一级**。
*
* 🔴 关掉时的实现不是另写一份循环,而是把每个节点的 children
* 清空后交给同一个渲染函数 —— 两份循环必然会漂移(3.2.55
* 就是两段各自硬编码两层),一处改了另一处忘改就出怪现象。 */
if ($show_children) {
$this->render_level($tree, $current_id, $current_ancestors, $show_count);
} else {
$roots = array();
foreach ($tree as $node) {
$node['children'] = array();
$roots[] = $node;
}
$this->render_level($roots, $current_id, $current_ancestors, $show_count);
}
echo '</div>';
echo '</details>';
echo '</div>';
/* ── 渐进增强:点面板外部 / 按 Esc 收起 ──────────────────────────
*
* 🔴 为什么这段必须写在挂件里而不是 kratos.js:
* 面板是 `<details>`,只有挂件知道它在哪;写进主脚本就得靠类名
* 全局查询,一旦页面上没有这个挂件就是一次空查询 + 一个常驻事件
* 监听。挂在挂件输出处,没挂件就没这段代码。
*
* 🔴 为什么用 `once` 标志:挂件可以有多个实例,每实例输出一段脚本,
* 全局 document 上的监听会被装 N 次。用 window 上的标志位保证
* 只装一次(监听器内部用 `closest('.w-cats')` 定位到自己)。
*
* 🔴 为什么不用内联 onclick 属性:CSP 里 `unsafe-inline` 一收紧
* 就全废。这里用 addEventListener,属性里只有 data-* 标记。
*
* 🔴 无 JS 时行为:点触发条展开、点分类跳转都正常,只是
* "点外部不自动收起"。功能不缺,所以不写成必要条件。 */
echo '<script>'
. '(function(){'
/* 已经装过一次就直接退出(多实例场景) */
. 'if(window.__yangCatsBound){return;}window.__yangCatsBound=1;'
/* 点外部关闭:只处理"点到的不是这个 details 内部"的情况 */
. 'document.addEventListener("click",function(e){'
. 'var t=e.target;'
. 'var list=document.querySelectorAll(".yang-cats-drop[open]");'
. 'for(var i=0;i<list.length;i++){'
. 'if(!list[i].contains(t)){list[i].removeAttribute("open");}'
. '}'
. '},false);'
/* Esc 关闭:键盘用户 */
. 'document.addEventListener("keydown",function(e){'
. 'if(e.key!=="Escape"&&e.keyCode!==27){return;}'
. 'var list=document.querySelectorAll(".yang-cats-drop[open]");'
. 'for(var i=0;i<list.length;i++){'
. 'list[i].removeAttribute("open");'
. 'var s=list[i].querySelector(".yang-cats-trigger");'
. 'if(s&&s.focus){s.focus();}'
. '}'
. '},false);'
. '})();'
. '</script>';
}
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);
// 与 widget() 保持同一默认值(理由见 widget() 里的注释)
$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)
*
* ── 为什么自己写一个 ────────────────────────────────────────────────
* WP 原生的 `WP_Widget_Recent_Comments` 在本主题里**已经被注销**
* (见文件顶部 widgets_init() 的 unregister_widget 列表)—— 它输出的是
* 裸 `<ul><li>` + 一条 `sprintf` 拼出来的长句,跟侧栏视觉完全不搭。
*
* 之前站点用的是插件 Comments Widget Plus(`comments-widget-plus`),
* 之所以「丑」,实测有三条硬证据:
*
* ① **中文语序断裂**。插件用一条 `_x('%1$s %2$son%3$s %4$s')` 拼句子,
* 英文读作「作者 on 文章标题」,中文渲染成「<标题>上的<作者>」,
* 而且插件**根本没带 zh_CN 语言包**(languages/ 只有一个 .pot)。
* ② **三层信息同为 16px**。文章标题 / 作者 / 摘要字号一样,只能靠颜色
* 区分;而文章标题最长(实测单条占 96px 高、标题折 2 行 50px),
* 把真正的评论内容挤到最下面。
* ③ **默认关头像**。纯文字流,视觉上没有抓手。
*
* ── 本实现的设计取舍 ────────────────────────────────────────────────
* · 信息结构改成「摘要(主体)→ 元信息行(作者 · 时间 · 文章)」,
* 不再把三件事塞进一个句子 —— 这样中文语序问题从根上消失。
* · 头像默认开、圆形、`object-fit:cover`,用 flex 而非插件的 `float:left`
* (float 在长文本下会出现文字环绕错位)。
* · 默认 5 条(插件默认 10 条,实测占满 992px 高,把侧栏撑得没有尽头)。
* · **不做缓存层**。插件那套 `wp_cache_get('cwp_widget','widget')` +
* 三个 add_action 清缓存有两个问题:缓存键是全局的(挂两个实例会互相
* 污染),而 wp_comments 上 comment_approved_date_gmt 有索引,
* 取 5 条的代价可以忽略。少一层缓存 = 少一类"改了不生效"的 bug。
* ====================================================================== */
class widget_recent_comments extends WP_Widget
{
public function __construct()
{
/* 名字只走第 2 参(工单 #23,理由见 widget_ad 的注释)。
*
* 3.2.52 这里曾同时写了两处:`$widget_ops` 里带 'name',第 2 参也传名字。
* 两处一致时不会出问题,但配两处会让"名字到底在哪配"产生歧义。
* 统一成第 2 参:这是 WP 文档写法,也不用依赖
* `wp_register_sidebar_widget()` 里 `array_merge($widget, $options)`
* 那层覆盖 —— 覆盖确实管用,但读代码的人得追进去才知道。 */
$widget_ops = array(
'description' => __('带头像与摘要的最近评论,替代 Comments Widget Plus 插件', 'kratos'),
);
parent::__construct('yang_recent_comments', __('最近评论', 'kratos'), $widget_ops);
}
/**
* 取实例字段;没填就用默认值。
*
* 🔴 判空一律用「trim 后是不是空串」,不用 empty():
* `!empty('0')` 是 false ⇒ 摘要字数填 0、或者标题就叫「0」会被当没填。
* 与 widget_about::pick() 保持同一套判据(3.2.51 的教训)。
*
* @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 没勾时根本不会提交,所以「缺键 = 关」
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;
}
/**
* 把评论内容压成单行摘要。
*
* 🔴 为什么不用 wp_html_excerpt():那个函数按**字节**截断
* (`mb_substr` 只在 WP 6.x 之后才在部分场景用),中文会被切出半个字。
* 这里用 mb_substr 按**字符**截,并显式带省略号。
* 🔴 为什么先 wp_strip_all_tags 再压缩空白:评论允许 HTML,
* 摘要里出现 `<blockquote>` 之类会把侧栏排版打乱。
*
* @param string $content 原始评论内容
* @param int $limit 保留多少个字符
* @return string
*/
private function excerpt($content, $limit)
{
$s = wp_strip_all_tags((string) $content);
// 换行/制表/连续空格统一压成一个空格,否则摘要是多行的
$s = preg_replace('/\s+/u', ' ', $s);
$s = trim((string) $s);
if ($s === '' || $limit <= 0) {
return $s;
}
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
if (mb_strlen($s, 'UTF-8') > $limit) {
return rtrim(mb_substr($s, 0, $limit, 'UTF-8')) . '…';
}
return $s;
}
// 没有 mbstring 时的兜底:按字节截,但避免切出半个 UTF-8 字符
if (strlen($s) > $limit) {
$cut = substr($s, 0, $limit);
// 把末尾不完整的字节序列削掉
$cut = preg_replace('/[\x80-\xBF]+$/', '', $cut);
return rtrim($cut) . '…';
}
return $s;
}
public function widget($args, $instance)
{
$number = $this->pick($instance, 'number', 5);
if ($number < 1) {
$number = 5;
}
if ($number > 20) {
$number = 20;
}
$show_avatar = $this->pick($instance, 'show_avatar', true);
$avatar_size = $this->pick($instance, 'avatar_size', 40);
$show_excerpt = $this->pick($instance, 'show_excerpt', true);
$excerpt_length = $this->pick($instance, 'excerpt_length', 40);
$order = $this->pick($instance, 'order', 'DESC');
$exclude_pings = $this->pick($instance, 'exclude_pings', true);
/* 🔴 post_type 不能走 pick()。
*
* pick() 的规则是「trim 后是空串 ⇒ 回落 fallback」。而「全部类型」
* 这个选项本身就要用空语义表达,于是:
* 用户选「全部类型」→ update() 存 '' →
* pick() 认为"没填" → 回落 'post' → 只显示文章评论。
* 后台显示已选中、保存也成功,前台却看不到页面上的评论 ——
* 又是一处"静默失效"。
*
* 解法两层:
* ① update() 存**哨兵值 `any`**(非空,pick 不会回落);
* ② 这里**直接读原值**,把 '' 与 'any' 都当"全部类型",
* 只有键不存在时才用默认 'post'。
* 两层都做,是因为老实例(若将来有)可能存的是空串。 */
$pt_raw = array_key_exists('post_type', $instance) ? $instance['post_type'] : 'post';
$post_type = is_string($pt_raw) ? trim($pt_raw) : 'post';
$all_types = ($post_type === '' || $post_type === 'any');
/* 夹紧:这里仍需夹一遍,因为老实例(3.2.52 之前存的)里可能有
* 越界值,而 update() 只对"这次保存"生效、不会回头改老数据。 */
if ($order !== 'ASC') {
$order = 'DESC';
}
if ($avatar_size < 16) {
$avatar_size = 16;
}
if ($avatar_size > 96) {
$avatar_size = 96;
}
if ($excerpt_length < 0) {
$excerpt_length = 0;
}
/* 只取审核通过的评论。
*
* 🔴 `'status' => 'approve'` 是必须的:不加的话草稿/垃圾评论会露出来。
* 🔴 `'post_status' => 'publish'` 也是必须的:评论所属文章若被改成
* 草稿/私密,评论仍存在表里,但前台不该展示 —— get_comment_link()
* 对未发布的文章会返回一个 404 链接。
* 🔴 用 WP_Comment_Query 一次取回,不逐条调 get_comment()。 */
$query = array(
'number' => $number,
'status' => 'approve',
'post_status' => 'publish',
'order' => $order,
'orderby' => 'comment_date_gmt',
);
if (!$all_types) {
$query['post_type'] = $post_type;
}
if ($exclude_pings) {
$query['type__not_in'] = array('pingback', 'trackback');
}
if (function_exists('apply_filters')) {
$query = apply_filters('yang_recent_comments_query', $query, $instance, $this->id_base);
}
$comments = get_comments($query);
// 🔴 没评论就整块不输出 —— 不留下"标题 + 空 ul"的空壳
if (!is_array($comments) || count($comments) === 0) {
return;
}
echo '<div class="' . esc_attr(yang_widget_class('w-comments')) . '">';
yang_widget_title($args, $instance, __('最近评论', 'kratos'), $this->id_base);
echo '<ul class="yang-comment-list">';
foreach ($comments as $comment) {
$link = get_comment_link($comment->comment_ID);
$author = get_comment_author($comment->comment_ID);
$post_title = get_the_title($comment->comment_post_ID);
if ($post_title === '') {
// 标题为空时(极少见)用「无标题」占位,不留空白
$post_title = __('无标题', 'kratos');
}
echo '<li class="yang-comment-item">';
// ── 头像(左侧,圆形)──
if ($show_avatar) {
echo '<a class="yang-comment-avatar" href="' . esc_url($link) . '" rel="nofollow" aria-hidden="true" tabindex="-1">';
echo get_avatar($comment->comment_author_email, $avatar_size, '', '', array(
'class' => 'avatar yang-avatar',
'extra_attr' => 'loading="lazy"',
));
echo '</a>';
}
echo '<div class="yang-comment-body">';
// ── 主体:评论摘要 ──
if ($show_excerpt) {
$text = $this->excerpt($comment->comment_content, $excerpt_length);
if ($text !== '') {
/* 整段链接到该评论锚点 —— 侧栏空间小,用户更容易点中的
* 是"这段文字"而不是下方小字。 */
echo '<a class="yang-comment-text" href="' . esc_url($link) . '" rel="nofollow">' . esc_html($text) . '</a>';
}
} else {
/* 关掉摘要时,主体的位置留给「作者 + 文章」,
* 否则整条会变成只有一行小字,视觉上太轻。 */
echo '<a class="yang-comment-text" href="' . esc_url($link) . '" rel="nofollow">'
. esc_html(sprintf(
/* translators: 1: comment author, 2: post title */
__('%1$s 评论了《%2$s》', 'kratos'),
$author,
$post_title
))
. '</a>';
}
/* ── 元信息行:作者 · 时间 · 文章标题 ──
*
* 🔴 这里**不拼成句子**(不像插件那样塞一个 "on"):
* 中文里「作者 在 文章 中评论」这种句式一旦走 i18n 字符串,
* 语序、量词、标点全都要重新适配;拆成三段各自独立,
* 任何语言下都不会错位。 */
echo '<div class="yang-comment-meta">';
echo '<span class="yang-comment-author">' . esc_html($author) . '</span>';
echo '<span class="yang-comment-sep" aria-hidden="true">·</span>';
echo '<time class="yang-comment-time" datetime="' . esc_attr(get_comment_date('c', $comment->comment_ID)) . '">'
. esc_html(function_exists('human_time_diff')
? sprintf(__('%s前', 'kratos'), human_time_diff(strtotime($comment->comment_date_gmt . ' GMT')))
: get_comment_date('', $comment->comment_ID))
. '</time>';
echo '<span class="yang-comment-sep" aria-hidden="true">·</span>';
echo '<a class="yang-comment-post" href="' . esc_url(get_permalink($comment->comment_post_ID)) . '" title="' . esc_attr($post_title) . '">'
. esc_html($post_title) . '</a>';
echo '</div>';
echo '</div>'; // .yang-comment-body
echo '</li>';
}
echo '</ul>';
echo '</div>';
}
public function update($new_instance, $old_instance)
{
$instance = array();
$instance['title'] = isset($new_instance['title'])
? sanitize_text_field(trim((string) $new_instance['title']))
: '';
// 数字:非法就落回默认,不要存 0 或空串(空串会让 pick 回落,
// 但存成 0 会被当成"用户要 0 条")
$num = isset($new_instance['number']) ? trim((string) $new_instance['number']) : '';
$instance['number'] = ($num !== '' && preg_match('/^-?\d+$/', $num)) ? (int) $num : 5;
$size = isset($new_instance['avatar_size']) ? trim((string) $new_instance['avatar_size']) : '';
$instance['avatar_size'] = ($size !== '' && preg_match('/^-?\d+$/', $size)) ? (int) $size : 40;
/* 🔴 存的时候就夹到 16~96。
* 只在 widget() 里夹(前端夹)不够:后台表单会原样显示一个
* -5,用户以为存进去了,而前台用的是 16 —— 两边不一致。
* 这里夹住,后台看到的和前台用到的才是同一个值。 */
if ($instance['avatar_size'] < 16) {
$instance['avatar_size'] = 16;
}
if ($instance['avatar_size'] > 96) {
$instance['avatar_size'] = 96;
}
$elen = isset($new_instance['excerpt_length']) ? trim((string) $new_instance['excerpt_length']) : '';
$instance['excerpt_length'] = ($elen !== '' && preg_match('/^-?\d+$/', $elen)) ? (int) $elen : 40;
if ($instance['excerpt_length'] < 0) {
$instance['excerpt_length'] = 0;
}
$instance['order'] = (isset($new_instance['order']) && $new_instance['order'] === 'ASC') ? 'ASC' : 'DESC';
/* post_type:'' 与 'any' 都是「全部类型」,统一存成哨兵值 'any'。
* 🔴 不能存空串 —— 空串在 pick() 里等于"没填",会回落成 'post',
* 用户选了「全部类型」却只看到文章评论(静默失效)。 */
$pt = isset($new_instance['post_type']) ? sanitize_key((string) $new_instance['post_type']) : '';
$instance['post_type'] = ($pt === '' || $pt === 'any') ? 'any' : $pt;
// checkbox:没提交就是关。这两项必须显式写 false,
// 否则老值会残留(update 返回的数组是全新的,不写就是丢键,
// 而丢键会让 pick 拿到 fallback=true —— 用户取消勾选无效)
$instance['show_avatar'] = !empty($new_instance['show_avatar']);
$instance['show_excerpt'] = !empty($new_instance['show_excerpt']);
$instance['exclude_pings'] = !empty($new_instance['exclude_pings']);
return $instance;
}
public function form($instance)
{
$title = isset($instance['title']) ? (string) $instance['title'] : '';
$number = $this->pick($instance, 'number', 5);
$show_avatar = $this->pick($instance, 'show_avatar', true);
$avatar_size = $this->pick($instance, 'avatar_size', 40);
$show_excerpt = $this->pick($instance, 'show_excerpt', true);
$excerpt_length = $this->pick($instance, 'excerpt_length', 40);
$order = $this->pick($instance, 'order', 'DESC');
$exclude_pings = $this->pick($instance, 'exclude_pings', true);
/* 🔴 post_type 在这里**也不能走 pick()**(和 widget() 同一个理由,
* 而且这里更隐蔽):pick() 把空串当"没填"回落 'post',
* 于是 form() 里 $all_types 变成 false ⇒ 后台下拉框把
* 「全部类型」显示成「文章」;而 widget() 那边把空串当
* 「全部类型」⇒ **前后台显示与行为不一致**。
* 用户看到后台选的是"文章",前台却显示页面的评论 —— 无法解释。
* 所以两边都直接读原值,判据也要求两处都不出现 pick(...'post_type'...)。 */
$pt_raw = array_key_exists('post_type', $instance) ? $instance['post_type'] : 'post';
$post_type = is_string($pt_raw) ? trim($pt_raw) : 'post';
/* 「全部类型」的两个入口都收:
* · 'any' —— 3.2.52 起的哨兵值(update() 存的)
* · '' —— 3.2.52 之前若有人存过空串
* 渲染时用 $all_types,别再去比对 $post_type 的字面量。 */
$all_types = ($post_type === '' || $post_type === 'any');
$types = get_post_types(array('public' => true), 'objects');
?>
<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>
<label for="<?php echo esc_attr($this->get_field_id('number')); ?>"><?php _e('显示条数:', 'kratos'); ?></label>
<input class="widefat" id="<?php echo esc_attr($this->get_field_id('number')); ?>" name="<?php echo esc_attr($this->get_field_name('number')); ?>" type="number" step="1" min="1" max="20" value="<?php echo esc_attr($number); ?>">
<span class="description"><?php _e('1 ~ 20 条,建议 5 条。', 'kratos'); ?></span>
</p>
<p>
<input class="checkbox" type="checkbox" id="<?php echo esc_attr($this->get_field_id('show_avatar')); ?>" name="<?php echo esc_attr($this->get_field_name('show_avatar')); ?>" <?php checked($show_avatar); ?>>
<label for="<?php echo esc_attr($this->get_field_id('show_avatar')); ?>"><?php _e('显示头像', 'kratos'); ?></label>
</p>
<p>
<label for="<?php echo esc_attr($this->get_field_id('avatar_size')); ?>"><?php _e('头像尺寸(px):', 'kratos'); ?></label>
<input class="widefat" id="<?php echo esc_attr($this->get_field_id('avatar_size')); ?>" name="<?php echo esc_attr($this->get_field_name('avatar_size')); ?>" type="number" step="1" min="16" max="96" value="<?php echo esc_attr($avatar_size); ?>">
<span class="description"><?php _e('16 ~ 96,建议 40。', 'kratos'); ?></span>
</p>
<p>
<input class="checkbox" type="checkbox" id="<?php echo esc_attr($this->get_field_id('show_excerpt')); ?>" name="<?php echo esc_attr($this->get_field_name('show_excerpt')); ?>" <?php checked($show_excerpt); ?>>
<label for="<?php echo esc_attr($this->get_field_id('show_excerpt')); ?>"><?php _e('显示评论摘要', 'kratos'); ?></label>
</p>
<p>
<label for="<?php echo esc_attr($this->get_field_id('excerpt_length')); ?>"><?php _e('摘要字数:', 'kratos'); ?></label>
<input class="widefat" id="<?php echo esc_attr($this->get_field_id('excerpt_length')); ?>" name="<?php echo esc_attr($this->get_field_name('excerpt_length')); ?>" type="number" step="1" min="0" value="<?php echo esc_attr($excerpt_length); ?>">
<span class="description"><?php _e('按字符数截断(中文一个字算一个),建议 40。', 'kratos'); ?></span>
</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="DESC" <?php selected($order, 'DESC'); ?>><?php _e('最新优先', 'kratos'); ?></option>
<option value="ASC" <?php selected($order, 'ASC'); ?>><?php _e('最早优先', 'kratos'); ?></option>
</select>
</p>
<p>
<label for="<?php echo esc_attr($this->get_field_id('post_type')); ?>"><?php _e('文章类型:', 'kratos'); ?></label>
<select class="widefat" id="<?php echo esc_attr($this->get_field_id('post_type')); ?>" name="<?php echo esc_attr($this->get_field_name('post_type')); ?>">
<option value="any" <?php selected($all_types); ?>><?php _e('全部类型', 'kratos'); ?></option>
<?php foreach ($types as $type) : ?>
<option value="<?php echo esc_attr($type->name); ?>" <?php selected(!$all_types && $post_type === $type->name); ?>><?php echo esc_html($type->labels->singular_name); ?></option>
<?php endforeach; ?>
</select>
</p>
<p>
<input class="checkbox" type="checkbox" id="<?php echo esc_attr($this->get_field_id('exclude_pings')); ?>" name="<?php echo esc_attr($this->get_field_name('exclude_pings')); ?>" <?php checked($exclude_pings); ?>>
<label for="<?php echo esc_attr($this->get_field_id('exclude_pings')); ?>"><?php _e('排除 pingback / trackback', 'kratos'); ?></label>
<span class="description"><?php _e('建议勾选:这类"评论"是别的博客的引用通知,不是真人留言。', 'kratos'); ?></span>
</p>
</div>
<?php
}
}
function register_widgets()
{
register_widget('widget_ad');
register_widget('widget_about');
register_widget('widget_tags');
register_widget('widget_search');
register_widget('widget_posts');
register_widget('widget_categories');
register_widget('widget_recent_comments');
}
add_action('widgets_init', 'register_widgets');