…` 或下拉框),塞不进三项计数。 * ③ 它按「按月/按年」二选一输出**整条时间线**,没有"给我某一年"的 * 接口,JS 切年时无法复用它。 * * 所以这里自己一次 SQL 取全量 + 两次批量 SQL 取计数,服务端组织成 * `年 → 月 → 条目` 的三层数组,**首屏直出今年**,其余年份以 JSON * 随页面下发。JS 切年只是从这份 JSON 重建 DOM —— 不发起任何请求。 * * ── 为什么计数要"批量"而不是逐篇 ─────────────────────────────── * 浏览数 `views` 与点赞数 `love` 都存在 postmeta 里(见 theme-article.php)。 * 逐篇 `get_post_meta()` 会先被 WP 的对象缓存接住一部分,但**没命中时 * 每次一条 SQL**。71 篇最坏 142 条。改成一条 * `WHERE meta_key IN ('views','love')` 就够,代价是自己在 PHP 里 * 按 post_id 归位 —— 这点代码远比 142 次往返便宜。 * * ── 为什么评论数也走 SQL 而不是 get_comments_number() ────────── * `get_comments_number()` 在没预热评论缓存的页面上同样是一条一条查 * (它内部走 `get_comments()` 的 count 分支,不带 `post_id` 时按文章查)。 * 归档页不是文章页,评论缓存根本没预热,所以这里必须自己 GROUP BY 一次。 * * ── 排序 ────────────────────────────────────────────────────── * 年份**倒序**(新的在前,符合翻归档的直觉),月份**升序**(1 月在 * 前,符合"这一年从头看"的直觉),月内日期**倒序**(新的在前)。 * 这三条方向不一致是**刻意**的:年份是"选哪一年",月份是"这一年 * 的进度条",月内是"最近写了什么"。 * * @author Yongquan Yang * @license MIT License */ if (!defined('ABSPATH')) { exit; } /** * 一次取全站归档数据 * * 结果结构: * * array( * 'years' => array( * 2026 => array( // 年份,倒序 * 'count' => 12, // 该年文章总数 * 'months' => array( * 10 => array( // 月份,升序 * 'count' => 3, * 'items' => array( // 月内,日期倒序 * array('id'=>1,'title'=>'…','url'=>'…', * 'day'=>'15','date'=>'2026-10-15', * 'views'=>12,'comments'=>3,'love'=>5), * ), * ), * ), * ), * ), * 'order' => array(2026, 2025, 2024), // 有文章的年份,倒序 * 'latest' => 2026, // 最新一篇所在年 * 'total' => 71, * ) * * ⚠️ 全部文章**一次性**取回。71 篇的量级完全撑得住;如果哪天到了 * 几万篇,需要改成分年懒加载 —— 但那时前端也得改成 AJAX, * 属于重新设计,不是在这里加个 limit 就能解决的(加了会算错每月 * 与每年的条目数)。 * * @return array */ function yang_archives_data() { /* 同一次请求内只算一次:一个页面可能放了两个 [Archives]。 * 不用 transient —— 归档数据要随发文/评论即时变化, * 加缓存就得处理 save_post / wp_insert_comment 失效, * 那套失效逻辑一旦漏一条,用户看到的就是"我刚发的文章不在归档里"。 * 静态变量只活一次请求,不存在这个问题。 */ static $cache = null; if ($cache !== null) { return $cache; } $empty = array('years' => array(), 'order' => array(), 'latest' => 0, 'total' => 0); global $wpdb; /* ① 文章本体。 * * 只取 post 类型、已发布、非密码保护 —— 密码保护的正文别人看不到, * 列进归档等于泄露标题。`post_password = ''` 就是排掉它们。 * * 🔴 不要在这里 `ORDER BY post_date DESC` 之后就当排好了: * 下面会重组成「年 → 月」,组内顺序必须由重组逻辑自己保证, * 否则将来有人改这里的排序,页面显示顺序会莫名其妙跟着变。 * 这里的 ORDER BY 只是让同一月内的记录天然有序,省一次 usort。 */ $rows = $wpdb->get_results( "SELECT ID, post_title, post_date FROM {$wpdb->posts} WHERE post_type = 'post' AND post_status = 'publish' AND post_password = '' ORDER BY post_date DESC", ARRAY_A ); if (empty($rows)) { $cache = $empty; return $cache; } /* ② 浏览数 / 点赞数:一次批量取。 * * `meta_value` 是 longtext,这里不强转 —— 转成 int 放到 PHP 侧做, * 保持 SQL 可走 meta_key 索引。 */ $ids = array(); foreach ($rows as $r) { $ids[] = (int) $r['ID']; } $ids_sql = implode(',', $ids); $meta = array(); if ($ids_sql !== '') { $mrows = $wpdb->get_results( "SELECT post_id, meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id IN ({$ids_sql}) AND meta_key IN ('views', 'love')", ARRAY_A ); foreach ($mrows as $m) { $pid = (int) $m['post_id']; $key = $m['meta_key']; if (!isset($meta[$pid])) { $meta[$pid] = array('views' => 0, 'love' => 0); } $meta[$pid][$key] = (int) $m['meta_value']; } } /* ③ 评论数:一次 GROUP BY。 * * 只数已批准的(`comment_approved = '1'`)—— 待审核的评论访客看不到, * 数进去会出现"显示 5 条、点进去只有 3 条"。 */ $ccount = array(); if ($ids_sql !== '') { $crows = $wpdb->get_results( "SELECT comment_post_ID AS pid, COUNT(*) AS n FROM {$wpdb->comments} WHERE comment_approved = '1' AND comment_post_ID IN ({$ids_sql}) GROUP BY comment_post_ID", ARRAY_A ); foreach ($crows as $c) { $ccount[(int) $c['pid']] = (int) $c['n']; } } /* ④ 组织成 年 → 月 → 条目 */ $years = array(); $total = 0; foreach ($rows as $r) { $pid = (int) $r['ID']; $date = $r['post_date']; // 'YYYY-MM-DD HH:MM:SS',WP 保证格式 $y = (int) substr($date, 0, 4); $m = (int) substr($date, 5, 2); $d = substr($date, 8, 2); /* 年份/月份为 0 说明 post_date 是脏数据('0000-00-00')。 * 这类记录直接跳过而不是塞进 0 年 0 月 —— 后者会在导航里 * 制造一个永远点不到内容的"第 0 年"。 */ if ($y < 1 || $m < 1 || $m > 12) { continue; } $title = (string) $r['post_title']; if ($title === '') { $title = __('(无标题)', 'kratos'); } $views = isset($meta[$pid]['views']) ? $meta[$pid]['views'] : 0; $love = isset($meta[$pid]['love']) ? $meta[$pid]['love'] : 0; $cmts = isset($ccount[$pid]) ? $ccount[$pid] : 0; if (!isset($years[$y])) { $years[$y] = array('count' => 0, 'months' => array()); } if (!isset($years[$y]['months'][$m])) { $years[$y]['months'][$m] = array('count' => 0, 'items' => array()); } $years[$y]['months'][$m]['items'][] = array( 'id' => $pid, 'title' => $title, 'url' => get_permalink($pid), 'day' => $d, 'date' => substr($date, 0, 10), 'views' => $views, 'comments' => $cmts, 'love' => $love, ); $years[$y]['months'][$m]['count']++; $years[$y]['count']++; $total++; } if (empty($years)) { $cache = $empty; return $cache; } // 年份倒序、月份升序 —— 见文件头「排序」一节 krsort($years); foreach ($years as $y => $yv) { ksort($years[$y]['months']); } /* 月内按日期倒序 —— 由产品自己排,不依赖 SQL 的 ORDER BY。 * * 🔴 为什么上面已经有 `ORDER BY post_date DESC` 还要排一次: * 那个 ORDER BY 保证的是"三张表里这一条 SQL 的返回顺序", * 它是真实存在的契约,正常情况下够用。但归档的"日期倒序" * 是**这个功能的对外承诺**,不该只由上游偶然保证 —— * 一旦有人调 SQL(换成 post_date_gmt、加上 UNION、被 * object-cache 插件换成别的查询),页面顺序会静默变乱, * 而且乱得很有迷惑性:同一天的多篇顺序不对,很难注意到。 * * 实测证据:本地单测把夹具**乱序**喂进来(不模拟 SQL 排序), * 产品就把 `20, 05, 01, 02` 原样输出了 —— 说明它此刻确实 * 完全依赖上游顺序。一次 usort 的代价可以忽略(一年几百条), * 换来的是这个承诺由产品自己兜住。 * * 用 `<=>` 比较 `YYYY-MM-DD HH:MM:SS` 字符串:定长格式下字典序 * 与时间序一致,比 strtotime 更快也不受时区影响。 */ foreach ($years as $y => $yv) { foreach ($years[$y]['months'] as $m => $mv) { usort($years[$y]['months'][$m]['items'], function ($a, $b) { if ($a['date'] === $b['date']) { // 同一时刻(极少,但导入数据里会有)→ 按 id 倒序,保证稳定 return $b['id'] <=> $a['id']; } return strcmp($b['date'], $a['date']); }); } } $order = array_keys($years); $cache = array( 'years' => $years, 'order' => $order, 'latest' => (int) $order[0], 'total' => $total, ); return $cache; } /** * 短代码参数 * * @param array $atts * @return array */ function yang_archives_atts($atts) { $a = shortcode_atts(array( 'year' => '', 'month' => '1', 'nav' => '1', ), $atts, 'archives'); return array( 'year' => $a['year'] === '' ? '' : (int) $a['year'], 'month' => (int) $a['month'] === 1 ? 1 : 0, 'nav' => (int) $a['nav'] === 1 ? 1 : 0, ); } /** * 决定首屏显示哪一年 * * 优先级:URL 参数 `?arc_year=YYYY` > 短代码 `year="YYYY"` > 今年 * * 为什么 URL 参数优先:切年时 JS 会 `replaceState` 把年份写进地址, * 用户复制链接给别人,对方打开必须看到**同一年的归档**。 * 如果短代码的 year 优先,这个链接就永远只能打开作者设定的那一年, * 分享出去等于没用。 * * ⚠️ 年份必须是数据里**真实存在**的年份,否则落到最新一年。 * 不这么做的话,`?arc_year=1900` 会渲染出一个空列表, * 而导航按钮的禁用态又是按"有没有数据"算的 —— 页面会卡在 * 一个既不能前进也不能后退的死年。 * * @param array $data yang_archives_data() 的结果 * @param int $attr_year 短代码上写的年份,0 表示没写 * @return int */ function yang_archives_pick_year($data, $attr_year) { $order = $data['order']; if (empty($order)) { return 0; } $candidates = array(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended if (isset($_GET['arc_year'])) { $candidates[] = (int) $_GET['arc_year']; } if ($attr_year > 0) { $candidates[] = (int) $attr_year; } foreach ($candidates as $y) { if (isset($data['years'][$y])) { return $y; } } // 都没命中 → 今年若在数据里就用今年,否则用最新一年 $this_year = (int) current_time('Y'); if (isset($data['years'][$this_year])) { return $this_year; } return (int) $order[0]; } /** * 渲染某一年的列表(服务端首屏用) * * ⚠️ JS 切年时会用**同一套类名**重建 DOM(见 assets/js/archives.js)。 * 改这里的结构必须同步改那边,否则首屏与切年后的样式会不一致。 * 判据在 `_archives_e2e.js` 里:两边建出来的节点类名必须逐个相同。 * * @param array $ydata 某一年('count' + 'months') * @param int $year 年份 * @param int $show_month 是否输出月份分组标题 * @return string */ function yang_archives_render_year($ydata, $year, $show_month) { if (empty($ydata['months'])) { return ''; } $out = ''; foreach ($ydata['months'] as $m => $mdata) { if ($show_month) { $out .= '
'; $out .= '

' . sprintf( /* translators: 1: 月份数字 2: 该月文章数 */ esc_html__('%1$s 月', 'kratos'), /* 🔴 月份同样不过 number_format_i18n —— 它会把 10 月 * 变成「10」没问题,但一旦有人把月份格式改成两位数 * 或站点语言用非逗号分隔,就会与 JS 侧(String(m)) * 不一致。月份是编号。 */ esc_html((string) $m) ) . '' . esc_html(number_format_i18n($mdata['count'])) . '' . '

'; } else { $out .= '
'; } $out .= '
    '; foreach ($mdata['items'] as $it) { $out .= yang_archives_render_item($it, $m); } $out .= '
'; } return $out; } /** * 渲染单条 * * @param array $it 条目 * @param int $m 月份(用于拼「月-日」) * @return string */ function yang_archives_render_item($it, $m) { $md = sprintf('%02d-%s', (int) $m, $it['day']); $out = '
  • '; $out .= ''; $out .= '' . esc_html($it['title']) . ''; /* 三项计数。用的 i-hot / i-comments / i-good 是主题自带图标集里 * 已经在用的三个(page-content.php 同款),不引入新图标, * 免得字体包里缺字形变成豆腐块。 * * `data-zero="1"` 给零值打标记,CSS 据此弱化 —— 站内大部分文章的 * 评论数和点赞数就是 0,三项同等强调会让计数列看起来全是「0 0 0」, * 反而看不出哪篇有热度。 */ $out .= ''; $out .= yang_archives_render_stat('views', 'i-hot', $it['views'], __('浏览数', 'kratos')); $out .= yang_archives_render_stat('comments', 'i-comments', $it['comments'], __('评论数', 'kratos')); $out .= yang_archives_render_stat('love', 'i-good', $it['love'], __('点赞数', 'kratos')); $out .= ''; $out .= '
  • '; return $out; } /** * 单个计数(图标 + 数字 + 无障碍文字) * * @param string $kind views|comments|love,只用于类名 * @param string $icon 图标类名 * @param int $num 数值 * @param string $label 无障碍标签 * @return string */ function yang_archives_render_stat($kind, $icon, $num, $label) { $num = (int) $num; return '' . '' . '' . esc_html(number_format_i18n($num)) . '' . '' . esc_html($label) . '' . ''; } /** * 四角导航 * * 语义按用户要求:**左侧「上一年」= 回到更近的年份**, * **右侧「下一年」= 去更早的年份**。 * * 🔴 这个方向初看是反的("下一年"却往过去走),但用户明确写了两遍 * 示例(2026 → 2025 → 2024),所以行为按示例来。 * 为了不让人误点,按钮上除了文案还直接显示**目标年份**, * 这样"点下去会去哪"一眼可见,不必靠文案猜。 * * 用的是 `