From 72c0d10172636723be2c5e4c31c044e68362300c Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 18 Jul 2026 23:54:03 +0800 Subject: [PATCH 1/3] feat(submit): pay_limit, require_login, coupon fields on front submit Expose VIP purchase gate, login-required purchase, and coupon toggle/desc in the pay sidebar; persist through FrontSubmitPay into DomainModel meta. --- src/AKSRT/Core/FrontSubmitPay.php | 8 ++++++++ templates/parts/submit/sidebar-pay.php | 28 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/AKSRT/Core/FrontSubmitPay.php b/src/AKSRT/Core/FrontSubmitPay.php index 9028df98..63207823 100644 --- a/src/AKSRT/Core/FrontSubmitPay.php +++ b/src/AKSRT/Core/FrontSubmitPay.php @@ -147,6 +147,14 @@ trait FrontSubmitPay { $meta['vip2_points'] = 0; } + $pay_limit = (int) ($raw['pay_limit'] ?? $data['pay_limit'] ?? 0); + $meta['pay_limit'] = in_array($pay_limit, [0, 1, 2], true) ? $pay_limit : 0; + $meta['require_login'] = !empty($raw['require_login']) || !empty($data['require_login']); + $meta['coupon_s'] = !empty($raw['coupon_s']) || !empty($data['coupon_s']); + if (isset($raw['coupon_desc']) || isset($data['coupon_desc'])) { + $meta['coupon_desc'] = sanitize_textarea_field((string) ($raw['coupon_desc'] ?? $data['coupon_desc'] ?? '')); + } + $this->write_pay_meta($post_id, $meta); return ['success' => true]; } diff --git a/templates/parts/submit/sidebar-pay.php b/templates/parts/submit/sidebar-pay.php index f4440176..4ed6831a 100644 --- a/templates/parts/submit/sidebar-pay.php +++ b/templates/parts/submit/sidebar-pay.php @@ -85,6 +85,34 @@ $downloads = is_array($pay['downloads'] ?? null) ? $pay['downloads'] : []; +
+ + +
+
+ + +
+
+ + +
+ +
From ac3b765a8357c6fed1fe6a314bdf507812205d7c Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 18 Jul 2026 23:54:04 +0800 Subject: [PATCH 2/3] feat(auth): WeixinGzh QR/code login REST + server push endpoint Add subscribe-mode static QR + keyword code login, service-mode scene QR polling, server URL verification, and modal-auth form/poll wiring. --- assets/js/modal-auth.js | 54 +++- functions.php | 3 + src/AKSRT/Auth/WeixinGzh.php | 540 +++++++++++++++++++++++++++++++++++ 3 files changed, 591 insertions(+), 6 deletions(-) create mode 100644 src/AKSRT/Auth/WeixinGzh.php 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/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 + */ + public static function settings(): array { + if (class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_weixingzh_settings')) { + return (array) \AKSRT\Settings\Settings::get_weixingzh_settings(); + } + return []; + } + + public static function is_enabled(): bool { + $s = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : []; + if (!empty($s['oauth_weixingzh_s'])) { + return true; + } + return !empty($s['weixingzh_appid']) && !empty($s['weixingzh_appsecret']); + } + + public function rest_qrcode_login(\WP_REST_Request $request) { + if (!self::is_enabled()) { + return new \WP_REST_Response(['success' => false, 'message' => __('微信公众号登录未开启', 'aksrt')], 400); + } + $cfg = self::settings(); + $appid = (string) ($cfg['appid'] ?? ''); + $secret = (string) ($cfg['appsecret'] ?? ''); + $gzh_type = (string) ($cfg['gzh_type'] ?? 'service'); + + // Subscribe / uncertified: static QR + code form. + if ($gzh_type === 'subscribe' || $gzh_type === 'not' || empty($secret)) { + $qr = (string) ($cfg['code_qrcode'] ?? ''); + $keyword = (string) ($cfg['code_keyword'] ?? __('登录', 'aksrt')); + if ($qr === '') { + return new \WP_REST_Response(['success' => false, 'message' => __('未配置公众号二维码', 'aksrt')], 400); + } + $html = ''; + $html .= '
'; + $html .= '

' . esc_html(sprintf(__('关注公众号后发送「%s」获取验证码', 'aksrt'), $keyword)) . '

'; + $html .= ''; + $html .= ''; + $html .= '
'; + return new \WP_REST_Response([ + 'success' => true, + 'data' => [ + 'mode' => 'code', + 'qr_html' => $html, + 'check_url' => '', + 'token' => '', + ], + ], 200); + } + + // Service account: temporary QR with scene string (state). + try { + $token = $this->get_access_token($appid, $secret); + $state = 'aksrt_' . wp_generate_password(16, false, false); + $scene = substr(md5($state . wp_salt('auth')), 0, 24); + $create = $this->api_post( + 'https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=' . rawurlencode($token), + [ + 'expire_seconds' => 300, + 'action_name' => 'QR_STR_SCENE', + 'action_info' => ['scene' => ['scene_str' => $scene]], + ] + ); + if (empty($create['ticket'])) { + $msg = (string) ($create['errmsg'] ?? __('创建二维码失败', 'aksrt')); + return new \WP_REST_Response(['success' => false, 'message' => $msg], 400); + } + $img = 'https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=' . rawurlencode((string) $create['ticket']); + $this->put_scan_state($state, [ + 'status' => 'pending', + 'scene' => $scene, + 'created' => time(), + 'openid' => '', + 'user_id' => 0, + ]); + $html = ''; + $html .= '

' . 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 = ''; + $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 "{$time}"; + exit; + } + + private function issue_code(string $openid): string { + $charset = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + $code = ''; + for ($i = 0; $i < 4; $i++) { + $code .= $charset[random_int(0, strlen($charset) - 1)]; + } + $data = get_option(self::OPT_CODES, []); + if (!is_array($data)) { + $data = []; + } + $now = time(); + // prune + foreach ($data as $k => $row) { + if (!is_array($row) || (int) ($row['time'] ?? 0) + 300 < $now) { + unset($data[$k]); + } + } + $data[$code] = ['time' => $now, 'openid' => $openid]; + update_option(self::OPT_CODES, $data, false); + return $code; + } + + private function consume_code(string $code): ?string { + $data = get_option(self::OPT_CODES, []); + if (!is_array($data) || empty($data[$code])) { + return null; + } + $row = $data[$code]; + unset($data[$code]); + update_option(self::OPT_CODES, $data, false); + if ((int) ($row['time'] ?? 0) + 300 < time()) { + return null; + } + $openid = (string) ($row['openid'] ?? ''); + return $openid !== '' ? $openid : null; + } + + /** + * @param array $profile + * @return \WP_User|\WP_Error + */ + private function login_or_create_by_openid(string $openid, array $profile) { + $oauth = class_exists(OAuth::class) ? OAuth::get_instance() : null; + $user_data = [ + 'provider' => 'weixingzh', + 'provider_id' => $openid, + 'username' => (string) ($profile['nickname'] ?? ''), + 'name' => (string) ($profile['nickname'] ?? ''), + 'email' => '', + 'avatar_url' => (string) ($profile['headimgurl'] ?? ''), + ]; + + // Find by meta. + $users = get_users([ + 'meta_key' => 'aksrt_weixingzh_id', + 'meta_value' => $openid, + 'number' => 1, + 'fields' => 'all', + ]); + if (!empty($users[0]) && $users[0] instanceof \WP_User) { + $user = $users[0]; + wp_set_current_user($user->ID); + wp_set_auth_cookie($user->ID, true); + do_action('wp_login', $user->user_login, $user); + return $user; + } + + $login = 'wx' . substr(md5($openid), 0, 10); + $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)) { + return $uid; + } + update_user_meta((int) $uid, 'aksrt_weixingzh_id', $openid); + $bindings = get_user_meta((int) $uid, 'aksrt_oauth_bindings', true); + $bindings = is_array($bindings) ? $bindings : []; + if (!in_array('weixingzh', $bindings, true)) { + $bindings[] = 'weixingzh'; + update_user_meta((int) $uid, 'aksrt_oauth_bindings', $bindings); + } + if ($user_data['avatar_url'] !== '') { + update_user_meta((int) $uid, 'aksrt_oauth_avatar', esc_url_raw($user_data['avatar_url'])); + } + $user = get_user_by('id', (int) $uid); + if (!$user instanceof \WP_User) { + return new \WP_Error('aksrt_wx_user', __('创建用户失败', 'aksrt')); + } + wp_set_current_user($user->ID); + wp_set_auth_cookie($user->ID, true); + do_action('wp_login', $user->user_login, $user); + return $user; + } + + /** + * @param array $row + */ + private function put_scan_state(string $state, array $row): void { + $all = get_option(self::OPT_SCANS, []); + if (!is_array($all)) { + $all = []; + } + $now = time(); + foreach ($all as $k => $v) { + if (!is_array($v) || (int) ($v['created'] ?? 0) + 600 < $now) { + unset($all[$k]); + } + } + $all[$state] = $row; + update_option(self::OPT_SCANS, $all, false); + } + + /** + * @return array|null + */ + private function get_scan_state(string $state): ?array { + $all = get_option(self::OPT_SCANS, []); + if (!is_array($all) || empty($all[$state]) || !is_array($all[$state])) { + return null; + } + return $all[$state]; + } + + private function delete_scan_state(string $state): void { + $all = get_option(self::OPT_SCANS, []); + if (!is_array($all)) { + return; + } + unset($all[$state]); + update_option(self::OPT_SCANS, $all, false); + } + + private function mark_scan_by_scene(string $scene, string $openid): void { + $all = get_option(self::OPT_SCANS, []); + if (!is_array($all)) { + return; + } + foreach ($all as $state => $row) { + if (!is_array($row)) { + continue; + } + if ((string) ($row['scene'] ?? '') === $scene) { + $user = $this->login_or_create_by_openid($openid, []); + if ($user instanceof \WP_User) { + // Don't keep session for server push path; store id for poller. + wp_logout(); + $all[$state]['status'] = 'ok'; + $all[$state]['openid'] = $openid; + $all[$state]['user_id'] = $user->ID; + update_option(self::OPT_SCANS, $all, false); + } + return; + } + } + } + + /** + * @throws \Exception + */ + private function get_access_token(string $appid, string $secret): string { + $cached = get_option(self::OPT_ACCESS, []); + if (is_array($cached) && !empty($cached['access_token']) && (int) ($cached['expiration_time'] ?? 0) > time() + 60) { + return (string) $cached['access_token']; + } + $url = add_query_arg([ + 'grant_type' => 'client_credential', + 'appid' => $appid, + 'secret' => $secret, + ], 'https://api.weixin.qq.com/cgi-bin/token'); + // Prefer stable_token when available. + $res = wp_remote_post('https://api.weixin.qq.com/cgi-bin/stable_token', [ + 'timeout' => 10, + 'headers' => ['Content-Type' => 'application/json'], + 'body' => wp_json_encode([ + 'grant_type' => 'client_credential', + 'appid' => $appid, + 'secret' => $secret, + ]), + ]); + if (is_wp_error($res)) { + $res = wp_remote_get($url, ['timeout' => 10]); + } + if (is_wp_error($res)) { + throw new \Exception($res->get_error_message()); + } + $data = json_decode((string) wp_remote_retrieve_body($res), true); + if (empty($data['access_token'])) { + throw new \Exception((string) ($data['errmsg'] ?? 'access_token failed')); + } + $data['expiration_time'] = time() + (int) ($data['expires_in'] ?? 7200); + update_option(self::OPT_ACCESS, $data, false); + return (string) $data['access_token']; + } + + /** + * @param array $body + * @return array + */ + private function api_post(string $url, array $body): array { + $res = wp_remote_post($url, [ + 'timeout' => 10, + 'headers' => ['Content-Type' => 'application/json'], + 'body' => wp_json_encode($body, JSON_UNESCAPED_UNICODE), + ]); + if (is_wp_error($res)) { + return ['errcode' => 1, 'errmsg' => $res->get_error_message()]; + } + $data = json_decode((string) wp_remote_retrieve_body($res), true); + return is_array($data) ? $data : []; + } +} From 4ce368190017145496e5e78cd6f7ec90138cf77f Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 18 Jul 2026 23:54:04 +0800 Subject: [PATCH 3/3] feat(widgets): AKSRT image slider widget Lightweight multi-slide carousel with captions, dots, arrows, and CSS bundled via widgets.css import; registered in widgets-register. --- assets/css/frontend/widget-slider.css | 93 ++++++++++ assets/css/frontend/widgets.css | 1 + inc/modules/widgets-register.php | 3 + src/AKSRT/Widgets/WidgetSlider.php | 241 ++++++++++++++++++++++++++ 4 files changed, 338 insertions(+) create mode 100644 assets/css/frontend/widget-slider.css create mode 100644 src/AKSRT/Widgets/WidgetSlider.php 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/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/Widgets/WidgetSlider.php b/src/AKSRT/Widgets/WidgetSlider.php new file mode 100644 index 00000000..fa9ad2b6 --- /dev/null +++ b/src/AKSRT/Widgets/WidgetSlider.php @@ -0,0 +1,241 @@ + esc_html__('多图轮播,支持链接与标题。', 'aksrt'), + 'classname' => 'widget_aksrt_slider', + ] + ); + } + + public function widget($args, $instance): void { + $slides = $this->normalize_slides($instance['slides'] ?? []); + if (!$slides) { + return; + } + $interval = max(2, min(20, (int) ($instance['interval'] ?? 4))); + $loop = !empty($instance['loop']); + $dots = !empty($instance['pagination']); + $nav = !empty($instance['button']); + $blank = !empty($instance['blank']); + $height = max(80, min(800, (int) ($instance['height'] ?? 220))); + + echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $title = apply_filters('widget_title', (string) ($instance['title'] ?? ''), $instance, $this->id_base); + if ($title !== '') { + echo $args['before_title'] . esc_html($title) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + + $uid = 'aksrt-slider-' . esc_attr($this->id); + printf( + '
', + $uid, + $interval * 1000, + $loop ? '1' : '0', + $height + ); + echo '
'; + foreach ($slides as $i => $slide) { + $active = $i === 0 ? ' is-active' : ''; + echo '
'; + $img = '' . esc_attr($slide['title']) . ''; + if ($slide['href'] !== '') { + printf( + '%s', + esc_url($slide['href']), + $blank ? ' target="_blank" rel="noopener noreferrer"' : '', + $img // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ); + } else { + echo $img; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + if ($slide['title'] !== '' || $slide['desc'] !== '') { + echo '
'; + if ($slide['title'] !== '') { + echo '' . esc_html($slide['title']) . ''; + } + if ($slide['desc'] !== '') { + echo '' . esc_html($slide['desc']) . ''; + } + echo '
'; + } + echo '
'; + } + echo '
'; + if ($nav) { + echo ''; + echo ''; + } + if ($dots) { + echo '
'; + foreach ($slides as $i => $slide) { + printf( + '', + $i === 0 ? ' is-active' : '', + (int) $i, + esc_attr(sprintf(__('第 %d 张', 'aksrt'), $i + 1)) + ); + } + echo '
'; + } + echo '
'; + echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + + // Inline init once per widget (no extra enqueue dependency). + ?> + + normalize_slides($instance['slides'] ?? []); + if (!$slides) { + $slides = [ + ['image' => '', 'href' => '', 'title' => '', 'desc' => ''], + ['image' => '', 'href' => '', 'title' => '', 'desc' => ''], + ]; + } + ?> +

+ +

+

+ +

+

+ +

+

+
+
+
+ +

+ $slide) : ?> +

+
+ + + + +

+ +

+ +

+ + " 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 + */ + private function normalize_slides($raw): array { + if (!is_array($raw)) { + return []; + } + $out = []; + foreach ($raw as $row) { + if (!is_array($row)) { + continue; + } + $image = esc_url_raw(trim((string) ($row['image'] ?? $row['link'] ?? ''))); + if ($image === '') { + continue; + } + $out[] = [ + 'image' => $image, + 'href' => esc_url_raw(trim((string) ($row['href'] ?? ''))), + 'title' => sanitize_text_field((string) ($row['title'] ?? '')), + 'desc' => sanitize_text_field((string) ($row['desc'] ?? $row['dec'] ?? '')), + ]; + if (count($out) >= 8) { + break; + } + } + return $out; + } +}