* @license MIT License * @version 2020.08.03 */ // 添加小工具 function widgets_init() { register_sidebar(array( 'name' => __('侧边栏工具', 'kratos'), 'id' => 'sidebar_tool', 'before_widget' => '
', 'after_widget' => '
', 'before_title' => '
', 'after_title' => '
', )); // 去掉默认小工具 $wp_widget = array( 'WP_Widget_Pages', 'WP_Widget_Meta', 'WP_Widget_Recent_Posts', 'WP_Widget_Recent_Comments', 'WP_Widget_RSS', 'WP_Widget_Search', 'WP_Widget_Tag_Cloud', 'WP_Nav_Menu_Widget', /* 3.2.54 / 工单 #24:分类挂件由主题接管(widget_categories, * id_base 沿用 'categories',理由见该类的注释)。 * 🔴 为什么必须在这里注销核心的那个: * `WP_Widget_Factory::register()` 是按**类名**存的,不是按 id_base。 * 只 register 自己的类,核心 `WP_Widget_Categories` 仍在工厂里, * 后台「可用挂件」列表会同时出现「分类」和「文章分类」两项 —— * 用户根本分不清该拖哪个。注销掉核心的,列表里只剩一个。 * 注销不影响存量实例:`_register_widgets()` 是按 id_base 读 * option `widget_categories`,本类 id_base 相同,照样认领全部实例。 * 执行顺序没问题:本函数挂在 `widgets_init` 优先级 10,而工厂的 * `_register_widgets` 挂在优先级 100(wp-includes/widgets.php), * 所以「先注销 + 再注册」一定在真正注册实例之前完成。 */ 'WP_Widget_Categories', ); foreach ($wp_widget as $wp_widget) { unregister_widget($wp_widget); } } add_action('widgets_init', 'widgets_init'); /* ========================================================================== * 后台「外观 → 小工具」用传统拖放面板,不用区块编辑器(3.2.53 / 工单 #23) * * ── 为什么要关 ────────────────────────────────────────────────────── * WP 5.8 起把后台小工具页默认换成了区块编辑器(`wp-admin/widgets-form-blocks.php`)。 * 它**没有"从左边拖到右边"这个动作**,只能点「+」再搜索挂件名,操作方式 * 与传统面板差异很大。用户的实际反馈就是「找不到可以拖动的地方」。 * * 那挂件名字有没有问题?实测没有 —— 4 个挂件虽然写的是 * `parent::__construct(false, false, $widget_ops)`(`$widget->name` 确实是 * false),但名字放在 `$widget_ops['name']` 里,经 * `wp_register_sidebar_widget()` 的 `array_merge($widget, $options)` * 覆盖后,`$wp_registered_widgets[...]['name']` 一直是正常中文名。 * 实测结论(老代码):`$widget->name` = false, * `$wp_registered_widgets['ad-1']['name']` = '图片广告'。 * **所以"找不到挂件"与名字无关,只是区块编辑器没有拖放。** * (这一度被我误判成名字为空,记一次教训:见 tests 里的说明。) * * ── 为什么这个钩子名是 `use_widgets_block_editor` ─────────────────── * WP 7.1.3 的 `wp_use_widgets_block_editor()` 只认这一个 filter: * * return apply_filters('use_widgets_block_editor', * get_theme_support('widgets-block-editor')); * * 网上常见的 `gutenberg_use_widgets_block_editor` / `widgets_block_editor_disabled` * 在 7.1.3 里**一处都不存在**(`grep -rn` 查过 wp-admin/ 与 wp-includes/ 全无命中), * 写了也是白写。这里按实际源码来。 * * ── 顺带把主题声明也去掉 ──────────────────────────────────────────── * 只挂 filter 也能生效,但 `get_theme_support('widgets-block-editor')` 仍返回 true, * 语义上"主题声称支持区块小工具"却又不让用,容易误导后来人。 * 所以在 after_setup_theme 里 `remove_theme_support` —— 必须挂在这个钩子上: * 主题的 `add_theme_support` 与 `functions.php` 的 require 同在 after_setup_theme * 早期跑,直接写 `remove_theme_support` 会因为"还没 add"而静默失败。 * * 🔴 如果哪天决定改用区块界面,把这两段一起删掉即可 —— 但**必须先确认 * 4 个挂件的名字都补上了**,否则会退回"找不到挂件"的老问题。 * ====================================================================== */ add_filter('use_widgets_block_editor', '__return_false'); function yang_drop_widgets_block_editor_support() { remove_theme_support('widgets-block-editor'); } add_action('after_setup_theme', 'yang_drop_widgets_block_editor_support', 20); /* ========================================================================== * 后台挂件表单的公共样式与脚本(3.2.51) * * ── 为什么不再各个挂件自己挂一份 ──────────────────────────────────── * 原来 `widget_about` / `widget_ad` 各自在构造函数里 add_action 一个 * `admin_enqueue_scripts`,动作里 enqueue 媒体库 + widget.min.js。 * 这套写法有两个毛病: * * ① 两个挂件都启用时,同一份资源被 enqueue 两次(id 相同,WP 会去重, * 但每次都会多跑一次 wp_enqueue_media,白白初始化一遍媒体框); * ② 更要紧的:媒体框的 JS 只在**挂件实例展开**用得上,可 WP 的旧版 * 挂件页会把所有实例的 form() 一次性渲染进页面 —— 资源没到就等于 * 「选择图片」按钮点了没反应。 * * 现在统一挂在一个函数里,只在挂件相关页面加载。 * * 🔴 缩略图预览这部分样式用 wp_add_inline_style 而不是去改 * assets/css/widget.min.css:那个文件**没有源文件**(仓库里只有压缩产物), * 为了加几条规则去手改一行压缩 CSS,下次别人再改就无从维护。 * ====================================================================== */ if (!function_exists('yang_widget_admin_assets')): function yang_widget_admin_assets($hook) { // 只在挂件页 / 自定义器加载。别的后台页面不需要媒体框。 if (!in_array($hook, array('widgets.php', 'customize.php'), true)) { return; } wp_enqueue_media(); wp_enqueue_script('widget_scripts', ASSET_PATH . '/assets/js/widget.min.js', array('jquery'), THEME_VERSION, true); wp_enqueue_style('widget_css', ASSET_PATH . '/assets/css/widget.min.css', array(), THEME_VERSION); $css = ' /* 挂件表单里的图片缩略图。 没有它的话,用户只能看到一串 URL,根本不知道那张图长什么样 —— 「背景图在哪改」这个问题有一半是"看不见"造成的。 */ .yang-media-preview{display:block;margin:8px 0 0;line-height:0} .yang-media-preview img{display:block;width:64px;height:64px;object-fit:cover;border:1px solid #dcdcde;border-radius:4px;background:#f6f7f7} .yang-media-preview-wide img{width:100%;max-width:260px;height:auto;aspect-ratio:16/9} /* 「清除」按钮跟「选择图片」贴在一起,别让它单独换行成一个大按钮 */ .media-widget-control .yang-media-clear{margin-left:6px} '; wp_register_style('yang-widget-admin', false, array('widget_css'), THEME_VERSION); wp_enqueue_style('yang-widget-admin'); wp_add_inline_style('yang-widget-admin', $css); /* 「清除」按钮:把输入框清空,并顺手把缩略图去掉。 * * 🔴 为什么必须同时清缩略图:只清 input 的话,图片还挂在那儿, * 用户会以为没清掉,再点一次又点回原来的图。 * 🔴 为什么用 change 事件触发:主题的 widget.min.js 靠 * `.change()` 让 WP 把表单标记为"已修改",否则直接 * 改 .val() 不会点亮「保存」按钮,用户点保存时改动丢失。 */ $js = <<<'JS' (function(){ if (typeof jQuery === 'undefined') { return; } jQuery(function($){ $(document).on('click', '.yang-media-clear', function(e){ e.preventDefault(); var id = $(this).attr('data-target'); if (!id) { return; } var $input = $('#' + id); $input.val('').trigger('change'); $input.closest('p').find('.yang-media-preview').remove(); $(this).remove(); }); }); })(); JS; wp_add_inline_script('widget_scripts', $js); } endif; add_action('admin_enqueue_scripts', 'yang_widget_admin_assets'); /* ========================================================================== * 挂件标题与外框(3.2.50) * * ── 为什么要有这两个函数 ────────────────────────────────────────────── * register_sidebar() 里已经声明了版式: * * 'before_title' => '
', * 'after_title' => '
', * * 但主题自己的挂件**全都绕开了它**:`widget_tags` 把「标签聚合」硬编码在 * 代码里,`widget_ad` / `widget_about` / `widget_posts` 干脆没有标题字段, * `widget_search` 有字段却自己手写了一个 `
`。 * * 结果是:在后台改侧栏版式(换标题标签、加装饰)对这四个挂件无效, * 而且用户根本没地方给它们起标题。这两个函数把「标题」收敛到一处, * 保证所有挂件走同一条路径。 * * 🔴 空标题必须**不输出任何节点**,而不是输出空的 `
` —— * 后者会留下一条只有内边距的空白条,视觉上像"多了一块间距"。 * ====================================================================== */ 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'] : '
'; $after = isset($args['after_title']) ? $args['after_title'] : '
'; 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 是 `
`,其中 %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 .= '' . '' . esc_html(wp_strip_all_tags($p->post_title)) . ''; } 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 '
'; yang_widget_title($args, $instance, '', $this->id_base); ?>
'; } public function form( $instance ) { $instance = wp_parse_args( (array) $instance, array( 'title' => '' ) ); $title = $instance['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[]['name']`。它是这么来的: * `_register_one()` → `wp_register_sidebar_widget($id, $this->name, ..., $this->widget_options)` * → 里面先 `$widget = array('name' => $name, ...)` * → 紧接着 `$widget = array_merge($widget, $options)` ← 关键 * `$options` 就是 `$this->widget_options`,其中的 `'name'` * **把前面的 false 覆盖掉了**。所以注册表里名字一直是好的。 * * 实测(老代码):`$widget->name` = false,但 * `$wp_registered_widgets['ad-1']['name']` = '图片广告' —— 名字没丢。 * 也就是说「用户找不到挂件」的真实原因是后台走了区块编辑器、 * 没有拖放面板,与本构造函数的实参无关(开关见文件上方)。 * * ── 那为什么还是改了 ──────────────────────────────────────── * ① 名字配在 `$widget_ops` 里是靠 array_merge **顺带**覆盖生效的, * 属于隐式行为;显式走第 2 参是 WP 文档写法,读代码的人不用 * 再去追 array_merge 才敢确认名字有没有生效。 * ② `$this->name` 在整个 WP 里有多处直接读(例如区块编辑器的 * 挂件类型接口),值是 false 时行为取决于调用方是否兜底 —— * 这次侥幸没事,不代表以后没事。 * * 第 1 参显式写 'ad':$id_base 决定 option 名(widget_ad)。 * 库里已经有用户的实例,**id_base 一旦变了配置就全丢**。 * 原来传 false 时 WP 靠类名推导出 'ad'(不是 widget_ad,它会把 * wp_/widget_ 前缀剥掉),效果一样 —— 写出来是为了不依赖推导。 */ $widget_ops = array( 'description' => __('显示自定义图片广告的工具', 'kratos'), ); parent::__construct('ad', __('图片广告', 'kratos'), $widget_ops); } public function widget($args, $instance) { $subtitle = !empty($instance['subtitle']) ? $instance['subtitle'] : __('广告', 'kratos'); $image = !empty($instance['image']) ? $instance['image'] : ''; $url = !empty($instance['url']) ? $instance['url'] : ''; /* 🔴 原来这三处都是裸拼: * '
' . $subtitle . '
' * ① $url 没 esc_url —— 后台填个带引号的地址就能截断 href 属性; * ② img 的 src 值**没有引号** —— 图片 URL 里只要有一个空格, * 后半截就会被解析成另一个属性(常见于文件名带空格的媒体库图片); * ③ $subtitle 没转义 —— 后台填 < 或 & 会破坏结构。 * 下面全部补齐;alt 取副标题,避免读屏软件只念文件名。 */ echo '
'; yang_widget_title($args, $instance, '', $this->id_base); $imgAlt = wp_strip_all_tags($subtitle); echo '' . '' . esc_attr($imgAlt) . '' . '
' . esc_html($subtitle) . '
' . '
'; echo '
'; } 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'] : ''; ?>

