`,其中 %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 .= '
';
}
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()
{
add_action('admin_enqueue_scripts', array($this, 'scripts'));
$widget_ops = array(
'name' => __('图片广告', 'kratos'),
'description' => __('显示自定义图片广告的工具', 'kratos'),
);
parent::__construct(false, false, $widget_ops);
}
public function scripts()
{
wp_enqueue_script('media-upload');
wp_enqueue_media();
wp_enqueue_script('widget_scripts', ASSET_PATH . '/assets/js/widget.min.js', array('jquery'));
wp_enqueue_style('widget_css', ASSET_PATH . '/assets/css/widget.min.css', array());
}
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 '
';
}
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'),
'description' => __('可跳转后台的个人简介展示工具', 'kratos'),
);
parent::__construct(false, false, $widget_ops);
}
public function scripts()
{
wp_enqueue_script('media-upload');
wp_enqueue_media();
wp_enqueue_script('widget_scripts', ASSET_PATH . '/assets/js/widget.min.js', array('jquery'));
wp_enqueue_style('widget_css', ASSET_PATH . '/assets/css/widget.min.css', array());
}
public function widget($args, $instance)
{
$introduce = kratos_option('a_about', __('保持饥渴的专注,追求最佳的品质', 'kratos'));
$username = kratos_option('a_nickname', __('Kratos', 'kratos'));
$avatar = yang_option_image('a_gravatar');
$background = !empty($instance['background']) ? $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() 又来一次 ⇒
* `&` 被编成 `&`,CSS 里拿到的是字面量 "amp",
* 背景图直接 404。测试里那条「URL 完整」判据抓到的就是它。
*
* 正确做法:URL 只做**协议/字符清洗**(esc_url_raw,不入实体),
* url() 内部用双引号包住,最后整个 style 属性交给 esc_attr()
* 统一转义一次。这样 `&` 只会被编成 `&`(正确),
* 浏览器解析回属性值后仍是原始的 `&`。
*
* 🔴 $introduce 来自主题选项 textarea,$username 来自 text,
* 原来是裸 echo。个人简介里写个 < 就把后面的结构吃掉了。
* 简介保留换行(textarea 的自然语义),其余全部转义。 */
$bg = 'background-image:url("' . esc_url_raw($background) . '");'
. 'background-repeat:no-repeat;'
. 'background-position:center center;'
. 'background-size:cover;';
echo '
';
}
public function update($new_instance, $old_instance)
{
$instance = array();
$instance['title'] = (!empty($new_instance['title'])) ? sanitize_text_field($new_instance['title']) : '';
$instance['background'] = (!empty($new_instance['background'])) ? esc_url_raw($new_instance['background']) : '';
return $instance;
}
public function form($instance)
{
$title = !empty($instance['title']) ? $instance['title'] : '';
$background = !empty($instance['background']) ? $instance['background'] : ASSET_PATH . '/assets/img/about-background.png';
?>
__('标签聚合', '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 '
';
}
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'),
'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 '