Files
wordpress-kratos-yang/inc/theme-widgets.php
T
laoyang baa1d16fd1 3.2.52: 新增「最近评论」挂件(替代 Comments Widget Plus 插件)#22
主题自带一个好看的最近评论挂件,用户不必再装插件。

新挂件 widget_recent_comments(id_base = yang_recent_comments):
- 头像 40px 圆形 + object-fit:cover,带 max-width/max-height 双保险
- 摘要按字符截断(mb_substr),先剥标签再压空白,不走 wp_html_excerpt(按字节切半个汉字)
- 作者 · 相对时间 · 文章标题三段各自成 span,不拼 i18n 句式
- 三层字号:摘要 15px / 作者 14px / 文章 13px(文章字号继承自 meta)
- 条间虚线,最后一条去掉
- 后台九项设置:标题、条数、头像、头像尺寸、摘要、摘要字数、排除 pingback、评论类型、文章类型

实现要点:
- CSS 不依赖 .k-main .sidebar 祖先,只依到 .w-comments 自身
  (3.2.51 的红线:带祖先的选择器在后台「外观→小工具」预览里一条不生效)
- .w-comments 自己不写 padding,交给 widget 基类,避免与其它挂件错位
- 只查 status=approve 且 post_status=publish,草稿文章评论不露出

同时修一个前后台不一致的 bug:
- form() 的 post_type 原来走 pick(),pick() 把空串当"没填"回落 'post'
  ⇒ 老实例(存空串)在后台下拉框显示「文章」,前台却按「全部类型」渲染。
  改为 widget() 与 form() 都直接读原值,两处都识别空串/any 为「全部类型」。

验证:行为 112/0 · 行为反证 25/25 + CSS 10/10 · 几何 50/0 ·
线上真实页面 30/0 · 静态反证 23/0 · 发布脚本 22 组反证全真红。
2026-10-27 08:40:53 +08:00

1418 lines
70 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',
);
foreach ($wp_widget as $wp_widget) {
unregister_widget($wp_widget);
}
}
add_action('widgets_init', 'widgets_init');
/* ==========================================================================
* 后台挂件表单的公共样式与脚本(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()
{
$widget_ops = array(
'name' => __('图片广告', 'kratos'),
'description' => __('显示自定义图片广告的工具', 'kratos'),
);
parent::__construct(false, false, $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()
{
$widget_ops = array(
'name' => __('个人简介', 'kratos'),
'description' => __('可跳转后台的个人简介展示工具', 'kratos'),
);
parent::__construct(false, false, $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()
{
$widget_ops = array(
'name' => __('标签聚合', 'kratos'),
'description' => __('文章标签的展示工具', 'kratos'),
);
parent::__construct(false, false, $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()
{
$widget_ops = array(
'name' => __('文章聚合', 'kratos'),
'description' => __('展示最热、随机、最新文章的工具', 'kratos'),
);
parent::__construct(false, false, $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.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()
{
$widget_ops = array(
'name' => __('最近评论', 'kratos'),
'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_recent_comments');
}
add_action('widgets_init', 'register_widgets');