进行了一些小优化

This commit is contained in:
2026-08-25 17:02:33 +08:00
parent 4a4009133c
commit 11f1c23fa7
12 changed files with 463 additions and 10 deletions
+1
View File
@@ -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
+128
View File
@@ -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;
+57
View File
@@ -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();
+3
View File
@@ -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();
}
+2 -1
View File
@@ -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);
}
@@ -0,0 +1,57 @@
<?php
/**
* User center notification preferences panel.
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
$context = is_array($args['context'] ?? null) ? $args['context'] : [];
$user_id = (int) ($context['user_id'] ?? get_current_user_id());
$preferences = class_exists('\AKSRT\User\NotificationPreferences')
? \AKSRT\User\NotificationPreferences::get($user_id)
: ['posts' => true, 'like' => true, 'system' => true];
$items = [
'posts' => [
'title' => __('文章评论', 'aksrt'),
'description' => __('接收文章、评论、收藏等相关消息', 'aksrt'),
],
'like' => [
'title' => __('点赞喜欢', 'aksrt'),
'description' => __('接收点赞、关注等相关消息', 'aksrt'),
],
'system' => [
'title' => __('系统消息', 'aksrt'),
'description' => __('接收订单、活动、等系统消息', 'aksrt'),
],
];
?>
<form class="uc-notification-preferences" data-aksrt-notification-preferences>
<div class="uc-notification-preferences__header">
<div>
<h2><?php esc_html_e('消息通知', 'aksrt'); ?></h2>
<p><?php esc_html_e('选择你希望接收的站内消息类型。', 'aksrt'); ?></p>
</div>
<span class="uc-notification-preferences__header-icon" aria-hidden="true"><i class="fa fas fa-bell"></i></span>
</div>
<div class="uc-notification-preferences__list">
<?php foreach ($items as $key => $item) : ?>
<label class="uc-notification-preference" for="aksrt-notification-<?php echo esc_attr($key); ?>">
<span class="uc-notification-preference__copy">
<strong><?php echo esc_html($item['title']); ?></strong>
<small><?php echo esc_html($item['description']); ?></small>
</span>
<span class="uc-notification-switch">
<input id="aksrt-notification-<?php echo esc_attr($key); ?>" type="checkbox" name="<?php echo esc_attr($key); ?>" value="1" role="switch" <?php checked(!empty($preferences[$key])); ?>>
<span aria-hidden="true"></span>
</span>
</label>
<?php endforeach; ?>
</div>
<div class="uc-notification-preferences__footer">
<button type="submit" class="btn-primary"><?php esc_html_e('保存设置', 'aksrt'); ?></button>
</div>
</form>
@@ -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'),
+8 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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(); }
+5 -5
View File
@@ -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));
+135
View File
@@ -0,0 +1,135 @@
<?php
/**
* User notification category preferences and REST persistence.
*
* @package AKSRT
*/
namespace AKSRT\User;
defined('ABSPATH') || exit;
final class NotificationPreferences {
private const META_KEY = 'aksrt_notification_preferences';
private const CATEGORIES = ['posts', 'like', 'system'];
public static function init(): void {
add_action('rest_api_init', [self::class, 'register_rest_route']);
}
public static function register_rest_route(): void {
register_rest_route('aksrt/v1', '/user/notification-preferences', [
'methods' => \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,
]);
}
}
@@ -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\)/);
});