Files
wordpress-kratos-yang/inc/theme-comments.php
T
laoyang cbe0f2dc59 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(真实文章渲染目录 + 真实评论数据渲染)。
2026-10-24 01:03:36 +08:00

761 lines
30 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 一次;现在没有插件了,
// 预编码反而会让邮件正文里出现字面的 &amp;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);