__('可跳转后台的个人简介展示工具', 'kratos'), ); parent::__construct('about', __('个人简介', 'kratos'), $widget_ops); } /** * 取挂件实例里的某个值;空则返回回退值。 * * 🔴 为什么不用 `!empty()` 判定: * ① 用户在后台把字段清空后,update() 存进去的是空字符串 '', * `!empty('')` 为 false —— 这一条两边一致,没问题; * ② 但 `!empty('0')` 也是 **false**!昵称就叫「0」、简介只写「0」 * 的站点会莫名其妙被回落到主题设置。判空只能判「去掉空白后是空串」。 * * 3.2.51 起 widget_about 的四个字段都走这里,取值顺序统一为: * 实例自己的值 → $fallback(主题设置 / 主题自带默认图) * * @param array $instance 挂件实例 * @param string $key 字段名 * @param string $fallback 实例里没填时用的值 * @return string */ private function pick($instance, $key, $fallback) { $v = isset($instance[$key]) ? $instance[$key] : ''; if (!is_string($v)) { return $fallback; } $v = trim($v); return ($v === '') ? $fallback : $v; } public function widget($args, $instance) { /* ══════════════════════════════════════════════════════════════ * 3.2.51:这三项原来**只读主题设置**,挂件表单里压根没有对应字段。 * * 老代码长这样(拿掉注释后的实质): * $introduce = kratos_option('a_about', ...); * $username = kratos_option('a_nickname', ...); * $avatar = yang_option_image('a_gravatar'); * * 于是用户点开侧栏「个人简介」,表单里只有「背景图片」一个字段, * 头像 / 昵称 / 简介**无处可填**,只能跑去「主题设置 → 站长信息」 * 改那份全站共用的。同一侧栏想放两个人的简介就不可能。 * * 取值顺序(实例优先,留空回落): * ① 挂件实例自己填的 * ② 主题设置「站长信息」—— **必须保留**:线上站点正用着 * 「老杨 / 博士,非博学之士…」那一套,改成"空了就空着" * 会让所有老站点升级后侧栏突然变空白。 * ③ 代码里的兜底文案 * ══════════════════════════════════════════════════════════════ */ $introduce = $this->pick( $instance, 'introduce', kratos_option('a_about', __('保持饥渴的专注,追求最佳的品质', 'kratos')) ); $username = $this->pick( $instance, 'nickname', kratos_option('a_nickname', __('Kratos', 'kratos')) ); $avatar = $this->pick( $instance, 'avatar', yang_option_image('a_gravatar') ); $background = $this->pick( $instance, 'background', ASSET_PATH . '/assets/img/about-background.png' ); /* 🔴 原来写的是 `background:url(' . $background . ') no-repeat ...` * —— url() 里的地址**没有引号**。只要路径含空格、括号、单引号, * CSS 解析器就会在那里截断,整条声明作废(背景变白)。 * 媒体库文件名带空格很常见。这里补引号。 * * 🔴 这里**只拼一次转义**,而且拼完还要整体 esc_attr()。 * 第一版写的是 esc_url($background) 再套 esc_attr($bg) —— * esc_url() 已经做过 HTML 实体化,esc_attr() 又来一次 ⇒ * `&` 被编成 `&amp;`,CSS 里拿到的是字面量 "amp", * 背景图直接 404。测试里那条「URL 完整」判据抓到的就是它。 * * 正确做法:URL 只做**协议/字符清洗**(esc_url_raw,不入实体), * url() 内部用双引号包住,最后整个 style 属性交给 esc_attr() * 统一转义一次。这样 `&` 只会被编成 `&`(正确), * 浏览器解析回属性值后仍是原始的 `&`。 * * 🔴 $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 '
'; yang_widget_title($args, $instance, '', $this->id_base); echo '
'; if (current_user_can('manage_options')) { echo ''; } else { echo ''; } echo '' . esc_attr(wp_strip_all_tags($username)) . ''; echo '
' . '

' . esc_html($username) . '

' . '

' . nl2br(esc_html($introduce)) . '

' . '
'; echo '
'; } /** * 保存实例。 * * 🔴 不能用 `!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'); ?>

__('文章标签的展示工具', 'kratos'), ); parent::__construct('tags', __('标签聚合', 'kratos'), $widget_ops); } public function widget($args, $instance) { $number = !empty($instance['number']) ? $instance['number'] : '8'; $order = !empty($instance['order']) ? $instance['order'] : 'RAND'; $tags = wp_tag_cloud(array( 'unit' => 'px', 'smallest' => 14, 'largest' => 14, 'number' => $number, 'format' => 'flat', 'orderby' => 'count', 'order' => $order, 'echo' => false, ) ); /* 🔴 标题原来是硬编码的 __('标签聚合'),后台没有输入框也改不了。 * 现在走实例的 title,没设才回落到「标签聚合」—— * 这样老站点的显示**一点不变**,但用户可以改了。 */ echo '
'; yang_widget_title($args, $instance, __('标签聚合', 'kratos'), $this->id_base); echo '
' . $tags . '
'; echo '
'; } 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'; ?>

__('展示最热、随机、最新文章的工具', 'kratos'), ); parent::__construct('posts', __('文章聚合', 'kratos'), $widget_ops); } public function widget($args, $instance) { $number = !empty($instance['number']) ? (int) $instance['number'] : 6; if ($number < 1) { $number = 6; } /* 🔴 id 唯一化(原来全是写死的)。 * 老代码把同一套标签页导航写了两份 —— 桌面一份带图标(d-none d-xl-flex)、 * 手机一份不带图标(d-xl-none)—— 于是 nav-tab / nav-new-tab / * nav-hot-tab / nav-random-tab **四个 id 各出现两次**,HTML 非法; * `aria-labelledby="nav-new-tab"` 也只能指到第一份。 * 挂两个「文章聚合」实例时撞得更彻底。 * * 现在只渲染**一份**导航:图标用 CSS 在窄屏隐藏(见 kratos.css 的 * `.w-recommended .tab-icon`)。这样 id 天然唯一,DOM 也少一半。 */ $uid = yang_widget_uid($this); echo '
'; yang_widget_title($args, $instance, '', $this->id_base); ?>
$number, 'offset' => 0, )); foreach ($myposts as $post): ?> post_title)) ?>
$number, 'offset' => 0, 'orderby' => 'rand', )); foreach ($myposts as $post): ?> post_title)) ?>
'; } 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'; ?>


