diff --git a/assets/admin/src/schema/sections/users/oauth.js b/assets/admin/src/schema/sections/users/oauth.js index a5687784..88bf94f9 100644 --- a/assets/admin/src/schema/sections/users/oauth.js +++ b/assets/admin/src/schema/sections/users/oauth.js @@ -24,6 +24,11 @@ export const oauthSchema = { oauth_weibo_s: false, oauth_baidu_s: false, oauth_alipay_s: false, + clogin_s: false, + clogin_url: '', + clogin_id: '', + clogin_key: '', + clogin_oauth_s: ['qq', 'weixin', 'weibo', 'alipay'], github_appid: '', github_appkey: '', gitee_appid: '', @@ -92,6 +97,7 @@ export const oauthSchema = { { key: 'oauth_weibo_s', label: '微博登录', type: 'switch', default: false }, { key: 'oauth_baidu_s', label: '百度登录', type: 'switch', default: false }, { key: 'oauth_alipay_s', label: '支付宝登录', type: 'switch', default: false }, + { key: 'clogin_s', label: '彩虹聚合登录', type: 'switch', default: false, help: '通过第三方聚合登录平台统一接入 QQ/微信/微博/支付宝等' }, ], }, { @@ -174,6 +180,23 @@ export const oauthSchema = { { key: 'baidu_appkey', label: 'Secret Key', type: 'input', default: '' }, ], }, + { + key: 'clogin', + title: '彩虹聚合登录', + icon: 'fas fa-cloud', + fields: [ + { key: 'clogin_url', label: '接口地址', type: 'input', default: '', help: '如 https://xxx.com 不要带 connect.php', dependsOn: { key: 'clogin_s', value: true } }, + { key: 'clogin_id', label: 'AppID', type: 'input', default: '', dependsOn: { key: 'clogin_s', value: true } }, + { key: 'clogin_key', label: 'AppKey', type: 'input', default: '', dependsOn: { key: 'clogin_s', value: true } }, + { key: 'clogin_oauth_s', label: '启用平台', type: 'checkbox', default: ['qq', 'weixin', 'weibo', 'alipay'], options: [ + { label: 'QQ', value: 'qq' }, + { label: '微信', value: 'weixin' }, + { label: '微博', value: 'weibo' }, + { label: '支付宝', value: 'alipay' }, + { label: '百度', value: 'baidu' }, + ], dependsOn: { key: 'clogin_s', value: true } }, + ], + }, { key: 'alipay', title: '支付宝配置', diff --git a/assets/js/forum-governance.js b/assets/js/forum-governance.js index 239161e4..ea89be4f 100644 --- a/assets/js/forum-governance.js +++ b/assets/js/forum-governance.js @@ -621,6 +621,44 @@ }); } + + function insertForumEditorContent(html) { + if (window.tinymce) { + var ed = window.tinymce.get('compose-content') || window.tinymce.get('post_content'); + if (ed && !ed.isHidden()) { + ed.focus(); + ed.execCommand('mceInsertContent', false, html); + return; + } + } + var ta = document.getElementById('compose-content') || document.getElementById('post_content'); + if (!ta) return; + var start = ta.selectionStart || 0; + var end = ta.selectionEnd || 0; + var val = ta.value || ''; + ta.value = val.slice(0, start) + html + val.slice(end); + ta.focus(); + } + + function bindComposeShortcodeTools(root) { + root = root || document; + root.addEventListener('click', function (e) { + var btn = e.target.closest('[data-insert-shortcode]'); + if (!btn) return; + e.preventDefault(); + var type = btn.getAttribute('data-insert-shortcode'); + if (type === 'hide') { + insertForumEditorContent('[hidecontent type="login"]隐藏内容[/hidecontent]'); + } else if (type === 'reply') { + insertForumEditorContent('[hidecontent type="reply"]回复后可见[/hidecontent]'); + } else if (type === 'payshow') { + insertForumEditorContent('[hidecontent type="payshow"]付费可见内容[/hidecontent]'); + } else if (type === 'vip') { + insertForumEditorContent('[vip]VIP 可见内容[/vip]'); + } + }); + } + function bindComposeVisibilityToggle(form) { var visSelect = form.querySelector('#compose-visibility'); var creditsField = form.querySelector('#compose-credits-cost-field'); diff --git a/assets/js/front-submit.js b/assets/js/front-submit.js index 637bc9ef..60f3c77b 100644 --- a/assets/js/front-submit.js +++ b/assets/js/front-submit.js @@ -543,9 +543,13 @@ e.preventDefault(); var type = btn.getAttribute('data-insert-shortcode'); if (type === 'hide') { - insertEditorContent('[hidecontent type="logged"]hidden[/hidecontent]'); + insertEditorContent('[hidecontent type="login"]' + (i18n.hidePlaceholder || '隐藏内容') + '[/hidecontent]'); } else if (type === 'payshow') { - insertEditorContent('[hidecontent type="payshow"]paid content[/hidecontent]'); + insertEditorContent('[hidecontent type="payshow"]' + (i18n.payPlaceholder || '付费可见内容') + '[/hidecontent]'); + } else if (type === 'reply') { + insertEditorContent('[hidecontent type="reply"]' + (i18n.replyPlaceholder || '回复后可见') + '[/hidecontent]'); + } else if (type === 'vip') { + insertEditorContent('[vip]' + (i18n.vipPlaceholder || 'VIP 可见内容') + '[/vip]'); } }); }, diff --git a/inc/modules/widgets-register.php b/inc/modules/widgets-register.php index 49e6627c..e068692a 100644 --- a/inc/modules/widgets-register.php +++ b/inc/modules/widgets-register.php @@ -50,6 +50,20 @@ function aksrt_register_widgets() { // 一言轮播 register_widget('\AKSRT\Widgets\WidgetYiyan'); + // 文章目录树(zibll posts_navs) + register_widget('\AKSRT\Widgets\WidgetPostsNavs'); + + // Forum widgets + if (class_exists('\AKSRT\Widgets\WidgetForumBoardList')) { + register_widget('\AKSRT\Widgets\WidgetForumBoardList'); + } + if (class_exists('\AKSRT\Widgets\WidgetForumHotTopics')) { + register_widget('\AKSRT\Widgets\WidgetForumHotTopics'); + } + if (class_exists('\AKSRT\Widgets\WidgetForumRecentTopics')) { + register_widget('\AKSRT\Widgets\WidgetForumRecentTopics'); + } + // Shop product widgets (§6.9) register_widget('\AKSRT\Widgets\WidgetProductList'); register_widget('\AKSRT\Widgets\WidgetProductOneline'); diff --git a/src/AKSRT/Admin/Native/UsersMenus.php b/src/AKSRT/Admin/Native/UsersMenus.php index f3ea77ac..8e1fbf5a 100644 --- a/src/AKSRT/Admin/Native/UsersMenus.php +++ b/src/AKSRT/Admin/Native/UsersMenus.php @@ -32,6 +32,7 @@ class UsersMenus { add_action('admin_post_aksrt_users_update_report', [$this, 'handle_update_report']); add_action('admin_post_aksrt_users_add_restriction', [$this, 'handle_add_restriction']); add_action('admin_post_aksrt_users_remove_restriction', [$this, 'handle_remove_restriction']); + add_action('admin_post_aksrt_users_process_appeal', [$this, 'handle_process_appeal']); add_action('admin_post_aksrt_users_batch_invite_codes', [$this, 'handle_batch_invite_codes']); add_action('admin_post_aksrt_users_disable_invite_code', [$this, 'handle_disable_invite_code']); } @@ -108,6 +109,21 @@ class UsersMenus { echo '

' . esc_html__('立即处理', 'aksrt') . '

'; } } + + if ($page !== 'aksrt_user_ban' && class_exists('\AKSRT\User\BanAppeal')) { + $pending_appeals = count(get_users([ + 'meta_key' => \AKSRT\User\BanAppeal::META_STATUS, + 'meta_value' => 'pending', + 'number' => 50, + 'fields' => 'ID', + ])); + if ($pending_appeals > 0) { + $url = admin_url('users.php?page=aksrt_user_ban&tab=appeals&status=pending'); + echo '

' . esc_html__('禁封申诉待处理', 'aksrt') . '

'; + echo '

' . esc_html(sprintf(__('您有 %d 条禁封申诉待处理', 'aksrt'), $pending_appeals)) . '

'; + echo '

' . esc_html__('立即处理', 'aksrt') . '

'; + } + } } private function ensure_cap(): void { @@ -131,6 +147,9 @@ class UsersMenus { 'restriction_added' => __('限制已添加。', 'aksrt'), 'restriction_removed' => __('限制已移除。', 'aksrt'), 'restriction_failed' => __('限制操作失败。', 'aksrt'), + 'appeal_approved' => __('申诉已通过,限制已解除。', 'aksrt'), + 'appeal_rejected' => __('申诉已驳回。', 'aksrt'), + 'appeal_failed' => __('申诉处理失败。', 'aksrt'), 'invite_batch_ok' => __('邀请码批量生成完成。', 'aksrt'), 'invite_batch_failed' => __('邀请码批量生成失败。', 'aksrt'), 'invite_disabled' => __('邀请码已禁用。', 'aksrt'), @@ -590,10 +609,13 @@ class UsersMenus { echo ''; if ($tab === 'restrictions') { $this->render_restrictions_tab(); + } elseif ($tab === 'appeals') { + $this->render_appeals_tab(); } else { $this->render_reports_tab(); } @@ -768,6 +790,151 @@ class UsersMenus { $this->redirect('aksrt_user_ban', $ok ? 'restriction_removed' : 'restriction_failed', ['tab' => 'restrictions']); } + + private function render_appeals_tab(): void { + if (!class_exists('\AKSRT\User\BanAppeal')) { + echo '

' . esc_html__('申诉模块未初始化。', 'aksrt') . '

'; + return; + } + $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : 'pending'; + if (!in_array($status, ['pending', 'approved', 'rejected', 'all'], true)) { + $status = 'pending'; + } + + echo '
'; + echo ''; + echo ' '; + submit_button(__('筛选', 'aksrt'), 'secondary', '', false); + echo '
'; + + $users = get_users([ + 'meta_key' => \AKSRT\User\BanAppeal::META_STATUS, + 'meta_compare' => 'EXISTS', + 'number' => 100, + 'fields' => ['ID', 'display_name', 'user_login', 'user_email'], + 'orderby' => 'ID', + 'order' => 'DESC', + ]); + + echo ''; + echo ''; + echo ''; + $shown = 0; + foreach ($users as $u) { + $uid = (int) $u->ID; + $st = \AKSRT\User\BanAppeal::get_status($uid); + if ($st === '') { + continue; + } + if ($status !== 'all' && $st !== $status) { + continue; + } + $payload = \AKSRT\User\BanAppeal::get_payload($uid); + $shown++; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + if ($shown < 1) { + echo ''; + } + echo '
UID' . esc_html__('用户', 'aksrt') . '' . esc_html__('状态', 'aksrt') . '' . esc_html__('类型', 'aksrt') . '' . esc_html__('说明', 'aksrt') . '' . esc_html__('提交时间', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '
' . esc_html((string) $uid) . '' . esc_html($u->display_name . ' (' . $u->user_login . ')') . '
' . esc_html($u->user_email) . '
' . esc_html($st) . '' . esc_html((string) ($payload['reason_key'] ?? '—')) . '' . esc_html((string) ($payload['reason'] ?? '')) . '' . esc_html((string) ($payload['time'] ?? '—')) . ''; + if ($st === 'pending') { + echo '
'; + echo ''; + echo ''; + wp_nonce_field('aksrt_users_process_appeal_' . $uid); + echo ''; + echo ''; + echo ''; + echo '
'; + } else { + echo '—'; + } + echo '
' . esc_html__('暂无申诉', 'aksrt') . '
'; + } + + public function handle_process_appeal(): void { + if (!current_user_can(self::CAPABILITY)) { + wp_die(esc_html__('权限不足', 'aksrt')); + } + $user_id = isset($_POST['user_id']) ? (int) $_POST['user_id'] : 0; + $decision = isset($_POST['decision']) ? sanitize_key((string) wp_unslash($_POST['decision'])) : ''; + $note = isset($_POST['note']) ? sanitize_textarea_field((string) wp_unslash($_POST['note'])) : ''; + check_admin_referer('aksrt_users_process_appeal_' . $user_id); + + if ($user_id <= 0 || !in_array($decision, ['approve', 'reject'], true) || !class_exists('\AKSRT\User\BanAppeal')) { + $this->redirect('aksrt_user_ban', 'appeal_failed', ['tab' => 'appeals']); + } + + $status = $decision === 'approve' ? 'approved' : 'rejected'; + update_user_meta($user_id, \AKSRT\User\BanAppeal::META_STATUS, $status); + + $payload = \AKSRT\User\BanAppeal::get_payload($user_id); + $payload['admin_note'] = $note; + $payload['handled_at'] = current_time('Y-m-d H:i'); + $payload['handled_by'] = get_current_user_id(); + $payload['decision'] = $status; + update_user_meta($user_id, \AKSRT\User\BanAppeal::META_PAYLOAD, $payload); + + $history = get_user_meta($user_id, \AKSRT\User\BanAppeal::META_HISTORY, true); + $history = is_array($history) ? $history : []; + $history[] = $payload; + update_user_meta($user_id, \AKSRT\User\BanAppeal::META_HISTORY, array_slice($history, -30)); + + if ($decision === 'approve' && class_exists('\AKSRT\User\Restriction')) { + global $wpdb; + $table = class_exists('\AKSRT\Core\DBMigrator') ? \AKSRT\Core\DBMigrator::table('user_restriction') : ''; + if ($table !== '') { + $wpdb->query($wpdb->prepare( + "UPDATE {$table} SET status = 'lifted', expires_at = %s WHERE user_id = %d AND type IN ('ban','suspend','mute')", + current_time('mysql'), + $user_id + )); + } + $svc = \AKSRT\User\Restriction::get_instance(); + if (method_exists($svc, 'clear_user_bans')) { + $svc->clear_user_bans($user_id); + } elseif (method_exists($svc, 'lift_ban')) { + $svc->lift_ban($user_id); + } + } + + if (class_exists('\AKSRT\Messages\Message')) { + $msg = $decision === 'approve' + ? __('你的禁封申诉已通过,限制已解除。', 'aksrt') + : __('你的禁封申诉未通过,可修改后重新提交。', 'aksrt'); + if ($note !== '') { + $msg .= ' ' . sprintf(__('备注:%s', 'aksrt'), $note); + } + \AKSRT\Messages\Message::get_instance()->send( + $user_id, + 'ban_appeal', + $msg, + [ + 'title' => $decision === 'approve' ? __('申诉通过', 'aksrt') : __('申诉驳回', 'aksrt'), + ] + ); + } + + do_action('aksrt_ban_appeal_processed', $user_id, $decision, $note); + + $this->redirect( + 'aksrt_user_ban', + $decision === 'approve' ? 'appeal_approved' : 'appeal_rejected', + ['tab' => 'appeals'] + ); + } + private function pagination(int $pages, int $page_num): void { if ($pages <= 1) { return; diff --git a/src/AKSRT/Admin/Schema/SchemaUsers.php b/src/AKSRT/Admin/Schema/SchemaUsers.php index ea78be14..4c255c90 100644 --- a/src/AKSRT/Admin/Schema/SchemaUsers.php +++ b/src/AKSRT/Admin/Schema/SchemaUsers.php @@ -254,6 +254,7 @@ class SchemaUsers { ['key' => 'oauth_weibo_s', 'label' => '微博登录', 'type' => 'switch', 'default' => false], ['key' => 'oauth_baidu_s', 'label' => '百度登录', 'type' => 'switch', 'default' => false], ['key' => 'oauth_alipay_s', 'label' => '支付宝登录', 'type' => 'switch', 'default' => false], + ['key' => 'clogin_s', 'label' => '彩虹聚合登录', 'type' => 'switch', 'default' => false], ]], ['key' => 'github', 'title' => 'GitHub配置', 'fields' => [ ['key' => 'github_appid', 'label' => 'Client ID', 'type' => 'input', 'default' => ''], @@ -302,6 +303,18 @@ class SchemaUsers { ['key' => 'baidu', 'title' => '百度配置', 'fields' => [ ['key' => 'baidu_appid', 'label' => 'API Key', 'type' => 'input', 'default' => ''], ['key' => 'baidu_appkey', 'label' => 'Secret Key', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'clogin', 'title' => '彩虹聚合登录', 'fields' => [ + ['key' => 'clogin_url', 'label' => '接口地址', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'clogin_s', 'value' => true], 'help' => '如 https://xxx.com'], + ['key' => 'clogin_id', 'label' => 'AppID', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'clogin_s', 'value' => true]], + ['key' => 'clogin_key', 'label' => 'AppKey', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'clogin_s', 'value' => true]], + ['key' => 'clogin_oauth_s', 'label' => '启用平台', 'type' => 'checkbox', 'default' => ['qq', 'weixin', 'weibo', 'alipay'], 'options' => [ + ['label' => 'QQ', 'value' => 'qq'], + ['label' => '微信', 'value' => 'weixin'], + ['label' => '微博', 'value' => 'weibo'], + ['label' => '支付宝', 'value' => 'alipay'], + ['label' => '百度', 'value' => 'baidu'], + ], 'dependsOn' => ['key' => 'clogin_s', 'value' => true]], ]], ['key' => 'alipay', 'title' => '支付宝配置', 'fields' => [ ['key' => 'alipay_appid', 'label' => 'AppID', 'type' => 'input', 'default' => ''], diff --git a/src/AKSRT/Auth/CLoginClient.php b/src/AKSRT/Auth/CLoginClient.php new file mode 100644 index 00000000..00d4f86e --- /dev/null +++ b/src/AKSRT/Auth/CLoginClient.php @@ -0,0 +1,106 @@ +apiurl = rtrim((string) ($config['apiurl'] ?? ''), '/') . '/connect.php'; + $this->appid = (string) ($config['appid'] ?? ''); + $this->appkey = (string) ($config['appkey'] ?? ''); + $this->callback = (string) ($config['callback'] ?? ''); + } + + /** + * @return array + */ + public function login(string $type): array { + $this->state = md5(uniqid((string) wp_rand(), true)); + $params = [ + 'act' => 'login', + 'appid' => $this->appid, + 'appkey' => $this->appkey, + 'type' => $this->to_api_type($type), + 'redirect_uri' => $this->callback, + 'state' => $this->state, + ]; + return $this->request($params); + } + + /** + * @return array + */ + public function callback(string $code): array { + return $this->request([ + 'act' => 'callback', + 'appid' => $this->appid, + 'appkey' => $this->appkey, + 'code' => $code, + ]); + } + + public function to_api_type(string $type): string { + switch (sanitize_key($type)) { + case 'weixin': + case 'wechat': + return 'wx'; + case 'weibo': + return 'sina'; + default: + return sanitize_key($type); + } + } + + public function from_api_type(string $type): string { + switch (sanitize_key($type)) { + case 'wx': + return 'weixin'; + case 'sina': + return 'weibo'; + default: + return sanitize_key($type); + } + } + + /** + * @param array $params + * @return array + */ + private function request(array $params): array { + $url = $this->apiurl . '?' . http_build_query($params); + $response = wp_remote_get($url, [ + 'timeout' => 15, + 'headers' => [ + 'User-Agent' => 'AKSRT-CLogin/1.0', + ], + ]); + if (is_wp_error($response)) { + return [ + 'code' => 1, + 'msg' => $response->get_error_message(), + ]; + } + $body = (string) wp_remote_retrieve_body($response); + $data = json_decode($body, true); + return is_array($data) ? $data : [ + 'code' => 1, + 'msg' => __('聚合登录接口返回异常', 'aksrt'), + ]; + } +} diff --git a/src/AKSRT/Auth/OAuth.php b/src/AKSRT/Auth/OAuth.php index 44fd14f7..c4e23d6d 100644 --- a/src/AKSRT/Auth/OAuth.php +++ b/src/AKSRT/Auth/OAuth.php @@ -24,12 +24,15 @@ class OAuth { public function init(): void { add_action('init', [$this, 'add_rewrite_rules']); add_action('template_redirect', [$this, 'handle_callback']); + add_action('template_redirect', [$this, 'handle_clogin_routes'], 9); add_action('show_user_profile', [$this, 'profile_bindings'], 10, 1); add_action('wp_ajax_aksrt_oauth_unbind', [$this, 'ajax_unbind']); } public function add_rewrite_rules(): void { add_rewrite_rule('^oauth/callback/?$', 'index.php?aksrt_oauth=callback', 'top'); + add_rewrite_rule('^oauth/clogin/?$', 'index.php?aksrt_oauth=clogin', 'top'); + add_rewrite_rule('^oauth/clogin/callback/?$', 'index.php?aksrt_oauth=clogin_callback', 'top'); add_filter('query_vars', function($vars) { $vars[] = 'aksrt_oauth'; return $vars; @@ -40,6 +43,15 @@ class OAuth { * 生成OAuth授权URL */ public function get_auth_url(string $provider, string $action = 'login'): string { + $provider = sanitize_key($provider); + if (str_starts_with($provider, 'clogin_') || str_starts_with($provider, 'clogin-')) { + $type = str_replace(['clogin_', 'clogin-'], '', $provider); + return $this->get_clogin_login_url($type, $action); + } + if ($provider === 'clogin') { + return $this->get_clogin_login_url('qq', $action); + } + if (class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::oauth_proxy_mode() === 'client') { if (class_exists('AKSRT\\Auth\\OAuthProxyClient')) { return OAuthProxyClient::get_instance()->get_proxy_auth_url($provider, $action); diff --git a/src/AKSRT/Auth/OAuthPart1.php b/src/AKSRT/Auth/OAuthPart1.php index d01567c4..db73bc4e 100644 --- a/src/AKSRT/Auth/OAuthPart1.php +++ b/src/AKSRT/Auth/OAuthPart1.php @@ -217,7 +217,28 @@ trait OAuthPart1 { } } } - return array_unique($enabled); + // Rainbow aggregate (clogin) providers as virtual keys: clogin_qq, clogin_weixin... + if (class_exists('\AKSRT\Settings\Settings')) { + $s = \AKSRT\Settings\Settings::get_all(); + if (!empty($s['clogin_s']) && !empty($s['clogin_url']) && !empty($s['clogin_id']) && !empty($s['clogin_key'])) { + $raw = $s['clogin_oauth_s'] ?? $s['clogin_types'] ?? ['qq', 'weixin', 'weibo', 'alipay']; + if (is_string($raw)) { + $raw = preg_split('/[,,\s]+/', $raw) ?: []; + } + if (!is_array($raw)) { + $raw = []; + } + foreach ($raw as $ctype) { + $ctype = sanitize_key((string) $ctype); + if ($ctype === '') { + continue; + } + $enabled[] = 'clogin_' . $ctype; + } + } + } + + return array_values(array_unique($enabled)); } /** @@ -242,10 +263,20 @@ trait OAuthPart1 { $url = $this->get_auth_url($key); if (empty($url)) continue; + $label = $platform_names[$key] ?? ''; + $icon_key = $key; + if (str_starts_with($key, 'clogin_')) { + $ctype = substr($key, 7); + $label = ($platform_names[$ctype] ?? strtoupper($ctype)) . ' · 聚合'; + $icon_key = $ctype === 'weixin' ? 'weixin' : $ctype; + } + if ($label === '') { + $label = ucfirst($key); + } $result[] = [ 'name' => $key, - 'label' => $platform_names[$key] ?? ucfirst($key), - 'icon' => function_exists('aksrt_brand_icon') ? aksrt_brand_icon((string) $key) : '', + 'label' => $label, + 'icon' => function_exists('aksrt_brand_icon') ? aksrt_brand_icon((string) $icon_key) : '', 'url' => esc_url($url), ]; } @@ -345,42 +376,28 @@ trait OAuthPart1 { } public function render_social_buttons(string $title = ''): string { - $providers = get_option('aksrt_oauth', []); - $enabled_providers = []; - foreach ($providers as $key => $cfg) { - if (!empty($cfg['enabled']) && !empty($cfg['client_id']) && in_array($key, ['github', 'gitee', 'qq', 'google', 'weibo', 'weixin', 'baidu', 'alipay'], true)) { - $enabled_providers[$key] = $cfg; - } + $items = $this->get_auth_providers_data(); + if (empty($items)) { + return ''; } - if (empty($enabled_providers)) return ''; - - $icons = [ - 'github' => aksrt_brand_icon('github'), - 'gitee' => aksrt_brand_icon('gitee'), - 'qq' => aksrt_brand_icon('qq'), - 'google' => aksrt_brand_icon('google'), - 'weibo' => aksrt_brand_icon('weibo'), - 'weixin' => aksrt_brand_icon('weixin'), - 'baidu' => aksrt_brand_icon('baidu'), - 'alipay' => aksrt_brand_icon('alipay'), - ]; - ob_start(); ?>
- $cfg): - $url = $this->get_auth_url($key); - if (empty($url)) continue; + - + class="oauth-btn oauth-btn--" + data-provider="" + aria-label="" + title=""> +
@@ -399,4 +416,200 @@ trait OAuthPart1 { public function register_message(string $message): string { return $message . self::get_instance()->render_social_buttons(__('或使用社交账号快速注册', 'aksrt')); } + + /** + * @return array{enabled:bool,url:string,id:string,key:string,types:string[]} + */ + private function get_clogin_settings(): array { + $s = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : []; + $raw = $s['clogin_oauth_s'] ?? $s['clogin_types'] ?? ['qq', 'weixin', 'weibo', 'alipay']; + if (is_string($raw)) { + $raw = preg_split('/[,,\s]+/', $raw) ?: []; + } + if (!is_array($raw)) { + $raw = []; + } + $types = []; + foreach ($raw as $t) { + $t = sanitize_key((string) $t); + if ($t !== '') { + $types[] = $t; + } + } + return [ + 'enabled' => !empty($s['clogin_s']), + 'url' => esc_url_raw((string) ($s['clogin_url'] ?? '')), + 'id' => (string) ($s['clogin_id'] ?? ''), + 'key' => (string) ($s['clogin_key'] ?? ''), + 'types' => array_values(array_unique($types)), + ]; + } + + private function get_clogin_login_url(string $type, string $action = 'login'): string { + $cfg = $this->get_clogin_settings(); + $type = sanitize_key($type); + if (!$cfg['enabled'] || $cfg['url'] === '' || $cfg['id'] === '' || $cfg['key'] === '' || $type === '') { + return ''; + } + if (!empty($cfg['types']) && !in_array($type, $cfg['types'], true)) { + return ''; + } + $args = ['type' => $type]; + if ($action === 'bind' && is_user_logged_in()) { + $args['bind'] = 1; + } + $rurl = isset($_GET['redirect_to']) ? esc_url_raw(wp_unslash((string) $_GET['redirect_to'])) : ''; + if ($rurl !== '') { + $args['rurl'] = $rurl; + } + return add_query_arg($args, home_url('/oauth/clogin/')); + } + + public function handle_clogin_routes(): void { + $route = get_query_var('aksrt_oauth'); + if ($route === 'clogin') { + $this->handle_clogin_login(); + return; + } + if ($route === 'clogin_callback') { + $this->handle_clogin_callback(); + } + } + + private function handle_clogin_login(): void { + $cfg = $this->get_clogin_settings(); + $type = isset($_GET['type']) ? sanitize_key(wp_unslash((string) $_GET['type'])) : ''; + if (!$cfg['enabled'] || $cfg['url'] === '' || $cfg['id'] === '' || $cfg['key'] === '' || $type === '') { + wp_die(esc_html__('暂未启用此登录方式', 'aksrt')); + } + if (!empty($cfg['types']) && !in_array($type, $cfg['types'], true)) { + wp_die(esc_html__('此登录方式未启用', 'aksrt')); + } + if (!class_exists(CLoginClient::class)) { + wp_die(esc_html__('聚合登录组件未加载', 'aksrt')); + } + + $client = new CLoginClient([ + 'apiurl' => $cfg['url'], + 'appid' => $cfg['id'], + 'appkey' => $cfg['key'], + 'callback' => home_url('/oauth/clogin/callback'), + ]); + $resp = $client->login($type); + if (!empty($resp['code']) && (int) $resp['code'] !== 0 && !empty($resp['msg'])) { + wp_die(esc_html(sprintf(__('处理失败:%s', 'aksrt'), (string) $resp['msg']))); + } + if (empty($resp['url'])) { + wp_die(esc_html__('无法获取聚合登录地址', 'aksrt')); + } + + if (!session_id()) { + @session_start(); + } + $_SESSION['AKSRT_CLOGIN_STATE'] = $client->state; + $_SESSION['AKSRT_CLOGIN_TYPE'] = $type; + $_SESSION['AKSRT_CLOGIN_RURL'] = !empty($_GET['rurl']) ? esc_url_raw(wp_unslash((string) $_GET['rurl'])) : ''; + $_SESSION['AKSRT_CLOGIN_BIND'] = !empty($_GET['bind']) ? 1 : 0; + + wp_redirect(esc_url_raw((string) $resp['url'])); + exit; + } + + private function handle_clogin_callback(): void { + if (!session_id()) { + @session_start(); + } + $state = isset($_GET['state']) ? (string) wp_unslash($_GET['state']) : ''; + $code = isset($_GET['code']) ? (string) wp_unslash($_GET['code']) : ''; + if ($code === '' || $state === '' || empty($_SESSION['AKSRT_CLOGIN_STATE']) || !hash_equals((string) $_SESSION['AKSRT_CLOGIN_STATE'], $state)) { + wp_die(esc_html__('环境异常,state 验证失败,请刷新后重试', 'aksrt')); + } + + $cfg = $this->get_clogin_settings(); + if (!$cfg['enabled']) { + wp_die(esc_html__('暂未启用此功能', 'aksrt')); + } + $client = new CLoginClient([ + 'apiurl' => $cfg['url'], + 'appid' => $cfg['id'], + 'appkey' => $cfg['key'], + ]); + $info = $client->callback($code); + if (!empty($info['code']) && (int) $info['code'] !== 0 && !empty($info['msg'])) { + wp_die(esc_html((string) $info['msg'])); + } + $openid = (string) ($info['social_uid'] ?? ''); + $api_type = (string) ($info['type'] ?? ($_SESSION['AKSRT_CLOGIN_TYPE'] ?? '')); + $provider_type = $client->from_api_type($api_type); + if ($openid === '' || $provider_type === '') { + wp_die(esc_html__('无法获取用户信息', 'aksrt')); + } + + $provider = 'clogin_' . sanitize_key($provider_type); + $user_data = [ + 'provider' => $provider, + 'provider_id' => $openid, + 'username' => (string) ($info['nickname'] ?? ''), + 'name' => (string) ($info['nickname'] ?? ''), + 'email' => '', + 'avatar_url' => (string) ($info['faceimg'] ?? ''), + ]; + if ($user_data['avatar_url'] !== '' && str_contains($user_data['avatar_url'], 'no_portrait')) { + $user_data['avatar_url'] = ''; + } + + $is_bind = !empty($_SESSION['AKSRT_CLOGIN_BIND']); + $rurl = !empty($_SESSION['AKSRT_CLOGIN_RURL']) ? (string) $_SESSION['AKSRT_CLOGIN_RURL'] : ''; + unset($_SESSION['AKSRT_CLOGIN_STATE'], $_SESSION['AKSRT_CLOGIN_TYPE'], $_SESSION['AKSRT_CLOGIN_RURL'], $_SESSION['AKSRT_CLOGIN_BIND']); + + try { + if ($is_bind) { + $uid = get_current_user_id(); + if ($uid <= 0) { + wp_safe_redirect(function_exists('aksrt_get_login_url') ? aksrt_get_login_url() : wp_login_url()); + exit; + } + $existing = $this->find_user_by_provider($provider, $openid); + if ($existing && (int) $existing->ID !== $uid) { + throw new \Exception(__('该第三方账号已被其他用户绑定', 'aksrt')); + } + $this->bind_provider_to_user($uid, $provider, $user_data); + $redirect = function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/'); + wp_safe_redirect(add_query_arg(['tab' => 'settings', 'oauth_action' => 'bind', 'oauth_bound' => $provider], $redirect)); + exit; + } + + $user = $this->find_or_create_user($provider, $user_data); + if (is_wp_error($user) && $user->get_error_code() === 'oauth_no_match') { + // Auto-create account for aggregate login when no match (zibll-like). + $login = 'u' . wp_generate_password(10, false, false); + $uid = wp_insert_user([ + 'user_login' => $login, + 'user_pass' => wp_generate_password(24, true, true), + 'display_name' => $user_data['name'] !== '' ? $user_data['name'] : $login, + 'nickname' => $user_data['name'] !== '' ? $user_data['name'] : $login, + 'role' => get_option('default_role', 'subscriber'), + ]); + if (is_wp_error($uid)) { + throw new \Exception($uid->get_error_message()); + } + $this->bind_provider_to_user((int) $uid, $provider, $user_data); + $user = get_user_by('id', (int) $uid); + } + if (is_wp_error($user) || !$user instanceof \WP_User) { + throw new \Exception(is_wp_error($user) ? $user->get_error_message() : __('登录失败', 'aksrt')); + } + wp_set_current_user($user->ID); + wp_set_auth_cookie($user->ID, true); + do_action('wp_login', $user->user_login, $user); + + $redirect = $rurl !== '' ? $rurl : (function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/')); + wp_safe_redirect(add_query_arg(['oauth_action' => 'login', 'oauth_bound' => $provider], $redirect)); + exit; + } catch (\Exception $e) { + wp_die(esc_html__('登录失败:', 'aksrt') . '
' . esc_html($e->getMessage())); + } + } + + } diff --git a/src/AKSRT/Forum/DraftService.php b/src/AKSRT/Forum/DraftService.php index 314b41cd..8d70e894 100644 --- a/src/AKSRT/Forum/DraftService.php +++ b/src/AKSRT/Forum/DraftService.php @@ -78,11 +78,33 @@ class DraftService { } if (isset($data['credits_cost'])) { - update_post_meta($post_id, '_aksrt_credits_cost', max(0, (int) $data['credits_cost'])); + $cost = max(0, (int) $data['credits_cost']); + update_post_meta($post_id, '_aksrt_credits_cost', $cost); + update_post_meta($post_id, '_aksrt_pay_credits', $cost); } if (isset($data['pay_price'])) { update_post_meta($post_id, '_aksrt_pay_price', sanitize_text_field((string) $data['pay_price'])); } + if (isset($data['credits_vip1']) && (string) $data['credits_vip1'] !== '') { + update_post_meta($post_id, '_aksrt_credits_vip1', max(0, (int) $data['credits_vip1'])); + } + if (isset($data['credits_vip2']) && (string) $data['credits_vip2'] !== '') { + update_post_meta($post_id, '_aksrt_credits_vip2', max(0, (int) $data['credits_vip2'])); + } + if (isset($data['pay_vip1_price']) && (string) $data['pay_vip1_price'] !== '') { + $v1 = max(0, (float) $data['pay_vip1_price']); + update_post_meta($post_id, '_aksrt_pay_vip1_price', $v1); + update_post_meta($post_id, '_aksrt_pay_vip_price', $v1); + } + if (isset($data['pay_vip2_price']) && (string) $data['pay_vip2_price'] !== '') { + update_post_meta($post_id, '_aksrt_pay_vip2_price', max(0, (float) $data['pay_vip2_price'])); + } + if (array_key_exists('pay_hide_part', $data)) { + update_post_meta($post_id, '_aksrt_pay_hide_part', !empty($data['pay_hide_part']) ? 1 : 0); + } + if (array_key_exists('topic_coupon_s', $data)) { + update_post_meta($post_id, '_aksrt_topic_coupon_s', !empty($data['topic_coupon_s']) ? 1 : 0); + } if (isset($data['visibility_roles']) && is_array($data['visibility_roles'])) { update_post_meta($post_id, '_aksrt_visibility_roles', array_map('sanitize_key', $data['visibility_roles'])); } @@ -236,6 +258,12 @@ class DraftService { 'featured_video' => get_post_meta($draft_id, '_aksrt_featured_video', true) ?: '', 'credits_cost' => (int) get_post_meta($draft_id, '_aksrt_credits_cost', true), 'pay_price' => get_post_meta($draft_id, '_aksrt_pay_price', true) ?: '', + 'credits_vip1' => get_post_meta($draft_id, '_aksrt_credits_vip1', true), + 'credits_vip2' => get_post_meta($draft_id, '_aksrt_credits_vip2', true), + 'pay_vip1_price' => get_post_meta($draft_id, '_aksrt_pay_vip1_price', true), + 'pay_vip2_price' => get_post_meta($draft_id, '_aksrt_pay_vip2_price', true), + 'pay_hide_part' => (int) get_post_meta($draft_id, '_aksrt_pay_hide_part', true) === 1, + 'topic_coupon_s' => (int) get_post_meta($draft_id, '_aksrt_topic_coupon_s', true) === 1, 'visibility_roles' => get_post_meta($draft_id, '_aksrt_visibility_roles', true) ?: [], 'topic_cat' => $topic_cat, 'vote_enabled' => get_post_meta($draft_id, '_aksrt_vote_enabled', true) === '1', @@ -288,6 +316,12 @@ class DraftService { 'featured_video' => esc_url_raw((string) ($_POST['featured_video'] ?? '')), 'credits_cost' => (int) ($_POST['credits_cost'] ?? 0), 'pay_price' => sanitize_text_field((string) ($_POST['pay_price'] ?? '')), + 'credits_vip1' => isset($_POST['credits_vip1']) ? sanitize_text_field((string) $_POST['credits_vip1']) : '', + 'credits_vip2' => isset($_POST['credits_vip2']) ? sanitize_text_field((string) $_POST['credits_vip2']) : '', + 'pay_vip1_price' => isset($_POST['pay_vip1_price']) ? sanitize_text_field((string) $_POST['pay_vip1_price']) : '', + 'pay_vip2_price' => isset($_POST['pay_vip2_price']) ? sanitize_text_field((string) $_POST['pay_vip2_price']) : '', + 'pay_hide_part' => !empty($_POST['pay_hide_part']), + 'topic_coupon_s' => !empty($_POST['topic_coupon_s']), 'visibility_roles' => isset($_POST['visibility_roles']) && is_array($_POST['visibility_roles']) ? array_map('sanitize_key', $_POST['visibility_roles']) : [], ]; diff --git a/src/AKSRT/Forum/TopicService.php b/src/AKSRT/Forum/TopicService.php index e95f186c..0d13088f 100644 --- a/src/AKSRT/Forum/TopicService.php +++ b/src/AKSRT/Forum/TopicService.php @@ -146,10 +146,28 @@ class TopicService { if ($visibility === Forum::VISIBILITY_CREDITS) { $credits_cost = max(0, (int) ($data['credits_cost'] ?? 0)); update_post_meta($post_id, '_aksrt_credits_cost', $credits_cost); + update_post_meta($post_id, '_aksrt_pay_credits', $credits_cost); + if (isset($data['credits_vip1']) && (string) $data['credits_vip1'] !== '') { + update_post_meta($post_id, '_aksrt_credits_vip1', max(0, (int) $data['credits_vip1'])); + } + if (isset($data['credits_vip2']) && (string) $data['credits_vip2'] !== '') { + update_post_meta($post_id, '_aksrt_credits_vip2', max(0, (int) $data['credits_vip2'])); + } + update_post_meta($post_id, '_aksrt_pay_hide_part', !empty($data['pay_hide_part']) ? 1 : 0); } if ($visibility === Forum::VISIBILITY_PAY) { $pay_price = sanitize_text_field((string) ($data['pay_price'] ?? '')); update_post_meta($post_id, '_aksrt_pay_price', $pay_price); + if (isset($data['pay_vip1_price']) && (string) $data['pay_vip1_price'] !== '') { + $v1 = max(0, (float) $data['pay_vip1_price']); + update_post_meta($post_id, '_aksrt_pay_vip1_price', $v1); + update_post_meta($post_id, '_aksrt_pay_vip_price', $v1); + } + if (isset($data['pay_vip2_price']) && (string) $data['pay_vip2_price'] !== '') { + update_post_meta($post_id, '_aksrt_pay_vip2_price', max(0, (float) $data['pay_vip2_price'])); + } + update_post_meta($post_id, '_aksrt_pay_hide_part', !empty($data['pay_hide_part']) ? 1 : 0); + update_post_meta($post_id, '_aksrt_topic_coupon_s', !empty($data['topic_coupon_s']) ? 1 : 0); } if ($visibility === Forum::VISIBILITY_ROLE) { $roles = isset($data['visibility_roles']) && is_array($data['visibility_roles']) diff --git a/src/AKSRT/Forum/TopicServicePart4.php b/src/AKSRT/Forum/TopicServicePart4.php index 3409ffb1..ee7b0afd 100644 --- a/src/AKSRT/Forum/TopicServicePart4.php +++ b/src/AKSRT/Forum/TopicServicePart4.php @@ -94,10 +94,38 @@ trait TopicServicePart4 { if (isset($this->get_visibility_map()[$visibility])) { update_post_meta($topic_id, '_aksrt_visibility', $visibility); if ($visibility === self::VISIBILITY_CREDITS) { - update_post_meta($topic_id, '_aksrt_credits_cost', max(0, (int) ($_POST['credits_cost'] ?? 0))); + $cost = max(0, (int) ($_POST['credits_cost'] ?? 0)); + update_post_meta($topic_id, '_aksrt_credits_cost', $cost); + update_post_meta($topic_id, '_aksrt_pay_credits', $cost); + if (isset($_POST['credits_vip1']) && (string) $_POST['credits_vip1'] !== '') { + update_post_meta($topic_id, '_aksrt_credits_vip1', max(0, (int) $_POST['credits_vip1'])); + } else { + delete_post_meta($topic_id, '_aksrt_credits_vip1'); + } + if (isset($_POST['credits_vip2']) && (string) $_POST['credits_vip2'] !== '') { + update_post_meta($topic_id, '_aksrt_credits_vip2', max(0, (int) $_POST['credits_vip2'])); + } else { + delete_post_meta($topic_id, '_aksrt_credits_vip2'); + } + update_post_meta($topic_id, '_aksrt_pay_hide_part', !empty($_POST['pay_hide_part']) ? 1 : 0); } if ($visibility === self::VISIBILITY_PAY) { update_post_meta($topic_id, '_aksrt_pay_price', sanitize_text_field((string) ($_POST['pay_price'] ?? ''))); + if (isset($_POST['pay_vip1_price']) && (string) $_POST['pay_vip1_price'] !== '') { + $v1 = max(0, (float) $_POST['pay_vip1_price']); + update_post_meta($topic_id, '_aksrt_pay_vip1_price', $v1); + update_post_meta($topic_id, '_aksrt_pay_vip_price', $v1); + } else { + delete_post_meta($topic_id, '_aksrt_pay_vip1_price'); + delete_post_meta($topic_id, '_aksrt_pay_vip_price'); + } + if (isset($_POST['pay_vip2_price']) && (string) $_POST['pay_vip2_price'] !== '') { + update_post_meta($topic_id, '_aksrt_pay_vip2_price', max(0, (float) $_POST['pay_vip2_price'])); + } else { + delete_post_meta($topic_id, '_aksrt_pay_vip2_price'); + } + update_post_meta($topic_id, '_aksrt_pay_hide_part', !empty($_POST['pay_hide_part']) ? 1 : 0); + update_post_meta($topic_id, '_aksrt_topic_coupon_s', !empty($_POST['topic_coupon_s']) ? 1 : 0); } if ($visibility === self::VISIBILITY_ROLE) { $roles = isset($_POST['visibility_roles']) && is_array($_POST['visibility_roles']) @@ -227,6 +255,12 @@ trait TopicServicePart4 { 'featured_video' => esc_url_raw((string) ($_POST['featured_video'] ?? '')), 'credits_cost' => (int) ($_POST['credits_cost'] ?? 0), 'pay_price' => sanitize_text_field((string) ($_POST['pay_price'] ?? '')), + 'credits_vip1' => isset($_POST['credits_vip1']) ? sanitize_text_field((string) $_POST['credits_vip1']) : '', + 'credits_vip2' => isset($_POST['credits_vip2']) ? sanitize_text_field((string) $_POST['credits_vip2']) : '', + 'pay_vip1_price' => isset($_POST['pay_vip1_price']) ? sanitize_text_field((string) $_POST['pay_vip1_price']) : '', + 'pay_vip2_price' => isset($_POST['pay_vip2_price']) ? sanitize_text_field((string) $_POST['pay_vip2_price']) : '', + 'pay_hide_part' => !empty($_POST['pay_hide_part']), + 'topic_coupon_s' => !empty($_POST['topic_coupon_s']), 'visibility_roles' => isset($_POST['visibility_roles']) && is_array($_POST['visibility_roles']) ? array_map('sanitize_key', $_POST['visibility_roles']) : [], ]; diff --git a/src/AKSRT/Widgets/WidgetPostsNavs.php b/src/AKSRT/Widgets/WidgetPostsNavs.php new file mode 100644 index 00000000..c8873898 --- /dev/null +++ b/src/AKSRT/Widgets/WidgetPostsNavs.php @@ -0,0 +1,82 @@ + esc_html__('显示当前文章/帖子标题目录,非详情页不输出。标题超过 3 个才显示。', 'aksrt'), + 'classname' => 'widget_aksrt_posts_navs', + ] + ); + } + + public function widget($args, $instance): void { + if (!is_singular(['post', 'page', 'aksrt_forum_topic', 'aksrt_product'])) { + return; + } + + $title = sanitize_text_field((string) ($instance['title'] ?? __('文章目录', 'aksrt'))); + $mini_title = sanitize_text_field((string) ($instance['mini_title'] ?? '')); + $in_affix = !empty($instance['in_affix']); + + $data_title = $title; + if ($mini_title !== '') { + $data_title .= ' · ' . $mini_title; + } + + echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '
'; + printf( + '
', + $in_affix ? ' data-affix="true"' : '', + $data_title !== '' ? ' data-title="' . esc_attr($data_title) . '"' : '' + ); + echo '
'; + echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + + public function form($instance): void { + $title = (string) ($instance['title'] ?? __('文章目录', 'aksrt')); + $mini_title = (string) ($instance['mini_title'] ?? ''); + $in_affix = !empty($instance['in_affix']); + ?> +

+ + +

+

+ + +

+

+ +

+

+ sanitize_text_field((string) ($new_instance['title'] ?? '')), + 'mini_title' => sanitize_text_field((string) ($new_instance['mini_title'] ?? '')), + 'in_affix' => !empty($new_instance['in_affix']) ? 1 : 0, + ]; + } +} diff --git a/templates/forum/forum-compose.php b/templates/forum/forum-compose.php index 89adc41b..35ba22af 100644 --- a/templates/forum/forum-compose.php +++ b/templates/forum/forum-compose.php @@ -154,6 +154,17 @@ $initial_content = $draft_data ? $draft_data['content'] : ''; wp_editor($initial_content, 'compose-content', $editor_settings); ?> + +
+ + + + + + + +
+ @@ -276,6 +287,14 @@ $initial_content = $draft_data ? $draft_data['content'] : ''; + + + +