961 lines
38 KiB
PHP
961 lines
38 KiB
PHP
<?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;
|
||
}
|
||
|
||
/**
|
||
* 3.2.24 富文本工具条:给访客评论放行 <span style="color:…">
|
||
*
|
||
* 工具条插入的三种标签里,<strong>/<em> 本来就在 WP 评论的
|
||
* kses 白名单($allowedtags)里,不用管;<span style="color:…">
|
||
* 不在 —— 访客提交时会被 wp_filter_kses 整个剥掉,颜色功能就白做了。
|
||
*
|
||
* 这里把 span(带 style / class 属性)追加进 kses 白名单。安全性:
|
||
* 1. kses 对 style 属性的内容会再过一遍 safecss_filter_attr(),
|
||
* 那是一份固定白名单(color、font-weight、text-align…),
|
||
* expression()/url()/behavior() 这些危险属性一律剥掉;
|
||
* 2. 工具条前端只生成 color 一个声明,白名单只是兜底;
|
||
* 3. 有 unfiltered_html 权限的用户(管理员)本来就不走 kses,不受影响。
|
||
*
|
||
* ⚠️ 不按 context 区分:wp_filter_kses 实际传进来的 context 串
|
||
* 在 WP 各版本间不稳定,写死 'pre_comment_user' 之类的会静默失效
|
||
* (失效 = 标签被剥 = 功能没了但没有任何报错)。span+style 放行到
|
||
* 任何 kses 场景都是安全的(style 内容有 safecss_filter_attr 挡着),
|
||
* 所以对除 'strip' 外的所有 context 一律追加。
|
||
*/
|
||
function yang_kses_allow_span($tags, $context)
|
||
{
|
||
if ($context === 'strip') {
|
||
return $tags;
|
||
}
|
||
if (!is_array($tags)) {
|
||
$tags = array();
|
||
}
|
||
$tags['span'] = array(
|
||
'style' => true,
|
||
'class' => true,
|
||
);
|
||
return $tags;
|
||
}
|
||
add_filter('wp_kses_allowed_html', 'yang_kses_allow_span', 10, 2);
|
||
|
||
/**
|
||
* 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 返回的新评论
|
||
* @param bool $close_li 是否在末尾闭合 </li>
|
||
* —— 走 wp_list_comments 时必须传 false,
|
||
* 否则子评论会被挤到 </li> 外面(详见下方注释)
|
||
* @return string
|
||
*/
|
||
|
||
/**
|
||
* 嵌套引导线的按层配色(3.2.27)。
|
||
*
|
||
* $depth 是「线里装着的评论」的深度:
|
||
* 2 = 直接回复(第 1 层嵌套)→ 返回 '',CSS 回落到主题浅蓝(保持现状);
|
||
* 3 = 第 2 层嵌套 → 深蓝;
|
||
* 4 = 第 3 层嵌套 → 点赞按钮背景色(.btn-thumbs 的 #fb5235);
|
||
* 5+ → 扩展色板按层循环:确定性的"随机"——同层恒同色、相邻层必异色,
|
||
* 页面刷新不变(真随机会让同一层的线各页各色,层级信号反而乱)。
|
||
*
|
||
* 颜色都取饱和偏深的中明度色:线上是白底,太浅的(如淡黄)贴白底看不见。
|
||
*
|
||
* @param int $depth 评论深度(最小 2,即第 1 层嵌套)
|
||
* @return string 颜色值;'' 表示用 CSS 默认(主题浅蓝)
|
||
*/
|
||
function yang_comment_guide_color($depth)
|
||
{
|
||
if ($depth <= 2) {
|
||
return '';
|
||
}
|
||
if ($depth === 3) {
|
||
return '#0057b8'; // 深蓝:与浅蓝同色相,明度拉开
|
||
}
|
||
if ($depth === 4) {
|
||
return '#fb5235'; // 点赞按钮(.btn-thumbs)的背景/描边色
|
||
}
|
||
$pool = array('#7c4dff', '#00a77a', '#e91e63', '#f59e0b', '#5b8def', '#8bc34a');
|
||
return $pool[($depth - 5) % count($pool)];
|
||
}
|
||
|
||
function yang_comment_html($comment, $args = array(), $depth = 1, $is_ajax = false, $close_li = true)
|
||
{
|
||
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();
|
||
/* 嵌套引导线按层配色(3.2.27):
|
||
线画在子容器 ol.yang-children 的 ::before 上,颜色从 --yq-guide-c 取。
|
||
给本 li 注入的值 = 「本条的直接回复那一层」该用的颜色:
|
||
第 1 层嵌套(直接回复)→ 不注入,CSS 回落到主题浅蓝(保持现状);
|
||
第 2 层 → 深蓝;第 3 层 → 点赞按钮背景色 #fb5235;
|
||
更深 → 扩展色板按层循环(确定性的"随机",同层同色、跨层异色)。
|
||
自定义属性沿 DOM 继承:内层 li 自己注入的值会覆盖继承值,
|
||
所以每个 ol 读到的都是离它最近的那个父 li 注入的颜色。 */
|
||
$guide_c = yang_comment_guide_color($depth + 1);
|
||
$style_attr = '';
|
||
if ($guide_c !== '') {
|
||
$style_attr = ' style="--yq-guide-c:' . esc_attr($guide_c) . ';"';
|
||
}
|
||
$o[] = '<li id="comment-' . $comment_id . '" class="' . esc_attr(implode(' ', $classes)) . '"'
|
||
. ' data-comment-id="' . $comment_id . '"'
|
||
. ' data-depth="' . $depth . '"' . $style_attr . '>';
|
||
|
||
// ── 头像列 ──
|
||
$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
|
||
|
||
/* 闭合 </li> 交给调用方决定。
|
||
wp_list_comments 的 walker 会在 start_el 之后、start_lvl 之前
|
||
插入子评论容器,所以走列表渲染时这条 </li> **必须先不闭合**,
|
||
否则子评论会被挤到 </li> 外面,变成父评论的兄弟节点 ——
|
||
HTML 会变成 </li></li><ol class="children"> 这种畸形结构,
|
||
CSS 里 "子评论占满整行" 的规则就完全不生效。
|
||
AJAX 单独拼 HTML 时没有 walker,才需要自己闭合。 */
|
||
if ($close_li) {
|
||
$o[] = '</li>';
|
||
}
|
||
|
||
return implode('', $o);
|
||
}
|
||
|
||
/**
|
||
* 评论列表的 walker
|
||
*
|
||
* 只覆盖两处,其余沿用 Walker_Comment:
|
||
* 1. 子评论容器类名用 `yang-children`(CSS 按这个类名写缩进与栅格)。
|
||
* 核心固定输出 `class="children"`,样式对不上。
|
||
* 2. 配合 `comment_callbacks()` 不再自己闭合 </li>,
|
||
* 让核心的 end_el 去闭合 —— 这样 <ol> 才落在 <li> 内部。
|
||
*/
|
||
class Yang_Walker_Comment extends Walker_Comment
|
||
{
|
||
public function start_lvl(&$output, $depth = 0, $args = array())
|
||
{
|
||
$GLOBALS['comment_depth'] = $depth + 1;
|
||
$output .= '<ol class="yang-children">' . "\n";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* wp_list_comments 的回调(列表渲染)
|
||
*
|
||
* 只是把参数转交给统一渲染函数。保留这个函数名是因为
|
||
* comments.php 里引用了它,且子主题可能覆盖过。
|
||
*
|
||
* 注意末尾传 false:不在这里闭合 </li>,交给 walker 的 end_el。
|
||
* 原因见 yang_comment_html() 里的注释。
|
||
*/
|
||
function comment_callbacks($comment, $args = array(), $depth = 1)
|
||
{
|
||
echo yang_comment_html($comment, $args, $depth, false, 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));
|
||
}
|
||
|
||
/**
|
||
* 取「第 N 页评论」:按**顶层评论**分页,再把每条的子孙展开成平铺数组
|
||
*
|
||
* ── 为什么不直接把 page/per_page 交给 wp_list_comments ──────
|
||
* WP 那个分支有缺陷:当 `page`/`per_page` 与它自己从查询变量推导出的
|
||
* 当前页完全相等时,内层 `if` 不成立,`$_comments` 就**一次都没被赋值**
|
||
* (源码里那句"fall back on $wp_query->comments"的注释下面并没有回退代码),
|
||
* 最终 `paged_walk(null, ...)` 返回空字符串。表现就是:
|
||
* 「评论(N)」标题在、输入框在,列表一条不显示。
|
||
* 单页评论的文章(max_num_comment_pages=1)必然命中,多页的反而侥幸正常 ——
|
||
* 这也是它为什么看起来"时好时坏"。
|
||
*
|
||
* 所以取数由这里负责,`wp_list_comments()` 的第 2 个参数直接收数组,
|
||
* 走它的 `null !== $comments` 分支,不碰那段有问题的推导。
|
||
*
|
||
* ── 为什么按顶层分页 ────────────────────────────────────────
|
||
* 分页若按"行"算,一条顶层评论和它的 10 条回复可能被切到两页,
|
||
* 第 2 页会出现一堆没有父评论的孤儿回复。按顶层 + 整体展开,
|
||
* 父子永远同页。
|
||
*
|
||
* @param int $post_id 文章 ID
|
||
* @param int $page 第几页(从 1 开始)
|
||
* @param int $per_page 每页多少条顶层评论;0 = 不分页,全取
|
||
* @return WP_Comment[] 平铺后的评论(父在子前,可直接交给 wp_list_comments)
|
||
*/
|
||
function yang_comment_page($post_id, $page = 1, $per_page = 0)
|
||
{
|
||
$post_id = (int) $post_id;
|
||
if ($post_id <= 0) {
|
||
return array();
|
||
}
|
||
|
||
$order = strtoupper((string) get_option('comment_order', 'asc')) === 'DESC' ? 'DESC' : 'ASC';
|
||
|
||
$args = array(
|
||
'post_id' => $post_id,
|
||
'status' => 'approve',
|
||
'orderby' => 'comment_date_gmt',
|
||
'order' => $order,
|
||
'parent' => 0,
|
||
'hierarchical' => 'threaded',
|
||
);
|
||
if ($per_page > 0) {
|
||
$args['number'] = (int) $per_page;
|
||
$args['offset'] = max(0, ((int) $page - 1) * (int) $per_page);
|
||
}
|
||
|
||
$top = get_comments($args);
|
||
if (!$top) {
|
||
return array();
|
||
}
|
||
|
||
/* 展开子孙。用 get_children() 而不是读 $comment->children ——
|
||
后者是 protected,而且要先经 hierarchical='threaded' 才会被填充。 */
|
||
$flat = array();
|
||
foreach ($top as $c) {
|
||
$flat[] = $c;
|
||
$kids = $c->get_children(array(
|
||
'format' => 'flat',
|
||
'status' => 'approve',
|
||
'orderby' => 'comment_date_gmt',
|
||
'order' => $order,
|
||
));
|
||
foreach ($kids as $kid) {
|
||
$flat[] = $kid;
|
||
}
|
||
}
|
||
return $flat;
|
||
}
|
||
|
||
/**
|
||
* 「加载更多评论」AJAX
|
||
*
|
||
* 旧模板里 `previous_comments_link(__('加载更多'))` 是个普通链接,
|
||
* 点了整页跳转(还跳到评论分页 URL)。这里改成原地加载。
|
||
*
|
||
* 取数与首屏共用 `yang_comment_page()`:以前这里自己写了一份只取顶层、
|
||
* 只渲染顶层的查询,导致点「加载更多」出来的评论**全部没有子回复**
|
||
* (实测某篇 10 条顶层名下有 12 条回复被吞掉)。
|
||
*/
|
||
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 = yang_comment_page($post_id, $page, $per_page);
|
||
|
||
$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,
|
||
);
|
||
|
||
/* 渲染走 wp_list_comments + 同一个 walker,和首屏完全同一条路径。
|
||
以前这里是 foreach 手动拼 yang_comment_html(),没有 walker,
|
||
于是子回复被平铺成父评论的兄弟节点、层级信息全丢
|
||
—— 实测某篇 10 条顶层名下的 12 条回复结构全错。 */
|
||
ob_start();
|
||
wp_list_comments(array(
|
||
'type' => 'comment',
|
||
'callback' => 'comment_callbacks',
|
||
'walker' => new Yang_Walker_Comment(),
|
||
'style' => 'ol',
|
||
'max_depth' => $max_depth > 0 ? $max_depth : 5,
|
||
'avatar_size' => 48,
|
||
'per_page' => 0,
|
||
'page' => 0,
|
||
), $comments);
|
||
$html = ob_get_clean();
|
||
|
||
// 分页按顶层算,所以"还有没有更多"也要按顶层比,不能拿总评论数比
|
||
$top_total = (int) get_comments(array(
|
||
'post_id' => $post_id,
|
||
'status' => 'approve',
|
||
'parent' => 0,
|
||
'count' => true,
|
||
));
|
||
$loaded_top = $page * $per_page;
|
||
|
||
$total = (int) get_comments_number($post_id);
|
||
|
||
wp_send_json(array(
|
||
'ok' => true,
|
||
'html' => $html,
|
||
'page' => $page,
|
||
'has_more' => $loaded_top < $top_total,
|
||
'next_page' => $page + 1,
|
||
'loaded' => $loaded_top,
|
||
'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'),
|
||
'boldPlaceholder' => __('加粗文字', 'kratos'),
|
||
'colorPlaceholder' => __('彩色文字', '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 一次;现在没有插件了,
|
||
// 预编码反而会让邮件正文里出现字面的 &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); |