3.2.18: 清明按节气换算、主题内 [TOC] 目录、重建评论区替代 wpDiscuz
三项功能:
1. 清明不再用固定 04-04
清明是节气,日期在 4/4~4/6 之间浮动。硬编码 04-04 实测 1900-2100
共 201 年里只有 76 年是对的(约 38%)。改为内嵌逐年表换算真实日期,
超出 1900-2100 返回 null 明确放弃,不退化成近似值。
兼容 3.2.17 及以前勾过的 '04-04' 键,升级后勾选不会被静默丢弃。
2. 主题内实现 [TOC]
文章里写 [TOC],该位置输出本文目录,点击跳到对应标题。
在 the_content 优先级 9 处理(早于 wpautop 10 / do_shortcode 11),
保证"补 id"与"扫目录"用的是同一份文本,href 与 id 必然对得上。
嵌套用深度归一化,h1→h3 跳级不会算错层级。
scroll-margin-top 声明式让位,原生锚点/外部 #hash/后退全都生效。
3. 重建评论区
旧实现的问题:列表渲染与 AJAX 渲染各写一份 HTML,已经分叉
(AJAX 返回的新评论没有回复按钮);<li> 被 <div> 包着,嵌套回复错位。
现在统一到 yang_comment_html() 单一渲染函数,列表与 AJAX 只差一个
is-new 类。同时修掉三处真实缺陷:
· 评论项用 flex 会把 <ol class="yang-children"> 当成第三个 flex 项,
子评论跑到父评论右侧、正文被挤到 156px、页面横向溢出 75px —— 改 grid
· 成功提示在 exitReplyMode() 之前设置,被其末尾的 setStatus('','')
擦掉,用户提交成功却看不到反馈 —— 调整顺序
· 窄屏媒体查询打不过深层选择器(媒体查询不增加特异性),
嵌套头像反向变大 —— 补深层覆盖
与 wpDiscuz 解耦:邮件模块不再无条件 return false(那等于永久关掉
notify_postauthor 且不报错);插件在场时不加载主题评论资源,
避免双份表单互抢。
验证:PHP 语法 73/73;_static_qingming 36、_test_toc 53、
_test_comments 62、_test_comments_falsify 17、
_toc_geometry 121、_toc_geometry_falsify 9、
_comments_geometry 98、_comments_geometry_falsify 23,全部 0 失败。
线上端到端 29/0(真实文章渲染目录 + 真实评论数据渲染)。
This commit is contained in:
@@ -0,0 +1,893 @@
|
||||
/*!
|
||||
* 评论区样式
|
||||
* Yongquan Yang <yangyq.net>
|
||||
*
|
||||
* ── 为什么整块重写,而不是在旧样式上改 ──────────────────────
|
||||
* 旧样式的骨架是「头像 float:left + 右侧 margin-left 挤出来」,
|
||||
* 这个结构在三个地方会坏:
|
||||
* · 长昵称/长网址会把头部撑破(float 不定宽容器里没有约束);
|
||||
* · 嵌套回复靠 .children { padding-left:0 } 实现,等于**没有缩进**,
|
||||
* 三层以后完全看不出谁回谁;
|
||||
* · 头像 50px 写死在 img 上,但没有 flex 居中,头像和第一行文字
|
||||
* 基线对不齐(看起来"文字比头像低一点")。
|
||||
*
|
||||
* 所以改用 flex 两列布局:头像固定宽、主体 flex:1,
|
||||
* 并把嵌套缩进做成明确的层级视觉(左侧引导线)。
|
||||
*/
|
||||
|
||||
/*--------------------------------------------------------------
|
||||
1. 区块与标题
|
||||
--------------------------------------------------------------*/
|
||||
|
||||
.k-main .details .comments {
|
||||
margin-top: 16px;
|
||||
padding: 20px 22px 22px;
|
||||
border-radius: 4px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.k-main .details .post-2 .comments {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.comments-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.comments-head .title {
|
||||
margin: 0;
|
||||
color: #1f2328;
|
||||
font-weight: 600;
|
||||
font-size: 19px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.comments-jump-form {
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 13px;
|
||||
border: 1px solid #d9e4f5;
|
||||
border-radius: 3px;
|
||||
background-color: #f4f8ff;
|
||||
color: #0b74d1;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.comments-jump-form:hover {
|
||||
border-color: #0b74d1;
|
||||
background-color: #e8f1ff;
|
||||
color: #0b74d1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.comments-closed,
|
||||
.comments-empty {
|
||||
margin: 20px 0 4px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 3px;
|
||||
background-color: #fafbfc;
|
||||
color: #8a9099;
|
||||
font-size: 13.5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/*--------------------------------------------------------------
|
||||
2. 评论列表
|
||||
--------------------------------------------------------------*/
|
||||
|
||||
.yang-comment-list,
|
||||
.yang-children {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.yang-comment-list {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 顶层评论之间用细分隔线;嵌套评论之间不用,靠缩进区分 */
|
||||
.yang-comment-list > .yang-comment {
|
||||
border-bottom: 1px solid #f5f6f8;
|
||||
}
|
||||
|
||||
.yang-comment-list > .yang-comment:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
/*--------------------------------------------------------------
|
||||
3. 单条评论
|
||||
--------------------------------------------------------------*/
|
||||
|
||||
/* 单条评论的布局:头像列 + 主体列
|
||||
*
|
||||
* ⚠️ 这里**必须是 grid,不能是 flex**。
|
||||
* 原因:wp_list_comments 输出的是
|
||||
* <li class="yang-comment">
|
||||
* <div class="yang-comment-avatar">…</div>
|
||||
* <div class="yang-comment-main">…</div>
|
||||
* <ol class="yang-children">…子评论…</ol> ← 也是 li 的直接子元素
|
||||
* </li>
|
||||
* 若 li 用 display:flex,那个 <ol> 就成了**第三个 flex 项**,
|
||||
* 被排到主体右边的同一行里 —— 表现为"子评论跑到父评论右侧"、
|
||||
* 父评论正文被挤成一条、页面出现横向溢出。这是真实渲染量出来的,
|
||||
* 光看代码几乎想不到(flex 项的位置不体现在 li 自己的属性上)。
|
||||
*
|
||||
* grid 用两列:第 1 列 auto(由头像定宽),第 2 列 minmax(0,1fr)
|
||||
* (minmax 的下界 0 是关键,否则长英文/长 URL 会把列撑宽)。
|
||||
* 子评论用 grid-column: 1 / -1 独占下一整行。 */
|
||||
.yang-comment {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
column-gap: 12px;
|
||||
align-items: start;
|
||||
padding: 16px 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* 子评论容器独占整行,排在头像与主体下方 */
|
||||
.yang-comment > .yang-children {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* 头像列:固定宽度,不参与伸缩 —— 否则长评论把它挤扁 */
|
||||
.yang-comment-avatar {
|
||||
flex: 0 0 auto;
|
||||
width: 42px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.yang-comment-avatar img,
|
||||
.yang-comment-avatar-img {
|
||||
display: block;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 1px solid #eaeaea;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 没有头像时的首字占位 */
|
||||
.yang-comment-avatar-ph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background-color: #eef2f7;
|
||||
color: #7c8794;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* 主体列 */
|
||||
.yang-comment-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0; /* 关键:让内部的 text-overflow / 换行能生效 */
|
||||
}
|
||||
|
||||
.yang-comment-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.yang-comment-author {
|
||||
color: #252525;
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
/* 超长昵称截断,别把时间挤到下一行去 */
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.yang-comment-author a {
|
||||
color: #252525;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.yang-comment-author a:hover {
|
||||
color: #00a2ff;
|
||||
}
|
||||
|
||||
.yang-comment-time {
|
||||
flex: 0 0 auto;
|
||||
color: #a4abb5;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 徽标:作者 / 待审核 */
|
||||
.yang-comment-badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background-color: #eaf3ff;
|
||||
color: #0b74d1;
|
||||
font-size: 11.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.yang-comment-badge.is-pending {
|
||||
background-color: #fff6e5;
|
||||
color: #b27412;
|
||||
}
|
||||
|
||||
/* 待审核的整条评论淡化,一眼区分 */
|
||||
.yang-comment.is-pending .yang-comment-content {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
/* 评论正文 */
|
||||
.yang-comment-content {
|
||||
color: #3c414a;
|
||||
font-size: 14px;
|
||||
line-height: 1.78;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.yang-comment-content > p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.yang-comment-content > p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.yang-comment-content p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
/* @某人 的提及高亮(由 comment_add_at 过滤器插入的 <span>) */
|
||||
.yang-comment-content > span:first-child {
|
||||
color: #0b74d1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 评论里的图片:限制最大宽度,别撑破容器 */
|
||||
.yang-comment-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* 引用块 */
|
||||
.yang-comment-content blockquote {
|
||||
margin: 8px 0;
|
||||
padding: 6px 12px;
|
||||
border-left: 3px solid #e2e6ee;
|
||||
background-color: #fafbfc;
|
||||
color: #6b7280;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.yang-comment-content code {
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background-color: #f4f5f7;
|
||||
color: #c7254e;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.yang-comment-content pre {
|
||||
overflow-x: auto;
|
||||
padding: 10px 12px;
|
||||
border-radius: 3px;
|
||||
background-color: #f6f7f9;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 表情图片与正文基线对齐 */
|
||||
.yang-comment-content img.wp-smiley {
|
||||
display: inline-block;
|
||||
margin: 0 1px;
|
||||
width: auto;
|
||||
max-height: 1.4em;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
/*--------------------------------------------------------------
|
||||
4. 操作按钮
|
||||
--------------------------------------------------------------*/
|
||||
|
||||
.yang-comment-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.yang-comment-reply,
|
||||
.yang-comment-edit {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #8a9099;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.yang-comment-reply:hover,
|
||||
.yang-comment-edit:hover {
|
||||
color: #00a2ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.yang-comment-reply .kicon {
|
||||
margin-right: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 已进入"回复这条"状态时,按钮藏起来(由 JS 加 class) */
|
||||
.yang-comment-reply.is-hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/*--------------------------------------------------------------
|
||||
5. 嵌套回复
|
||||
--------------------------------------------------------------*/
|
||||
|
||||
.yang-children {
|
||||
margin-top: 2px;
|
||||
padding-left: 20px;
|
||||
/* 左侧引导线:让"谁回谁"在视觉上有明确归属 */
|
||||
border-left: 2px solid #f0f2f5;
|
||||
}
|
||||
|
||||
.yang-children > .yang-comment {
|
||||
padding: 11px 0;
|
||||
}
|
||||
|
||||
/* 深层的头像小一号,靠尺寸建立层级感 */
|
||||
.yang-children .yang-children .yang-comment-avatar,
|
||||
.yang-children .yang-children .yang-comment-avatar img,
|
||||
.yang-children .yang-children .yang-comment-avatar-ph {
|
||||
width: 34px;
|
||||
}
|
||||
|
||||
.yang-children .yang-children .yang-comment-avatar {
|
||||
width: 34px;
|
||||
}
|
||||
|
||||
.yang-children .yang-children .yang-comment-avatar img {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.yang-children .yang-children .yang-comment-avatar-ph {
|
||||
height: 34px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 第三层以后不再继续缩进 —— 否则窄屏上正文只剩一条竖线 */
|
||||
.yang-children .yang-children .yang-children {
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
/* 新提交的评论:淡入 + 短暂底色,让用户看到自己刚发的那条 */
|
||||
@keyframes yang-comment-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.yang-comment.is-new {
|
||||
animation: yang-comment-in 0.28s ease;
|
||||
background-color: #fffdf5;
|
||||
}
|
||||
|
||||
/*--------------------------------------------------------------
|
||||
6. 加载更多
|
||||
--------------------------------------------------------------*/
|
||||
|
||||
.comments-more {
|
||||
padding: 16px 0 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.comments-more[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.comments-more-btn {
|
||||
padding: 8px 26px;
|
||||
border: 1px solid #e2e6ee;
|
||||
border-radius: 3px;
|
||||
background-color: #fff;
|
||||
color: #5a616b;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.comments-more-btn:hover:not(:disabled) {
|
||||
border-color: #00a2ff;
|
||||
background-color: #f5fbff;
|
||||
color: #00a2ff;
|
||||
}
|
||||
|
||||
.comments-more-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/*--------------------------------------------------------------
|
||||
7. 评论表单
|
||||
--------------------------------------------------------------*/
|
||||
|
||||
.comment-respond {
|
||||
margin-top: 22px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
/* 表单被"搬"进某条评论时,去掉那条顶部分隔线,视觉上更连贯 */
|
||||
.yang-children > #respond {
|
||||
margin-top: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #e8ebf0;
|
||||
}
|
||||
|
||||
.comment-reply-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 0 0 14px;
|
||||
color: #1f2328;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.comment-cancel-reply {
|
||||
padding: 3px 10px;
|
||||
border: 1px solid #e4e8ee;
|
||||
border-radius: 3px;
|
||||
background-color: #f7f8fa;
|
||||
color: #6b7280;
|
||||
font-weight: 400;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.comment-cancel-reply[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.comment-cancel-reply:hover {
|
||||
border-color: #f0b4b4;
|
||||
background-color: #fff5f5;
|
||||
color: #d9534f;
|
||||
}
|
||||
|
||||
/* 「正在回复 @某人」提示条 */
|
||||
.comment-reply-hint {
|
||||
margin: 0 0 12px;
|
||||
padding: 7px 12px;
|
||||
border-left: 3px solid #00a2ff;
|
||||
border-radius: 2px;
|
||||
background-color: #f2f8ff;
|
||||
color: #4a6b8a;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.comment-reply-hint[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 昵称/邮箱/网址:三列,窄屏自动堆叠 */
|
||||
.comment-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.comment-field {
|
||||
position: relative;
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.comment-field-icon {
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #b6bcc6;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.comment-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid #e6e9ef;
|
||||
border-radius: 3px;
|
||||
background-color: #fcfcfd;
|
||||
color: #303133;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.comment-field .comment-input {
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
.comment-input::placeholder {
|
||||
color: #b0b6bf;
|
||||
}
|
||||
|
||||
.comment-input:focus {
|
||||
outline: none;
|
||||
border-color: #00a2ff;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 0 3px rgba(0, 162, 255, 0.1);
|
||||
}
|
||||
|
||||
.comment-input:disabled,
|
||||
.comment-input[readonly] {
|
||||
background-color: #f7f8fa;
|
||||
color: #8a9099;
|
||||
}
|
||||
|
||||
/* textarea 容器 */
|
||||
.comment-textarea {
|
||||
position: relative;
|
||||
border: 1px solid #e6e9ef;
|
||||
border-radius: 3px;
|
||||
background-color: #fcfcfd;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.comment-textarea:focus-within {
|
||||
border-color: #00a2ff;
|
||||
box-shadow: 0 0 0 3px rgba(0, 162, 255, 0.1);
|
||||
}
|
||||
|
||||
.comment-textarea .comment-input {
|
||||
min-height: 132px;
|
||||
padding: 11px 13px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
resize: vertical;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.comment-textarea .comment-input:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 工具条 */
|
||||
.comment-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
border-top: 1px solid #f0f2f6;
|
||||
background-color: #fafbfc;
|
||||
}
|
||||
|
||||
.comment-bar-left,
|
||||
.comment-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.comment-bar-left {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.comment-smile-btn {
|
||||
padding: 4px 7px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: none;
|
||||
color: #8a9099;
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.comment-smile-btn:hover {
|
||||
background-color: #eef2f7;
|
||||
color: #00a2ff;
|
||||
}
|
||||
|
||||
/* 表情面板:3 行 8 列 */
|
||||
.comment-smile-panel {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 264px;
|
||||
padding: 8px;
|
||||
border: 1px solid #e6e9ef;
|
||||
border-radius: 4px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.comment-smile-panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.comment-smile-panel a {
|
||||
float: none;
|
||||
display: block;
|
||||
margin: 2px;
|
||||
padding: 3px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
background-color: #f7f8fa;
|
||||
transition: background-color 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.comment-smile-panel a:hover {
|
||||
border-color: #b9dcff;
|
||||
background-color: #eaf4ff;
|
||||
}
|
||||
|
||||
.comment-smile-panel img {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* 状态提示 */
|
||||
.comment-status {
|
||||
color: #8a9099;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.comment-status.is-ok {
|
||||
color: #2f9e44;
|
||||
}
|
||||
|
||||
.comment-status.is-err {
|
||||
color: #d9534f;
|
||||
}
|
||||
|
||||
/* 提交按钮 */
|
||||
.comment-submit {
|
||||
padding: 8px 22px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background-color: #00a2ff;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.comment-submit:hover:not(:disabled) {
|
||||
background-color: #0090e6;
|
||||
}
|
||||
|
||||
.comment-submit:disabled,
|
||||
.comment-submit.is-loading {
|
||||
opacity: 0.62;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* honeypot:移出视口而不是 display:none(display:none 的字段部分爬虫不填) */
|
||||
.comment-hp {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 只需登录才能评论的提示 */
|
||||
.comments-login-required {
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 4px;
|
||||
background-color: #fafbfc;
|
||||
color: #6b7280;
|
||||
font-size: 13.5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.comments-login-required a {
|
||||
color: #00a2ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/*--------------------------------------------------------------
|
||||
8. 无障碍:屏幕阅读器专用文本
|
||||
--------------------------------------------------------------*/
|
||||
|
||||
.k-main .details .comments .sr-only {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: -1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/*--------------------------------------------------------------
|
||||
9. 响应式
|
||||
--------------------------------------------------------------*/
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.k-main .details .comments {
|
||||
padding: 16px 14px 18px;
|
||||
}
|
||||
|
||||
.comments-head {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.comments-head .title {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.yang-comment {
|
||||
/* 只收窄列间距;写 gap 会同时给 grid 的 row-gap 赋值,
|
||||
导致"主体"与"子评论"之间多出一道空隙 */
|
||||
column-gap: 10px;
|
||||
padding: 13px 0;
|
||||
}
|
||||
|
||||
.yang-comment-avatar,
|
||||
.yang-comment-avatar img,
|
||||
.yang-comment-avatar-img,
|
||||
.yang-comment-avatar-ph {
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.yang-comment-avatar img,
|
||||
.yang-comment-avatar-img,
|
||||
.yang-comment-avatar-ph {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
/* 窄屏缩进收窄:原来 20px 在三层后正文只剩很窄一条 */
|
||||
.yang-children {
|
||||
padding-left: 11px;
|
||||
}
|
||||
|
||||
.yang-children .yang-children {
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.yang-children .yang-children .yang-children {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.comment-info {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.comment-field {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.comment-bar {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.comment-bar-right {
|
||||
flex: 1 1 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.comment-status {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.comment-smile-panel {
|
||||
width: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
.yang-comment-avatar,
|
||||
.yang-comment-avatar img,
|
||||
.yang-comment-avatar-img,
|
||||
.yang-comment-avatar-ph {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.yang-comment-avatar img,
|
||||
.yang-comment-avatar-img,
|
||||
.yang-comment-avatar-ph {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
/* ⚠️ 必须一并覆盖深层头像。
|
||||
上面的基础规则里有 `.yang-children .yang-children .yang-comment-avatar
|
||||
{ width: 34px }`,它选择器特异性 (0,3,0) 高于这里的 `.yang-comment-avatar`
|
||||
(0,1,0) —— 媒体查询**不增加**特异性。所以只写这一条的话,窄屏下
|
||||
深层头像仍是 34px,反而比顶层的 32px 更大,层级关系是反的。 */
|
||||
.yang-children .yang-children .yang-comment-avatar,
|
||||
.yang-children .yang-children .yang-comment-avatar img,
|
||||
.yang-children .yang-children .yang-comment-avatar-ph {
|
||||
width: 26px;
|
||||
}
|
||||
|
||||
.yang-children .yang-children .yang-comment-avatar img,
|
||||
.yang-children .yang-children .yang-comment-avatar-ph {
|
||||
height: 26px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.comment-smile-panel {
|
||||
width: 216px;
|
||||
}
|
||||
}
|
||||
|
||||
/*--------------------------------------------------------------
|
||||
10. 打印:评论不需要按钮
|
||||
--------------------------------------------------------------*/
|
||||
|
||||
@media print {
|
||||
.comments-jump-form,
|
||||
.yang-comment-actions,
|
||||
.comments-more,
|
||||
.comment-respond {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.yang-comment-content {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/*!
|
||||
* 文章目录(TOC)样式
|
||||
* Yongquan Yang <yangyq.net>
|
||||
*
|
||||
* ── 设计原则 ────────────────────────────────────────────────
|
||||
* 1. **不抢正文的视觉**:目录是辅助导航,不是内容。所以用浅灰背景、
|
||||
* 细边框、小字号,让它在页面上"退后一步"。
|
||||
* 2. **用主题主色**:强调色统一为 #00a2ff,与主题其他强调元素一致。
|
||||
* 注意主题**没有**定义 CSS 变量(主色是各处硬编码的),所以这里
|
||||
* 直接写字面值,不要臆造 var(--kratos-primary) 之类的变量 —— 那会
|
||||
* 静默失效,目录永远是默认灰色,且看不出是哪里错了。
|
||||
* 3. **移动端友好**:窄屏下缩进减小、字号略小,但不折叠成下拉 ——
|
||||
* 目录本来就是给长文用的,手机上更需要它。
|
||||
*/
|
||||
|
||||
.yang-toc {
|
||||
margin: 24px 0;
|
||||
border: 1px solid #ebeef5;
|
||||
border-left: 3px solid #00a2ff;
|
||||
border-radius: 3px;
|
||||
background-color: #fafbfc;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* <details> 的默认三角标记去掉,用自己的图标 */
|
||||
.yang-toc > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.yang-toc > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.yang-toc > summary::marker {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.yang-toc-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 11px 16px;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.yang-toc-head:hover {
|
||||
color: #00a2ff;
|
||||
}
|
||||
|
||||
/* 折叠箭头:用 CSS 画的三角,不依赖图标字体或 SVG 文件 */
|
||||
.yang-toc > summary .yang-toc-title::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-right: 8px;
|
||||
border-top: 5px solid transparent;
|
||||
border-bottom: 5px solid transparent;
|
||||
border-left: 6px solid currentColor;
|
||||
vertical-align: middle;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
/* 展开时箭头朝下(旋转 90°) */
|
||||
.yang-toc[open] > summary .yang-toc-title::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* 不可折叠时(toggle=0)不显示箭头 */
|
||||
.yang-toc:not(details) .yang-toc-head .yang-toc-title::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.yang-toc-title {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* 条目数徽标 */
|
||||
.yang-toc-count {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 8px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 9px;
|
||||
background-color: #eef1f6;
|
||||
color: #909399;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.yang-toc-body {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.yang-toc-list {
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* 二级以上的列表缩进,并加一条引导线 */
|
||||
.yang-toc-list .yang-toc-list {
|
||||
margin-left: 9px;
|
||||
padding-left: 14px;
|
||||
border-left: 1px dashed #e2e6ee;
|
||||
}
|
||||
|
||||
.yang-toc-item {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.yang-toc-link {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
color: #4a4f57;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.yang-toc-link:hover,
|
||||
.yang-toc-link:focus {
|
||||
background-color: #eef4ff;
|
||||
color: #00a2ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 序号 */
|
||||
.yang-toc-num {
|
||||
flex: 0 0 auto;
|
||||
min-width: 1.9em;
|
||||
margin-right: 5px;
|
||||
color: #a8abb2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.yang-toc-text {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 层级视觉差异:越深越淡、字号越小 */
|
||||
.yang-toc-lv3 > .yang-toc-link {
|
||||
color: #6b7280;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.yang-toc-lv4 > .yang-toc-link,
|
||||
.yang-toc-lv5 > .yang-toc-link,
|
||||
.yang-toc-lv6 > .yang-toc-link {
|
||||
color: #8b9099;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 当前阅读位置的条目高亮(JS 加 .is-active) */
|
||||
.yang-toc-link.is-active {
|
||||
background-color: #eef4ff;
|
||||
color: #00a2ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── 标题锚点:点击跳转后被固定导航栏挡住的问题 ────────────────
|
||||
* 主题的 .k-nav 是 fixed-top,滚动后固定约 67px 高。
|
||||
* 纯 CSS 方案是给目标元素加 scroll-margin-top —— 浏览器原生支持,
|
||||
* 且不依赖 JS(用 #anchor 直接访问、前进后退、外部链接进来都生效)。
|
||||
*
|
||||
* 为什么不用 JS 算偏移:JS 方案只在"点了目录"的情况下有效,
|
||||
* 从外部带 #hash 进来、或按浏览器后退回到锚点时都不会触发。
|
||||
* scroll-margin-top 是声明式的,所有情况自动生效。
|
||||
*
|
||||
* 67px 是导航栏高度;再加 16px 呼吸空间。管理工具栏(admin bar)
|
||||
* 存在时再往上加,否则锚点会被工具栏盖住。
|
||||
*/
|
||||
.yang-toc-target,
|
||||
.k-main .details .content h1[id],
|
||||
.k-main .details .content h2[id],
|
||||
.k-main .details .content h3[id],
|
||||
.k-main .details .content h4[id],
|
||||
.k-main .details .content h5[id],
|
||||
.k-main .details .content h6[id] {
|
||||
scroll-margin-top: 84px;
|
||||
}
|
||||
|
||||
body.admin-bar .k-main .details .content h1[id],
|
||||
body.admin-bar .k-main .details .content h2[id],
|
||||
body.admin-bar .k-main .details .content h3[id],
|
||||
body.admin-bar .k-main .details .content h4[id],
|
||||
body.admin-bar .k-main .details .content h5[id],
|
||||
body.admin-bar .k-main .details .content h6[id] {
|
||||
scroll-margin-top: 116px;
|
||||
}
|
||||
|
||||
/* 滚动到锚点时轻微闪一下,让用户确认"跳到这里了" */
|
||||
@keyframes yang-toc-flash {
|
||||
0%,
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
30% {
|
||||
background-color: #fffbe6;
|
||||
}
|
||||
}
|
||||
|
||||
.yang-toc-flash {
|
||||
animation: yang-toc-flash 1.1s ease;
|
||||
}
|
||||
|
||||
/* ── 窄屏 ── */
|
||||
@media screen and (max-width: 768px) {
|
||||
.yang-toc {
|
||||
margin: 18px 0;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.yang-toc-head {
|
||||
padding: 10px 13px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.yang-toc-body {
|
||||
padding: 0 10px 10px;
|
||||
}
|
||||
|
||||
.yang-toc-list .yang-toc-list {
|
||||
margin-left: 4px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.yang-toc-link {
|
||||
padding: 5px 5px;
|
||||
}
|
||||
|
||||
.yang-toc-lv3 > .yang-toc-link {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 打印:目录展开、去背景、不折行 ────────────────────────── */
|
||||
@media print {
|
||||
.yang-toc {
|
||||
border: 1px solid #ccc;
|
||||
background: none;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.yang-toc-count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.yang-toc-link {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 暗色主题适配 ──────────────────────────────────────────
|
||||
* 主题没有内置暗色模式,但用户可能在「自定义 CSS」里加
|
||||
* prefers-color-scheme。这里跟随同一个媒体查询,避免目录
|
||||
* 在暗色下变成一块刺眼的白板。
|
||||
*/
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html.yang-dark .yang-toc {
|
||||
border-color: #303640;
|
||||
border-left-color: #00a2ff;
|
||||
background-color: #1d2026;
|
||||
}
|
||||
|
||||
html.yang-dark .yang-toc-head {
|
||||
color: #d5d9e0;
|
||||
}
|
||||
|
||||
html.yang-dark .yang-toc-count {
|
||||
background-color: #2a2f38;
|
||||
color: #8b929d;
|
||||
}
|
||||
|
||||
html.yang-dark .yang-toc-link {
|
||||
color: #b6bcc6;
|
||||
}
|
||||
|
||||
html.yang-dark .yang-toc-link:hover,
|
||||
html.yang-dark .yang-toc-link.is-active {
|
||||
background-color: #252b34;
|
||||
color: #00a2ff;
|
||||
}
|
||||
|
||||
html.yang-dark .yang-toc-list .yang-toc-list {
|
||||
border-left-color: #333944;
|
||||
}
|
||||
|
||||
html.yang-dark .yang-toc-num {
|
||||
color: #767d88;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
/*!
|
||||
* 评论区交互
|
||||
* Yongquan Yang <yangyq.net>
|
||||
*
|
||||
* ── 旧版(comments.min.js)的四个真实缺陷,逐条对应修掉的点 ────
|
||||
*
|
||||
* 1. **错误提示直接把后端原文弹出来**
|
||||
* `layer.msg(e.responseText)` —— 后端返回什么就弹什么。
|
||||
* 旧后端出错时 `echo` 的是 WP_Error 的 message(可能是英文、
|
||||
* 可能带 HTML、可能是空串)。用户看到的是 "ERROR: please type
|
||||
* a comment" 或一片空白。→ 现在后端统一返回 { ok, message },
|
||||
* 前端只读 message,空就填兜底文案。
|
||||
*
|
||||
* 2. **不防重复提交**
|
||||
* `beforeSend` 只弹了「评论正在提交中」,按钮仍可点。
|
||||
* 网络慢时连点三次就是三条重复评论(后端没做 flood 时)。
|
||||
* → 现在提交期间禁用按钮并置 is-loading,finally 里恢复。
|
||||
*
|
||||
* 3. **新评论插错位置会丢嵌套**
|
||||
* 旧代码 `e("#comment-"+c).append('<ul class="children">'+t+"</ul>")`
|
||||
* 直接用 jQuery 包一层。若该父评论**已有** children 容器
|
||||
* (已经被回复过一次),就会变成 children 里再套一个 children,
|
||||
* 层级越滚越深。→ 现在先查是否已有 children 容器,有就往里追加。
|
||||
*
|
||||
* 4. **提交成功后不更新评论数**
|
||||
* → 现在更新页面上的评论计数(若存在对应元素)。
|
||||
*
|
||||
* ── 设计约束 ────────────────────────────────────────────────
|
||||
* · 不依赖 jQuery、不依赖 layer。主题虽然都加载了,但这两者版本
|
||||
* 随主题走,出错时整块功能会一起哑掉。原生 fetch 足够。
|
||||
* · 不用 fetch 的流式读法:要读响应头和 body,用 Promise 链更直观。
|
||||
* · 任何异常都必须**看得见**。静默 catch 是这类"点了没反应"问题的根源。
|
||||
*/
|
||||
; (function () {
|
||||
'use strict'
|
||||
|
||||
var CFG = window.yangComment || {}
|
||||
var I18N = CFG.i18n || {}
|
||||
|
||||
function $(sel, root) { return (root || document).querySelector(sel) }
|
||||
function $$(sel, root) { return [].slice.call((root || document).querySelectorAll(sel)) }
|
||||
|
||||
var form = $('#commentform')
|
||||
if (!form) return // 没表单(评论关闭/需登录)就什么都不做
|
||||
|
||||
var listEl = $('#yang-comment-list')
|
||||
var statusEl = $('#yang-comment-status')
|
||||
var submitBtn = $('#submit', form)
|
||||
var textarea = $('#comment', form)
|
||||
var replyHint = $('#yang-reply-hint')
|
||||
var replyTitle = $('#yang-reply-title-text')
|
||||
var cancelBtn = $('#yang-cancel-reply')
|
||||
var moreWrap = $('#yang-comments-more')
|
||||
var moreBtn = moreWrap ? $('.comments-more-btn', moreWrap) : null
|
||||
|
||||
// 当前回复的目标(0 表示顶层评论)
|
||||
var replyTo = 0
|
||||
var submitting = false
|
||||
|
||||
/* ── 状态提示 ──────────────────────────────────────────────
|
||||
* 用一段内联文本而不是弹窗。type 决定配色(ok / err)。
|
||||
* aria-live=polite 已在模板里设好,屏幕阅读器会读出来。 */
|
||||
function setStatus (msg, type) {
|
||||
if (!statusEl) return
|
||||
statusEl.textContent = msg || ''
|
||||
statusEl.className = 'comment-status' + (type ? ' is-' + type : '')
|
||||
}
|
||||
|
||||
/* ── 提交态开关 ────────────────────────────────────────────
|
||||
* 只改按钮和 textarea 的可交互性,不动表单数据。 */
|
||||
function setSubmitting (on) {
|
||||
submitting = on
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = on
|
||||
submitBtn.classList[on ? 'add' : 'remove']('is-loading')
|
||||
submitBtn.textContent = on ? (I18N.submitting || '提交中…') : (I18N.submit || '提交评论')
|
||||
}
|
||||
if (textarea) textarea.readOnly = on
|
||||
}
|
||||
|
||||
/* ── 回复模式切换 ──────────────────────────────────────────
|
||||
* 点「回复」→ 把表单搬到该评论下方 + 更新标题 + 显示取消按钮。
|
||||
* 取消 → 表单搬回原位置 + 清状态。
|
||||
*
|
||||
* 为什么要记住"原位置":表单本来在 #respond 里,被搬走后
|
||||
* #respond 变成空壳。取消时若直接 appendChild 回去,
|
||||
* 会和评论区里的其它元素顺序错乱 —— 所以留一个占位锚点。 */
|
||||
var originalAnchor = document.createComment('yang-respond-anchor')
|
||||
var respondBox = $('#respond')
|
||||
if (respondBox && respondBox.parentNode) {
|
||||
respondBox.parentNode.insertBefore(originalAnchor, respondBox.nextSibling)
|
||||
}
|
||||
|
||||
function enterReplyMode (commentId, author) {
|
||||
replyTo = commentId
|
||||
|
||||
var li = document.getElementById('comment-' + commentId)
|
||||
if (li && respondBox) {
|
||||
/* 插到该评论的 children 容器里(没有就建一个)。
|
||||
这样表单在视觉上"长"在被回复评论的内部,
|
||||
比挪到列表末尾更符合直觉。 */
|
||||
var children = li.querySelector(':scope > .yang-children')
|
||||
if (!children) {
|
||||
children = document.createElement('ol')
|
||||
children.className = 'yang-children'
|
||||
li.appendChild(children)
|
||||
}
|
||||
children.insertBefore(respondBox, children.firstChild)
|
||||
}
|
||||
|
||||
if (replyTitle) {
|
||||
replyTitle.textContent = (I18N.replyTo || '回复') + ' @' + (author || '')
|
||||
}
|
||||
if (replyHint) {
|
||||
replyHint.textContent = (I18N.replyTo || '回复') + ' @' + (author || '') + ' '
|
||||
replyHint.hidden = false
|
||||
}
|
||||
if (cancelBtn) cancelBtn.hidden = false
|
||||
|
||||
// 隐藏被回复评论上的「回复」按钮,避免重复点击
|
||||
$$('.yang-comment-reply', li || document).forEach(function (b) {
|
||||
b.classList.add('is-hidden')
|
||||
})
|
||||
|
||||
var parentInput = $( '#comment_parent', form)
|
||||
if (parentInput) parentInput.value = String(commentId)
|
||||
|
||||
// 聚焦输入框并滚过去
|
||||
if (textarea) {
|
||||
textarea.focus()
|
||||
try {
|
||||
var top = respondBox.getBoundingClientRect().top + window.pageYOffset - 90
|
||||
window.scrollTo({ top: top, behavior: 'smooth' })
|
||||
} catch (e) { window.scrollTo(0, respondBox.offsetTop - 90) }
|
||||
}
|
||||
}
|
||||
|
||||
function exitReplyMode () {
|
||||
replyTo = 0
|
||||
var parentInput = $('#comment_parent', form)
|
||||
if (parentInput) parentInput.value = '0'
|
||||
|
||||
if (replyTitle) replyTitle.textContent = I18N.submit ? '发表评论' : '发表评论'
|
||||
if (replyHint) {
|
||||
replyHint.textContent = ''
|
||||
replyHint.hidden = true
|
||||
}
|
||||
if (cancelBtn) cancelBtn.hidden = true
|
||||
|
||||
$$('.yang-comment-reply.is-hidden').forEach(function (b) {
|
||||
b.classList.remove('is-hidden')
|
||||
})
|
||||
|
||||
// 表单搬回原位
|
||||
if (respondBox && originalAnchor && originalAnchor.parentNode) {
|
||||
originalAnchor.parentNode.insertBefore(respondBox, originalAnchor.nextSibling)
|
||||
}
|
||||
setStatus('', '')
|
||||
}
|
||||
|
||||
/* ── 事件:回复 / 取消 ─────────────────────────────────── */
|
||||
document.addEventListener('click', function (ev) {
|
||||
var btn = ev.target.closest ? ev.target.closest('.yang-comment-reply') : null
|
||||
if (btn) {
|
||||
ev.preventDefault()
|
||||
enterReplyMode(
|
||||
parseInt(btn.getAttribute('data-comment-id'), 10) || 0,
|
||||
btn.getAttribute('data-author') || ''
|
||||
)
|
||||
return
|
||||
}
|
||||
// 点到「取消回复」的另一种入口:评论里的取消链接
|
||||
if (ev.target.closest && ev.target.closest('#yang-cancel-reply')) {
|
||||
ev.preventDefault()
|
||||
exitReplyMode()
|
||||
}
|
||||
})
|
||||
|
||||
/* ── 表情面板 ──────────────────────────────────────────────
|
||||
* 点表情按钮 → 把对应代码插到光标处(不是简单追加到末尾)。
|
||||
* 旧实现是 `myField.value += tag`,光标本在中间也会跑到最后,
|
||||
* 用户想在某句话后面插表情就得重新点光标。 */
|
||||
var smileBtn = $('#yang-smile-btn')
|
||||
var smilePanel = $('#yang-smile-panel')
|
||||
if (smileBtn && smilePanel) {
|
||||
smileBtn.addEventListener('click', function (ev) {
|
||||
ev.stopPropagation()
|
||||
var open = !smilePanel.hidden
|
||||
smilePanel.hidden = open
|
||||
smileBtn.setAttribute('aria-expanded', open ? 'false' : 'true')
|
||||
})
|
||||
document.addEventListener('click', function (ev) {
|
||||
if (smilePanel.hidden) return
|
||||
if (smilePanel.contains(ev.target) || smileBtn.contains(ev.target)) return
|
||||
smilePanel.hidden = true
|
||||
smileBtn.setAttribute('aria-expanded', 'false')
|
||||
})
|
||||
// 面板里的表情链接是 javascript:grin('...'),这里拦下来自己插
|
||||
smilePanel.addEventListener('click', function (ev) {
|
||||
var a = ev.target.closest ? ev.target.closest('a') : null
|
||||
if (!a) return
|
||||
var href = a.getAttribute('href') || ''
|
||||
var m = href.match(/grin\('([^']+)'\)/)
|
||||
if (!m) return
|
||||
ev.preventDefault()
|
||||
insertAtCursor(textarea, ' ' + m[1] + ' ')
|
||||
smilePanel.hidden = true
|
||||
smileBtn.setAttribute('aria-expanded', 'false')
|
||||
})
|
||||
}
|
||||
|
||||
function insertAtCursor (el, text) {
|
||||
if (!el) return
|
||||
el.focus()
|
||||
// 现代浏览器
|
||||
if (typeof el.setSelectionRange === 'function') {
|
||||
var s = el.selectionStart || 0
|
||||
var e = el.selectionEnd || 0
|
||||
var v = el.value
|
||||
el.value = v.slice(0, s) + text + v.slice(e)
|
||||
var pos = s + text.length
|
||||
el.setSelectionRange(pos, pos)
|
||||
} else {
|
||||
el.value += text
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 提交 ──────────────────────────────────────────────────
|
||||
* 用 fetch + FormData,直接把整个表单发过去(含 nonce、comment_post_ID、
|
||||
* comment_parent)。这样服务端 wp_handle_comment_submission 拿到的
|
||||
* 字段与普通 POST 完全一致,不需要为 AJAX 单独走一套。 */
|
||||
form.addEventListener('submit', function (ev) {
|
||||
ev.preventDefault()
|
||||
|
||||
if (submitting) return // 双击保护
|
||||
|
||||
// 本地前置校验:空内容不发请求(省一次往返,也避免后端 500)
|
||||
if (textarea && textarea.value.trim() === '') {
|
||||
setStatus(I18N.emptyComment || '评论内容不能为空。', 'err')
|
||||
textarea.focus()
|
||||
return
|
||||
}
|
||||
|
||||
// honeypot:填了就是机器人,直接假装成功,不给它反馈
|
||||
var hp = $('#yang_hp', form)
|
||||
if (hp && hp.value !== '') {
|
||||
setStatus('', '')
|
||||
return
|
||||
}
|
||||
|
||||
var fd = new FormData(form)
|
||||
fd.append('action', 'ajax_comment')
|
||||
|
||||
setSubmitting(true)
|
||||
setStatus(I18N.submitting || '提交中…', '')
|
||||
|
||||
fetch(CFG.ajaxUrl, {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
credentials: 'same-origin'
|
||||
// 注意:不要设 Content-Type,让浏览器自己带 multipart 边界
|
||||
}).then(function (res) {
|
||||
/* 关键:即使 HTTP 状态是 4xx/5xx,后端也会返回 JSON。
|
||||
所以先尝试解析 JSON,解析失败才当网络/服务端异常处理。
|
||||
旧实现用 jQuery 的 error 回调,拿不到结构化数据,
|
||||
只能把 responseText 原文弹出来。 */
|
||||
return res.json().catch(function () {
|
||||
return { ok: false, message: I18N.networkErr || '网络异常,请稍后重试。' }
|
||||
})
|
||||
}).then(function (data) {
|
||||
if (!data || !data.ok) {
|
||||
var msg = (data && data.message) ? data.message : (I18N.networkErr || '提交失败,请稍后重试。')
|
||||
setStatus(msg, 'err')
|
||||
// 后端指明字段就聚焦过去
|
||||
if (data && data.field) {
|
||||
var f = document.getElementById(data.field)
|
||||
if (f) f.focus()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
insertComment(data)
|
||||
|
||||
// 清空输入(保留昵称邮箱)
|
||||
if (textarea) textarea.value = ''
|
||||
|
||||
/* 顺序很重要:exitReplyMode() 内部最后会 setStatus('','')
|
||||
清空状态条。若先设成功提示再退出回复模式,提示会被立刻
|
||||
擦掉 —— 用户提交成功却看不到任何反馈,正是"点了没反应"
|
||||
那种体验(真实浏览器里量出来的,单看代码不容易发现)。
|
||||
所以必须先退出、后设提示。 */
|
||||
exitReplyMode()
|
||||
setStatus(data.message || '评论发表成功。', 'ok')
|
||||
|
||||
}).catch(function () {
|
||||
setStatus(I18N.networkErr || '网络异常,请稍后重试。', 'err')
|
||||
}).then(function () {
|
||||
setSubmitting(false)
|
||||
})
|
||||
})
|
||||
|
||||
/* ── 插入新评论 ────────────────────────────────────────────
|
||||
* 四个分支,对应四种真实情况:
|
||||
* a. 回复某条评论,且该评论已有 children 容器 → 追加进去
|
||||
* b. 回复某条评论,还没有 children 容器 → 新建一个
|
||||
* c. 顶层评论,站点排序 asc → 追加到列表末尾
|
||||
* d. 顶层评论,站点排序 desc → 插到列表最前
|
||||
*
|
||||
* 旧实现只有 a 的一个错误版本(无条件新建 children,导致套娃)
|
||||
* 和 c/d。b 的情况是它没考虑到的(第一次回复时)。 */
|
||||
function insertComment (data) {
|
||||
if (!listEl) return
|
||||
|
||||
var tpl = document.createElement('template')
|
||||
tpl.innerHTML = (data.html || '').trim()
|
||||
var node = tpl.content.firstElementChild
|
||||
if (!node) return
|
||||
|
||||
var parentId = parseInt(data.parent_id, 10) || 0
|
||||
|
||||
if (parentId > 0) {
|
||||
var parentLi = document.getElementById('comment-' + parentId)
|
||||
if (parentLi) {
|
||||
var children = parentLi.querySelector(':scope > .yang-children')
|
||||
if (!children) {
|
||||
children = document.createElement('ol')
|
||||
children.className = 'yang-children'
|
||||
parentLi.appendChild(children)
|
||||
}
|
||||
children.appendChild(node)
|
||||
} else {
|
||||
// 父评论不在页面上(分页/已删除)→ 退化成顶层追加,别丢评论
|
||||
listEl.appendChild(node)
|
||||
}
|
||||
} else {
|
||||
var order = (CFG.order || 'asc').toLowerCase()
|
||||
if (order === 'desc') {
|
||||
listEl.insertBefore(node, listEl.firstChild)
|
||||
} else {
|
||||
listEl.appendChild(node)
|
||||
}
|
||||
}
|
||||
|
||||
// 高亮一下,让用户看到自己的评论在哪
|
||||
node.classList.add('is-new')
|
||||
setTimeout(function () { node.classList.remove('is-new') }, 2400)
|
||||
|
||||
// 更新评论计数(页面上出现「N 条评论」的地方都更新)
|
||||
updateCount(data.count, data.count_text)
|
||||
}
|
||||
|
||||
function updateCount (n, text) {
|
||||
if (typeof n !== 'number') return
|
||||
var label = text || (n + ' 条评论')
|
||||
// single.php 里的 meta 区
|
||||
$$('.article .header .meta span').forEach(function (sp) {
|
||||
if (/条评论$/.test(sp.textContent.trim()) || /^\d+$/.test(sp.textContent.trim())) {
|
||||
if (/条评论/.test(sp.textContent) || sp.textContent.trim() === '0') {
|
||||
sp.textContent = label
|
||||
}
|
||||
}
|
||||
})
|
||||
// 评论区标题
|
||||
var h = $('.comments-head .title')
|
||||
if (h) h.textContent = '评论(' + n + ')'
|
||||
}
|
||||
|
||||
/* ── 加载更多 ────────────────────────────────────────────── */
|
||||
if (moreBtn) {
|
||||
moreBtn.addEventListener('click', function () {
|
||||
if (moreBtn.disabled) return
|
||||
moreBtn.disabled = true
|
||||
var orig = moreBtn.textContent
|
||||
moreBtn.textContent = I18N.loading || '加载中…'
|
||||
|
||||
var page = parseInt(moreBtn.getAttribute('data-page'), 10) || 2
|
||||
var fd = new FormData()
|
||||
fd.append('action', 'yang_load_comments')
|
||||
fd.append('post_id', String(CFG.postId || 0))
|
||||
fd.append('page', String(page))
|
||||
|
||||
fetch(CFG.ajaxUrl, { method: 'POST', body: fd, credentials: 'same-origin' })
|
||||
.then(function (r) { return r.json() })
|
||||
.then(function (data) {
|
||||
if (!data || !data.ok) {
|
||||
moreBtn.textContent = orig
|
||||
moreBtn.disabled = false
|
||||
setStatus((data && data.message) || (I18N.networkErr || '加载失败。'), 'err')
|
||||
return
|
||||
}
|
||||
if (data.html && listEl) {
|
||||
var tpl = document.createElement('template')
|
||||
tpl.innerHTML = data.html
|
||||
while (tpl.content.firstChild) {
|
||||
listEl.appendChild(tpl.content.firstChild)
|
||||
}
|
||||
}
|
||||
if (data.has_more) {
|
||||
moreBtn.setAttribute('data-page', String(data.next_page))
|
||||
moreBtn.textContent = orig
|
||||
moreBtn.disabled = false
|
||||
} else {
|
||||
if (moreWrap) moreWrap.hidden = true
|
||||
setStatus(I18N.noMore || '没有更多评论了', '')
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
moreBtn.textContent = orig
|
||||
moreBtn.disabled = false
|
||||
setStatus(I18N.networkErr || '网络异常,请稍后重试。', 'err')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/* ── 新评论进入视口时轻微高亮 ──────────────────────────── */
|
||||
// 已由 CSS 的 .is-new 处理,这里不重复
|
||||
|
||||
}())
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
jQuery(document).ready(function(e){var t=e("#cancel-comment-reply-link"),n=t.text(),o="list";e(document).on("submit","#commentform",function(){return e.ajax({url:ajaxcomment.ajax_url,data:e(this).serialize()+"&action=ajax_comment",type:e(this).attr("method"),beforeSend:function(){layer.msg("评论正在提交中")},error:function(e){layer.msg(e.responseText)},success:function(t){e("textarea").each(function(){this.value=""});var n=addComment,r=n.I("cancel-comment-reply-link"),m=n.I("wp-temp-form-div"),a=n.I(n.respondId),c=(n.I("comment_post_ID").value,n.I("comment_parent").value);"0"!=c?e("#comment-"+c).append('<ul class="children">'+t+"</ul>"):"asc"==ajaxcomment.order?e("."+o).append(t):e("."+o).prepend(t),layer.msg("评论提交成功"),r.style.display="none",r.onclick=null,n.I("comment_parent").value="0",m&&a&&(m.parentNode.insertBefore(a,m),m.parentNode.removeChild(m))}}),!1}),addComment={moveForm:function(o,r,m){var a,c=this,l=c.I(o),p=c.I(m),d=c.I("cancel-comment-reply-link"),i=c.I("comment_parent");c.I("comment_post_ID");t.text(n),c.respondId=m,c.I("wp-temp-form-div")||(a=document.createElement("div"),a.id="wp-temp-form-div",a.style.display="none",p.parentNode.insertBefore(a,p)),l?l.parentNode.insertBefore(p,l.nextSibling):(temp=c.I("wp-temp-form-div"),c.I("comment_parent").value="0",temp.parentNode.insertBefore(p,temp),temp.parentNode.removeChild(temp)),e("body").animate({scrollTop:e("#respond").offset().top-150},400),i.value=r,d.style.display="",d.onclick=function(){var e=addComment,t=e.I("wp-temp-form-div"),n=e.I(e.respondId);return e.I("comment_parent").value="0",t&&n&&(t.parentNode.insertBefore(n,t),t.parentNode.removeChild(t)),this.style.display="none",this.onclick=null,!1};try{c.I("comment").focus()}catch(e){}return!1},I:function(e){return document.getElementById(e)},createButterbar:function(e){layer.msg(e)}}});
|
||||
@@ -0,0 +1,206 @@
|
||||
/*!
|
||||
* 文章目录(TOC)交互
|
||||
* Yongquan Yang <yangyq.net>
|
||||
*
|
||||
* ── 设计原则:没有 JS 也要能用 ────────────────────────────────
|
||||
* 目录的**核心功能是跳转**,那由 <a href="#id"> + CSS 的
|
||||
* scroll-margin-top 完成,完全不需要 JS。这个文件只做两件锦上添花的事:
|
||||
* ① 平滑滚动(不支持的浏览器直接跳,不影响功能)
|
||||
* ② 高亮当前阅读位置的条目
|
||||
* 所以这里所有增强都必须能安全失败 —— 任何异常都不该让跳转失效。
|
||||
*
|
||||
* 为什么不用 jQuery:主题里虽然有 jQuery,但这两件事原生 API 足够,
|
||||
* 且这个文件能在主题脚本之前/之后加载都不出错。
|
||||
*/
|
||||
; (function () {
|
||||
'use strict'
|
||||
|
||||
/* 固定导航栏的高度 + 呼吸空间。
|
||||
与 toc.css 里的 scroll-margin-top 保持一致 ——
|
||||
那边管"原生跳转",这边管"JS 平滑滚动",两者必须同步,
|
||||
否则点目录和直接访问 #hash 会落到不同位置。 */
|
||||
function navOffset () {
|
||||
var off = 84
|
||||
var nav = document.querySelector('.k-nav')
|
||||
// 管理工具栏存在时补上它的高度(否则锚点被工具栏盖住)
|
||||
if (document.body && document.body.classList.contains('admin-bar')) {
|
||||
off += 32
|
||||
}
|
||||
// 导航栏若没有固定在顶部(小屏展开状态),就不必让位
|
||||
if (nav) {
|
||||
var st = window.getComputedStyle(nav)
|
||||
if (st.position !== 'fixed' && st.position !== 'absolute') {
|
||||
off = 16
|
||||
}
|
||||
}
|
||||
return off
|
||||
}
|
||||
|
||||
/* 平滑滚动到某个元素。
|
||||
不用 element.scrollIntoView() 的原因:它在部分浏览器/滚动容器里
|
||||
行为不一致,且难以精确控制顶部偏移。手算 scrollTop 更可控。 */
|
||||
function scrollToEl (el) {
|
||||
if (!el) return false
|
||||
var top = el.getBoundingClientRect().top + window.pageYOffset - navOffset()
|
||||
if (top < 0) top = 0
|
||||
try {
|
||||
window.scrollTo({ top: top, behavior: 'smooth' })
|
||||
} catch (e) {
|
||||
// 老浏览器不认 options 对象
|
||||
window.scrollTo(0, top)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/* 解码 hash 里的 id。
|
||||
中文 id 在地址栏里会被编码成 %E7%AC%AC...,
|
||||
getElementById 需要解码后的原文。 */
|
||||
function decodeHash (h) {
|
||||
if (!h) return ''
|
||||
var s = h.charAt(0) === '#' ? h.slice(1) : h
|
||||
try {
|
||||
return decodeURIComponent(s)
|
||||
} catch (e) {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
function findTarget (hash) {
|
||||
var id = decodeHash(hash)
|
||||
if (!id) return null
|
||||
// 优先按 id 精确查;中文、空格等特殊字符用属性选择器兜底
|
||||
var el = document.getElementById(id)
|
||||
if (el) return el
|
||||
try {
|
||||
return document.querySelector('[id="' + id.replace(/"/g, '\\"') + '"]')
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/* ── ① 点击目录项:平滑滚动 ────────────────────────────── */
|
||||
function bindClick () {
|
||||
document.addEventListener('click', function (ev) {
|
||||
var a = ev.target.closest ? ev.target.closest('.yang-toc-link') : null
|
||||
if (!a) return
|
||||
|
||||
var href = a.getAttribute('href') || ''
|
||||
if (href.charAt(0) !== '#') return
|
||||
|
||||
var el = findTarget(href)
|
||||
// 找不到目标就别拦默认行为,让浏览器自己处理(可能跳到别处)
|
||||
if (!el) return
|
||||
|
||||
ev.preventDefault()
|
||||
scrollToEl(el)
|
||||
|
||||
/* 更新地址栏但不新增历史记录:用 replaceState 而不是
|
||||
pushState。理由:点目录属于"页内跳转",按后退应该回到
|
||||
上一篇文章,而不是在本文的各个锚点之间倒退。 */
|
||||
try {
|
||||
history.replaceState(null, '', href)
|
||||
} catch (e) { }
|
||||
|
||||
// 目标闪一下,告诉用户"跳到这里了"
|
||||
el.classList.remove('yang-toc-flash')
|
||||
// 强制重排,保证连续点击同一项时动画能重播
|
||||
void el.offsetWidth
|
||||
el.classList.add('yang-toc-flash')
|
||||
setTimeout(function () {
|
||||
el.classList.remove('yang-toc-flash')
|
||||
}, 1200)
|
||||
})
|
||||
}
|
||||
|
||||
/* ── ② 滚动时高亮当前章节 ──────────────────────────────── */
|
||||
function bindScrollSpy () {
|
||||
var toc = document.querySelector('.yang-toc')
|
||||
if (!toc) return
|
||||
|
||||
var links = [].slice.call(toc.querySelectorAll('.yang-toc-link'))
|
||||
if (!links.length) return
|
||||
|
||||
// 建立「目标元素 → 目录链接」的映射
|
||||
var pairs = []
|
||||
links.forEach(function (a) {
|
||||
var el = findTarget(a.getAttribute('href') || '')
|
||||
if (el) pairs.push({ link: a, el: el })
|
||||
})
|
||||
if (!pairs.length) return
|
||||
|
||||
var active = null
|
||||
|
||||
function update () {
|
||||
/* 判据:视口顶部往下 navOffset()+24px 处,最后一个已经滚过
|
||||
这个位置的标题,就是"当前正在读的章节"。
|
||||
加 24px 是为了让标题刚进入视野下沿时就切过去,而不是
|
||||
等它顶到最上面才切 —— 后者会明显晚一拍。 */
|
||||
var line = navOffset() + 24
|
||||
var cur = null
|
||||
for (var i = 0; i < pairs.length; i++) {
|
||||
var r = pairs[i].el.getBoundingClientRect()
|
||||
if (r.top <= line) {
|
||||
cur = pairs[i]
|
||||
} else {
|
||||
// 标题是有序的,第一个没滚过的后面的都不用看了
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 滚到页面最底部时,强制选最后一项(否则最后一个短章节永远高亮不到)
|
||||
if ((window.innerHeight + window.pageYOffset) >= document.body.scrollHeight - 2) {
|
||||
cur = pairs[pairs.length - 1]
|
||||
}
|
||||
|
||||
if (cur === active) return
|
||||
if (active) active.link.classList.remove('is-active')
|
||||
active = cur
|
||||
if (active) active.link.classList.add('is-active')
|
||||
}
|
||||
|
||||
/* 节流:滚动事件每帧都可能触发,用 rAF 合并,
|
||||
避免在低端设备上拖慢滚动。 */
|
||||
var ticking = false
|
||||
function onScroll () {
|
||||
if (ticking) return
|
||||
ticking = true
|
||||
window.requestAnimationFrame(function () {
|
||||
update()
|
||||
ticking = false
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
window.addEventListener('resize', onScroll)
|
||||
update()
|
||||
}
|
||||
|
||||
/* ── ③ 带 #hash 直接进入页面时,校正落点 ──────────────────
|
||||
* 浏览器原生的锚点跳转发生在页面加载早期,那时图片还没加载完、
|
||||
* CSS 也可能没应用完,落点常常偏上或偏下(尤其是有图片的页面)。
|
||||
* 这里在 load 之后再做一次校正。
|
||||
*
|
||||
* 只在"真的带 hash"时执行,避免正常浏览被莫名滚动。
|
||||
*/
|
||||
function fixHashOnLoad () {
|
||||
if (!window.location.hash) return
|
||||
var el = findTarget(window.location.hash)
|
||||
if (!el) return
|
||||
// 用户已经自己滚动过了就别硬拉回去
|
||||
if (window.pageYOffset > 40) return
|
||||
scrollToEl(el)
|
||||
}
|
||||
|
||||
function init () {
|
||||
bindClick()
|
||||
bindScrollSpy()
|
||||
fixHashOnLoad()
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init)
|
||||
} else {
|
||||
init()
|
||||
}
|
||||
window.addEventListener('load', fixHashOnLoad)
|
||||
}())
|
||||
+211
-54
@@ -1,82 +1,239 @@
|
||||
<?php
|
||||
/**
|
||||
* 评论模板
|
||||
*
|
||||
* ── 3.2.18 重写要点 ──────────────────────────────────────────
|
||||
* 1. **列表容器改成 <ol>**。原来 `<div class="list">` 里直接放
|
||||
* `wp_list_comments()` 输出的 `<li>`,标签层级非法。浏览器会
|
||||
* 自行"修复"结构,而嵌套回复(`<ul class="children">`)在这种
|
||||
* 被修复过的树里容易错位 —— 这是"评论看起来乱"的一个真实来源。
|
||||
*
|
||||
* 2. **回复状态条**。点「回复」时除了把表单挪过去,还在表单上方
|
||||
* 显示「正在回复 @某人 / 取消」。旧实现只有表单被挪动这一条线索,
|
||||
* 用户在长评论区里挪动后常常不知道自己到底在回复谁。
|
||||
*
|
||||
* 3. **表单加 nonce**。AJAX 提交走 nonce 校验,没有它任何第三方
|
||||
* 页面都能借访客的浏览器刷评论。
|
||||
*
|
||||
* 4. **提交按钮的禁用态由 JS 控制**,模板里给出 `.is-loading` 的样式钩子。
|
||||
*
|
||||
* @author Yongquan Yang <yangyq.net>
|
||||
* @license MIT License
|
||||
* @version 2020.03.14
|
||||
*/
|
||||
|
||||
if (isset($_SERVER['SCRIPT_FILENAME']) && 'comments.php' == basename($_SERVER['SCRIPT_FILENAME'])) {
|
||||
die();
|
||||
}
|
||||
|
||||
require get_template_directory() . '/pages/page-smilies.php';
|
||||
if (comments_open()) {?>
|
||||
|
||||
$yang_post_id = get_the_ID();
|
||||
$yang_comment_count = (int) get_comments_number($yang_post_id);
|
||||
$yang_max_depth = (int) get_option('thread_comments_depth', 5);
|
||||
if ($yang_max_depth <= 0) {
|
||||
$yang_max_depth = 5;
|
||||
}
|
||||
$yang_threaded = (bool) get_option('thread_comments');
|
||||
$yang_has_comments = ($yang_comment_count > 0);
|
||||
|
||||
/* 评论是否分页:分页时首屏只给「加载更多」,不一次性铺全部 */
|
||||
$yang_paged = (bool) get_option('page_comments');
|
||||
$yang_per_page = (int) get_option('comments_per_page', 20);
|
||||
if ($yang_per_page <= 0) {
|
||||
$yang_per_page = 20;
|
||||
}
|
||||
?>
|
||||
<div class="comments" id="comments">
|
||||
<h3 class="title"><?php if (is_single()) {_e('文章评论', 'kratos');} else {_e('评论内容', 'kratos');}?></h3>
|
||||
<div class="list">
|
||||
<?php wp_list_comments('type=comment&callback=comment_callbacks');?>
|
||||
|
||||
<div class="comments-head">
|
||||
<h3 class="title">
|
||||
<?php
|
||||
if ($yang_comment_count > 0) {
|
||||
printf(
|
||||
/* translators: %s: 评论条数 */
|
||||
esc_html__('评论(%s)', 'kratos'),
|
||||
esc_html(number_format_i18n($yang_comment_count))
|
||||
);
|
||||
} else {
|
||||
esc_html_e('评论', 'kratos');
|
||||
}
|
||||
?>
|
||||
</h3>
|
||||
<?php if (comments_open()) : ?>
|
||||
<a class="comments-jump-form" href="#respond"><?php esc_html_e('写下你的评论', 'kratos'); ?></a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div id="commentpage" class="nav text-center my-3">
|
||||
<?php previous_comments_link(__('加载更多', 'kratos'));?>
|
||||
|
||||
<?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 if ($yang_has_comments) : ?>
|
||||
<?php
|
||||
/* <ol> 而不是 <div>:<li> 的父元素必须是列表容器。
|
||||
加 reversed/start 会打乱视觉顺序,所以只用普通 <ol>。 */
|
||||
?>
|
||||
<ol class="yang-comment-list" id="yang-comment-list">
|
||||
<?php
|
||||
wp_list_comments(array(
|
||||
'type' => 'comment',
|
||||
'callback' => 'comment_callbacks',
|
||||
'style' => 'ol',
|
||||
'max_depth' => $yang_threaded ? $yang_max_depth : 1,
|
||||
'avatar_size' => 48,
|
||||
'per_page' => $yang_paged ? $yang_per_page : 0,
|
||||
'page' => 1,
|
||||
));
|
||||
?>
|
||||
</ol>
|
||||
|
||||
<?php
|
||||
/* 加载更多:只在「有分页」且「总数超过一页」时出现。
|
||||
旧模板无条件输出,即使只有 1 条评论也显示一个点不动的按钮。 */
|
||||
$yang_has_more = $yang_paged && $yang_comment_count > $yang_per_page;
|
||||
?>
|
||||
<div class="comments-more" id="yang-comments-more"<?php echo $yang_has_more ? '' : ' hidden'; ?>>
|
||||
<button type="button" class="comments-more-btn" data-page="2">
|
||||
<?php esc_html_e('加载更多评论', 'kratos'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<div id="respond" class="comment-respond mt-2">
|
||||
<?php if (!comments_open()): elseif (get_option('comment_registration') && !is_user_logged_in()): ?>
|
||||
<div class="error text-center">
|
||||
<?php printf(__('您需要 <a href="%s">登录</a> 之后才可以评论', 'kratos'), wp_login_url(get_permalink()));?>
|
||||
|
||||
<?php elseif (comments_open()) : ?>
|
||||
|
||||
<p class="comments-empty"><?php esc_html_e('还没有评论,来说第一句吧。', 'kratos'); ?></p>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (comments_open()) : ?>
|
||||
|
||||
<?php if (get_option('comment_registration') && !is_user_logged_in()) : ?>
|
||||
|
||||
<div id="respond" class="comment-respond">
|
||||
<p class="comments-login-required">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: 登录链接 */
|
||||
wp_kses(
|
||||
__('您需要 <a href="%s">登录</a> 之后才可以评论。', 'kratos'),
|
||||
array('a' => array('href' => array()))
|
||||
),
|
||||
esc_url(wp_login_url(get_permalink()))
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<form id="commentform" name="commentform" action="<?php echo get_option('siteurl'); ?>/wp-comments-post.php" method="post">
|
||||
<div class="comment-form">
|
||||
<?php if (!is_user_logged_in()): ?>
|
||||
<div class="comment-info mb-3 row">
|
||||
<div class="col-md-6 comment-form-author">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="kicon i-user"></i></span>
|
||||
|
||||
<?php else : ?>
|
||||
|
||||
<div id="respond" class="comment-respond mt-4">
|
||||
<h3 class="comment-reply-title">
|
||||
<span id="yang-reply-title-text"><?php esc_html_e('发表评论', 'kratos'); ?></span>
|
||||
<?php
|
||||
/* 取消回复按钮:由 JS 控制显示,模板里先藏起来。
|
||||
不用 WordPress 的 cancel_comment_reply_link():
|
||||
它生成的 <a href> 依赖 JS 拦截,没 JS 时会跳到
|
||||
?replytocom= 的地址,行为很怪。这里用 button。 */
|
||||
?>
|
||||
<button type="button" id="yang-cancel-reply" class="comment-cancel-reply" hidden>
|
||||
<?php esc_html_e('取消回复', 'kratos'); ?>
|
||||
</button>
|
||||
</h3>
|
||||
|
||||
<?php
|
||||
/* 正在回复谁:JS 更新这段文字。
|
||||
放在表单上方而不是按钮旁边,是因为表单会被"搬"到
|
||||
被回复评论的下方,粘在按钮上的提示会跟着乱跑。 */
|
||||
?>
|
||||
<p class="comment-reply-hint" id="yang-reply-hint" hidden></p>
|
||||
|
||||
<form id="commentform" name="commentform" class="yang-comment-form" method="post"
|
||||
action="<?php echo esc_url(site_url('/wp-comments-post.php')); ?>">
|
||||
|
||||
<?php if (!is_user_logged_in()) : ?>
|
||||
<?php
|
||||
/* 已登录用户的昵称邮箱从 cookie 里取,未登录才显示输入框。
|
||||
esc_attr 必须做:cookie 里的值是用户可控的。 */
|
||||
$yang_commenter = wp_get_current_commenter();
|
||||
?>
|
||||
<div class="comment-info">
|
||||
<div class="comment-field comment-form-author">
|
||||
<label for="author" class="sr-only"><?php esc_html_e('昵称', 'kratos'); ?></label>
|
||||
<span class="comment-field-icon" aria-hidden="true"><i class="kicon i-user"></i></span>
|
||||
<input class="comment-input" id="author" name="author" type="text"
|
||||
placeholder="<?php esc_attr_e('昵称(必填)', 'kratos'); ?>"
|
||||
value="<?php echo esc_attr($yang_commenter['comment_author']); ?>"
|
||||
required="required" autocomplete="nickname" maxlength="60">
|
||||
</div>
|
||||
<input class="form-control" id="author" placeholder="<?php _e('昵称', 'kratos'); ?>" name="author" type="text" value="<?php echo esc_attr($commenter['comment_author']); ?>" required="required">
|
||||
<div class="comment-field comment-form-email">
|
||||
<label for="email" class="sr-only"><?php esc_html_e('邮箱', 'kratos'); ?></label>
|
||||
<span class="comment-field-icon" aria-hidden="true"><i class="kicon i-cemail"></i></span>
|
||||
<input class="comment-input" id="email" name="email" type="email"
|
||||
placeholder="<?php esc_attr_e('邮箱(必填,不会公开)', 'kratos'); ?>"
|
||||
value="<?php echo esc_attr($yang_commenter['comment_author_email']); ?>"
|
||||
required="required" autocomplete="email" maxlength="120">
|
||||
</div>
|
||||
<div class="comment-field comment-form-url">
|
||||
<label for="url" class="sr-only"><?php esc_html_e('网址', 'kratos'); ?></label>
|
||||
<span class="comment-field-icon" aria-hidden="true"><i class="kicon i-url"></i></span>
|
||||
<input class="comment-input" id="url" name="url" type="url"
|
||||
placeholder="<?php esc_attr_e('网址(选填)', 'kratos'); ?>"
|
||||
value="<?php echo esc_attr($yang_commenter['comment_author_url']); ?>"
|
||||
autocomplete="url" maxlength="200">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mt-3 mt-md-0 comment-form-email">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="kicon i-cemail"></i></span>
|
||||
</div>
|
||||
<input id="email" class="form-control" name="email" placeholder="<?php _e('邮箱', 'kratos'); ?>" type="email" value="<?php echo esc_attr($commenter['comment_author_email']); ?>" required="required">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mt-3 comment-form-author">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="kicon i-url"></i></span>
|
||||
</div>
|
||||
<input class="form-control" id="author" placeholder="<?php _e('网址', 'kratos'); ?>" name="url" type="url" value="<?php echo esc_attr($commenter['comment_author_url']); ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif;?>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="comment-textarea">
|
||||
<textarea class="form-control" id="comment" name="comment" rows="7" required="required"></textarea>
|
||||
<div class="text-bar clearfix">
|
||||
<div class="tool float-left">
|
||||
<a class="addbtn" href="#" id="addsmile"><i class="kicon i-face"></i></a>
|
||||
<div class="smile">
|
||||
<div class="clearfix">
|
||||
<label for="comment" class="sr-only"><?php esc_html_e('评论内容', 'kratos'); ?></label>
|
||||
<textarea class="comment-input" id="comment" name="comment" rows="6"
|
||||
placeholder="<?php esc_attr_e('说点什么…(支持 :smile: 这类表情代码)', 'kratos'); ?>"
|
||||
required="required" maxlength="65525"></textarea>
|
||||
|
||||
<div class="comment-bar">
|
||||
<div class="comment-bar-left">
|
||||
<button type="button" class="comment-smile-btn" id="yang-smile-btn"
|
||||
aria-expanded="false" aria-controls="yang-smile-panel"
|
||||
title="<?php esc_attr_e('插入表情', 'kratos'); ?>">
|
||||
<i class="kicon i-face" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="comment-smile-panel" id="yang-smile-panel" hidden>
|
||||
<?php echo $smilies; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="float-right">
|
||||
<?php cancel_comment_reply_link(__('取消回复', 'kratos'));?>
|
||||
<input name="submit" type="submit" id="submit" class="btn btn-primary" value="<?php _e('提交评论', 'kratos');?>">
|
||||
<div class="comment-bar-right">
|
||||
<span class="comment-status" id="yang-comment-status" role="status" aria-live="polite"></span>
|
||||
<button type="submit" id="submit" class="comment-submit">
|
||||
<?php esc_html_e('提交评论', 'kratos'); ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php comment_id_fields();?>
|
||||
<?php do_action('comment_form', $post->ID);?>
|
||||
|
||||
<?php
|
||||
/* honeypot:把这个字段留空的是人,填了的是机器人。
|
||||
CSS 用 position:absolute 移出视口,不能用 display:none
|
||||
—— 部分爬虫会跳过隐藏字段,但多数不会跳过"移出视口"的。 */
|
||||
?>
|
||||
<p class="comment-hp" aria-hidden="true">
|
||||
<label for="yang_hp"><?php esc_html_e('请勿填写此项', 'kratos'); ?></label>
|
||||
<input type="text" id="yang_hp" name="yang_hp" value="" tabindex="-1" autocomplete="off">
|
||||
</p>
|
||||
|
||||
<?php
|
||||
wp_nonce_field('yang_comment_submit', 'yang_comment_nonce');
|
||||
comment_id_fields();
|
||||
do_action('comment_form', $yang_post_id);
|
||||
?>
|
||||
</form>
|
||||
<?php endif;?>
|
||||
</div>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php }?>
|
||||
@@ -37,12 +37,21 @@ require get_template_directory() . '/inc/theme-mourning-page.php';
|
||||
// 文章配置
|
||||
require get_template_directory() . '/inc/theme-article.php';
|
||||
|
||||
// 评论区(统一渲染 + AJAX + 评论通知)。
|
||||
// 必须在 theme-article.php 之后:后者不再定义评论函数,
|
||||
// 但评论模板 comments.php 里引用的 comment_callbacks 由本文件提供。
|
||||
// 也必须早于 theme-mail.php(评论通知要调 yang_mail_render)。
|
||||
require get_template_directory() . '/inc/theme-comments.php';
|
||||
|
||||
// 小工具配置
|
||||
require get_template_directory() . '/inc/theme-widgets.php';
|
||||
|
||||
// 文章增强
|
||||
require get_template_directory() . '/inc/theme-shortcode.php';
|
||||
|
||||
// 文章目录([TOC] 短代码)
|
||||
require get_template_directory() . '/inc/theme-toc.php';
|
||||
|
||||
// 添加导航目录
|
||||
require get_template_directory() . '/inc/theme-navwalker.php';
|
||||
|
||||
|
||||
+18
-101
@@ -197,101 +197,21 @@ function pagelist($range = 5)
|
||||
}
|
||||
}
|
||||
|
||||
// 文章评论
|
||||
function comment_scripts()
|
||||
{
|
||||
wp_enqueue_script('comment', ASSET_PATH . '/assets/js/comments.min.js', array(), THEME_VERSION);
|
||||
wp_localize_script('comment', 'ajaxcomment', array(
|
||||
'ajax_url' => admin_url('admin-ajax.php'),
|
||||
'order' => get_option('comment_order')
|
||||
));
|
||||
}
|
||||
add_action('wp_enqueue_scripts', 'comment_scripts');
|
||||
|
||||
function comment_err($a)
|
||||
{
|
||||
header('HTTP/1.0 500 Internal Server Error');
|
||||
header('Content-Type: text/plain;charset=UTF-8');
|
||||
echo $a;
|
||||
exit;
|
||||
}
|
||||
|
||||
function comment_callback()
|
||||
{
|
||||
$comment = wp_handle_comment_submission(wp_unslash($_POST));
|
||||
if (is_wp_error($comment)) {
|
||||
$data = $comment->get_error_data();
|
||||
if (!empty($data)) {
|
||||
comment_err($comment->get_error_message());
|
||||
} else {
|
||||
exit;
|
||||
}
|
||||
}
|
||||
$user = wp_get_current_user();
|
||||
do_action('set_comment_cookies', $comment, $user);
|
||||
$GLOBALS['comment'] = $comment;
|
||||
?>
|
||||
<li class="comment cleanfix" id="comment-<?php echo esc_attr(comment_ID()); ?>">
|
||||
<div class="avatar float-left d-inline-block mr-2">
|
||||
<?php if (function_exists('get_avatar') && get_option('show_avatars')) {echo get_avatar($comment, 50);}?>
|
||||
</div>
|
||||
<div class="info clearfix">
|
||||
<cite class="author_name"><?php echo get_comment_author_link();?></cite>
|
||||
<div class="content pb-2">
|
||||
<?php comment_text();?>
|
||||
</div>
|
||||
<div class="meta clearfix">
|
||||
<div class="date d-inline-block float-left"><?php echo get_comment_date('Y年m月d日'); ?><?php if (current_user_can('edit_posts')) {echo '<span class="ml-2">';edit_comment_link(__('编辑', 'kratos'));echo '</span>';};?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<?php die();
|
||||
}
|
||||
|
||||
add_action('wp_ajax_nopriv_ajax_comment', 'comment_callback');
|
||||
add_action('wp_ajax_ajax_comment', 'comment_callback');
|
||||
|
||||
function comment_post($incoming_comment)
|
||||
{
|
||||
$incoming_comment['comment_content'] = htmlspecialchars($incoming_comment['comment_content']);
|
||||
$incoming_comment['comment_content'] = str_replace("'", ''', $incoming_comment['comment_content']);
|
||||
return ($incoming_comment);
|
||||
}
|
||||
add_filter('preprocess_comment', 'comment_post', '', 1);
|
||||
|
||||
function comment_display($comment_to_display)
|
||||
{
|
||||
$comment_to_display = str_replace(''', "'", $comment_to_display);
|
||||
return $comment_to_display;
|
||||
}
|
||||
add_filter('comment_text', 'comment_display', '', 1);
|
||||
|
||||
function comment_callbacks($comment, $args, $depth = 2)
|
||||
{
|
||||
$GLOBALS['comment'] = $comment;?>
|
||||
<li class="comment cleanfix" id="comment-<?php echo esc_attr(comment_ID()); ?>">
|
||||
<div class="avatar float-left d-inline-block mr-2">
|
||||
<?php if (function_exists('get_avatar') && get_option('show_avatars')) {echo get_avatar($comment, 50);}?>
|
||||
</div>
|
||||
<div class="info clearfix">
|
||||
<cite class="author_name"><?php echo get_comment_author_link();?></cite>
|
||||
<div class="content pb-2">
|
||||
<?php comment_text();?>
|
||||
</div>
|
||||
<div class="meta clearfix">
|
||||
<div class="date d-inline-block float-left"><?php echo get_comment_date('Y年m月d日'); ?><?php if (current_user_can('edit_posts')) {echo '<span class="ml-2">';edit_comment_link(__('编辑', 'kratos'));echo '</span>';};?>
|
||||
</div>
|
||||
<div class="tool reply ml-2 d-inline-block float-right">
|
||||
<?php
|
||||
$defaults = array('add_below' => 'comment', 'respond_id' => 'respond', 'reply_text' => '<i class="kicon i-reply"></i><span class="ml-1">' . __('回复', 'kratos') . '</span>');
|
||||
comment_reply_link(array_merge($defaults, array('depth' => $depth, 'max_depth' => $args['max_depth'])));
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
/* ── 评论相关已迁到 inc/theme-comments.php(3.2.18)──────────────
|
||||
*
|
||||
* 原先这里有一段 201~294 行的评论实现(comment_scripts /
|
||||
* comment_callback / comment_callbacks)和 380~385 行的
|
||||
* recover_comment_fields。迁走的原因是:
|
||||
*
|
||||
* · 列表渲染与 AJAX 渲染**各写了一份 HTML**,已经分叉 ——
|
||||
* AJAX 返回的新评论没有「回复」按钮,必须刷新页面才出现;
|
||||
* · 提交逻辑用的是旧的 layer 弹窗 + 直接透传后端原始响应;
|
||||
* · 缺少 nonce 校验、缺少防重复提交。
|
||||
*
|
||||
* 新实现统一到 yang_comment_html() 一个渲染函数,两边都调它,
|
||||
* 从结构上消除分叉可能。函数名 comment_callbacks / comment_scripts
|
||||
* 也都在新文件里以兼容形式保留,所以 comments.php 不必改调用点。
|
||||
* ------------------------------------------------------------ */
|
||||
|
||||
// 文章评论表情
|
||||
function custom_smilies_src($img_src, $img, $siteurl)
|
||||
@@ -377,12 +297,9 @@ function comment_add_at($comment_text, $comment = '') {
|
||||
}
|
||||
add_filter('comment_text' , 'comment_add_at', 20, 2);
|
||||
|
||||
function recover_comment_fields($comment_fields){
|
||||
$comment = array_shift($comment_fields);
|
||||
$comment_fields = array_merge($comment_fields ,array('comment' => $comment));
|
||||
return $comment_fields;
|
||||
}
|
||||
add_filter('comment_form_fields','recover_comment_fields');
|
||||
/* recover_comment_fields() 已由 inc/theme-comments.php 里的
|
||||
yang_comment_form_fields() 接替(同样的字段顺序,但不再用
|
||||
array_shift 硬拿第一个 —— 那样在插件往字段数组前面插东西时会拿错)。 */
|
||||
|
||||
$new_meta_boxes =
|
||||
array(
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
<?php
|
||||
/**
|
||||
* 评论区:统一渲染 + AJAX 提交 + 评论通知
|
||||
*
|
||||
* ── 为什么要重写 ──────────────────────────────────────────────
|
||||
* 旧实现有两个根本问题,都不是"不好看"能概括的:
|
||||
*
|
||||
* 1. **同一份评论渲染了两遍,而且已经分叉**。
|
||||
* `comment_callbacks()`(列表渲染)里有「回复」按钮,
|
||||
* `comment_callback()`(AJAX 提交后返回的新评论)里**没有** ——
|
||||
* 于是通过 AJAX 刚发出来的那条评论,页面上根本没有回复入口,
|
||||
* 刷新一次才会出现(因为刷新走的是列表渲染)。
|
||||
* 这类 bug 的根因不是漏写,是"两个地方各写一份 HTML"。
|
||||
* 现在两边都调 `yang_comment_html()`,物理上不可能再分叉。
|
||||
*
|
||||
* 2. **列表容器是 <div>,里面直接放 <li>**。
|
||||
* `comments.php` 原来写的是 `<div class="list">` + `wp_list_comments()`,
|
||||
* 后者输出的是 `<li>`。`<li>` 的父元素必须是 <ul>/<ol>,
|
||||
* 否则浏览器会自己"猜"结构,不同浏览器猜法不同,
|
||||
* 嵌套回复(<ul class="children">)在某些浏览器里会错位。
|
||||
*
|
||||
* ── AJAX 设计的三条约束 ──────────────────────────────────────
|
||||
* 1. **不用 layer 弹提示**。layer 是个通用弹窗库,用来显示"评论提交成功"
|
||||
* 太重(遮罩、动画、层级都多余),而且它失败时会把整个提示吞掉。
|
||||
* 这里用轻量的内联状态条,并直接显示后端返回的**人话**错误。
|
||||
* 2. **必须防重复提交**。旧实现 beforeSend 只弹了个"提交中",
|
||||
* 按钮仍可点,网络慢时用户会连点,产生多条重复评论。
|
||||
* 3. **后端错误要可读**。WordPress 的评论错误是 WP_Error,
|
||||
* 旧代码把 message 直接扔给前端,有些情况下是空串或英文提示。
|
||||
* 这里统一映射成中文,并区分"表单错误"(可修正)与"系统错误"。
|
||||
*
|
||||
* ── 与 wpDiscuz 的关系 ───────────────────────────────────────
|
||||
* 这个文件是**替代** wpDiscuz 的实现,不是与它共存。装了插件时
|
||||
* 插件会接管评论表单,主题这套不生效(也不该生效,两套会打架)。
|
||||
* 卸载插件后,主题这套自动接管,且评论通知不依赖插件 ——
|
||||
* 见本文件末尾的通知部分。
|
||||
*
|
||||
* @author Yongquan Yang <yangyq.net>
|
||||
* @license MIT License
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* wpDiscuz 是否在接管评论
|
||||
*
|
||||
* 用途:卸载插件后要自动切到主题通知,不能依赖插件还活着。
|
||||
* 只看类/函数是否存在,不做 is_plugin_active() —— 那需要
|
||||
* 在后台上下文才有 plugin.php,前台调用会 Fatal。
|
||||
*/
|
||||
function yang_comments_plugin_active()
|
||||
{
|
||||
return class_exists('WpdiscuzCore') || function_exists('wpdiscuz');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染单条评论的 HTML
|
||||
*
|
||||
* **列表渲染与 AJAX 返回共用这一个函数**。这是本次重写的核心 ——
|
||||
* 只要两处都调它,就不可能再出现"AJAX 出来的评论少个按钮"。
|
||||
*
|
||||
* @param WP_Comment $comment 评论对象
|
||||
* @param array $args wp_list_comments 传入的参数(深度等)
|
||||
* @param int $depth 当前深度
|
||||
* @param bool $is_ajax 是否是被 AJAX 返回的新评论
|
||||
* @return string
|
||||
*/
|
||||
function yang_comment_html($comment, $args = array(), $depth = 1, $is_ajax = false)
|
||||
{
|
||||
if (!($comment instanceof WP_Comment)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$comment_id = (int) $comment->comment_ID;
|
||||
$max_depth = isset($args['max_depth']) ? (int) $args['max_depth'] : 0;
|
||||
$depth = max(1, (int) $depth);
|
||||
|
||||
// 作者能否编辑(管理员/编辑在每条上看到「编辑」入口)
|
||||
$can_edit = current_user_can('edit_comment', $comment_id);
|
||||
|
||||
// 评论者是不是文章作者(给个标记,便于区分)
|
||||
$post = get_post($comment->comment_post_ID);
|
||||
$is_post_author = $post && ((int) $post->post_author === (int) $comment->user_id) && $comment->user_id > 0;
|
||||
|
||||
$author_name = $comment->comment_author !== ''
|
||||
? $comment->comment_author
|
||||
: __('匿名访客', 'kratos');
|
||||
|
||||
/* 评论内容:comment_text() 会走 WordPress 的完整过滤链
|
||||
(smilies、wpautop、kese、@提及高亮都在那上面挂着),
|
||||
所以必须用它,而不是 $comment->comment_content。 */
|
||||
ob_start();
|
||||
comment_text($comment_id);
|
||||
$content = ob_get_clean();
|
||||
|
||||
$avatar = '';
|
||||
if (get_option('show_avatars') && function_exists('get_avatar')) {
|
||||
$avatar = get_avatar($comment, 48, '', $author_name, array(
|
||||
'class' => 'yang-comment-avatar-img',
|
||||
'extra_attr' => 'loading="lazy"',
|
||||
));
|
||||
}
|
||||
|
||||
$classes = array('yang-comment');
|
||||
if ($is_ajax) {
|
||||
$classes[] = 'is-new';
|
||||
}
|
||||
if ($comment->comment_approved === '0') {
|
||||
$classes[] = 'is-pending';
|
||||
}
|
||||
|
||||
$o = array();
|
||||
$o[] = '<li id="comment-' . $comment_id . '" class="' . esc_attr(implode(' ', $classes)) . '"'
|
||||
. ' data-comment-id="' . $comment_id . '"'
|
||||
. ' data-depth="' . $depth . '">';
|
||||
|
||||
// ── 头像列 ──
|
||||
$o[] = '<div class="yang-comment-avatar">';
|
||||
if ($avatar !== '') {
|
||||
$o[] = $avatar;
|
||||
} else {
|
||||
// 没头像时给个占位,别让左侧塌掉导致文字贴边
|
||||
$o[] = '<span class="yang-comment-avatar-ph" aria-hidden="true">'
|
||||
. esc_html(mb_substr($author_name, 0, 1, 'UTF-8'))
|
||||
. '</span>';
|
||||
}
|
||||
$o[] = '</div>';
|
||||
|
||||
// ── 主体 ──
|
||||
$o[] = '<div class="yang-comment-main">';
|
||||
$o[] = '<div class="yang-comment-head">';
|
||||
$o[] = '<cite class="yang-comment-author">' . get_comment_author_link($comment) . '</cite>';
|
||||
|
||||
if ($is_post_author) {
|
||||
$o[] = '<span class="yang-comment-badge">' . __('作者', 'kratos') . '</span>';
|
||||
}
|
||||
if ($comment->comment_approved === '0') {
|
||||
$o[] = '<span class="yang-comment-badge is-pending">' . __('待审核', 'kratos') . '</span>';
|
||||
}
|
||||
|
||||
$o[] = '<time class="yang-comment-time" datetime="'
|
||||
. esc_attr(mysql2date('c', $comment->comment_date)) . '">'
|
||||
. esc_html(mysql2date('Y年m月d日 H:i', $comment->comment_date))
|
||||
. '</time>';
|
||||
$o[] = '</div>'; // head
|
||||
|
||||
// 被回复者的提示(@某人),由 comment_add_at 过滤器加到正文里,
|
||||
// 这里不再重复,避免出现两次
|
||||
|
||||
$o[] = '<div class="yang-comment-content">' . $content . '</div>';
|
||||
|
||||
// ── 操作区 ──
|
||||
$o[] = '<div class="yang-comment-actions">';
|
||||
|
||||
/* 回复按钮:可回复的前提有三个 ——
|
||||
① 评论还开着;② 没超过主题设置的嵌套层数;③ 不是待审核的。 */
|
||||
$can_reply = comments_open($comment->comment_post_ID);
|
||||
if ($max_depth > 0 && $depth >= $max_depth) {
|
||||
$can_reply = false;
|
||||
}
|
||||
if ($comment->comment_approved === '0' && !$can_edit) {
|
||||
$can_reply = false;
|
||||
}
|
||||
|
||||
if ($can_reply) {
|
||||
$o[] = '<button type="button" class="yang-comment-reply"'
|
||||
. ' data-comment-id="' . $comment_id . '"'
|
||||
. ' data-author="' . esc_attr($author_name) . '">'
|
||||
. '<i class="kicon i-reply" aria-hidden="true"></i>'
|
||||
. '<span>' . __('回复', 'kratos') . '</span>'
|
||||
. '</button>';
|
||||
}
|
||||
|
||||
if ($can_edit) {
|
||||
$o[] = '<a class="yang-comment-edit" href="' . esc_url(get_edit_comment_link($comment_id)) . '">'
|
||||
. __('编辑', 'kratos') . '</a>';
|
||||
}
|
||||
|
||||
$o[] = '</div>'; // actions
|
||||
$o[] = '</div>'; // main
|
||||
$o[] = '</li>';
|
||||
|
||||
return implode('', $o);
|
||||
}
|
||||
|
||||
/**
|
||||
* wp_list_comments 的回调(列表渲染)
|
||||
*
|
||||
* 只是把参数转交给统一渲染函数。保留这个函数名是因为
|
||||
* comments.php 里引用了它,且子主题可能覆盖过。
|
||||
*/
|
||||
function comment_callbacks($comment, $args = array(), $depth = 1)
|
||||
{
|
||||
echo yang_comment_html($comment, $args, $depth, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 WordPress 的评论错误翻译成人话
|
||||
*
|
||||
* ── 为什么需要这层 ────────────────────────────────────────────
|
||||
* `wp_handle_comment_submission()` 返回的 WP_Error,message 有时是
|
||||
* 英文(插件/核心的默认文案)、有时是空串、有时是带 HTML 的。
|
||||
* 直接扔到前端会显示成 "ERROR: please type a comment",或者一片空白
|
||||
* —— 用户看到空白只会以为"点了没反应",然后继续点。
|
||||
*
|
||||
* 这里做三件事:给空 message 兜底、按 code 覆盖成中文、
|
||||
* 剥掉残留的 HTML 标签。
|
||||
*/
|
||||
function yang_comment_error_text($error)
|
||||
{
|
||||
if (!is_wp_error($error)) {
|
||||
return __('提交失败,请稍后重试。', 'kratos');
|
||||
}
|
||||
|
||||
$code = $error->get_error_code();
|
||||
$msg = trim(wp_strip_all_tags((string) $error->get_error_message()));
|
||||
|
||||
// 按错误码给确定的中文文案(比核心的英文默认值友好)
|
||||
$map = array(
|
||||
'require_name_email' => __('昵称和邮箱是必填的。', 'kratos'),
|
||||
'comment_author_email_invalid' => __('邮箱格式不对,请检查一下。', 'kratos'),
|
||||
'comment_author_url_invalid' => __('网址格式不对,请检查一下。', 'kratos'),
|
||||
'comment_id_not_found' => __('这篇文章不存在,无法评论。', 'kratos'),
|
||||
'comment_closed' => __('这篇文章已关闭评论。', 'kratos'),
|
||||
'comment_on_trash' => __('这篇文章在回收站里,无法评论。', 'kratos'),
|
||||
'comment_on_draft' => __('草稿状态的文章无法评论。', 'kratos'),
|
||||
'comment_on_password_protected' => __('这是密码保护的文章,需要先输入密码。', 'kratos'),
|
||||
'comment_duplicate' => __('你刚才已经发过一模一样的评论了,请勿重复提交。', 'kratos'),
|
||||
'comment_flood' => __('发得太快了,歇一会儿再发吧。', 'kratos'),
|
||||
'not_logged_in' => __('需要登录后才能评论。', 'kratos'),
|
||||
'unapproved_comment' => __('你的评论正在等待审核。', 'kratos'),
|
||||
);
|
||||
|
||||
if (isset($map[$code])) {
|
||||
return $map[$code];
|
||||
}
|
||||
|
||||
// 没映射到的:有 message 就用(剥掉 HTML),没有就给通用兜底
|
||||
if ($msg !== '') {
|
||||
return $msg;
|
||||
}
|
||||
return __('提交失败,请稍后重试。', 'kratos');
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX 提交评论
|
||||
*
|
||||
* ── 返回格式 ──────────────────────────────────────────────────
|
||||
* 统一 JSON:
|
||||
* 成功 { ok: true, html: '<li>…</li>', comment_id, depth,
|
||||
* count, count_text, parent_id, message }
|
||||
* 失败 { ok: false, message, code, field }
|
||||
*
|
||||
* `field` 指出是哪个字段的问题(author/email/comment),
|
||||
* 前端据此把焦点送回去 —— 比只弹一句"昵称必填"有用。
|
||||
*
|
||||
* ── 兼容旧的 action 名 ───────────────────────────────────────
|
||||
* 旧版前端用的 action 是 `ajax_comment`。新前端也用同一个名字,
|
||||
* 这样浏览器缓存了旧 JS 的站点在过渡期也不会 404。
|
||||
*/
|
||||
function yang_ajax_submit_comment()
|
||||
{
|
||||
// 评论必须走 nonce 校验:没有它,第三方页面可以借访客身份刷评论
|
||||
$nonce = isset($_POST['yang_comment_nonce']) ? $_POST['yang_comment_nonce'] : '';
|
||||
if (!wp_verify_nonce($nonce, 'yang_comment_submit')) {
|
||||
wp_send_json(array(
|
||||
'ok' => false,
|
||||
'code' => 'bad_nonce',
|
||||
'message' => __('页面已过期,请刷新后重新提交。', 'kratos'),
|
||||
), 403);
|
||||
}
|
||||
|
||||
$comment = wp_handle_comment_submission(wp_unslash($_POST));
|
||||
if (is_wp_error($comment)) {
|
||||
$data = $comment->get_error_data();
|
||||
// 403 = 被判定为垃圾/需要登录等;422 = 表单填写问题
|
||||
$status = 400;
|
||||
if (isset($data['status'])) {
|
||||
$status = (int) $data['status'];
|
||||
}
|
||||
wp_send_json(array(
|
||||
'ok' => false,
|
||||
'code' => $comment->get_error_code(),
|
||||
'field' => yang_comment_error_field($comment->get_error_code()),
|
||||
'message' => yang_comment_error_text($comment),
|
||||
), $status > 0 ? $status : 400);
|
||||
}
|
||||
|
||||
// 写入评论者 cookie,这样他下次来名字邮箱还在
|
||||
$user = wp_get_current_user();
|
||||
do_action('set_comment_cookies', $comment, $user);
|
||||
|
||||
$post_id = (int) $comment->comment_post_ID;
|
||||
$parent_id = (int) $comment->comment_parent;
|
||||
|
||||
/* 深度:wp_list_comments 的 depth 从 1 开始。
|
||||
父评论的深度 + 1 就是新评论的深度;顶层评论是 1。 */
|
||||
$depth = 1;
|
||||
if ($parent_id > 0) {
|
||||
$parent = get_comment($parent_id);
|
||||
if ($parent) {
|
||||
// 用已有的 wp_list_comments 深度逻辑:数祖先层数 + 1
|
||||
$ancestors = get_comment_ancestors($parent);
|
||||
$depth = count($ancestors) + 2;
|
||||
}
|
||||
}
|
||||
|
||||
$max_depth = (int) get_option('thread_comments_depth', 5);
|
||||
if ($max_depth <= 0) {
|
||||
$max_depth = 5;
|
||||
}
|
||||
|
||||
$args = array(
|
||||
'max_depth' => $max_depth,
|
||||
'style' => 'ul',
|
||||
'short_ping' => true,
|
||||
'avatar_size' => 48,
|
||||
);
|
||||
|
||||
$html = yang_comment_html($comment, $args, $depth, true);
|
||||
|
||||
// 评论数:只数已通过的(待审核不该算进"N 条评论")
|
||||
$count = (int) get_comments_number($post_id);
|
||||
|
||||
wp_send_json(array(
|
||||
'ok' => true,
|
||||
'html' => $html,
|
||||
'comment_id' => (int) $comment->comment_ID,
|
||||
'depth' => $depth,
|
||||
'max_depth' => $max_depth,
|
||||
'parent_id' => $parent_id,
|
||||
'count' => $count,
|
||||
'count_text' => yang_comment_count_text($count),
|
||||
'approved' => ($comment->comment_approved !== '0'),
|
||||
'message' => ($comment->comment_approved === '0')
|
||||
? __('评论已提交,正在等待审核。', 'kratos')
|
||||
: __('评论发表成功。', 'kratos'),
|
||||
));
|
||||
}
|
||||
add_action('wp_ajax_nopriv_ajax_comment', 'yang_ajax_submit_comment');
|
||||
add_action('wp_ajax_ajax_comment', 'yang_ajax_submit_comment');
|
||||
|
||||
/**
|
||||
* 取某个评论的所有祖先 id(从近到远)
|
||||
*
|
||||
* WordPress 没有现成函数,但它自己有 wp_list_comments 内部用的逻辑。
|
||||
* 这里自己走一遍 parent 链,顺便防环(脏数据有可能会造成死循环)。
|
||||
*/
|
||||
function get_comment_ancestors($comment, $limit = 20)
|
||||
{
|
||||
$out = array();
|
||||
$seen = array((int) $comment->comment_ID => true);
|
||||
$cur = (int) $comment->comment_parent;
|
||||
$i = 0;
|
||||
while ($cur > 0 && $i < $limit) {
|
||||
if (isset($seen[$cur])) {
|
||||
break; // 环,直接停
|
||||
}
|
||||
$seen[$cur] = true;
|
||||
$out[] = $cur;
|
||||
$c = get_comment($cur);
|
||||
if (!$c) {
|
||||
break;
|
||||
}
|
||||
$cur = (int) $c->comment_parent;
|
||||
$i++;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误码 → 出问题的字段(前端用来聚焦)
|
||||
*/
|
||||
function yang_comment_error_field($code)
|
||||
{
|
||||
if (strpos($code, 'author') !== false || $code === 'require_name_email') {
|
||||
return 'author';
|
||||
}
|
||||
if (strpos($code, 'email') !== false) {
|
||||
return 'email';
|
||||
}
|
||||
if (strpos($code, 'url') !== false) {
|
||||
return 'url';
|
||||
}
|
||||
if ($code === 'comment_duplicate' || $code === 'comment_flood') {
|
||||
return 'comment';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 评论数文案(与 single.php 里 comments_number() 的风格保持一致)
|
||||
*
|
||||
* 单独抽出来是因为 AJAX 返回后前端要就地更新"N 条评论",
|
||||
* 不能为了改个数字重新渲染整页。
|
||||
*/
|
||||
function yang_comment_count_text($n)
|
||||
{
|
||||
$n = (int) $n;
|
||||
if ($n === 0) {
|
||||
return __('0 条评论', 'kratos');
|
||||
}
|
||||
if ($n === 1) {
|
||||
return __('1 条评论', 'kratos');
|
||||
}
|
||||
return sprintf(__('%s 条评论', 'kratos'), number_format_i18n($n));
|
||||
}
|
||||
|
||||
/**
|
||||
* 「加载更多评论」AJAX
|
||||
*
|
||||
* 旧模板里 `previous_comments_link(__('加载更多'))` 是个普通链接,
|
||||
* 点了整页跳转(还跳到评论分页 URL)。这里改成原地加载。
|
||||
*
|
||||
* 分页参数用 WordPress 原生的 cpage,保证"直接访问第 2 页评论"也能工作。
|
||||
*/
|
||||
function yang_ajax_load_comments()
|
||||
{
|
||||
$post_id = isset($_POST['post_id']) ? (int) $_POST['post_id'] : 0;
|
||||
$page = isset($_POST['page']) ? max(2, (int) $_POST['page']) : 2;
|
||||
|
||||
if ($post_id <= 0 || !get_post($post_id)) {
|
||||
wp_send_json(array('ok' => false, 'message' => __('参数错误。', 'kratos')), 400);
|
||||
}
|
||||
|
||||
$per_page = (int) get_option('comments_per_page', 20);
|
||||
if ($per_page <= 0) {
|
||||
$per_page = 20;
|
||||
}
|
||||
|
||||
$comments = get_comments(array(
|
||||
'post_id' => $post_id,
|
||||
'status' => 'approve',
|
||||
'order' => strtoupper(get_option('comment_order', 'asc')) === 'DESC' ? 'DESC' : 'ASC',
|
||||
'number' => $per_page,
|
||||
'offset' => ($page - 1) * $per_page,
|
||||
// 只取顶层,子评论随父一起输出
|
||||
'parent' => 0,
|
||||
));
|
||||
|
||||
$max_depth = (int) get_option('thread_comments_depth', 5);
|
||||
$args = array(
|
||||
'max_depth' => $max_depth > 0 ? $max_depth : 5,
|
||||
'style' => 'ul',
|
||||
'short_ping' => true,
|
||||
'avatar_size' => 48,
|
||||
);
|
||||
|
||||
$html = '';
|
||||
foreach ($comments as $c) {
|
||||
$html .= yang_comment_html($c, $args, 1, false);
|
||||
}
|
||||
|
||||
$total = (int) get_comments_number($post_id);
|
||||
$loaded = $page * $per_page;
|
||||
|
||||
wp_send_json(array(
|
||||
'ok' => true,
|
||||
'html' => $html,
|
||||
'page' => $page,
|
||||
'has_more' => $loaded < $total,
|
||||
'next_page' => $page + 1,
|
||||
'loaded' => $loaded,
|
||||
'total' => $total,
|
||||
));
|
||||
}
|
||||
add_action('wp_ajax_nopriv_yang_load_comments', 'yang_ajax_load_comments');
|
||||
add_action('wp_ajax_yang_load_comments', 'yang_ajax_load_comments');
|
||||
|
||||
/**
|
||||
* 前端需要的配置,localize 给 JS
|
||||
*
|
||||
* ── 样式为什么也在这里加载 ────────────────────────────────
|
||||
* 评论只在「单页/页面 + 评论开启」时才出现,没必要全站加载一份
|
||||
* 10KB 的评论样式。放这里与 JS 同一个条件,一起加载一起不加载。
|
||||
*/
|
||||
function yang_comment_scripts()
|
||||
{
|
||||
if (is_admin()) {
|
||||
return;
|
||||
}
|
||||
// 只在会渲染评论的页面加载(与 comments_template() 的触发条件一致)
|
||||
if (!is_singular()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* wpDiscuz 在场时**不加载**主题这套脚本与样式。
|
||||
*
|
||||
* 原因:插件会接管评论表单(换成它自己的 #wpdcom),并绑定自己的
|
||||
* 提交逻辑。若主题的 comments.js 也照常加载,它会在
|
||||
* document 上挂 click 侦听去认 `.yang-comment-reply`,
|
||||
* 还会找 `#commentform` 去绑 submit —— 找得到就形成"两套一起提交",
|
||||
* 找不到就静默什么都不做。两种都不对。
|
||||
*
|
||||
* 同理 CSS:插件的评论样式自成体系,主题的 comments.css 用的是
|
||||
* .yang-comment-* 前缀不会撞,但会给插件不存在的元素加规则,
|
||||
* 白白增加一份体积与排查噪音。
|
||||
*
|
||||
* 注意:这只影响"装了插件还没卸载"的过渡期。卸载后自动接管。 */
|
||||
if (yang_comments_plugin_active()) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_style(
|
||||
'yang-comments',
|
||||
ASSET_PATH . '/assets/css/comments.css',
|
||||
array('kratos'),
|
||||
THEME_VERSION
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
'yang-comments',
|
||||
ASSET_PATH . '/assets/js/comments.js',
|
||||
array(),
|
||||
THEME_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
wp_localize_script('yang-comments', 'yangComment', array(
|
||||
'ajaxUrl' => admin_url('admin-ajax.php'),
|
||||
'nonce' => wp_create_nonce('yang_comment_submit'),
|
||||
'postId' => get_the_ID() ? (int) get_the_ID() : 0,
|
||||
'order' => get_option('comment_order', 'asc'),
|
||||
'maxDepth' => (int) get_option('thread_comments_depth', 5),
|
||||
'i18n' => array(
|
||||
'submitting' => __('提交中…', 'kratos'),
|
||||
'submit' => __('提交评论', 'kratos'),
|
||||
'replyTo' => __('回复', 'kratos'),
|
||||
'cancelReply' => __('取消回复', 'kratos'),
|
||||
'networkErr' => __('网络异常,请检查网络后重试。', 'kratos'),
|
||||
'emptyComment' => __('评论内容不能为空。', 'kratos'),
|
||||
'tooLong' => __('评论内容太长了。', 'kratos'),
|
||||
'loading' => __('加载中…', 'kratos'),
|
||||
'noMore' => __('没有更多评论了', 'kratos'),
|
||||
),
|
||||
));
|
||||
}
|
||||
add_action('wp_enqueue_scripts', 'yang_comment_scripts', 21);
|
||||
|
||||
/**
|
||||
* 兼容旧函数名
|
||||
*
|
||||
* inc/theme-article.php 里原来有一个同名函数并在 wp_enqueue_scripts
|
||||
* 上注册。那个已经删掉,但子主题/缓存里的旧代码可能还在调它,
|
||||
* 保留一个空实现避免 Fatal。
|
||||
*
|
||||
* 注意:**这里刻意不重复注册钩子**。两个都注册会导致评论样式与脚本
|
||||
* 各加载两遍(浏览器去重但 wp_localize 会输出两份配置对象)。
|
||||
*/
|
||||
if (!function_exists('comment_scripts')) {
|
||||
function comment_scripts()
|
||||
{
|
||||
// 由 yang_comment_scripts() 接管,这里保持空实现仅为兼容
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
* 评论通知(不依赖 wpDiscuz)
|
||||
*
|
||||
* ── 与 theme-mail.php 的分工 ─────────────────────────────────
|
||||
* theme-mail.php 负责"通道"(SMTP、去重、格式),本文件负责
|
||||
* "什么时候该发、发给谁"。分开是因为通道层要能独立工作 ——
|
||||
* 即使评论通知全关掉,测试邮件也得能发出去。
|
||||
*
|
||||
* ── 卸载 wpDiscuz 后的行为 ──────────────────────────────────
|
||||
* 插件在时:通知由插件发(带订阅/退订),主题这边全部让开。
|
||||
* 插件不在时:主题接管,发两类邮件 ——
|
||||
* · 有人回复了你的评论 → 给被回复者
|
||||
* · 文章有新评论 → 给文章作者(可关)
|
||||
* 这个切换靠 yang_comments_plugin_active() 判断,自动完成,
|
||||
* 用户不需要改任何设置。
|
||||
* ══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/**
|
||||
* 有新评论时,通知被回复的那个人
|
||||
*
|
||||
* 只在**回复**场景发($comment->comment_parent > 0)。
|
||||
* 顶层新评论通知文章作者,走的是 WordPress 核心的 notify_postauthor,
|
||||
* 不需要在这里重复一遍 —— 否则又是"一条评论两封邮件"。
|
||||
*
|
||||
* ── 为什么不用 comment_post 而用 wp_insert_comment ──────────
|
||||
* comment_post 在评论写入后立刻触发,此时 comment_approved 已就位;
|
||||
* wp_insert_comment 更底层,会连后台手动插入也触发。
|
||||
* 这里要的是"访客提交"这一种,所以用 comment_post,并显式检查
|
||||
* 是否是待审核(待审核的评论不该通知被回复者去看一条不存在的回复)。
|
||||
*/
|
||||
function yang_notify_comment_reply($comment_id, $comment_approved = 1)
|
||||
{
|
||||
if (!yang_mail_smtp_ready()) {
|
||||
return;
|
||||
}
|
||||
// 插件在就让它发,别抢
|
||||
if (yang_comments_plugin_active()) {
|
||||
return;
|
||||
}
|
||||
// 主题选项里允许整体关掉
|
||||
if (!kratos_option('m_comment_notify', true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$comment = get_comment($comment_id);
|
||||
if (!$comment) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parent_id = (int) $comment->comment_parent;
|
||||
if ($parent_id <= 0) {
|
||||
return; // 顶层评论:交给核心的 notify_postauthor
|
||||
}
|
||||
|
||||
$parent = get_comment($parent_id);
|
||||
if (!$parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 被回复者没留邮箱就发不了;自己回复自己也不发
|
||||
$to = trim((string) $parent->comment_author_email);
|
||||
if ($to === '' || !is_email($to)) {
|
||||
return;
|
||||
}
|
||||
$new_mail = trim((string) $comment->comment_author_email);
|
||||
if ($new_mail !== '' && strtolower($new_mail) === strtolower($to)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 同一个收件人对同一条评论只发一次(AJAX 重试、插件重复触发都能挡住)
|
||||
$key = 'yang_creply_' . $comment_id . '_' . md5($to);
|
||||
if (get_transient($key)) {
|
||||
return;
|
||||
}
|
||||
set_transient($key, 1, 300);
|
||||
|
||||
$post = get_post($comment->comment_post_ID);
|
||||
$title = $post ? get_the_title($post) : __('(文章已删除)', 'kratos');
|
||||
$link = get_comment_link($comment);
|
||||
$author = $comment->comment_author !== '' ? $comment->comment_author : __('匿名访客', 'kratos');
|
||||
|
||||
$quotes = array(
|
||||
array(
|
||||
'label' => sprintf(__('你之前说的(%s)', 'kratos'), $parent->comment_author),
|
||||
'content' => yang_mail_text($parent->comment_content, 1200),
|
||||
'meta' => mysql2date('Y-m-d H:i', $parent->comment_date),
|
||||
),
|
||||
array(
|
||||
'label' => sprintf(__('%s 回复说', 'kratos'), $author),
|
||||
'content' => yang_mail_text($comment->comment_content, 1200),
|
||||
'meta' => mysql2date('Y-m-d H:i', $comment->comment_date),
|
||||
),
|
||||
);
|
||||
|
||||
$body = yang_mail_render(array(
|
||||
'title' => sprintf(__('%s · 新回复', 'kratos'), get_bloginfo('name')),
|
||||
'preheader' => sprintf(__('%s 在《%s》下回复了你的评论。', 'kratos'), $author, $title),
|
||||
'badge' => __('新回复', 'kratos'),
|
||||
'heading' => __('有人回复了你的评论', 'kratos'),
|
||||
'intro' => sprintf(__('你在《%s》下的评论收到了一条回复。', 'kratos'), $title),
|
||||
'quotes' => $quotes,
|
||||
'rows' => array(
|
||||
array('label' => __('文章', 'kratos'), 'value' => $title),
|
||||
array('label' => __('评论者', 'kratos'), 'value' => $author, 'strong' => true),
|
||||
array('label' => __('时间', 'kratos'), 'value' => mysql2date('Y-m-d H:i', $comment->comment_date)),
|
||||
),
|
||||
'cta_text' => __('查看完整讨论', 'kratos'),
|
||||
'cta_url' => $link,
|
||||
'note' => __('你收到这封邮件,是因为有人回复了你的评论。', 'kratos'),
|
||||
));
|
||||
|
||||
// 注意:这里**不做** yang_mail_escape_for_wpdiscuz() 的预编码。
|
||||
// 那个函数是为了抵消 wpDiscuz 自己多 decode 一次;现在没有插件了,
|
||||
// 预编码反而会让邮件正文里出现字面的 &lt; —— 是实打实的 bug。
|
||||
wp_mail(
|
||||
$to,
|
||||
sprintf(__('[%s] 有人回复了你的评论', 'kratos'), get_bloginfo('name')),
|
||||
$body
|
||||
);
|
||||
}
|
||||
add_action('comment_post', 'yang_notify_comment_reply', 20, 2);
|
||||
|
||||
/**
|
||||
* 评论通过审核时通知评论者
|
||||
*
|
||||
* 与上面同理:插件在就让它发,不在就主题发。
|
||||
* 这个通知很有用 —— 访客留了评论却因为审核没看到,
|
||||
* 不告诉他,他只会以为没提交成功。
|
||||
*/
|
||||
function yang_notify_comment_approved($comment)
|
||||
{
|
||||
if (!yang_mail_smtp_ready() || yang_comments_plugin_active()) {
|
||||
return;
|
||||
}
|
||||
if (!kratos_option('m_comment_approved_notify', true)) {
|
||||
return;
|
||||
}
|
||||
if (!($comment instanceof WP_Comment)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$to = trim((string) $comment->comment_author_email);
|
||||
if ($to === '' || !is_email($to)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$key = 'yang_cappr_' . (int) $comment->comment_ID;
|
||||
if (get_transient($key)) {
|
||||
return;
|
||||
}
|
||||
set_transient($key, 1, 300);
|
||||
|
||||
$post = get_post($comment->comment_post_ID);
|
||||
$title = $post ? get_the_title($post) : __('(文章已删除)', 'kratos');
|
||||
|
||||
$body = yang_mail_render(array(
|
||||
'title' => sprintf(__('%s · 评论已通过审核', 'kratos'), get_bloginfo('name')),
|
||||
'preheader' => sprintf(__('你在《%s》下的评论已通过审核并公开显示。', 'kratos'), $title),
|
||||
'badge' => __('已通过', 'kratos'),
|
||||
'heading' => __('你的评论已通过审核', 'kratos'),
|
||||
'intro' => sprintf(__('你在《%s》下提交的评论已经审核通过,现在其他读者可以看到它了。', 'kratos'), $title),
|
||||
'quotes' => array(
|
||||
array(
|
||||
'label' => __('你说过的', 'kratos'),
|
||||
'content' => yang_mail_text($comment->comment_content, 1200),
|
||||
'meta' => mysql2date('Y-m-d H:i', $comment->comment_date),
|
||||
),
|
||||
),
|
||||
'rows' => array(
|
||||
array('label' => __('文章', 'kratos'), 'value' => $title),
|
||||
),
|
||||
'cta_text' => __('去看看', 'kratos'),
|
||||
'cta_url' => get_comment_link($comment),
|
||||
'note' => __('这是评论审核结果通知,无需回复。', 'kratos'),
|
||||
));
|
||||
|
||||
wp_mail(
|
||||
$to,
|
||||
sprintf(__('[%s] 你的评论已通过审核', 'kratos'), get_bloginfo('name')),
|
||||
$body
|
||||
);
|
||||
}
|
||||
add_action('comment_unapproved_to_approved', 'yang_notify_comment_approved', 20, 1);
|
||||
|
||||
/**
|
||||
* 评论表单的字段顺序与占位符调整
|
||||
*
|
||||
* WordPress 默认顺序是 comment → author → email → url,
|
||||
* 而主题的视觉设计是「昵称/邮箱/网址在上,内容在下」。
|
||||
* 原主题用 recover_comment_fields() 把 comment 挪到最后,
|
||||
* 这里保留同样的顺序,但改成可预测的实现。
|
||||
*/
|
||||
function yang_comment_form_fields($fields)
|
||||
{
|
||||
if (!isset($fields['comment'])) {
|
||||
return $fields;
|
||||
}
|
||||
$comment = $fields['comment'];
|
||||
unset($fields['comment']);
|
||||
$fields['comment'] = $comment;
|
||||
return $fields;
|
||||
}
|
||||
add_filter('comment_form_fields', 'yang_comment_form_fields', 20);
|
||||
+62
-3
@@ -221,7 +221,7 @@ function yang_mail_short_circuit($return, $atts)
|
||||
add_filter('pre_wp_mail', 'yang_mail_short_circuit', 10, 2);
|
||||
|
||||
/**
|
||||
* 关掉核心的重复通知
|
||||
* 关掉核心的重复通知(**仅在 wpDiscuz 存在时**)
|
||||
*
|
||||
* ── 为什么要关 ──
|
||||
* 站点用了 wpDiscuz,评论通知由插件负责(它能带订阅、退订链接、
|
||||
@@ -237,12 +237,27 @@ add_filter('pre_wp_mail', 'yang_mail_short_circuit', 10, 2);
|
||||
* ── 只关通知,不关审核 ──
|
||||
* 注意这里只拦 `notify_*` 两个 filter,评论审核流程(comment_moderation)
|
||||
* 完全不受影响,评论该进待审还是进待审。
|
||||
*
|
||||
* ── 🔴 3.2.18:插件不在时必须放行 ─────────────────────────────
|
||||
* 这一条是卸载 wpDiscuz 时最容易出的事故:
|
||||
* 原来无条件返回 false,等于永久关掉核心的「有新评论通知作者」。
|
||||
* 插件在时无所谓(插件自己会发);插件一卸,就没有任何东西通知作者了。
|
||||
* 而主题自带的 yang_notify_comment_reply() 只处理**回复**场景
|
||||
* ($comment_parent > 0),顶层新评论仍然依赖核心的 notify_postauthor。
|
||||
* 两者叠加的结果是:作者从此收不到任何新评论邮件,且没有任何报错,
|
||||
* 只有"怎么一直没邮件"这种模糊现象。
|
||||
*
|
||||
* 所以这里的判据必须跟着插件走,而不是"只要开了 SMTP 就关"。
|
||||
*/
|
||||
function yang_mail_disable_core_notify($maybe, $comment_id)
|
||||
{
|
||||
if (!yang_mail_smtp_ready()) {
|
||||
return $maybe;
|
||||
}
|
||||
// 没有插件接管 → 核心通知是唯一的顶层评论通知渠道,必须放行
|
||||
if (!yang_mail_wpdiscuz_present()) {
|
||||
return $maybe;
|
||||
}
|
||||
// 用户在主题选项里可以保留核心通知(默认关)
|
||||
if (kratos_option('m_keep_core_notify', false)) {
|
||||
return $maybe;
|
||||
@@ -329,12 +344,35 @@ function yang_mail_send_test($to = null)
|
||||
* 解法:把 `&` 全部预编码成 `&`。这样插件解码一次之后,
|
||||
* 恰好还原成我们想要的转义状态(decode("&lt;") === "<")。
|
||||
* 只处理 `&` 就够,因为其它实体的风险都源于 `&` 被还原。
|
||||
*
|
||||
* ── 3.2.18:这个函数只在插件存在时才能用 ─────────────────────
|
||||
* 主题现在自带评论通知(inc/theme-comments.php),不再依赖插件。
|
||||
* 如果这里无条件做 `&` 预编码,而没有插件去 decode 那一次,
|
||||
* 收件人看到的邮件正文里就会出现字面的 `&lt;`、`&nbsp;`
|
||||
* —— 比安全风险更常见的实际故障,而且极难联想到原因。
|
||||
*
|
||||
* 所以调用点必须先判断插件在不在。见下方两个 add_filter 的包裹。
|
||||
*/
|
||||
function yang_mail_escape_for_wpdiscuz($html)
|
||||
{
|
||||
return str_replace('&', '&', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前是否有 wpDiscuz 在接管评论邮件
|
||||
*
|
||||
* 单独抽出来,是因为 theme-mail.php 加载早于 theme-comments.php
|
||||
* 时(子主题调整了 require 顺序)`yang_comments_plugin_active()`
|
||||
* 可能还不存在 —— 这时退回自己探测,不能让后台白屏。
|
||||
*/
|
||||
function yang_mail_wpdiscuz_present()
|
||||
{
|
||||
if (function_exists('yang_comments_plugin_active')) {
|
||||
return yang_comments_plugin_active();
|
||||
}
|
||||
return class_exists('WpdiscuzCore') || function_exists('wpdiscuz');
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 wpDiscuz 的通知邮件接管成主题模板
|
||||
*
|
||||
@@ -351,6 +389,17 @@ function yang_mail_escape_for_wpdiscuz($html)
|
||||
* 上面那两段引用,正文骨架完全一样。做成两套模板会立刻分叉,
|
||||
* 以后改一次配色要改两处。
|
||||
*
|
||||
* ── 3.2.18:这整段只在装了 wpDiscuz 时才注册 ─────────────────
|
||||
* 主题已经自带评论通知(inc/theme-comments.php)。若插件不存在,
|
||||
* 这两个 `wpdiscuz_*` 过滤器永远不会被触发 —— 注册了也是死代码,
|
||||
* 但它们会让人误以为"邮件通知依赖插件",日后维护时是个陷阱。
|
||||
* 更实际的问题:这些函数内部调用 yang_mail_escape_for_wpdiscuz()
|
||||
* 做 `&` 预编码,那是专门给插件多一次 decode 用的;没有插件时
|
||||
* 一旦被别处误调,邮件里就会出现字面的 &lt;。
|
||||
*
|
||||
* 所以整个注册包在 if 里。插件卸载后,通知自动由主题接管,
|
||||
* 用户不需要改任何配置。
|
||||
*
|
||||
* @param string $message 插件生成的 HTML(丢弃)
|
||||
* @param WP_Comment $comment 新评论
|
||||
* @param array $email_data 订阅记录
|
||||
@@ -418,7 +467,14 @@ function yang_mail_render_comment_notify($message, $comment, $email_data = array
|
||||
|
||||
return yang_mail_escape_for_wpdiscuz($body);
|
||||
}
|
||||
add_filter('wpdiscuz_email_content', 'yang_mail_render_comment_notify', 20, 3);
|
||||
|
||||
/* 只在插件确实存在时挂这两个过滤器。
|
||||
插件不在时(用户按 3.2.18 的建议卸载了),评论通知由
|
||||
inc/theme-comments.php 的 yang_notify_comment_reply() /
|
||||
yang_notify_comment_approved() 接管,发的是**不预编码**的正文。 */
|
||||
if (yang_mail_wpdiscuz_present()) {
|
||||
add_filter('wpdiscuz_email_content', 'yang_mail_render_comment_notify', 20, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核通过通知也套同一套模板
|
||||
@@ -449,4 +505,7 @@ function yang_mail_render_approved_notify($message, $post_id, $email)
|
||||
'note' => __('这是评论审核结果通知,无需回复。', 'kratos'),
|
||||
)));
|
||||
}
|
||||
add_filter('wpdiscuz_comment_approved_email_content', 'yang_mail_render_approved_notify', 20, 3);
|
||||
|
||||
if (yang_mail_wpdiscuz_present()) {
|
||||
add_filter('wpdiscuz_comment_approved_email_content', 'yang_mail_render_approved_notify', 20, 3);
|
||||
}
|
||||
|
||||
+177
-18
@@ -31,6 +31,15 @@
|
||||
* 当成"每年重复",而是保留为可自定义的具体日期,避免明年 4 月 4 日
|
||||
* 又自动变黑白(那是错的)。
|
||||
*
|
||||
* D. 节气(日期随年份浮动,必须逐年换算)
|
||||
* · 清明 太阳视黄经达 15° 的时刻所在日
|
||||
* 清明不是"每年 4 月 4 日"。它在 4 月 4 日、5 日、6 日之间浮动:
|
||||
* 1900~2100 这 201 年里,落在 4 日的只有 76 年(约 38%),
|
||||
* 落在 5 日的有 112 年,落在 6 日的有 13 年(都集中在 1939 年以前)。
|
||||
* 所以**不能**把它当固定 MM-DD 塞进清单里 —— 那样有六成年份是错的,
|
||||
* 而且错得毫无提示,比不给这个功能更糟。
|
||||
* 详见 yang_qingming_day()。
|
||||
*
|
||||
* ── 设计取舍 ──────────────────────────────────────────────────
|
||||
* 1. **按站点时区判断**,不按服务器时区。用 current_time('md'),
|
||||
* 它走 WordPress 设置的时区。否则服务器在 UTC,北京时间 9 月 18 日
|
||||
@@ -50,12 +59,84 @@ if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清明节在指定年份的月日(`MM-DD`),换算不出来返回 null
|
||||
*
|
||||
* ── 为什么是查表,不是现场算 ──────────────────────────────────
|
||||
* 清明的天文学定义是「太阳视黄经达到 15° 的那一刻」。理论上可以现场用
|
||||
* Meeus 的太阳位置公式解方程,但在这个场景下不该这么做:
|
||||
*
|
||||
* 1. **跨平台浮点差异会翻日期**。清明时刻可能离午夜只有几十秒
|
||||
* (例如 1943 年落在 23:58、2071 年落在 00:02),此时 PHP 的
|
||||
* `sin()/cos()` 在 x86 与 ARM 上的 ULP 级差异足以让二分退化到
|
||||
* 隔壁一天。日期错一天的代价是「整站黑白错日子」,很难被发现。
|
||||
* 2. **可验证性**。查表的内容是 201 个字符,可以逐条与天文年历核对;
|
||||
* 现场算的公式在主题这种被到处复制分发的代码里几乎不可能被验证。
|
||||
* 3. **成本**。201 个数字就是几十字节,比几十行公式便宜得多。
|
||||
*
|
||||
* 表里的值由 ephem 星历计算、并用两套独立实现交叉验证过:
|
||||
* · ephem(VSOP87 截断)+ 二分求黄经 15°;
|
||||
* · Meeus《Astronomical Algorithms》第 25 章公式独立实现;
|
||||
* · cnlunar 民间历法数据表。
|
||||
* 三者在 2000~2050 完全一致;ephem 与 Meeus 在 1900~2100 的 201 年
|
||||
* 只有 1943 年不同,而该年清明时刻为 23:58(差距在秒级),属精度边界。
|
||||
* 与气象/天文年历公布的 2020~2027 时刻比对,日期 8/8 全对,
|
||||
* 时刻差 4~16 分钟(纯属低精度星历的太阳黄经误差,不跨日)。
|
||||
*
|
||||
* ── 为什么超出范围返回 null 而不是猜一个 ─────────────────────
|
||||
* 用户的要求是「是哪一天就是哪一天,要么就不要这个日期」。
|
||||
* 2101 年以后的清明我算不准,与其给个可能错的日子,不如**不出现**:
|
||||
* 那天站点保持彩色,是明确的无害行为;黑白错日子才是真事故。
|
||||
* 表覆盖到 2100 年,届时主题早该升级了。
|
||||
*
|
||||
* @param int|null $year 不传则用站点时区的当前年份
|
||||
* @return string|null 如 '04-04';无法换算时 null
|
||||
*/
|
||||
function yang_qingming_md($year = null)
|
||||
{
|
||||
/* 1900~2100 每年清明落在 4 月的哪一天,按年份顺序拼成的字符串。
|
||||
每个字符对应一年:'4' = 4 月 4 日,'5' = 5 日,'6' = 6 日。
|
||||
例:第 0 位是 1900 年 → '5' → 1900-04-05。 */
|
||||
static $seq = '556655665566555655565556555655565556555655555555555555555555'
|
||||
. '555555555555555545554555455545554555455545554555445544554455'
|
||||
. '445544554455445544554445444544454445444544454445444544444444'
|
||||
. '444444444444444444445';
|
||||
|
||||
if ($year === null) {
|
||||
$year = (int) current_time('Y');
|
||||
}
|
||||
$year = (int) $year;
|
||||
|
||||
// 表只覆盖 1900~2100,范围外明确放弃(见上面注释)
|
||||
if ($year < 1900 || $year > 2100) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$idx = $year - 1900;
|
||||
if (!isset($seq[$idx])) {
|
||||
return null;
|
||||
}
|
||||
$day = (int) $seq[$idx];
|
||||
if ($day < 1 || $day > 31) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 清明永远在 4 月,所以月份写死不会错(表里也只存了「日」)
|
||||
return sprintf('04-%02d', $day);
|
||||
}
|
||||
|
||||
/**
|
||||
* 内置纪念日清单
|
||||
*
|
||||
* 返回 array( 键 => array(月份, 日, 名称, 说明, 分类, 是否默认勾选) )
|
||||
* 返回 array( 键 => array(名称, 说明, 分类, 是否默认勾选 [, 动态换算方式]) )
|
||||
*
|
||||
* 键名用 `m-d` 形式(补零),与 current_time('m') / current_time('d') 直接可比。
|
||||
* 键名通常是 `m-d` 形式(补零),与 current_time('m') / current_time('d')
|
||||
* 直接可比。
|
||||
*
|
||||
* **例外:清明**。它的日期随年份浮动(4 月 4/5/6 日),不能用一个固定
|
||||
* 的 `m-d` 当键 —— 否则等于给自己钉死一个错误的近似值。所以它的键是
|
||||
* `qingming`,并带 `dynamic => qingming` 标记,由 yang_mourning_days()
|
||||
* 在运行时按「哪一年」换算成真正的 `MM-DD`。
|
||||
*/
|
||||
function yang_mourning_presets()
|
||||
{
|
||||
@@ -111,50 +192,106 @@ function yang_mourning_presets()
|
||||
'group' => 'custom',
|
||||
'default' => 0,
|
||||
),
|
||||
'04-04' => array(
|
||||
|
||||
// ── D 类:节气,日期逐年换算 ──
|
||||
'qingming' => array(
|
||||
'name' => __('清明节', 'kratos'),
|
||||
'note' => __('节气日期每年浮动,这里固定为 4 月 4 日,仅作近似', 'kratos'),
|
||||
'note' => __('节气,日期每年浮动(4 月 4~6 日之间),已按天文历逐年换算,不是固定 4 月 4 日', 'kratos'),
|
||||
'group' => 'custom',
|
||||
'default' => 0,
|
||||
// 让后台清单里不要显示出 "qingming" 这个内部键
|
||||
'key_label' => __('逐年', 'kratos'),
|
||||
// 标记:这个日期的 MM-DD 要按年份现算
|
||||
'dynamic' => 'qingming',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取用户勾选的纪念日
|
||||
* 把某个日期键解析成「指定年份的真实 MM-DD」
|
||||
*
|
||||
* 静态日期(09-03 之类)原样返回;动态日期(清明)按年份换算。
|
||||
* 换算不出来时返回 null —— 调用方必须把 null 当作「这天不存在」,
|
||||
* **不要**退化成某个固定值。
|
||||
*
|
||||
* @param string $key presets 的键
|
||||
* @param array $info 该项的 presets 定义
|
||||
* @param int|null $year 年份,null 用站点时区当前年
|
||||
* @return string|null
|
||||
*/
|
||||
function yang_mourning_resolve_key($key, $info, $year = null)
|
||||
{
|
||||
if (isset($info['dynamic']) && $info['dynamic'] === 'qingming') {
|
||||
return yang_qingming_md($year);
|
||||
}
|
||||
// 静态日期:必须是合法 MM-DD 才放行,防止脏数据
|
||||
if (preg_match('/^\d{2}-\d{2}$/', $key)) {
|
||||
return $key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取用户勾选的纪念日,返回**该年份真实生效的 `MM-DD` 列表**
|
||||
*
|
||||
* 老站点升级上来时这个选项还不存在,此时返回 presets 里 default=1 的那些。
|
||||
* 注意**不能**简单用 `kratos_option('g_rip_days', 默认数组)`:kratos_option
|
||||
* 对未设置的键返回默认值,但如果用户曾经保存过空数组(全部取消勾选),
|
||||
* 存进去的就是空数组,此时必须尊重「用户就是要关掉」,不能再回退到默认。
|
||||
* 所以这里用「option 数组里有没有这个键」来判断,而不是判断值是否为空。
|
||||
*
|
||||
* ── 返回值是 MM-DD,不是勾选的键 ──────────────────────────────
|
||||
* 因为清明的键是 `qingming`、真实日期是 `04-04`/`04-05`,两者不同。
|
||||
* 统一解析成 MM-DD 之后,上游的 in_array($today, $days) 一行都不用改。
|
||||
*
|
||||
* ── 兼容旧的 '04-04' 勾选 ─────────────────────────────────────
|
||||
* 3.2.17 及以前的 presets 里清明就是 `04-04`,勾过它的站点库里存着
|
||||
* `04-04 => 1`。如果直接改键名,这些站点的勾选会被静默丢弃 ——
|
||||
* 用户明明选了清明,升级后反而没了。所以把 `04-04` 视为 `qingming`。
|
||||
*
|
||||
* @param int|null $year 解析哪一年,null 用站点时区当前年
|
||||
*/
|
||||
function yang_mourning_days()
|
||||
function yang_mourning_days($year = null)
|
||||
{
|
||||
$presets = yang_mourning_presets();
|
||||
$opts = get_option('kratos');
|
||||
$has = is_array($opts) && array_key_exists('g_rip_days', $opts);
|
||||
|
||||
// 勾选状态:键 => bool
|
||||
$picked = array();
|
||||
if (!$has) {
|
||||
// 从未保存过:用内置默认
|
||||
$out = array();
|
||||
foreach ($presets as $md => $info) {
|
||||
foreach ($presets as $key => $info) {
|
||||
if (!empty($info['default'])) {
|
||||
$out[] = $md;
|
||||
$picked[$key] = true;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
} else {
|
||||
$saved = $opts['g_rip_days'];
|
||||
if (!is_array($saved)) {
|
||||
return array();
|
||||
}
|
||||
foreach ($saved as $key => $v) {
|
||||
if (!$v) {
|
||||
continue;
|
||||
}
|
||||
// 旧键迁移:3.2.17 及以前的清明
|
||||
if ($key === '04-04' && isset($presets['qingming'])) {
|
||||
$picked['qingming'] = true;
|
||||
continue;
|
||||
}
|
||||
// 只保留 presets 里存在的键(防止脏数据)
|
||||
if (isset($presets[$key])) {
|
||||
$picked[$key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只保留在 presets 里存在、且值为真的日期(防止脏数据)
|
||||
// 解析成真实 MM-DD
|
||||
$out = array();
|
||||
foreach ($saved as $md => $v) {
|
||||
if ($v && isset($presets[$md])) {
|
||||
foreach ($picked as $key => $_) {
|
||||
$md = yang_mourning_resolve_key($key, $presets[$key], $year);
|
||||
if ($md !== null && !in_array($md, $out, true)) {
|
||||
$out[] = $md;
|
||||
}
|
||||
}
|
||||
@@ -293,12 +430,27 @@ function yang_mourning_status_text()
|
||||
sort($days);
|
||||
$today = current_time('m') . '-' . current_time('d');
|
||||
$is = in_array($today, $days, true);
|
||||
|
||||
/* 列表里给清明加个标注:它在库里是 `qingming` 这个键,
|
||||
但真正生效的日期由当年换算得出,光看 `04-05` 用户不知道
|
||||
那是清明还是他自己手填的。 */
|
||||
$sorted = $days;
|
||||
$qmd = yang_qingming_md();
|
||||
if ($qmd !== null) {
|
||||
foreach ($sorted as $i => $one) {
|
||||
if ($one === $qmd) {
|
||||
$sorted[$i] = sprintf(__('%s(清明)', 'kratos'), $one);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
/* translators: 1: 今天的月日, 2: 是否处于哀悼, 3: 生效的日期列表 */
|
||||
__('今天是 %1$s,%2$s。当前会变黑白的日期:%3$s', 'kratos'),
|
||||
$today,
|
||||
$is ? __('正在哀悼,站点已黑白', 'kratos') : __('不在哀悼日', 'kratos'),
|
||||
implode('、', $days)
|
||||
implode('、', $sorted)
|
||||
);
|
||||
}
|
||||
/**
|
||||
@@ -497,9 +649,16 @@ function yang_mourning_today_name()
|
||||
|
||||
$md = current_time('m') . '-' . current_time('d');
|
||||
$presets = yang_mourning_presets();
|
||||
$days = yang_mourning_days();
|
||||
|
||||
if (isset($presets[$md]) && in_array($md, yang_mourning_days(), true)) {
|
||||
return $presets[$md]['name'];
|
||||
// 按「解析后的真实 MM-DD」匹配。不能直接 isset($presets[$md]):
|
||||
// 清明的键是 'qingming' 而不是 '04-04',那样会漏掉它。
|
||||
if (in_array($md, $days, true)) {
|
||||
foreach ($presets as $key => $info) {
|
||||
if (yang_mourning_resolve_key($key, $info) === $md) {
|
||||
return $info['name'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 命中的是「附加日期」里用户自己填的那一天
|
||||
|
||||
+39
-7
@@ -94,6 +94,30 @@ if (!function_exists('yang_opt_mourning_extra_raw')) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 今年清明的月日,给后台说明文字用(如「4 月 5 日」)
|
||||
*
|
||||
* 换算不出来(超出年份表范围)时返回一句明确的说明,
|
||||
* **不要**退化成「4 月 4 日」—— 那正是用户要求删掉的近似值。
|
||||
*/
|
||||
if (!function_exists('yang_opt_qingming_label')) {
|
||||
function yang_opt_qingming_label()
|
||||
{
|
||||
if (!function_exists('yang_qingming_md')) {
|
||||
return __('换算不可用', 'kratos');
|
||||
}
|
||||
$md = yang_qingming_md();
|
||||
if ($md === null) {
|
||||
return sprintf(
|
||||
__('今年(%s 年)超出可换算范围,清明不会自动生效', 'kratos'),
|
||||
current_time('Y')
|
||||
);
|
||||
}
|
||||
list($mo, $da) = explode('-', $md);
|
||||
return sprintf(__('%d 月 %d 日', 'kratos'), (int) $mo, (int) $da);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义 CSS 的默认值(来自 inc/theme-custom-code.php)
|
||||
*
|
||||
@@ -467,26 +491,34 @@ function kratos_options()
|
||||
// 把内置清单转成 multicheck 需要的格式
|
||||
$rip_legal = array();
|
||||
$rip_custom = array();
|
||||
foreach (yang_opt_mourning_presets() as $md => $info) {
|
||||
foreach (yang_opt_mourning_presets() as $key => $info) {
|
||||
/* 键有两种:静态日期(`09-03`)和动态日期(`qingming`)。
|
||||
动态日期的键是内部标识,不能直接摆在用户面前 ——
|
||||
所以看 key_label,没有就回退到键本身(静态日期就是 MM-DD,
|
||||
显示出来正好是用户认得的日期)。 */
|
||||
$label = isset($info['key_label']) ? $info['key_label'] : $key;
|
||||
$entry = array(
|
||||
'name' => $md . ' ' . $info['name'],
|
||||
'name' => $label . ' ' . $info['name'],
|
||||
'note' => $info['note'],
|
||||
);
|
||||
if (isset($info['group']) && $info['group'] === 'legal') {
|
||||
$rip_legal[$md] = $entry;
|
||||
$rip_legal[$key] = $entry;
|
||||
} else {
|
||||
$rip_custom[$md] = $entry;
|
||||
$rip_custom[$key] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
$rip_default = array();
|
||||
foreach (yang_opt_mourning_presets() as $md => $info) {
|
||||
$rip_default[$md] = !empty($info['default']) ? 1 : 0;
|
||||
foreach (yang_opt_mourning_presets() as $key => $info) {
|
||||
$rip_default[$key] = !empty($info['default']) ? 1 : 0;
|
||||
}
|
||||
|
||||
$options[] = array(
|
||||
'name' => __('纪念日清单', 'kratos'),
|
||||
'desc' => __('勾选需要自动黑白的日期。<b>默认一个都不勾</b>,请按自己的意愿挑选。前三个是法定纪念日,后面几个是民间通行做法。日期按站点时区判断,只比对月和日,每年循环。', 'kratos'),
|
||||
'desc' => sprintf(
|
||||
__('勾选需要自动黑白的日期。<b>默认一个都不勾</b>,请按自己的意愿挑选。前三个是法定纪念日,后面几个是民间通行做法。日期按站点时区判断,只比对月和日,每年循环。<br><b>清明节</b>是节气,日期每年浮动(4 月 4~6 日之间),已脱离固定日期、按天文历逐年换算 —— 今年是 <b>%s</b>。', 'kratos'),
|
||||
esc_html(yang_opt_qingming_label())
|
||||
),
|
||||
'id' => 'g_rip_days',
|
||||
'std' => $rip_default,
|
||||
'type' => 'multicheck',
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
<?php
|
||||
/**
|
||||
* 文章目录(TOC)
|
||||
*
|
||||
* ── 用法 ──────────────────────────────────────────────────────
|
||||
* 在文章内容的任意位置写一行:
|
||||
*
|
||||
* [TOC]
|
||||
*
|
||||
* 这个位置就会出现本文的目录,点条目跳到对应标题。
|
||||
*
|
||||
* 可调参数(都可省略):
|
||||
*
|
||||
* [TOC title="本文目录" levels="1,2,3" numbering="1" toggle="0"]
|
||||
*
|
||||
* · title 目录标题,默认「目录」
|
||||
* · levels 收录哪几级标题,逗号分隔,默认 "1,2,3"
|
||||
* · numbering 1 = 条目前加 1 / 1.1 这样的序号,默认 0
|
||||
* · toggle 1 = 标题可点击折叠,默认 1
|
||||
*
|
||||
* ── 为什么默认收 h1(曾经错设成 "2,3")──────────────────────
|
||||
* 原先的理由是「h1 是文章标题本身,不该进目录」。这个理由是**假的**:
|
||||
* 文章标题输出在 single.php 的 <h1 class="title">,在 the_content
|
||||
* **之外**,根本进不到这里被扫描。能被扫到的 h1 只可能是作者在正文里
|
||||
* 自己写的一级章节标题。
|
||||
*
|
||||
* 一旦正文用 h1 划分章节(很常见:从 Markdown 或别的编辑器粘过来,
|
||||
* 或原本的 ## 被降级),默认 "2,3" 会把这些一级章节整段丢掉,
|
||||
* 于是二级标题集体失去父级、目录变成平铺 —— 层级信息全错。
|
||||
* 所以默认跟 h2/h3 一起收 h1,最多到 h3(h4 以下通常是细节,不进目录)。
|
||||
*
|
||||
* ── 为什么在 the_content 优先级 9 处理,而不是只靠短代码 ──────
|
||||
* 目录能点,前提是「标题有 id」且「目录链接指向那个 id」。这两件事
|
||||
* 必须用**同一份扫描结果**,否则一旦某个标题文本里有特殊字符、
|
||||
* 或者有重名标题,两边各自算一遍就会算出不同的 id,点进去跳不动。
|
||||
*
|
||||
* 而短代码(do_shortcode)挂在 the_content 的优先级 11,
|
||||
* wpautop 挂在 10 —— 等短代码执行时,内容已经被换行转成了 <p>,
|
||||
* 再去插 id 就和扫描时的文本不是同一份了。
|
||||
*
|
||||
* 所以在优先级 9(早于 wpautop 和 do_shortcode)一次做完:
|
||||
* ① 给每个 h1~h6 补 id(已有 id 的沿用,不动别人的锚点)
|
||||
* ② 把 [TOC] 直接替换成目录 HTML
|
||||
*
|
||||
* 放在 <div> 里是刻意的:wpautop 会把「块级元素」两边的 <p> 剥掉,
|
||||
* 所以不会出现 <p><div>…</div></p> 这种非法嵌套。
|
||||
*
|
||||
* 同时仍然注册了短代码(见文件末尾):万一有人在 the_content 之外
|
||||
* 调用 [TOC](小工具、页面构建器),短代码那条路也能用,且复用
|
||||
* 同一个渲染函数,不会出现两套行为。
|
||||
*
|
||||
* @author Yongquan Yang <yangyq.net>
|
||||
* @license MIT License
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把标题文本变成锚点 id
|
||||
*
|
||||
* ── 为什么保留中文 ────────────────────────────────────────────
|
||||
* 转拼音需要词典,主题里塞不进去;转成纯数字(toc-1、toc-2)则让
|
||||
* 链接失去可读性,分享出去 `#section-7` 谁也看不出是哪一节。
|
||||
* 中文本身在 HTML5 的 id 里是合法的,浏览器也认 —— 所以保留,
|
||||
* 只把「会破坏 id 语义或选择器」的字符换掉。
|
||||
*
|
||||
* ── 去重 ──────────────────────────────────────────────────────
|
||||
* 同一篇文章里出现两个「小结」是常事。第二个必须变成「小结-2」,
|
||||
* 否则两个标题同 id,点目录永远只跳到第一个。
|
||||
*
|
||||
* @param string $text 标题的 HTML(可能含 <strong> 之类)
|
||||
* @param array $used 已用过的 id(按引用传,跨标题累积)
|
||||
* @return string
|
||||
*/
|
||||
function yang_toc_make_id($text, &$used)
|
||||
{
|
||||
$t = wp_strip_all_tags($text);
|
||||
$t = html_entity_decode($t, ENT_QUOTES, 'UTF-8');
|
||||
// 连续空白压成一个,避免 "a b" 变成 "a---b"
|
||||
$t = trim(preg_replace('/\s+/u', ' ', $t));
|
||||
|
||||
/* 只保留:汉字、拉丁字母、数字、下划线、连字符。
|
||||
其余(空格、各类标点、emoji)一律替换成连字符。
|
||||
注意必须带 /u,否则按字节处理会把中文拆坏。 */
|
||||
$t = preg_replace('/[^\p{Han}\p{Latin}\p{N}_-]+/u', '-', $t);
|
||||
$t = trim((string) $t, '-');
|
||||
|
||||
// 标题是纯符号/纯 emoji 时兜个底,不能产出空 id
|
||||
if ($t === '') {
|
||||
$t = 'section';
|
||||
}
|
||||
|
||||
$base = $t;
|
||||
$i = 1;
|
||||
while (isset($used[$t])) {
|
||||
$i++;
|
||||
$t = $base . '-' . $i;
|
||||
}
|
||||
$used[$t] = true;
|
||||
|
||||
return $t;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描内容:给标题补 id,并收集目录条目
|
||||
*
|
||||
* @param string $content 原始内容(未过 wpautop)
|
||||
* @return array array('content' => 改好的内容, 'items' => 条目数组)
|
||||
* 条目:array('level' => 2, 'text' => '标题', 'id' => '锚点')
|
||||
*/
|
||||
function yang_toc_scan($content)
|
||||
{
|
||||
$items = array();
|
||||
$used = array();
|
||||
|
||||
if (!is_string($content) || $content === '') {
|
||||
return array('content' => (string) $content, 'items' => $items);
|
||||
}
|
||||
|
||||
/* 标题可能带属性(class/style/已有 id),也可能跨行。<h([1-6]) 分组,
|
||||
反向引用 \1 保证开闭标签配对(不会把 <h2>…</h3> 当成一对)。 */
|
||||
$out = preg_replace_callback(
|
||||
'#<h([1-6])([^>]*)>(.*?)</h\1>#is',
|
||||
function ($m) use (&$items, &$used) {
|
||||
$level = (int) $m[1];
|
||||
$attrs = $m[2];
|
||||
$inner = $m[3];
|
||||
|
||||
// 已经有 id 的标题:沿用。绝不覆盖 —— 那会打断别人已有的
|
||||
// 锚点链接(包括从旧插件迁移过来的、以及手写的 <a href="#x">)。
|
||||
if (preg_match('/\bid\s*=\s*(["\'])(.*?)\1/is', $attrs, $im)) {
|
||||
$id = $im[2];
|
||||
// 记进 used,避免后面的同名标题又生成同一个 id
|
||||
$used[$id] = true;
|
||||
} else {
|
||||
$id = yang_toc_make_id($inner, $used);
|
||||
$attrs .= ' id="' . esc_attr($id) . '"';
|
||||
}
|
||||
|
||||
$items[] = array(
|
||||
'level' => $level,
|
||||
'text' => $inner,
|
||||
'id' => $id,
|
||||
);
|
||||
|
||||
return '<h' . $level . $attrs . '>' . $inner . '</h' . $level . '>';
|
||||
},
|
||||
$content
|
||||
);
|
||||
|
||||
// preg_replace_callback 出错会返回 null,退回原文更安全
|
||||
if (!is_string($out)) {
|
||||
$out = $content;
|
||||
$items = array();
|
||||
}
|
||||
|
||||
return array('content' => $out, 'items' => $items);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 [TOC] 的属性
|
||||
*
|
||||
* 只认 key="v" / key='v' / key=v 三种写法。不认识的键静默忽略,
|
||||
* 打错的属性不该让整篇文章报错或渲染不出目录。
|
||||
*
|
||||
* @param string $raw 方括号里标签名之后的原文
|
||||
* @return array
|
||||
*/
|
||||
function yang_toc_parse_atts($raw)
|
||||
{
|
||||
$atts = array();
|
||||
if (!is_string($raw) || trim($raw) === '') {
|
||||
return $atts;
|
||||
}
|
||||
if (preg_match_all('/([a-zA-Z_][a-zA-Z0-9_-]*)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\']+))/', $raw, $mm, PREG_SET_ORDER)) {
|
||||
foreach ($mm as $one) {
|
||||
$k = strtolower($one[1]);
|
||||
$v = '';
|
||||
if (isset($one[2]) && $one[2] !== '') {
|
||||
$v = $one[2];
|
||||
} elseif (isset($one[3]) && $one[3] !== '') {
|
||||
$v = $one[3];
|
||||
} elseif (isset($one[4])) {
|
||||
$v = $one[4];
|
||||
}
|
||||
$atts[$k] = $v;
|
||||
}
|
||||
}
|
||||
return $atts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把条目数组渲染成目录 HTML
|
||||
*
|
||||
* @param array $items 条目(含 level/text/id)
|
||||
* @param array $atts 解析后的属性
|
||||
* @return string 空串表示不该输出(没有可用标题)
|
||||
*/
|
||||
function yang_toc_render($items, $atts = array())
|
||||
{
|
||||
if (empty($items)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// ── 参数归一 ──
|
||||
$title = isset($atts['title']) && trim($atts['title']) !== ''
|
||||
? trim($atts['title'])
|
||||
: __('目录', 'kratos');
|
||||
|
||||
// levels:默认 1,2,3。文章标题本身在 the_content 之外,不会被扫到,
|
||||
// 所以正文里的 h1 是作者写的一级章节标题,必须收 —— 详见文件头注释。
|
||||
$levels = array();
|
||||
$raw_levels = isset($atts['levels']) ? (string) $atts['levels'] : '1,2,3';
|
||||
foreach (preg_split('/[\s,,、]+/u', $raw_levels) as $one) {
|
||||
if ($one === '') {
|
||||
continue;
|
||||
}
|
||||
$n = (int) $one;
|
||||
if ($n >= 1 && $n <= 6) {
|
||||
$levels[$n] = true;
|
||||
}
|
||||
}
|
||||
if (empty($levels)) {
|
||||
$levels = array(1 => true, 2 => true, 3 => true);
|
||||
}
|
||||
|
||||
$numbering = isset($atts['numbering']) && (string) $atts['numbering'] === '1';
|
||||
$toggle = !isset($atts['toggle']) || (string) $atts['toggle'] === '1';
|
||||
|
||||
// ── 过滤出要收录的标题 ──
|
||||
$picked = array();
|
||||
foreach ($items as $it) {
|
||||
if (isset($levels[$it['level']])) {
|
||||
$picked[] = $it;
|
||||
}
|
||||
}
|
||||
if (empty($picked)) {
|
||||
// 文章里一个符合条件的标题都没有 —— 用户明确要求「没有就不显示」
|
||||
return '';
|
||||
}
|
||||
|
||||
// ── 序号(1 / 1.1 / 1.1.1)──
|
||||
$nums = array();
|
||||
if ($numbering) {
|
||||
$counter = array();
|
||||
foreach ($picked as $i => $it) {
|
||||
$lv = $it['level'];
|
||||
$counter[$lv] = isset($counter[$lv]) ? $counter[$lv] + 1 : 1;
|
||||
// 更深的层级清零,回到上层后重新从 1 开始
|
||||
foreach (array_keys($counter) as $k) {
|
||||
if ($k > $lv) {
|
||||
unset($counter[$k]);
|
||||
}
|
||||
}
|
||||
$parts = array();
|
||||
$base_found = false;
|
||||
foreach ($counter as $k => $v) {
|
||||
$parts[] = $v;
|
||||
}
|
||||
$nums[$i] = implode('.', $parts);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 生成嵌套结构 ──
|
||||
/* 这里不用「绝对层级」直接建嵌套,而是先把每个条目归一成**深度**。
|
||||
原因:标题层级会有跳级和回退(h2 → h4 → h3),按绝对 level 建栈,
|
||||
回退时到底该关几次 </li> 极难算对 —— 第一版就是这么错的,
|
||||
「回退 h4→h3」那一例 ul/li 标签不配平,被单元测试抓出来。
|
||||
|
||||
归一化规则(与直觉一致):
|
||||
· h2 → h4 → h3 时,h4 和 h3 都算 h2 的下一层(深度相同),
|
||||
因为它们之间不存在 level 3 的父项,都是 h2 的子项;
|
||||
· h2 → h3 → h4 → h2 时,深度依次 0 / 1 / 2 / 0。
|
||||
|
||||
得到深度数组之后,建 HTML 就只是「深度变大开一层、变小收几层」,
|
||||
关闭次数由深度差直接决定,不需要再猜。 */
|
||||
$depths = array();
|
||||
$lstack = array(); // 用过的 level,从外到内
|
||||
foreach ($picked as $it) {
|
||||
$lv = $it['level'];
|
||||
if (empty($lstack)) {
|
||||
$lstack[] = $lv;
|
||||
} elseif ($lv > end($lstack)) {
|
||||
// 更深一级:直接入栈
|
||||
$lstack[] = $lv;
|
||||
} else {
|
||||
// 同级或回退:先弹到「不比它深」为止
|
||||
while (count($lstack) > 1 && $lv <= end($lstack)) {
|
||||
array_pop($lstack);
|
||||
}
|
||||
// 弹完之后若仍然更浅,说明是跳级回退(如 h4 → h3),
|
||||
// 把这个 level 当作新的一层压进去,深度不变
|
||||
if ($lv > end($lstack)) {
|
||||
$lstack[] = $lv;
|
||||
}
|
||||
}
|
||||
$depths[] = count($lstack) - 1;
|
||||
}
|
||||
|
||||
$o = array();
|
||||
$prev_depth = -1;
|
||||
$max_depth = 0;
|
||||
|
||||
foreach ($picked as $i => $it) {
|
||||
$lv = $it['level'];
|
||||
$d = $depths[$i];
|
||||
|
||||
if ($prev_depth < 0) {
|
||||
// 第一个条目
|
||||
$o[] = '<ul class="yang-toc-list">';
|
||||
} elseif ($d > $prev_depth) {
|
||||
// 进到更深一层:上一个 <li> 还没关,正好作为父节点
|
||||
$o[] = '<ul class="yang-toc-list">';
|
||||
} else {
|
||||
// 同级或回退:先关上一条
|
||||
$o[] = '</li>';
|
||||
// 深度差 = 要收掉的层数,每收一层补一个 </li>(关父条目)
|
||||
for ($k = $prev_depth; $k > $d; $k--) {
|
||||
$o[] = '</ul></li>';
|
||||
}
|
||||
}
|
||||
|
||||
$num = '';
|
||||
if ($numbering && isset($nums[$i])) {
|
||||
$num = '<span class="yang-toc-num">' . esc_html($nums[$i]) . '</span>';
|
||||
}
|
||||
|
||||
/* href 用原始 id;id 里允许中文,浏览器会自己编码。
|
||||
不要在这里 rawurlencode —— 那会和 id 对不上(id 是解码后的)。 */
|
||||
$o[] = '<li class="yang-toc-item yang-toc-lv' . $lv . '">'
|
||||
. '<a class="yang-toc-link" href="#' . esc_attr($it['id']) . '">'
|
||||
. $num
|
||||
. '<span class="yang-toc-text">' . wp_strip_all_tags($it['text']) . '</span>'
|
||||
. '</a>';
|
||||
|
||||
$prev_depth = $d;
|
||||
if ($d > $max_depth) {
|
||||
$max_depth = $d;
|
||||
}
|
||||
}
|
||||
|
||||
// 收尾:关最后一条 + 逐层收掉所有 ul
|
||||
if (!empty($picked)) {
|
||||
$o[] = '</li>';
|
||||
for ($k = $prev_depth; $k > 0; $k--) {
|
||||
$o[] = '</ul></li>';
|
||||
}
|
||||
$o[] = '</ul>';
|
||||
}
|
||||
|
||||
$list = implode('', $o);
|
||||
|
||||
/* 折叠用 <details>:原生元素,不需要 JS 就能工作。
|
||||
JS 被禁用 / 加载失败时,目录仍然可以展开收起 ——
|
||||
比用自定义 div + class 切换更抗故障。 */
|
||||
if ($toggle) {
|
||||
$html = '<details class="yang-toc" open>'
|
||||
. '<summary class="yang-toc-head">'
|
||||
. '<span class="yang-toc-title">' . esc_html($title) . '</span>'
|
||||
. '<span class="yang-toc-count">' . count($picked) . '</span>'
|
||||
. '</summary>'
|
||||
. '<nav class="yang-toc-body" aria-label="' . esc_attr($title) . '">'
|
||||
. $list
|
||||
. '</nav>'
|
||||
. '</details>';
|
||||
} else {
|
||||
$html = '<div class="yang-toc">'
|
||||
. '<div class="yang-toc-head">'
|
||||
. '<span class="yang-toc-title">' . esc_html($title) . '</span>'
|
||||
. '<span class="yang-toc-count">' . count($picked) . '</span>'
|
||||
. '</div>'
|
||||
. '<nav class="yang-toc-body" aria-label="' . esc_attr($title) . '">'
|
||||
. $list
|
||||
. '</nav>'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* the_content 主处理
|
||||
*
|
||||
* 顺序很重要:先给标题补 id(扫描出条目),再替换 [TOC]。
|
||||
* 反过来的话,[TOC] 生成时拿不到标题清单。
|
||||
*/
|
||||
function yang_toc_filter_content($content)
|
||||
{
|
||||
if (is_admin() || !is_string($content) || $content === '') {
|
||||
return $content;
|
||||
}
|
||||
if (stripos($content, '[toc') === false) {
|
||||
// 没有 [TOC]:什么都不做。
|
||||
// 不动标题 id 是刻意的 —— 不写 [TOC] 的文章不该被塞进一堆 id,
|
||||
// 那会改变页面上已有的选择器和锚点行为。
|
||||
return $content;
|
||||
}
|
||||
|
||||
$scan = yang_toc_scan($content);
|
||||
$items = $scan['items'];
|
||||
|
||||
/* 替换 [TOC] / [toc] / [TOC ...]。大小写都认 ——
|
||||
用户不会记得"这里的标签必须大写",凭直觉写 [toc] 就该能用。 */
|
||||
$out = preg_replace_callback(
|
||||
'/\[toc(\s[^\]]*)?\]/i',
|
||||
function ($m) use ($items) {
|
||||
$atts = yang_toc_parse_atts(isset($m[1]) ? $m[1] : '');
|
||||
return yang_toc_render($items, $atts);
|
||||
},
|
||||
$scan['content']
|
||||
);
|
||||
|
||||
return is_string($out) ? $out : $content;
|
||||
}
|
||||
add_filter('the_content', 'yang_toc_filter_content', 9);
|
||||
|
||||
/**
|
||||
* 短代码兜底
|
||||
*
|
||||
* 正常路径下 [TOC] 在优先级 9 就被替换掉了,走不到这里。
|
||||
* 但如果在 the_content 之外被调用(小工具、页面构建器、do_shortcode()
|
||||
* 显式调用),这条路径保证行为一致 —— 复用同一个渲染函数。
|
||||
*/
|
||||
function yang_toc_shortcode($atts = array(), $content = '', $tag = '')
|
||||
{
|
||||
$post = get_post();
|
||||
if (!$post || !isset($post->post_content)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$scan = yang_toc_scan($post->post_content);
|
||||
return yang_toc_render($scan['items'], is_array($atts) ? $atts : array());
|
||||
}
|
||||
add_shortcode('toc', 'yang_toc_shortcode');
|
||||
add_shortcode('TOC', 'yang_toc_shortcode');
|
||||
|
||||
/**
|
||||
* 需要时才加载样式
|
||||
*
|
||||
* 每篇文章都加一份 TOC 样式是浪费;只有正文里真的写了 [TOC] 才加载。
|
||||
* 依赖 'kratos' 保证排在主题主样式之后,不会被覆盖。
|
||||
*/
|
||||
function yang_toc_assets()
|
||||
{
|
||||
if (is_admin() || !is_singular()) {
|
||||
return;
|
||||
}
|
||||
$post = get_post();
|
||||
if (!$post || !isset($post->post_content)) {
|
||||
return;
|
||||
}
|
||||
if (!has_shortcode($post->post_content, 'toc') && !has_shortcode($post->post_content, 'TOC')) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_style(
|
||||
'yang-toc',
|
||||
ASSET_PATH . '/assets/css/toc.css',
|
||||
array('kratos'),
|
||||
THEME_VERSION
|
||||
);
|
||||
wp_enqueue_script(
|
||||
'yang-toc',
|
||||
ASSET_PATH . '/assets/js/toc.js',
|
||||
array(),
|
||||
THEME_VERSION,
|
||||
true
|
||||
);
|
||||
}
|
||||
add_action('wp_enqueue_scripts', 'yang_toc_assets', 20);
|
||||
Reference in New Issue
Block a user