From eceddb5f7d3ddcc737d8c7cfb9cb813df82f6ff0 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 11 Nov 2026 15:47:13 +0800 Subject: [PATCH] feat(forum): add the reference forum widgets Port the six forum widgets from the reference theme: posts list, multi-tab posts, plate list (default and card), plate info, moderator list and topic list. They join the schema widget catalog under a forum group that stays hidden while the forum module is off, reuse the shared forum cards, paginate through the widget REST client contract, and pull the forum stylesheet/runtime onto any sidebar that hosts them. Co-Authored-By: Claude Fable 5 --- assets/css/routes/forum.css | 26 ++++ assets/js/forum-runtime.js | 4 +- src/Forum/Core.php | 50 ++++++- src/Forum/WidgetQuery.php | 189 ++++++++++++++++++++++++++ src/Forum/WidgetRenderer.php | 177 ++++++++++++++++++++++++ src/Forum/WidgetSchemas.php | 146 ++++++++++++++++++++ src/WidgetSystem/FrontendRenderer.php | 15 +- src/WidgetSystem/RendererRegistry.php | 5 +- src/WidgetSystem/RestController.php | 4 +- src/WidgetSystem/WidgetCatalog.php | 33 ++++- tests/forum/widgets-contract.test.cjs | 86 ++++++++++++ tools/admin-contract-report.json | 2 +- 12 files changed, 724 insertions(+), 13 deletions(-) create mode 100644 src/Forum/WidgetQuery.php create mode 100644 src/Forum/WidgetRenderer.php create mode 100644 src/Forum/WidgetSchemas.php create mode 100644 tests/forum/widgets-contract.test.cjs diff --git a/assets/css/routes/forum.css b/assets/css/routes/forum.css index 27c8a174..1ec38f28 100644 --- a/assets/css/routes/forum.css +++ b/assets/css/routes/forum.css @@ -256,3 +256,29 @@ body.dark-theme .jio-forum__dialog{background:var(--main-bg-color);color:var(--k body.dark-theme .jio-forum__dialog input,body.dark-theme .jio-forum__dialog select,body.dark-theme .jio-forum__dialog textarea{background:var(--main-bg-color);color:var(--key-color);border-color:var(--muted-4-color)} body.dark-theme #forum .jio-forum__action{color:var(--forum-muted)} body.dark-theme .panel.panel-plate,body.dark-theme .plate-lists .plate-item,body.dark-theme .forum-posts.alone,body.dark-theme .scroll-plate .plate-card{background:var(--main-bg-color)} + +/* ── 论坛小工具(任意侧栏 / 全宽区域) ─────────────────────────── */ +.forum-header{box-shadow:0 0 10px var(--main-shadow);border-radius:var(--main-radius);position:relative;background:var(--main-bg-color)} +.forum-header .header-content{padding:70px 50px 50px;background:var(--main-bg-color);border-radius:var(--main-radius)} +.forum-header .plate-thumb{width:115px;height:115px;margin-left:auto;margin-right:auto} +.forum-header .plate-thumb > img{border-radius:4px} +.forum-header .count-mates{margin-top:10px} +.forum-header .count-mates item{display:inline-block} +.forum-header .count-mates > item + item{border-left:1px solid var(--main-border-color);padding-left:20px;margin-left:20px} +.forum-header .img-badge.top{right:auto;left:1.2em} +.plate-info-sidebar .header-content{padding:55px 20px 25px} +.plate-info-sidebar .follow-info{background:var(--main-border-color);box-shadow:none;color:var(--focus-color);padding:6px 12px;margin-top:38px;opacity:.9} +.jio-widget-box.moderator-box{padding:15px} +.moderator-box .user-info + .user-info{border-top:1px solid var(--main-border-color)} +.moderator-box .avatar-img img{width:100%;height:100%;border-radius:100px} +.topic-item{transition:.2s;border-radius:var(--main-radius)} +.topic-item:hover{background:var(--muted-border-color)} +.topic-item .term-title{font-size:15px;font-weight:600} +.topic-item .desc span{margin-right:10px} +.jio-widget-box .widget-ajaxpager .forum-posts + .forum-posts{border-top:1px solid var(--main-border-color)} +@media (max-width:640px){ + .forum-header .header-content{padding:60px 15px 30px} + .forum-header .plate-thumb{width:105px;height:105px} + .forum-header .count-mates > item + item{padding-left:15px;margin-left:15px} +} +body.dark-theme .forum-header,body.dark-theme .forum-header .header-content{background:var(--main-bg-color)} diff --git a/assets/js/forum-runtime.js b/assets/js/forum-runtime.js index df947eff..37b33b26 100644 --- a/assets/js/forum-runtime.js +++ b/assets/js/forum-runtime.js @@ -1,6 +1,8 @@ (() => { const config = window.jioForum || {} - const root = document.getElementById('forum') + // 论坛路由根节点是 #forum;论坛小工具放在任意侧栏时没有该节点, + // 此时把委托挂到 body,让 follow / apply 等小工具按钮共用同一套动作。 + const root = document.getElementById('forum') || document.body if (!root) return const send = async (path, method = 'GET', data = {}, idempotencyKey = '') => { diff --git a/src/Forum/Core.php b/src/Forum/Core.php index f17e6273..dc02d5f8 100644 --- a/src/Forum/Core.php +++ b/src/Forum/Core.php @@ -23,6 +23,7 @@ final class Core { add_action('admin_init', [self::class, 'ensure_home']); add_action('init', [self::class, 'ensure_home'], 20); add_action('wp_enqueue_scripts', [self::class, 'assets'], 30); + add_action('wp_enqueue_scripts', [self::class, 'widget_assets'], 31); add_action('widgets_init', static function (): void { register_widget(Widget::class); }, 11); add_action('wp_loaded', [self::class, 'maybe_flush_routes'], 30); add_action('trashed_post', [self::class, 'cascade_trashed_plate']); @@ -56,6 +57,20 @@ final class Core { return home_url('/' . self::slug('edit') . '/' . ($id > 0 ? $id . '/' : '')); } + /** Plate of the current request: the plate itself or the plate owning the current post/term. */ + public static function current_plate_id(): int { + $obj = get_queried_object(); + if ($obj instanceof \WP_Post) { + if ($obj->post_type === self::PLATE) return (int) $obj->ID; + if ($obj->post_type === self::POST) return (int) get_post_meta($obj->ID, '_jio_forum_plate_id', true); + } + if ($obj instanceof \WP_Term && in_array($obj->taxonomy, [self::TOPIC, self::TAG], true)) { + $post = get_posts(['post_type' => self::POST, 'post_status' => 'publish', 'posts_per_page' => 1, 'fields' => 'ids', 'tax_query' => [['taxonomy' => $obj->taxonomy, 'terms' => [$obj->term_id]]]]); + if ($post) return (int) get_post_meta((int) $post[0], '_jio_forum_plate_id', true); + } + return 0; + } + public static function register(): void { if (!self::enabled()) { return; @@ -199,9 +214,8 @@ final class Core { if (!self::enabled() || !(is_singular([self::POST, self::PLATE]) || is_tax([self::CATEGORY, self::TOPIC, self::TAG]) || is_page_template('templates/pages/page-forums.php') || $account_route || get_query_var('jio_forum_edit') || (is_search() && in_array(sanitize_key((string) ($_GET['type'] ?? '')), ['forum', 'plate'], true)))) { return; } - $css = get_theme_file_path('assets/css/routes/forum.css'); $js = get_theme_file_path('assets/js/forum-runtime.js'); - wp_enqueue_style('jio-forum', get_theme_file_uri('assets/css/routes/forum.css'), ['jio-public'], (string) filemtime($css)); + self::enqueue_css(); wp_enqueue_script('jio-forum', get_theme_file_uri('assets/js/forum-runtime.js'), [], (string) filemtime($js), true); wp_enqueue_script('jio-qrcode'); $plates = get_posts(['post_type' => self::PLATE, 'post_status' => 'publish', 'posts_per_page' => 100, 'fields' => 'ids']); @@ -213,4 +227,36 @@ final class Core { 'plates' => array_map(static fn(int $id): array => ['id' => $id, 'title' => get_the_title($id)], $plates), ]) . ';', 'before'); } + + /** Forum card styles are shared by the forum routes and the forum widgets. */ + public static function enqueue_css(): void { + $css = get_theme_file_path('assets/css/routes/forum.css'); + wp_enqueue_style('jio-forum', get_theme_file_uri('assets/css/routes/forum.css'), ['jio-public'], is_file($css) ? (string) filemtime($css) : null); + } + + /** A forum widget on any sidebar needs the card styles and the follow/apply runtime. */ + public static function widget_assets(): void { + if (!self::enabled() || !self::has_widgets()) { + return; + } + self::enqueue_css(); + $js = get_theme_file_path('assets/js/forum-runtime.js'); + wp_enqueue_script('jio-forum', get_theme_file_uri('assets/js/forum-runtime.js'), [], is_file($js) ? (string) filemtime($js) : null, true); + wp_add_inline_script('jio-forum', 'window.jioForum=' . wp_json_encode([ + 'api' => esc_url_raw(rest_url('jio/v1/forum/')), + 'nonce' => wp_create_nonce('wp_rest'), + 'login' => function_exists('jio_get_template_url') ? jio_get_template_url('sign') : wp_login_url(), + 'orders' => function_exists('jio_get_user_center_url') ? jio_get_user_center_url('orders') : home_url('/user/orders/'), + 'plates' => [], + ]) . ';', 'before'); + } + + private static function has_widgets(): bool { + foreach (\Jio\Forum\WidgetSchemas::SLUGS as $slug) { + if (is_active_widget(false, false, 'jio_widget_' . $slug, true)) { + return true; + } + } + return false; + } } diff --git a/src/Forum/WidgetQuery.php b/src/Forum/WidgetQuery.php new file mode 100644 index 00000000..3ca48e18 --- /dev/null +++ b/src/Forum/WidgetQuery.php @@ -0,0 +1,189 @@ + Core::POST, + 'post_status' => 'publish', + 'posts_per_page' => $count, + 'paged' => max(1, $page), + 'ignore_sticky_posts' => true, + ]; + $plate = self::current_plate($instance); + if ($plate > 0) { + $args['meta_query'][] = ['key' => '_jio_forum_plate_id', 'value' => $plate]; + } else { + $include = self::ids($instance['include_plate'] ?? ''); + $exclude = self::ids($instance['exclude_plate'] ?? ''); + if ($include) $args['meta_query'][] = ['key' => '_jio_forum_plate_id', 'value' => $include, 'compare' => 'IN']; + if ($exclude) $args['meta_query'][] = ['key' => '_jio_forum_plate_id', 'value' => $exclude, 'compare' => 'NOT IN']; + } + $tax = []; + foreach ([Core::TOPIC => 'include_topic', Core::TAG => 'include_tag'] as $taxonomy => $key) { + $ids = self::ids($instance[$key] ?? ''); + if ($ids) $tax[] = ['taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => $ids]; + } + if ($tax) $args['tax_query'] = $tax; + + $types = array_values(array_filter(array_map('sanitize_key', (array) ($instance['bbs_type'] ?? [])))); + if ($types) { + $args['meta_query'][] = ['key' => '_jio_forum_type', 'value' => $types, 'compare' => 'IN']; + } + $filters = array_values(array_filter(array_map('sanitize_key', (array) ($instance['filter'] ?? [])))); + if (in_array('essence', $filters, true)) $args['meta_query'][] = ['key' => '_jio_forum_essence', 'value' => 1]; + if (in_array('topping', $filters, true)) $args['meta_query'][] = ['key' => '_jio_forum_pinned', 'value' => 1]; + if (in_array('vote', $filters, true)) $args['meta_query'][] = ['key' => '_jio_forum_type', 'value' => 'vote']; + if (in_array('question_status', $filters, true)) $args['meta_query'][] = ['key' => '_jio_forum_adopted_comment', 'value' => '', 'compare' => '!=']; + + $orderby = sanitize_key((string) ($instance['orderby'] ?? 'date')); + switch ($orderby) { + case 'views': + case 'score': + case 'last_reply': + $args['jio_forum_list'] = $orderby; + break; + case 'comment_count': + $args['orderby'] = 'comment_count'; + break; + case 'favorite_count': + $args['meta_key'] = '_jio_forum_favorite_count'; + $args['orderby'] = 'meta_value_num'; + break; + case 'name': + $args['orderby'] = 'title'; + $args['order'] = 'ASC'; + break; + case 'rand': + $args['orderby'] = 'rand'; + break; + case 'modified': + $args['orderby'] = 'modified'; + break; + default: + $args['orderby'] = 'date'; + } + return new \WP_Query($args); + } + + /** @return \WP_Post[] */ + public static function plates(array $instance): array { + $count = max(1, min(20, (int) ($instance['count'] ?? 6))); + $args = ['post_type' => Core::PLATE, 'post_status' => 'publish', 'posts_per_page' => $count, 'no_found_rows' => true]; + $cats = array_values(array_filter(array_map('absint', (array) ($instance['category'] ?? [])))); + if ($cats) { + $args['tax_query'] = [['taxonomy' => Core::CATEGORY, 'field' => 'term_id', 'terms' => $cats, 'include_children' => true]]; + } + switch (sanitize_key((string) ($instance['orderby'] ?? 'modified'))) { + case 'name': + $args['orderby'] = 'title'; + $args['order'] = 'ASC'; + break; + case 'date': + $args['orderby'] = 'date'; + break; + case 'follow_count': + $args['meta_key'] = '_jio_forum_follow_count'; + $args['orderby'] = 'meta_value_num'; + break; + case 'posts_count': + case 'today_count': + // Resolved from the batched stats table after the query. + $args['orderby'] = 'modified'; + $args['posts_per_page'] = -1; + break; + case 'rand': + $args['orderby'] = 'rand'; + break; + default: + $args['orderby'] = 'modified'; + } + $plates = get_posts($args); + PlateStats::prime(wp_list_pluck($plates, 'ID')); + $by_stat = sanitize_key((string) ($instance['orderby'] ?? '')); + if (in_array($by_stat, ['posts_count', 'today_count'], true)) { + $key = $by_stat === 'posts_count' ? 'posts' : 'today'; + usort($plates, static fn(\WP_Post $a, \WP_Post $b): int => PlateStats::get($b->ID)[$key] <=> PlateStats::get($a->ID)[$key]); + $plates = array_slice($plates, 0, $count); + } + return $plates; + } + + /** @return \WP_Term[] */ + public static function topics(array $instance): array { + $count = max(0, (int) ($instance['count'] ?? 6)); + $orderby = sanitize_key((string) ($instance['orderby'] ?? 'count')); + $include = array_values(array_filter(array_map('absint', (array) ($instance['include'] ?? [])))); + $args = [ + 'taxonomy' => Core::TOPIC, + 'hide_empty' => !empty($instance['hide_empty']), + 'number' => $count, + 'orderby' => $orderby === 'include' ? 'include' : ($orderby === 'name' ? 'name' : 'count'), + 'order' => $orderby === 'name' ? 'ASC' : 'DESC', + ]; + if ($include) { + $args['include'] = $include; + if ($orderby === 'include') $args['orderby'] = 'include'; + } + $terms = get_terms($args); + return is_array($terms) ? $terms : []; + } + + /** AJAX payload for the paginated forum lists. */ + public static function payload(string $slug, array $instance, int $page = 1, string $segment = ''): array { + if ($slug === 'forum_tab_posts') { + $tabs = array_values((array) ($instance['tabs'] ?? [])); + $index = ctype_digit($segment) ? (int) $segment : 0; + $tab = $tabs[$index] ?? ($tabs[0] ?? []); + if (is_array($tab)) { + $instance = array_merge($instance, $tab); + } + } + $query = self::posts($instance, $page); + $html = WidgetRenderer::items($query, $instance); + $max = max(1, (int) $query->max_num_pages); + return [ + 'html' => $html, + 'paginate_html' => self::pagination($instance, $page, $max, $segment), + 'has_more' => $page < $max, + 'page' => $page, + 'max_pages' => $max, + 'total' => (int) $query->found_posts, + ]; + } + + private static function pagination(array $instance, int $page, int $max, string $segment): string { + $mode = sanitize_key((string) ($instance['paginate'] ?? 'none')); + if ($mode === 'none' || $max <= 1) return ''; + $segment_attr = $segment !== '' ? ' data-segment="' . esc_attr($segment) . '"' : ''; + if ($mode === 'default') { + $links = ''; + for ($number = 1; $number <= $max; $number++) { + $links .= $number === $page + ? '' . $number . '' + : '' . $number . ''; + } + return ''; + } + if ($page >= $max) return ''; + return ''; + } + + /** @return int[] */ + private static function ids($raw): array { + $values = is_array($raw) ? $raw : preg_split('/[,,\s]+/', (string) $raw); + return array_values(array_unique(array_filter(array_map('absint', $values ?: [])))); + } + + /** Current plate when the widget is pinned to the page it sits on. */ + private static function current_plate(array $instance): int { + if (empty($instance['current_plate'])) return 0; + return Core::current_plate_id(); + } +} diff --git a/src/Forum/WidgetRenderer.php b/src/Forum/WidgetRenderer.php new file mode 100644 index 00000000..08fe4c0f --- /dev/null +++ b/src/Forum/WidgetRenderer.php @@ -0,0 +1,177 @@ +'; + if ($style === 'mini') $html .= '
'; + $html .= $pager; + if ($style === 'mini') $html .= '
'; + return $html . ''; + } + + private static function tab_posts(array $instance, string $widget_id): string { + $tabs = array_values((array) ($instance['tabs'] ?? [])); + $tabs = array_values(array_filter($tabs, static fn($tab): bool => is_array($tab) && trim(wp_strip_all_tags((string) ($tab['title'] ?? ''))) !== '')); + if (!$tabs) return ''; + $key = sanitize_html_class($widget_id) ?: 'preview'; + $buttons = $panels = ''; + foreach ($tabs as $index => $tab) { + $selected = $index === 0; + $panel_id = 'forum-tab-' . $key . '-' . $index; + $buttons .= '
  • ' . wp_kses_post((string) $tab['title']) . '
  • '; + $body = $selected + ? self::pager(array_merge($instance, $tab), 'forum_tab_posts', $widget_id, 1, (string) $index) + : '
    ' . HomeTabs::placeholder('posts', (int) ($instance['count'] ?? 10)) . '
    '; + $panels .= '
    ' . $body . '
    '; + } + $style = self::style($instance); + return '
      ' . $buttons . '
    ' . $panels . '
    '; + } + + public static function items(\WP_Query $query, array $instance): string { + if (!$query->have_posts()) { + return jio_get_ajax_null('暂无内容'); + } + Cards::prime($query->posts); + $style = self::style($instance); + $class = empty($instance['alone']) ? 'ajax-item' : 'alone ajax-item'; + $show_topping = in_array('topping', array_map('sanitize_key', (array) ($instance['filter'] ?? [])), true); + $html = ''; + foreach ($query->posts as $post) { + if ($style === 'detail') { + $html .= Cards::post_detail($post, $class, true, $show_topping); + } elseif ($style === 'minimalism') { + $html .= '
    ' . Cards::title($post, 'forum-title', 'em09', $show_topping) . '
    '; + } else { + $html .= Cards::post_mini($post, $class, $show_topping); + } + } + return $html; + } + + private static function plates(array $instance): string { + $plates = WidgetQuery::plates($instance); + if (!$plates) { + return jio_get_ajax_null('暂无版块'); + } + if (sanitize_key((string) ($instance['style'] ?? 'default')) === 'card') { + return '
    ' . Cards::plate_slide($plates) . '
    '; + } + $items = ''; + foreach ($plates as $plate) { + $items .= Cards::plate_item($plate); + } + return '
    ' . $items . '
    '; + } + + private static function plate_info(): string { + $plate_id = Core::current_plate_id(); + $plate = $plate_id > 0 ? get_post($plate_id) : null; + if (!$plate instanceof \WP_Post || $plate->post_type !== Core::PLATE) return ''; + PlateStats::prime([$plate_id]); + $stats = PlateStats::get($plate_id); + $followers = (int) get_post_meta($plate_id, '_jio_forum_follow_count', true); + $plat_name = (string) jio_get_setting('jio_forum_plate_name', '版块') ?: '版块'; + $html = '
    '; + if ($followers > 0) { + $html .= ''; + } + $html .= '
    '; + $html .= '
    ' . self::plate_thumb($plate) . '
    '; + $html .= '
    '; + $html .= '

    ' . esc_html($plate->post_title) . '

    '; + $excerpt = wp_trim_words(wp_strip_all_tags($plate->post_excerpt ?: $plate->post_content), 40); + if ($excerpt !== '') $html .= '
    ' . esc_html($excerpt) . '
    '; + $html .= '
    发布' . esc_html((string) jio_get_setting('jio_forum_post_name', '帖子')) . '' . Cards::follow_button($plate_id, 'but hollow c-red') . '
    '; + $html .= '
    ' . esc_html($plat_name) . '帖子
    ' . esc_html(jio_cut_count($stats['posts'])) . '
    '; + $html .= '
    今日
    ' . esc_html(jio_cut_count($stats['today'])) . '
    '; + $html .= '
    关注
    ' . esc_html(jio_cut_count($followers)) . '
    '; + return $html . '
    '; + } + + private static function moderators(array $instance): string { + $plate_id = Core::current_plate_id(); + $plate = $plate_id > 0 ? get_post($plate_id) : null; + if (!$plate instanceof \WP_Post || $plate->post_type !== Core::PLATE) return ''; + $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)) + )))); + $list = ''; + foreach ($ids as $user_id) { + $user = get_userdata($user_id); + if (!$user) continue; + $owner = $user_id === (int) $plate->post_author; + $list .= ''; + } + if ($list === '') return ''; + $title = trim(wp_strip_all_tags((string) ($instance['title_text'] ?? ''))); + $apply = !empty($instance['apply_btn']) && is_user_logged_in() && !Access::is_moderator(get_current_user_id(), $plate_id) && jio_get_setting('jio_forum_moderator_apply', true) + ? '' + : ''; + $head = ($title !== '' || $apply !== '') ? '
    ' . ($title !== '' ? '
    ' . esc_html($title) . '
    ' : '
    ') . $apply . '
    ' : ''; + return '
    ' . $head . $list . '
    '; + } + + private static function topics(array $instance): string { + $terms = WidgetQuery::topics($instance); + if (!$terms) return '
    ' . jio_get_ajax_null('暂无内容', 20) . '
    '; + $list = ''; + foreach ($terms as $term) { + $icon = ''; + $list .= '
    ' . $icon . esc_html($term->name) . $icon . '
    ' + . '
    帖子:' . esc_html(jio_cut_count($term->count)) . '
    '; + } + return '
    ' . $list . '
    '; + } + + /** Loader-bearing container consumed by assets/widgets/frontend.js. */ + private static function pager(array $instance, string $slug, string $widget_id, int $page, string $segment): string { + $attrs = ' data-widget-items data-widget="' . esc_attr($slug) . '" data-instance="' . esc_attr($widget_id) . '" data-page="' . $page . '"'; + if ($segment !== '') $attrs .= ' data-segment="' . esc_attr($segment) . '"'; + $payload = WidgetQuery::payload($slug, $instance, $page, $segment); + $loader = '
    ' . HomeTabs::placeholder('posts', (int) ($instance['count'] ?? 10)) . '
    '; + return '
    ' . $payload['html'] . $payload['paginate_html'] . $loader . '
    '; + } + + private static function style(array $instance): string { + $style = sanitize_key((string) ($instance['style'] ?? 'detail')); + return in_array($style, ['detail', 'mini', 'minimalism'], true) ? $style : 'detail'; + } + + private static function plate_thumb(\WP_Post $plate): string { + $src = get_the_post_thumbnail_url($plate, 'thumbnail') ?: get_template_directory_uri() . '/assets/img/thumbnail.svg'; + return ''; + } +} diff --git a/src/Forum/WidgetSchemas.php b/src/Forum/WidgetSchemas.php new file mode 100644 index 00000000..265f9185 --- /dev/null +++ b/src/Forum/WidgetSchemas.php @@ -0,0 +1,146 @@ + 'style', 'type' => 'radio', 'label' => '列表样式', 'inline' => true, 'default' => 'detail', 'options' => [ + 'detail' => '详细内容', + 'mini' => '简约风格', + 'minimalism' => '极简风格', + ]], + ['id' => 'alone', 'type' => 'switcher', 'label' => '列表独立', 'default' => false, 'description' => '每一个列表都独立显示为模块'], + ['id' => 'count', 'type' => 'spinner', 'label' => '显示数量', 'default' => 10, 'min' => 4, 'max' => 20, 'step' => 1, 'unit' => '篇'], + self::paginate_field(), + ]); + } + + private static function tab_posts_fields(): array { + return [ + ['id' => 'style', 'type' => 'radio', 'label' => '列表样式', 'inline' => true, 'default' => 'detail', 'options' => [ + 'detail' => '详细内容', + 'mini' => '简约风格', + 'minimalism' => '极简风格', + ]], + ['id' => 'alone', 'type' => 'switcher', 'label' => '列表独立', 'default' => false, 'description' => '每一个列表都独立显示为模块'], + ['id' => 'count', 'type' => 'spinner', 'label' => '显示数量', 'default' => 10, 'min' => 4, 'max' => 20, 'step' => 1, 'unit' => '篇'], + self::paginate_field(), + [ + 'id' => 'tabs', 'type' => 'group', 'label' => '栏目', 'max_items' => 10, 'item_label' => '栏目', + 'default' => [ + ['title' => '热门推荐', 'orderby' => 'views'], + ['title' => '最新帖子', 'orderby' => 'modified'], + ['title' => '精华帖子', 'orderby' => 'modified', 'filter' => ['essence']], + ], + 'fields' => array_merge( + [['id' => 'title', 'type' => 'textarea', 'label' => '标题(必填)', 'default' => '', 'allow_html' => true, 'attributes' => ['rows' => 1], 'description' => '栏目显示的标题,支持HTML代码']], + self::post_filter_fields(), + [self::orderby_field()] + ), + ], + ]; + } + + private static function plates_fields(): array { + return [ + ['id' => 'category', 'type' => 'terms', 'label' => '分类筛选', 'taxonomy' => ['jio_forum_plate_cat'], 'default' => [], 'description' => '仅显示所选分类的版块,留空则显示全部分类'], + ['id' => 'orderby', 'type' => 'select', 'label' => '排序方式', 'default' => 'modified', 'options' => [ + 'name' => '标题名称', + 'modified' => '更新时间', + 'date' => '创建时间', + 'follow_count' => '最多关注', + 'posts_count' => '最多帖子', + 'today_count' => '今日最多新帖', + 'rand' => '随机', + ]], + ['id' => 'style', 'type' => 'radio', 'label' => '列表样式', 'inline' => true, 'default' => 'default', 'options' => [ + 'default' => '默认列表', + 'card' => '单行滚动卡片', + ]], + ['id' => 'count', 'type' => 'spinner', 'label' => '显示数量', 'default' => 6, 'min' => 4, 'max' => 20, 'step' => 1, 'unit' => '个'], + ]; + } + + private static function moderator_fields(): array { + return [ + ['id' => 'title_text', 'type' => 'textarea', 'label' => '标题内容', 'default' => '版块版主', 'attributes' => ['rows' => 1]], + ['id' => 'apply_btn', 'type' => 'switcher', 'label' => '显示申请按钮', 'default' => true], + ]; + } + + private static function topic_fields(): array { + return [ + ['id' => 'include', 'type' => 'terms', 'label' => '显示话题', 'taxonomy' => ['jio_forum_topic'], 'default' => [], 'description' => '仅显示所选话题,留空则显示全部话题'], + ['id' => 'orderby', 'type' => 'select', 'label' => '排序方式', 'default' => 'count', 'options' => [ + 'name' => '名称排序', + 'count' => '帖子数量', + 'include' => '手动排序(上方选项不为空时才有效)', + ]], + ['id' => 'hide_empty', 'type' => 'switcher', 'label' => '排除帖子为空的话题', 'default' => true], + ['id' => 'count', 'type' => 'spinner', 'label' => '显示数量(0 为显示全部)', 'default' => 6, 'min' => 0, 'max' => 20, 'step' => 1, 'unit' => '个'], + ]; + } + + /** Filters shared by the posts list and every tab of the tab list. */ + private static function post_filter_fields(): array { + return [ + ['id' => 'current_plate', 'type' => 'switcher', 'label' => '仅显示当前版块的帖子', 'default' => false, 'description' => '放置在版块页面时按当前版块筛选;开启该模块只会在版块和帖子页面显示'], + ['id' => 'include_plate', 'type' => 'text', 'label' => '包含版块', 'default' => '', 'sanitize' => 'ids', 'description' => '填写版块 ID,多个用逗号隔开'], + ['id' => 'exclude_plate', 'type' => 'text', 'label' => '排除版块', 'default' => '', 'sanitize' => 'ids', 'description' => '填写需要排除的版块 ID,多个用逗号隔开'], + ['id' => 'include_topic', 'type' => 'text', 'label' => '包含话题', 'default' => '', 'sanitize' => 'ids', 'description' => '填写话题 ID,多个用逗号隔开'], + ['id' => 'include_tag', 'type' => 'text', 'label' => '包含标签', 'default' => '', 'sanitize' => 'ids', 'description' => '填写论坛标签 ID,多个用逗号隔开'], + ['id' => 'bbs_type', 'type' => 'checkbox', 'label' => '类型筛选', 'inline' => true, 'default' => [], 'options' => ['normal' => '标准', 'question' => '提问', 'vote' => '投票']], + ['id' => 'filter', 'type' => 'checkbox', 'label' => '其它筛选', 'inline' => true, 'default' => [], 'options' => [ + 'topping' => '置顶帖子', + 'vote' => '投票帖子', + 'essence' => '精华帖子', + 'question_status' => '提问已解决', + ]], + ]; + } + + private static function orderby_field(): array { + return ['id' => 'orderby', 'type' => 'select', 'label' => '排序方式', 'default' => 'date', 'options' => [ + 'name' => '标题名称', + 'date' => '最新发布', + 'modified' => '最近更新', + 'last_reply' => '最新回复', + 'views' => '最多查看', + 'score' => '评分最高', + 'comment_count' => '最多回复', + 'favorite_count' => '最多收藏', + 'rand' => '随机', + ]]; + } + + private static function paginate_field(): array { + return ['id' => 'paginate', 'type' => 'radio', 'label' => '翻页按钮', 'inline' => true, 'default' => 'none', 'options' => [ + 'none' => '不允许翻页', + 'ajax_lists' => 'AJAX追加列表翻页', + 'default' => '数字翻页按钮', + ]]; + } +} diff --git a/src/WidgetSystem/FrontendRenderer.php b/src/WidgetSystem/FrontendRenderer.php index 8861df3c..7c423b9a 100644 --- a/src/WidgetSystem/FrontendRenderer.php +++ b/src/WidgetSystem/FrontendRenderer.php @@ -33,16 +33,23 @@ final class FrontendRenderer { self::render_reference_widget($definition, $instance, $widget, $content, true); return; } - if (in_array($definition->slug(), ['user_card', 'author_card', 'tab_posts', 'posts'], true)) { + if (in_array($definition->slug(), ['user_card', 'author_card', 'tab_posts', 'posts'], true) + || in_array($definition->slug(), ['forum_posts', 'forum_tab_posts'], true)) { self::render_reference_widget($definition, $instance, $widget, $content); return; } + if (in_array($definition->slug(), ['forum_plate_info', 'forum_moderators'], true)) { + // Reference: the plate card and the moderator box ship their own + // surface and title, so they only take the wrapper layer. + self::render_reference_widget($definition, $instance, $widget, $content, true, true); + return; + } if (in_array($definition->slug(), [ 'post_pay', 'oneline_posts', 'hot_posts', 'slider', 'slider_media_combo', 'icon_cards', 'icon_cover_cards', 'buttons', 'notice', 'term_cards', 'term_aggregation', 'media_cards', 'combo_info', 'partners', 'recent_comments', - ], true)) { + ], true) || in_array($definition->slug(), ['forum_plates', 'forum_topics'], true)) { echo (string) ($args['before_widget'] ?? ''); if ($definition->slug() !== 'post_pay') echo self::reference_title($instance); $content_classes = ['widget-content']; @@ -100,7 +107,7 @@ final class FrontendRenderer { * 搜索框 / 一言自带卡片(.zib-widget.widget-search / .yiyan-box), * 此时不再套 .widget-content 与第二层卡片;其余模块保持 .widget-content 结构层。 */ - private static function render_reference_widget(WidgetDefinition $definition, array $instance, SchemaWidget $widget, string $content, bool $own_card = false): void { + private static function render_reference_widget(WidgetDefinition $definition, array $instance, SchemaWidget $widget, string $content, bool $own_card = false, bool $own_title = false): void { $classes = ['jio-widget-box-wrap', 'jio-' . str_replace('_', '-', $definition->slug()) . '-wrap']; if (in_array($definition->slug(), ['user_card', 'author_card'], true)) { $classes[] = 'jio-user-widget-wrap'; @@ -127,7 +134,7 @@ final class FrontendRenderer { if ($own_card) { // 参考主题:搜索框/一言的中间层是 .theme-box,承载 20px 底部间距与入场动画。 echo '
    '; - echo self::reference_title($instance); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + if (!$own_title) echo self::reference_title($instance); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo '
    '; return; diff --git a/src/WidgetSystem/RendererRegistry.php b/src/WidgetSystem/RendererRegistry.php index 435131c4..4c020aa2 100644 --- a/src/WidgetSystem/RendererRegistry.php +++ b/src/WidgetSystem/RendererRegistry.php @@ -37,6 +37,9 @@ final class RendererRegistry { 'slider' => SliderRenderer::class, 'slider_media_combo' => SliderRenderer::class, 'user_card' => UserRenderer::class, 'author_card' => UserRenderer::class, 'user_ranking' => UserRenderer::class, 'user_list' => UserRenderer::class, + 'forum_posts' => \Jio\Forum\WidgetRenderer::class, 'forum_tab_posts' => \Jio\Forum\WidgetRenderer::class, + 'forum_plates' => \Jio\Forum\WidgetRenderer::class, 'forum_plate_info' => \Jio\Forum\WidgetRenderer::class, + 'forum_moderators' => \Jio\Forum\WidgetRenderer::class, 'forum_topics' => \Jio\Forum\WidgetRenderer::class, ]; } @@ -49,6 +52,6 @@ final class RendererRegistry { } public static function supports_items(string $slug): bool { - return in_array($slug, ['posts', 'tab_posts', 'oneline_posts'], true); + return in_array($slug, ['posts', 'tab_posts', 'oneline_posts'], true) || in_array($slug, \Jio\Forum\WidgetRenderer::SUPPORTS_ITEMS, true); } } diff --git a/src/WidgetSystem/RestController.php b/src/WidgetSystem/RestController.php index e722109f..fcbc24ac 100644 --- a/src/WidgetSystem/RestController.php +++ b/src/WidgetSystem/RestController.php @@ -49,7 +49,9 @@ final class RestController extends \WP_REST_Controller { } $page = max(1, (int) $request->get_param('page')); $segment = (string) $request->get_param('segment'); - $payload = PostRenderer::payload($instance, $slug, $page, $segment); + $payload = in_array($slug, \Jio\Forum\WidgetRenderer::SUPPORTS_ITEMS, true) + ? \Jio\Forum\WidgetQuery::payload($slug, $instance, $page, $segment) + : PostRenderer::payload($instance, $slug, $page, $segment); return rest_ensure_response(['success' => true, 'code' => 'ok'] + $payload); } diff --git a/src/WidgetSystem/WidgetCatalog.php b/src/WidgetSystem/WidgetCatalog.php index b3f90524..29033e42 100644 --- a/src/WidgetSystem/WidgetCatalog.php +++ b/src/WidgetSystem/WidgetCatalog.php @@ -5,7 +5,7 @@ namespace Jio\WidgetSystem; defined('ABSPATH') || exit; /** - * Canonical catalog of the 37 modern Jio widgets. + * Canonical catalog of the modern Jio widgets, including the forum group. */ final class WidgetCatalog { public static function definitions(): array { @@ -57,11 +57,26 @@ final class WidgetCatalog { ['user_ranking', '用户排行榜', '按签到、积分或粉丝生成排行', 'sidebar'], ['user_list', '用户列表', '筛选并显示注册用户列表', 'wide'], ], + 'forum' => [ + ['forum_posts', '论坛帖子列表', '按版块、话题、标签、类型筛选论坛帖子,支持多种排序、样式与翻页', 'sidebar'], + ['forum_tab_posts', '论坛多栏目帖子', '多个栏目切换显示论坛帖子列表', 'wide'], + ['forum_plates', '论坛版块列表', '显示论坛版块,支持分类筛选、排序与卡片样式', 'sidebar'], + ['forum_plate_info', '论坛版块信息', '显示当前版块的信息,只能放在版块或帖子页面侧边栏', 'sidebar'], + ['forum_moderators', '论坛版主列表', '显示当前版块的版主与副版主,只能放在版块或帖子页面侧边栏', 'sidebar'], + ['forum_topics', '论坛话题列表', '显示论坛话题及帖子数量,可排除空话题', 'sidebar'], + ], ]; $definitions = []; + // Forum widgets exist only while the forum module is on; standalone + // harnesses have no settings layer, so they keep their own catalog. + $forum_on = function_exists('jio_get_setting') && \Jio\Forum\Core::enabled(); foreach ($groups as $group => $items) { + if ($group === 'forum' && !$forum_on) { + continue; + } foreach ($items as [$slug, $name, $description, $size]) { + $forum = str_starts_with($slug, 'forum_'); $definitions[] = [ 'slug' => $slug, 'name' => 'Jio ' . $name, @@ -69,14 +84,14 @@ final class WidgetCatalog { 'group' => $group, 'renderer' => $slug, 'size' => $size, - 'common' => [ + 'common' => $forum ? self::forum_common($slug) : [ 'title' => !in_array($slug, ['post_pay', 'layout_gap', 'slider', 'slider_media_combo', 'buttons', 'posts_toc'], true), 'affix' => ($size === 'sidebar' && $slug !== 'posts_toc') || in_array($slug, ['buttons', 'notice', 'posts'], true), 'visibility' => $slug !== 'posts_toc', 'layout' => $slug !== 'posts_toc', 'animation' => !in_array($slug, ['buttons', 'term_cards', 'term_aggregation', 'media_cards', 'combo_info', 'slider_media_combo', 'posts_toc'], true), ], - 'fields' => WidgetSchemas::for($slug), + 'fields' => $forum ? \Jio\Forum\WidgetSchemas::for($slug) : WidgetSchemas::for($slug), 'assets' => self::assets($slug), 'title_default' => $slug === 'search' ? '搜索' : '', ]; @@ -85,6 +100,18 @@ final class WidgetCatalog { return $definitions; } + /** Forum widgets reuse the reference flags; plate-scoped ones hide themselves off their pages. */ + private static function forum_common(string $slug): array { + $scoped = in_array($slug, ['forum_plate_info', 'forum_moderators'], true); + return [ + 'title' => !in_array($slug, ['forum_plate_info'], true), + 'affix' => $slug !== 'forum_tab_posts', + 'visibility' => !$scoped, + 'layout' => true, + 'animation' => $slug !== 'forum_tab_posts', + ]; + } + private static function assets(string $slug): array { if (in_array($slug, ['slider', 'slider_media_combo', 'reviews', 'oneline_posts'], true)) { return ['swiper']; diff --git a/tests/forum/widgets-contract.test.cjs b/tests/forum/widgets-contract.test.cjs new file mode 100644 index 00000000..a796fcab --- /dev/null +++ b/tests/forum/widgets-contract.test.cjs @@ -0,0 +1,86 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const root = path.resolve(__dirname, '..', '..') +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8') + +const SLUGS = ['forum_posts', 'forum_tab_posts', 'forum_plates', 'forum_plate_info', 'forum_moderators', 'forum_topics'] + +test('every reference forum widget is catalogued with a schema and a renderer', () => { + const catalog = read('src/WidgetSystem/WidgetCatalog.php') + const registry = read('src/WidgetSystem/RendererRegistry.php') + const schemas = read('src/Forum/WidgetSchemas.php') + const renderer = read('src/Forum/WidgetRenderer.php') + for (const slug of SLUGS) { + assert.ok(catalog.includes(`['${slug}',`), `${slug} missing from the widget catalog`) + assert.ok(registry.includes(`'${slug}' => \\Jio\\Forum\\WidgetRenderer::class`), `${slug} missing from the renderer registry`) + } + // The plate info card is configuration-free, like the reference widget. + for (const slug of SLUGS.filter((slug) => slug !== 'forum_plate_info')) { + assert.ok(schemas.includes(`case '${slug}':`), `${slug} missing a field schema`) + } + for (const slug of SLUGS) { + assert.ok(renderer.includes(`case '${slug}':`), `${slug} renderer missing`) + } + assert.match(catalog, /\$forum \? \\Jio\\Forum\\WidgetSchemas::for\(\$slug\) : WidgetSchemas::for\(\$slug\)/) +}) + +test('forum widgets reuse the shared reference cards instead of forking markup', () => { + const renderer = read('src/Forum/WidgetRenderer.php') + for (const helper of ['Cards::post_detail(', 'Cards::post_mini(', 'Cards::title(', 'Cards::plate_item(', 'Cards::plate_slide(', 'Cards::follow_button(']) { + assert.ok(renderer.includes(helper), `forum widgets must reuse ${helper}`) + } + assert.doesNotMatch(renderer, /zib|zibll|Qinver/i) +}) + +test('forum widget lists paginate through the widget REST client contract', () => { + const renderer = read('src/Forum/WidgetRenderer.php') + const query = read('src/Forum/WidgetQuery.php') + const registry = read('src/WidgetSystem/RendererRegistry.php') + const rest = read('src/WidgetSystem/RestController.php') + // The container carries the attributes frontend.js reads, with the skeleton inside it. + assert.match(renderer, /class="widget-ajaxpager"/) + assert.match(renderer, /data-widget-items data-widget=/) + assert.match(renderer, /post_ajax_loader/) + assert.doesNotMatch(renderer, /post_ajax_trigger/) + // Item payloads resolve through the forum query builder, not the article one. + assert.match(rest, /\\Jio\\Forum\\WidgetQuery::payload/) + assert.match(registry, /WidgetRenderer::SUPPORTS_ITEMS/) + assert.match(query, /jio_forum_list/) + // Pagination exposes the widget client's page hooks and the reference modes. + assert.match(query, /data-widget-page=/) + assert.match(query, /data-next-page=/) + assert.match(query, /ajaxpager-target="\.widget-ajaxpager"/) + const schemas = read('src/Forum/WidgetSchemas.php') + assert.match(schemas, /'ajax_lists'/) + assert.match(schemas, /'default' => '数字翻页按钮'/) +}) + +test('plate-scoped forum widgets stay silent outside plate and post pages', () => { + const renderer = read('src/Forum/WidgetRenderer.php') + const core = read('src/Forum/Core.php') + assert.match(core, /public static function current_plate_id\(\)/) + for (const method of ['plate_info', 'moderators']) { + const start = renderer.indexOf(`private static function ${method}(`) + const body = renderer.slice(start, renderer.indexOf('private static function', start + 10)) + assert.match(body, /Core::current_plate_id\(\)/) + assert.match(body, /Core::PLATE/) + } +}) + +test('forum widgets load the forum card styles and runtime on any sidebar', () => { + const core = read('src/Forum/Core.php') + const runtime = read('assets/js/forum-runtime.js') + assert.match(core, /add_action\('wp_enqueue_scripts', \[self::class, 'widget_assets'\]/) + assert.match(core, /public static function enqueue_css\(\)/) + assert.match(core, /is_active_widget\(false, false, 'jio_widget_' \. \$slug, true\)/) + assert.match(runtime, /document\.getElementById\('forum'\) \|\| document\.body/) +}) + +test('forum widgets keep the module off until it is enabled', () => { + const catalog = read('src/WidgetSystem/WidgetCatalog.php') + assert.match(catalog, /if \(\$group === 'forum' && !\$forum_on\)/) + assert.match(catalog, /function_exists\('jio_get_setting'\) && \\Jio\\Forum\\Core::enabled\(\)/) +}) diff --git a/tools/admin-contract-report.json b/tools/admin-contract-report.json index 41aaac44..9a18bb7a 100644 --- a/tools/admin-contract-report.json +++ b/tools/admin-contract-report.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-11-11T06:33:58+00:00", + "generated_at": "2026-11-11T07:46:58+00:00", "errors": [], "warnings": [ "customPage tools-update section 'tools/update' not in PHP schema (ok if pure operational page)",