Files
wordpress-kratos-yang/inc/theme-widgets.php
T
laoyang c1dde099d7 「个人简介」挂件:头像/昵称/简介/背景四项可单独填 + 修大图撑爆 #21
一、头像 / 昵称 / 简介原来**只读主题设置**,挂件表单里没有字段
  · 用户点开侧栏「个人简介」只能改背景图,头像和那句话得跑去
    「主题设置 → 站长信息」改全站共用的那份。
  · 现在四项都是挂件实例字段:avatar / nickname / introduce / background。
  · 取值顺序:实例 → 主题设置 → 内置兜底,**留空即回落**,
    老站点升级后显示一点不变。
  · pick() 判空统一用「trim 后是不是空串」,不用 empty()
    (!empty('0') 是 false,昵称就叫「0」会被当没填)。
  · 简介用 sanitize_textarea_field(保留换行),URL 用 esc_url_raw
    (不做 HTML 实体化,避免与 esc_attr 双重转义)。

二、后台 form() 补齐五个字段
  · 头像 / 背景各带「选择图片」按钮(调媒体库),背景另有「清除」。
  · 输入框显示用户填的原始值,缩略图显示前台最终会用的那张,刻意分开。
  · 资源加载统一到 yang_widget_admin_assets(),不再每个挂件各挂一次。

三、大图撑爆挂件的真因:CSS 选择器依赖三层祖先
  · 老规则是 `.k-main .sidebar .w-about .wrapper img`,而后台
    「外观 → 小工具」预览里没有 `.k-main .sidebar` 这层祖先,
    规则一条都不生效 ⇒ 头像按原图 2136×2136 铺开、背景塌成 0。
    前台看着正常是因为前台恰好有那层祖先,问题被完全掩盖。
  · 改为只管 `.w-about` 内部元素,并补 max-width/max-height 双保险。
  · 老规则必须**删掉**而不是并存:它是 (0,3,1),新规则是 (0,2,1),
    并存时老规则在前台永远赢,新规则只对后台生效。
  · 保留 `.k-main .sidebar .w-about { padding:0 }`:通用规则
    `.k-main .sidebar .widget` 是 (0,3,0),少了这条会多出内边距、整体高 57px。
  · 头像保持 inline(display:block 会让它脱离 text-align:center 的居中)。
  · 顺带:object-fit 从 fill 改 cover(超宽图不再压扁);
    背景 url() 补引号(文件名带空格时不再整条声明作废)。

验证
  · 行为测试 67 条全通过;反向验证 13 组全部真红。
  · 浏览器几何 76 条全通过(前台/后台 × 四种极端原图)。
  · 修复前 CSS 跑同一套判据:38 条报红,精确复现用户症状
    (后台挂件高 2217、头像 2136×2136、横向滚动 2146)。
  · 线上真实页面 + 新版 CSS:16 条全通过。
2026-10-26 23:51:20 +08:00

986 lines
48 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
}
}
function register_widgets()
{
register_widget('widget_ad');
register_widget('widget_about');
register_widget('widget_tags');
register_widget('widget_search');
register_widget('widget_posts');
}
add_action('widgets_init', 'register_widgets');