Files
wordpress-theme-aksrt/src/Forum/Cards.php
T
kate522 ec5dfcd82f fix(forum): align forum pages with the reference layout
- cards: restore the shared card shell, add author follow/message actions,
  score group, last-reply meta and reference plate header actions
- home: desktop left tab rail, widget-only sidebar, reference load-more markup
- plate: 1-based tabs, reference defaults (全部/推荐/最新回复/热门/精华)
- post: fixed action rail, score footer, author card, h1 article title
- quick publish: plate selector moved into the submit row like the reference
2026-11-11 18:06:33 +08:00

336 lines
21 KiB
PHP

<?php
namespace Jio\Forum;
defined('ABSPATH') || exit;
final class Cards {
/** @var array<int, \WP_Comment> */
private static array $hot = [];
/** @var array<int, string> */
private static array $last_reply = [];
/** Prime authors, plates, hot replies and last replies for a page of posts in batch queries. */
public static function prime(array $posts): void {
$ids = $authors = $plates = [];
foreach ($posts as $post) {
if (!$post instanceof \WP_Post) continue;
$ids[] = $post->ID;
$authors[] = (int) $post->post_author;
$plates[] = (int) get_post_meta($post->ID, '_jio_forum_plate_id', true);
}
if (!$ids) return;
cache_users(array_values(array_unique(array_filter($authors))));
_prime_post_caches(array_values(array_unique(array_filter($plates))), true, true);
$wanted = array_flip($ids);
$comments = get_comments(['status' => 'approve', 'meta_key' => '_jio_forum_hot', 'meta_value' => '1', 'number' => 200, 'orderby' => 'comment_date_gmt', 'order' => 'DESC']);
foreach ($comments as $comment) {
$post_id = (int) $comment->comment_post_ID;
if (isset($wanted[$post_id]) && !isset(self::$hot[$post_id])) self::$hot[$post_id] = $comment;
}
self::prime_last_replies($ids);
}
/** @param int[] $ids */
private static function prime_last_replies(array $ids): void {
$missing = array_values(array_diff(array_unique(array_map('absint', $ids)), array_keys(self::$last_reply)));
if (!$missing) return;
global $wpdb;
$placeholders = implode(',', array_fill(0, count($missing), '%d'));
$sql = "SELECT comment_post_ID AS post_id, MAX(comment_date) AS last_date FROM {$wpdb->comments} WHERE comment_approved = '1' AND comment_post_ID IN ($placeholders) GROUP BY comment_post_ID";
$rows = $wpdb->get_results($wpdb->prepare($sql, $missing)); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
foreach ((array) $rows as $row) {
self::$last_reply[(int) $row->post_id] = (string) $row->last_date;
}
}
public static function post_detail(\WP_Post $post, string $class = 'alone ajax-item', bool $show_plate = true, bool $show_topping = false): string {
$id = $post->ID;
$can_view = Access::can_view(get_current_user_id(), $id);
$plate_id = (int) get_post_meta($id, '_jio_forum_plate_id', true);
$plate = $show_plate ? self::plate_button($plate_id) : '';
$html = '<article class="forum-posts detail ' . esc_attr($class) . '">';
$html .= self::status_badge($post);
$html .= self::author_box($post);
$html .= $can_view ? self::cover($post) : '';
$html .= '<div>' . self::title($post, 'forum-title flex ac', '', $show_topping) . '</div>';
$html .= self::content($post, $can_view);
$html .= '<div class="flex ac jsb mt10">' . ($plate !== '' ? $plate : '<span class="plate-null"></span>') . self::action_meta($post) . '</div>';
return $html . '</article>';
}
public static function post_mini(\WP_Post $post, string $class = 'alone ajax-item', bool $show_topping = false): string {
$author = (int) $post->post_author;
$name = self::author_link($author);
$html = '<article class="forum-posts mini ' . esc_attr($class) . '">';
$html .= self::status_badge($post);
$html .= '<div class="mr20 forum-user">' . self::avatar($author, 'avatar-img forum-avatar') . '<span class="show-sm ml6 flex ac" style="width: 90%;">' . $name . '</span></div>';
$html .= '<div class="entry-info">' . self::title($post, 'forum-title flex ac', 'text-ellipsis', $show_topping);
$html .= '<div class="flex ac jsb item-meta"><div class="meta-left em09-sm flex"><span class="hide-sm">' . $name . '</span>';
$html .= self::reply_time($post) . '</div>';
$html .= '<div class="meta-right">' . self::icon_metas($post) . '</div></div></div>';
return $html . '</article>';
}
/** Reference mini-card meta: "最后回复" when replies exist, otherwise the publish time. */
private static function reply_time(\WP_Post $post): string {
$last = self::$last_reply[$post->ID] ?? '';
if ($last !== '') {
return '<span class="icon-circle" title="' . esc_attr(sprintf('最后回复:%s', $last)) . '">' . esc_html(self::ago($last) . '回复') . '</span>';
}
return '<span class="icon-circle" title="' . esc_attr(sprintf('发布时间:%s', get_the_date('Y-m-d H:i:s', $post))) . '">' . esc_html(self::ago($post->post_date) . '发布') . '</span>';
}
public static function title(\WP_Post $post, string $class, string $link_class = '', bool $show_topping = false, string $tag = 'h2'): string {
$id = $post->ID;
$badges = '';
if ($show_topping && get_post_meta($id, '_jio_forum_pinned', true)) $badges .= '<span class="badg badg-sm c-blue mr3">置顶</span>';
if (get_post_meta($id, '_jio_forum_essence', true)) $badges .= '<span class="badg badg-sm jb-red mr3">精华</span>';
$type = (string) get_post_meta($id, '_jio_forum_type', true);
if ($type === 'question') {
$solved = (bool) get_post_meta($id, '_jio_forum_adopted_comment', true);
$badges .= '<span class="badg badg-sm badge-question mr3 ' . ($solved ? 'c-green' : 'c-yellow') . '">问<text>' . ($solved ? '已解决' : '待解决') . '</text></span>';
} elseif ($type === 'vote') {
$badges .= '<span class="badg badg-sm c-blue mr3">投票</span>';
}
$url = $post->post_status === 'publish' ? get_permalink($post) : Core::edit_url($id);
$link_class = $link_class !== '' ? ' class="' . esc_attr($link_class) . '"' : '';
$tag = in_array($tag, ['h1', 'h2'], true) ? $tag : 'h2';
return '<' . $tag . ' class="' . esc_attr($class) . '"><a' . $link_class . ' href="' . esc_url($url) . '" title="' . esc_attr($post->post_title) . '">' . $badges . esc_html($post->post_title) . '</a></' . $tag . '>';
}
/** Plate page header: blurred cover, title, stats and moderators (reference layout). */
public static function plate_header(\WP_Post $plate): string {
$id = $plate->ID;
PlateStats::prime([$id]);
$stats = PlateStats::get($id);
$followers = (int) get_post_meta($id, '_jio_forum_follow_count', true);
$plate_name = (string) jio_get_setting('jio_forum_plate_name', '版块') ?: '版块';
$thumb = self::thumbnail($plate);
$cats = '';
$terms = wp_get_post_terms($id, Core::CATEGORY);
foreach (is_array($terms) ? $terms : [] as $term) {
$cats .= '<a class="badg badg-sm b-theme" href="' . esc_url(get_term_link($term)) . '">' . esc_html($term->name) . '</a>';
}
$meta = '<div class="px12-sm mt10"><item class="mate-posts mr10">' . esc_html(sprintf('%s %s', $plate_name, jio_cut_count($stats['posts']))) . '</item>'
. '<item class="mate-reply mr10">' . esc_html(sprintf('今日 %s', jio_cut_count($stats['today']))) . '</item>'
. ($followers > 0
? '<item class="mate-follow mr10">' . esc_html(sprintf('关注 %s', jio_cut_count($followers))) . '</item>'
: '') . '</div>';
$excerpt = wp_trim_words(wp_strip_all_tags($plate->post_excerpt ?: $plate->post_content), 40);
$html = '<div class="plate-header-wrap"><div class="forum-header blur-header relative-h mb20">';
$html .= '<div class="abs-blur-bg">' . $thumb . '</div><div class="absolute forum-mask"></div>';
$html .= self::plate_more_btns($plate);
if (get_post_meta($id, '_jio_forum_hot', true)) {
$html .= '<span class="img-badge top jb-red px12-sm">热门</span>';
}
$html .= '<div class="relative header-content"><div class="flex ac"><div class="item-info flex1">';
if ($cats !== '') $html .= '<div class="mb6">' . $cats . '</div>';
$html .= '<h1 class="forum-title em14"><a href="' . esc_url(get_permalink($plate)) . '">' . esc_html($plate->post_title) . '</a></h1>';
$html .= $meta;
if ($excerpt !== '') $html .= '<div class="desc px12-sm mt6">' . esc_html($excerpt) . '</div>';
$html .= '</div><div class="plate-thumb flex0 ml10">' . $thumb . '</div></div>';
$html .= self::plate_moderator_btns($plate);
return $html . '</div></div></div>';
}
/** Header corner actions: follow, search and share (reference `.more-btns`). */
private static function plate_more_btns(\WP_Post $plate): string {
$html = '<div class="flex ac more-btns">' . self::follow_button($plate->ID, '');
if (jio_get_setting('jio_forum_search_enabled', true)) {
$html .= '<a class="main-search-btn item" href="' . esc_url(add_query_arg(['type' => 'plate', 's' => $plate->post_title], home_url('/'))) . '" title="搜索"><i class="fa fas fa-search"></i></a>';
}
if (function_exists('jio_get_post_share_btn')) {
$html .= jio_get_post_share_btn($plate, 'item btn-share', true, true);
}
return $html . '</div>';
}
/** Plate moderator avatars row under the header. */
private static function plate_moderator_btns(\WP_Post $plate): string {
$ids = array_values(array_unique(array_filter(array_merge(
[(int) $plate->post_author],
array_map('absint', (array) get_post_meta($plate->ID, '_jio_forum_moderators', true))
))));
$btns = '';
foreach ($ids as $user_id) {
$user = get_userdata($user_id);
if (!$user) continue;
$btns .= '<a class="but c-white mr6 mt6" href="' . esc_url(get_author_posts_url($user_id)) . '" title="' . esc_attr($user->display_name) . '"><span class="avatar-mini mr6">' . get_avatar($user_id, 40) . '</span>' . esc_html($user->display_name) . '</a>';
}
if (is_user_logged_in() && Access::can_publish(get_current_user_id(), $plate->ID)) {
$btns .= '<a rel="nofollow" class="but c-white mr6 mt6" href="' . esc_url(add_query_arg('plate', $plate->ID, Core::edit_url())) . '"><i class="fa fas fa-plus mr6"></i>发布</a>';
}
$html = '<div class="mt10 moderator-btns em09">' . $btns;
if (is_user_logged_in() && !Access::is_moderator(get_current_user_id(), $plate->ID) && jio_get_setting('jio_forum_moderator_apply', true)) {
$html .= '<button type="button" class="but hollow c-white mt6" data-forum-action="apply" data-id="' . (int) $plate->ID . '">申请版主</button>';
}
return $html . '</div>';
}
public static function plate_item(\WP_Post $plate): string {
$url = get_permalink($plate);
$counts = PlateStats::get($plate->ID);
$today = $counts['today'] > 0 ? '<span class="badg but-plate hide-sm flex0" title="' . esc_attr(sprintf('今日新帖 %d', $counts['today'])) . '"><i class="fa fas fa-comment-dots c-red mr3"></i>' . esc_html(sprintf('今日:%s', jio_cut_count($counts['today']))) . '</span>' : '';
$excerpt = wp_trim_words(wp_strip_all_tags($plate->post_excerpt ?: $plate->post_content), 30);
$html = '<div class="plate-item flex">';
if (jio_get_setting('jio_forum_show_plate_cover', true)) {
$html .= '<a class="plate-thumb" href="' . esc_url($url) . '">' . self::thumbnail($plate) . '</a>';
}
$html .= '<div class="item-info flex1"><div class="info-header"><h2 class="forum-title"><a class="flex ac" href="' . esc_url($url) . '" title="' . esc_attr($plate->post_title) . '"><span class="mr6 text-ellipsis">' . esc_html($plate->post_title) . '</span>' . $today . '</a></h2></div>';
$html .= '<a class="excerpt em09" href="' . esc_url($url) . '">' . esc_html($excerpt) . '</a>';
$html .= '<div class="count-mates px12"><item><text>帖子</text>' . esc_html(jio_cut_count($counts['posts'])) . '</item><item class="ml10"><text>关注</text>' . esc_html(jio_cut_count((int) get_post_meta($plate->ID, '_jio_forum_follow_count', true))) . '</item></div>';
return $html . '</div></div>';
}
public static function plate_card(\WP_Post $plate, string $class = '', bool $follow = true, bool $today = false): string {
$url = get_permalink($plate);
$counts = PlateStats::get($plate->ID);
$spot = $today && $counts['today'] > 0 ? '<badge class="spot abs-tr" title="' . esc_attr(sprintf('今日有%d篇新帖', $counts['today'])) . '"></badge>' : '';
$html = '<div class="plate-card' . ($class !== '' ? ' ' . esc_attr($class) : '') . '"><a title="' . esc_attr($plate->post_title) . '" href="' . esc_url($url) . '">';
$html .= '<div class="plate-thumb">' . self::thumbnail($plate) . $spot . '</div>';
$html .= '<div class="title text-ellipsis mt10">' . esc_html($plate->post_title) . '</div>';
$html .= '<div class="mt3 px12 muted-2-color text-ellipsis count">' . esc_html(sprintf('%s关注', jio_cut_count((int) get_post_meta($plate->ID, '_jio_forum_follow_count', true)))) . '</div></a>';
if ($follow) $html .= self::follow_button($plate->ID, 'but jb-pink but-plate btn-block mt20');
return $html . '</div>';
}
/** @param \WP_Post[] $plates */
public static function plate_slide(array $plates, string $class = '', bool $follow = true, bool $today = false): string {
if (!$plates) return '';
$slides = '';
foreach ($plates as $plate) {
$slides .= '<div class="swiper-slide">' . self::plate_card($plate, $class, $follow, $today) . '</div>';
}
return '<div class="swiper-container swiper-scroll scroll-plate"><div class="swiper-wrapper">' . $slides . '</div><div class="swiper-button-prev"></div><div class="swiper-button-next"></div></div>';
}
public static function follow_button(int $plate_id, string $class): string {
if (!is_user_logged_in()) {
return '<a class="btn-follow ' . esc_attr($class) . '" href="' . esc_url(jio_get_login_url(get_permalink($plate_id))) . '"><text><i class="fa far fa-heart mr6 em12"></i></text></a>';
}
$followed = Access::follows(get_current_user_id(), $plate_id);
return '<button type="button" class="btn-follow ' . esc_attr($class . ($followed ? ' active' : '')) . '" data-forum-action="follow" data-id="' . (int) $plate_id . '"><text><i class="fa ' . ($followed ? 'fas' : 'far') . ' fa-heart mr6 em12"></i></text></button>';
}
public static function plate_button(int $plate_id): string {
if ($plate_id <= 0 || get_queried_object_id() === $plate_id || get_post_status($plate_id) !== 'publish') return '';
return '<a class="but but-plate text-ellipsis" href="' . esc_url(get_permalink($plate_id)) . '"><i class="fas fa-cube mr3"></i>' . esc_html(get_the_title($plate_id)) . '<i class="fas fa-angle-right ml6"></i></a>';
}
public static function avatar(int $user_id, string $class): string {
return function_exists('jio_get_avatar_box') ? jio_get_avatar_box($user_id, $class) : '<span class="' . esc_attr($class) . '">' . get_avatar($user_id, 80) . '</span>';
}
public static function ago(string $date): string {
return function_exists('jio_time_ago') ? jio_time_ago($date) : human_time_diff((int) strtotime($date), (int) current_time('timestamp')) . '前';
}
private static function author_link(int $user_id): string {
$url = function_exists('jio_get_n') ? jio_get_n($user_id) : get_author_posts_url($user_id);
return '<a class="display-name text-ellipsis" href="' . esc_url($url) . '">' . esc_html((string) get_the_author_meta('display_name', $user_id)) . '</a>';
}
/** Reference author row: avatar, name, publish time + views, follow and message actions. */
private static function author_box(\WP_Post $post): string {
$author = (int) $post->post_author;
$name = function_exists('jio_get_user_name') ? jio_get_user_name(['id' => $author, 'class' => 'flex ac flex1']) : self::author_link($author);
$views = jio_cut_count((int) get_post_meta($post->ID, '_jio_forum_views', true));
$desc = '<span title="' . esc_attr(get_the_date('Y-m-d H:i:s', $post)) . '">' . esc_html(self::ago($post->post_date) . '发布') . '</span><span class="icon-spot">' . esc_html(sprintf('%s次阅读', $views)) . '</span>';
$actions = '';
if (function_exists('jio_get_user_follow')) {
$actions .= jio_get_user_follow('px12-sm ml10 follow but c-red', $author);
}
if (function_exists('jio_get_messages_page_url')) {
$messages = jio_get_messages_page_url();
if ($messages !== '' && get_current_user_id() !== $author) {
$actions .= '<a class="ml6 but c-blue px12-sm" href="' . esc_url($messages) . '"><i class="fa far fa-envelope mr3"></i>私信</a>';
}
}
$html = '<div class="user-info flex ac mb10">' . self::avatar($author, 'avatar-img') . '<div class="user-right flex flex1 ac jsb ml10"><div class="flex1 overflow-hidden"><div class="flex ac">' . $name . '</div><div class="px12-sm muted-2-color text-ellipsis">' . $desc . '</div></div>';
if ($actions !== '') $html .= '<div class="flex0 user-action">' . $actions . '</div>';
return $html . '</div></div>';
}
private static function status_badge(\WP_Post $post): string {
$labels = ['pending' => '待审核', 'draft' => '草稿', 'trash' => '已删除'];
return isset($labels[$post->post_status]) ? '<span class="img-badge badge-status top jb-yellow px12">' . esc_html($labels[$post->post_status]) . '</span>' : '';
}
private static function cover(\WP_Post $post): string {
if (!has_post_thumbnail($post)) return '';
return '<div class="forum-lists-cover mb10"><div class="graphic imgbox-container" style="padding-bottom:45%">' . get_the_post_thumbnail($post, 'large', ['class' => 'fit-cover']) . '</div></div>';
}
private static function content(\WP_Post $post, bool $can_view): string {
if (!$can_view) {
return '<div class="hide-post mt6"><i class="fa fas fa-unlock-alt mr6"></i>此处内容已隐藏</div>';
}
$url = $post->post_status === 'publish' ? get_permalink($post) : Core::edit_url($post->ID);
$images = self::images($post);
$text = trim((string) preg_replace('/\s+/u', ' ', wp_strip_all_tags(strip_shortcodes($post->post_excerpt ?: $post->post_content))));
$text = jio_str_cut($text, 0, $images !== '' ? 60 : 120);
$html = '<div><div class="content mt6">' . ($text !== '' ? '<a href="' . esc_url($url) . '" class="excerpt">' . esc_html($text) . '</a>' : '') . '</div>';
$html .= $images;
$hot = self::$hot[$post->ID] ?? null;
if ($hot instanceof \WP_Comment) {
$html .= '<div class="hot-comment mt10"><div class="flex ac mb6">' . self::avatar((int) $hot->user_id, 'avatar-img') . '<span class="muted-color em09">' . esc_html(get_comment_author($hot)) . '</span><span class="badg badg-sm c-red ml6">热门回复</span></div>';
$html .= '<div class="comment-content">' . esc_html(wp_trim_words(wp_strip_all_tags($hot->comment_content), 40)) . '</div></div>';
}
return $html . '</div>';
}
private static function images(\WP_Post $post): string {
if (!preg_match_all('/<img[^>]+src=["\']([^"\']+)["\']/i', $post->post_content, $matches)) return '';
$urls = array_values(array_unique(array_filter(array_map('esc_url_raw', $matches[1]))));
$total = count($urls);
if ($total === 0) return '';
$shown = min(4, $total);
$html = '<div class="imgbox-container lists-imgs mt6 count-' . $shown . '">';
foreach (array_slice($urls, 0, $shown) as $index => $url) {
$more = $index === $shown - 1 && $total > $shown ? '<span class="abs-center lists-imgs-more">+' . ($total - $shown) . '</span>' : '';
$html .= '<span><img' . jio_get_lazy_attr('lazy_posts_content', $url, 'fit-cover radius4') . ' alt="' . esc_attr($post->post_title) . '">' . $more . '</span>';
}
return $html . '</div>';
}
/** Reference action row: score up/down group, reply count and share. */
private static function action_meta(\WP_Post $post): string {
$id = $post->ID;
$html = '<div class="action-meta flex0 flex jsb">';
if (jio_get_setting('jio_forum_score_enabled', true)) {
$user_id = get_current_user_id();
$user_score = $user_id > 0 ? (int) get_user_meta($user_id, '_jio_forum_score_' . $id, true) : 0;
$score = (int) get_post_meta($id, '_jio_forum_score', true);
$html .= '<div class="score-btns">' . self::score_button($post, 'up', 'extra', $user_score > 0) . '<text>' . esc_html($score ? jio_cut_count($score) : '评分') . '</text>' . self::score_button($post, 'down', 'deduct', $user_score < 0) . '</div>';
}
$reply = (int) $post->comment_count;
$html .= '<a rel="nofollow" href="' . esc_url(get_permalink($post) . '#comments') . '" class="item"><i class="fa far fa-comment"></i><text>' . esc_html($reply ? jio_cut_count($reply) : '回复') . '</text></a>';
if ($post->post_status === 'publish' && function_exists('jio_get_post_share_btn')) {
$html .= jio_get_post_share_btn($post, 'item btn-share', true, true);
}
return $html . '</div>';
}
private static function score_button(\WP_Post $post, string $direction, string $kind, bool $active): string {
$icon = '<i class="fa fas fa-' . ($direction === 'up' ? 'plus' : 'minus') . '-circle"></i>';
$class = 'btn-score ' . $kind . ($active ? ' active' : '');
if (!is_user_logged_in()) {
return '<a href="' . esc_url(jio_get_login_url(get_permalink($post))) . '" class="' . esc_attr($class . ' signin-loader') . '" data-id="' . (int) $post->ID . '">' . $icon . '</a>';
}
return '<button type="button" class="' . esc_attr($class) . '" data-forum-action="score" data-direction="' . esc_attr($direction) . '" data-id="' . (int) $post->ID . '">' . $icon . '</button>';
}
private static function icon_metas(\WP_Post $post): string {
$id = $post->ID;
return '<item><i class="fa far fa-eye mr3"></i>' . esc_html(jio_cut_count((int) get_post_meta($id, '_jio_forum_views', true))) . '</item>'
. '<item><i class="fa far fa-comment mr3"></i>' . esc_html(jio_cut_count((int) $post->comment_count)) . '</item>'
. '<item><i class="fa far fa-thumbs-up mr3"></i>' . esc_html(jio_cut_count((int) get_post_meta($id, '_jio_forum_score', true))) . '</item>';
}
private static function thumbnail(\WP_Post $plate): string {
$src = get_the_post_thumbnail_url($plate, 'thumbnail') ?: get_template_directory_uri() . '/assets/img/thumbnail.svg';
return '<img' . jio_get_lazy_attr('lazy_other', (string) $src, 'forum-thumbnail fit-cover') . ' alt="' . esc_attr($plate->post_title) . '">';
}
}