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,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 处理,这里不重复
|
||||
|
||||
}())
|
||||
Reference in New Issue
Block a user