'文章分类', 'count' => 1, 'hierarchical' => 1, 'dropdown' => 1 ) * * 也就是说访客实际看到的是一个 `),但样式要好看」。 * * 所以现在是**真下拉面板**: * 收起 → 一行触发条(当前分类名,不在分类页时显示「选择分类」+ 箭头) * 展开 → 浮层面板,`position:absolute` 贴在触发条下方,**不推开下面的挂件** * 内容 → 默认只列一级分类(本站 9 个);子分类是可选项(默认关) * * 🔴 仍然用 `
/` 做开合,**核心逻辑不写 JS**: * - 不依赖 jQuery/Bootstrap 版本,不会跟主题其它脚本抢事件; * - 键盘可操作、读屏软件能正确朗读,自带展开/收起语义; * - 关掉 JS 也能展开、也能点分类跳转(分类导航是基础功能)。 * JS 只做两件 `
` 原生没有的事:**点面板外部关闭、按 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&Debian`(id=122), * 不是 `Ubuntu&Debian`。我一开始认定必须 `html_entity_decode()` 一层, * 理由是"下拉框里会显示成 `Ubuntu&Debian`"。**实测证明这个判断是错的**: * * raw = [Ubuntu&Debian] * decode = [Ubuntu&Debian] * esc(raw) = [Ubuntu&Debian] * esc(decode) = [Ubuntu&Debian] ← 两者完全相同 * * 关键点:`esc_html()` 对 `&` 的输出**恒为** `&`。所以 * · 库里存 `&` → esc_html 变 `&` → 浏览器显示 `&` * · 库里存 `&` → esc_html 不动它 → 浏览器显示 `&` * 两种数据经 esc_html 后**字节完全一致**,浏览器里都是 `Ubuntu&Debian`, * 本来就是正常的。加 decode 不会改变任何输出,只是个看着"更讲究"的冗余。 * * ⇒ 所以这里**不 decode**,直接用 `esc_html()`。少一层处理 = 少一处 * 可能出错的地方(decode 反而有风险:若将来分类名里有人写了 * `<script>` 这种真·字面量,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 对 `&` 的输出恒为 `&`,所以库里存 `&` 还是 * 存 `&`,最终 HTML 字节一致、浏览器显示也一致。decode 是冗余。 * 反过来 decode 有真实风险:分类名里若有人写了字面量 `<script>`, * 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,因为要输出
面板, * 而 Walker_Category 只认