diff --git a/assets/css/routes/forum.css b/assets/css/routes/forum.css
index 1ec38f28..d5d155fd 100644
--- a/assets/css/routes/forum.css
+++ b/assets/css/routes/forum.css
@@ -181,6 +181,7 @@
.forum-posts.detail .avatar-img{--this-size:38px}
.lists-imgs.count-3{--img-count:3}
.lists-imgs.count-2,.lists-imgs.count-4{--img-count:2}
+}
@media(max-width:480px){
#forum .jio-forum__wrap{width:calc(100% - 24px)}
#forum h1{font-size:23px}
@@ -282,3 +283,88 @@ body.dark-theme .panel.panel-plate,body.dark-theme .plate-lists .plate-item,body
.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)}
+
+/* ── 版块 / 分类页面 ──────────────────────────────────────────── */
+.forum-listing .jio-forum__breadcrumbs{margin:0 0 12px}
+.forum-listing .jio-forum__list-main{min-width:0}
+.plate-tab{padding:0}
+.plate-tab .affix-header-sm{padding:0 15px;background:var(--main-bg-color);border-radius:var(--main-radius) var(--main-radius) 0 0}
+.plate-tab .affix-header-sm.affix,.plate-tab .affix-header-sm.affix-bottom{padding:8px 25px}
+.plate-tab .tab-nav-theme{margin:0;border-bottom:1px solid var(--main-border-color)}
+.plate-tab .tab-nav-theme li a{font-size:16px;color:var(--muted-2-color);display:block;padding:12px 14px}
+.plate-tab .tab-nav-theme li.active a{font-weight:bold;color:var(--focus-color)}
+.plate-tab .tab-content{padding:10px 15px}
+.plate-tab .tab-content > .tab-pane{display:none}
+.plate-tab .tab-content > .tab-pane.active{display:block}
+.plate-tab .forum-tab-pane{min-height:120px}
+.forum-header.blur-header{overflow:hidden;color:#fff}
+.forum-header.blur-header .header-content{background:transparent;color:#fff;padding:28px 30px 22px}
+.forum-header.blur-header .forum-title,.forum-header.blur-header .forum-title a{color:#fff}
+.forum-header.blur-header .desc,.forum-header.blur-header .px12-sm{color:rgba(255,255,255,.86)}
+.forum-header .abs-blur-bg img{width:100%;height:100%;object-fit:cover}
+.forum-header .forum-mask{position:absolute;inset:0;background:rgba(76,80,87,.6)}
+.forum-header.blur-header .plate-thumb{width:90px;height:90px;flex-shrink:0}
+.forum-header.blur-header .plate-thumb > img{border-radius:var(--main-radius)}
+.forum-header .moderator-btns{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
+.forum-header .moderator-btns .avatar-mini{--this-size:22px;margin-right:4px}
+.forum-header .moderator-btns .avatar-mini img{border-radius:100px}
+.forum-header .moderator-btns .but{font-size:12px;padding:3px 10px}
+
+/* ── 帖子详情页 ───────────────────────────────────────────────── */
+.forum-post .single-wrap{width:100%}
+.forum-single-cover{border-radius:var(--main-radius);overflow:hidden}
+.forum-article{padding:20px 26px}
+.forum-article .article-header{margin-bottom:15px;padding-bottom:12px;margin-top:5px;border-bottom:1px solid var(--main-border-color)}
+.forum-article .article-title{font-size:24px;line-height:1.4;margin:0 0 10px;font-weight:600}
+.forum-article .article-title a{color:var(--key-color)}
+.forum-article-meta,.forum-article-meta a{color:var(--muted-2-color);font-size:13px}
+.forum-article-meta .meta-left{display:flex;align-items:center;gap:8px}
+.forum-article-meta .meta-right{display:flex;align-items:center}
+.forum-article-meta .item{margin-left:18px;display:inline-flex;align-items:center;color:var(--muted-2-color)}
+.forum-article .wp-posts-content{font-size:15px;line-height:1.9;overflow-wrap:anywhere}
+.forum-article .article-tags{display:flex;flex-wrap:wrap;gap:6px}
+.forum-article .article-tags .but{font-size:12px;padding:4px 10px}
+.footer-actions{margin:18px -10px 10px;padding-top:14px;border-top:1px solid var(--main-border-color)}
+.footer-actions .item{display:flex;align-items:center;padding:4px 8px;margin:0 4px;color:var(--muted-color);border:0;background:none;cursor:pointer;border-radius:4px}
+.footer-actions .item:hover{background:var(--muted-border-color)}
+.footer-actions .item > i{font-size:18px;margin-right:5px}
+.footer-actions .item text{font-size:12px}
+.single-footer .but{font-size:12px;padding:4px 12px}
+.vote-box.forum-vote{padding:20px 0}
+.vote-box.forum-vote h2{font-size:17px}
+.vote-item{position:relative;overflow:hidden;padding:8px 3.5em 8px 15px;margin-bottom:6px;border-radius:50px;background:var(--muted-border-color);color:var(--muted-color);display:block;cursor:pointer;text-overflow:ellipsis;white-space:nowrap}
+.vote-item input{margin-right:8px}
+.vote-progress{position:absolute;left:0;top:0;bottom:0;background:rgba(130,130,130,.15);transition:width .6s,background .2s;border-radius:50px;width:0;z-index:-1}
+.vote-item .vote-count{position:absolute;right:14px;font-size:.9em;opacity:.8}
+.vote-box.forum-vote button{display:block;margin:16px auto 0}
+
+/* ── 发帖 / 编辑页 ────────────────────────────────────────────── */
+.forum-edit .content-layout{margin-right:calc(var(--sidebar-width,311px) + 15px)}
+.forum-edit .sidebar{float:left;width:var(--sidebar-width,311px);margin-left:calc(0px - var(--sidebar-width,311px))}
+.forum-edit .editor-main-box{padding:20px 26px}
+.forum-edit .edit-posts-featured{display:flex;align-items:center;gap:12px}
+.forum-edit .forum-cover-preview{width:180px;height:101px;border-radius:var(--main-radius);overflow:hidden;background:var(--muted-border-color);display:flex;align-items:center;justify-content:center;color:var(--muted-3-color)}
+.forum-edit .forum-cover-preview img{width:100%;height:100%;object-fit:cover}
+.forum-edit .edit-posts-title{position:relative;margin:10px 0 14px}
+.forum-edit .line-form-input{width:100%;border:0;border-bottom:1px solid var(--main-border-color);background:transparent;font-size:22px;font-weight:600;padding:8px 0;resize:none;overflow:hidden;outline:none;color:var(--key-color)}
+.forum-edit .line-form-input:focus{border-bottom-color:var(--focus-color)}
+.forum-edit .line-form-line{display:none}
+.forum-edit .sidebar .zib-widget{padding:0}
+.forum-edit .drop-btn{padding:12px 15px;cursor:pointer}
+.forum-edit .drop-select .dropdown-menu{width:100%;left:0;right:0}
+.forum-edit .select-drop-box .but{display:block;width:100%;text-align:left;margin-bottom:4px;border-radius:4px}
+.forum-edit .select-drop-box .but.active{background:var(--focus-color);color:#fff}
+.forum-edit .type-drop{padding:12px 15px}
+.forum-edit .type-drop .but.active{--this-bg:var(--focus-color);--this-color:#fff}
+.forum-edit .forum-type-vote .form-control{width:100%;margin-bottom:6px}
+.forum-edit .jio-forum__editor-tools .but{font-size:13px}
+@media (max-width:800px){
+ .forum-edit .content-layout{margin-right:0}
+ .forum-edit .sidebar{width:100%;margin-left:0;margin-top:16px}
+ .forum-edit .editor-main-box{padding:15px}
+ .forum-article{padding:15px}
+ .forum-article .article-title{font-size:20px}
+ .forum-header.blur-header .header-content{padding:20px 15px}
+ .forum-header.blur-header .plate-thumb{width:64px;height:64px}
+}
+body.dark-theme .forum-header.blur-header .forum-mask{background:rgba(20,22,26,.72)}
diff --git a/assets/js/forum-runtime.js b/assets/js/forum-runtime.js
index 37b33b26..72d06722 100644
--- a/assets/js/forum-runtime.js
+++ b/assets/js/forum-runtime.js
@@ -95,6 +95,93 @@
typeSelect?.addEventListener('change', syncType)
syncType()
+ // 发帖页:下拉式设置项(阅读权限 / 版块 / 话题 / 标签 / 类型)统一走隐藏输入。
+ const syncHidden = (name, value) => {
+ root.querySelectorAll(`input[type="hidden"][name="${name}"]`).forEach((input) => { input.value = value })
+ }
+ const syncTypeBox = () => {
+ const box = root.querySelector('.forum-type-vote')
+ const current = root.querySelector('input[type="hidden"][name="type"]')?.value || 'normal'
+ if (box) box.hidden = current !== 'vote'
+ }
+ root.addEventListener('click', (event) => {
+ const type = event.target.closest('[data-forum-type-set]')
+ if (type) {
+ root.querySelectorAll('[data-forum-type-set]').forEach((item) => item.classList.toggle('active', item === type))
+ syncHidden('type', type.dataset.forumTypeSet)
+ syncTypeBox()
+ return
+ }
+ const view = event.target.closest('[data-forum-view]')
+ if (view) {
+ root.querySelectorAll('[data-forum-view]').forEach((item) => item.classList.toggle('active', item === view))
+ syncHidden('view_limit', view.dataset.forumView)
+ const label = root.querySelector('span[name="view_limit"]')
+ if (label) label.textContent = view.textContent.trim()
+ return
+ }
+ const plate = event.target.closest('[data-forum-plate]')
+ if (plate) {
+ root.querySelectorAll('[data-forum-plate]').forEach((item) => item.classList.toggle('active', item === plate))
+ syncHidden('plate_id', plate.dataset.forumPlate)
+ const label = root.querySelector('span[name="plate_id"]')
+ if (label) label.textContent = plate.textContent.trim()
+ return
+ }
+ const status = event.target.closest('[data-forum-status]')
+ if (status) {
+ root.querySelectorAll('[data-forum-status]').forEach((item) => item.classList.toggle('active', item === status))
+ syncHidden('post_status', status.dataset.forumStatus)
+ return
+ }
+ const pick = event.target.closest('[data-forum-cover-pick]')
+ if (pick) {
+ root.querySelector('[data-forum-cover-file]')?.click()
+ return
+ }
+ const draft = event.target.closest('[data-forum-draft]')
+ if (draft) {
+ const form = draft.closest('[data-forum-form]')
+ if (form) {
+ syncHidden('post_status', 'draft')
+ form.requestSubmit()
+ }
+ }
+ })
+ root.addEventListener('change', (event) => {
+ const term = event.target.closest('[data-forum-topics],[data-forum-tags]')
+ if (term) {
+ const field = term.matches('[data-forum-topics]') ? 'topics' : 'tags'
+ const values = [...root.querySelectorAll(`[data-forum-${field}]:checked`)].map((input) => input.value)
+ syncHidden(field, values.join(','))
+ term.closest('label')?.classList.toggle('active', term.checked)
+ const label = root.querySelector(`[data-forum-${field}-label]`)
+ if (label) label.textContent = values.length ? values.slice(0, 2).join('、') : '请选择'
+ }
+ })
+ root.addEventListener('change', async (event) => {
+ const cover = event.target.closest('[data-forum-cover-file]')
+ if (!cover?.files?.[0]) return
+ const data = new FormData()
+ data.append('file', cover.files[0])
+ try {
+ const response = await fetch(config.api + 'upload/image', { method: 'POST', credentials: 'same-origin', headers: { 'X-WP-Nonce': config.nonce }, body: data })
+ const payload = await response.json()
+ if (!response.ok || !payload.success) throw new Error(payload.message || '上传失败')
+ const preview = root.querySelector('.forum-cover-preview')
+ if (preview) {
+ preview.classList.remove('is-empty')
+ preview.innerHTML = '
'
+ }
+ const hidden = root.querySelector('input[name="featured_image_id"]')
+ if (hidden) hidden.value = payload.data.id || ''
+ } catch (error) {
+ message(cover.closest('.edit-posts-featured') || root, error.message)
+ } finally {
+ cover.value = ''
+ }
+ })
+
const insertMedia = (kind, url, name) => {
const element = document.createElement(kind === 'image' ? 'img' : kind === 'video' ? 'video' : 'a')
if (kind === 'resource') { element.href = url; element.textContent = name || '下载附件' }
diff --git a/src/Admin/Schema/SchemaForum.php b/src/Admin/Schema/SchemaForum.php
index 476ab0da..c12b8a00 100644
--- a/src/Admin/Schema/SchemaForum.php
+++ b/src/Admin/Schema/SchemaForum.php
@@ -53,6 +53,24 @@ final class SchemaForum {
self::field('jio_forum_home_follow_plate', '版块栏目显示已关注版块', 'switcher', true),
self::field('jio_forum_home_recommend_plate', '关注栏目显示版块推荐', 'switcher', true),
self::field('jio_forum_show_plate_cover', '显示版块封面', 'switcher', true),
+ self::field('jio_forum_plate_tab_active', '版块页默认栏目', 'number', 1, ['min' => 1, 'max' => 20, 'description' => '打开版块页面时默认显示第几个栏目']),
+ self::field('jio_forum_plate_tab_items', '版块页栏目', 'repeater', self::default_plate_tabs(), [
+ 'addText' => '添加栏目',
+ 'description' => '版块页顶部的栏目条;留空则使用「最新发布 / 最近回复 / 热门 / 精华」默认栏目',
+ 'fields' => [
+ self::field('title', '栏目标题', 'input', ''),
+ self::field('orderby', '排序方式', 'select', 'date', ['options' => self::options([
+ 'date' => '发布时间',
+ 'modified' => '修改时间',
+ 'last_reply' => '最新回复',
+ 'views' => '阅读量',
+ 'score' => '评分',
+ 'comment_count' => '回复数量',
+ ])]),
+ self::field('filter', '内容筛选', 'select', '', ['options' => self::options(['' => '全部', 'essence' => '精华'])]),
+ self::field('style', '列表样式', 'radio', 'detail', ['options' => self::options(['detail' => '详细内容', 'mini' => '简约风格'])]),
+ ],
+ ]),
self::field('jio_forum_search_enabled', '搜索包含帖子', 'switcher', true),
]),
'publishing' => self::section('publishing', '发布与审核', [
@@ -95,6 +113,16 @@ final class SchemaForum {
]];
}
+ /** Initial plate-page tab rows; the listing falls back to these when unset. */
+ private static function default_plate_tabs(): array {
+ return [
+ ['title' => '最新发布', 'orderby' => 'date', 'filter' => '', 'style' => 'detail'],
+ ['title' => '最近回复', 'orderby' => 'last_reply', 'filter' => '', 'style' => 'detail'],
+ ['title' => '热门', 'orderby' => 'views', 'filter' => '', 'style' => 'detail'],
+ ['title' => '精华', 'orderby' => 'modified', 'filter' => 'essence', 'style' => 'detail'],
+ ];
+ }
+
private static function section(string $key, string $title, array $fields): array {
return ['key' => $key, 'title' => $title, 'version' => '1.0.0', 'option_key' => 'jio_settings', 'groups' => [['key' => $key, 'fields' => $fields]]];
}
diff --git a/src/Forum/Cards.php b/src/Forum/Cards.php
index 75cd6021..30a8b7f2 100644
--- a/src/Forum/Cards.php
+++ b/src/Forum/Cards.php
@@ -73,8 +73,61 @@ final class Cards {
return '
';
}
- public static function plate_item(\WP_Post $plate): string {
- $url = get_permalink($plate);
+ /** 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 .= '' . esc_html($term->name) . '';
+ }
+ $meta = '- ' . esc_html(sprintf('%s %s', $plate_name, jio_cut_count($stats['posts']))) . '
'
+ . '- ' . esc_html(sprintf('今日 %s', jio_cut_count($stats['today']))) . '
'
+ . ($followers > 0
+ ? '- ' . esc_html(sprintf('关注 %s', jio_cut_count($followers))) . '
'
+ : '') . '
';
+ $excerpt = wp_trim_words(wp_strip_all_tags($plate->post_excerpt ?: $plate->post_content), 40);
+ $html = '';
+ }
+
+ /** 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))
+ ))));
+ if (!$ids) return '';
+ $btns = '';
+ foreach ($ids as $user_id) {
+ $user = get_userdata($user_id);
+ if (!$user) continue;
+ $btns .= '' . get_avatar($user_id, 40) . '' . esc_html($user->display_name) . '';
+ }
+ $html = '' . $btns;
+ if (is_user_logged_in() && !Access::is_moderator(get_current_user_id(), $plate->ID) && jio_get_setting('jio_forum_moderator_apply', true)) {
+ $html .= '';
+ }
+ return $html . '
';
+ }
+
+ public static function plate_item(\WP_Post $plate): string { $url = get_permalink($plate);
$counts = PlateStats::get($plate->ID);
$today = $counts['today'] > 0 ? '' . esc_html(sprintf('今日:%s', jio_cut_count($counts['today']))) . '' : '';
$excerpt = wp_trim_words(wp_strip_all_tags($plate->post_excerpt ?: $plate->post_content), 30);
diff --git a/src/Forum/EditView.php b/src/Forum/EditView.php
new file mode 100644
index 00000000..c64709b5
--- /dev/null
+++ b/src/Forum/EditView.php
@@ -0,0 +1,167 @@
+ 0 ? get_post($edit_id) : null;
+ if ($edit_id > 0 && (!$post instanceof \WP_Post || !Access::can_manage_post(get_current_user_id(), $edit_id))) {
+ status_header(403);
+ echo '' . jio_get_null('抱歉!您暂无编辑此内容的权限', 60, 'null-post.svg') . '
';
+ get_footer();
+ return;
+ }
+ $user_id = get_current_user_id();
+ $type = $post ? (string) get_post_meta($post->ID, '_jio_forum_type', true) : 'normal';
+ $plate_id = $post ? (int) get_post_meta($post->ID, '_jio_forum_plate_id', true) : absint(is_scalar($_GET['plate'] ?? 0) ? $_GET['plate'] : 0);
+ $view_limit = $post ? (string) get_post_meta($post->ID, '_jio_forum_view_limit', true) : 'all';
+ $vote_options = $post ? (array) get_post_meta($post->ID, '_jio_forum_vote_options', true) : [];
+ $topics = $post ? wp_get_post_terms($post->ID, Core::TOPIC, ['fields' => 'names']) : [];
+ $tags = $post ? wp_get_post_terms($post->ID, Core::TAG, ['fields' => 'names']) : [];
+
+ wp_enqueue_editor();
+ echo '';
+ get_footer();
+ }
+}
diff --git a/src/Forum/ListingView.php b/src/Forum/ListingView.php
new file mode 100644
index 00000000..d56a38b6
--- /dev/null
+++ b/src/Forum/ListingView.php
@@ -0,0 +1,198 @@
+post_type !== Core::PLATE) {
+ self::term();
+ return;
+ }
+ self::open($plate->post_title, 'plate', $plate);
+ \Jio\WidgetSystem\WidgetAreas::render('forum_plate_top_fluid');
+ echo '';
+ echo Cards::plate_header($plate); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ echo self::tab_bar($plate); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ echo '
';
+ self::close('plate');
+ }
+
+ /** Taxonomy archives (plate category / topic / tag). */
+ public static function term(): void {
+ $term = get_queried_object();
+ if (!$term instanceof \WP_Term) {
+ self::open('论坛', 'term', null);
+ self::close('term');
+ return;
+ }
+ self::open($term->name, 'term', null, $term);
+ \Jio\WidgetSystem\WidgetAreas::render('forum_term_top_fluid');
+ echo '';
+ echo self::tab_bar(null, $term); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ echo '
';
+ self::close('term');
+ }
+
+ private static function open(string $title, string $context, ?\WP_Post $plate, ?\WP_Term $term = null): void {
+ echo '';
+ echo '';
+ echo '
';
+ if ($plate) {
+ $plate_name = (string) jio_get_setting('jio_forum_plate_name', '版块') ?: '版块';
+ echo '
';
+ } elseif ($term instanceof \WP_Term) {
+ echo '
';
+ }
+ }
+
+ private static function close(string $context): void {
+ echo '
';
+ self::sidebar($context);
+ echo '
';
+ \Jio\WidgetSystem\WidgetAreas::render('forum_' . $context . '_bottom_fluid');
+ echo '';
+ }
+
+ private static function sidebar(string $context): void {
+ if (!jio_is_show_sidebar()) return;
+ echo '';
+ }
+
+ /** Plate tab bar + panes; the first tab renders server-side, the rest load by `?plate_tab=N`. */
+ public static function tab_bar(?\WP_Post $plate = null, ?\WP_Term $term = null): string {
+ $tabs = self::tabs($plate);
+ if (!$tabs) return '';
+ $active = self::active_index(count($tabs));
+ $base = remove_query_arg(['plate_tab', 'fpage']);
+ if ($term instanceof \WP_Term) $base = get_term_link($term);
+ $nav = $panes = '';
+ foreach ($tabs as $index => $tab) {
+ $id = 'plate-tab-' . $index;
+ $href = add_query_arg('plate_tab', $index, is_string($base) ? $base : Core::home_url());
+ $nav .= '
' . $tab['title'] . '';
+ $panes .= '
';
+ $panes .= $index === $active
+ ? self::pane($tab, $plate, $term, $index)
+ : '
' . HomeTabs::placeholder('posts', 3) . '
';
+ $panes .= '
';
+ }
+ return '
';
+ }
+
+ /** @return array
> */
+ public static function tabs(?\WP_Post $plate = null): array {
+ $tabs = [];
+ $configured = jio_get_setting('jio_forum_plate_tab_items', null);
+ if (is_array($configured) && $configured) {
+ foreach ($configured as $row) {
+ if (!is_array($row)) continue;
+ $tabs[] = [
+ 'title' => wp_kses_post((string) ($row['title'] ?? '') ?: '栏目'),
+ 'orderby' => sanitize_key((string) ($row['orderby'] ?? 'date')),
+ 'filter' => sanitize_key((string) ($row['filter'] ?? '')),
+ 'style' => sanitize_key((string) ($row['style'] ?? 'detail')),
+ ];
+ }
+ } else {
+ foreach ([
+ ['最新发布', 'date', '', 'detail'],
+ ['最近回复', 'last_reply', '', 'detail'],
+ ['热门', 'views', '', 'detail'],
+ ['精华', 'modified', 'essence', 'detail'],
+ ] as [$title, $orderby, $filter, $style]) {
+ $tabs[] = ['title' => $title, 'orderby' => $orderby, 'filter' => $filter, 'style' => $style];
+ }
+ }
+ $user_id = get_current_user_id();
+ if ($plate instanceof \WP_Post && Access::is_moderator($user_id, $plate->ID)) {
+ $tabs[] = ['title' => '待审核', 'orderby' => 'date', 'filter' => '', 'style' => 'mini', 'status' => 'pending'];
+ $tabs[] = ['title' => '回收站', 'orderby' => 'date', 'filter' => '', 'style' => 'mini', 'status' => 'trash'];
+ }
+ return $tabs;
+ }
+
+ public static function active_index(int $count): int {
+ $raw = $_GET['plate_tab'] ?? 0;
+ $index = (is_scalar($raw) ? absint($raw) : 0) ?: (int) jio_get_setting('jio_forum_plate_tab_active', 1);
+ return $index >= 1 && $index <= $count ? $index : 1;
+ }
+
+ private static function pane(array $tab, ?\WP_Post $plate, ?\WP_Term $term, int $index): string {
+ $page = max(1, is_scalar($_GET['fpage'] ?? 1) ? absint($_GET['fpage'] ?? 1) : 1);
+ $args = [
+ 'post_type' => Core::POST,
+ 'post_status' => $tab['status'] ?? 'publish',
+ 'posts_per_page' => max(1, min(50, (int) jio_get_setting('jio_forum_page_size', 20))),
+ 'paged' => $page,
+ 'jio_forum_list' => $tab['orderby'],
+ ];
+ if ($plate instanceof \WP_Post) {
+ $args['meta_query'][] = ['key' => '_jio_forum_plate_id', 'value' => $plate->ID];
+ $topping = self::topping($plate->ID, $tab);
+ if ($topping) {
+ $args['post__not_in'] = $topping;
+ }
+ } elseif ($term instanceof \WP_Term) {
+ $args['tax_query'][] = ['taxonomy' => $term->taxonomy, 'terms' => [$term->term_id]];
+ }
+ if (($tab['filter'] ?? '') === 'essence') {
+ $args['meta_query'][] = ['key' => '_jio_forum_essence', 'value' => 1];
+ }
+ $query = new \WP_Query($args);
+ $html = '';
+ if ($plate instanceof \WP_Post && ($tab['status'] ?? '') === '') {
+ Cards::prime($topping_posts = self::topping_posts($plate->ID, $tab));
+ foreach ($topping_posts as $post) {
+ $html .= Cards::post_mini($post, 'ajax-item', true);
+ }
+ }
+ if ($query->have_posts()) {
+ Cards::prime($query->posts);
+ foreach ($query->posts as $post) {
+ $html .= ($tab['style'] ?? 'detail') === 'detail'
+ ? Cards::post_detail($post, 'ajax-item')
+ : Cards::post_mini($post, 'ajax-item');
+ }
+ }
+ $base = $plate instanceof \WP_Post ? get_permalink($plate) : ($term instanceof \WP_Term ? get_term_link($term) : Core::home_url());
+ $next = add_query_arg(['plate_tab' => $index, 'fpage' => $page + 1], is_string($base) ? $base : Core::home_url());
+ if ($page < (int) $query->max_num_pages) {
+ $html .= '';
+ }
+ $html .= '' . HomeTabs::placeholder('posts', 3) . '
';
+ if ($html !== '' && trim(wp_strip_all_tags($html)) !== '') return $html;
+ $empty = jio_get_ajax_null('内容空空如也');
+ if ($index === 1 && $page === 1 && $plate instanceof \WP_Post) {
+ $empty .= '';
+ }
+ return $empty;
+ }
+
+ /** @return int[] */
+ private static function topping(int $plate_id, array $tab): array {
+ if (($tab['filter'] ?? '') === 'essence' || ($tab['status'] ?? '') !== '') return [];
+ return wp_list_pluck(self::topping_posts($plate_id, $tab), 'ID');
+ }
+
+ /** @return \WP_Post[] */
+ private static function topping_posts(int $plate_id, array $tab): array {
+ return get_posts([
+ 'post_type' => Core::POST,
+ 'post_status' => 'publish',
+ 'posts_per_page' => 5,
+ 'meta_query' => [
+ ['key' => '_jio_forum_plate_id', 'value' => $plate_id],
+ ['key' => '_jio_forum_pinned', 'value' => 1],
+ ],
+ ]);
+ }
+}
diff --git a/src/Forum/PostView.php b/src/Forum/PostView.php
new file mode 100644
index 00000000..7f9efe81
--- /dev/null
+++ b/src/Forum/PostView.php
@@ -0,0 +1,190 @@
+post_type !== Core::POST) {
+ return;
+ }
+ $id = (int) $post->ID;
+ $plate_id = (int) get_post_meta($id, '_jio_forum_plate_id', true);
+ $type = (string) get_post_meta($id, '_jio_forum_type', true);
+ $user_id = get_current_user_id();
+ $can_view = Access::can_view($user_id, $id);
+
+ echo '';
+ echo '
';
+ \Jio\WidgetSystem\WidgetAreas::render('forum_post_top_fluid');
+ do_action('bbs_posts_page_content_top');
+ if ($can_view) echo self::cover($post);
+ echo self::breadcrumbs($post);
+ echo '
';
+ echo '' . self::title($post) . self::meta($post) . '
';
+ echo self::content($post, $can_view);
+ echo self::footer($post);
+ echo '';
+ if ($can_view) {
+ self::comments($id);
+ }
+ do_action('bbs_posts_page_content_bottom');
+ \Jio\WidgetSystem\WidgetAreas::render('forum_post_bottom_fluid');
+ echo '
';
+ if (jio_is_show_sidebar()) {
+ echo '';
+ }
+ echo '
';
+ }
+
+ private static function breadcrumbs(\WP_Post $post): string {
+ $plate_id = (int) get_post_meta($post->ID, '_jio_forum_plate_id', true);
+ $icon = ' ';
+ $html = '';
+ }
+
+ private static function cover(\WP_Post $post): string {
+ if (!has_post_thumbnail($post)) return '';
+ return '' . get_the_post_thumbnail($post, 'large', ['class' => 'fit-cover']) . '
';
+ }
+
+ private static function title(\WP_Post $post): string {
+ return Cards::title($post, 'article-title', '', true);
+ }
+
+ private static function meta(\WP_Post $post): string {
+ $author = (int) $post->post_author;
+ $name = function_exists('jio_get_user_name') ? jio_get_user_name(['id' => $author, 'class' => '', 'name_class' => 'focus-color']) : esc_html((string) get_the_author_meta('display_name', $author));
+ $left = '' . $name
+ . '' . esc_html(Cards::ago($post->post_date)) . '
';
+ $right = ''
+ . '- ' . esc_html(jio_cut_count((int) get_post_meta($post->ID, '_jio_forum_views', true))) . '
'
+ . ''
+ . '
';
+ return '' . $left . $right . '
';
+ }
+
+ private static function content(\WP_Post $post, bool $can_view): string {
+ $id = (int) $post->ID;
+ if (!$can_view) {
+ return '' . self::locked($post) . '
';
+ }
+ $html = '' . apply_filters('the_content', $post->post_content) . '
';
+ $type = (string) get_post_meta($id, '_jio_forum_type', true);
+ if ($type === 'vote') {
+ $html .= self::vote($post);
+ }
+ return $html;
+ }
+
+ private static function locked(\WP_Post $post): string {
+ $id = (int) $post->ID;
+ $plate_id = (int) get_post_meta($id, '_jio_forum_plate_id', true);
+ $user_id = get_current_user_id();
+ $html = jio_get_null('抱歉!您暂无查看此内容的权限', 10, 'null-post.svg');
+ if ($user_id <= 0) {
+ return $html . '登录后查看
';
+ }
+ $plate_rule = (string) get_post_meta($plate_id, '_jio_forum_view_limit', true);
+ $post_rule = (string) get_post_meta($id, '_jio_forum_view_limit', true);
+ $action = '';
+ if (in_array($plate_rule, ['points', 'pay'], true) && !Commerce::has_plate_entitlement($user_id, $plate_id)) {
+ $action = '';
+ } elseif (($plate_rule === 'follow' || $post_rule === 'follow') && !Access::follows($user_id, $plate_id)) {
+ $action = Cards::follow_button($plate_id, 'but jb-blue padding-lg');
+ } elseif (in_array($post_rule, ['points', 'pay'], true)) {
+ $action = '';
+ }
+ return $html . ($action !== '' ? '' . $action . '
' : '');
+ }
+
+ private static function vote(\WP_Post $post): string {
+ $id = (int) $post->ID;
+ $options = (array) get_post_meta($id, '_jio_forum_vote_options', true);
+ if (!$options) return '';
+ $counts = (array) get_post_meta($id, '_jio_forum_vote_counts', true);
+ $total = (int) get_post_meta($id, '_jio_forum_vote_total', true);
+ $vote_type = (string) get_post_meta($id, '_jio_forum_vote_type', true);
+ $deadline = (int) get_post_meta($id, '_jio_forum_vote_deadline', true);
+ $expired = $deadline > 0 && current_time('timestamp') > $deadline;
+ $display = (string) jio_get_setting('jio_forum_vote_number_type', 'percentage');
+ $voted = get_current_user_id() > 0 && get_user_meta(get_current_user_id(), '_jio_forum_vote_' . $id, true) !== '';
+ $html = '';
+ }
+
+ private static function footer(\WP_Post $post): string {
+ $id = (int) $post->ID;
+ $user_id = get_current_user_id();
+ $plate_id = (int) get_post_meta($id, '_jio_forum_plate_id', true);
+ $html = '';
+ foreach (is_array($terms = wp_get_post_terms($id, Core::TOPIC)) ? $terms : [] as $term) {
+ $html .= '
' . esc_html($term->name) . '';
+ }
+ foreach (is_array($tags = wp_get_post_terms($id, Core::TAG)) ? $tags : [] as $term) {
+ $html .= '
' . esc_html($term->name) . '';
+ }
+ $html .= '
';
+ $html .= '';
+ if (jio_get_setting('jio_forum_score_enabled', true)) {
+ $score = (int) get_post_meta($id, '_jio_forum_score', true);
+ $html .= '';
+ $html .= '';
+ }
+ $html .= '
';
+ if (function_exists('jio_get_post_share_btn')) $html .= jio_get_post_share_btn($post, 'item', true, false);
+ $html .= '
';
+ $html .= '
';
+ if (Access::can_manage_post($user_id, $id)) {
+ $html .= '
编辑';
+ $html .= '
';
+ }
+ $html .= '
';
+ $html .= '';
+ if (get_post_meta($id, '_jio_forum_type', true) === 'question' && get_post_meta($id, '_jio_forum_adopted_comment', true)) {
+ $html .= '已采纳回复
';
+ }
+ return $html;
+ }
+
+ private static function comments(int $post_id): void {
+ echo '';
+ View::replies($post_id);
+ echo '
';
+ }
+}
diff --git a/src/Forum/RestPosts.php b/src/Forum/RestPosts.php
index 9ee1e6e9..104a9bb8 100644
--- a/src/Forum/RestPosts.php
+++ b/src/Forum/RestPosts.php
@@ -107,6 +107,13 @@ trait RestPosts {
return self::fail('已有采纳回复后不能更改帖子类型');
}
$status = Access::is_moderator($user_id, $plate_id) || !jio_get_setting('jio_forum_post_review', true) ? 'publish' : 'pending';
+ // Authors may park their own post as a draft from the compose page.
+ $requested = sanitize_key((string) ($input['post_status'] ?? ''));
+ if ($requested === 'draft' && !($current && in_array($current->post_status, ['publish', 'pending'], true))) {
+ $status = 'draft';
+ } elseif ($requested !== '' && $current && Access::is_moderator($user_id, $plate_id) && in_array($requested, ['publish', 'pending', 'draft'], true)) {
+ $status = $requested;
+ }
$post_data = [
'ID' => $id, 'post_type' => Core::POST, 'post_title' => $title, 'post_content' => $content,
'post_status' => $status, 'post_author' => $current ? (int) $current->post_author : $user_id,
@@ -124,6 +131,12 @@ trait RestPosts {
update_post_meta($id, '_jio_forum_type', $type);
update_post_meta($id, '_jio_forum_view_limit', $view);
update_post_meta($id, '_jio_forum_price', $price);
+ $cover_id = absint($input['featured_image_id'] ?? 0);
+ if ($cover_id > 0 && wp_attachment_is_image($cover_id)) {
+ set_post_thumbnail($id, $cover_id);
+ } elseif (array_key_exists('featured_image_id', $input)) {
+ delete_post_thumbnail($id);
+ }
foreach (['vip1', 'vip2'] as $vip) {
if (!isset($input['price_' . $vip])) continue;
$vip_price = (string) $input['price_' . $vip];
diff --git a/src/Forum/View.php b/src/Forum/View.php
index e769ad80..e18b78b7 100644
--- a/src/Forum/View.php
+++ b/src/Forum/View.php
@@ -44,67 +44,7 @@ final class View {
}
public static function listing(): void {
- $obj = get_queried_object();
- $plate = $obj instanceof \WP_Post && $obj->post_type === Core::PLATE ? $obj : null;
- $term = $obj instanceof \WP_Term ? $obj : null;
- $title = $plate ? $plate->post_title : ($term ? $term->name : '论坛');
- $context = $plate ? 'plate' : 'term';
- self::open($title, $context);
- \Jio\WidgetSystem\WidgetAreas::render('forum_' . $context . '_top_fluid');
- echo '';
- echo '
' . esc_html($title) . '
';
- if ($plate) {
- echo '' . wp_kses_post(wpautop($plate->post_excerpt ?: $plate->post_content)) . '
';
- echo '' . (int) get_post_meta($plate->ID, '_jio_forum_follow_count', true) . ' 人关注
';
- echo Cards::follow_button($plate->ID, 'but c-blue'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
- if (Access::is_moderator(get_current_user_id(), $plate->ID)) {
- self::plate_editor($plate);
- }
- if (is_user_logged_in() && !Access::is_moderator(get_current_user_id(), $plate->ID) && jio_get_setting('jio_forum_moderator_apply', true)) {
- self::action('apply', $plate->ID, '申请版主');
- }
- if (Access::is_admin(get_current_user_id()) || (int) $plate->post_author === get_current_user_id()) {
- self::applications($plate->ID);
- }
- } elseif ($term) {
- echo '' . esc_html($term->description) . '
';
- }
- echo '';
- self::tabs();
- if ($term && $term->taxonomy === Core::CATEGORY) {
- self::plates(['tax_query' => [['taxonomy' => Core::CATEGORY, 'terms' => [$term->term_id]]]]);
- } else {
- $args = ['post_type' => Core::POST, 'post_status' => 'publish'];
- if ($plate) {
- $args['meta_query'] = [['key' => '_jio_forum_plate_id', 'value' => $plate->ID]];
- } elseif ($term) {
- $args['tax_query'] = [['taxonomy' => $term->taxonomy, 'terms' => [$term->term_id]]];
- }
- self::posts($args, $context);
- if ($plate && Access::is_moderator(get_current_user_id(), $plate->ID)) {
- echo '
待审核帖子
';
- $args['post_status'] = 'pending';
- self::posts($args, $context);
- }
- }
- echo '
';
- self::sidebar($context);
- echo '
';
- \Jio\WidgetSystem\WidgetAreas::render('forum_' . $context . '_bottom_fluid');
- self::close();
- }
-
- /** Listing pages (plate / taxonomy) use a fixed, always-available tab set. */
- public static function tabs(): void {
- $tabs = ['latest' => '最新', 'popular' => '热门', 'essence' => '精华', 'question' => '问答', 'vote' => '投票'];
- $base_url = is_singular(Core::PLATE) ? get_permalink(get_queried_object_id()) : (is_tax([Core::CATEGORY, Core::TOPIC, Core::TAG]) ? get_term_link(get_queried_object()) : Core::home_url());
- echo '';
+ ListingView::plate();
}
public static function posts(array $args, string $context = 'home'): void {
diff --git a/templates/pages/page-forum-edit.php b/templates/pages/page-forum-edit.php
index 67e30329..8a88ccb5 100644
--- a/templates/pages/page-forum-edit.php
+++ b/templates/pages/page-forum-edit.php
@@ -1,57 +1,4 @@
0 ? get_post($edit_id) : null;
-if (!is_user_logged_in()) {
- wp_safe_redirect(jio_get_login_url(Core::edit_url($edit_id)));
- exit;
-}
-if ($edit_id > 0 && (!$post instanceof WP_Post || !Access::can_manage_post(get_current_user_id(), $edit_id))) {
- status_header(403);
- wp_die(esc_html__('无权编辑此帖子', 'jio'));
-}
-
-wp_enqueue_editor();
-
-$plate_id = $post ? (int) get_post_meta($post->ID, '_jio_forum_plate_id', true) : absint($_GET['plate'] ?? 0);
-$vote_started = $post ? (int) get_post_meta($post->ID, '_jio_forum_vote_started', true) : 0;
-$vote_deadline = $post ? (int) get_post_meta($post->ID, '_jio_forum_vote_deadline', true) : 0;
-$vote_days = $vote_started && $vote_deadline ? (int) round(($vote_deadline - $vote_started) / DAY_IN_SECONDS) : 0;
-$plates = get_posts(['post_type' => Core::PLATE, 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC']);
-View::open($post ? '编辑帖子' : '发布帖子', 'edit');
-?>
-
-
-
-
-
+\Jio\Forum\EditView::render();
diff --git a/templates/pages/page-forum-post.php b/templates/pages/page-forum-post.php
index ad8a052c..42624e70 100644
--- a/templates/pages/page-forum-post.php
+++ b/templates/pages/page-forum-post.php
@@ -1,85 +1,5 @@
ID;
-$plate_id = (int) get_post_meta($id, '_jio_forum_plate_id', true);
-$type = (string) get_post_meta($id, '_jio_forum_type', true);
-$user_id = get_current_user_id();
-$can_view = Access::can_view($user_id, $id);
-$can_manage = Access::can_manage_post($user_id, $id);
-$can_moderate = Access::is_moderator($user_id, $plate_id);
-View::open($post->post_title, 'post');
-\Jio\WidgetSystem\WidgetAreas::render('forum_post_top_fluid');
-?>
-
-
-
-
- post_content); ?>
-
- 0 && current_time('timestamp') > $deadline;
- $vote_display = (string) jio_get_setting('jio_forum_vote_number_type', 'percentage');
- $already_voted = $user_id > 0 && get_user_meta($user_id, '_jio_forum_vote_' . $id, true) !== '';
- ?>
-
-
-
- 内容已隐藏
请完成访问条件后查看。
- 0) : ?>
- 关注版块';
- } elseif (in_array($post_rule, ['points', 'pay'], true)) {
- View::action('unlock', $id, '解锁帖子');
- }
- ?>
- 登录查看
-
-
-
-
-
-
-
-
编辑
- comment_status === 'open' ? '关闭回复' : '开放回复'); View::action('move', $id, '移动版块'); endif; ?>
-
-
-
-
已采纳回复 #
-
-
-
-
+\Jio\Forum\PostView::render();
diff --git a/tests/forum/pages-contract.test.cjs b/tests/forum/pages-contract.test.cjs
new file mode 100644
index 00000000..29fc651c
--- /dev/null
+++ b/tests/forum/pages-contract.test.cjs
@@ -0,0 +1,63 @@
+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')
+
+test('plate pages render the reference header card, tab bar and list', () => {
+ const listing = read('src/Forum/ListingView.php')
+ const cards = read('src/Forum/Cards.php')
+ const view = read('src/Forum/View.php')
+ assert.match(view, /public static function listing\(\): void \{\s*ListingView::plate\(\);/)
+ for (const token of ['plate-tab', 'tab-nav-theme', 'affix-header-sm', 'data-forum-tab', 'data-forum-more', 'post_ajax_loader', 'content-wrap', 'content-layout', 'sidebar']) {
+ assert.ok(listing.includes(token), `plate page missing ${token}`)
+ }
+ // Reference header: blurred cover + mask + stats + moderator avatars.
+ for (const token of ['plate-header-wrap', 'forum-header blur-header', 'abs-blur-bg', 'forum-mask', 'moderator-btns', 'count-mates']) {
+ assert.ok(cards.includes(token), `plate header missing ${token}`)
+ }
+ // Moderator-only tabs (pending / trash) appear on plate pages.
+ assert.match(listing, /'pending'/)
+ assert.match(listing, /'trash'/)
+ // Topping posts are pulled out of the main loop on the first tab.
+ assert.match(listing, /_jio_forum_pinned/)
+ assert.match(listing, /post__not_in/)
+})
+
+test('post detail pages use the reference article shell', () => {
+ const post = read('src/Forum/PostView.php')
+ const template = read('templates/pages/page-forum-post.php')
+ assert.match(template, /PostView::render\(\)/)
+ for (const token of ['fixed-wrap single-wrap', 'fixed-wrap-content', 'forum-single-cover', 'breadcrumb', 'id="article"', 'article zib-widget forum-article', 'article-header clearfix', 'forum-article-meta', 'wp-posts-content', 'article-tags', 'forum-article-footer', 'footer-actions', 'vote-box forum-vote', 'comments-card']) {
+ assert.ok(post.includes(token), `post page missing ${token}`)
+ }
+ // Banners, permissions and actions stay server-side.
+ assert.match(post, /Access::can_view\(/)
+ assert.match(post, /Cards::title\(\$post, 'article-title'/)
+ assert.match(post, /data-forum-action="score"/)
+ assert.match(post, /jio_is_show_sidebar\(\)/)
+})
+
+test('compose pages mirror the reference editor plus settings sidebar', () => {
+ const edit = read('src/Forum/EditView.php')
+ const template = read('templates/pages/page-forum-edit.php')
+ assert.match(template, /EditView::render\(\)/)
+ for (const token of ['editor-main-box', 'edit-posts-featured', 'edit-posts-title', 'line-form-input', 'jio_forum_content', 'bbs-posts-edit', 'but-average', 'bbs-posts-submit', 'data-forum-draft', 'drop-select', 'allow-view-drop', 'plate-drop', 'type-drop', 'forum-type-vote']) {
+ assert.ok(edit.includes(token), `compose page missing ${token}`)
+ }
+ assert.match(edit, /class="sidebar show-sidebar"/)
+ assert.match(edit, /wp_editor\(/)
+})
+
+test('compose page saves draft state and the cover through the REST contract', () => {
+ const rest = read('src/Forum/RestPosts.php')
+ const runtime = read('assets/js/forum-runtime.js')
+ assert.match(rest, /\$requested === 'draft'/)
+ assert.match(rest, /featured_image_id/)
+ assert.match(rest, /set_post_thumbnail\(/)
+ for (const token of ['data-forum-type-set', 'data-forum-view', 'data-forum-plate', 'data-forum-status', 'data-forum-topics', 'data-forum-tags', 'data-forum-cover-file']) {
+ assert.ok(runtime.includes(token), `compose runtime missing ${token}`)
+ }
+})
diff --git a/tools/admin-contract-report.json b/tools/admin-contract-report.json
index 9a18bb7a..0a1fc009 100644
--- a/tools/admin-contract-report.json
+++ b/tools/admin-contract-report.json
@@ -1,5 +1,5 @@
{
- "generated_at": "2026-11-11T07:46:58+00:00",
+ "generated_at": "2026-11-11T08:16:48+00:00",
"errors": [],
"warnings": [
"customPage tools-update section 'tools/update' not in PHP schema (ok if pure operational page)",