feat(forum): add complete forum workflows

This commit is contained in:
kate522 committed 2026-11-11 00:09:43 +08:00
1 parent a254cbd3e9
commit ee65fc7fd2
39 files changed
+3425 -4

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
<?php
namespace Jio\Forum;
defined('ABSPATH') || exit;
final class Access {
public static function is_admin(int $user_id): bool {
if ($user_id <= 0) {
return false;
}
$raw = jio_get_setting('jio_forum_admin_users', '');
$admins = array_map('absint', is_array($raw) ? $raw : explode(',', (string) $raw));
return user_can($user_id, 'manage_options') || in_array($user_id, $admins, true);
}
public static function is_moderator(int $user_id, int $plate_id): bool {
if (self::is_admin($user_id)) {
return true;
}
if ($user_id <= 0 || get_post_type($plate_id) !== Core::PLATE) {
return false;
}
$moderators = array_map('absint', (array) get_post_meta($plate_id, '_jio_forum_moderators', true));
$owner = (int) get_post_field('post_author', $plate_id);
if ($owner === $user_id || in_array($user_id, $moderators, true)) {
return true;
}
$terms = wp_get_post_terms($plate_id, Core::CATEGORY, ['fields' => 'ids']);
foreach (is_array($terms) ? $terms : [] as $term_id) {
$ids = array_map('absint', (array) get_term_meta($term_id, '_jio_forum_moderators', true));
if (in_array($user_id, $ids, true)) {
return true;
}
}
return false;
}
public static function can_manage_moderators(int $user_id, int $plate_id): bool {
return self::is_admin($user_id) || ($user_id > 0 && get_post_type($plate_id) === Core::PLATE && (int) get_post_field('post_author', $plate_id) === $user_id);
}
public static function can_manage_post(int $user_id, int $post_id): bool {
if (get_post_type($post_id) !== Core::POST || $user_id <= 0) {
return false;
}
return (int) get_post_field('post_author', $post_id) === $user_id
|| self::is_moderator($user_id, (int) get_post_meta($post_id, '_jio_forum_plate_id', true));
}
public static function can_publish(int $user_id, int $plate_id): bool {
if ($user_id <= 0 || get_post_type($plate_id) !== Core::PLATE || get_post_status($plate_id) !== 'publish') {
return false;
}
if (self::is_moderator($user_id, $plate_id)) {
return true;
}
if (jio_get_setting('jio_forum_follow_required', false) && !self::follows($user_id, $plate_id)) {
return false;
}
$limit = (string) get_post_meta($plate_id, '_jio_forum_publish_limit', true);
return $limit === '' || $limit === 'all' || ($limit === 'follow' && self::follows($user_id, $plate_id));
}
public static function follows(int $user_id, int $plate_id): bool {
return $user_id > 0 && (bool) get_user_meta($user_id, '_jio_forum_follow_' . $plate_id, true);
}
public static function can_view(int $user_id, int $post_id): bool {
$post = get_post($post_id);
if (!$post instanceof \WP_Post || $post->post_type !== Core::POST) {
return false;
}
if ($post->post_status !== 'publish' && !self::can_manage_post($user_id, $post_id)) {
return false;
}
if ($user_id <= 0 && !jio_get_setting('jio_forum_allow_guest_read', true)) {
return false;
}
if (self::can_manage_post($user_id, $post_id)) {
return true;
}
$plate = (int) get_post_meta($post_id, '_jio_forum_plate_id', true);
$gate = (string) get_post_meta($post_id, '_jio_forum_view_limit', true);
$plate_gate = (string) get_post_meta($plate, '_jio_forum_view_limit', true);
foreach (['plate' => $plate_gate, 'post' => $gate] as $scope => $rule) {
if ($rule === 'login' && $user_id <= 0) {
return false;
}
if ($rule === 'follow' && !self::follows($user_id, $plate)) {
return false;
}
if ($rule === 'points' || $rule === 'pay') {
$allowed = $scope === 'plate'
? Commerce::has_plate_entitlement($user_id, $plate) && self::follows($user_id, $plate)
: $user_id > 0 && \Jio\Payment\Order::get_instance()->has_paid_access($user_id, 'forum_post', $post_id);
if (!$allowed) {
return false;
}
}
}
return true;
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace Jio\Forum;
defined('ABSPATH') || exit;
final class Commerce {
public static function init(): void {
add_action('jio_order_paid', [self::class, 'on_paid'], 10, 2);
add_action('jio_order_status_changed', [self::class, 'on_status_changed'], 10, 3);
}
public static function has_plate_entitlement(int $user_id, int $plate_id): bool {
if ($user_id <= 0 || $plate_id <= 0) return false;
$number = (string) get_user_meta($user_id, '_jio_forum_plate_entitlement_' . $plate_id, true);
return $number !== '' && \Jio\Payment\Order::get_instance()->has_paid_access($user_id, 'forum_plate_follow', $plate_id);
}
public static function revoke_plate_entitlement(int $user_id, int $plate_id): void {
delete_user_meta($user_id, '_jio_forum_plate_entitlement_' . $plate_id);
}
public static function on_paid(string $order_number, array $order): void {
if ((string) ($order['product_type'] ?? '') !== 'forum_plate_follow') {
return;
}
$user = (int) ($order['user_id'] ?? 0);
$plate = (int) ($order['product_id'] ?? 0);
if ($user > 0 && get_post_type($plate) === Core::PLATE) {
update_user_meta($user, '_jio_forum_plate_entitlement_' . $plate, $order_number);
if (add_user_meta($user, '_jio_forum_follow_' . $plate, 1, true)) {
jio_update_post_counter($plate, '_jio_forum_follow_count', 1);
}
}
}
public static function on_status_changed(string $order_number, string $status, array $order): void {
if ($status !== 'refunded' || (string) ($order['product_type'] ?? '') !== 'forum_plate_follow') return;
$user = (int) ($order['user_id'] ?? 0);
$plate = (int) ($order['product_id'] ?? 0);
if ($user > 0 && $plate > 0 && (string) get_user_meta($user, '_jio_forum_plate_entitlement_' . $plate, true) === $order_number) {
self::revoke_plate_entitlement($user, $plate);
if (get_user_meta($user, '_jio_forum_follow_' . $plate, true)) jio_update_post_counter($plate, '_jio_forum_follow_count', -1);
delete_user_meta($user, '_jio_forum_follow_' . $plate);
}
}
}
+208
View File
@@ -0,0 +1,208 @@
<?php
namespace Jio\Forum;
defined('ABSPATH') || exit;
final class Core {
private const ROUTE_VERSION = 1;
public const PLATE = 'jio_forum_plate';
public const POST = 'jio_forum_post';
public const CATEGORY = 'jio_forum_plate_cat';
public const TOPIC = 'jio_forum_topic';
public const TAG = 'jio_forum_tag';
public static function init(): void {
add_action('init', [self::class, 'register'], 2);
add_action('init', [self::class, 'routes'], 10);
add_filter('query_vars', [self::class, 'query_vars']);
add_filter('post_type_link', [self::class, 'permalink'], 10, 2);
add_filter('template_include', [self::class, 'template']);
add_filter('pre_handle_404', [self::class, 'edit_404'], 10, 2);
add_action('after_switch_theme', [self::class, 'ensure_home']);
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('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']);
add_action('untrashed_post', [self::class, 'restore_trashed_plate']);
add_filter('wp_untrash_post_status', [self::class, 'restore_status'], 10, 2);
}
public static function enabled(): bool {
return (bool) jio_get_setting('jio_forum_enabled', true);
}
public static function slug(string $type): string {
$defaults = ['plate' => 'forum', 'post' => 'forum-post', 'edit' => 'posts-edit'];
$slug = sanitize_title((string) jio_get_setting('jio_forum_slug_' . $type, $defaults[$type] ?? ''));
return $slug !== '' ? $slug : ($defaults[$type] ?? 'forum');
}
public static function home_url(): string {
return function_exists('jio_get_template_url') ? jio_get_template_url('forums') : home_url('/forums/');
}
public static function edit_url(int $id = 0): string {
return home_url('/' . self::slug('edit') . '/' . ($id > 0 ? $id . '/' : ''));
}
public static function register(): void {
if (!self::enabled()) {
return;
}
$plate_name = sanitize_text_field((string) jio_get_setting('jio_forum_plate_name', '版块')) ?: '版块';
$post_name = sanitize_text_field((string) jio_get_setting('jio_forum_post_name', '帖子')) ?: '帖子';
$topic_name = sanitize_text_field((string) jio_get_setting('jio_forum_topic_name', '话题')) ?: '话题';
register_post_type(self::PLATE, [
'labels' => ['name' => '论坛' . $plate_name, 'singular_name' => $plate_name, 'add_new_item' => '创建' . $plate_name, 'edit_item' => '编辑' . $plate_name],
'public' => true, 'show_ui' => true, 'show_in_rest' => false, 'show_in_menu' => true,
'menu_icon' => 'dashicons-buddicons-forums', 'supports' => ['title', 'editor', 'excerpt', 'thumbnail', 'author'],
'has_archive' => false, 'rewrite' => false, 'exclude_from_search' => true,
'capability_type' => 'post', 'capabilities' => self::admin_capabilities(), 'map_meta_cap' => false,
]);
register_post_type(self::POST, [
'labels' => ['name' => '论坛' . $post_name, 'singular_name' => $post_name, 'add_new_item' => '发布' . $post_name, 'edit_item' => '编辑' . $post_name],
'public' => true, 'show_ui' => true, 'show_in_rest' => false, 'show_in_menu' => true,
'menu_icon' => 'dashicons-buddicons-topics', 'supports' => ['title', 'editor', 'excerpt', 'thumbnail', 'author', 'comments'],
'has_archive' => false, 'rewrite' => false, 'exclude_from_search' => false,
'capability_type' => 'post', 'capabilities' => self::admin_capabilities(), 'map_meta_cap' => false,
]);
register_taxonomy(self::CATEGORY, [self::PLATE], [
'labels' => ['name' => '版块分类', 'singular_name' => '版块分类'], 'public' => true,
'show_ui' => true, 'show_in_rest' => false, 'show_admin_column' => true,
'hierarchical' => true, 'rewrite' => ['slug' => 'plate_cat'],
'capabilities' => self::term_capabilities(),
]);
foreach ([self::TOPIC => ['论坛' . $topic_name, 'forum_topic'], self::TAG => ['论坛标签', 'forum_tag']] as $taxonomy => [$label, $slug]) {
register_taxonomy($taxonomy, [self::POST], [
'labels' => ['name' => $label, 'singular_name' => $label], 'public' => true,
'show_ui' => true, 'show_in_rest' => false, 'show_admin_column' => true,
'hierarchical' => false, 'rewrite' => ['slug' => $slug],
'capabilities' => self::term_capabilities(),
]);
}
}
private static function admin_capabilities(): array {
return array_fill_keys([
'edit_post', 'read_post', 'delete_post', 'edit_posts', 'edit_others_posts', 'publish_posts',
'read_private_posts', 'delete_posts', 'delete_private_posts', 'delete_published_posts',
'delete_others_posts', 'edit_private_posts', 'edit_published_posts', 'create_posts',
], 'manage_options');
}
private static function term_capabilities(): array {
return ['manage_terms' => 'manage_options', 'edit_terms' => 'manage_options', 'delete_terms' => 'manage_options', 'assign_terms' => 'manage_options'];
}
public static function cascade_trashed_plate(int $plate_id): void {
if (get_post_type($plate_id) !== self::PLATE) return;
$posts = get_posts(['post_type' => self::POST, 'post_status' => ['publish', 'pending', 'draft'], 'meta_key' => '_jio_forum_plate_id', 'meta_value' => $plate_id, 'posts_per_page' => -1, 'fields' => 'ids']);
foreach ($posts as $post_id) {
update_post_meta($post_id, '_jio_forum_trashed_with_plate', $plate_id);
wp_trash_post($post_id);
}
}
public static function restore_trashed_plate(int $plate_id): void {
if (get_post_type($plate_id) !== self::PLATE) return;
$posts = get_posts(['post_type' => self::POST, 'post_status' => 'trash', 'meta_key' => '_jio_forum_trashed_with_plate', 'meta_value' => $plate_id, 'posts_per_page' => -1, 'fields' => 'ids']);
foreach ($posts as $post_id) {
wp_untrash_post($post_id);
delete_post_meta($post_id, '_jio_forum_trashed_with_plate');
}
}
public static function restore_status(string $status, int $post_id): string {
if (!in_array(get_post_type($post_id), [self::PLATE, self::POST], true)) return $status;
$previous = (string) get_post_meta($post_id, '_wp_trash_meta_status', true);
return in_array($previous, ['publish', 'pending', 'draft'], true) ? $previous : $status;
}
public static function routes(): void {
if (!self::enabled()) {
return;
}
foreach ([self::PLATE => 'plate', self::POST => 'post'] as $post_type => $kind) {
$slug = preg_quote(self::slug($kind), '#');
add_rewrite_rule('^' . $slug . '/([0-9]+)\.html/?$', 'index.php?post_type=' . $post_type . '&p=$matches[1]', 'top');
}
$edit = preg_quote(self::slug('edit'), '#');
add_rewrite_rule('^' . $edit . '/([0-9]+)/?$', 'index.php?jio_forum_edit=$matches[1]', 'top');
add_rewrite_rule('^' . $edit . '/?$', 'index.php?jio_forum_edit=add', 'top');
}
public static function maybe_flush_routes(): void {
$hash = hash('sha256', wp_json_encode([
self::ROUTE_VERSION, self::enabled(), self::slug('plate'), self::slug('post'), self::slug('edit'),
]));
if ((string) get_option('jio_forum_rewrite_hash', '') !== $hash) {
flush_rewrite_rules();
update_option('jio_forum_rewrite_hash', $hash, false);
}
}
public static function query_vars(array $vars): array {
$vars[] = 'jio_forum_edit';
return $vars;
}
public static function permalink(string $link, \WP_Post $post): string {
$kind = $post->post_type === self::PLATE ? 'plate' : ($post->post_type === self::POST ? 'post' : '');
return $kind ? home_url('/' . self::slug($kind) . '/' . $post->ID . '.html') : $link;
}
public static function edit_404($preempt, \WP_Query $query) {
if ($query->is_main_query() && get_query_var('jio_forum_edit')) {
$query->is_404 = false;
return true;
}
return $preempt;
}
public static function template(string $template): string {
if (!self::enabled()) {
return $template;
}
if (get_query_var('jio_forum_edit')) {
return get_theme_file_path('templates/pages/page-forum-edit.php');
}
if (is_singular(self::POST)) {
return get_theme_file_path('templates/pages/page-forum-post.php');
}
if (is_singular(self::PLATE) || is_tax([self::CATEGORY, self::TOPIC, self::TAG])) {
return get_theme_file_path('templates/pages/page-forum-list.php');
}
return $template;
}
public static function ensure_home(): void {
if (self::enabled() && function_exists('jio_ensure_template_page')) {
jio_ensure_template_page('forums');
}
}
public static function assets(): void {
$account_route = get_query_var('jio_user_center_tab') === 'forum'
|| (string) get_query_var('jio_author_identifier') !== ''
|| is_author();
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));
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']);
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' => array_map(static fn(int $id): array => ['id' => $id, 'title' => get_the_title($id)], $plates),
]) . ';', 'before');
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
namespace Jio\Forum;
defined('ABSPATH') || exit;
final class Integration {
public static function init(): void {
add_filter('jio_user_center_panels', [self::class, 'center_panel']);
add_filter('jio_user_home_tabs', [self::class, 'home_tab']);
add_filter('jio_searchable_post_types', [self::class, 'searchable_types']);
add_action('pre_get_posts', [self::class, 'search_query'], 30);
add_filter('posts_clauses', [self::class, 'sort_forum_posts'], 10, 2);
add_action('wp', [self::class, 'track_view']);
add_filter('pre_comment_approved', [self::class, 'reply_review'], 10, 2);
add_filter('preprocess_comment', [self::class, 'comment_guard']);
add_filter('the_content', [self::class, 'content_guard'], 1);
add_filter('get_the_excerpt', [self::class, 'excerpt_guard'], 1, 2);
add_filter('jio_seo_head_payload', [self::class, 'seo_guard']);
add_action('jio_forum_post_created', [self::class, 'post_created'], 10, 3);
add_action('jio_forum_post_reviewed', [self::class, 'post_reviewed'], 10, 3);
add_action('jio_forum_plate_reviewed', [self::class, 'plate_reviewed'], 10, 3);
add_action('jio_forum_answer_adopted', [self::class, 'answer_adopted'], 10, 3);
add_action('jio_forum_moderator_application_processed', [self::class, 'application_processed'], 10, 3);
add_action('jio_forum_moderator_applied', [self::class, 'moderator_applied'], 10, 2);
}
public static function center_panel(array $panels): array {
if (Core::enabled()) {
$panels['forum'] = ['label' => '论坛', 'icon' => 'comments', 'priority' => 22, 'type' => 'panel', 'template' => 'templates/parts/user-center/forum'];
}
return $panels;
}
public static function home_tab(array $tabs): array {
if (Core::enabled()) {
$tabs['forum'] = ['label' => '论坛', 'icon' => 'comments', 'priority' => 15];
}
return $tabs;
}
public static function searchable_types(array $types): array {
if (Core::enabled() && jio_get_setting('jio_forum_search_enabled', true)) {
$types[] = Core::POST;
$types[] = Core::PLATE;
}
return $types;
}
public static function search_query(\WP_Query $query): void {
if (is_admin() || !$query->is_main_query() || !$query->is_search() || !Core::enabled()) {
return;
}
$type = sanitize_key((string) ($_GET['type'] ?? ''));
if (in_array($type, ['forum', 'plate'], true) && jio_get_setting('jio_forum_search_enabled', true)) {
$query->set('post_type', $type === 'forum' ? Core::POST : Core::PLATE);
$query->set('type', $type);
}
}
public static function sort_forum_posts(array $clauses, \WP_Query $query): array {
$mode = sanitize_key((string) $query->get('jio_forum_list'));
if (!in_array($mode, ['latest', 'popular', 'question', 'vote', 'essence', 'follow'], true)) return $clauses;
global $wpdb;
$clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS jio_forum_pin ON ({$wpdb->posts}.ID = jio_forum_pin.post_id AND jio_forum_pin.meta_key = '_jio_forum_pinned')";
$pin = 'CAST(COALESCE(jio_forum_pin.meta_value, 0) AS SIGNED) DESC';
if ($mode === 'popular') {
$clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS jio_forum_up ON ({$wpdb->posts}.ID = jio_forum_up.post_id AND jio_forum_up.meta_key = '_jio_forum_score_up_total')";
$clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS jio_forum_down ON ({$wpdb->posts}.ID = jio_forum_down.post_id AND jio_forum_down.meta_key = '_jio_forum_score_down_total')";
$clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS jio_forum_fav ON ({$wpdb->posts}.ID = jio_forum_fav.post_id AND jio_forum_fav.meta_key = '_jio_forum_favorite_count')";
$clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS jio_forum_views ON ({$wpdb->posts}.ID = jio_forum_views.post_id AND jio_forum_views.meta_key = '_jio_forum_views')";
$rank = "({$wpdb->posts}.comment_count * 2 + CAST(COALESCE(jio_forum_up.meta_value, 0) AS SIGNED) * 10 - CAST(COALESCE(jio_forum_down.meta_value, 0) AS SIGNED) * 15 + CAST(COALESCE(jio_forum_fav.meta_value, 0) AS SIGNED) * 10 + CAST(COALESCE(jio_forum_views.meta_value, 0) AS SIGNED))";
$clauses['orderby'] = $pin . ', ' . $rank . ' DESC, ' . $wpdb->posts . '.post_date DESC';
} else {
$clauses['orderby'] = $pin . ', ' . $wpdb->posts . '.post_date DESC';
}
return $clauses;
}
public static function track_view(): void {
if (!is_singular(Core::POST) || is_preview()) return;
$id = (int) get_queried_object_id();
if (!$id || !Access::can_view(get_current_user_id(), $id)) return;
$key = 'jio_forum_view_' . $id;
if (!empty($_COOKIE[$key])) return;
if (function_exists('jio_update_post_counter')) jio_update_post_counter($id, '_jio_forum_views', 1);
else update_post_meta($id, '_jio_forum_views', (int) get_post_meta($id, '_jio_forum_views', true) + 1);
setcookie($key, '1', ['expires' => time() + DAY_IN_SECONDS, 'path' => '/', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax']);
$_COOKIE[$key] = '1';
}
public static function reply_review($approved, array $comment_data) {
$id = (int) ($comment_data['comment_post_ID'] ?? 0);
if (get_post_type($id) === Core::POST && jio_get_setting('jio_forum_reply_review', false) && !Access::is_moderator(get_current_user_id(), (int) get_post_meta($id, '_jio_forum_plate_id', true))) {
return 0;
}
return $approved;
}
public static function comment_guard(array $data): array {
if (get_post_type((int) ($data['comment_post_ID'] ?? 0)) === Core::POST && !Rest::inside_reply()) {
wp_die(esc_html__('请在帖子页面提交回复', 'jio'), '', ['response' => 403]);
}
return $data;
}
public static function content_guard(string $content): string {
$id = get_the_ID();
return $id && get_post_type($id) === Core::POST && !Access::can_view(get_current_user_id(), $id) ? '<p>内容已隐藏</p>' : $content;
}
public static function excerpt_guard(string $excerpt, \WP_Post $post): string {
return $post->post_type === Core::POST && !Access::can_view(get_current_user_id(), $post->ID) ? '内容已隐藏' : $excerpt;
}
public static function seo_guard(array $payload): array {
$post = $payload['object'] ?? null;
if ($post instanceof \WP_Post && $post->post_type === Core::POST && !Access::can_view(get_current_user_id(), $post->ID)) {
$payload['description'] = '内容已隐藏';
$payload['keywords'] = '';
}
return $payload;
}
public static function post_created(int $post_id, int $user_id, string $status): void {
if ($status !== 'pending') {
return;
}
$plate = (int) get_post_meta($post_id, '_jio_forum_plate_id', true);
$owner = (int) get_post_field('post_author', $plate);
if ($owner > 0 && $owner !== $user_id) {
self::notify($owner, '帖子待审核', get_the_title($post_id), Core::edit_url($post_id));
}
}
public static function post_reviewed(int $post_id, string $status, int $actor): void {
$owner = (int) get_post_field('post_author', $post_id);
if ($owner > 0 && $owner !== $actor) {
self::notify($owner, $status === 'publish' ? '帖子审核通过' : '帖子待审核', get_the_title($post_id), get_permalink($post_id));
}
}
public static function plate_reviewed(int $plate_id, string $status, int $actor): void {
$owner = (int) get_post_field('post_author', $plate_id);
if ($owner > 0 && $owner !== $actor) {
self::notify($owner, $status === 'publish' ? '版块审核通过' : '版块待审核', get_the_title($plate_id), get_permalink($plate_id));
}
}
public static function answer_adopted(int $post_id, int $comment_id, int $actor): void {
$comment = get_comment($comment_id);
$user = $comment instanceof \WP_Comment ? (int) $comment->user_id : 0;
if ($user > 0 && $user !== $actor) {
self::notify($user, '回答被采纳', get_the_title($post_id), get_comment_link($comment_id));
}
}
public static function moderator_applied(int $plate_id, int $user_id): void {
$owner = (int) get_post_field('post_author', $plate_id);
if ($owner > 0 && $owner !== $user_id) {
self::notify($owner, '收到版主申请', get_the_title($plate_id), get_permalink($plate_id));
}
}
public static function application_processed(int $plate_id, int $user_id, string $status): void {
self::notify($user_id, $status === 'approved' ? '版主申请通过' : '版主申请未通过', get_the_title($plate_id), get_permalink($plate_id));
}
private static function notify(int $user_id, string $title, string $desc, string $url): void {
if (function_exists('jio_add_user_notification')) {
jio_add_user_notification($user_id, ['type' => 'system', 'title' => $title, 'desc' => $desc, 'url' => $url]);
}
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Jio\Forum;
defined('ABSPATH') || exit;
final class Lock {
public static function acquire(string $scope, int $user_id, int $object_id): string {
$key = self::key($scope, $user_id, $object_id);
$token = wp_generate_uuid4();
if (add_option($key, ['token' => $token, 'created' => time()], '', false)) return $token;
$old = get_option($key);
if (is_array($old) && time() - (int) ($old['created'] ?? 0) > 120) {
delete_option($key);
if (add_option($key, ['token' => $token, 'created' => time()], '', false)) return $token;
}
return '';
}
public static function release(string $scope, int $user_id, int $object_id, string $token): void {
$key = self::key($scope, $user_id, $object_id);
$stored = get_option($key);
if (is_array($stored) && (string) ($stored['token'] ?? '') === $token) delete_option($key);
}
private static function key(string $scope, int $user_id, int $object_id): string {
return 'jio_forum_lock_' . md5($scope . ':' . $user_id . ':' . $object_id);
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
namespace Jio\Forum;
defined('ABSPATH') || exit;
final class Rest {
use RestPosts;
use RestPlates;
use RestActions;
use RestCommerce;
use RestReplies;
use RestMedia;
public static function init(): void {
add_action('rest_api_init', [self::class, 'routes']);
}
public static function routes(): void {
$base = 'jio/v1';
$auth = [self::class, 'authorize'];
$routes = [
['/forum/posts', 'POST', 'create_post'],
['/forum/posts/(?P<id>\d+)', 'PATCH', 'update_post'],
['/forum/plates', 'POST', 'create_plate'],
['/forum/plates/(?P<id>\d+)', 'PATCH', 'update_plate'],
['/forum/plates/(?P<id>\d+)/follow', 'POST', 'follow_plate'],
['/forum/plates/(?P<id>\d+)/audit', 'POST', 'audit_plate'],
['/forum/plates/(?P<id>\d+)/delete', 'POST', 'delete_plate'],
['/forum/plates/(?P<id>\d+)/restore', 'POST', 'restore_plate'],
['/forum/posts/(?P<id>\d+)/score', 'POST', 'score'],
['/forum/posts/(?P<id>\d+)/favorite', 'POST', 'favorite'],
['/forum/posts/(?P<id>\d+)/vote', 'POST', 'vote'],
['/forum/posts/(?P<id>\d+)/adopt', 'POST', 'adopt'],
['/forum/posts/(?P<id>\d+)/delete', 'POST', 'delete_post'],
['/forum/posts/(?P<id>\d+)/restore', 'POST', 'restore_post'],
['/forum/posts/(?P<id>\d+)/audit', 'POST', 'audit_post'],
['/forum/posts/(?P<id>\d+)/pin', 'POST', 'pin_post'],
['/forum/posts/(?P<id>\d+)/essence', 'POST', 'essence_post'],
['/forum/posts/(?P<id>\d+)/move', 'POST', 'move_post'],
['/forum/posts/(?P<id>\d+)/comments', 'POST', 'comments_status'],
['/forum/posts/(?P<id>\d+)/report', 'POST', 'report_post'],
['/forum/posts/(?P<id>\d+)/replies', 'POST', 'reply'],
['/forum/replies/(?P<id>\d+)/like', 'POST', 'like_reply'],
['/forum/replies/(?P<id>\d+)/hot', 'POST', 'hot_reply'],
['/forum/replies/(?P<id>\d+)/audit', 'POST', 'audit_reply'],
['/forum/replies/(?P<id>\d+)/delete', 'POST', 'delete_reply'],
['/forum/replies/(?P<id>\d+)/restore', 'POST', 'restore_reply'],
['/forum/terms', 'POST', 'create_term'],
['/forum/terms/(?P<id>\d+)', 'PATCH', 'update_term'],
['/forum/terms/(?P<id>\d+)', 'DELETE', 'delete_term'],
['/forum/plates/(?P<id>\d+)/moderators', 'POST', 'moderator'],
['/forum/plates/(?P<id>\d+)/apply', 'POST', 'apply_moderator'],
['/forum/plates/(?P<id>\d+)/applications/(?P<user_id>\d+)', 'POST', 'process_application'],
['/forum/posts/(?P<id>\d+)/checkout', 'POST', 'checkout_post'],
['/forum/posts/(?P<id>\d+)/checkout', 'GET', 'preview_post'],
['/forum/plates/(?P<id>\d+)/checkout', 'POST', 'checkout_plate'],
['/forum/plates/(?P<id>\d+)/checkout', 'GET', 'preview_plate'],
['/forum/upload/(?P<kind>image|video|resource)', 'POST', 'upload'],
];
foreach ($routes as [$path, $method, $handler]) {
register_rest_route($base, $path, [
'methods' => $method,
'callback' => [self::class, $handler],
'permission_callback' => $auth,
]);
}
}
public static function authorize(\WP_REST_Request $request) {
if (!Core::enabled()) {
return new \WP_Error('jio_forum_disabled', '论坛未启用', ['status' => 404]);
}
if (!is_user_logged_in()) {
return new \WP_Error('jio_forum_login', '请先登录', ['status' => 401]);
}
$nonce = (string) $request->get_header('X-WP-Nonce');
if ($nonce === '' || !wp_verify_nonce($nonce, 'wp_rest')) {
return new \WP_Error('jio_forum_nonce', '安全验证失败,请刷新页面', ['status' => 403]);
}
return true;
}
private static function fail(string $message, int $status = 400): \WP_Error {
return new \WP_Error('jio_forum_error', $message, ['status' => $status]);
}
private static function success(array $data = [], string $message = '操作成功'): \WP_REST_Response {
return new \WP_REST_Response(['success' => true, 'data' => $data, 'message' => $message], 200);
}
private static function input(\WP_REST_Request $request): array {
$json = $request->get_json_params();
return is_array($json) ? $json : $request->get_params();
}
private static function post(int $id): ?\WP_Post {
$post = get_post($id);
return $post instanceof \WP_Post && $post->post_type === Core::POST ? $post : null;
}
private static function plate(int $id): ?\WP_Post {
$post = get_post($id);
return $post instanceof \WP_Post && $post->post_type === Core::PLATE ? $post : null;
}
private static function actor(): int {
return get_current_user_id();
}
private static function with_lock(string $scope, int $id, callable $operation) {
$user = self::actor();
$token = Lock::acquire($scope, $user, $id);
if ($token === '') return self::fail('操作正在处理中,请稍后重试', 409);
try {
$object_token = Lock::acquire($scope . '-object', 0, $id);
if ($object_token === '') return self::fail('操作正在处理中,请稍后重试', 409);
try {
return $operation();
} finally {
Lock::release($scope . '-object', 0, $id, $object_token);
}
} finally {
Lock::release($scope, $user, $id, $token);
}
}
}
+229
View File
@@ -0,0 +1,229 @@
<?php
namespace Jio\Forum;
defined('ABSPATH') || exit;
trait RestActions {
public static function follow_plate(\WP_REST_Request $request) {
$id = absint($request['id']);
if (!self::plate($id) || get_post_status($id) !== 'publish') {
return self::fail('版块不存在', 404);
}
$user = self::actor();
return self::with_lock('follow', $id, static function () use ($user, $id) {
$key = '_jio_forum_follow_' . $id;
if (Access::follows($user, $id)) {
delete_user_meta($user, $key);
Commerce::revoke_plate_entitlement($user, $id);
jio_update_post_counter($id, '_jio_forum_follow_count', -1);
return self::success(['following' => false, 'label' => '关注版块'], '已取消关注');
}
$gate = (string) get_post_meta($id, '_jio_forum_follow_mode', true);
if (in_array($gate, ['pay', 'points'], true) && !Commerce::has_plate_entitlement($user, $id)) {
return self::fail('此版块需要先完成付费关注', 402);
}
add_user_meta($user, $key, 1, true);
jio_update_post_counter($id, '_jio_forum_follow_count', 1);
do_action('jio_forum_plate_followed', $id, $user);
return self::success(['following' => true, 'label' => '已关注'], '已关注版块');
});
}
public static function score(\WP_REST_Request $request) {
$id = absint($request['id']);
if (!self::post($id) || get_post_status($id) !== 'publish') {
return self::fail('帖子不存在', 404);
}
$user = self::actor();
if (!jio_get_setting('jio_forum_score_enabled', true)) {
return self::fail('评分未启用');
}
$direction = sanitize_key((string) (self::input($request)['direction'] ?? 'up'));
if (!in_array($direction, ['up', 'down'], true)) {
return self::fail('评分类型无效');
}
return self::with_lock('score', $id, static function () use ($user, $id, $direction) {
$key = '_jio_forum_score_' . $id;
$previous = (int) get_user_meta($user, $key, true);
$delta = $direction === 'up' ? 1 : -1;
$new_user_score = $previous + $delta;
$limit = max(0, (int) jio_get_setting($direction === 'up' ? 'jio_forum_score_up_max' : 'jio_forum_score_down_max', 5));
if (($delta > 0 && $new_user_score > $limit) || ($delta < 0 && $new_user_score < -$limit)) {
return self::fail('已达到评分上限');
}
update_user_meta($user, $key, $new_user_score);
jio_update_post_counter($id, '_jio_forum_score_up_total', max(0, $new_user_score) - max(0, $previous));
jio_update_post_counter($id, '_jio_forum_score_down_total', max(0, -$new_user_score) - max(0, -$previous));
$score = (int) get_post_meta($id, '_jio_forum_score', true) + $delta;
update_post_meta($id, '_jio_forum_score', $score);
do_action('jio_forum_post_scored', $id, $user, $direction);
return self::success(['score' => $score, 'user_score' => $new_user_score, 'direction' => $direction, 'label' => '评分 ' . $score], '评分成功');
});
}
public static function favorite(\WP_REST_Request $request) {
$id = absint($request['id']);
if (!self::post($id) || get_post_status($id) !== 'publish') {
return self::fail('帖子不存在', 404);
}
$key = '_jio_forum_favorite_' . $id;
$user = self::actor();
return self::with_lock('favorite', $id, static function () use ($user, $id, $key) {
if (get_user_meta($user, $key, true)) {
delete_user_meta($user, $key);
$active = false;
} else {
add_user_meta($user, $key, 1, true);
$active = true;
}
jio_update_post_counter($id, '_jio_forum_favorite_count', $active ? 1 : -1);
do_action('jio_forum_post_favorited', $id, $user, $active);
return self::success(['active' => $active, 'label' => $active ? '已收藏' : '收藏'], $active ? '已收藏' : '已取消收藏');
});
}
public static function vote(\WP_REST_Request $request) {
$id = absint($request['id']);
if (!self::post($id) || get_post_status($id) !== 'publish' || get_post_meta($id, '_jio_forum_type', true) !== 'vote') {
return self::fail('投票不存在', 404);
}
if (!Access::can_view(self::actor(), $id)) return self::fail('无权查看此投票', 403);
$user = self::actor();
return self::with_lock('vote', $id, static function () use ($request, $user, $id) {
$key = '_jio_forum_vote_' . $id;
if (get_user_meta($user, $key, true) !== '') {
return self::success(['selected' => get_user_meta($user, $key, true)], '已经投票');
}
$deadline = (int) get_post_meta($id, '_jio_forum_vote_deadline', true);
if ($deadline > 0 && current_time('timestamp') > $deadline) return self::fail('投票已截止', 409);
$input = self::input($request);
$options = (array) get_post_meta($id, '_jio_forum_vote_options', true);
$type = (string) get_post_meta($id, '_jio_forum_vote_type', true);
$selected = $type === 'multiple' ? (array) ($input['options'] ?? []) : [$input['option'] ?? null];
$indices = [];
foreach ($selected as $value) {
$index = filter_var($value, FILTER_VALIDATE_INT);
if ($index === false || $index === null || !array_key_exists($index, $options) || in_array($index, $indices, true)) {
return self::fail('请选择有效选项');
}
$indices[] = $index;
}
if (!$indices || $type !== 'multiple' && count($indices) !== 1) return self::fail('请选择有效选项');
add_user_meta($user, $key, $type === 'multiple' ? $indices : (string) $indices[0], true);
$counts = (array) get_post_meta($id, '_jio_forum_vote_counts', true);
foreach ($indices as $index) $counts[$index] = (int) ($counts[$index] ?? 0) + 1;
update_post_meta($id, '_jio_forum_vote_counts', $counts);
update_post_meta($id, '_jio_forum_vote_total', (int) get_post_meta($id, '_jio_forum_vote_total', true) + 1);
return self::success(['selected' => $indices, 'counts' => $counts], '投票成功');
});
}
public static function adopt(\WP_REST_Request $request) {
$id = absint($request['id']);
$post = self::post($id);
$comment_id = absint(self::input($request)['comment_id'] ?? 0);
$comment = get_comment($comment_id);
if (!$post || get_post_meta($id, '_jio_forum_type', true) !== 'question' || !$comment instanceof \WP_Comment || (int) $comment->comment_post_ID !== $id || $comment->comment_approved !== '1') {
return self::fail('回复无效');
}
if (!Access::can_manage_post(self::actor(), $id)) {
return self::fail('无权采纳回复', 403);
}
$old = (int) get_post_meta($id, '_jio_forum_adopted_comment', true);
if ($old && $old !== $comment_id) {
return self::fail('此问题已有采纳回复');
}
update_post_meta($id, '_jio_forum_adopted_comment', $comment_id);
update_comment_meta($comment_id, '_jio_forum_adopted', 1);
do_action('jio_forum_answer_adopted', $id, $comment_id, self::actor());
return self::success(['comment_id' => $comment_id], '已采纳回复');
}
public static function delete_post(\WP_REST_Request $request) {
$id = absint($request['id']);
if (!self::post($id) || !Access::can_manage_post(self::actor(), $id)) {
return self::fail('无权删除', 403);
}
wp_trash_post($id);
return self::success(['url' => Core::home_url()], '已删除');
}
public static function restore_post(\WP_REST_Request $request) {
$id = absint($request['id']);
if (!self::post($id) || get_post_status($id) !== 'trash' || !Access::can_manage_post(self::actor(), $id)) {
return self::fail('无权撤销删除', 403);
}
wp_untrash_post($id);
return self::success(['url' => get_permalink($id)], '已恢复');
}
public static function audit_post(\WP_REST_Request $request) {
$id = absint($request['id']);
$post = self::post($id);
if (!$post || !Access::is_moderator(self::actor(), (int) get_post_meta($id, '_jio_forum_plate_id', true))) {
return self::fail('无权审核', 403);
}
$status = sanitize_key((string) (self::input($request)['status'] ?? 'publish'));
if (!in_array($status, ['publish', 'pending'], true)) {
return self::fail('审核状态无效');
}
wp_update_post(['ID' => $id, 'post_status' => $status]);
do_action('jio_forum_post_reviewed', $id, $status, self::actor());
return self::success(['status' => $status], $status === 'publish' ? '审核通过' : '已转为待审核');
}
public static function pin_post(\WP_REST_Request $request) {
return self::flag($request, '_jio_forum_pinned', '置顶');
}
public static function essence_post(\WP_REST_Request $request) {
return self::flag($request, '_jio_forum_essence', '精华');
}
private static function flag(\WP_REST_Request $request, string $key, string $label) {
$id = absint($request['id']);
if (!self::post($id) || !Access::is_moderator(self::actor(), (int) get_post_meta($id, '_jio_forum_plate_id', true))) {
return self::fail('无权操作', 403);
}
$active = !(bool) get_post_meta($id, $key, true);
update_post_meta($id, $key, $active ? 1 : 0);
return self::success(['active' => $active, 'label' => ($active ? '取消' : '') . $label], $active ? '已设置' . $label : '已取消' . $label);
}
public static function move_post(\WP_REST_Request $request) {
$id = absint($request['id']);
$old_plate = (int) get_post_meta($id, '_jio_forum_plate_id', true);
$new_plate = absint(self::input($request)['plate_id'] ?? 0);
if (!self::post($id) || !Access::is_moderator(self::actor(), $old_plate) || !self::plate($new_plate) || get_post_status($new_plate) !== 'publish') {
return self::fail('无权移动帖子', 403);
}
update_post_meta($id, '_jio_forum_plate_id', $new_plate);
return self::success(['plate_id' => $new_plate], '已移动帖子');
}
public static function comments_status(\WP_REST_Request $request) {
$id = absint($request['id']);
if (!self::post($id) || !Access::is_moderator(self::actor(), (int) get_post_meta($id, '_jio_forum_plate_id', true))) {
return self::fail('无权管理回复', 403);
}
$open = (string) (self::input($request)['status'] ?? 'closed') === 'open';
wp_update_post(['ID' => $id, 'comment_status' => $open ? 'open' : 'closed']);
return self::success(['open' => $open], $open ? '已开放回复' : '已关闭回复');
}
public static function report_post(\WP_REST_Request $request) {
$id = absint($request['id']);
if (!self::post($id)) {
return self::fail('帖子不存在', 404);
}
$input = self::input($request);
$reason = sanitize_textarea_field((string) ($input['reason'] ?? ''));
$category = sanitize_text_field((string) ($input['category'] ?? '其他'));
if ($reason === '') {
return self::fail('请填写举报原因');
}
$result = \Jio\Messages\ReportCenter::get_instance()->submit(self::actor(), 'post', $id, $reason, $category);
return $result ? self::success(['report_id' => $result], '举报已提交') : self::fail('举报提交失败');
}
}
+201
View File
@@ -0,0 +1,201 @@
<?php
namespace Jio\Forum;
defined('ABSPATH') || exit;
trait RestCommerce {
public static function preview_post(\WP_REST_Request $request) {
return self::preview_checkout('post', absint($request['id']));
}
public static function preview_plate(\WP_REST_Request $request) {
return self::preview_checkout('plate', absint($request['id']));
}
private static function preview_checkout(string $kind, int $id) {
$subject = $kind === 'post' ? self::post($id) : self::plate($id);
if (!$subject || $subject->post_status !== 'publish') {
return self::fail('内容不存在', 404);
}
if ($kind === 'post') {
$plate_access = self::parent_plate_access($id, self::actor());
if (is_wp_error($plate_access)) return $plate_access;
}
$rule = (string) get_post_meta($id, $kind === 'post' ? '_jio_forum_view_limit' : '_jio_forum_follow_mode', true);
if ($kind === 'plate' && !in_array($rule, ['points', 'pay'], true)) {
$rule = (string) get_post_meta($id, '_jio_forum_view_limit', true);
}
if (!in_array($rule, ['points', 'pay'], true)) {
return self::fail('此内容不需要付费');
}
$price = self::price($kind, $id, $rule, self::actor());
if (is_wp_error($price)) return $price;
$methods = ['points'];
if ($rule === 'pay') {
$methods = [];
foreach (['balance', 'alipay', 'wechatpay', 'paypal', 'stripe'] as $method) {
if (isset(\Jio\Payment\PaymentOrchestrator::resolve_gateway($method)['gateway'])) {
$methods[] = $method;
}
}
if ($price !== '0.00' && !$methods) return self::fail('暂无可用支付方式', 503);
}
return self::success(['title' => $subject->post_title, 'mode' => $rule, 'price' => $price, 'methods' => $methods], '确认解锁');
}
public static function checkout_post(\WP_REST_Request $request) {
return self::checkout('post', absint($request['id']), $request);
}
public static function checkout_plate(\WP_REST_Request $request) {
return self::checkout('plate', absint($request['id']), $request);
}
private static function price(string $kind, int $id, string $mode, int $user_id) {
$key = $kind === 'post' ? '_jio_forum_price' : '_jio_forum_follow_price';
$raw = (string) get_post_meta($id, $key, true);
$vip = function_exists('jio_get_user_vip_level') ? min(2, (int) jio_get_user_vip_level($user_id)) : 0;
if ($vip > 0 && jio_get_setting('jio_forum_vip_price_enabled', true)) {
$override = (string) get_post_meta($id, $key . '_vip' . $vip, true);
if ($override !== '') {
$raw = $override;
}
}
if ($mode === 'points') {
if (!preg_match('/^(0|[1-9]\d*)$/', $raw) || strlen($raw) > strlen((string) PHP_INT_MAX) || (strlen($raw) === strlen((string) PHP_INT_MAX) && strcmp($raw, (string) PHP_INT_MAX) > 0)) {
return self::fail('积分价格无效');
}
return $raw;
}
try {
return \Jio\Ledger\AssetAmount::balance($raw)->decimal();
} catch (\InvalidArgumentException $exception) {
return self::fail('付费价格无效');
}
}
private static function checkout(string $kind, int $id, \WP_REST_Request $request) {
$subject = $kind === 'post' ? self::post($id) : self::plate($id);
if (!$subject || $subject->post_status !== 'publish') {
return self::fail('内容不存在', 404);
}
$user = self::actor();
if ($kind === 'post') {
$plate_access = self::parent_plate_access($id, $user);
if (is_wp_error($plate_access)) return $plate_access;
}
$mode = (string) get_post_meta($id, $kind === 'post' ? '_jio_forum_view_limit' : '_jio_forum_follow_mode', true);
if ($kind === 'plate' && !in_array($mode, ['points', 'pay'], true)) {
$mode = (string) get_post_meta($id, '_jio_forum_view_limit', true);
}
if (!in_array($mode, ['points', 'pay'], true)) {
return self::fail('此内容不需要付费');
}
$product = $kind === 'post' ? 'forum_post' : 'forum_plate_follow';
$lock = Lock::acquire('checkout-' . $product, $user, $id);
if ($lock === '') return self::fail('正在处理同一内容的支付,请稍后重试', 409);
try {
$orders = \Jio\Payment\Order::get_instance();
if ($kind === 'plate' ? Commerce::has_plate_entitlement($user, $id) : $orders->has_paid_access($user, $product, $id)) {
return self::success(['paid' => true, 'url' => $kind === 'post' ? get_permalink($id) : get_permalink($id)], '已解锁');
}
$price = self::price($kind, $id, $mode, $user);
if (is_wp_error($price)) return $price;
$input = self::input($request);
$method = $mode === 'points' ? 'points' : sanitize_key((string) ($input['payment_method'] ?? 'balance'));
$resolved_gateway = null;
if ($mode === 'pay' && $price !== '0.00') {
$resolved_gateway = \Jio\Payment\PaymentOrchestrator::resolve_gateway($method);
if (!isset($resolved_gateway['gateway'])) {
return self::fail((string) ($resolved_gateway['message'] ?? '支付方式不可用'));
}
}
$uuid = (string) $request->get_header('Idempotency-Key');
if (!wp_is_uuid($uuid)) {
return self::fail('缺少有效的重复请求标识');
}
$request_key = \Jio\Ledger\OperationKey::deterministic('forum-checkout', [$user, strtolower($uuid)]);
$repo = \Jio\Payment\OrderRepository::get_instance();
$existing = $repo->get_by_request_key($request_key);
if (!$existing) {
foreach ($repo->get_user_orders($user, ['product_type' => $product, 'status' => 'pending', 'limit' => 100]) as $pending) {
if ((int) ($pending['product_id'] ?? 0) === $id) {
$existing = $pending;
break;
}
}
}
$stored_amount = $mode === 'points' ? (int) $price : $price;
if ($existing && ((int) $existing['user_id'] !== $user || (string) $existing['product_type'] !== $product || (int) $existing['product_id'] !== $id || (string) $existing['currency'] !== ($mode === 'points' ? 'points' : 'cny') || $existing['amount'] !== $stored_amount || (string) $existing['payment_method'] !== $method)) {
return self::fail('重复请求标识已用于其他购买', 409);
}
if ($existing && $existing['status'] === 'paid' && $kind !== 'plate') {
return self::success(['paid' => true, 'url' => get_permalink($id)], '已解锁');
}
if ($existing && $existing['status'] === 'paid') {
return self::fail('此订单的关注资格已结束,请重新发起支付', 409);
}
$order_number = $existing ? (string) $existing['order_number'] : $repo->create([
'user_id' => $user, 'product_type' => $product, 'product_id' => $id,
'amount' => $stored_amount, 'currency' => $mode === 'points' ? 'points' : 'CNY',
'status' => \Jio\Payment\Order::STATUS_PENDING, 'payment_method' => $method,
'request_key' => $request_key, 'note' => wp_json_encode(['forum' => 1, 'mode' => $mode, 'title' => $subject->post_title]),
]);
if ($order_number === '') {
$existing = $repo->get_by_request_key($request_key);
$order_number = $existing ? (string) $existing['order_number'] : '';
}
if ($order_number === '') {
return self::fail('创建订单失败', 500);
}
if ((($mode === 'points') && (int) $price === 0) || (($mode === 'pay') && $price === '0.00')) {
if (!$orders->update_status($order_number, \Jio\Payment\Order::STATUS_PAID, ['payment_method' => 'free', 'require_transition' => true])) {
return self::fail('订单确认失败', 409);
}
return self::success(['paid' => true, 'url' => get_permalink($id)], '已解锁');
}
if ($mode === 'points') {
$result = \Jio\Ledger\LedgerService::get_instance()->debit(
$user, \Jio\Ledger\AssetType::POINTS, (int) $price,
\Jio\Ledger\OperationKey::deterministic('forum-points', [$order_number]),
'forum_points_pay', ['type' => 'order', 'key' => $order_number],
'论坛内容解锁', $user, ['order_number' => $order_number],
static function () use ($orders, $order_number) {
return $orders->update_status($order_number, \Jio\Payment\Order::STATUS_PAID, ['payment_method' => 'points', 'require_transition' => true]);
},
static function () use ($repo, $order_number, $user) {
$order = $repo->get($order_number);
return $order && (int) $order['user_id'] === $user && $order['status'] === 'pending';
}
);
if (is_wp_error($result) || !$result) {
return self::fail(is_wp_error($result) ? $result->get_error_message() : '积分支付失败', 402);
}
return self::success(['paid' => true, 'url' => get_permalink($id)], '积分支付成功');
}
$order = $repo->get($order_number);
$order['subject'] = '论坛内容:' . $subject->post_title;
$order['payment_method'] = $method;
$paid = \Jio\Payment\PaymentOrchestrator::pay($resolved_gateway['gateway'], $order, ['paid_note' => '论坛内容解锁']);
if (empty($paid['success'])) {
return self::fail((string) ($paid['message'] ?? '支付失败'), (int) ($paid['status'] ?? 400));
}
return self::success(array_merge(['order_number' => $order_number, 'url' => get_permalink($id)], \Jio\Payment\PaymentOrchestrator::cashier_payload($paid)), (string) ($paid['message'] ?? '请完成支付'));
} finally {
Lock::release('checkout-' . $product, $user, $id, $lock);
}
}
private static function parent_plate_access(int $post_id, int $user_id) {
$plate_id = (int) get_post_meta($post_id, '_jio_forum_plate_id', true);
if (!self::plate($plate_id) || get_post_status($plate_id) !== 'publish') return self::fail('所属版块不可用', 404);
if (Access::is_moderator($user_id, $plate_id)) return true;
$rule = (string) get_post_meta($plate_id, '_jio_forum_view_limit', true);
if ($rule === 'follow' && !Access::follows($user_id, $plate_id)) return self::fail('请先关注版块', 403);
if (in_array($rule, ['points', 'pay'], true) && !(Commerce::has_plate_entitlement($user_id, $plate_id) && Access::follows($user_id, $plate_id))) {
return self::fail('请先解锁版块', 402);
}
return true;
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Jio\Forum;
defined('ABSPATH') || exit;
trait RestMedia {
public static function upload(\WP_REST_Request $request) {
$kind = sanitize_key((string) $request['kind']);
$key = ['image' => 'jio_forum_upload_image', 'video' => 'jio_forum_upload_video', 'resource' => 'jio_forum_upload_file'][$kind] ?? '';
if ($key === '' || !jio_get_setting($key, $kind === 'image')) {
return self::fail('此类型上传未启用', 403);
}
$files = $request->get_file_params();
$file = $files['file'] ?? null;
if (!is_array($file)) {
return self::fail('请选择文件');
}
$front = \Jio\Core\FrontSubmit::get_instance();
$result = $front->persist_uploaded_file($kind, $file);
if (is_wp_error($result)) return $result;
if (!$result instanceof \WP_REST_Response) return self::fail('上传失败', 500);
$data = $result->get_data();
return self::success(['id' => absint($data['id'] ?? 0), 'url' => esc_url_raw((string) ($data['url'] ?? '')), 'name' => sanitize_text_field((string) ($data['title'] ?? $file['name'] ?? ''))], '上传成功');
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace Jio\Forum;
defined('ABSPATH') || exit;
trait RestPlates {
public static function audit_plate(\WP_REST_Request $request) {
$id = absint($request['id']);
if (!self::plate($id) || !Access::is_admin(self::actor())) {
return self::fail('无权审核版块', 403);
}
$status = sanitize_key((string) (self::input($request)['status'] ?? 'publish'));
if (!in_array($status, ['publish', 'pending'], true)) {
return self::fail('审核状态无效');
}
wp_update_post(['ID' => $id, 'post_status' => $status]);
do_action('jio_forum_plate_reviewed', $id, $status, self::actor());
return self::success(['status' => $status, 'url' => get_permalink($id)], $status === 'publish' ? '版块审核通过' : '版块待审核');
}
public static function delete_plate(\WP_REST_Request $request) {
$id = absint($request['id']);
if (!self::plate($id) || !Access::can_manage_moderators(self::actor(), $id)) {
return self::fail('无权删除版块', 403);
}
$input = self::input($request);
$action = sanitize_key((string) ($input['posts_action'] ?? 'trash'));
if (!in_array($action, ['trash', 'move'], true)) {
return self::fail('帖子处理方式无效');
}
if ($action === 'move') {
$target = absint($input['target_plate_id'] ?? 0);
if ($target === $id || !self::plate($target) || get_post_status($target) !== 'publish') {
return self::fail('目标版块无效');
}
$posts = get_posts(['post_type' => Core::POST, 'post_status' => ['publish', 'pending', 'draft'], 'meta_key' => '_jio_forum_plate_id', 'meta_value' => $id, 'posts_per_page' => -1, 'fields' => 'ids']);
foreach ($posts as $post_id) update_post_meta($post_id, '_jio_forum_plate_id', $target);
}
wp_trash_post($id);
return self::success(['url' => Core::home_url()], '版块已移入回收站');
}
public static function restore_plate(\WP_REST_Request $request) {
$id = absint($request['id']);
if (!self::plate($id) || get_post_status($id) !== 'trash' || !Access::can_manage_moderators(self::actor(), $id)) {
return self::fail('无权恢复版块', 403);
}
wp_untrash_post($id);
return self::success(['url' => get_permalink($id)], '版块已恢复');
}
private static function taxonomy(string $name): string {
return match ($name) {
'category' => Core::CATEGORY,
'topic' => Core::TOPIC,
'tag' => Core::TAG,
default => '',
};
}
private static function term_permission(string $taxonomy, int $term_id = 0): bool {
if (Access::is_admin(self::actor())) {
return true;
}
return $term_id === 0 && $taxonomy === Core::TOPIC && (bool) jio_get_setting('jio_forum_user_topics', true);
}
public static function create_term(\WP_REST_Request $request) {
$input = self::input($request);
$taxonomy = self::taxonomy(sanitize_key((string) ($input['type'] ?? '')));
if ($taxonomy === '' || !self::term_permission($taxonomy)) {
return self::fail('无权创建分类', 403);
}
$name = sanitize_text_field((string) ($input['name'] ?? ''));
if ($name === '' || mb_strlen($name) > 60) {
return self::fail('名称无效');
}
$result = wp_insert_term($name, $taxonomy, [
'description' => sanitize_textarea_field((string) ($input['description'] ?? '')),
'parent' => $taxonomy === Core::CATEGORY ? absint($input['parent'] ?? 0) : 0,
]);
if (is_wp_error($result)) {
return self::fail($result->get_error_message());
}
return self::success(['id' => (int) $result['term_id']], '已创建');
}
public static function update_term(\WP_REST_Request $request) {
$id = absint($request['id']);
$term = get_term($id);
if (!$term instanceof \WP_Term || !self::term_permission($term->taxonomy, $id)) {
return self::fail('无权编辑分类', 403);
}
$input = self::input($request);
$result = wp_update_term($id, $term->taxonomy, [
'name' => sanitize_text_field((string) ($input['name'] ?? $term->name)),
'description' => sanitize_textarea_field((string) ($input['description'] ?? $term->description)),
]);
return is_wp_error($result) ? self::fail($result->get_error_message()) : self::success(['id' => $id], '已更新');
}
public static function delete_term(\WP_REST_Request $request) {
$id = absint($request['id']);
$term = get_term($id);
if (!$term instanceof \WP_Term || !self::term_permission($term->taxonomy, $id)) {
return self::fail('无权删除分类', 403);
}
$result = wp_delete_term($id, $term->taxonomy);
return is_wp_error($result) || !$result ? self::fail('删除失败') : self::success([], '已删除');
}
public static function moderator(\WP_REST_Request $request) {
$id = absint($request['id']);
if (!self::plate($id) || !Access::can_manage_moderators(self::actor(), $id)) {
return self::fail('无权设置版主', 403);
}
$input = self::input($request);
$user_id = absint($input['user_id'] ?? 0);
$active = !empty($input['active']);
if (!$user_id || !get_userdata($user_id)) {
return self::fail('用户不存在');
}
$list = array_map('absint', (array) get_post_meta($id, '_jio_forum_moderators', true));
$list = array_values(array_unique(array_diff($list, [$user_id])));
if ($active) {
$list[] = $user_id;
}
update_post_meta($id, '_jio_forum_moderators', $list);
do_action('jio_forum_moderator_changed', $id, $user_id, $active);
return self::success(['moderators' => $list], $active ? '已设为版主' : '已移除版主');
}
public static function apply_moderator(\WP_REST_Request $request) {
$id = absint($request['id']);
$user = self::actor();
if (!self::plate($id) || get_post_status($id) !== 'publish' || Access::is_moderator($user, $id) || !jio_get_setting('jio_forum_moderator_apply', true)) {
return self::fail('无法申请此版块版主', 403);
}
$key = '_jio_forum_application_' . $id;
$state = (string) get_user_meta($user, $key, true);
if ($state === 'pending') {
return self::success(['status' => 'pending'], '申请正在审核');
}
update_user_meta($user, $key, 'pending');
update_user_meta($user, $key . '_reason', sanitize_textarea_field((string) (self::input($request)['reason'] ?? '')));
do_action('jio_forum_moderator_applied', $id, $user);
return self::success(['status' => 'pending'], '申请已提交');
}
public static function process_application(\WP_REST_Request $request) {
$id = absint($request['id']);
$user_id = absint($request['user_id']);
if (!self::plate($id) || !Access::can_manage_moderators(self::actor(), $id) || !get_userdata($user_id)) {
return self::fail('无权处理申请', 403);
}
$key = '_jio_forum_application_' . $id;
if (get_user_meta($user_id, $key, true) !== 'pending') {
return self::fail('申请不存在');
}
$status = sanitize_key((string) (self::input($request)['status'] ?? ''));
if (!in_array($status, ['approved', 'rejected'], true)) {
return self::fail('处理结果无效');
}
update_user_meta($user_id, $key, $status);
if ($status === 'approved') {
$list = array_map('absint', (array) get_post_meta($id, '_jio_forum_moderators', true));
$list[] = $user_id;
update_post_meta($id, '_jio_forum_moderators', array_values(array_unique($list)));
}
do_action('jio_forum_moderator_application_processed', $id, $user_id, $status);
return self::success(['status' => $status], '申请已处理');
}
}
+251
View File
@@ -0,0 +1,251 @@
<?php
namespace Jio\Forum;
defined('ABSPATH') || exit;
trait RestPosts {
public static function create_post(\WP_REST_Request $request) {
return self::save_post($request, 0);
}
public static function update_post(\WP_REST_Request $request) {
return self::save_post($request, absint($request['id']));
}
private static function save_post(\WP_REST_Request $request, int $id) {
$user_id = self::actor();
$current = $id > 0 ? self::post($id) : null;
if ($id > 0 && (!$current || !Access::can_manage_post($user_id, $id))) {
return self::fail('无权编辑此帖子', 403);
}
$input = self::input($request);
$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);
}
if ($id === 0 && !Access::is_moderator($user_id, $plate_id)) {
$limit = max(0, (int) jio_get_setting('jio_forum_daily_post_limit', 20));
if ($limit > 0) {
$recent = get_posts(['post_type' => Core::POST, 'post_status' => ['publish', 'pending'], 'author' => $user_id, 'date_query' => [['after' => 'today']], 'posts_per_page' => $limit, 'fields' => 'ids']);
if (count($recent) >= $limit) {
return self::fail('已达到今日发帖上限', 429);
}
}
}
$title = sanitize_text_field((string) ($input['title'] ?? ($current->post_title ?? '')));
$content = wp_kses_post((string) ($input['content'] ?? ($current->post_content ?? '')));
if (mb_strlen($title) < 2 || mb_strlen($title) > 120 || trim(wp_strip_all_tags($content)) === '') {
return self::fail('请填写标题和正文');
}
$type = sanitize_key((string) ($input['type'] ?? ($current ? get_post_meta($id, '_jio_forum_type', true) : 'normal')));
if (!in_array($type, ['normal', 'question', 'vote'], true)) {
return self::fail('帖子类型无效');
}
if (($type === 'vote' && !jio_get_setting('jio_forum_vote_enabled', true)) || ($type === 'question' && !jio_get_setting('jio_forum_question_enabled', true))) {
return self::fail('当前未启用此帖子类型');
}
$votes = [];
$vote_type = 'single';
$vote_title = '';
$vote_days = 0;
if ($type === 'vote') {
$vote_type = sanitize_key((string) ($input['vote_type'] ?? ($current ? get_post_meta($id, '_jio_forum_vote_type', true) : 'single')));
if ($vote_type === '') $vote_type = 'single';
if (!in_array($vote_type, ['single', 'multiple', 'pk'], true)) return self::fail('投票方式无效');
$vote_title = sanitize_text_field((string) ($input['vote_title'] ?? ($current ? get_post_meta($id, '_jio_forum_vote_title', true) : '')));
if (mb_strlen($vote_title) > 60) return self::fail('投票标题过长');
$vote_days = absint($input['vote_days'] ?? 0);
if (!in_array($vote_days, [0, 1, 7, 30], true)) return self::fail('投票有效期无效');
$raw = (string) ($input['vote_options'] ?? '');
if ($raw === '' && $current) {
$votes = (array) get_post_meta($id, '_jio_forum_vote_options', true);
} else {
$votes = array_values(array_unique(array_filter(array_map('sanitize_text_field', preg_split('/\R/', $raw)))));
}
$max_votes = max(2, min(20, (int) jio_get_setting('jio_forum_vote_max', 10)));
if (count($votes) < 2 || count($votes) > $max_votes) {
return self::fail('投票选项数量无效');
}
if ($vote_type === 'pk' && count($votes) !== 2) return self::fail('双选 PK 只能有两个选项');
if ($current && get_post_meta($id, '_jio_forum_vote_total', true) && $votes !== (array) get_post_meta($id, '_jio_forum_vote_options', true)) {
return self::fail('已有投票后不能更改选项');
}
if ($current && get_post_meta($id, '_jio_forum_vote_total', true) && $vote_type !== (string) get_post_meta($id, '_jio_forum_vote_type', true)) {
return self::fail('已有投票后不能更改投票方式');
}
}
$view = sanitize_key((string) ($input['view_limit'] ?? ($current ? get_post_meta($id, '_jio_forum_view_limit', true) : 'all')));
if (!in_array($view, ['all', 'login', 'follow', 'points', 'pay'], true)) {
return self::fail('访问权限无效');
}
if (($view === 'points' && !jio_get_setting('jio_forum_points_enabled', true)) || ($view === 'pay' && !jio_get_setting('jio_forum_paid_content', true))) {
return self::fail('当前未启用此解锁方式');
}
$price = (string) ($input['price'] ?? ($current ? get_post_meta($id, '_jio_forum_price', true) : '0'));
if (($view === 'points' && (!ctype_digit($price) || (int) $price <= 0)) || ($view === 'pay' && !preg_match('/^(0|[1-9]\d*)(?:\.\d{1,2})?$/', $price))) {
return self::fail('解锁价格无效');
}
if (in_array($view, ['points', 'pay'], true)) {
$valid_price = self::validate_price($price, $view);
if (is_wp_error($valid_price)) return $valid_price;
foreach (['vip1', 'vip2'] as $vip) {
if (!isset($input['price_' . $vip]) || $input['price_' . $vip] === '') continue;
$valid_price = self::validate_price((string) $input['price_' . $vip], $view);
if (is_wp_error($valid_price)) return $valid_price;
}
}
if (isset($input['topics']) && !jio_get_setting('jio_forum_user_topics', true) && !Access::is_admin($user_id)) {
foreach (array_filter(array_map('sanitize_text_field', explode(',', (string) $input['topics']))) as $name) {
if (!term_exists($name, Core::TOPIC)) return self::fail('无权创建新话题', 403);
}
}
if ($current && (int) get_post_meta($id, '_jio_forum_vote_total', true) > 0 && $type !== 'vote') {
return self::fail('已有投票后不能更改帖子类型');
}
if ($current && get_post_meta($id, '_jio_forum_adopted_comment', true) && $type !== 'question') {
return self::fail('已有采纳回复后不能更改帖子类型');
}
$status = Access::is_moderator($user_id, $plate_id) || !jio_get_setting('jio_forum_post_review', true) ? 'publish' : 'pending';
$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,
'comment_status' => $current ? $current->comment_status : 'open', 'ping_status' => 'closed',
];
$result = $id ? wp_update_post($post_data, true) : wp_insert_post($post_data, true);
if (is_wp_error($result) || !$result) {
return self::fail('保存失败', 500);
}
$id = (int) $result;
if (class_exists('\\Jio\\Attachment\\PendingUploads')) {
\Jio\Attachment\PendingUploads::claim_text($content, $user_id);
}
update_post_meta($id, '_jio_forum_plate_id', $plate_id);
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);
foreach (['vip1', 'vip2'] as $vip) {
if (!isset($input['price_' . $vip])) continue;
$vip_price = (string) $input['price_' . $vip];
if ($vip_price === '' || preg_match($view === 'points' ? '/^\d+$/' : '/^(0|[1-9]\d*)(?:\.\d{1,2})?$/', $vip_price)) {
update_post_meta($id, '_jio_forum_price_' . $vip, $vip_price);
}
}
if ($type === 'vote') {
update_post_meta($id, '_jio_forum_vote_options', $votes);
update_post_meta($id, '_jio_forum_vote_type', $vote_type);
update_post_meta($id, '_jio_forum_vote_title', $vote_title);
if (!$current || !get_post_meta($id, '_jio_forum_vote_started', true)) {
$started = current_time('timestamp');
update_post_meta($id, '_jio_forum_vote_started', $started);
update_post_meta($id, '_jio_forum_vote_deadline', $vote_days ? $started + $vote_days * DAY_IN_SECONDS : 0);
}
}
foreach ([Core::TOPIC => 'topics', Core::TAG => 'tags'] as $taxonomy => $key) {
if (isset($input[$key])) {
$names = array_values(array_filter(array_map('sanitize_text_field', explode(',', (string) $input[$key]))));
wp_set_object_terms($id, $names, $taxonomy);
}
}
do_action($current ? 'jio_forum_post_updated' : 'jio_forum_post_created', $id, $user_id, $status);
return self::success(['id' => $id, 'url' => $status === 'pending' ? Core::edit_url($id) : get_permalink($id), 'status' => $status], $status === 'pending' ? '已提交审核' : '帖子已发布');
}
public static function create_plate(\WP_REST_Request $request) {
return self::save_plate($request, 0);
}
public static function update_plate(\WP_REST_Request $request) {
return self::save_plate($request, absint($request['id']));
}
private static function save_plate(\WP_REST_Request $request, int $id) {
$user_id = self::actor();
$current = $id > 0 ? self::plate($id) : null;
if ($id > 0 && (!$current || !Access::is_moderator($user_id, $id))) {
return self::fail('无权编辑版块', 403);
}
if ($id === 0 && !Access::is_admin($user_id) && !jio_get_setting('jio_forum_user_plates', true)) {
return self::fail('当前不允许创建版块', 403);
}
$input = self::input($request);
$title = sanitize_text_field((string) ($input['title'] ?? ($current->post_title ?? '')));
if (mb_strlen($title) < 2 || mb_strlen($title) > 80) {
return self::fail('版块名称需为 2 至 80 个字符');
}
$follow_mode = sanitize_key((string) ($input['follow_mode'] ?? ($current ? get_post_meta($id, '_jio_forum_follow_mode', true) : 'all')));
$view_limit = sanitize_key((string) ($input['view_limit'] ?? ($current ? get_post_meta($id, '_jio_forum_view_limit', true) : 'all')));
if (!in_array($follow_mode, ['all', 'points', 'pay'], true) || !in_array($view_limit, ['all', 'login', 'follow', 'points', 'pay'], true)) {
return self::fail('版块权限无效');
}
if (in_array($follow_mode, ['points', 'pay'], true) && !jio_get_setting('jio_forum_paid_follow', true)) {
return self::fail('付费关注未启用');
}
if (($follow_mode === 'points' || $view_limit === 'points') && !jio_get_setting('jio_forum_points_enabled', true)) {
return self::fail('积分解锁未启用');
}
$follow_price = (string) ($input['follow_price'] ?? ($current ? get_post_meta($id, '_jio_forum_follow_price', true) : '0'));
if ($follow_price === '') $follow_price = '0';
if (!preg_match($follow_mode === 'points' ? '/^\d+$/' : '/^(0|[1-9]\d*)(?:\.\d{1,2})?$/', $follow_price)) {
return self::fail('关注价格无效');
}
if (in_array($follow_mode, ['points', 'pay'], true)) {
$valid_price = self::validate_price($follow_price, $follow_mode);
if (is_wp_error($valid_price)) return $valid_price;
foreach (['vip1', 'vip2'] as $vip) {
if (!isset($input['follow_price_' . $vip]) || $input['follow_price_' . $vip] === '') continue;
$valid_price = self::validate_price((string) $input['follow_price_' . $vip], $follow_mode);
if (is_wp_error($valid_price)) return $valid_price;
}
}
$status = Access::is_admin($user_id) || !jio_get_setting('jio_forum_plate_review', true) ? 'publish' : 'pending';
$result = $id ? wp_update_post([
'ID' => $id, 'post_title' => $title,
'post_content' => wp_kses_post((string) ($input['content'] ?? $current->post_content)),
], true) : wp_insert_post([
'post_type' => Core::PLATE, 'post_title' => $title, 'post_status' => $status,
'post_author' => $user_id, 'post_content' => wp_kses_post((string) ($input['content'] ?? '')),
], true);
if (is_wp_error($result) || !$result) {
return self::fail('版块保存失败', 500);
}
$id = (int) $result;
if (isset($input['category_id'])) {
$term_id = absint($input['category_id']);
if ($term_id && term_exists($term_id, Core::CATEGORY)) {
wp_set_object_terms($id, [$term_id], Core::CATEGORY);
}
}
if (Access::is_moderator($user_id, $id) && isset($input['publish_limit'])) {
$limit = sanitize_key((string) $input['publish_limit']);
if (in_array($limit, ['all', 'follow'], true)) {
update_post_meta($id, '_jio_forum_publish_limit', $limit);
}
}
update_post_meta($id, '_jio_forum_follow_mode', $follow_mode);
update_post_meta($id, '_jio_forum_follow_price', $follow_price);
update_post_meta($id, '_jio_forum_view_limit', $view_limit);
foreach (['vip1', 'vip2'] as $vip) {
if (!isset($input['follow_price_' . $vip])) continue;
$vip_price = (string) $input['follow_price_' . $vip];
if ($vip_price === '' || preg_match($follow_mode === 'points' ? '/^\d+$/' : '/^(0|[1-9]\d*)(?:\.\d{1,2})?$/', $vip_price)) {
update_post_meta($id, '_jio_forum_follow_price_' . $vip, $vip_price);
}
}
return self::success(['id' => $id, 'url' => get_post_status($id) === 'publish' ? get_permalink($id) : Core::home_url(), 'status' => get_post_status($id)], get_post_status($id) === 'pending' ? '版块已提交审核' : '版块已保存');
}
private static function validate_price(string $value, string $mode) {
if ($mode === 'points') {
$max = (string) PHP_INT_MAX;
return preg_match('/^(0|[1-9]\d*)$/', $value) && (strlen($value) < strlen($max) || strlen($value) === strlen($max) && strcmp($value, $max) <= 0)
? true : self::fail('积分价格超出范围');
}
try {
\Jio\Ledger\AssetAmount::balance($value);
return true;
} catch (\InvalidArgumentException $exception) {
return self::fail('付费价格超出范围');
}
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace Jio\Forum;
defined('ABSPATH') || exit;
trait RestReplies {
private static bool $posting_reply = false;
public static function inside_reply(): bool {
return self::$posting_reply;
}
public static function reply(\WP_REST_Request $request) {
$id = absint($request['id']);
$post = self::post($id);
if (!$post || $post->post_status !== 'publish' || $post->comment_status !== 'open' || !Access::can_view(self::actor(), $id)) {
return self::fail('当前无法回复', 403);
}
$input = self::input($request);
$content = trim(wp_kses_post((string) ($input['content'] ?? '')));
if (mb_strlen(wp_strip_all_tags($content)) < 2 || mb_strlen($content) > 10000) {
return self::fail('回复长度需为 2 至 10000 个字符');
}
$parent = absint($input['parent_id'] ?? 0);
if ($parent) {
$parent_comment = get_comment($parent);
if (!$parent_comment instanceof \WP_Comment || (int) $parent_comment->comment_post_ID !== $id || $parent_comment->comment_approved !== '1') {
return self::fail('被回复的内容不存在');
}
}
$user = wp_get_current_user();
self::$posting_reply = true;
try {
$result = wp_new_comment([
'comment_post_ID' => $id,
'comment_content' => $content,
'comment_parent' => $parent,
'user_id' => (int) $user->ID,
'comment_author' => $user->display_name,
'comment_author_email' => $user->user_email,
'comment_author_url' => $user->user_url,
], true);
} finally {
self::$posting_reply = false;
}
if (is_wp_error($result) || !$result) {
return self::fail(is_wp_error($result) ? $result->get_error_message() : '回复失败');
}
$comment = get_comment((int) $result);
if (class_exists('\\Jio\\Attachment\\PendingUploads')) {
\Jio\Attachment\PendingUploads::claim_text($content, (int) $user->ID);
}
return self::success(['id' => (int) $result, 'status' => $comment->comment_approved === '1' ? 'publish' : 'pending'], $comment->comment_approved === '1' ? '回复成功' : '回复已提交审核');
}
public static function like_reply(\WP_REST_Request $request) {
$id = absint($request['id']);
$comment = get_comment($id);
if (!$comment instanceof \WP_Comment || get_post_type((int) $comment->comment_post_ID) !== Core::POST || $comment->comment_approved !== '1') {
return self::fail('回复不存在', 404);
}
$key = '_jio_forum_reply_like_' . $id;
$user = self::actor();
return self::with_lock('reply-like', $id, static function () use ($id, $user, $key) {
$active = !get_user_meta($user, $key, true);
if ($active) add_user_meta($user, $key, 1, true);
else delete_user_meta($user, $key);
$count = max(0, (int) get_comment_meta($id, 'jio_comment_likes', true) + ($active ? 1 : -1));
update_comment_meta($id, 'jio_comment_likes', $count);
do_action('jio_comment_liked', $id, $user, $active);
return self::success(['active' => $active, 'count' => $count, 'label' => '赞 ' . $count], $active ? '已点赞' : '已取消点赞');
});
}
public static function hot_reply(\WP_REST_Request $request) {
$id = absint($request['id']);
$comment = get_comment($id);
$post_id = $comment instanceof \WP_Comment ? (int) $comment->comment_post_ID : 0;
$plate = (int) get_post_meta($post_id, '_jio_forum_plate_id', true);
if (!$comment instanceof \WP_Comment || get_post_type($post_id) !== Core::POST || !Access::is_moderator(self::actor(), $plate)) {
return self::fail('无权设置热门回复', 403);
}
$active = !get_comment_meta($id, '_jio_forum_hot', true);
update_comment_meta($id, '_jio_forum_hot', $active ? 1 : 0);
return self::success(['active' => $active, 'label' => $active ? '取消热门' : '设为热门'], $active ? '已设为热门' : '已取消热门');
}
private static function can_moderate_reply(int $id): bool {
$comment = get_comment($id);
if (!$comment instanceof \WP_Comment || get_post_type((int) $comment->comment_post_ID) !== Core::POST) return false;
$plate = (int) get_post_meta((int) $comment->comment_post_ID, '_jio_forum_plate_id', true);
return Access::is_moderator(self::actor(), $plate);
}
public static function audit_reply(\WP_REST_Request $request) {
$id = absint($request['id']);
if (!self::can_moderate_reply($id)) return self::fail('无权审核回复', 403);
$approved = sanitize_key((string) (self::input($request)['status'] ?? 'approve'));
if (!in_array($approved, ['approve', 'hold'], true)) return self::fail('审核状态无效');
if (!wp_set_comment_status($id, $approved)) return self::fail('回复审核失败', 500);
if ($approved === 'hold') self::clear_adoption($id);
return self::success(['status' => $approved], $approved === 'approve' ? '回复已通过' : '回复已隐藏');
}
public static function delete_reply(\WP_REST_Request $request) {
$id = absint($request['id']);
$comment = get_comment($id);
if (!$comment instanceof \WP_Comment || get_post_type((int) $comment->comment_post_ID) !== Core::POST || ((int) $comment->user_id !== self::actor() && !self::can_moderate_reply($id))) return self::fail('无权删除回复', 403);
if (!wp_trash_comment($id)) return self::fail('删除失败', 500);
self::clear_adoption($id);
return self::success([], '回复已删除');
}
public static function restore_reply(\WP_REST_Request $request) {
$id = absint($request['id']);
$comment = get_comment($id);
if (!$comment instanceof \WP_Comment || get_post_type((int) $comment->comment_post_ID) !== Core::POST || $comment->comment_approved !== 'trash' || ((int) $comment->user_id !== self::actor() && !self::can_moderate_reply($id))) return self::fail('无权恢复回复', 403);
if (!wp_untrash_comment($id)) return self::fail('恢复失败', 500);
return self::success([], '回复已恢复');
}
private static function clear_adoption(int $comment_id): void {
$comment = get_comment($comment_id);
if (!$comment instanceof \WP_Comment) return;
$post_id = (int) $comment->comment_post_ID;
if ((int) get_post_meta($post_id, '_jio_forum_adopted_comment', true) === $comment_id) {
delete_post_meta($post_id, '_jio_forum_adopted_comment');
delete_comment_meta($comment_id, '_jio_forum_adopted');
}
}
}
+387
View File
@@ -0,0 +1,387 @@
<?php
namespace Jio\Forum;
defined('ABSPATH') || exit;
final class View {
public static function open(string $title, string $context): void {
get_header();
echo '<main id="forum" class="jio-forum jio-forum--' . esc_attr($context) . '"><div class="jio-forum__wrap">';
echo '<nav class="jio-forum__breadcrumbs" aria-label="路径"><a href="' . esc_url(home_url('/')) . '">首页</a><span>/</span><a href="' . esc_url(Core::home_url()) . '">论坛</a>';
if ($context !== 'home') {
echo '<span>/</span><span>' . esc_html($title) . '</span>';
}
echo '</nav>';
}
public static function close(): void {
echo '</div></main>';
get_footer();
}
public static function sidebar(string $context): void {
echo '<aside class="jio-forum__aside">';
if (is_user_logged_in()) {
echo '<a class="jio-forum__new" href="' . esc_url(Core::edit_url()) . '">发布帖子</a>';
if ($context === 'home' && jio_get_setting('jio_forum_user_plates', true)) {
echo '<button type="button" class="jio-forum__action jio-forum__create-plate" data-forum-open="plate">创建版块</button>';
echo '<dialog class="jio-forum__dialog" data-forum-dialog="plate"><form data-forum-form="plate"><h2>创建版块</h2><label>名称<input name="title" required maxlength="80"></label><label>介绍<textarea name="content" rows="5"></textarea></label><p class="jio-forum__message" role="status"></p><div class="jio-forum__dialog-actions"><button type="button" data-forum-close>取消</button><button type="submit" class="jio-forum__primary">提交</button></div></form></dialog>';
}
} else {
echo '<a class="jio-forum__new" href="' . esc_url(jio_get_login_url(Core::edit_url())) . '">登录后发帖</a>';
}
\Jio\WidgetSystem\WidgetAreas::render('forum_' . $context . '_sidebar');
$plates = get_posts(['post_type' => Core::PLATE, 'post_status' => 'publish', 'posts_per_page' => 8, 'orderby' => 'date']);
if ($plates) {
echo '<section class="jio-forum__aside-section"><h2>热门版块</h2>';
foreach ($plates as $plate) {
echo '<a href="' . esc_url(get_permalink($plate)) . '">' . esc_html($plate->post_title) . '</a>';
}
echo '</section>';
}
echo '</aside>';
}
public static function home(): void {
self::open('论坛', 'home');
\Jio\WidgetSystem\WidgetAreas::render('forum_home_top_fluid');
echo '<div class="jio-forum__layout"><div class="jio-forum__main">';
$name = (string) jio_get_setting('jio_forum_forum_name', '论坛');
echo '<header class="jio-forum__hero"><h1>' . esc_html($name) . '</h1></header>';
if (Access::is_admin(get_current_user_id())) {
$pending = get_posts(['post_type' => Core::PLATE, 'post_status' => 'pending', 'posts_per_page' => 20]);
if ($pending) {
echo '<section class="jio-forum__pending"><h2>待审核版块</h2>';
foreach ($pending as $plate) {
echo '<div><span>' . esc_html($plate->post_title) . '</span><button type="button" data-forum-action="plate-audit" data-id="' . (int) $plate->ID . '">通过审核</button></div>';
}
echo '</section>';
}
self::term_management();
}
self::tabs();
$categories = get_terms(['taxonomy' => Core::CATEGORY, 'hide_empty' => false]);
if (is_array($categories) && $categories) {
echo '<div class="jio-forum__chips">';
foreach ($categories as $category) {
echo '<a href="' . esc_url(get_term_link($category)) . '">' . esc_html($category->name) . '</a>';
}
echo '</div>';
}
self::posts(['post_type' => Core::POST, 'post_status' => 'publish'], 'home');
echo '</div>';
self::sidebar('home');
echo '</div>';
\Jio\WidgetSystem\WidgetAreas::render('forum_home_bottom_fluid');
self::close();
}
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 '<div class="jio-forum__layout"><div class="jio-forum__main">';
echo '<header class="jio-forum__page-head"><h1>' . esc_html($title) . '</h1>';
if ($plate) {
echo '<div class="jio-forum__description">' . wp_kses_post(wpautop($plate->post_excerpt ?: $plate->post_content)) . '</div>';
echo '<p class="jio-forum__stats">' . (int) get_post_meta($plate->ID, '_jio_forum_follow_count', true) . ' 人关注</p>';
self::action('follow', $plate->ID, Access::follows(get_current_user_id(), $plate->ID) ? '已关注' : '关注版块');
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 '<p>' . esc_html($term->description) . '</p>';
}
echo '</header>';
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 '<h2 class="jio-forum__section-title">待审核帖子</h2>';
$args['post_status'] = 'pending';
self::posts($args, $context);
}
}
echo '</div>';
self::sidebar($context);
echo '</div>';
\Jio\WidgetSystem\WidgetAreas::render('forum_' . $context . '_bottom_fluid');
self::close();
}
public static function tabs(): void {
$tabs = ['latest' => '最新', 'popular' => '热门', 'essence' => '精华', 'question' => '问答', 'vote' => '投票', 'plates' => '版块', 'follow' => '已关注'];
$enabled = (array) jio_get_setting('jio_forum_home_tabs', array_keys($tabs));
$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 '<nav class="jio-forum__tabs" aria-label="论坛栏目">';
foreach ($tabs as $key => $label) {
if (!in_array($key, $enabled, true)) continue;
if ($key === 'follow' && !is_user_logged_in()) continue;
$url = add_query_arg('forum_view', $key, $base_url);
$selected = (string) ($_GET['forum_view'] ?? 'latest') === $key;
echo '<a class="' . ($selected ? 'is-active' : '') . '" href="' . esc_url($url) . '">' . esc_html($label) . '</a>';
}
echo '</nav>';
}
public static function posts(array $args, string $context = 'home'): void {
$view = sanitize_key((string) ($_GET['forum_view'] ?? 'latest'));
if ($view === 'plates') {
self::plates([]);
return;
}
if ($view === 'follow') {
$ids = self::user_marked_ids(get_current_user_id(), '_jio_forum_follow_');
$args['meta_query'][] = ['key' => '_jio_forum_plate_id', 'value' => $ids ?: [0], 'compare' => 'IN'];
}
if (in_array($view, ['question', 'vote'], true)) {
$args['meta_query'][] = ['key' => '_jio_forum_type', 'value' => $view];
}
if ($view === 'essence') {
$args['meta_query'][] = ['key' => '_jio_forum_essence', 'value' => 1];
}
$args['posts_per_page'] = max(1, min(50, (int) jio_get_setting('jio_forum_page_size', 20)));
$args['paged'] = max(1, (int) get_query_var('paged', 1));
$args['orderby'] = 'date';
$args['order'] = 'DESC';
$args['jio_forum_list'] = in_array($view, ['latest', 'popular', 'question', 'vote', 'essence', 'follow'], true) ? $view : 'latest';
$query = new \WP_Query($args);
\Jio\WidgetSystem\WidgetAreas::render('forum_' . $context . '_top_content');
echo '<div class="jio-forum__list">';
if (!$query->have_posts()) {
echo '<p class="jio-forum__empty">暂无帖子</p>';
}
while ($query->have_posts()) {
$query->the_post();
self::post_card(get_post());
}
wp_reset_postdata();
echo '</div>';
self::pagination($query);
\Jio\WidgetSystem\WidgetAreas::render('forum_' . $context . '_bottom_content');
}
public static function plates(array $args): void {
$query = new \WP_Query(array_merge(['post_type' => Core::PLATE, 'post_status' => 'publish', 'posts_per_page' => 20], $args));
echo '<div class="jio-forum__plates">';
while ($query->have_posts()) {
$query->the_post();
$plate = get_post();
self::plate_card($plate);
}
wp_reset_postdata();
echo '</div>';
}
public static function plate_card(\WP_Post $plate): void {
$url = get_permalink($plate);
echo '<article class="jio-forum__plate"><a class="jio-forum__plate-cover" href="' . esc_url($url) . '">';
if (jio_get_setting('jio_forum_show_plate_cover', true) && has_post_thumbnail($plate)) {
echo get_the_post_thumbnail($plate, 'jio-card');
}
echo '</a><div><h2><a href="' . esc_url($url) . '">' . esc_html($plate->post_title) . '</a></h2><p>' . esc_html(wp_trim_words(wp_strip_all_tags($plate->post_excerpt ?: $plate->post_content), 22)) . '</p><small>' . (int) get_post_meta($plate->ID, '_jio_forum_follow_count', true) . ' 人关注</small></div></article>';
}
public static function post_card(\WP_Post $post): void {
$plate_id = (int) get_post_meta($post->ID, '_jio_forum_plate_id', true);
$type = (string) get_post_meta($post->ID, '_jio_forum_type', true);
echo '<article class="jio-forum__post"><a class="jio-forum__avatar" href="' . esc_url(get_author_posts_url((int) $post->post_author)) . '">' . get_avatar((int) $post->post_author, 44) . '</a><div class="jio-forum__post-body">';
echo '<div class="jio-forum__post-top"><span>' . esc_html(get_the_author_meta('display_name', (int) $post->post_author)) . '</span><time datetime="' . esc_attr(get_the_date(DATE_W3C, $post)) . '">' . esc_html(get_the_date('', $post)) . '</time></div>';
$url = $post->post_status === 'publish' ? get_permalink($post) : Core::edit_url($post->ID);
echo '<h2><a href="' . esc_url($url) . '">' . esc_html($post->post_title) . '</a></h2>';
$excerpt = Access::can_view(get_current_user_id(), $post->ID) ? wp_trim_words(wp_strip_all_tags($post->post_excerpt ?: $post->post_content), 30) : '内容已隐藏';
echo '<p>' . esc_html($excerpt) . '</p>';
echo '<div class="jio-forum__post-meta">';
if (get_post_meta($post->ID, '_jio_forum_pinned', true)) {
echo '<span class="jio-forum__badge">置顶</span>';
}
if ($type !== '') {
echo '<span class="jio-forum__badge">' . esc_html($type === 'question' ? '问答' : '投票') . '</span>';
}
if ($type === 'question' && get_post_meta($post->ID, '_jio_forum_adopted_comment', true)) {
echo '<span class="jio-forum__badge">已解决</span>';
}
if (get_post_meta($post->ID, '_jio_forum_essence', true)) {
echo '<span class="jio-forum__badge">精华</span>';
}
if ($plate_id > 0) {
echo '<a href="' . esc_url(get_permalink($plate_id)) . '">' . esc_html(get_the_title($plate_id)) . '</a>';
}
echo '<span>' . (int) get_post_meta($post->ID, '_jio_forum_views', true) . ' 阅读</span>';
echo '<span>' . (int) $post->comment_count . ' 回复</span>';
echo '<span>' . (int) get_post_meta($post->ID, '_jio_forum_score', true) . ' 评分</span>';
echo '<span>' . (int) get_post_meta($post->ID, '_jio_forum_favorite_count', true) . ' 收藏</span></div></div></article>';
$hot = get_comments(['post_id' => $post->ID, 'status' => 'approve', 'number' => 1, 'meta_key' => '_jio_forum_hot', 'meta_value' => '1']);
if ($hot) {
echo '<div class="jio-forum__hot-reply">热门回复:' . esc_html(wp_trim_words(wp_strip_all_tags($hot[0]->comment_content), 25)) . '</div>';
}
}
public static function replies(int $post_id): void {
$page = max(1, (int) ($_GET['cpage'] ?? 1));
$limit = max(1, min(100, (int) jio_get_setting('jio_forum_reply_page_size', 20)));
$comments = get_comments(['post_id' => $post_id, 'status' => 'approve', 'number' => $limit, 'offset' => ($page - 1) * $limit, 'orderby' => 'comment_date_gmt', 'order' => 'ASC']);
$total = (int) get_comments(['post_id' => $post_id, 'status' => 'approve', 'count' => true]);
echo '<section class="jio-forum__replies" id="comments"><h2>回复 <small>' . $total . '</small></h2>';
if (!$comments) echo '<p class="jio-forum__empty">暂无回复</p>';
foreach ($comments as $comment) {
$adopted = (bool) get_comment_meta($comment->comment_ID, '_jio_forum_adopted', true);
$hot = (bool) get_comment_meta($comment->comment_ID, '_jio_forum_hot', true);
echo '<article class="jio-forum__reply" id="comment-' . (int) $comment->comment_ID . '"><div class="jio-forum__reply-avatar">' . get_avatar((int) $comment->user_id, 40) . '</div><div class="jio-forum__reply-main"><div class="jio-forum__reply-head"><b>' . esc_html(get_comment_author($comment)) . '</b><time>' . esc_html(get_comment_date('', $comment)) . '</time>' . ($adopted ? '<strong>已采纳</strong>' : '') . '</div>';
if ($hot) echo '<span class="jio-forum__badge">热门回复</span>';
if ((int) $comment->comment_parent > 0) {
$parent = get_comment((int) $comment->comment_parent);
if ($parent instanceof \WP_Comment) echo '<p class="jio-forum__reply-parent">回复 @' . esc_html(get_comment_author($parent)) . '</p>';
}
echo '<div class="jio-forum__reply-text">' . wp_kses_post(wpautop($comment->comment_content)) . '</div><div class="jio-forum__reply-actions">';
if (is_user_logged_in()) echo '<button type="button" data-forum-action="reply-like" data-id="' . (int) $comment->comment_ID . '">赞 ' . (int) get_comment_meta($comment->comment_ID, 'jio_comment_likes', true) . '</button>';
if (is_user_logged_in() && ((int) $comment->user_id === get_current_user_id() || Access::is_moderator(get_current_user_id(), (int) get_post_meta($post_id, '_jio_forum_plate_id', true)))) echo '<button type="button" data-forum-action="reply-delete" data-id="' . (int) $comment->comment_ID . '">删除</button>';
if (Access::is_moderator(get_current_user_id(), (int) get_post_meta($post_id, '_jio_forum_plate_id', true))) echo '<button type="button" data-forum-action="reply-hot" data-id="' . (int) $comment->comment_ID . '">' . ($hot ? '取消热门' : '设为热门') . '</button>';
if (is_user_logged_in()) echo '<button type="button" data-forum-reply-to="' . (int) $comment->comment_ID . '">回复</button>';
if (get_post_meta($post_id, '_jio_forum_type', true) === 'question' && !get_post_meta($post_id, '_jio_forum_adopted_comment', true) && Access::can_manage_post(get_current_user_id(), $post_id)) {
echo '<button type="button" data-forum-action="adopt" data-id="' . $post_id . '" data-comment-id="' . (int) $comment->comment_ID . '">采纳</button>';
}
echo '</div></div></article>';
}
if ($total > $limit) {
$links = paginate_links(['base' => add_query_arg('cpage', '%#%', get_permalink($post_id)), 'current' => $page, 'total' => (int) ceil($total / $limit), 'type' => 'list']);
echo '<nav class="jio-forum__pagination" aria-label="回复分页">' . wp_kses_post((string) $links) . '</nav>';
}
if (get_post_field('comment_status', $post_id) === 'open') {
if (is_user_logged_in()) {
echo '<form class="jio-forum__reply-form" data-forum-form="reply" data-id="' . $post_id . '"><h3>发表回复</h3><input type="hidden" name="parent_id" value="0"><textarea name="content" required rows="5" placeholder="写下你的回复"></textarea><p class="jio-forum__message" role="status"></p><button type="submit" class="jio-forum__primary">提交回复</button></form>';
} else {
echo '<p><a href="' . esc_url(jio_get_login_url(get_permalink($post_id))) . '">登录后回复</a></p>';
}
} else {
echo '<p class="jio-forum__empty">回复已关闭</p>';
}
echo '</section>';
}
private static function term_management(): void {
echo '<section class="jio-forum__term-management"><h2>分类与话题</h2><form data-forum-form="term"><label>类型<select name="type"><option value="category">版块分类</option><option value="topic">论坛话题</option><option value="tag">论坛标签</option></select></label><label>名称<input name="name" required maxlength="60"></label><p class="jio-forum__message" role="status"></p><button type="submit" class="jio-forum__action">添加</button></form>';
foreach (['category' => Core::CATEGORY, 'topic' => Core::TOPIC, 'tag' => Core::TAG] as $label => $taxonomy) {
$terms = get_terms(['taxonomy' => $taxonomy, 'hide_empty' => false, 'number' => 30]);
if (!is_array($terms)) continue;
foreach ($terms as $term) {
echo '<div class="jio-forum__term-row"><span>' . esc_html($term->name) . '</span><button type="button" data-forum-action="term-edit" data-id="' . (int) $term->term_id . '">编辑</button><button type="button" data-forum-action="term-delete" data-id="' . (int) $term->term_id . '">删除</button></div>';
}
}
echo '</section>';
}
private static function plate_editor(\WP_Post $plate): void {
$id = $plate->ID;
echo '<button type="button" class="jio-forum__action" data-forum-open="plate-edit-' . $id . '">编辑版块</button>';
echo '<button type="button" class="jio-forum__action" data-forum-action="plate-delete" data-id="' . $id . '">删除版块</button>';
echo '<dialog class="jio-forum__dialog" data-forum-dialog="plate-edit-' . $id . '"><form data-forum-form="plate" data-id="' . $id . '"><h2>编辑版块</h2>';
echo '<label>名称<input name="title" required value="' . esc_attr($plate->post_title) . '"></label>';
echo '<label>介绍<textarea name="content" rows="5">' . esc_textarea($plate->post_content) . '</textarea></label>';
echo '<label>发帖权限<select name="publish_limit"><option value="all">所有用户</option><option value="follow"' . selected(get_post_meta($id, '_jio_forum_publish_limit', true), 'follow', false) . '>已关注用户</option></select></label>';
echo '<label>关注方式<select name="follow_mode"><option value="all">免费</option><option value="points"' . selected(get_post_meta($id, '_jio_forum_follow_mode', true), 'points', false) . '>积分</option><option value="pay"' . selected(get_post_meta($id, '_jio_forum_follow_mode', true), 'pay', false) . '>付费</option></select></label>';
echo '<label>关注价格<input name="follow_price" type="number" min="0" step="0.01" value="' . esc_attr((string) get_post_meta($id, '_jio_forum_follow_price', true)) . '"></label>';
echo '<label>会员一级价格<input name="follow_price_vip1" type="number" min="0" step="0.01" value="' . esc_attr((string) get_post_meta($id, '_jio_forum_follow_price_vip1', true)) . '"></label>';
echo '<label>会员二级价格<input name="follow_price_vip2" type="number" min="0" step="0.01" value="' . esc_attr((string) get_post_meta($id, '_jio_forum_follow_price_vip2', true)) . '"></label>';
echo '<label>阅读权限<select name="view_limit">';
foreach (['all' => '所有人', 'login' => '登录后', 'follow' => '关注后', 'points' => '积分解锁', 'pay' => '付费解锁'] as $key => $label) {
echo '<option value="' . esc_attr($key) . '"' . selected(get_post_meta($id, '_jio_forum_view_limit', true), $key, false) . '>' . esc_html($label) . '</option>';
}
echo '</select></label><p class="jio-forum__message" role="status"></p><div class="jio-forum__dialog-actions"><button type="button" data-forum-close>取消</button><button type="submit" class="jio-forum__primary">保存</button></div></form></dialog>';
}
private static function applications(int $plate_id): void {
$users = get_users(['meta_key' => '_jio_forum_application_' . $plate_id, 'meta_value' => 'pending', 'number' => 20]);
echo '<section class="jio-forum__applications"><h2>版主管理</h2>';
$moderators = array_map('absint', (array) get_post_meta($plate_id, '_jio_forum_moderators', true));
foreach ($moderators as $user_id) {
$user = get_userdata($user_id);
if ($user) echo '<div class="jio-forum__application"><span>' . esc_html($user->display_name) . '</span><button type="button" data-forum-action="moderator-remove" data-id="' . $plate_id . '" data-user-id="' . $user_id . '">移除</button></div>';
}
echo '<form data-forum-form="moderator" data-id="' . $plate_id . '"><label>用户 ID<input name="user_id" type="number" min="1" required></label><p class="jio-forum__message" role="status"></p><button type="submit" class="jio-forum__action">添加版主</button></form>';
if ($users) echo '<h3>待处理申请</h3>';
foreach ($users as $user) {
echo '<div class="jio-forum__application"><span>' . esc_html($user->display_name) . '</span><button type="button" data-forum-action="application" data-id="' . $plate_id . '" data-user-id="' . (int) $user->ID . '" data-status="approved">通过</button><button type="button" data-forum-action="application" data-id="' . $plate_id . '" data-user-id="' . (int) $user->ID . '" data-status="rejected">拒绝</button></div>';
}
echo '</section>';
}
public static function pagination(\WP_Query $query): void {
if ($query->max_num_pages <= 1) {
return;
}
echo '<nav class="jio-forum__pagination" aria-label="分页">' . wp_kses_post(paginate_links(['total' => $query->max_num_pages, 'current' => max(1, (int) get_query_var('paged', 1)), 'type' => 'list'])) . '</nav>';
}
public static function action(string $action, int $id, string $label): void {
echo '<button type="button" class="jio-forum__action" data-forum-action="' . esc_attr($action) . '" data-id="' . esc_attr((string) $id) . '">' . esc_html($label) . '</button>';
}
public static function user_marked_ids(int $user_id, string $prefix): array {
if ($user_id <= 0) return [];
$ids = [];
foreach (get_user_meta($user_id) as $key => $values) {
if (str_starts_with((string) $key, $prefix) && !empty($values[0])) {
$id = absint(substr((string) $key, strlen($prefix)));
if ($id) $ids[] = $id;
}
}
return array_values(array_unique($ids));
}
public static function user_panel(int $user_id, bool $owner): void {
$tab = sanitize_key((string) ($_GET['forum_tab'] ?? 'posts'));
$tabs = ['posts' => '帖子', 'plates' => '版块'];
if ($owner) $tabs += ['favorites' => '收藏', 'follow' => '关注'];
if (!isset($tabs[$tab])) $tab = 'posts';
echo '<div id="forum" class="jio-forum jio-forum__user-panel"><nav class="jio-forum__tabs" aria-label="论坛内容">';
foreach ($tabs as $key => $label) {
echo '<a class="' . ($tab === $key ? 'is-active' : '') . '" href="' . esc_url(add_query_arg('forum_tab', $key)) . '">' . esc_html($label) . '</a>';
}
echo '</nav>';
if ($owner) echo '<p><a class="jio-forum__primary" href="' . esc_url(Core::edit_url()) . '">发布帖子</a></p>';
$args = ['post_status' => $owner ? ['publish', 'pending'] : 'publish', 'posts_per_page' => 20];
if ($tab === 'posts' || $tab === 'plates') {
$args['post_type'] = $tab === 'posts' ? Core::POST : Core::PLATE;
$args['author'] = $user_id;
} else {
$args['post_type'] = $tab === 'favorites' ? Core::POST : Core::PLATE;
$ids = self::user_marked_ids($user_id, $tab === 'favorites' ? '_jio_forum_favorite_' : '_jio_forum_follow_');
$args['post__in'] = $ids ?: [0];
}
$query = new \WP_Query($args);
echo '<div class="jio-forum__list">';
if (!$query->have_posts()) echo '<p class="jio-forum__empty">暂无内容</p>';
while ($query->have_posts()) {
$query->the_post();
$post = get_post();
if ($post->post_type === Core::POST) self::post_card($post);
else {
$url = $post->post_status === 'publish' ? get_permalink($post) : '';
echo '<div class="jio-forum__user-row">' . ($url ? '<a href="' . esc_url($url) . '">' : '<span>') . esc_html($post->post_title) . ($url ? '</a>' : '</span>') . ($url ? '' : '<small>待审核</small>') . '</div>';
if ($owner && !$url) self::plate_editor($post);
}
}
wp_reset_postdata();
echo '</div></div>';
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace Jio\Forum;
defined('ABSPATH') || exit;
final class Widget extends \WP_Widget {
public function __construct() {
parent::__construct('jio_forum_widget', '论坛内容', ['description' => '显示论坛帖子、版块或话题']);
}
public function widget($args, $instance): void {
if (!Core::enabled()) {
return;
}
$title = sanitize_text_field((string) ($instance['title'] ?? '论坛动态'));
$type = sanitize_key((string) ($instance['type'] ?? 'posts'));
$count = max(1, min(20, (int) ($instance['count'] ?? 6)));
echo $args['before_widget'] ?? '';
echo ($args['before_title'] ?? '<h3>') . esc_html($title) . ($args['after_title'] ?? '</h3>');
if ($type === 'topics') {
$terms = get_terms(['taxonomy' => Core::TOPIC, 'hide_empty' => true, 'number' => $count, 'orderby' => 'count', 'order' => 'DESC']);
if (is_array($terms)) {
foreach ($terms as $term) {
echo '<a class="jio-forum__widget-link" href="' . esc_url(get_term_link($term)) . '">' . esc_html($term->name) . '<span>' . (int) $term->count . '</span></a>';
}
}
} else {
$posts = get_posts(['post_type' => $type === 'plates' ? Core::PLATE : Core::POST, 'post_status' => 'publish', 'posts_per_page' => $count, 'orderby' => $type === 'hot' ? 'comment_count' : 'date']);
foreach ($posts as $post) {
echo '<a class="jio-forum__widget-link" href="' . esc_url(get_permalink($post)) . '">' . esc_html($post->post_title) . '</a>';
}
}
echo $args['after_widget'] ?? '';
}
public function form($instance): void {
$title = (string) ($instance['title'] ?? '论坛动态');
$type = (string) ($instance['type'] ?? 'posts');
$count = (int) ($instance['count'] ?? 6);
echo '<p><label for="' . esc_attr($this->get_field_id('title')) . '">标题</label><input class="widefat" id="' . esc_attr($this->get_field_id('title')) . '" name="' . esc_attr($this->get_field_name('title')) . '" value="' . esc_attr($title) . '"></p>';
echo '<p><label for="' . esc_attr($this->get_field_id('type')) . '">内容</label><select class="widefat" id="' . esc_attr($this->get_field_id('type')) . '" name="' . esc_attr($this->get_field_name('type')) . '">';
foreach (['posts' => '最新帖子', 'hot' => '热门帖子', 'plates' => '版块', 'topics' => '热门话题'] as $key => $label) {
echo '<option value="' . esc_attr($key) . '"' . selected($type, $key, false) . '>' . esc_html($label) . '</option>';
}
echo '</select></p>';
echo '<p><label for="' . esc_attr($this->get_field_id('count')) . '">数量</label><input type="number" min="1" max="20" class="tiny-text" id="' . esc_attr($this->get_field_id('count')) . '" name="' . esc_attr($this->get_field_name('count')) . '" value="' . esc_attr((string) $count) . '"></p>';
}
public function update($new_instance, $old_instance): array {
$type = sanitize_key((string) ($new_instance['type'] ?? 'posts'));
return [
'title' => sanitize_text_field((string) ($new_instance['title'] ?? '')),
'type' => in_array($type, ['posts', 'hot', 'plates', 'topics'], true) ? $type : 'posts',
'count' => max(1, min(20, (int) ($new_instance['count'] ?? 6))),
];
}
}