Files
laoyang da0d9ae4a1 代码高亮整套自研:10 浅 + 10 深配色、语言标签、评论区贴代码 (#11)
不再依赖第三方高亮插件,全部由主题自己实现。

主题配色(20 套)
- 浅色 10:github / vs / atom-one-light / idea / xcode /
  stackoverflow-light / intellij-light / googlecode / magula / color-brewer
- 深色 10:github-dark / atom-one-dark / monokai / github-dark-dimmed /
  a11y-dark / stackoverflow-dark / night-owl / srcery /
  panda-syntax-dark / tomorrow-night-blue
- 深色卡片带 data-kind="dark",顶栏跟着换深色底
- 只加载当前选中的一套(20 套全加载会让后出现的规则压前面的,
  切换配色容易"看起来没变")

代码语言标签(用户要求:防止复制错语言)
- 作者标了 language-xxx → 实线边框、正体
- 自动识别猜出来的 → 虚线边框、斜体
- 认不出来 → 显示 "—",仍保持虚线斜体(不冒充作者标注)

行号
- 默认关;行号槽在 <pre> 外面,复制天然不带行号
- 改掉原主题的 word-break:break-all(任意字符断行,代码像乱码)
- 归零 hljs 主题自带的 pre code.hljs{padding:1em},
  否则代码被压低 1em、与行号槽错位 15.5px

复制
- 默认开;安全上下文判断 + execCommand 退路(HTTP 下 clipboard 是 undefined)
- 取 textContent 而非 innerHTML
- 默认 opacity:0、hover 显示、触屏覆盖回 1
  (旧写法只在 @media(hover:none) 里写 1,等于空操作)

自动识别
- 做成选项,默认开
- 只对没标语言的块生效;站内实测长块准确率 22%,不能覆盖作者标注

评论区贴代码
- 评论工具条加"代码"按钮 + 29 种语言面板
- kses 放行 pre 并给 pre/code 放开 class
  (WP 默认 pre 整条剥掉、code 属性全清,此前根本贴不了代码)
- 放行 pre 之后 wpautop/wptexturize/make_clickable 自动让路,
  代码里的连续空行、引号、URL 都不会被改写
- comments.js 用占位符保护代码块,避免 walk() 的 /\n{3,}/ 压缩

其它
- 资源版本号改为读主题头,不再写死(写死会导致以后升级后
  浏览器一直用旧缓存,改了 CSS/JS 完全不生效)
- 删除 footer.php 里旧的 Prism 兜底(两套高亮器同页会互相打架)

验收
- 用线上 284 个真实代码块跑产品代码:284/284 全部变卡片,内容丢字 0、双转义 0
- 47 个 text 标记全部映射到 plaintext(hljs 无 text 语言,不映射会静默不上色)
- 80 个裸 <pre>(无 code 子元素)全部覆盖
- 核心纯函数 65/65;核心反向 10/10 变红
- 浏览器端到端 32/32;行号几何逐行对齐偏差 0.00px,几何反向 4/4 变红
- 端到端反向 7/7 变红;语言标签反向 2/2 变红
- 全量 PHP 语法 95 文件 0 错误

发布:走 theme-update 通道(zip + readme + update.json),
未覆盖服务器主题目录;线上主题目录仍是 3.2.41,待后台升级。
2026-10-26 09:00:17 +08:00

657 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* 评论区交互
* 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 : '')
}
/* ── 提交态开关 ────────────────────────────────────────────
* 只改按钮和编辑区的可交互性,不动表单数据。 */
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 (editor) editor.setAttribute('contenteditable', on ? 'false' : 'true')
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 (editor) {
editor.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()
}
})
/* ── 所见即所得编辑器(3.2.25)─────────────────────────────
* 3.2.24 的做法是往 textarea 里插 <strong> 这类源码:用户看到的
* 是代码不是格式;光标点错位置再插表情,代码就被劈成两半 ——
* 3.2.25 用户明确要求改掉。
*
* 现在:#yang-editor 是 contentEditable,格式所见即所得;
* 提交前由 serializeEditor() 把编辑区内容序列化成**后端白名单
* HTML**(theme-comments.php 的 kses 白名单 + safecss_filter_attr
* 消毒保持不变):
* strong / em / span[style color] 保留
* 表情图 <img data-code> 转回 :code: 文本(存储不变)
* 其余一切标签 只保留文字(粘贴垃圾进不来)
*/
var editor = $('#yang-editor', form)
/* 工具按钮用 mousedown 抢焦点会弄丢编辑区选区 —— 一律挡掉,
* 这样「选中一段字 → 点 B」的选区在 click 到来时还在。
* ⚠️ 面板内部的元素(色板、表情链接)也要挡:3.2.25 本地探针实测,
* 点色板时焦点被抢走,foreColor 落在塌陷光标上,颜色功能整个失效。 */
$$('.comment-tool-btn, .comment-color-swatch, .comment-color-panel, .comment-smile-panel, .comment-smile-panel a, .comment-code-panel, .comment-code-lang', form)
.forEach(function (b) {
b.addEventListener('mousedown', function (ev) { ev.preventDefault() })
})
function exec (cmd, val) {
if (!editor) return
editor.focus()
try { document.execCommand(cmd, false, val || null) } catch (ex) { /* 忽略 */ }
}
// B / I:浏览器原生 execCommand,Ctrl+B / Ctrl+I 在编辑区内天然可用
var boldBtn = $('#yang-tool-bold', form)
var italicBtn = $('#yang-tool-italic', form)
if (boldBtn) boldBtn.addEventListener('click', function () { exec('bold') })
if (italicBtn) italicBtn.addEventListener('click', function () { exec('italic') })
/* 字体颜色面板:开合逻辑与表情面板同款(点按钮开/关,点外面关)。
* styleWithCSS(true) 让 foreColor 产出 <span style="color">,
* 而不是废弃的 <font color> —— 正好对齐后端白名单。 */
var colorBtn = $('#yang-tool-color', form)
var colorPanel = $('#yang-color-panel', form)
if (colorBtn && colorPanel) {
colorBtn.addEventListener('click', function (ev) {
ev.stopPropagation()
var open = !colorPanel.hidden
colorPanel.hidden = open
colorBtn.setAttribute('aria-expanded', open ? 'false' : 'true')
})
document.addEventListener('click', function (ev) {
if (colorPanel.hidden) return
if (colorPanel.contains(ev.target) || colorBtn.contains(ev.target)) return
colorPanel.hidden = true
colorBtn.setAttribute('aria-expanded', 'false')
})
colorPanel.addEventListener('click', function (ev) {
var sw = ev.target.closest ? ev.target.closest('.comment-color-swatch') : null
if (!sw) return
var c = sw.getAttribute('data-color') || '#000000'
// 色值只允许 #rrggbb,面板里写死的,这里再挡一道
if (!/^#[0-9a-fA-F]{6}$/.test(c)) return
exec('styleWithCSS', true)
exec('foreColor', c)
colorPanel.hidden = true
colorBtn.setAttribute('aria-expanded', 'false')
})
}
/* ── 表情面板 ──────────────────────────────────────────────
* 插入**可见的表情图片**(img 带 data-code 记住代码),
* 不再插 :smile: 这类源码 —— 那正是 3.2.25 要改掉的行为。
* 提交时 serializeEditor() 把图片转回代码,存储格式与旧评论兼容。 */
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()
var img = a.querySelector('img')
var src = img ? (img.getAttribute('src') || '') : ''
if (src) {
exec('insertHTML', ' <img class="yang-editor-emoji" src="' + src +
'" data-code="' + m[1] + '" alt="' + m[1] + '"> ')
} else {
exec('insertText', ' ' + m[1] + ' ')
}
smilePanel.hidden = true
smileBtn.setAttribute('aria-expanded', 'false')
})
}
/* ── 3.2.42 代码块面板 ──────────────────────────────────
* 与颜色/表情面板同款开合逻辑(点按钮开合、点外面关掉)。
* 选定语言后会在光标处插入:
* <pre><code class="language-xxx">这里换成你的代码</code></pre>
* 为什么先插占位文字而不是空块:空的 <pre> 在浏览器里高度接近 0,
* 光标定位困难、看起来像"没插进去",访客会反复点。
*
* 🔴 为什么语言必须让访客自己选:
* 自动识别在站内实测准确率只有 22%(18 个长块对 4 个),
* 让它替访客决定语言等于制造错误标签。
* 🔴 插入后必须把光标留在代码块里,否则访客紧接着打字会打到块外面。
*/
var codeBtn = $('#yang-code-btn')
var codePanel = $('#yang-code-panel')
if (codeBtn && codePanel) {
codeBtn.addEventListener('click', function (ev) {
ev.stopPropagation()
var open = !codePanel.hidden
codePanel.hidden = open
codeBtn.setAttribute('aria-expanded', open ? 'false' : 'true')
})
document.addEventListener('click', function (ev) {
if (codePanel.hidden) return
if (codePanel.contains(ev.target) || codeBtn.contains(ev.target)) return
codePanel.hidden = true
codeBtn.setAttribute('aria-expanded', 'false')
})
codePanel.addEventListener('click', function (ev) {
var b = ev.target.closest ? ev.target.closest('.comment-code-lang') : null
if (!b) return
ev.preventDefault()
var lang = b.getAttribute('data-lang') || 'text'
insertCodeBlock(lang)
codePanel.hidden = true
codeBtn.setAttribute('aria-expanded', 'false')
})
}
/* 在光标处插入一个代码块,并把占位文字选中,方便直接覆盖 */
function insertCodeBlock (lang) {
if (!editor) return
editor.focus()
var cls = lang && lang !== 'text' ? ' class="language-' + lang + '"' : ''
var html = '<pre><code' + cls + '></code></pre>'
// 先插空块,再把占位文字塞进 code 里并选中
try {
document.execCommand('insertHTML', false, html)
} catch (ex) {
// 老浏览器兜底:直接插纯文本标记
document.execCommand('insertText', false,
'\n<pre><code' + cls + '>\n\n</code></pre>\n')
return
}
// 找到刚插入的最后那个 code,放占位文字并全选
var codes = $$('pre:last-of-type code', editor)
var target = codes.length ? codes[codes.length - 1] : null
if (!target) {
var all = $$('pre code', editor)
target = all.length ? all[all.length - 1] : null
}
if (target) {
target.textContent = '在这里粘贴或输入代码'
var range = document.createRange()
range.selectNodeContents(target)
var sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(range)
}
}
/* 编辑区被清空后常残留 <br>/<div><br></div>,压成真空,
* 否则 :empty 占位符不显示。 */
editor.addEventListener('input', function () {
var h = editor.innerHTML
if (h === '<br>' || h === '<div><br></div>' || h === '<div><br></div><br>') {
editor.innerHTML = ''
}
})
/* 粘贴只收纯文本:富文本粘贴会把整篇网页的样式标签搬进来,
* 序列化时虽然只剩文字,但先按纯文本插入可以少绕一圈。 */
editor.addEventListener('paste', function (ev) {
ev.preventDefault()
var t = (ev.clipboardData || window.clipboardData).getData('text/plain') || ''
exec('insertText', t)
})
/* 序列化:编辑区 DOM → 后端白名单 HTML。
* 白名单在这里再收一次(不只依赖 kses):提交的是我们生成的串,
* 自己保证干净,比"交上去让后端删"更稳。 */
function escHtml (s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function serializeEditor () {
if (!editor) return ''
var clone = editor.cloneNode(true)
// 表情图 → :code: 文本(前后带空格,和旧版纯文本行为一致)
$$('img[data-code]', clone).forEach(function (im) {
im.parentNode.replaceChild(
document.createTextNode(' ' + (im.getAttribute('data-code') || '') + ' '),
im)
})
/* ── 3.2.42 代码块:必须在 walk() 之前单独序列化 ──────────
* 🔴 不能交给 walk() 递归处理,原因是 walk 会给所有文本做
* escHtml()(把 & 变 &amp;、< 变 &lt;)。代码块里的内容
* 出发时是**已转义过的显示文本**(浏览器 textContent 已把
* &lt; 还原成 <),需要重新转义一次才对;但 walk 还可能
* 把代码里的连续空行压掉(收尾的 /\n{3,}/ 替换),
* 而代码里连续空行是完全正常的。
* ⇒ 做法:先把每个 <pre> 换成唯一占位标记,
* walk 结束后再把标记替换回序列化好的代码块。
* 占位符用 \u0000 包裹,escHtml 不会改动它。
*/
var codeBlocks = []
var pres = $$('pre', clone)
for (var pi = 0; pi < pres.length; pi++) {
var pre = pres[pi]
var codeEl = pre.querySelector('code')
var srcEl = codeEl || pre
var cls = (codeEl ? codeEl.className : '') + ' ' + (pre.className || '')
var mm = /(?:language|lang)-([a-z0-9+#.-]+)/i.exec(cls)
var lang = mm ? mm[1].toLowerCase() : ''
// textContent 是浏览器还原后的纯文本,重新转义成安全形态再提交
var raw = srcEl.textContent || ''
var safe = raw.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
var block = '<pre><code'
+ (lang ? ' class="language-' + lang + '"' : '')
+ '>' + safe + '</code></pre>'
var ph = '\u0000CB' + codeBlocks.length + '\u0000'
codeBlocks.push(block)
pre.parentNode.replaceChild(document.createTextNode(ph), pre)
}
function walk (n) {
var out = ''
for (var i = 0; i < n.childNodes.length; i++) {
var c = n.childNodes[i]
if (c.nodeType === 3) { out += escHtml(c.nodeValue); continue }
if (c.nodeType !== 1) continue
var tag = c.tagName.toLowerCase()
if (tag === 'br') { out += '\n'; continue }
var inner = walk(c)
/* 颜色不一定长在 span 上:foreColor 作用在斜体/加粗选区时,
* 产出的是 <i style="color:..."> / <b style="color:...">
* (3.2.25 本地探针实测)。所以任何允许标签都检查自身 color,
* 有就包一层 span 提交 —— 后端白名单只认 span[style]。 */
var col = ((c.style && c.style.color) || (c.getAttribute && c.getAttribute('color')) || '').trim()
var colSpan = /^(#[0-9a-fA-F]{3}|#[0-9a-fA-F]{6}|rgb\()/.test(col)
? '<span style="color:' + col + '">' + inner + '</span>'
: inner
if (tag === 'strong' || tag === 'b') { out += '<strong>' + colSpan + '</strong>' }
else if (tag === 'em' || tag === 'i') { out += '<em>' + colSpan + '</em>' }
else if (tag === 'span' || tag === 'font') { out += colSpan }
else if (tag === 'div' || tag === 'p') { out += inner + '\n' }
else { out += inner }
}
return out
}
var body = walk(clone)
// 空行压缩只作用于代码块之外 —— 先把已存的块换回来,避免误伤
body = body.replace(/\n{3,}/g, '\n\n')
// 🔴 占位符还原:必须在压缩空行**之后**做。
// 如果先还原,代码里的连续空行会被上面的 /\n{3,}/ 一起压掉。
for (var ci = 0; ci < codeBlocks.length; ci++) {
body = body.split('\u0000CB' + ci + '\u0000').join(codeBlocks[ci])
}
return body.trim()
}
/* ── 提交 ──────────────────────────────────────────────────
* 用 fetch + FormData,直接把整个表单发过去(含 nonce、comment_post_ID、
* comment_parent)。这样服务端 wp_handle_comment_submission 拿到的
* 字段与普通 POST 完全一致,不需要为 AJAX 单独走一套。 */
form.addEventListener('submit', function (ev) {
ev.preventDefault()
if (submitting) return // 双击保护
// 编辑区内容序列化进真实提交字段(隐藏的 #comment)
var content = serializeEditor()
if (textarea) textarea.value = content
// 本地前置校验:空内容不发请求(省一次往返,也避免后端 500)。
// 「空」= 没有文字也没有表情图(只点了个表情也允许提交)。
var hasEmoji = editor ? !!editor.querySelector('img[data-code]') : false
if (content === '' && !hasEmoji) {
setStatus(I18N.emptyComment || '评论内容不能为空。', 'err')
if (editor) editor.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 (editor) editor.innerHTML = ''
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 处理,这里不重复
}())