Files
wordpress-theme-aksrt/inc/classes/forum/class-aksrt-forum-comment-service.php
T
kate522 780c8cb4f7 feat(forum): frontend 1:1 list/tabs/urls/enqueue
Wire Zibll-style board sort tabs, shared topic rows, page-template
URLs, forum asset enqueue on home/plate, home synthesis load-more,
and topic last_reply meta for last-reply ordering. No dual-read.
2026-07-12 08:38:31 +08:00

588 lines
28 KiB
PHP

<?php
/**
* Forum comment/reply service.
*
* Extracted from AKSRT_Forum to isolate the comment engine:
* querying, rendering, pinning, hot-comment detection, and adopt-answer flow.
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Forum_Comment_Service {
const COMMENT_TOP_NONE = 0;
const COMMENT_TOP_NORMAL = 1;
const COMMENT_TOP_HOT = 2;
const COMMENT_TOP_BEST = 3;
/** @var AKSRT_Forum */
private $forum;
public function __construct($forum) {
$this->forum = $forum;
}
// ── Hook: forum comment posted ───────────────────────────────────
public function on_forum_comment_posted(int $comment_id, $approved, array $commentdata = []): void {
$comment = get_comment($comment_id);
if (!$comment) return;
$post = get_post((int) $comment->comment_post_ID);
if (!$post || $post->post_type !== 'aksrt_forum_topic') return;
if ((int) $comment->comment_approved !== 1) return;
$commenter_id = (int) $comment->user_id;
if ($commenter_id <= 0) return;
// Topic-level last_reply for board sort tabs.
update_post_meta((int) $post->ID, '_aksrt_last_reply', (string) $comment->comment_date);
$this->forum->notify_topic_reply((int) $post->ID, $commenter_id);
}
// ── AJAX: get forum comments ─────────────────────────────────────
/**
* AJAX: Get forum comments with sort/filter/pagination.
*/
public function ajax_forum_comments(): void {
check_ajax_referer('aksrt_public_nonce', 'nonce');
$topic_id = (int) ($_POST['topic_id'] ?? 0);
$sort = sanitize_key((string) ($_POST['sort'] ?? 'newest'));
$sort = in_array($sort, ['newest', 'oldest', 'hottest'], true) ? $sort : 'newest';
$author_only = !empty($_POST['author_only']);
$page = max(1, (int) ($_POST['page'] ?? 1));
$per_page = max(1, min(100, (int) ($_POST['per_page'] ?? 30)));
if ($topic_id <= 0) {
wp_send_json_error(['message' => __('参数无效', 'aksrt')]);
}
$result = $this->get_forum_comments($topic_id, $sort, $author_only, $page, $per_page);
ob_start();
$this->render_forum_comments($result['comments'], $topic_id, $result['adopted_id']);
$html = ob_get_clean();
wp_send_json_success([
'html' => $html,
'total' => $result['total'],
'max_pages' => $result['max_pages'],
'page' => $result['page'],
'has_more' => $result['page'] < $result['max_pages'],
]);
}
// ── AJAX: comment topping ────────────────────────────────────────
/**
* AJAX: Set comment topping level (moderator action).
*/
public function ajax_comment_topping(): void {
check_ajax_referer('aksrt_public_nonce', 'nonce');
$comment_id = (int) ($_POST['comment_id'] ?? 0);
$level = max(0, min(3, (int) ($_POST['level'] ?? 0)));
$user_id = get_current_user_id();
$comment = get_comment($comment_id);
if (!$comment) {
wp_send_json_error(['message' => __('评论不存在', 'aksrt')]);
}
$topic_id = (int) $comment->comment_post_ID;
if (!$this->forum->forum_user_can($user_id, 'reply_top', $topic_id)) {
wp_send_json_error(['message' => __('权限不足', 'aksrt')]);
}
if ($this->set_comment_topping($comment_id, $level)) {
wp_send_json_success([
'message' => $level > 0 ? __('已置顶', 'aksrt') : __('已取消置顶', 'aksrt'),
'level' => $level,
]);
}
wp_send_json_error(['message' => __('操作失败', 'aksrt')]);
}
// ── Render forum comments ────────────────────────────────────────
/**
* Render forum comments HTML for AJAX and initial load.
*
* @param WP_Comment[] $comments Comments (parents + children mixed).
* @param int $topic_id Topic post ID.
* @param int $adopted_id Adopted comment ID (0 if none).
*/
public function render_forum_comments(array $comments, int $topic_id, int $adopted_id = 0): void {
$post = get_post($topic_id);
$post_author_id = $post ? (int) $post->post_author : 0;
$current_user_id = get_current_user_id();
$is_question = 'question' === (string) get_post_meta($topic_id, '_aksrt_bbs_type', true);
$can_adopt = $is_question && $current_user_id > 0
&& ($current_user_id === $post_author_id || $this->forum->forum_user_can($current_user_id, 'reply_adopt_other', $topic_id));
$can_moderate = $this->forum->forum_user_can($current_user_id, 'topic_moderate', $topic_id);
if (empty($comments)) {
echo '<div class="aksrt-forum-replies__empty"><i class="far fa-comment-dots"></i> ' . esc_html__('暂无回复,来抢沙发吧!', 'aksrt') . '</div>';
return;
}
foreach ($comments as $comment) {
if (!($comment instanceof \WP_Comment)) continue;
if ((int) $comment->comment_parent !== 0) continue;
$comment_id = (int) $comment->comment_ID;
$commenter_uid = (int) $comment->user_id;
$is_adopted = $comment_id === $adopted_id;
$is_hot = $this->is_hot_comment($comment_id);
$is_post_author = $commenter_uid > 0 && $commenter_uid === $post_author_id;
$topping = (int) get_comment_meta($comment_id, '_aksrt_comment_topping', true);
$badge = $this->get_commenter_badge($commenter_uid, $topic_id);
$like_count = function_exists('aksrt_get_comment_likes') ? aksrt_get_comment_likes($comment_id) : 0;
$is_liked = function_exists('aksrt_is_comment_liked') ? aksrt_is_comment_liked($comment_id) : false;
$classes = ['aksrt-forum-reply'];
if ($is_adopted) $classes[] = 'is-adopted';
if ($is_hot) $classes[] = 'is-hot';
if ($topping > 0) $classes[] = 'is-pinned';
?>
<div class="<?php echo esc_attr(implode(' ', $classes)); ?>" id="comment-<?php echo esc_attr((string) $comment_id); ?>" data-comment-id="<?php echo esc_attr((string) $comment_id); ?>">
<div class="aksrt-forum-reply__avatar"><?php echo get_avatar($comment, 40); ?></div>
<div class="aksrt-forum-reply__body">
<div class="aksrt-forum-reply__meta">
<strong><?php comment_author($comment_id); ?></strong>
<?php if ($is_post_author) : ?>
<span class="aksrt-forum-reply__role-badge aksrt-forum-reply__role-badge--author"><?php esc_html_e('楼主', 'aksrt'); ?></span>
<?php endif; ?>
<?php if ($badge) : ?>
<span class="aksrt-forum-reply__role-badge <?php echo esc_attr($badge['class']); ?>"><?php echo esc_html($badge['label']); ?></span>
<?php endif; ?>
<?php if ($topping > 0) : ?>
<span class="aksrt-forum-reply__badge aksrt-forum-reply__badge--pinned"><i class="fas fa-thumbtack"></i> <?php esc_html_e('置顶', 'aksrt'); ?></span>
<?php endif; ?>
<?php if ($is_hot) : ?>
<span class="aksrt-forum-reply__badge aksrt-forum-reply__badge--hot"><i class="fas fa-fire"></i> <?php esc_html_e('神评', 'aksrt'); ?></span>
<?php endif; ?>
<?php if ($is_adopted) : ?>
<span class="aksrt-forum-reply__adopted-badge"><i class="fas fa-check-circle"></i> <?php esc_html_e('最佳答案', 'aksrt'); ?></span>
<?php endif; ?>
<time datetime="<?php echo esc_attr(get_comment_date('c', $comment)); ?>"><?php echo esc_html(get_comment_date('Y-m-d H:i', $comment)); ?></time>
</div>
<div class="aksrt-forum-reply__content"><?php echo function_exists('aksrt_render_comment_content') ? aksrt_render_comment_content((string) get_comment_text($comment_id)) : wp_kses_post(get_comment_text($comment_id)); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
<div class="aksrt-forum-reply__actions">
<button type="button" class="aksrt-forum-reply__like-btn js-aksrt-comment-like<?php echo $is_liked ? ' is-active' : ''; ?>"
data-comment-id="<?php echo esc_attr((string) $comment_id); ?>"
data-active="<?php echo $is_liked ? '1' : '0'; ?>">
<i class="<?php echo $is_liked ? 'fas' : 'far'; ?> fa-thumbs-up"></i>
<span class="aksrt-forum-reply__like-count"><?php echo $like_count > 0 ? esc_html((string) $like_count) : ''; ?></span>
</button>
<?php if (is_user_logged_in()) : ?>
<button type="button" class="aksrt-forum-reply__report-btn js-aksrt-report-trigger"
data-target-type="comment"
data-target-id="<?php echo esc_attr((string) $comment_id); ?>"
data-target-title="<?php echo esc_attr(wp_strip_all_tags(get_comment_excerpt($comment))); ?>">
<i class="far fa-flag"></i> <?php esc_html_e('举报', 'aksrt'); ?>
</button>
<?php endif; ?>
<?php if (comments_open($topic_id)) : ?>
<button type="button" class="aksrt-forum-reply__reply-btn js-aksrt-comment-reply"
data-comment-id="<?php echo esc_attr((string) $comment_id); ?>"
data-author="<?php echo esc_attr(get_comment_author($comment_id)); ?>">
<i class="fas fa-reply"></i> <?php esc_html_e('回复', 'aksrt'); ?>
</button>
<?php endif; ?>
<?php if ($can_adopt && !$is_adopted) : ?>
<button type="button" class="aksrt-forum-reply__adopt-btn js-aksrt-adopt-answer"
data-topic-id="<?php echo esc_attr((string) $topic_id); ?>"
data-comment-id="<?php echo esc_attr((string) $comment_id); ?>">
<i class="fas fa-check"></i> <?php esc_html_e('采纳', 'aksrt'); ?>
</button>
<?php endif; ?>
<?php if ($can_moderate) : ?>
<button type="button" class="aksrt-forum-reply__top-btn js-aksrt-comment-top"
data-comment-id="<?php echo esc_attr((string) $comment_id); ?>"
data-level="<?php echo esc_attr((string) $topping); ?>">
<i class="fas fa-thumbtack"></i> <?php echo $topping > 0 ? esc_html__('取消置顶', 'aksrt') : esc_html__('置顶', 'aksrt'); ?>
</button>
<?php endif; ?>
</div>
<?php
// Render nested children (楼中楼)
$child_comments = array_filter($comments, function ($c) use ($comment_id) {
return ($c instanceof \WP_Comment) && (int) $c->comment_parent === $comment_id;
});
if (!empty($child_comments)) : ?>
<div class="aksrt-forum-reply__children">
<?php foreach ($child_comments as $child) :
$child_id = (int) $child->comment_ID;
$child_uid = (int) $child->user_id;
$child_badge = $this->get_commenter_badge($child_uid, $topic_id);
$child_is_author = $child_uid > 0 && $child_uid === $post_author_id;
$child_like_count = function_exists('aksrt_get_comment_likes') ? aksrt_get_comment_likes($child_id) : 0;
$child_is_liked = function_exists('aksrt_is_comment_liked') ? aksrt_is_comment_liked($child_id) : false;
$parent_comment = get_comment((int) $child->comment_parent);
$parent_name = $parent_comment ? get_comment_author((int) $parent_comment->comment_ID) : '';
?>
<div class="aksrt-forum-reply aksrt-forum-reply--nested" id="comment-<?php echo esc_attr((string) $child_id); ?>" data-comment-id="<?php echo esc_attr((string) $child_id); ?>">
<div class="aksrt-forum-reply__avatar aksrt-forum-reply__avatar--sm"><?php echo get_avatar($child, 28); ?></div>
<div class="aksrt-forum-reply__body">
<div class="aksrt-forum-reply__meta">
<strong><?php comment_author($child_id); ?></strong>
<?php if ($child_is_author) : ?>
<span class="aksrt-forum-reply__role-badge aksrt-forum-reply__role-badge--author"><?php esc_html_e('楼主', 'aksrt'); ?></span>
<?php endif; ?>
<?php if ($child_badge) : ?>
<span class="aksrt-forum-reply__role-badge <?php echo esc_attr($child_badge['class']); ?>"><?php echo esc_html($child_badge['label']); ?></span>
<?php endif; ?>
<?php if ($parent_name && (int) $child->comment_parent !== $comment_id) : ?>
<span class="aksrt-forum-reply__reply-to"><?php printf(esc_html__('回复 %s', 'aksrt'), esc_html($parent_name)); ?></span>
<?php endif; ?>
<time datetime="<?php echo esc_attr(get_comment_date('c', $child)); ?>"><?php echo esc_html(get_comment_date('Y-m-d H:i', $child)); ?></time>
</div>
<div class="aksrt-forum-reply__content"><?php echo function_exists('aksrt_render_comment_content') ? aksrt_render_comment_content((string) get_comment_text($child_id)) : wp_kses_post(get_comment_text($child_id)); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
<div class="aksrt-forum-reply__actions">
<button type="button" class="aksrt-forum-reply__like-btn js-aksrt-comment-like<?php echo $child_is_liked ? ' is-active' : ''; ?>"
data-comment-id="<?php echo esc_attr((string) $child_id); ?>"
data-active="<?php echo $child_is_liked ? '1' : '0'; ?>">
<i class="<?php echo $child_is_liked ? 'fas' : 'far'; ?> fa-thumbs-up"></i>
<span class="aksrt-forum-reply__like-count"><?php echo $child_like_count > 0 ? esc_html((string) $child_like_count) : ''; ?></span>
</button>
<?php if (comments_open($topic_id)) : ?>
<button type="button" class="aksrt-forum-reply__reply-btn js-aksrt-comment-reply"
data-comment-id="<?php echo esc_attr((string) $child_id); ?>"
data-author="<?php echo esc_attr(get_comment_author($child_id)); ?>">
<i class="fas fa-reply"></i> <?php esc_html_e('回复', 'aksrt'); ?>
</button>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<?php
}
}
// ── Adopt answer (best answer) ──────────────────────────────────
public function adopt_answer(int $topic_id, int $comment_id, int $operator_id) {
$post = get_post($topic_id);
if (!$post || $post->post_type !== 'aksrt_forum_topic') {
return new \WP_Error('invalid_topic', __('帖子不存在', 'aksrt'));
}
$bbs_type = sanitize_key((string) get_post_meta($topic_id, '_aksrt_bbs_type', true));
if ($bbs_type !== 'question') {
return new \WP_Error('not_question', __('该帖不是提问类型', 'aksrt'));
}
$author_id = (int) $post->post_author;
if ($operator_id !== $author_id && !$this->forum->can_moderate_topic($operator_id, $topic_id)) {
return new \WP_Error('forbidden', __('仅提问者或版主可采纳答案', 'aksrt'));
}
$comment = get_comment($comment_id);
if (!$comment || (int) $comment->comment_post_ID !== $topic_id) {
return new \WP_Error('invalid_comment', __('回复不存在', 'aksrt'));
}
$existing = get_post_meta($topic_id, '_aksrt_adopted_comment', true);
if ($existing && (int) $existing === $comment_id) {
return new \WP_Error('already_adopted', __('该回复已被采纳', 'aksrt'));
}
if ($existing) {
delete_comment_meta((int) $existing, '_aksrt_adopted');
}
update_comment_meta($comment_id, '_aksrt_adopted', '1');
update_post_meta($topic_id, '_aksrt_adopted_comment', $comment_id);
update_post_meta($topic_id, '_aksrt_question_status', 1); // QUESTION_SOLVED
$adoptee_id = (int) $comment->user_id;
if ($adoptee_id > 0 && class_exists('AKSRT_Credits')) {
$config = $this->forum->get_config();
$amount = (int) ($config['credits']['adopted'] ?? 20);
if ($amount > 0) {
$already = get_comment_meta($comment_id, '_aksrt_adopt_credited', true);
if (!$already) {
\AKSRT_Credits::add($adoptee_id, $amount, 'forum_adopted', [
'type' => 'comment',
'id' => $comment_id,
]);
update_comment_meta($comment_id, '_aksrt_adopt_credited', '1');
}
}
$this->forum->notify($adoptee_id, 'system', sprintf(
__('你的回复被采纳为最佳答案(帖子「%s」)', 'aksrt'),
get_the_title($topic_id)
), [
'title' => __('回复被采纳', 'aksrt'),
'target_url' => get_permalink($topic_id) . '#comment-' . $comment_id,
]);
}
return [
'message' => __('已采纳该回复为最佳答案', 'aksrt'),
'comment_id' => $comment_id,
'solved' => true,
];
}
public function get_adopted_comment_id(int $topic_id): int {
return (int) get_post_meta($topic_id, '_aksrt_adopted_comment', true);
}
// ── Get forum comments (query) ──────────────────────────────────
/**
* Get forum comments with sort/filter support.
*
* @param int $topic_id Topic post ID.
* @param string $sort 'newest'|'oldest'|'hottest'
* @param bool $author_only Only show post-author replies.
* @param int $page Page number.
* @param int $per_page Items per page.
* @return array{comments:WP_Comment[],total:int,max_pages:int,page:int,adopted_id:int}
*/
public function get_forum_comments(int $topic_id, string $sort = 'newest', bool $author_only = false, int $page = 1, int $per_page = 30): array {
$post = get_post($topic_id);
if (!$post || $post->post_type !== 'aksrt_forum_topic') {
return ['comments' => [], 'total' => 0, 'max_pages' => 1, 'page' => 1, 'adopted_id' => 0];
}
$args = [
'post_id' => $topic_id,
'status' => 'approve',
'parent' => 0,
'number' => $per_page,
'offset' => ($page - 1) * $per_page,
];
if ($author_only) {
$args['user_id'] = (int) $post->post_author;
}
if ($sort === 'hottest') {
$args['meta_key'] = 'aksrt_comment_likes';
$args['orderby'] = 'meta_value_num';
$args['order'] = 'DESC';
} elseif ($sort === 'oldest') {
$args['orderby'] = 'comment_date_gmt';
$args['order'] = 'ASC';
} else {
$args['orderby'] = 'comment_date_gmt';
$args['order'] = 'DESC';
}
// Total count for pagination
$count_args = $args;
unset($count_args['number'], $count_args['offset']);
$count_args['count'] = true;
$total = (int) get_comments($count_args);
$top_level = get_comments($args);
// Fetch child comments for these parents
if (!empty($top_level)) {
$parent_ids = wp_list_pluck($top_level, 'comment_ID');
$children = get_comments([
'post_id' => $topic_id,
'status' => 'approve',
'parent__in' => $parent_ids,
'orderby' => 'comment_date_gmt',
'order' => 'ASC',
'number' => 200,
]);
} else {
$children = [];
}
// Float pinned top-level comments
$pinned = [];
$normal = [];
foreach ($top_level as $c) {
$topping = (int) get_comment_meta((int) $c->comment_ID, '_aksrt_comment_topping', true);
if ($topping > 0) {
$c->aksrt_topping = $topping;
$pinned[] = $c;
} else {
$normal[] = $c;
}
}
usort($pinned, function ($a, $b) {
return ($b->aksrt_topping ?? 0) <=> ($a->aksrt_topping ?? 0);
});
$comments = array_merge($pinned, $normal, $children);
return [
'comments' => $comments,
'total' => $total,
'max_pages' => max(1, (int) ceil($total / $per_page)),
'page' => $page,
'adopted_id' => $this->get_adopted_comment_id($topic_id),
];
}
// ── Set comment topping ─────────────────────────────────────────
/**
* Set comment topping level.
*
* @param int $comment_id Comment ID.
* @param int $level 0=unpin, 1=normal pin, 2=hot pin, 3=best answer pin.
*/
public function set_comment_topping(int $comment_id, int $level): bool {
$comment = get_comment($comment_id);
if (!$comment) {
return false;
}
if ($level <= 0) {
delete_comment_meta($comment_id, '_aksrt_comment_topping');
delete_comment_meta($comment_id, '_aksrt_comment_pinned');
} else {
update_comment_meta($comment_id, '_aksrt_comment_topping', $level);
update_comment_meta($comment_id, '_aksrt_comment_pinned', '1');
}
return true;
}
// ── Maybe mark hot comment ──────────────────────────────────────
/**
* Check and mark a comment as hot ("神评") if it crosses the like threshold.
*
* @return bool True if newly marked as hot.
*/
public function maybe_mark_hot_comment(int $comment_id): bool {
$comment = get_comment($comment_id);
if (!$comment || '1' !== (string) $comment->comment_approved) {
return false;
}
$post = get_post((int) $comment->comment_post_ID);
if (!$post || $post->post_type !== 'aksrt_forum_topic') {
return false;
}
$already_hot = '1' === (string) get_comment_meta($comment_id, '_aksrt_is_hot', true);
if ($already_hot) {
return false;
}
$config = $this->forum->get_config();
$threshold = max(1, (int) ($config['reply']['hot_threshold'] ?? 10));
$likes = max(0, (int) get_comment_meta($comment_id, 'aksrt_comment_likes', true));
if ($likes < $threshold) {
return false;
}
update_comment_meta($comment_id, '_aksrt_is_hot', '1');
// Credit the comment author
$author_id = (int) $comment->user_id;
if ($author_id > 0 && class_exists('AKSRT_Credits')) {
$amount = (int) ($config['credits']['comment_like'] ?? 1);
if ($amount > 0) {
\AKSRT_Credits::add($author_id, $amount, 'forum_hot_comment', [
'type' => 'comment',
'id' => $comment_id,
]);
}
}
// Notify the author
if ($author_id > 0) {
$this->forum->notify($author_id, 'system', sprintf(
__('你的回复成为了神评(帖子「%s」)', 'aksrt'),
get_the_title((int) $comment->comment_post_ID)
), [
'title' => __('恭喜获得神评', 'aksrt'),
'target_url' => get_permalink((int) $comment->comment_post_ID) . '#comment-' . $comment_id,
]);
}
return true;
}
// ── Is hot comment ──────────────────────────────────────────────
/**
* Check if a comment is a hot comment ("神评").
*/
public function is_hot_comment(int $comment_id): bool {
return '1' === (string) get_comment_meta($comment_id, '_aksrt_is_hot', true);
}
// ── Get commenter badge ─────────────────────────────────────────
/**
* Get the user's moderator tier label for badge rendering in comments.
*
* @return array{tier:int,label:string,class:string}|null
*/
public function get_commenter_badge(int $user_id, int $topic_id): ?array {
if ($user_id <= 0) {
return null;
}
if ($this->forum->is_forum_admin($user_id)) {
return [
'tier' => AKSRT_Forum::TIER_FORUM_ADMIN,
'label' => __('管理员', 'aksrt'),
'class' => 'aksrt-forum-reply__role-badge--admin',
];
}
$plate_id = $this->forum->get_topic_plate_id($topic_id);
if ($plate_id > 0) {
$tier = $this->forum->get_user_plate_tier($user_id, $plate_id);
if ($tier >= AKSRT_Forum::TIER_CAT_MOD) {
$names = $this->forum->get_config()['names'] ?? [];
return [
'tier' => $tier,
'label' => $names['cat_moderator'] ?? __('分区版主', 'aksrt'),
'class' => 'aksrt-forum-reply__role-badge--catmod',
];
}
if ($tier >= AKSRT_Forum::TIER_PLATE_AUTHOR) {
$names = $this->forum->get_config()['names'] ?? [];
return [
'tier' => $tier,
'label' => $names['plate_author'] ?? __('超级版主', 'aksrt'),
'class' => 'aksrt-forum-reply__role-badge--super',
];
}
if ($tier >= AKSRT_Forum::TIER_MODERATOR) {
$names = $this->forum->get_config()['names'] ?? [];
return [
'tier' => $tier,
'label' => $names['plate_moderator'] ?? __('版主', 'aksrt'),
'class' => 'aksrt-forum-reply__role-badge--mod',
];
}
}
return null;
}
}