Files
laoyang 492d8e11a6 3.2.48: 归档倒序/字号/底边对齐/共N篇 + 评论标题线等长 + 工具条分隔线统一 #18
工单 #18 八条诉求逐条落地:

1. 月份、日期、年份全部倒序(最近的在最上面)
   - PHP 侧 `krsort($years[$y]['months'])`(原来是 ksort)
   - JS 侧 `Object.keys(...).sort((a,b)=>b-a)` —— 对象键序对纯数字整数键
     会强制升序,服务端顺序传不过来,必须前端自己再排一次
   - 年份本来就是 krsort,日期本来就是倒序,本次只补月份

2. 年份字体再大一点
   - 原来 CSS 写的 22px **从来没有生效过**:`.yang-archives .ar-year` 是
     (0,2,0),被 `.k-main .details .article .content h3` 的 (0,4,1) 压死,
     线上实际是 18px
   - 改为带 `.k-main` 前缀(提到 (0,5,0))+ 26px + 700
   - `.ar-month-title` 同一个坑,一并修(写 14px/600,线上实际 16px/500)

3. 「共 N 篇」太高、比年份还高 → 下移与年份底边对齐
   - `.ar-head` 的 `align-items: center` → `flex-end`
   - `.ar-year` 的 margin 显式清零(kratos 的 `h1..h6 { margin: 0 0 30px 0 }`)
   - 窄屏媒体查询同步改,否则手机上又回到居中
   - 注意 `flex-end` 对的是 margin box,所以 margin 不清零等于没改

4. 每月后面的裸数字 → 「共 N 篇」
   - PHP:`sprintf(esc_html__('共 %s 篇'), number_format_i18n(...))`
   - JS:同一个文案键 `i18n.monthCount`,服务端输出到 `yangArchivesData`
   - 新增 `monthCount` 键,**没有复用** `totalFmt`(两处语义不同,
     以后要分别改文案时不至于互相牵连)

5. 上一年/下一年按钮看不清 → 与「写下你的评论」同款观感
   - 平时:`1px solid #cfe6fb` 浅蓝框 + 透明底 + `--yq-primary-dark` 蓝字
   - 悬停:主色实底 + 白字(`.ar-nav-year` / `.ar-nav-arrow` 也要跟白,
     否则图标还是蓝的在蓝底上看不见)
   - 禁用态保持灰(`is-disabled` + opacity .42),不能跟可用态一个样

6. 评论「评论者」下面的灰线太短 → 延长到与评论分隔线等长、右侧到头
   - 🔴 这里改过一次方案:一开始以为把 h3 `flex: 1 1 auto` 撑宽就行,
     实测发现 `.comments-head` 是 flex 行,右边还有「写下你的评论」按钮
     (flex: 0 0 auto),h3 撑满也只能长到按钮左边缘 —— 原理上到不了右端
     (实测 h3 110.5px / li 561.98px,差的 451px 就是按钮 + gap)
   - 最终方案:**线从 h3 搬到 `.comments-head` 容器上**,
     h3 显式 `border-bottom: none` 干掉 kratos 的 #f0f0f0,
     否则会出现一短一长两条线,比原来更难看
   - 容器那条规则**还必须带 `.k-main` 前缀**((0,5,0)),否则压不过
     kratos 的 `.comments .title`。发布判据当场抓到这一条:注释里写了
     「带 .k-main 前缀」,代码里漏了 —— 注释不能当证据

7. 工具条分隔细线不统一 → 5 个按钮 4 条线
   - 原来 B/I 之间没有线,其余有 ⇒ 现在统一
   - 写成「每条后面跟一条」会在末尾多出贴容器边缘的孤线,所以是 5 按钮 4 线

8. 清理无用 JS/CSS
   - 四轮查证结论:**没有可安全删除的**,代码一行未动
   - 排查过程已作为评论发到工单

测试:归档单测 113/0(反向 13 真红)、归档 e2e 112/0(反向 24 真红)、
评论几何 157/0(反向含新增 5 条,覆盖"线搬回标题""两处都画"两种错误形态)。
线上真实页面几何探针发布前 17 通过/16 失败(每条都对应上面某一条诉求),
发布后要求全绿。
2026-10-26 18:42:40 +08:00

550 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* 归档式文章列表([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;
}
/* 月份**倒序**(12 月在先)—— 与 PHP 的 krsort 一致。
*
* 🔴 3.2.48 从升序改成降序,必须与 inc/theme-archives.php 那个
* krsort 同向。这里写死方向、不读服务端顺序,是因为切年时
* JS 拿到的 JSON 里 months 是**对象**,对象键序在 JS 里对
* 纯数字键会**自动升序**(ECMAScript 的整数索引规则),
* `Object.keys()` 得到的本来就是 1..12 升序 —— 也就是说
* 「照抄服务端顺序」这件事在这里根本做不到,只能自己排。
*/
var months = Object.keys(yd.months).map(Number).sort(function (a, b) {
return b - a;
});
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)));
/* 3.2.48:裸数字 → 「共 N 篇」,与 PHP 侧同一文案。
* i18n.monthCount 由 PHP 透传('共 %s 篇'),这里只做占位替换,
* **不要**在 JS 里硬写中文 —— 站点语言切换时两侧会不一致。 */
var cnt = el('span', 'ar-month-count',
fmt(i18n.monthCount || '共 %s 篇', 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();
}
})();