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 '
' . $links . '
';
+ }
+ 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)
+ : '';
+ $panels .= '' . $body . '
';
+ }
+ $style = self::style($instance);
+ return '';
+ }
+
+ 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 '';
+ }
+
+ 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 = '';
+ }
+
+ 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 .= '' . get_avatar($user_id, 80) . ''
+ . '
'
+ . ($owner ? '' : '
' . jio_get_user_follow('follow c-red', $user_id) . '
')
+ . '
';
+ }
+ 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 '';
+ }
+
+ 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 '';
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)",