` + `wp_list_comments()`,
* 后者输出的是 `
`。`` 的父元素必须是 /,
* 否则浏览器会自己"猜"结构,不同浏览器猜法不同,
* 嵌套回复()在某些浏览器里会错位。
*
* ── AJAX 设计的三条约束 ──────────────────────────────────────
* 1. **不用 layer 弹提示**。layer 是个通用弹窗库,用来显示"评论提交成功"
* 太重(遮罩、动画、层级都多余),而且它失败时会把整个提示吞掉。
* 这里用轻量的内联状态条,并直接显示后端返回的**人话**错误。
* 2. **必须防重复提交**。旧实现 beforeSend 只弹了个"提交中",
* 按钮仍可点,网络慢时用户会连点,产生多条重复评论。
* 3. **后端错误要可读**。WordPress 的评论错误是 WP_Error,
* 旧代码把 message 直接扔给前端,有些情况下是空串或英文提示。
* 这里统一映射成中文,并区分"表单错误"(可修正)与"系统错误"。
*
* ── 与 wpDiscuz 的关系 ───────────────────────────────────────
* 这个文件是**替代** wpDiscuz 的实现,不是与它共存。装了插件时
* 插件会接管评论表单,主题这套不生效(也不该生效,两套会打架)。
* 卸载插件后,主题这套自动接管,且评论通知不依赖插件 ——
* 见本文件末尾的通知部分。
*
* @author Yongquan Yang
* @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[] = '';
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: '- …
', 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 一次;现在没有插件了,
// 预编码反而会让邮件正文里出现字面的 < —— 是实打实的 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);