diff --git a/assets/css/frontend/widget-slider.css b/assets/css/frontend/widget-slider.css new file mode 100644 index 00000000..7e64fbec --- /dev/null +++ b/assets/css/frontend/widget-slider.css @@ -0,0 +1,93 @@ +/** + * Widget slider (AKSRT 幻灯片). + * @package AKSRT + */ +.aksrt-widget-slider { + position: relative; + overflow: hidden; + border-radius: 8px; + background: var(--surface-2, #f3f4f6); +} +.aksrt-widget-slider__track { + position: relative; + width: 100%; + height: var(--aksrt-slider-h, 220px); +} +.aksrt-widget-slider__slide { + position: absolute; + inset: 0; + opacity: 0; + pointer-events: none; + transition: opacity .35s ease; +} +.aksrt-widget-slider__slide.is-active { + opacity: 1; + pointer-events: auto; + z-index: 1; +} +.aksrt-widget-slider__slide img, +.aksrt-widget-slider__link { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} +.aksrt-widget-slider__caption { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 10px 12px; + background: linear-gradient(transparent, rgba(0,0,0,.55)); + color: #fff; + display: flex; + flex-direction: column; + gap: 2px; + font-size: 13px; +} +.aksrt-widget-slider__caption strong { + font-size: 14px; +} +.aksrt-widget-slider__prev, +.aksrt-widget-slider__next { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 2; + width: 28px; + height: 28px; + border: 0; + border-radius: 50%; + background: rgba(0,0,0,.35); + color: #fff; + cursor: pointer; + line-height: 28px; + text-align: center; + padding: 0; +} +.aksrt-widget-slider__prev { left: 8px; } +.aksrt-widget-slider__next { right: 8px; } +.aksrt-widget-slider__dots { + position: absolute; + left: 0; + right: 0; + bottom: 8px; + z-index: 2; + display: flex; + justify-content: center; + gap: 6px; + pointer-events: none; +} +.aksrt-widget-slider__dot { + pointer-events: auto; + width: 7px; + height: 7px; + border-radius: 50%; + border: 0; + padding: 0; + background: rgba(255,255,255,.45); + cursor: pointer; +} +.aksrt-widget-slider__dot.is-active { + background: #fff; +} diff --git a/assets/css/frontend/widgets.css b/assets/css/frontend/widgets.css index 78107132..ed3d1caf 100644 --- a/assets/css/frontend/widgets.css +++ b/assets/css/frontend/widgets.css @@ -2,3 +2,4 @@ @import url("widgets/part-2.css"); @import url("widgets/part-3.css"); @import url("widgets/part-4.css"); +@import url("widget-slider.css"); diff --git a/assets/js/modal-auth.js b/assets/js/modal-auth.js index f1b7b7a7..46f67739 100644 --- a/assets/js/modal-auth.js +++ b/assets/js/modal-auth.js @@ -419,6 +419,37 @@ // ── QR Code Login (§10.7) ── + + function bindWeixingzhCodeForm(container) { + if (!container) return; + var form = container.querySelector('[data-aksrt-weixingzh-code]'); + if (!form || form._bound) return; + form._bound = true; + form.addEventListener('submit', function (e) { + e.preventDefault(); + var input = form.querySelector('[name="code"]'); + var code = input ? String(input.value || '').trim() : ''; + if (!code) return; + var btn = form.querySelector('button[type="submit"]'); + if (btn) btn.disabled = true; + request('/auth/weixingzh-code', { code: code }) + .then(function (result) { + var data = (result && result.data) || result || {}; + if ((result && result.ok) || (result && result.success) || data.logged_in) { + onAuthSuccess(data.data || data); + return; + } + throw new Error((result && (result.message || result.msg)) || '验证码错误'); + }) + .catch(function (err) { + alert(err.message || '登录失败'); + }) + .finally(function () { + if (btn) btn.disabled = false; + }); + }); + } + function initQrLogin() { var qrPanel = modal.querySelector('[data-panel="qrcode"]'); if (!qrPanel) return; @@ -442,9 +473,16 @@ request('/auth/qrcode-login', {}) .then(function (result) { - if (result.ok && result.data && result.data.qr_html) { - container.innerHTML = result.data.qr_html; - startQrPolling(result.data.check_url || '/auth/check-qrcode-login', result.data.token || ''); + var payload = (result && result.data) || result || {}; + var qrHtml = payload.qr_html || (payload.data && payload.data.qr_html) || ''; + var checkUrl = payload.check_url || (payload.data && payload.data.check_url) || '/auth/check-qrcode-login'; + var token = payload.token || (payload.data && payload.data.token) || ''; + if (qrHtml) { + container.innerHTML = qrHtml; + bindWeixingzhCodeForm(container); + if (checkUrl && token) { + startQrPolling(checkUrl, token); + } } else { container.innerHTML = '
扫码登录暂不可用
'; } @@ -459,9 +497,13 @@ qrLoginTimer = setInterval(function () { request(checkUrl, { token: token }) .then(function (result) { - if (result.ok && result.data && result.data.logged_in) { - stopQrPolling(); - onAuthSuccess(result.data); + var data = (result && result.data) || result || {}; + var body = data.data || data; + if ((result && result.ok) || data.success || body.logged_in) { + if (body.logged_in || (data.data && data.data.logged_in)) { + stopQrPolling(); + onAuthSuccess(body.data || body); + } } }) .catch(function () { /* keep polling */ }); diff --git a/functions.php b/functions.php index 51e249b3..5767db5d 100644 --- a/functions.php +++ b/functions.php @@ -137,6 +137,9 @@ if (get_option('aksrt_flushed_version') !== AKSRT_VERSION) { \AKSRT\Core\Scripts::get_instance()->init(); \AKSRT\Admin\Admin::get_instance()->init(); \AKSRT\Auth\OAuth::get_instance()->init(); +if (class_exists('\AKSRT\Auth\WeixinGzh')) { + \AKSRT\Auth\WeixinGzh::get_instance()->init(); +} // 初始化代理登录(根据配置模式选择性加载) $aksrt_proxy_mode = \AKSRT\Settings\Settings::oauth_proxy_mode(); diff --git a/inc/modules/widgets-register.php b/inc/modules/widgets-register.php index e068692a..65fbb549 100644 --- a/inc/modules/widgets-register.php +++ b/inc/modules/widgets-register.php @@ -53,6 +53,9 @@ function aksrt_register_widgets() { // 文章目录树(zibll posts_navs) register_widget('\AKSRT\Widgets\WidgetPostsNavs'); + // 幻灯片 + register_widget('\AKSRT\Widgets\WidgetSlider'); + // Forum widgets if (class_exists('\AKSRT\Widgets\WidgetForumBoardList')) { register_widget('\AKSRT\Widgets\WidgetForumBoardList'); diff --git a/src/AKSRT/Auth/WeixinGzh.php b/src/AKSRT/Auth/WeixinGzh.php new file mode 100644 index 00000000..288093f2 --- /dev/null +++ b/src/AKSRT/Auth/WeixinGzh.php @@ -0,0 +1,540 @@ + ['GET', 'POST'], + 'callback' => [$this, 'rest_qrcode_login'], + 'permission_callback' => '__return_true', + ]); + register_rest_route('aksrt/v1', '/auth/check-qrcode-login', [ + 'methods' => ['GET', 'POST'], + 'callback' => [$this, 'rest_check_qrcode_login'], + 'permission_callback' => '__return_true', + ]); + register_rest_route('aksrt/v1', '/auth/weixingzh-code', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_code_login'], + 'permission_callback' => '__return_true', + ]); + } + + /** + * @return array' . esc_html__('微信扫码登录', 'aksrt') . '
'; + return new \WP_REST_Response([ + 'success' => true, + 'data' => [ + 'mode' => 'scan', + 'qr_html' => $html, + 'check_url' => '/auth/check-qrcode-login', + 'token' => $state, + ], + ], 200); + } catch (\Throwable $e) { + // Fallback to static QR form if API fails. + $qr = (string) ($cfg['code_qrcode'] ?? ''); + if ($qr === '') { + return new \WP_REST_Response(['success' => false, 'message' => $e->getMessage()], 400); + } + $html = '' . esc_html__('请使用公众号二维码 + 验证码登录', 'aksrt') . '
'; + return new \WP_REST_Response([ + 'success' => true, + 'data' => ['mode' => 'code', 'qr_html' => $html, 'check_url' => '', 'token' => ''], + ], 200); + } + } + + public function rest_check_qrcode_login(\WP_REST_Request $request) { + $params = $request->get_json_params(); + if (!is_array($params)) { + $params = $request->get_params(); + } + $token = sanitize_text_field((string) ($params['token'] ?? $request->get_param('token') ?? '')); + if ($token === '') { + return new \WP_REST_Response(['success' => false, 'message' => __('缺少 token', 'aksrt')], 400); + } + $row = $this->get_scan_state($token); + if (!$row) { + return new \WP_REST_Response(['success' => true, 'data' => ['logged_in' => false, 'status' => 'expired']], 200); + } + if (($row['status'] ?? '') === 'ok' && !empty($row['user_id'])) { + $user = get_user_by('id', (int) $row['user_id']); + if ($user instanceof \WP_User) { + wp_set_current_user($user->ID); + wp_set_auth_cookie($user->ID, true); + do_action('wp_login', $user->user_login, $user); + $this->delete_scan_state($token); + return new \WP_REST_Response([ + 'success' => true, + 'data' => [ + 'logged_in' => true, + 'redirect' => function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/'), + 'user' => [ + 'id' => $user->ID, + 'display_name' => $user->display_name, + ], + ], + ], 200); + } + } + return new \WP_REST_Response([ + 'success' => true, + 'data' => ['logged_in' => false, 'status' => (string) ($row['status'] ?? 'pending')], + ], 200); + } + + public function rest_code_login(\WP_REST_Request $request) { + $params = $request->get_json_params(); + if (!is_array($params)) { + $params = $request->get_params(); + } + $code = strtoupper(sanitize_text_field((string) ($params['code'] ?? ''))); + if ($code === '') { + return new \WP_REST_Response(['success' => false, 'message' => __('请输入验证码', 'aksrt')], 400); + } + $openid = $this->consume_code($code); + if ($openid === null) { + return new \WP_REST_Response(['success' => false, 'message' => __('验证码无效或已过期', 'aksrt')], 400); + } + $user = $this->login_or_create_by_openid($openid, []); + if (is_wp_error($user)) { + return new \WP_REST_Response(['success' => false, 'message' => $user->get_error_message()], 400); + } + return new \WP_REST_Response([ + 'success' => true, + 'data' => [ + 'logged_in' => true, + 'redirect' => function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/'), + 'user' => [ + 'id' => $user->ID, + 'display_name' => $user->display_name, + ], + ], + ], 200); + } + + /** + * WeChat server URL verification: /?aksrt_weixingzh=1&signature&echostr + */ + public function maybe_server_verify(): void { + if (empty($_GET['aksrt_weixingzh']) || empty($_GET['echostr']) || empty($_GET['signature'])) { + return; + } + $cfg = self::settings(); + if (!empty($cfg['token_nocheck'])) { + echo (string) wp_unslash($_GET['echostr']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + exit; + } + $token = (string) ($cfg['token'] ?? ''); + $tmp = [(string) $token, (string) ($_GET['timestamp'] ?? ''), (string) ($_GET['nonce'] ?? '')]; + sort($tmp, SORT_STRING); + if (sha1(implode($tmp)) === (string) $_GET['signature']) { + echo (string) wp_unslash($_GET['echostr']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + exit; + } + status_header(403); + exit; + } + + /** + * Receive WeChat server push (XML) on same endpoint. + */ + public function maybe_server_message(): void { + if (empty($_GET['aksrt_weixingzh']) || empty($_GET['signature']) || !empty($_GET['echostr'])) { + return; + } + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + return; + } + $raw = file_get_contents('php://input'); + if (!is_string($raw) || $raw === '') { + exit; + } + $xml = @simplexml_load_string($raw, 'SimpleXMLElement', LIBXML_NOCDATA); + if (!$xml) { + exit; + } + $from = (string) ($xml->FromUserName ?? ''); + $to = (string) ($xml->ToUserName ?? ''); + $msg_type = (string) ($xml->MsgType ?? ''); + $event = strtolower((string) ($xml->Event ?? '')); + $content = trim((string) ($xml->Content ?? '')); + $cfg = self::settings(); + $keyword = (string) ($cfg['code_keyword'] ?? '登录'); + + // Scene scan login (service QR). + if ($msg_type === 'event' && in_array($event, ['subscribe', 'scan'], true)) { + $scene = (string) ($xml->EventKey ?? ''); + $scene = ltrim($scene, 'qrscene_'); + if ($scene !== '') { + $this->mark_scan_by_scene($scene, $from); + } + $reply = (string) ($cfg['subscribe_msg'] ?? $cfg['scan_msg'] ?? ''); + if ($reply !== '') { + $this->echo_text_reply($from, $to, $reply); + } + echo 'success'; + exit; + } + + // Keyword → verification code. + if ($msg_type === 'text' && $content !== '' && mb_strtolower($content) === mb_strtolower($keyword)) { + $code = $this->issue_code($from); + $tpl = (string) ($cfg['code_reply'] ?? __('您的验证码是:%s,有效期 %d 分钟。', 'aksrt')); + // Support %s/%d and %code%/%time%. + $msg = $tpl; + if (str_contains($msg, '%code%')) { + $msg = str_replace('%code%', $code, $msg); + } + if (str_contains($msg, '%time%')) { + $msg = str_replace('%time%', '5', $msg); + } + if (substr_count($msg, '%') >= 2 && str_contains($msg, '%s')) { + $msg = sprintf($msg, $code, 5); + } elseif (str_contains($msg, '%s')) { + $msg = sprintf($msg, $code); + } + $this->echo_text_reply($from, $to, $msg); + exit; + } + + // Optional auto replies. + if ($msg_type === 'text' && !empty($cfg['auto_reply_text'])) { + $this->echo_text_reply($from, $to, (string) $cfg['auto_reply_text']); + exit; + } + echo 'success'; + exit; + } + + private function echo_text_reply(string $to_user, string $from_user, string $content): void { + $time = time(); + $content = htmlspecialchars($content, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + echo "+ +
++ +
++ +
+
+
+
+
+
+
+
+
+
+
+
+
+ + " placeholder=""> + " placeholder=""> + " placeholder=""> + " placeholder=""> +
+ normalize_slides($new_instance['slides'] ?? []); + return [ + 'title' => sanitize_text_field((string) ($new_instance['title'] ?? '')), + 'interval' => max(2, min(20, (int) ($new_instance['interval'] ?? 4))), + 'height' => max(80, min(800, (int) ($new_instance['height'] ?? 220))), + 'loop' => !empty($new_instance['loop']) ? 1 : 0, + 'button' => !empty($new_instance['button']) ? 1 : 0, + 'pagination' => !empty($new_instance['pagination']) ? 1 : 0, + 'blank' => !empty($new_instance['blank']) ? 1 : 0, + 'slides' => $slides, + ]; + } + + /** + * @param mixed $raw + * @return array