Files

2216 lines
112 KiB
PHP
Raw Permalink Normal View History

2022-01-07 11:15:42 +08:00
<?php
/**
* 侧栏小工具
* @author Yongquan Yang <yangyq.net>
2022-01-07 11:15:42 +08:00
* @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',
2022-01-07 11:15:42 +08:00
);
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 返回条数
*/
2022-01-07 11:15:42 +08:00
function most_comm_posts($days = 30, $nums = 6)
{
$posts = yang_hot_posts($nums);
2022-01-07 11:15:42 +08:00
$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>';
2022-01-07 11:15:42 +08:00
}
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);
2022-01-07 11:15:42 +08:00
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>';
2022-01-07 11:15:42 +08:00
}
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_ 前缀剥掉),效果一样 —— 写出来是为了不依赖推导。 */
2022-01-07 11:15:42 +08:00
$widget_ops = array(
'description' => __('显示自定义图片广告的工具', 'kratos'),
);
parent::__construct('ad', __('图片广告', 'kratos'), $widget_ops);
2022-01-07 11:15:42 +08:00
}
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>';
2022-01-07 11:15:42 +08:00
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']) : '';
2022-01-07 11:15:42 +08:00
return $instance;
}
public function form($instance)
{
$title = !empty($instance['title']) ? $instance['title'] : '';
2022-01-07 11:15:42 +08:00
$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>
2022-01-07 11:15:42 +08:00
<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 —— 线上已有实例。 */
2022-01-07 11:15:42 +08:00
$widget_ops = array(
'description' => __('可跳转后台的个人简介展示工具', 'kratos'),
);
parent::__construct('about', __('个人简介', 'kratos'), $widget_ops);
2022-01-07 11:15:42 +08:00
}
/**
* 取挂件实例里的某个值;空则返回回退值。
*
* 🔴 为什么不用 `!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)
2022-01-07 11:15:42 +08:00
{
$v = isset($instance[$key]) ? $instance[$key] : '';
if (!is_string($v)) {
return $fallback;
}
$v = trim($v);
return ($v === '') ? $fallback : $v;
2022-01-07 11:15:42 +08:00
}
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'
);
2022-01-07 11:15:42 +08:00
/* 🔴 原来写的是 `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">';
2022-01-07 11:15:42 +08:00
if (current_user_can('manage_options')) {
echo '<a href="' . esc_url(admin_url()) . '">';
2022-01-07 11:15:42 +08:00
} else {
echo '<a href="' . esc_url(wp_login_url()) . '">';
2022-01-07 11:15:42 +08:00
}
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>';
2022-01-07 11:15:42 +08:00
echo '</div>';
}
/**
* 保存实例。
*
* 🔴 不能用 `!empty($new_instance[...])` 判"用户填了没有":
* `!empty('0')` 是 **false** ⇒ 昵称就叫「0」、简介只写「0」的站点
* 保存后会变成空串、前台回落主题设置,而且**后台看起来像没保存成功**。
* 测试 `_about_test.php` 的「昵称『0』能存下来」抓到的就是这个。
* 统一用「转成字符串、trim 后是不是空串」判断,与 pick() 一致。
*/
2022-01-07 11:15:42 +08:00
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');
2022-01-07 11:15:42 +08:00
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)));
}
2022-01-07 11:15:42 +08:00
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');
2022-01-07 11:15:42 +08:00
?>
<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>
2022-01-07 11:15:42 +08:00
<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); ?>">
2022-01-07 11:15:42 +08:00
<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; ?>
2022-01-07 11:15:42 +08:00
</p>
</div>
<?php
}
}
class widget_tags extends WP_Widget
{
public function __construct()
{
/* 名字走第 2 参(工单 #23,完整理由与实测见 widget_ad 的注释)。
* 第 1 参写 'tags',保住 option 名 widget_tags。 */
2022-01-07 11:15:42 +08:00
$widget_ops = array(
'description' => __('文章标签的展示工具', 'kratos'),
);
parent::__construct('tags', __('标签聚合', 'kratos'), $widget_ops);
2022-01-07 11:15:42 +08:00
}
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);
2022-01-07 11:15:42 +08:00
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';
2022-01-07 11:15:42 +08:00
return $instance;
}
public function form($instance)
{
global $wpdb;
$title = !empty($instance['title']) ? $instance['title'] : '';
2022-01-07 11:15:42 +08:00
$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>
2022-01-07 11:15:42 +08:00
<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 —— 线上已有实例。 */
2022-01-07 11:15:42 +08:00
$widget_ops = array(
'description' => __('展示最热、随机、最新文章的工具', 'kratos'),
);
parent::__construct('posts', __('文章聚合', 'kratos'), $widget_ops);
2022-01-07 11:15:42 +08:00
}
public function widget($args, $instance)
{
$number = !empty($instance['number']) ? (int) $instance['number'] : 6;
if ($number < 1) {
$number = 6;
}
2022-01-07 11:15:42 +08:00
/* 🔴 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);
2022-01-07 11:15:42 +08:00
?>
<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>
2022-01-07 11:15:42 +08:00
</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>
2022-01-07 11:15:42 +08:00
<?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);
} ?>
2022-01-07 11:15:42 +08:00
</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>
2022-01-07 11:15:42 +08:00
<?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;
2022-01-07 11:15:42 +08:00
/* `days` 已废弃:旧实现拿它当「最近 N 天发布」的时间窗,
* 那正是热点只显示一条的根因。这里不再保存,
* 老实例里残留的 days 值会在下次保存时被丢掉。 */
2022-01-07 11:15:42 +08:00
return $instance;
}
public function form($instance)
{
$title = !empty($instance['title']) ? $instance['title'] : '';
2022-01-07 11:15:42 +08:00
$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>
2022-01-07 11:15:42 +08:00
<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'); ?>
2022-01-07 11:15:42 +08:00
</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
}
}
2022-01-07 11:15:42 +08:00
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');
2022-01-07 11:15:42 +08:00
}
add_action('widgets_init', 'register_widgets');