feat(forum): add quick publish, captcha and hidden content

Port the reference inline quick-publish composer to the forum home:
title/body inputs, image upload, plate/topic/access/hidden-content
dropdowns and save/publish actions. Wire the site captcha into forum
publishing through a new `forum_post` scene (admin + REST gate), and
turn the hidden-content box into the theme's own visibility shortcodes
(login/comment/vip/payshow) so the existing gate renderer unlocks them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-11-11 16:37:31 +08:00
1 parent 5d3358d844
commit 7a59c65252
11 files changed
+204 -20

No files matched your search

@@ -84,6 +84,7 @@ export const permissionsSchemas = {
{ label: '资源下载', value: 'download' },
{ label: '每日签到', value: 'signin' },
{ label: '前台投稿', value: 'front_submit' },
{ label: '论坛发帖', value: 'forum_post' },
{ label: '打赏用户', value: 'tip_user' },
{ label: '提交工单', value: 'ticket_create' },
{ label: '回复工单', value: 'ticket_reply' },
+16
View File
@@ -51,6 +51,22 @@
}
.quick-posts-box{border-radius:var(--main-radius);padding:15px}
.quick-posts-box .title-theme{margin-bottom:10px}
.quick-input-group{padding:15px;border:1px solid var(--main-border-color);border-radius:var(--main-radius)}
.quick-posts-box .edit-posts-title{position:relative}
.quick-posts-box .edit-posts-title .line-form-line{display:none}
.quick-posts-box .edit-posts-title textarea{width:100%;color:var(--main-color);padding:0;margin-bottom:10px;font-weight:500;font-size:18px;border:0;border-bottom:1px solid var(--main-border-color);background:transparent;resize:none;outline:0}
.quick-posts-box .edit-posts-content textarea{outline:0!important;border:none;display:block;width:100%;padding:0;background:transparent;color:var(--main-color);resize:vertical;min-height:88px}
.quick-posts-box .preview .add,.quick-posts-box .preview .preview-item{width:90px;height:90px;border:1px dashed var(--main-border-color);background:none;margin:2px;border-radius:var(--main-radius);position:relative}
.quick-posts-box .preview .add:before{content:"+";position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:28px;color:var(--muted-3-color)}
.quick-posts-box .drop-select .dropdown-menu{min-width:320px}
.quick-more-btns{color:var(--muted-color);position:relative;flex-wrap:wrap;gap:6px}
.quick-more-btns .drop-btn{padding:6px 4px;cursor:pointer}
.quick-more-btns .drop-select{position:relative}
.quick-posts-submit .but{padding:6px 5px;min-width:85px}
.quick-posts-submit .quick-captcha{margin-bottom:8px}
.hide-type-drop .select-drop-box .badg.active{--this-bg:var(--focus-color);--this-color:#fff}
.hide-type-drop textarea{margin-top:8px;width:100%}
.jio-forum__captcha{margin-bottom:15px}
.forum-home .jio-widget-box{clear:both;background-color:var(--main-bg-color);padding:15px;box-shadow:0 0 10px var(--main-shadow);border-radius:var(--main-radius);margin-bottom:20px}
.forum-home .jio-widget-box .btn-block{display:block;width:100%;text-align:center}
.forum-home .jio-widget-box .title-theme{font-size:15px;font-weight:600}
+15
View File
@@ -134,6 +134,21 @@
syncHidden('post_status', status.dataset.forumStatus)
return
}
const hideType = event.target.closest('[data-forum-hide-type]')
if (hideType) {
root.querySelectorAll('[data-forum-hide-type]').forEach((item) => item.classList.toggle('active', item === hideType))
syncHidden('hide_type', hideType.dataset.forumHideType)
return
}
const topic = event.target.closest('[data-forum-topic],[data-forum-topic-none]')
if (topic) {
const name = topic.dataset.forumTopic || ''
root.querySelectorAll('[data-forum-topic],[data-forum-topic-none]').forEach((item) => item.classList.toggle('active', item === topic))
syncHidden('topics', name)
const label = root.querySelector('[name="topics"]')
if (label) label.lastChild.textContent = name || '话题'
return
}
const pick = event.target.closest('[data-forum-cover-pick]')
if (pick) {
root.querySelector('[data-forum-cover-file]')?.click()
+1
View File
@@ -374,6 +374,7 @@ function jio_captcha_scenes(): array {
'download' => __('资源下载', 'jio'),
'signin' => __('每日签到', 'jio'),
'front_submit' => __('前台投稿', 'jio'),
'forum_post' => __('论坛发帖', 'jio'),
'tip_user' => __('打赏用户', 'jio'),
'ticket_create' => __('提交工单', 'jio'),
'ticket_reply' => __('回复工单', 'jio'),
+1
View File
@@ -80,6 +80,7 @@ class SchemaPermissions {
['label' => '资源下载', 'value' => 'download'],
['label' => '每日签到', 'value' => 'signin'],
['label' => '前台投稿', 'value' => 'front_submit'],
['label' => '论坛发帖', 'value' => 'forum_post'],
['label' => '打赏用户', 'value' => 'tip_user'],
['label' => '提交工单', 'value' => 'ticket_create'],
['label' => '回复工单', 'value' => 'ticket_reply'],
+18
View File
@@ -71,6 +71,12 @@ final class EditView {
echo self::term_box('tags', '标签', Core::TAG, $tags, 'fa-tag');
echo self::type_box($type);
echo self::vote_box($vote_options, $post);
echo self::hide_box($post);
if (!$post && function_exists('jio_render_captcha') && function_exists('jio_is_captcha_enabled_for') && jio_is_captcha_enabled_for('forum_post')) {
echo '<div class="zib-widget mb10-sm mb20 jio-forum__captcha">';
jio_render_captcha('forum_post');
echo '</div>';
}
\Jio\WidgetSystem\WidgetAreas::render('forum_edit_sidebar');
echo '</div>';
self::close();
@@ -150,6 +156,18 @@ final class EditView {
return '<div class="type-drop zib-widget mb10-sm mb20"><div class="flex ac jsb"><div class="flex ac"><i class="fa fas fa-delicious mr6"></i>发布类型</div><div class="but-average radius em09">' . $list . '</div></div></div>';
}
/** Hidden-content composer: the block becomes a site visibility shortcode on save. */
private static function hide_box(?\WP_Post $post): string {
$types = '';
foreach (['logged' => '登录可见', 'reply' => '评论可见', 'vip1' => '会员可见', 'payshow' => '付费可见'] as $key => $label) {
$types .= '<span data-forum-hide-type="' . $key . '" class="mb6 mr6 badg p2-10 pointer' . ($key === 'logged' ? ' active' : '') . '">' . esc_html($label) . '</span>';
}
return '<div class="hide-type-drop drop-select dropdown zib-widget mb10-sm mb20"><div data-toggle-class="open" data-target=".hide-type-drop" class="flex ac jsb drop-btn"><span class="flex ac"><i class="far fa-eye-slash mr6"></i>隐藏内容</span><i class="ml6 fa fas fa-angle-right em12"></i></div>'
. '<input type="hidden" name="hide_type" value="">'
. '<div class="dropdown-menu fluid"><div class="mini-scrollbar padding-w10 select-drop-box"><div class="em09 muted-3-color mb10">添加额外的隐藏内容</div><div class="flex hh">' . $types . '</div>'
. '<textarea rows="6" name="hide_content" class="form-control input-textarea" placeholder="在此输入需要隐藏的内容"></textarea></div></div></div>';
}
/** @param string[] $options */
private static function vote_box(array $options, ?\WP_Post $post): string {
$type = $post ? (string) get_post_meta($post->ID, '_jio_forum_type', true) : 'normal';
+81 -19
View File
@@ -5,34 +5,96 @@ namespace Jio\Forum;
defined('ABSPATH') || exit;
final class QuickPost {
/** Inline "publish now" box shown on the configured home tab. */
/** Inline "publish now" box shown on the configured home tab (reference layout). */
public static function render(): string {
$user_id = get_current_user_id();
if (!$user_id) {
return '<div class="ajax-item-header jio-widget-box quick-posts-box mb20"><p class="muted-2-color">登录后即可快速发布' . esc_html(self::post_name()) . '</p><a class="but jb-blue padding-lg" href="' . esc_url(jio_get_login_url(Core::home_url())) . '"><i class="fa fas fa-sign-in-alt mr6"></i>登录</a></div>';
$post_name = (string) jio_get_setting('jio_forum_post_name', '帖子') ?: '帖子';
$plate_name = (string) jio_get_setting('jio_forum_plate_name', '版块') ?: '版块';
$topic_name = (string) jio_get_setting('jio_forum_topic_name', '话题') ?: '话题';
$plates = get_posts(['post_type' => Core::PLATE, 'post_status' => 'publish', 'posts_per_page' => 200, 'orderby' => 'title', 'order' => 'ASC']);
if (!$plates) return '';
$html = '<form class="quick-posts-box zib-widget mb10-sm mb20" data-forum-form="post"><div class="quick-input-group mb10">';
$html .= '<div class="relative edit-posts-title"><textarea class="line-form-input input-lg new-title" name="title" rows="1" maxlength="120" tabindex="1" placeholder="' . esc_attr((string) jio_get_setting('jio_forum_title_placeholder', '请输入标题')) . '"></textarea><i class="line-form-line"></i></div>';
$html .= '<div class="relative edit-posts-content"><textarea class="form-control" name="content" tabindex="2" rows="4" placeholder="' . esc_attr((string) jio_get_setting('jio_forum_content_placeholder', '请输入内容')) . '"></textarea></div>';
if (jio_get_setting('jio_forum_upload_image', true)) {
$html .= '<div class="' . ($user_id ? 'quick-upload' : 'signin-loader') . '"><div class="preview"><div class="add"></div></div><input type="file" accept="image/*" data-forum-file data-kind="image" hidden></div>';
}
$plates = get_posts(['post_type' => Core::PLATE, 'post_status' => 'publish', 'posts_per_page' => 100, 'orderby' => 'title', 'order' => 'ASC']);
$html .= '</div>';
// More options: plate / topic / access / hidden content
$html .= '<div class="flex ac quick-more-btns hh">';
$html .= self::plate_dropdown($plates, $plate_name, $user_id);
$html .= self::topic_dropdown($topic_name, $user_id);
$html .= self::access_dropdown($user_id);
if ($user_id) {
$html .= self::hide_dropdown();
}
$html .= '</div>';
// Submit row + captcha
$html .= '<div class="flex ac jsb hh quick-posts-submit mt10">';
if (function_exists('jio_render_captcha') && function_exists('jio_is_captcha_enabled_for') && jio_is_captcha_enabled_for('forum_post')) {
$html .= '<div class="quick-captcha">';
ob_start();
jio_render_captcha('forum_post');
$html .= (string) ob_get_clean();
$html .= '</div>';
}
$html .= '<div class="flex ac">';
if ($user_id) {
$html .= '<button type="button" class="but c-green mr6" data-forum-draft tabindex="3">保存</button>';
$html .= '<button type="submit" class="but jb-blue" tabindex="4">发布</button>';
} else {
$html .= '<a class="but jb-blue signin-loader" href="' . esc_url(jio_get_login_url(Core::home_url())) . '">登录后发布</a>';
}
$html .= '</div></div>';
$html .= '<input type="hidden" name="plate_id" value=""><input type="hidden" name="topics" value=""><input type="hidden" name="view_limit" value="all"><input type="hidden" name="hide_type" value="">';
$html .= '<p class="jio-forum__message mt6 muted-2-color" role="status"></p>';
return $html . '</form>';
}
/** @param \WP_Post[] $plates */
private static function plate_dropdown(array $plates, string $plate_name, int $user_id): string {
$options = '';
foreach ($plates as $plate) {
$options .= '<option value="' . (int) $plate->ID . '">' . esc_html($plate->post_title) . '</option>';
$options .= '<div data-forum-plate="' . (int) $plate->ID . '" class="pointer but p2-10 mb6">' . esc_html($plate->post_title) . '</div>';
}
if ($options === '') return '';
return '<div class="ajax-item-header jio-widget-box quick-posts-box mb20">'
. '<form data-forum-form="post" class="quick-posts-form"><h4 class="title-theme mb10">快速发布' . esc_html(self::post_name()) . '</h4>'
. '<input class="form-control mb10" type="text" name="title" required maxlength="120" placeholder="输入标题" aria-label="标题">'
. '<textarea class="form-control mb10" name="content" rows="3" required placeholder="说点什么…" aria-label="正文"></textarea>'
. '<div class="flex ac jsb">'
. '<select class="form-control w-auto" name="plate_id" required aria-label="' . esc_attr(self::plate_name()) . '"><option value="">选择' . esc_html(self::plate_name()) . '</option>' . $options . '</select>'
. '<button type="submit" class="but jb-blue"><i class="fa fas fa-paper-plane mr6"></i>发布</button>'
. '</div><p class="jio-forum__message mt6 muted-2-color" role="status"></p></form></div>';
if ($user_id <= 0) {
return '<div class="plate-drop signin-loader mr20 mt10"><div class="flex ac jsb drop-btn"><span class="flex ac"><i class="fa fas fa-cube mr6"></i>请选择' . esc_html($plate_name) . '</span><i class="ml6 fa fas fa-angle-right em12"></i></div></div>';
}
return '<div class="plate-drop drop-select dropdown mr20 mt10"><div data-toggle-class="open" data-target=".plate-drop" class="flex ac jsb drop-btn"><span class="flex ac" name="plate_id"><i class="fa fas fa-cube mr6"></i><span>请选择' . esc_html($plate_name) . '</span></span><i class="ml6 fa fas fa-angle-right em12"></i></div><div class="dropdown-menu fluid"><div class="mini-scrollbar padding-w10 select-drop-box">' . $options . '</div></div></div>';
}
private static function post_name(): string {
return (string) jio_get_setting('jio_forum_post_name', '帖子') ?: '帖子';
private static function topic_dropdown(string $topic_name, int $user_id): string {
if ($user_id <= 0) {
return '<div class="flex ac jsb drop-btn mr20 signin-loader"><i class="fa fas fa-hashtag mr6"></i>' . esc_html($topic_name) . '</div>';
}
$terms = get_terms(['taxonomy' => Core::TOPIC, 'hide_empty' => false, 'number' => 100, 'orderby' => 'count', 'order' => 'DESC']);
$options = '<div data-forum-topic-none class="pointer but p2-10 mb6"><i class="fa fas fa-ban mr6"></i>不添加' . esc_html($topic_name) . '</div>';
foreach (is_array($terms) ? $terms : [] as $term) {
$options .= '<div data-forum-topic="' . esc_attr($term->name) . '" class="pointer but p2-10 mb6">' . esc_html($term->name) . '</div>';
}
return '<div class="topic-drop drop-select dropdown mr20"><div data-toggle-class="open" data-target=".topic-drop" class="flex ac jsb drop-btn"><span class="flex ac" name="topics"><i class="fa fas fa-hashtag mr6"></i>' . esc_html($topic_name) . '</span><i class="ml6 fa fas fa-angle-right em12"></i></div><div class="dropdown-menu fluid"><div class="mini-scrollbar padding-w10 select-drop-box">' . $options . '</div></div></div>';
}
private static function plate_name(): string {
return (string) jio_get_setting('jio_forum_plate_name', '版块') ?: '版块';
private static function access_dropdown(int $user_id): string {
$labels = ['all' => '公开', 'login' => '登录后可查看', 'follow' => '关注版块后可查看', 'points' => '支付积分查看', 'pay' => '付费查看'];
if ($user_id <= 0) {
return '<div class="flex ac jsb drop-btn mr20 signin-loader"><i class="fa far fa-eye mr6"></i><span name="view_limit">公开</span></div>';
}
$options = '';
foreach ($labels as $key => $label) {
$options .= '<div data-forum-view="' . $key . '" class="pointer but p2-10 mb6' . ($key === 'all' ? ' active' : '') . '">' . esc_html($label) . '</div>';
}
return '<div class="allow-view-drop drop-select dropdown mr20"><div data-toggle-class="open" data-target=".allow-view-drop" class="flex ac jsb drop-btn"><span class="flex ac"><i class="far fa-eye mr6"></i><span name="view_limit">公开</span></span><i class="ml6 fa fas fa-angle-right em12"></i></div><div class="dropdown-menu fluid"><div class="mini-scrollbar padding-w10 select-drop-box">' . $options . '</div></div></div>';
}
private static function hide_dropdown(): string {
$items = '';
foreach (['logged' => '登录可见', 'reply' => '评论可见', 'vip1' => '会员可见', 'payshow' => '付费可见'] as $key => $label) {
$items .= '<span data-forum-hide-type="' . $key . '" class="mb6 mr6 badg p2-10 pointer' . ($key === 'logged' ? ' active' : '') . '">' . esc_html($label) . '</span>';
}
return '<div class="hide-type-drop drop-select dropdown mr20"><div data-toggle-class="open" data-target=".hide-type-drop" class="flex ac jsb drop-btn"><i class="fa far fa-eye-slash mr6"></i><span>隐藏内容</span><i class="ml6 fa fas fa-angle-right em12"></i></div><div class="dropdown-menu fluid"><div class="mini-scrollbar padding-w10 select-drop-box"><div class="em09 muted-3-color mb10">添加额外的隐藏内容</div><div class="flex hh">' . $items . '</div><textarea rows="6" name="hide_content" class="form-control input-textarea" placeholder="在此输入需要隐藏的内容"></textarea></div></div></div>';
}
}
+20
View File
@@ -94,6 +94,26 @@ final class Rest {
return is_array($json) ? $json : $request->get_params();
}
/**
* Quick-publish "hidden content" box becomes the site's own visibility
* shortcode, so the existing gate renderer owns unlock logic.
*/
private static function append_hidden_content(string $content, array $input): string {
$type = sanitize_key((string) ($input['hide_type'] ?? ''));
$body = trim((string) ($input['hide_content'] ?? ''));
if ($type === '' || $body === '') {
return $content;
}
$mode = ['logged' => 'login', 'reply' => 'comment', 'vip1' => 'vip', 'payshow' => 'payshow'][$type] ?? '';
if ($mode === '') {
return $content;
}
$safe = wp_kses_post($body);
$tag = $mode === 'payshow' ? 'payshow' : 'hidecontent type="' . $mode . '"';
$close = $mode === 'payshow' ? 'payshow' : 'hidecontent';
return rtrim($content) . "\n\n[" . $tag . ']' . $safe . '[/' . $close . ']';
}
private static function post(int $id): ?\WP_Post {
$post = get_post($id);
return $post instanceof \WP_Post && $post->post_type === Core::POST ? $post : null;
+10
View File
@@ -20,6 +20,15 @@ trait RestPosts {
return self::fail('无权编辑此帖子', 403);
}
$input = self::input($request);
if ($id === 0 && function_exists('jio_require_captcha')) {
// Same gate as the article submit flow; disabled scenes pass through.
$captcha = jio_require_captcha('forum_post', [
'jio_captcha_proof' => sanitize_text_field((string) ($input['jio_captcha_proof'] ?? '')),
]);
if (is_wp_error($captcha)) {
return self::fail($captcha->get_error_message(), 403);
}
}
$plate_id = absint($input['plate_id'] ?? ($current ? get_post_meta($id, '_jio_forum_plate_id', true) : 0));
if (!self::plate($plate_id) || !Access::can_publish($user_id, $plate_id)) {
return self::fail('没有在此版块发布的权限', 403);
@@ -35,6 +44,7 @@ trait RestPosts {
}
$title = sanitize_text_field((string) ($input['title'] ?? ($current->post_title ?? '')));
$content = wp_kses_post((string) ($input['content'] ?? ($current->post_content ?? '')));
$content = self::append_hidden_content($content, $input);
if (mb_strlen($title) < 2 || mb_strlen($title) > 120 || trim(wp_strip_all_tags($content)) === '') {
return self::fail('请填写标题和正文');
}
+40
View File
@@ -61,3 +61,43 @@ test('compose page saves draft state and the cover through the REST contract', (
assert.ok(runtime.includes(token), `compose runtime missing ${token}`)
}
})
test('quick publish mirrors the reference inline composer', () => {
const quick = read('src/Forum/QuickPost.php')
for (const token of ['quick-posts-box', 'quick-input-group', 'edit-posts-title', 'line-form-input', 'edit-posts-content', 'quick-upload', 'quick-more-btns', 'plate-drop', 'topic-drop', 'allow-view-drop', 'hide-type-drop', 'quick-posts-submit', 'data-forum-draft']) {
assert.ok(quick.includes(token), `quick publish missing ${token}`)
}
// Guests get the login trigger instead of the editor actions.
assert.match(quick, /signin-loader/)
assert.match(quick, /登录后发布/)
})
test('forum publishing is gated by the site captcha scene', () => {
const rest = read('src/Forum/RestPosts.php')
const captcha = read('inc/modules/captcha.php')
const schema = read('src/Admin/Schema/SchemaPermissions.php')
const vue = read('assets/admin/src/schema/sections/permissions/index.js')
const quick = read('src/Forum/QuickPost.php')
const edit = read('src/Forum/EditView.php')
assert.match(captcha, /'forum_post'\s*=>\s*__\('论坛发帖'/)
assert.match(schema, /\['label' => '论坛发帖', 'value' => 'forum_post'\]/)
assert.match(vue, /\{ label: '论坛发帖', value: 'forum_post' \}/)
assert.match(rest, /jio_require_captcha\('forum_post'/)
assert.match(rest, /jio_captcha_proof/)
assert.match(quick, /jio_render_captcha\('forum_post'\)/)
assert.match(edit, /jio_render_captcha\('forum_post'\)/)
})
test('hidden content becomes the site visibility shortcode on save', () => {
const rest = read('src/Forum/Rest.php')
const posts = read('src/Forum/RestPosts.php')
const quick = read('src/Forum/QuickPost.php')
const edit = read('src/Forum/EditView.php')
assert.match(posts, /append_hidden_content\(\$content, \$input\)/)
assert.match(rest, /'logged' => 'login', 'reply' => 'comment', 'vip1' => 'vip', 'payshow' => 'payshow'/)
assert.match(rest, /hidecontent type="/)
assert.match(rest, /payshow/)
for (const token of ['hide_type', 'hide_content', '登录可见', '评论可见', '会员可见', '付费可见']) {
assert.ok(quick.includes(token) && edit.includes(token), `hidden content missing ${token}`)
}
})
+1 -1
View File
@@ -1,5 +1,5 @@
{
"generated_at": "2026-11-11T08:16:48+00:00",
"generated_at": "2026-11-11T08:37:24+00:00",
"errors": [],
"warnings": [
"customPage tools-update section 'tools/update' not in PHP schema (ok if pure operational page)",