3.2.46: 新增 [Archives] 归档短代码 + 评论关闭时整块零输出 #15
需求一:[Archives] 短代码 —— 归档式全站文章列表
· 在文章/页面正文写一行 [Archives],该位置渲染全站文章的年月归档
· 默认显示今年,按月份归类;每篇带浏览/评论/点赞三个数
· 上下两排各有一组「上一年 / 下一年」按钮(四角),JS 切换不刷新页面
· 地址栏同步 ?arc_year=,刷新与分享链接都能保持同一年
· 只在用到短代码的页面才加载 assets/js|css/archives.js|css
· 参数:year="2025" 指定首年;month="0" 不按月分组
需求二:评论关闭时评论区整块消失
· comments.php 在**任何输出之前** `if (!comments_open()) return;`
—— 零字节,不是空容器、不是 display:none、不留提示文字
· 已有评论的老文章关评论后也不再显示(需求原话:正文之外什么都没有)
· inc/theme-comments.php 的资源守卫与之同口径:关评论时不加载
约 10KB 的 comments.css / comments.js
· 删掉 .comments-closed 样式与两处「评论已关闭」提示分支
开发中实测抓到并修掉的三个真实 bug(都是"测试把期望值写对之后"才显形):
· 年份标题 / 导航年份过了 number_format_i18n() ⇒ 输出「2,025」,
而 JS 侧 String(year) 输出「2025」⇒ 首屏与切年后不一致
· 月份标题同样过 number_format_i18n(),与 JS 侧 String(m) 口径不一致
· yang_archives_render_nav() 把 ar-nav-top 写死,而下排也调它生成
⇒ 下排被叫成 top(视觉上被更高特异性规则压住,看不出问题)
验证:
归档 _archives_unit.php 94 通过 / 0 失败
_archives_e2e.js 109 通过 / 0 失败
_archives_e2e_falsify.js 反证 25 真红 / 0 无效
_archives_unit_falsify.py 反证 6 真红 / 0 无效
评论 _comments_closed_test.php 54 通过 / 0 失败
_comments_closed_falsify.py 反证 13 真红 / 0 无效
回归 _test_comments.php 64 通过 / 0 失败
_hl_unit_run.py 24 通过 / 0 失败
全量 96 个 PHP 文件语法检查通过;包内 lint 通过
发布 已推 3.2.46 到 /theme-update/,update.json 已落 3.2.46;
服务器 wp-content/themes/kratos_yang/ 仍为 3.2.45(未覆盖)
另:把 _archives_unit.php 从 `__DIR__` 定位改为接受根目录参数,
否则它无法登记进 verify_all.sh,只能手动跑(手动跑 = 长期不跑)。
5 个新套件已登记进 verify_all.sh(2j ~ 2o)。
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
/*!
|
||||
* 归档式文章列表([Archives])样式
|
||||
* Yongquan Yang <yangyq.net>
|
||||
*
|
||||
* ── 设计原则 ────────────────────────────────────────────────
|
||||
* 1. **它是导航,不是内容**:所以字号小一点、颜色退后一步,
|
||||
* 让文章标题成为这一屏唯一的主角。
|
||||
* 2. **色值一律带兜底**:`var(--ar-primary, #00a2ff)`。
|
||||
* 主题**没有**定义全站 CSS 变量(主色是各处硬编码的),
|
||||
* 照抄 var(--kratos-primary) 会静默失效、整块永远是默认灰。
|
||||
* 所以本文件自己定义一组 `--ar-*` 局部变量,挂在 `.yang-archives`
|
||||
* 作用域内,不污染全站;同时每个使用点都带兜底值。
|
||||
* 3. **与评论区的变量命名区分**:评论用 `--yq-*`,这里用 `--ar-*`。
|
||||
* 两套互不依赖,将来任一方改色都不会波及另一方。
|
||||
* 4. **不引入图标字体以外的东西**:三项计数用的 `i-hot`/`i-comments`/`i-good`
|
||||
* 是主题图标集里已有的三个(page-content.php 同款)。
|
||||
*/
|
||||
|
||||
.yang-archives {
|
||||
/* 主色 / 文字三级 / 线 —— 与 comments.css 同一套取值,视觉统一 */
|
||||
--ar-primary: #00a2ff;
|
||||
--ar-primary-soft: #eaf6ff;
|
||||
--ar-text: #33383f;
|
||||
--ar-text-2: #5b6470;
|
||||
--ar-text-3: #6b7280;
|
||||
--ar-line: #eaedf2;
|
||||
--ar-line-soft: #f1f4f8;
|
||||
--ar-radius: 4px;
|
||||
|
||||
margin: 24px 0 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--ar-text, #33383f);
|
||||
}
|
||||
|
||||
/*--------------------------------------------------------------
|
||||
1. 头部:年份 + 总数 + 上排导航
|
||||
--------------------------------------------------------------*/
|
||||
|
||||
.yang-archives .ar-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--ar-line, #eaedf2);
|
||||
}
|
||||
|
||||
.yang-archives .ar-year {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--ar-text, #33383f);
|
||||
/* 等宽数字:切年时 2026 → 2025 两位都变,不等宽会左右跳一下 */
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.yang-archives .ar-total {
|
||||
font-size: 12.5px;
|
||||
color: var(--ar-text-3, #6b7280);
|
||||
}
|
||||
|
||||
/*--------------------------------------------------------------
|
||||
2. 四角导航
|
||||
--------------------------------------------------------------*/
|
||||
|
||||
/* 上排靠右、下排靠右 —— 两侧按钮分居两端 */
|
||||
.yang-archives .ar-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.yang-archives .ar-nav-top {
|
||||
/* 头部是 flex,导航自己靠右;窄屏会换行到标题下方 */
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.yang-archives .ar-foot {
|
||||
margin-top: 16px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--ar-line, #eaedf2);
|
||||
}
|
||||
|
||||
.yang-archives .ar-foot .ar-nav {
|
||||
margin-left: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.yang-archives .ar-nav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--ar-line, #eaedf2);
|
||||
border-radius: var(--ar-radius, 4px);
|
||||
background-color: #fff;
|
||||
color: var(--ar-text-2, #5b6470);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.16s ease, color 0.16s ease, background-color 0.16s ease;
|
||||
}
|
||||
|
||||
.yang-archives a.ar-nav-btn:hover {
|
||||
border-color: var(--ar-primary, #00a2ff);
|
||||
background-color: var(--ar-primary-soft, #eaf6ff);
|
||||
color: var(--ar-primary, #00a2ff);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.yang-archives a.ar-nav-btn:focus-visible {
|
||||
outline: 2px solid var(--ar-primary, #00a2ff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.yang-archives .ar-nav-arrow {
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.yang-archives .ar-nav-year {
|
||||
font-size: 12px;
|
||||
color: var(--ar-text-3, #6b7280);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.yang-archives a.ar-nav-btn:hover .ar-nav-year {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* 禁用态:到最早/最新一年。用 opacity + 去掉交互,
|
||||
* 不用 display:none —— 按钮消失会让"上排只剩一个按钮"看着像坏了。 */
|
||||
.yang-archives .ar-nav-btn.is-disabled {
|
||||
opacity: 0.42;
|
||||
cursor: not-allowed;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/*--------------------------------------------------------------
|
||||
3. 月份分组
|
||||
--------------------------------------------------------------*/
|
||||
|
||||
.yang-archives .ar-month + .ar-month {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.yang-archives .ar-month-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 14px 0 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ar-text-2, #5b6470);
|
||||
}
|
||||
|
||||
/* 月份标题左侧一小段主色竖线,作为分组视觉锚点 */
|
||||
.yang-archives .ar-month-title::before {
|
||||
content: "";
|
||||
flex: 0 0 auto;
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--ar-primary, #00a2ff);
|
||||
}
|
||||
|
||||
.yang-archives .ar-month-count {
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--ar-line-soft, #f1f4f8);
|
||||
color: var(--ar-text-3, #6b7280);
|
||||
font-size: 11.5px;
|
||||
font-weight: 400;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/*--------------------------------------------------------------
|
||||
4. 条目
|
||||
--------------------------------------------------------------*/
|
||||
|
||||
.yang-archives .ar-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.yang-archives .ar-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--ar-radius, 4px);
|
||||
/* 分隔用极浅的线,不抢视觉 */
|
||||
border-bottom: 1px solid var(--ar-line-soft, #f1f4f8);
|
||||
}
|
||||
|
||||
.yang-archives .ar-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.yang-archives .ar-item:hover {
|
||||
background-color: var(--ar-primary-soft, #eaf6ff);
|
||||
}
|
||||
|
||||
.yang-archives .ar-date {
|
||||
flex: 0 0 auto;
|
||||
min-width: 44px;
|
||||
color: var(--ar-text-3, #6b7280);
|
||||
font-size: 12.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.yang-archives .ar-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
color: var(--ar-text, #33383f);
|
||||
text-decoration: none;
|
||||
/* 长标题单行截断:归档是"扫一眼找文章",换行会把行距撑乱 */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.yang-archives .ar-title:hover {
|
||||
color: var(--ar-primary, #00a2ff);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.yang-archives .ar-item:hover .ar-title {
|
||||
color: var(--ar-primary, #00a2ff);
|
||||
}
|
||||
|
||||
/*--------------------------------------------------------------
|
||||
5. 三项计数
|
||||
--------------------------------------------------------------*/
|
||||
|
||||
.yang-archives .ar-stats {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.yang-archives .ar-stat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: var(--ar-text-3, #6b7280);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.yang-archives .ar-stat .kicon {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 零值弱化:0 浏览 / 0 评论 / 0 赞占了相当多,一律同等强调会让
|
||||
* 计数列看起来全是"0 0 0",反而看不出哪篇有热度。
|
||||
* 属性由 PHP 在渲染时按真实值打(见 yang_archives_render_item())。 */
|
||||
.yang-archives .ar-stat[data-zero="1"] {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/*--------------------------------------------------------------
|
||||
6. 空态
|
||||
--------------------------------------------------------------*/
|
||||
|
||||
.yang-archives .ar-empty {
|
||||
margin: 20px 0 4px;
|
||||
padding: 18px 16px;
|
||||
border: 1px dashed #dfe5ec;
|
||||
border-radius: var(--ar-radius, 4px);
|
||||
color: var(--ar-text-3, #6b7280);
|
||||
font-size: 13.5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/*--------------------------------------------------------------
|
||||
7. 切年时的过渡
|
||||
--------------------------------------------------------------*/
|
||||
|
||||
/* 只做淡入,不做高度动画:
|
||||
* 高度动画需要先量 scrollHeight 再改 height,在长列表(一年 12 个月)
|
||||
* 上会强制同步布局,切年时有肉眼可见的卡顿。
|
||||
* 淡入用 CSS animation 而不是 JS 定时器,是为了让它自己结束、
|
||||
* 不留 pending 的 timeout(否则快速连点切年,几个 timeout 会交错)。 */
|
||||
.yang-archives .ar-body.is-switching {
|
||||
animation: ar-fade 0.18s ease;
|
||||
}
|
||||
|
||||
@keyframes ar-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 尊重系统的"减少动效"设置 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.yang-archives .ar-body.is-switching {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/*--------------------------------------------------------------
|
||||
8. 窄屏
|
||||
--------------------------------------------------------------*/
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.yang-archives {
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.yang-archives .ar-head {
|
||||
/* 窄屏下导航换到标题下方并且铺满,两个按钮各占一半 */
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.yang-archives .ar-nav {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.yang-archives .ar-nav-btn {
|
||||
flex: 1 1 0;
|
||||
justify-content: center;
|
||||
padding: 7px 8px;
|
||||
}
|
||||
|
||||
/* 窄屏隐藏计数图标,只留数字 —— 图标加数字在 320px 宽度下
|
||||
* 会把标题挤到只剩两三个字 */
|
||||
.yang-archives .ar-stat .kicon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.yang-archives .ar-stats {
|
||||
gap: 8px;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.yang-archives .ar-date {
|
||||
min-width: 40px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.yang-archives .ar-title {
|
||||
/* 极窄屏允许两行,否则标题只剩几个字完全看不出是哪篇 */
|
||||
white-space: normal;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.yang-archives .ar-item {
|
||||
/* 计数换到标题下一行,避免和标题抢宽度 */
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.yang-archives .ar-stats {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
padding-left: 50px;
|
||||
}
|
||||
}
|
||||
@@ -180,7 +180,9 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.comments-closed,
|
||||
/* .comments-closed 的样式已在 3.2.46 删除:评论关闭时整个评论区
|
||||
* 一个字节都不输出(见 comments.php 开头的 return),这个类名
|
||||
* 再也不会出现在页面上。留着它会让后来人以为"还有个关闭态要美化"。 */
|
||||
.comments-empty {
|
||||
margin: 20px 0 4px;
|
||||
padding: 18px 16px;
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
/*!
|
||||
* 归档式文章列表([Archives])—— 年份切换
|
||||
* Yongquan Yang <yangyq.net>
|
||||
*
|
||||
* ── 它只做一件事 ────────────────────────────────────────────
|
||||
* 把 `<script type="application/json" data-role="data">` 里那份数据
|
||||
* 按选中的年份重建成 DOM,替换 `[data-role="body"]` 的内容。
|
||||
* **不发任何请求** —— 数据首屏就全在页面里了。
|
||||
*
|
||||
* ── 为什么"不刷新"要做成增强而不是替代 ──────────────────────
|
||||
* 导航按钮本身是 `<a href="?arc_year=2025">`。这意味着:
|
||||
* · JS 没加载 / 报错 → 点下去是新的一次请求,仍然能看到 2025 的归档;
|
||||
* · 中键 / Ctrl+点击 → 新标签打开对应年份,行为符合直觉;
|
||||
* · 爬虫 → 顺着链接能爬到每一年。
|
||||
* JS 接管的只是"同页内的普通左键点击":`preventDefault` 后走 DOM 重建。
|
||||
* 这样"不刷新"是锦上添花,而不是唯一能用的一条路。
|
||||
*
|
||||
* ── 为什么用事件委托而不是给每个按钮绑 ──────────────────────
|
||||
* 每次切年都会重建导航(禁用态、目标年份都要变),逐个绑就得逐个解绑,
|
||||
* 漏一个就是"点一下就切两年"。委托到容器上,重建多少次都不会漏。
|
||||
*
|
||||
* ── 为什么用 scrollIntoView 而不是 scrollTop ────────────────
|
||||
* 切年后列表高度会变(12 个月 → 3 个月)。若不做处理,用户可能停在
|
||||
* 页面中部、看到的是一片空白,会以为"点坏了"。切完把容器顶部滚到
|
||||
* 视口内即可,同时尊重 `prefers-reduced-motion`。
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var ROOT_SEL = '.yang-archives';
|
||||
|
||||
/**
|
||||
* 安全的 textContent 设置
|
||||
*
|
||||
* 一律走 textContent,**绝不用 innerHTML 拼标题** ——
|
||||
* 文章标题里可能带 `<`、`&`、引号,拼进去就是 XSS 面。
|
||||
*/
|
||||
function txt(node, s) {
|
||||
node.textContent = s == null ? '' : String(s);
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 建一个元素
|
||||
*
|
||||
* @param {string} tag
|
||||
* @param {string} cls
|
||||
* @param {string} [text]
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function el(tag, cls, text) {
|
||||
var n = document.createElement(tag);
|
||||
if (cls) {
|
||||
n.className = cls;
|
||||
}
|
||||
if (text !== undefined) {
|
||||
txt(n, text);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读页面里的 JSON 数据
|
||||
*
|
||||
* 🔴 解析失败必须**出声**,不能静默 return。
|
||||
* 这个 JSON 是 PHP 用 JSON_HEX_TAG 等四个 flag 编的,
|
||||
* 正常不可能坏;一旦坏了(比如别的插件过滤了短代码输出),
|
||||
* 页面会表现为"按钮点了没反应",而控制台一个字都没有 ——
|
||||
* 那是最难查的一类。所以这里必须 console.error。
|
||||
*
|
||||
* @param {HTMLElement} root
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
function readData(root) {
|
||||
var node = root.querySelector('[data-role="data"]');
|
||||
if (!node) {
|
||||
if (window.console && console.error) {
|
||||
console.error('[yang-archives] 找不到数据节点 [data-role="data"],'
|
||||
+ '年份切换不可用。检查 inc/theme-archives.php 的输出。');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(node.textContent || '{}');
|
||||
} catch (e) {
|
||||
if (window.console && console.error) {
|
||||
console.error('[yang-archives] 数据解析失败,年份切换不可用:', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染单条
|
||||
*
|
||||
* ⚠️ 类名必须与 PHP 的 yang_archives_render_item() **逐个一致**。
|
||||
* 首屏是 PHP 出的、切年后是这里出的,两者样式必须完全一样,
|
||||
* 否则会出现"刚进来是对的、点一下年就变样"。
|
||||
* 判据在测试 `_archives_e2e.js`:它比对两条路径建出的类名集合。
|
||||
*
|
||||
* @param {Object} it
|
||||
* @param {number} month
|
||||
* @param {Object} i18n
|
||||
* @returns {HTMLLIElement}
|
||||
*/
|
||||
function buildItem(it, month, i18n) {
|
||||
var li = el('li', 'ar-item');
|
||||
|
||||
var date = el('time', 'ar-date', pad2(month) + '-' + pad2(it.day));
|
||||
date.setAttribute('datetime', it.date || '');
|
||||
li.appendChild(date);
|
||||
|
||||
var a = el('a', 'ar-title');
|
||||
a.href = it.url || '#';
|
||||
txt(a, it.title || i18n.untitled);
|
||||
li.appendChild(a);
|
||||
|
||||
var stats = el('span', 'ar-stats');
|
||||
stats.appendChild(buildStat('views', 'i-hot', it.views, i18n.views));
|
||||
stats.appendChild(buildStat('comments', 'i-comments', it.comments, i18n.comments));
|
||||
stats.appendChild(buildStat('love', 'i-good', it.love, i18n.love));
|
||||
li.appendChild(stats);
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个计数
|
||||
*
|
||||
* 结构与 PHP 的 yang_archives_render_stat() 一致(含 data-zero 与 sr-only)。
|
||||
*
|
||||
* @param {string} kind
|
||||
* @param {string} icon
|
||||
* @param {number} num
|
||||
* @param {string} label
|
||||
* @returns {HTMLSpanElement}
|
||||
*/
|
||||
function buildStat(kind, icon, num, label) {
|
||||
var n = parseInt(num, 10);
|
||||
if (!isFinite(n) || n < 0) {
|
||||
n = 0;
|
||||
}
|
||||
|
||||
var s = el('span', 'ar-stat ar-stat-' + kind);
|
||||
s.setAttribute('title', label || '');
|
||||
s.setAttribute('data-zero', n === 0 ? '1' : '0');
|
||||
|
||||
var i = document.createElement('i');
|
||||
i.className = 'kicon ' + icon;
|
||||
i.setAttribute('aria-hidden', 'true');
|
||||
s.appendChild(i);
|
||||
|
||||
s.appendChild(el('span', 'ar-num', formatNum(n)));
|
||||
|
||||
var sr = el('span', 'sr-only', label || '');
|
||||
s.appendChild(sr);
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 两位补零 */
|
||||
function pad2(v) {
|
||||
v = String(v == null ? '' : v);
|
||||
return v.length >= 2 ? v : ('0' + v);
|
||||
}
|
||||
|
||||
/**
|
||||
* 千分位
|
||||
*
|
||||
* 与服务端的 number_format_i18n 对齐。这里只做最常见的"每三位一个
|
||||
* 逗号",不跟随 WP 的语言设置 —— 归档计数是纯数字,差异只在
|
||||
* 少数语言的分隔符上;真要精确对齐,应该把格式化后的字符串
|
||||
* 直接从 PHP 传下来。目前站内是中文/英文,逗号一致。
|
||||
*/
|
||||
function formatNum(n) {
|
||||
var s = String(n);
|
||||
if (s.length <= 3) {
|
||||
return s;
|
||||
}
|
||||
return s.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染某一年的列表
|
||||
*
|
||||
* @param {Object} yd {count, months}
|
||||
* @param {number} year
|
||||
* @param {number} showMonth
|
||||
* @param {Object} i18n
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
function buildYear(yd, year, showMonth, i18n) {
|
||||
var frag = document.createDocumentFragment();
|
||||
if (!yd || !yd.months) {
|
||||
frag.appendChild(el('p', 'ar-empty', i18n.empty));
|
||||
return frag;
|
||||
}
|
||||
|
||||
// 月份升序(1 月在先)—— 与 PHP 的 ksort 一致
|
||||
var months = Object.keys(yd.months).map(Number).sort(function (a, b) {
|
||||
return a - b;
|
||||
});
|
||||
|
||||
if (!months.length) {
|
||||
frag.appendChild(el('p', 'ar-empty', i18n.empty));
|
||||
return frag;
|
||||
}
|
||||
|
||||
months.forEach(function (m) {
|
||||
var md = yd.months[m] || yd.months[String(m)];
|
||||
if (!md) {
|
||||
return;
|
||||
}
|
||||
|
||||
var box = el('div', 'ar-month');
|
||||
|
||||
if (showMonth) {
|
||||
var h = el('h4', 'ar-month-title', fmt(i18n.month, pad2MonthLabel(m)));
|
||||
var cnt = el('span', 'ar-month-count', formatNum(md.count || 0));
|
||||
h.appendChild(cnt);
|
||||
box.appendChild(h);
|
||||
}
|
||||
|
||||
var ul = el('ul', 'ar-list');
|
||||
(md.items || []).forEach(function (it) {
|
||||
ul.appendChild(buildItem(it, m, i18n));
|
||||
});
|
||||
box.appendChild(ul);
|
||||
|
||||
frag.appendChild(box);
|
||||
});
|
||||
|
||||
return frag;
|
||||
}
|
||||
|
||||
/**
|
||||
* 月份标题文字
|
||||
*
|
||||
* PHP 侧用的是 number_format_i18n($m),中文环境就是 "10"。
|
||||
* 这里不做补零(与 PHP 保持一致),只在必要时补。
|
||||
*/
|
||||
function pad2MonthLabel(m) {
|
||||
return String(m);
|
||||
}
|
||||
|
||||
/** 极简 %s 占位符替换(不引入 sprintf 依赖) */
|
||||
function fmt(tpl, val) {
|
||||
if (typeof tpl !== 'string') {
|
||||
return String(val);
|
||||
}
|
||||
return tpl.replace('%s', val);
|
||||
}
|
||||
|
||||
/**
|
||||
* 找一个方向的目标年份
|
||||
*
|
||||
* `order` 是**倒序**年份数组:[2026, 2025, 2024]
|
||||
* · prev(上一年/更近)→ 索引 - 1
|
||||
* · next(下一年/更早)→ 索引 + 1
|
||||
*
|
||||
* 🔴 这个方向与"按下一年却回到过去"的文案一致,见 PHP 里的说明。
|
||||
*
|
||||
* @param {number[]} order
|
||||
* @param {number} year
|
||||
* @param {string} dir prev|next
|
||||
* @returns {number} 0 表示没有
|
||||
*/
|
||||
function pickTarget(order, year, dir) {
|
||||
var i = order.indexOf(Number(year));
|
||||
if (i < 0) {
|
||||
return 0;
|
||||
}
|
||||
var t = dir === 'prev' ? i - 1 : i + 1;
|
||||
return order[t] === undefined ? 0 : Number(order[t]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建导航
|
||||
*
|
||||
* 每次切年都要重建:目标年份变了、禁用态变了。
|
||||
*
|
||||
* @param {HTMLElement} navBox .ar-nav 容器
|
||||
* @param {Object} cfg {order, year, i18n, baseUrl}
|
||||
*/
|
||||
function rebuildNav(navBox, cfg) {
|
||||
if (!navBox) {
|
||||
return;
|
||||
}
|
||||
navBox.innerHTML = '';
|
||||
|
||||
var prev = pickTarget(cfg.order, cfg.year, 'prev');
|
||||
var next = pickTarget(cfg.order, cfg.year, 'next');
|
||||
|
||||
navBox.appendChild(buildNavBtn(prev, 'prev', cfg));
|
||||
navBox.appendChild(buildNavBtn(next, 'next', cfg));
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个导航按钮
|
||||
*
|
||||
* 结构与 PHP 的 yang_archives_render_nav_btn() 一致。
|
||||
*
|
||||
* @param {number} target
|
||||
* @param {string} dir
|
||||
* @param {Object} cfg
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function buildNavBtn(target, dir, cfg) {
|
||||
var isPrev = dir === 'prev';
|
||||
var label = isPrev ? cfg.i18n.prev : cfg.i18n.next;
|
||||
var arrow = isPrev ? '\u2191' : '\u2193';
|
||||
|
||||
if (!target) {
|
||||
var span = el('span', 'ar-nav-btn is-disabled');
|
||||
span.setAttribute('aria-disabled', 'true');
|
||||
span.appendChild(el('span', 'ar-nav-arrow', arrow));
|
||||
span.appendChild(el('span', 'ar-nav-label', label));
|
||||
return span;
|
||||
}
|
||||
|
||||
var a = el('a', 'ar-nav-btn ar-nav-' + dir);
|
||||
/* 回退地址带上目标年份:无 JS / 新标签打开都能用。
|
||||
* 用 URL 对象改参数,不去猜 baseUrl 里有没有 ? —— 手工拼
|
||||
* 字符串在带 ? 的地址上会拼出 `?a=1&arc_year=2` 这种错。 */
|
||||
a.href = buildYearUrl(cfg.baseUrl, target);
|
||||
a.setAttribute('data-year', String(target));
|
||||
a.setAttribute('rel', 'nofollow');
|
||||
|
||||
a.appendChild(el('span', 'ar-nav-arrow', arrow));
|
||||
a.appendChild(el('span', 'ar-nav-label', label));
|
||||
/* 🔴 年份不千分位。`formatNum(2025)` 会输出「2,025」——
|
||||
* 与 PHP 侧的 number_format_i18n 犯的是同一个错,两边一起改。
|
||||
* 年份是编号,不是数量。 */
|
||||
a.appendChild(el('span', 'ar-nav-year', String(target)));
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼 ?arc_year=YYYY
|
||||
*
|
||||
* @param {string} base
|
||||
* @param {number} year
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildYearUrl(base, year) {
|
||||
try {
|
||||
var u = new URL(base || window.location.href, window.location.href);
|
||||
u.searchParams.set('arc_year', String(year));
|
||||
/* 去掉 page 参数:切年后还带着原来的分页页码,在分页型页面上
|
||||
* 会跳到一个不存在的页,表现是"404 或空白"。 */
|
||||
u.searchParams.delete('page');
|
||||
return u.toString();
|
||||
} catch (e) {
|
||||
// URL 不可用(很老的浏览器)就退回字符串拼
|
||||
var s = (base || window.location.pathname);
|
||||
return s + (s.indexOf('?') >= 0 ? '&' : '?') + 'arc_year=' + year;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切到某一年
|
||||
*
|
||||
* @param {HTMLElement} root
|
||||
* @param {Object} data
|
||||
* @param {number} year
|
||||
* @param {boolean} [push] 是否把年份写进地址栏
|
||||
*/
|
||||
function switchTo(root, data, year, push) {
|
||||
var yd = data.years[year] || data.years[String(year)];
|
||||
if (!yd) {
|
||||
// 目标年份没有数据 —— 正常不会走到(导航按 order 算的),
|
||||
// 但**不能静默**:出声比"点了没反应"好查得多。
|
||||
if (window.console && console.warn) {
|
||||
console.warn('[yang-archives] 没有 ' + year + ' 年的归档数据');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var body = root.querySelector('[data-role="body"]');
|
||||
var titleEl = root.querySelector('[data-role="year-title"]');
|
||||
var totalEl = root.querySelector('[data-role="year-total"]');
|
||||
var i18n = data.i18n || {};
|
||||
|
||||
if (body) {
|
||||
body.innerHTML = '';
|
||||
body.appendChild(buildYear(yd, year, data.showMonth, i18n));
|
||||
|
||||
/* 淡入:先移除再加类,确保连续切年时动画每次都会重放
|
||||
* (同一个类名不加移除,浏览器认为没变化,动画不会重跑)。 */
|
||||
body.classList.remove('is-switching');
|
||||
// 读一次布局属性强制回流,让上面的移除生效
|
||||
void body.offsetWidth;
|
||||
body.classList.add('is-switching');
|
||||
}
|
||||
|
||||
if (titleEl) {
|
||||
titleEl.textContent = String(year);
|
||||
}
|
||||
if (totalEl) {
|
||||
totalEl.textContent = fmt(i18n.totalFmt || '共 %s 篇', formatNum(yd.count || 0));
|
||||
}
|
||||
|
||||
root.setAttribute('data-year', String(year));
|
||||
data.current = year;
|
||||
|
||||
// 两个导航(上排 + 下排)都要重建
|
||||
var navs = root.querySelectorAll('.ar-nav');
|
||||
for (var i = 0; i < navs.length; i++) {
|
||||
rebuildNav(navs[i], {
|
||||
order: data.order,
|
||||
year: year,
|
||||
i18n: i18n,
|
||||
baseUrl: data.baseUrl || window.location.href
|
||||
});
|
||||
}
|
||||
|
||||
/* 地址栏同步。用 replaceState 而不是 pushState:
|
||||
* 翻归档年份是"同一次浏览里的筛选",不该在历史里留下十几条记录
|
||||
* (用户按后退会一年一年退回去,体验很差)。
|
||||
* 同时让复制出去的链接能定位到当前年份 —— 这正是 PHP 侧
|
||||
* 「?arc_year 优先于短代码 year」的配套。 */
|
||||
if (push && window.history && typeof window.history.replaceState === 'function') {
|
||||
try {
|
||||
window.history.replaceState(
|
||||
{ arcYear: year },
|
||||
'',
|
||||
buildYearUrl(data.baseUrl || window.location.href, year)
|
||||
);
|
||||
} catch (e) {
|
||||
/* 跨域等情况下 replaceState 会抛。地址栏没同步不影响功能,
|
||||
* 但也不该让整个切换失败 —— 所以只吞掉这一个异常,
|
||||
* 上面的 DOM 已经更新好了。 */
|
||||
}
|
||||
}
|
||||
|
||||
/* 把容器顶部拉回视口。
|
||||
* 🔴 必须放在 DOM 更新**之后**:切年前量的位置,切年后高度变了,
|
||||
* 滚到旧位置可能正好落在空白处。 */
|
||||
scrollToRoot(root);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切年后把容器顶部带回视野
|
||||
*
|
||||
* 只在"用户已经滑过容器顶部"时才滚 —— 否则用户本来就在看列表,
|
||||
* 无端滚动等于把阅读位置丢掉。
|
||||
*
|
||||
* @param {HTMLElement} root
|
||||
*/
|
||||
function scrollToRoot(root) {
|
||||
try {
|
||||
var rect = root.getBoundingClientRect();
|
||||
var headRoom = 60; // 预留一点顶部空间,别贴着视口边缘
|
||||
if (rect.top < headRoom) {
|
||||
var reduce = window.matchMedia
|
||||
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
root.scrollIntoView({
|
||||
behavior: reduce ? 'auto' : 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
/* getBoundingClientRect / scrollIntoView 在极老浏览器上可能没有,
|
||||
* 或元素已从文档里移除。滚不动不影响功能,静默即可。 */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化一个容器
|
||||
*
|
||||
* @param {HTMLElement} root
|
||||
*/
|
||||
function initRoot(root) {
|
||||
// 幂等:同一个容器被初始化两次会导致一次点击切两年
|
||||
if (root.getAttribute('data-ar-ready') === '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
var data = readData(root);
|
||||
if (!data || !data.order || !data.order.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.setAttribute('data-ar-ready', '1');
|
||||
|
||||
/* 事件委托:click 挂在容器上。
|
||||
* 只接管"左键 + 无修饰键 + 落在 .ar-nav-btn 上"的点击,
|
||||
* 其余(中键、Ctrl/Cmd/Shift + 点击)交给浏览器默认行为 ——
|
||||
* 这样"新标签打开某一年"照常可用。 */
|
||||
root.addEventListener('click', function (ev) {
|
||||
if (ev.defaultPrevented || ev.button !== 0) {
|
||||
return;
|
||||
}
|
||||
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
var btn = ev.target && ev.target.closest
|
||||
? ev.target.closest('.ar-nav-btn')
|
||||
: null;
|
||||
if (!btn || !root.contains(btn)) {
|
||||
return;
|
||||
}
|
||||
if (btn.classList && btn.classList.contains('is-disabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var target = parseInt(btn.getAttribute('data-year'), 10);
|
||||
if (!target || target === data.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 接管:不刷新页面
|
||||
ev.preventDefault();
|
||||
switchTo(root, data, target, true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动
|
||||
*
|
||||
* 脚本在页脚输出(`in_footer = true`),正常此时 DOM 已就绪;
|
||||
* 但仍然判一次 readyState —— 万一被别的插件提前加载,
|
||||
* 直接 `querySelectorAll` 会拿到空集,页面就永久不工作了。
|
||||
*/
|
||||
function boot() {
|
||||
var roots = document.querySelectorAll(ROOT_SEL);
|
||||
for (var i = 0; i < roots.length; i++) {
|
||||
initRoot(roots[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
+55
-15
@@ -37,6 +37,32 @@ if (isset($_SERVER['SCRIPT_FILENAME']) && 'comments.php' == basename($_SERVER['S
|
||||
die();
|
||||
}
|
||||
|
||||
/* ── 3.2.46 评论关闭时整块不输出 ──────────────────────────────
|
||||
*
|
||||
* 以前这里会渲染一个 `.comments` 容器,并在里面打一句
|
||||
* 「评论已关闭。」/「评论已关闭,但你可以阅读上面的讨论。」
|
||||
* —— 用户明确要求:**评论一关,这一块整个消失**,
|
||||
* 不留提示文字、不留空容器、已有评论也不再显示。
|
||||
* 正文结束之后直接就是页脚。
|
||||
*
|
||||
* 🔴 为什么是 `return` 而不是把内容包进 `if (comments_open())`:
|
||||
* 包起来的话,容器 `<div class="comments">` 仍会被输出(一个空盒子),
|
||||
* 而主题给 `.comments` 有 margin/padding,页面上会留下一道空白 ——
|
||||
* 看起来像"有个东西没加载出来"。`return` 是**一个字节都不输出**,
|
||||
* 这才是需求要的"什么都没有"。
|
||||
*
|
||||
* 🔴 必须放在**任何输出之前**:这个位置在文件里还没有 echo 过任何东西,
|
||||
* 所以 return 是干净的。一旦挪到后面,前面的 HTML 已经发出去了,
|
||||
* 再加 return 只会得到一个残疾的残块。
|
||||
*
|
||||
* ⚠️ `comments.php` 是被 `comments_template()` include 的,
|
||||
* 在它里面 `return` 只会结束这个模板文件的执行,
|
||||
* 不会中断 single.php / page.php 的后续渲染。
|
||||
*/
|
||||
if (!comments_open()) {
|
||||
return;
|
||||
}
|
||||
|
||||
require get_template_directory() . '/pages/page-smilies.php';
|
||||
|
||||
$yang_post_id = get_the_ID();
|
||||
@@ -71,20 +97,20 @@ if ($yang_per_page <= 0) {
|
||||
}
|
||||
?>
|
||||
</h3>
|
||||
<?php if (comments_open()) : ?>
|
||||
<a class="comments-jump-form" href="#respond"><?php esc_html_e('写下你的评论', 'kratos'); ?></a>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
/* 3.2.46:原本是 `if (comments_open())`。文件开头已在评论关闭时
|
||||
return,所以这里恒真,条件已去掉。留着它会让后来人以为
|
||||
"还另有一个关评论的分支"。 */
|
||||
?>
|
||||
<a class="comments-jump-form" href="#respond"><?php esc_html_e('写下你的评论', 'kratos'); ?></a>
|
||||
</div>
|
||||
|
||||
<?php if (!comments_open() && $yang_comment_count === 0) : ?>
|
||||
|
||||
<p class="comments-closed"><?php esc_html_e('评论已关闭。', 'kratos'); ?></p>
|
||||
|
||||
<?php elseif (!comments_open()) : ?>
|
||||
|
||||
<p class="comments-closed"><?php esc_html_e('评论已关闭,但你可以阅读上面的讨论。', 'kratos'); ?></p>
|
||||
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
/* 3.2.46 起这里不再有「评论已关闭」提示分支:
|
||||
文件开头已经 `if (!comments_open()) return;`,走不到这一行。
|
||||
原先两句「评论已关闭。」「评论已关闭,但你可以阅读上面的讨论。」
|
||||
是用户明确要求删掉的 —— 关评论就该整块消失。 */
|
||||
?>
|
||||
|
||||
<?php if ($yang_has_comments) : ?>
|
||||
<?php
|
||||
@@ -135,13 +161,24 @@ if ($yang_per_page <= 0) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<?php elseif (comments_open()) : ?>
|
||||
<?php
|
||||
/* 3.2.46:这里原本是 `elseif (comments_open())`。文件开头已经
|
||||
在评论关闭时 `return`,所以走到这里时 comments_open() **恒真** ——
|
||||
保留那个条件只会变成一句永远为真的死判断,后来人读到这里会
|
||||
以为"还有个关评论的分支要照顾"。改成 else。 */
|
||||
?>
|
||||
<?php else : ?>
|
||||
|
||||
<p class="comments-empty"><?php esc_html_e('还没有评论,来说第一句吧。', 'kratos'); ?></p>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (comments_open()) : ?>
|
||||
<?php
|
||||
/* 3.2.46:原本是 `if (comments_open())`,同上,恒真,已去掉条件。
|
||||
下面的登录提示 / 评论表单因此只在"评论开放"时才走到这里 ——
|
||||
这正是需求要的:关评论时整块不输出(而且文件开头就 return 了,
|
||||
连外层 .comments 容器都不会有)。 */
|
||||
?>
|
||||
|
||||
<?php if (get_option('comment_registration') && !is_user_logged_in()) : ?>
|
||||
|
||||
@@ -416,5 +453,8 @@ if ($yang_per_page <= 0) {
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
<?php endif; ?>
|
||||
<?php /* 3.2.46:原来这里还有一个 `endif;` 关掉外层的
|
||||
`if (comments_open())`。那个条件已删除,所以这里也少了对应的
|
||||
endif —— 多的那个会导致 "syntax error"。 */ ?>
|
||||
|
||||
</div>
|
||||
@@ -60,6 +60,11 @@ require get_template_directory() . '/inc/theme-shortcode.php';
|
||||
// 文章目录([TOC] 短代码)
|
||||
require get_template_directory() . '/inc/theme-toc.php';
|
||||
|
||||
// 归档式文章列表([Archives] 短代码):年/月归类 + 浏览/评论/点赞计数 + 四角年份切换。
|
||||
// 必须排在 theme-article.php 之后:计数读的是它写的 post meta(views / love)。
|
||||
// 也必须排在 theme-core.php 之后:它用 ASSET_PATH 与 THEME_VERSION 挂资源。
|
||||
require get_template_directory() . '/inc/theme-archives.php';
|
||||
|
||||
// 添加导航目录
|
||||
require get_template_directory() . '/inc/theme-navwalker.php';
|
||||
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
<?php
|
||||
/**
|
||||
* 归档式文章列表([Archives] 短代码)
|
||||
*
|
||||
* ── 用法 ──────────────────────────────────────────────────────
|
||||
* 在文章或页面正文里任意位置写一行:
|
||||
*
|
||||
* [Archives]
|
||||
*
|
||||
* 这个位置就会渲染出全站文章的**年 / 月归档列表**:
|
||||
*
|
||||
* · 默认显示**今年**,按月份归类(只列有文章的月份)
|
||||
* · 每篇后面带 **浏览数 / 评论数 / 点赞数**
|
||||
* · 列表上左/上右、下左/下右四角各有「上一年 / 下一年」按钮
|
||||
* · 切年**纯 JS 重建 DOM,不刷新页面**
|
||||
*
|
||||
* 可调参数(都可省略):
|
||||
*
|
||||
* [Archives year="2025" month="1" nav="1"]
|
||||
*
|
||||
* · year 首次显示哪一年,默认今年;也可用 ?arc_year=2025 覆盖
|
||||
* · month 1 = 显示月份分组标题,0 = 只平铺列表,默认 1
|
||||
* · nav 1 = 显示四角年份导航,0 = 不显示,默认 1
|
||||
*
|
||||
* ── 为什么不用 wp_get_archives() ───────────────────────────────
|
||||
* 三个原因,每个都足以否掉它:
|
||||
* ① 它拿不到计数。要显示浏览数/评论数/点赞数,只能每篇再查一次 ——
|
||||
* 71 篇就是 71 次查询(外加 2×71 次 meta 查询)。
|
||||
* ② 它的输出格式固定(`<li><a>…</a></li>` 或下拉框),塞不进三项计数。
|
||||
* ③ 它按「按月/按年」二选一输出**整条时间线**,没有"给我某一年"的
|
||||
* 接口,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 <yangyq.net>
|
||||
* @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 .= '<div class="ar-month">';
|
||||
$out .= '<h4 class="ar-month-title">'
|
||||
. sprintf(
|
||||
/* translators: 1: 月份数字 2: 该月文章数 */
|
||||
esc_html__('%1$s 月', 'kratos'),
|
||||
/* 🔴 月份同样不过 number_format_i18n —— 它会把 10 月
|
||||
* 变成「10」没问题,但一旦有人把月份格式改成两位数
|
||||
* 或站点语言用非逗号分隔,就会与 JS 侧(String(m))
|
||||
* 不一致。月份是编号。 */
|
||||
esc_html((string) $m)
|
||||
)
|
||||
. '<span class="ar-month-count">'
|
||||
. esc_html(number_format_i18n($mdata['count']))
|
||||
. '</span>'
|
||||
. '</h4>';
|
||||
} else {
|
||||
$out .= '<div class="ar-month">';
|
||||
}
|
||||
|
||||
$out .= '<ul class="ar-list">';
|
||||
|
||||
foreach ($mdata['items'] as $it) {
|
||||
$out .= yang_archives_render_item($it, $m);
|
||||
}
|
||||
|
||||
$out .= '</ul></div>';
|
||||
}
|
||||
|
||||
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 = '<li class="ar-item">';
|
||||
|
||||
$out .= '<time class="ar-date" datetime="' . esc_attr($it['date']) . '">'
|
||||
. esc_html($md) . '</time>';
|
||||
|
||||
$out .= '<a class="ar-title" href="' . esc_url($it['url']) . '">'
|
||||
. esc_html($it['title']) . '</a>';
|
||||
|
||||
/* 三项计数。用的 i-hot / i-comments / i-good 是主题自带图标集里
|
||||
* 已经在用的三个(page-content.php 同款),不引入新图标,
|
||||
* 免得字体包里缺字形变成豆腐块。
|
||||
*
|
||||
* `data-zero="1"` 给零值打标记,CSS 据此弱化 —— 站内大部分文章的
|
||||
* 评论数和点赞数就是 0,三项同等强调会让计数列看起来全是「0 0 0」,
|
||||
* 反而看不出哪篇有热度。 */
|
||||
$out .= '<span class="ar-stats">';
|
||||
|
||||
$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 .= '</span>';
|
||||
|
||||
$out .= '</li>';
|
||||
|
||||
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 '<span class="ar-stat ar-stat-' . esc_attr($kind) . '"'
|
||||
. ' title="' . esc_attr($label) . '"'
|
||||
. ' data-zero="' . ($num === 0 ? '1' : '0') . '">'
|
||||
. '<i class="kicon ' . esc_attr($icon) . '" aria-hidden="true"></i>'
|
||||
. '<span class="ar-num">' . esc_html(number_format_i18n($num)) . '</span>'
|
||||
. '<span class="sr-only">' . esc_html($label) . '</span>'
|
||||
. '</span>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 四角导航
|
||||
*
|
||||
* 语义按用户要求:**左侧「上一年」= 回到更近的年份**,
|
||||
* **右侧「下一年」= 去更早的年份**。
|
||||
*
|
||||
* 🔴 这个方向初看是反的("下一年"却往过去走),但用户明确写了两遍
|
||||
* 示例(2026 → 2025 → 2024),所以行为按示例来。
|
||||
* 为了不让人误点,按钮上除了文案还直接显示**目标年份**,
|
||||
* 这样"点下去会去哪"一眼可见,不必靠文案猜。
|
||||
*
|
||||
* 用的是 `<button>` 而不是 `<a href>`:
|
||||
* · 切年不刷新页面,`<a>` 的默认行为要 `preventDefault` 才有意义,
|
||||
* 而无 JS 时 `<a>` 带 `?arc_year=` 还能用 —— 这点是有价值的。
|
||||
* · 所以这里**两者结合**:`<a href="?arc_year=YYYY">` 作为真实
|
||||
* 回退(新标签/无 JS 都能用),JS 接管后 `preventDefault` 改走
|
||||
* DOM 重建。这样"不刷新"是增强,不是唯一路径。
|
||||
*
|
||||
* 🔴 `$variant` 必须由调用方给(top / bottom):
|
||||
* 第一版把 `ar-nav ar-nav-top` 写死在这里,而下排也是调这个函数生成的
|
||||
* ⇒ 下排同时带上了 `ar-nav-top` 类名。虽然 `.ar-foot .ar-nav`
|
||||
* (0,3,0)特异性高于 `.ar-nav-top`(0,2,0)把它压住了、视觉上没坏,
|
||||
* 但类名本身就是错的("下排"不是"top"),CSS/JS 以后想按位置区分
|
||||
* 就无从下手,测试也没法分辨上下排。**位置由调用方决定,不由渲染函数猜。**
|
||||
*
|
||||
* @param int $year 当前年份
|
||||
* @param array $order 有文章的年份(倒序)
|
||||
* @param string $variant top|bottom,只用于类名
|
||||
* @return string
|
||||
*/
|
||||
function yang_archives_render_nav($year, $order, $variant = 'top')
|
||||
{
|
||||
$pos = array_search($year, $order, true);
|
||||
if ($pos === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
/* order 是倒序:[2026, 2025, 2024]
|
||||
* · 更近的年份(上一年)在 pos-1
|
||||
* · 更早的年份(下一年)在 pos+1 */
|
||||
$prev = isset($order[$pos - 1]) ? (int) $order[$pos - 1] : 0;
|
||||
$next = isset($order[$pos + 1]) ? (int) $order[$pos + 1] : 0;
|
||||
|
||||
$variant = ($variant === 'bottom') ? 'bottom' : 'top';
|
||||
|
||||
$out = '<div class="ar-nav ar-nav-' . esc_attr($variant) . '">';
|
||||
$out .= yang_archives_render_nav_btn($prev, 'prev', $year);
|
||||
$out .= yang_archives_render_nav_btn($next, 'next', $year);
|
||||
$out .= '</div>';
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个导航按钮
|
||||
*
|
||||
* @param int $target 目标年份,0 表示没有
|
||||
* @param string $dir prev|next
|
||||
* @param int $year 当前年份(拼 URL 用)
|
||||
* @return string
|
||||
*/
|
||||
function yang_archives_render_nav_btn($target, $dir, $year)
|
||||
{
|
||||
$is_prev = ($dir === 'prev');
|
||||
$label = $is_prev ? __('上一年', 'kratos') : __('下一年', 'kratos');
|
||||
$arrow = $is_prev ? '↑' : '↓';
|
||||
|
||||
if ($target === 0) {
|
||||
// 没有目标年 → 禁用态。用 <span> 而不是 disabled 的 <a>:
|
||||
// <a> 没有 disabled 语义,加了也不生效,还会被键盘 Tab 到。
|
||||
return '<span class="ar-nav-btn is-disabled" aria-disabled="true">'
|
||||
. '<span class="ar-nav-arrow" aria-hidden="true">' . $arrow . '</span>'
|
||||
. '<span class="ar-nav-label">' . esc_html($label) . '</span>'
|
||||
. '</span>';
|
||||
}
|
||||
|
||||
return '<a class="ar-nav-btn ar-nav-' . esc_attr($dir) . '"'
|
||||
. ' href="' . esc_url(add_query_arg('arc_year', $target)) . '"'
|
||||
. ' data-year="' . esc_attr($target) . '"'
|
||||
. ' rel="nofollow">'
|
||||
. '<span class="ar-nav-arrow" aria-hidden="true">' . $arrow . '</span>'
|
||||
. '<span class="ar-nav-label">' . esc_html($label) . '</span>'
|
||||
/* 🔴 年份**绝不能**过 number_format_i18n:它会输出「2,025」。
|
||||
* 实测证据(e2e):首屏 PHP 出的是「2,025」,
|
||||
* 而 JS 切年后写的是 String(year) = 「2025」——
|
||||
* 同一个按钮点一下就变样,像 bug 一样。
|
||||
* 年份是编号不是数量,任何时候都不该有千分位。 */
|
||||
. '<span class="ar-nav-year">' . esc_html((string) $target) . '</span>'
|
||||
. '</a>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 短代码主体
|
||||
*
|
||||
* @param array $atts
|
||||
* @return string
|
||||
*/
|
||||
function yang_archives_shortcode($atts)
|
||||
{
|
||||
$opt = yang_archives_atts($atts);
|
||||
$data = yang_archives_data();
|
||||
|
||||
if (empty($data['order'])) {
|
||||
return '<div class="yang-archives"><p class="ar-empty">'
|
||||
. esc_html__('还没有任何文章。', 'kratos') . '</p></div>';
|
||||
}
|
||||
|
||||
$year = yang_archives_pick_year($data, $opt['year']);
|
||||
if ($year === 0) {
|
||||
return '<div class="yang-archives"><p class="ar-empty">'
|
||||
. esc_html__('还没有任何文章。', 'kratos') . '</p></div>';
|
||||
}
|
||||
|
||||
/* 资源按需加载:只在真的用了短代码的页面加载。
|
||||
* 与 theme-toc.php 同一套做法(那边也是 wp_enqueue_scripts 里
|
||||
* 判 has_marker 再 enqueue)。这里更直接 —— 短代码已经开始渲染了,
|
||||
* 说明页面确实要用,直接 enqueue 即可。
|
||||
*
|
||||
* 🔴 为什么必须走 wp_enqueue_* 而不是手写 <link>:
|
||||
* 主题的 ASSET_PATH 会随「CDN 加速」选项改变(inc/theme-cdn.php),
|
||||
* 手写相对路径会绕过 CDN 改写,也拿不到 THEME_VERSION 做缓存失效。 */
|
||||
wp_enqueue_style(
|
||||
'yang-archives',
|
||||
ASSET_PATH . '/assets/css/archives.css',
|
||||
array('kratos'),
|
||||
THEME_VERSION
|
||||
);
|
||||
wp_enqueue_script(
|
||||
'yang-archives',
|
||||
ASSET_PATH . '/assets/js/archives.js',
|
||||
array(),
|
||||
THEME_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
/* 把全部年份的数据交给 JS。
|
||||
*
|
||||
* 🔴 用 `<script type="application/json">` 而不是内联 `var data = {...}`:
|
||||
* ① 不污染全局作用域;
|
||||
* ② 内容不需要转义 `</script>`(下面还有一道保险);
|
||||
* ③ 浏览器不执行它,CSP 里 `unsafe-inline` 也管不到。
|
||||
*
|
||||
* 🔴 JSON_HEX_TAG 等四个 flag 是**必须**的:文章标题里出现
|
||||
* `</script>` 时,若不把这四个字符转成 \\u003C 之类,
|
||||
* 浏览器的 HTML 解析器会在标题中间就把脚本块截断 ——
|
||||
* 页面后面全部错乱,而且看起来像"某个标题把页面搞坏了"。 */
|
||||
$json = wp_json_encode(
|
||||
array(
|
||||
'years' => $data['years'],
|
||||
'order' => $data['order'],
|
||||
'latest' => $data['latest'],
|
||||
'total' => $data['total'],
|
||||
'current' => $year,
|
||||
'showMonth' => $opt['month'],
|
||||
'showNav' => $opt['nav'],
|
||||
'baseUrl' => get_permalink(),
|
||||
'i18n' => array(
|
||||
'month' => __('%s 月', 'kratos'),
|
||||
'prev' => __('上一年', 'kratos'),
|
||||
'next' => __('下一年', 'kratos'),
|
||||
'views' => __('浏览数', 'kratos'),
|
||||
'comments' => __('评论数', 'kratos'),
|
||||
'love' => __('点赞数', 'kratos'),
|
||||
'untitled' => __('(无标题)', 'kratos'),
|
||||
'empty' => __('这一年还没有文章。', 'kratos'),
|
||||
'totalFmt' => __('共 %s 篇', 'kratos'),
|
||||
),
|
||||
),
|
||||
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
|
||||
);
|
||||
|
||||
$uid = 'yang-archives-' . wp_unique_id();
|
||||
|
||||
$out = '<div class="yang-archives" id="' . esc_attr($uid) . '"'
|
||||
. ' data-year="' . esc_attr($year) . '">';
|
||||
|
||||
// 顶部:年份标题 + 四角导航上排
|
||||
$out .= '<div class="ar-head">';
|
||||
/* 🔴 年份标题同样**不能**过 number_format_i18n(会变「2,025」)。
|
||||
* 而且要输出纯数字,与 JS 切年后的 `String(year)` 完全一致 ——
|
||||
* 两边不一致就是「首屏 2,025、点一下 2025」。 */
|
||||
$out .= '<h3 class="ar-year" data-role="year-title">'
|
||||
. esc_html((string) $year) . '</h3>';
|
||||
|
||||
$out .= '<span class="ar-total" data-role="year-total">'
|
||||
. sprintf(
|
||||
/* translators: %s: 文章数 */
|
||||
esc_html__('共 %s 篇', 'kratos'),
|
||||
esc_html(number_format_i18n($data['years'][$year]['count']))
|
||||
)
|
||||
. '</span>';
|
||||
|
||||
if ($opt['nav']) {
|
||||
$out .= yang_archives_render_nav($year, $data['order'], 'top');
|
||||
}
|
||||
|
||||
$out .= '</div>';
|
||||
|
||||
// 列表主体
|
||||
$out .= '<div class="ar-body" data-role="body">';
|
||||
$out .= yang_archives_render_year($data['years'][$year], $year, $opt['month']);
|
||||
$out .= '</div>';
|
||||
|
||||
// 底部导航(四角的下排)
|
||||
if ($opt['nav']) {
|
||||
$out .= '<div class="ar-foot">';
|
||||
$out .= yang_archives_render_nav($year, $data['order'], 'bottom');
|
||||
$out .= '</div>';
|
||||
}
|
||||
|
||||
$out .= '<script type="application/json" data-role="data">'
|
||||
. $json . '</script>';
|
||||
|
||||
$out .= '</div>';
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/* WP 的 shortcode 标签是**大小写不敏感**的(它内部统一 strtolower),
|
||||
* 所以注册小写 `archives` 就能同时匹配 [Archives] / [ARCHIVES] / [archives]。
|
||||
* 这里按 WP 惯例注册小写。 */
|
||||
add_shortcode('archives', 'yang_archives_shortcode');
|
||||
@@ -744,6 +744,22 @@ function yang_comment_scripts()
|
||||
return;
|
||||
}
|
||||
|
||||
/* 3.2.46:评论关闭时 comments.php 会整块 return(一个字节都不输出),
|
||||
* 那么这套 10KB 的评论样式与脚本就**永远用不到**。
|
||||
*
|
||||
* 判据必须与 comments.php 的那个 return 同口径,否则会出现两种错:
|
||||
* · 这里判"开"、那边判"关" → 加载了资源却没有 DOM 可绑,
|
||||
* comments.js 里找 #commentform 的逻辑全部静默落空(无害但白费);
|
||||
* · 这里判"关"、那边判"开" → 评论框能显示但没样式,一眼就散架。
|
||||
* 两边都用 `comments_open()`(后者不带参数,取当前文章)。
|
||||
*
|
||||
* ⚠️ 必须放在 is_singular() 之后:在非单页上 comments_open() 没有
|
||||
* 明确的主语,WP 会退回 $post 全局,可能取到列表里最后一篇文章的
|
||||
* 状态 —— 那样判断结果没有意义。 */
|
||||
if (!comments_open(get_queried_object_id())) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* wpDiscuz 在场时**不加载**主题这套脚本与样式。
|
||||
*
|
||||
* 原因:插件会接管评论表单(换成它自己的 #wpdcom),并绑定自己的
|
||||
|
||||
Reference in New Issue
Block a user