/*! * 评论区交互 * Yongquan Yang * * ── 旧版(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('")` * 直接用 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 处理,这里不重复 }())