diff --git a/.gitignore b/.gitignore index e5aa37d3..a26257fc 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ !/tests/widget-system/auth-source-contract.test.cjs !/tests/widget-system/user-badges-contract.test.cjs !/tests/widget-system/user-features-contract.test.cjs +!/tests/widget-system/notification-preferences-contract.test.cjs !/tests/widget-system/asset-ledger-contract.test.cjs !/tests/widget-system/financial-flow-contract.test.cjs !/tests/widget-system/pay-orders-filter-contract.test.cjs diff --git a/assets/css/routes/user-center/settings.css b/assets/css/routes/user-center/settings.css index cb2959b7..e0eec789 100644 --- a/assets/css/routes/user-center/settings.css +++ b/assets/css/routes/user-center/settings.css @@ -2,6 +2,134 @@ * User Center — account settings + invite panel */ +/* 消息通知偏好 */ +.uc-notification-preferences__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding-bottom: 20px; + border-bottom: 1px solid var(--color-line-soft, rgba(0,0,0,0.06)); +} + +.uc-notification-preferences__header h2 { + margin: 0 0 6px; + color: var(--color-ink); + font-size: 20px; +} + +.uc-notification-preferences__header p { + margin: 0; + color: var(--color-ink-4); + font-size: 13px; +} + +.uc-notification-preferences__header-icon { + display: grid; + place-items: center; + width: 44px; + height: 44px; + flex: 0 0 44px; + border-radius: 14px; + color: var(--color-primary); + background: var(--color-primary-soft); + font-size: 18px; +} + +.uc-notification-preferences__list { + display: flex; + flex-direction: column; +} + +.uc-notification-preference { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + min-height: 82px; + padding: 18px 0; + border-bottom: 1px solid var(--color-line-soft, rgba(0,0,0,0.06)); + cursor: pointer; +} + +.uc-notification-preference__copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 6px; +} + +.uc-notification-preference__copy strong { + color: var(--color-ink); + font-size: 15px; +} + +.uc-notification-preference__copy small { + color: var(--color-ink-4); + font-size: 13px; + font-weight: 400; + line-height: 1.6; +} + +.uc-notification-switch { + position: relative; + display: inline-flex; + width: 44px; + height: 24px; + flex: 0 0 44px; +} + +.uc-notification-switch input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.uc-notification-switch span { + width: 100%; + height: 100%; + border-radius: 999px; + background: var(--color-line, #cbd5e1); + transition: background-color 0.2s ease; +} + +.uc-notification-switch span::after { + content: ''; + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(15,23,42,0.22); + transition: transform 0.2s ease; +} + +.uc-notification-switch input:checked + span { + background: var(--color-primary); +} + +.uc-notification-switch input:checked + span::after { + transform: translateX(20px); +} + +.uc-notification-switch input:focus-visible + span { + outline: 2px solid var(--color-primary); + outline-offset: 3px; +} + +.uc-notification-preferences__footer { + display: flex; + justify-content: flex-end; + padding-top: 20px; +} + +.uc-notification-preferences__footer .btn-primary { + min-width: 112px; +} + /* 设置区增强 */ .uc-content .settings-section { margin-bottom: 24px; diff --git a/assets/js/routes/user-center.js b/assets/js/routes/user-center.js index 8305ce4c..2c693a3a 100644 --- a/assets/js/routes/user-center.js +++ b/assets/js/routes/user-center.js @@ -577,6 +577,58 @@ } } + /* ==================== 消息通知偏好 ==================== */ + function initNotificationPreferences(root) { + root = root || document; + var form = root.matches && root.matches('[data-aksrt-notification-preferences]') + ? root + : root.querySelector('[data-aksrt-notification-preferences]'); + if (!form || form.dataset.aksrtInitialized === 'true') return; + form.dataset.aksrtInitialized = 'true'; + + form.addEventListener('submit', function(e) { + e.preventDefault(); + var button = form.querySelector('[type="submit"]'); + if (!button || button.disabled) return; + var toastId = 'aksrt-notification-preferences-toast'; + var restUrl = (window.aksrtData && (window.aksrtData.restUrl || window.aksrtData.apiUrl)) || cfg.restUrl || ''; + var nonce = (window.aksrtData && (window.aksrtData.restNonce || window.aksrtData.nonce)) || cfg.restNonce || ''; + var originalText = button.textContent; + var preferences = {}; + ['posts', 'like', 'system'].forEach(function(key) { + var input = form.querySelector('[name="' + key + '"]'); + preferences[key] = Boolean(input && input.checked); + }); + + button.disabled = true; + button.textContent = i18n.saving || '保存中…'; + notyf(i18n.saving || '正在保存设置…', 'load', '', toastId); + fetch(restUrl.replace(/\/$/, '') + '/user/notification-preferences', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': nonce + }, + body: JSON.stringify({ preferences: preferences }) + }).then(function(response) { + return response.json().then(function(payload) { + if (!response.ok || !payload || !payload.success) { + throw new Error(payload && payload.message ? payload.message : (i18n.saveFailed || '保存失败')); + } + return payload; + }); + }).then(function(payload) { + notyf(payload.message || i18n.saved || '设置已保存', 'success', 5, toastId); + }).catch(function(error) { + notyf(error.message || i18n.networkError || '网络错误,请重试', 'danger', 5, toastId); + }).finally(function() { + button.disabled = false; + button.textContent = originalText; + }); + }); + } + /* ==================== 账号设置:弹窗 / 密码 / 邮箱 / 绑定 ==================== */ function initSettings(root) { if (!ajaxUrl || !settingsNonce) return; @@ -873,6 +925,9 @@ initSettings(root); initBindPhone(root); }); + window.AksrtFrontend.register('aksrt-notification-preferences', function(root) { + initNotificationPreferences(root); + }); } @@ -884,6 +939,7 @@ initUnfavorite(); initPostDeletion(); initNotifications(); + initNotificationPreferences(); initSettings(); initBindPhone(); initSettingsCallbackToast(); @@ -895,6 +951,7 @@ initUnfavorite(); initPostDeletion(); initNotifications(); + initNotificationPreferences(); initSettings(); initBindPhone(); initSettingsCallbackToast(); diff --git a/functions.php b/functions.php index 0b45a479..60f04c2c 100644 --- a/functions.php +++ b/functions.php @@ -181,6 +181,9 @@ if (class_exists('\AKSRT\User\Restriction')) { if (class_exists('\AKSRT\User\Center')) { \AKSRT\User\Center::get_instance()->init(); } +if (class_exists('\AKSRT\User\NotificationPreferences')) { + \AKSRT\User\NotificationPreferences::init(); +} if (class_exists('\AKSRT\User\BadgeAutoAward')) { \AKSRT\User\BadgeAutoAward::get_instance()->init(); } diff --git a/inc/helpers/aksrt-user-center.php b/inc/helpers/aksrt-user-center.php index f86d0fd9..30773a6b 100644 --- a/inc/helpers/aksrt-user-center.php +++ b/inc/helpers/aksrt-user-center.php @@ -178,6 +178,7 @@ if (!function_exists('aksrt_capture_user_center_view')) { 'orders' => 'orders', 'auth' => 'auth', 'revenue' => 'revenue', + 'msg' => 'msg', 'settings' => 'settings', 'header' => 'header', 'sidebar' => 'sidebar', @@ -201,7 +202,7 @@ if (!function_exists('aksrt_capture_user_center_panel')) { * Capture a built-in or registered user-center panel body. */ function aksrt_capture_user_center_panel(string $tab_key, array $tab, array $context): string { - $built_in_tabs = ['profile', 'level', 'creator', 'posts', 'follow', 'comments', 'favorites', 'credits', 'vip', 'orders', 'auth', 'revenue', 'settings']; + $built_in_tabs = ['profile', 'level', 'creator', 'posts', 'follow', 'comments', 'favorites', 'credits', 'vip', 'orders', 'auth', 'revenue', 'msg', 'settings']; if (in_array($tab_key, $built_in_tabs, true)) { return aksrt_capture_user_center_view($tab_key, $context); } diff --git a/inc/helpers/user-center-renderers/profile-msg.php b/inc/helpers/user-center-renderers/profile-msg.php new file mode 100644 index 00000000..82042647 --- /dev/null +++ b/inc/helpers/user-center-renderers/profile-msg.php @@ -0,0 +1,57 @@ + true, 'like' => true, 'system' => true]; +$items = [ + 'posts' => [ + 'title' => __('文章评论', 'aksrt'), + 'description' => __('接收文章、评论、收藏等相关消息', 'aksrt'), + ], + 'like' => [ + 'title' => __('点赞喜欢', 'aksrt'), + 'description' => __('接收点赞、关注等相关消息', 'aksrt'), + ], + 'system' => [ + 'title' => __('系统消息', 'aksrt'), + 'description' => __('接收订单、活动、等系统消息', 'aksrt'), + ], +]; +?> +
diff --git a/inc/helpers/user-center-renderers/sidebar-nav.php b/inc/helpers/user-center-renderers/sidebar-nav.php index 63f448f2..be6f5800 100644 --- a/inc/helpers/user-center-renderers/sidebar-nav.php +++ b/inc/helpers/user-center-renderers/sidebar-nav.php @@ -43,8 +43,8 @@ $settings = array_values(array_filter([ 'label' => __('消息通知', 'aksrt'), 'icon' => 'bell', 'tone' => 'notice', - 'url' => (string) ($messages_url ?? ''), - 'count' => (int) ($notification_unread_count ?? 0), + 'url' => isset($dashboard_tabs['msg']) ? aksrt_get_user_center_tab_url('msg') : '', + 'tab' => 'msg', ], [ 'label' => __('个人资料', 'aksrt'), diff --git a/inc/modules/user-notifications.php b/inc/modules/user-notifications.php index 3478e7b1..a2afe24e 100644 --- a/inc/modules/user-notifications.php +++ b/inc/modules/user-notifications.php @@ -32,7 +32,11 @@ function aksrt_get_user_notifications(int $user_id = 0): array { 'status' => 'hold', ]); - if ($pending_comments > 0 && (!class_exists('\AKSRT\Settings\Settings') || \AKSRT\Settings\Settings::notification_enabled('pending_comments'))) { + if ( + $pending_comments > 0 + && (!class_exists('\AKSRT\Settings\Settings') || \AKSRT\Settings\Settings::notification_enabled('pending_comments')) + && (!class_exists('\AKSRT\User\NotificationPreferences') || \AKSRT\User\NotificationPreferences::allows($user_id, 'pending_comments')) + ) { $items[] = [ 'type' => 'pending_comments', 'title' => __('评论待审核', 'aksrt'), @@ -249,6 +253,9 @@ if (!function_exists('aksrt_add_user_notification')) : function aksrt_add_user_notification(int $user_id, array $payload): void { if ($user_id <= 0) return; $type = sanitize_key((string) ($payload['type'] ?? 'notice')); + if (class_exists('\AKSRT\User\NotificationPreferences') && !\AKSRT\User\NotificationPreferences::allows($user_id, $type)) { + return; + } if (class_exists('\AKSRT\Settings\Settings') && !\AKSRT\Settings\Settings::notification_enabled($type)) { return; } diff --git a/inc/modules/user-parts/routing.php b/inc/modules/user-parts/routing.php index 670f3460..a8a00d6d 100644 --- a/inc/modules/user-parts/routing.php +++ b/inc/modules/user-parts/routing.php @@ -55,7 +55,7 @@ add_action('template_redirect', function () { $tab = $tab === 'balance' ? 'credits' : sanitize_key($tab); $allowed = class_exists('\\AKSRT\\User\\Center') ? array_keys(\AKSRT\User\Center::get_instance()->get_dashboard_tabs()) - : ['profile', 'level', 'creator', 'posts', 'follow', 'comments', 'favorites', 'credits', 'vip', 'orders', 'auth', 'revenue', 'settings']; + : ['profile', 'level', 'creator', 'posts', 'follow', 'comments', 'favorites', 'credits', 'vip', 'orders', 'auth', 'revenue', 'msg', 'settings']; if (!in_array($tab, $allowed, true)) { global $wp_query; if ($wp_query instanceof WP_Query) { $wp_query->set_404(); } diff --git a/src/User/Center.php b/src/User/Center.php index 98624474..f56207ab 100644 --- a/src/User/Center.php +++ b/src/User/Center.php @@ -188,11 +188,11 @@ class Center { 'priority' => 55, 'type' => 'panel', ], - 'notif' => [ + 'msg' => [ 'label' => __('消息通知', 'aksrt'), 'icon' => 'bell', 'priority' => 60, - 'type' => 'link', + 'type' => 'panel', ], 'auth' => [ 'label' => __('官方认证', 'aksrt'), @@ -307,6 +307,9 @@ class Center { if (!in_array('settings', $order, true)) { $order[] = 'settings'; } + if (!in_array('msg', $order, true)) { + $order[] = 'msg'; + } foreach ($order as $key) { if (!isset($definitions[$key])) { @@ -339,9 +342,6 @@ class Center { $this->panels[$key] = $panel; } - // Notif is a link entry (messages page), not a dashboard panel body. - $this->panels['notif'] = $definitions['notif']; - // Keep stable order by priority. uasort($this->panels, static function (array $a, array $b): int { return ((int) ($a['priority'] ?? 100)) <=> ((int) ($b['priority'] ?? 100)); diff --git a/src/User/NotificationPreferences.php b/src/User/NotificationPreferences.php new file mode 100644 index 00000000..6ea1092d --- /dev/null +++ b/src/User/NotificationPreferences.php @@ -0,0 +1,135 @@ + \WP_REST_Server::CREATABLE, + 'callback' => [self::class, 'save'], + 'permission_callback' => static function (): bool { + return get_current_user_id() > 0 && current_user_can('read'); + }, + 'args' => [ + 'preferences' => [ + 'type' => 'object', + 'required' => true, + 'properties' => [ + 'posts' => ['type' => 'boolean', 'required' => true], + 'like' => ['type' => 'boolean', 'required' => true], + 'system' => ['type' => 'boolean', 'required' => true], + ], + 'additionalProperties' => false, + 'validate_callback' => [self::class, 'validate_preferences'], + 'sanitize_callback' => [self::class, 'sanitize_preferences'], + ], + ], + ]); + } + + /** @return array{posts:bool,like:bool,system:bool} */ + public static function get(int $user_id): array { + $defaults = array_fill_keys(self::CATEGORIES, true); + $stored = get_user_meta($user_id, self::META_KEY, true); + if (!is_array($stored)) { + return $defaults; + } + + foreach (self::CATEGORIES as $category) { + if (array_key_exists($category, $stored)) { + $defaults[$category] = (bool) $stored[$category]; + } + } + return $defaults; + } + + public static function allows(int $user_id, string $notification_type): bool { + if ($user_id <= 0) { + return false; + } + $preferences = self::get($user_id); + return $preferences[self::category_for_type($notification_type)]; + } + + public static function category_for_type(string $notification_type): string { + $type = sanitize_key($notification_type); + if ( + in_array($type, ['posts', 'post', 'comment', 'favorite', 'pending_comments'], true) + || str_starts_with($type, 'post_') + || str_starts_with($type, 'comment_') + || str_starts_with($type, 'favorite_') + ) { + return 'posts'; + } + if ( + in_array($type, ['like', 'follow', 'followed', 'hot'], true) + || str_starts_with($type, 'like_') + || str_starts_with($type, 'follow_') + || str_starts_with($type, 'followed_') + ) { + return 'like'; + } + return 'system'; + } + + public static function validate_preferences($value): bool { + if ( + !is_array($value) + || count($value) !== count(self::CATEGORIES) + || array_diff(array_keys($value), self::CATEGORIES) + || array_diff(self::CATEGORIES, array_keys($value)) + ) { + return false; + } + foreach (self::CATEGORIES as $category) { + if (!is_bool($value[$category])) { + return false; + } + } + return true; + } + + /** @return array{posts:bool,like:bool,system:bool} */ + public static function sanitize_preferences($value): array { + $value = is_array($value) ? $value : []; + return [ + 'posts' => (bool) ($value['posts'] ?? false), + 'like' => (bool) ($value['like'] ?? false), + 'system' => (bool) ($value['system'] ?? false), + ]; + } + + public static function save(\WP_REST_Request $request) { + $user_id = get_current_user_id(); + if ($user_id <= 0) { + return new \WP_Error('aksrt_notification_preferences_auth', __('请先登录', 'aksrt'), ['status' => 401]); + } + + $preferences = self::sanitize_preferences($request->get_param('preferences')); + $result = update_user_meta($user_id, self::META_KEY, $preferences); + if ($result === false && self::get($user_id) !== $preferences) { + return new \WP_Error('aksrt_notification_preferences_save', __('消息通知设置保存失败', 'aksrt'), ['status' => 500]); + } + + return rest_ensure_response([ + 'success' => true, + 'message' => __('消息通知设置已保存', 'aksrt'), + 'preferences' => $preferences, + ]); + } +} diff --git a/tests/widget-system/notification-preferences-contract.test.cjs b/tests/widget-system/notification-preferences-contract.test.cjs new file mode 100644 index 00000000..c71dafc5 --- /dev/null +++ b/tests/widget-system/notification-preferences-contract.test.cjs @@ -0,0 +1,64 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const root = path.resolve(__dirname, '..', '..'); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); + +test('message notification settings are a canonical user/msg inline panel', () => { + const center = read('src/User/Center.php'); + const helpers = read('inc/helpers/aksrt-user-center.php'); + const sidebar = read('inc/helpers/user-center-renderers/sidebar-nav.php'); + const routing = read('inc/modules/user-parts/routing.php'); + + assert.match(center, /'msg'\s*=>\s*\[[\s\S]*?'type'\s*=>\s*'panel'/); + assert.doesNotMatch(center, /'notif'\s*=>/); + assert.match(helpers, /'msg'\s*=>\s*'msg'/); + assert.match(sidebar, /aksrt_get_user_center_tab_url\('msg'\)/); + assert.match(sidebar, /'tab'\s*=>\s*'msg'/); + assert.doesNotMatch(sidebar, /'url'\s*=>\s*\(string\) \(\$messages_url/); + assert.match(routing, /'revenue', 'msg', 'settings'/); +}); + +test('notification preference endpoint stores only the three boolean categories', () => { + const controller = read('src/User/NotificationPreferences.php'); + + assert.match(controller, /META_KEY = 'aksrt_notification_preferences'/); + assert.match(controller, /CATEGORIES = \['posts', 'like', 'system'\]/); + assert.match(controller, /register_rest_route\('aksrt\/v1', '\/user\/notification-preferences'/); + assert.match(controller, /permission_callback/); + assert.match(controller, /current_user_can\('read'\)/); + assert.match(controller, /'additionalProperties'\s*=>\s*false/); + assert.match(controller, /array_diff\(array_keys\(\$value\), self::CATEGORIES\)/); + assert.doesNotMatch(controller, /message_shield|migration|legacy/i); +}); + +test('notification preference UI uses the global toast lifecycle', () => { + const template = read('inc/helpers/user-center-renderers/profile-msg.php'); + const script = read('assets/js/routes/user-center.js'); + + for (const text of [ + '文章评论', + '接收文章、评论、收藏等相关消息', + '点赞喜欢', + '接收点赞、关注等相关消息', + '系统消息', + '接收订单、活动、等系统消息', + ]) { + assert.ok(template.includes(text)); + } + assert.match(template, /role="switch"/); + assert.match(script, /aksrt-notification-preferences-toast/); + assert.match(script, /notyf\([^;]+?'load'[^;]+?toastId/); + assert.match(script, /notyf\([^;]+?'success'[^;]+?toastId/); + assert.match(script, /notyf\([^;]+?'danger'[^;]+?toastId/); + assert.doesNotMatch(script, /\b(?:window\.)?alert\s*\(/); +}); + +test('notification writes and stateful alerts honor the category preference', () => { + const notifications = read('inc/modules/user-notifications.php'); + + assert.match(notifications, /NotificationPreferences::allows\(\$user_id, 'pending_comments'\)/); + assert.match(notifications, /NotificationPreferences::allows\(\$user_id, \$type\)/); +});